7 Commits
Author SHA1 Message Date
evandrodevbr 2aefc7ef5a fix: adapt the bridge to omp 18.4.8 host differences
Validate / validate (push) Successful in 9m53s
- resolve the agent dir through the host getAgentDir() (registered by the composition root) instead of sniffing execPath/argv
- synthesize agent_settled from the first terminal agent_end on omp
- read the live model through ctx.models.current() and prefer it over the stored model on omp
- adapt omp Settings (reloadFromDisk, global enabledModels) at the SDK boundary and drop scoped-model persistence on omp
2026-10-01 10:07:47 -03:00
Evandro Fonseca Junior 52c9fbeaf3 Merge pull request #3 from evandrodevbr/codex/review-2026-09-30-049051
fix: resolve diagnostic paths and recover time injection
2026-09-30 23:03:45 -03:00
Evandro Fonseca Junior a46fb916a0 fix: resolve diagnostic paths and recover time injection 2026-09-30 17:10:30 -03:00
Evandro Fonseca Junior a9364a344a docs: point the README verification section at the green CI run
The Validate workflow is green again on main after the audit overrides
refresh and the bus ledger test fix; record the run id instead of the
stale 2026-07-15 entry. Also note the fix under Unreleased.
2026-09-14 08:32:10 -03:00
Evandro Fonseca Junior 0852274102 test: stop assuming a fixed envelope delivery time in the bus ledger test
"Bus local server memoizes completed and in-flight request results" sent two
envelopes, slept a fixed 5 ms and then asserted that the handler had already
run once. On a slower CI runner the first envelope can still be in the socket
queue at that point, so the run failed with 0 !== 1 (run 34838232340).

Wait (bounded, 5 s deadline) until the first envelope reaches the handler
before asserting. The duplicate stays in flight because the gate is only
released after the assertion, so the memoization guarantee under test is
unchanged.
2026-09-14 08:31:01 -03:00
Evandro Fonseca Junior 7a54e2dbeb docs: rewrite README against verified code, gates and omp CLI
- standard structure: about, how it works, stack, requirements, quick
  start, usage/API, production and release, project structure,
  verification, current state and limitations, documentation, license.
- fix the omp install instructions: "omp install --link <path>" is not a
  valid flag (Unknown option). Document the local-path form that was
  actually exercised and the plugin source syntax from omp plugin --help.
- state plainly that the package is not published on npm and that omp's
  plugin source resolver rejects npm plugin sources.
- correct the agent directory resolution (PI_CODING_AGENT_DIR, then
  ~/.omp/agent under omp, else ~/.pi/agent) and the proxy note (proxy
  support comes from the Node runtime, not from this extension).
- list the three registered tools and the real env vars with defaults.
- add a Verification table with the commands and their real results.
2026-09-14 08:26:33 -03:00
Evandro Fonseca Junior 897c69006e fix: keep the validate and release gates green
- refresh dependency overrides so the audit gate passes again
  (protobufjs 7.6.4 -> 7.6.6, undici 8.5.0 -> 8.10.2, new
  brace-expansion override 5.0.9). npm run validate is green again:
  typecheck, 1229/1230 tests, 0 vulnerabilities, pack clean.
- promote the 0.22.0-evandro.1 changelog section from "###" to "##" so
  .github/workflows/release.yml can build release notes for the tag.
  The previous Release run failed with "CHANGELOG.md has no section for
  0.22.0-evandro.1".
- record both fixes under "## Unreleased".
2026-09-14 08:26:27 -03:00
20 changed files with 500 additions and 288 deletions

No files matched your search

+14 -1
View File
@@ -2,7 +2,20 @@
## Unreleased
### 0.22.0-evandro.1 — Evandro Fonseca Junior fork (oh-my-pi-telegram)
- `omp Agent Directory`: Resolve the agent directory from the host's own `getAgentDir()` (registered by the composition root via `setHostAgentDirResolver`) instead of sniffing `execPath`/`argv[1]` for an `omp` prefix. omp installed through Bun runs as `bun …/@oh-my-pi/pi-coding-agent/dist/cli.js`, which matched neither, so with `telegram.json` in `~/.omp/agent` the bridge never connected and wrote its log and bus socket under `~/.pi/agent`. `PI_CODING_AGENT_DIR` still wins; with no host registered the fallback stays `~/.pi/agent`. Impact: a bot token saved by `/telegram-setup` lives in omp's own agent directory and is found again. `lib/paths.ts` stays free of Pi SDK imports, which cost about a second of startup per importing process.
- `omp Settled Boundary`: omp never emits `agent_settled`, the only place the activity runtime finalized a turn. On omp the first terminal `agent_end` (no `willContinue: true`, which omp sets for retries, compaction continuations, todo/plan reminders and async wakes) now drives `onAgentSettled`; hosts that emit `agent_settled` natively are unchanged. Impact: Activity API consumers receive `agent-settled` and the activity id no longer leaks across turns.
- `omp Current Model`: omp never emits `model_select`, so the stored current model went stale after a local `/model` switch, and omp builds command contexts by spreading a base context, which freezes `ctx.model` at the moment of `/telegram-connect`. On omp the live `ctx.models.current()` now wins over both, and the stored value no longer overrides it. Impact: the Telegram model menu marks the model omp is actually using.
- `omp Settings Manager`: omp's `SettingsManager.create()` returns its own `Settings`, which has no `reload()` or `getEnabledModels()`; rendering the model menu and `/status` failed with `settingsManager.reload is not a function`, so no menu reached Telegram. The SDK boundary now adapts it (`reloadFromDisk()`, global `enabledModels` read) and scoped-model persistence is not offered on omp. Impact: the operator menus render on omp.
- `Time Injection`: Reject non-finite configured intervals and resume time annotations after the wall clock moves backwards.
- `Paths`: Resolve diagnostic paths from the current agent directory and profile instead of reporting hard-coded `~/.pi` locations.
- `Security`: Apply the `brace-expansion` `5.0.12` override at both root and coding-agent scopes to address the latest recursion and expansion denial-of-service advisories.
- **Security overrides refreshed** — `protobufjs` `7.6.4` → `7.6.6`, `undici` `8.5.0` → `8.10.2`, and a new `brace-expansion` `5.0.9` override keep `npm audit` at 0 vulnerabilities; the `validate` gate (`typecheck` → `test` → `audit` → `pack:check`) passes again.
- **README rewritten** to the repository audit standard: verified install/build/test commands, the real `omp install` syntax, omp agent-directory resolution, and an explicit limitations section.
- **Release notes extraction fixed** — the `0.22.0-evandro.1` fork section promoted from `###` to `##` so `.github/workflows/release.yml` can build release notes for tag `v0.22.0-evandro.1`.
- **Bus ledger test made timing-independent** — `tests/bus.test.ts` no longer assumes the first bus envelope reaches the server within a fixed 5 ms, which failed on a slower CI runner (`0 !== 1`). The `Validate` workflow is green again on `main`.
## 0.22.0-evandro.1 — Evandro Fonseca Junior fork (oh-my-pi-telegram)
- **Repackaged as `@evandrodevbr/oh-my-pi-telegram`** — version suffix `-evandro.1` keeps the 0.22.0 source line (SHA `afe09c5`) traceable to upstream `llblab/pi-telegram` while making the fork unambiguous in the `omp` extension registry.
- **Oh My Pi (omp) compatibility metadata** — added an `omp` field in `package.json` (displayName, homepage, `compatibleWith: { "@oh-my-pi/pi-coding-agent": ">=16.5.2" }`) so the fork is discoverable as a first-class extension for `can1357/oh-my-pi` (`omp >= 16.5.2`).
+207 -98
View File
@@ -1,150 +1,259 @@
<div align="center">
# oh-my-pi-telegram
<img src="assets/readme/hero.svg" width="100%" alt="oh-my-pi-telegram: drive a live omp session from a private Telegram chat, with /start, the /compact, /next and /stop menu and prompts sent from the phone">
**Telegram runtime adapter for live [Oh My Pi](https://github.com/can1357/oh-my-pi) (`omp`) sessions: send prompts, queue work, watch previews and collect artifacts from a private Telegram DM while the agent keeps running in the terminal.**
![Node >=22.19.0](https://img.shields.io/badge/Node-%E2%89%A522.19.0-5FA04E?style=flat-square&logo=nodedotjs&logoColor=white)
![omp >=16.5.2](https://img.shields.io/badge/omp-%E2%89%A516.5.2-informational?style=flat-square)
![Version 0.22.0-evandro.1](https://img.shields.io/badge/version-0.22.0--evandro.1-informational?style=flat-square)
![License MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)
[![Validate](https://github.com/evandrodevbr/oh-my-pi-telegram/actions/workflows/validate.yml/badge.svg)](https://github.com/evandrodevbr/oh-my-pi-telegram/actions/workflows/validate.yml)
![TypeScript](https://img.shields.io/badge/TypeScript-7-3178C6?logo=typescript&logoColor=white)
![Node](https://img.shields.io/badge/Node-%3E%3D22.19.0-5FA04E?logo=nodedotjs&logoColor=white)
![omp](https://img.shields.io/badge/omp-%3E%3D16.5.2-111111)
![License](https://img.shields.io/badge/license-MIT-green)
[Issues](https://git.evandro.dev.br/evandro/oh-my-pi-telegram/issues) · [Docs](docs/README.md) · [Changelog](CHANGELOG.md) · [Backlog](BACKLOG.md)
![Telegram DM with the operator menu, model picker and a formatted answer, next to the local session](screenshot.png)
</div>
> Fork of [`@llblab/[email protected]`](https://github.com/llblab/pi-telegram/tree/v0.22.0) (SHA `afe09c5`), republished as `@evandrodevbr/oh-my-pi-telegram` for `omp`.
> Lineage: `badlogic/pi-telegram` → `llblab/pi-telegram` (0.22.0) → **this fork**.
> The fork is based on upstream 0.22.0, with `omp` compatibility metadata and local runtime fixes recorded in `CHANGELOG.md`.
![oh-my-pi-telegram running: an omp terminal session on the left and two Telegram chats on the right, one with the /start menu and session status, one with the model picker](screenshot.png)
## About
## What it is
Supervising a long agent run means staying at the keyboard, because the work lives in the terminal session. This extension removes that constraint for the part that does not need a terminal: it binds a private Telegram DM to a running `omp` instance, so text, replies, images, files and voice notes sent from the phone become ordinary turns in that instance's active session, and answers, artifacts, buttons and voice come back to the chat.
oh-my-pi-telegram is an [Oh My Pi](https://github.com/can1357/oh-my-pi) (`omp`) extension that turns a private chat with your own Telegram bot into a companion for the session running on your machine. From the phone you send prompts, files and voice notes, queue and steer turns, switch the model or thinking level, and receive streamed previews and the final answer.
What it does:
It is a fork of [`@llblab/[email protected]`](https://github.com/llblab/pi-telegram/tree/v0.22.0) (SHA `afe09c5`), repackaged as `@evandrodevbr/oh-my-pi-telegram` and declared as an `omp` extension.
- Accepts prompts from Telegram (text, replies, edits, images, files, albums, voice) and injects them into the active `omp` session.
- Queues turns while the agent is busy instead of interrupting the running turn, with menu controls to inspect, promote, delete or force the next item.
- Streams native "typing"/draft previews and delivers final answers as Telegram Rich Markdown, plus files through `telegram_attach`.
- Exposes an operator menu (`/start`) for status, model, thinking level, settings, queue, prompt templates and diagnostics.
- Pairs exactly one Telegram owner; other users are ignored.
| Surface | What you can do |
|---|---|
| Prompt intake | Send text, replies, edits, images, files, albums and voice notes into the session. |
| Queue control | Messages sent while the agent is busy become queued turns: inspect, delete, promote, continue, abort, stop or force the next one. |
| Operator menu | `/start` opens status, prompt templates, model, thinking level, settings, queue and extension sections. |
| Rich replies | Rich Markdown (default) or legacy Markdown-to-HTML for final answers; optional live draft previews; native `...typing` status while the agent works. |
| Files and voice | Inbound files land in `<agent dir>/tmp/telegram` (50 MiB default limit); `telegram_attach` returns artifacts as real files; inbound voice goes through command templates or STT handlers; outbound voice through `telegram_voice` and TTS providers. |
| Buttons | Hidden `telegram_button` comments become inline buttons whose callbacks re-enter the session. |
| Threaded Mode | One leader plus visible follower `omp` instances, each with its own Telegram thread, through one bot. |
| Named profiles | Independent bots from one agent directory, with isolated polling, diagnostics and bus transport. |
## Why use it
It is a runtime adapter, not a remote terminal. A Telegram prompt is a normal model turn in the session that is active right now and inherits its post-compaction context, so token cost is not proportional only to the new mobile message.
Why this fork and not upstream: `@llblab/[email protected]` targets the Pi extension platform (`@earendil-works/pi-*`), and `omp` consumes the same platform republished as `@oh-my-pi/pi-coding-agent`. This fork:
1. Renames the package to `@evandrodevbr/oh-my-pi-telegram` so it is unambiguous in the `omp` extension registry.
2. Keeps the upstream `peerDependencies` on `@earendil-works/pi-{ai,agent-core,coding-agent}` `>=0.80.6`, with no code changes or shims.
3. Declares the extension through an `omp` block in `package.json` (extensions, display name, `compatibleWith: >=16.5.2`); the old `pi` block was removed in `572967c`.
Do not use it if you need a real terminal from the phone: there are no hidden `omp` processes, no PTY and no raw TTY input. Telegram can compact the current session but cannot create, resume, fork, browse or switch sessions, and arbitrary slash commands are never forwarded to the TUI. For plain Pi, use upstream `@llblab/pi-telegram`.
What it deliberately is not: a remote terminal, a PTY, a shell, a process launcher or a session browser. It does not spawn hidden `omp` processes and does not forward arbitrary Telegram slash commands into the TUI.
## How it works
- The active `omp` instance owns `getUpdates` polling for one bot. You start it with `/telegram-connect`; Telegram never launches hidden `omp` processes.
- Each Telegram message becomes a turn: it goes to the queue or is dispatched to the active session, then streams back as a preview with the native typing status and ends as a final Rich Markdown reply, optionally with files, voice, buttons or callback actions.
- In Threaded Mode, follower instances talk to the leader over a local bus (Unix socket or named pipe) and get their own Telegram thread.
- Code layout: `index.ts` is the composition root, `lib/*.ts` are flat domain modules (queue, polling, locks, bus, menus, rendering, delivery), `api/*.ts` are the public entry points for companion extensions, and `docs/` holds the architecture and contract documents.
## Quickstart
Requires `omp` >= 16.5.2 (`package.json` → `omp.compatibleWith`) and Node.js >= 22.19.0 (`engines.node`).
```bash
# From git (this repository)
omp plugin install https://git.evandro.dev.br/evandro/oh-my-pi-telegram.git
# From a local checkout (development)
omp plugin link /path/to/oh-my-pi-telegram
```
Telegram DM ──▶ getUpdates (single polling owner)
│
▼
inbound layer ──▶ Telegram turn ──▶ queue / active dispatch
│ │
│ ▼
│ omp active session (model turn)
▼ │
inbound files to <agent-dir>/tmp/telegram ▼
streaming preview / native active status
│
▼
final Rich Markdown reply ──▶ Telegram DM
(+ files, voice, inline buttons)
```
The npm package `@evandrodevbr/oh-my-pi-telegram` is not published yet. Restart (or reload) `omp` so the extension loads, then:
- One live `omp` instance owns polling for a profile; each Telegram destination follows that instance and sends prompts into its currently active session (it is not bound to one session file).
- A second `omp` instance started while Telegram private-chat Threaded Mode is available registers as a follower through a local leader/follower bus, each one behind its own Telegram thread.
- Companion extensions can register commands, menu sections, status rows, update/callback handlers and voice providers without owning a second bot loop.
1. Create a bot with [@BotFather](https://t.me/BotFather) (`/newbot`) and copy the token.
2. In `omp`, run `/telegram-setup` and paste the token.
3. Run `/telegram-connect` to start polling for this instance.
4. In the bot DM, send `/start`.
## Stack
> [!WARNING]
> The first Telegram user to message the bot becomes the allowed owner; everyone else is ignored. Pair right after `/telegram-connect`, and never put the bot token in chat, source, committed config or logs. Setup can prefill the token from `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN` or `TELEGRAM_KEY`.
| Layer | Choice |
| --- | --- |
| Language | TypeScript 7, strict, run directly by the `omp`/Pi runtime (no build step) |
| Runtime | Node.js >= 22.19.0 (`node:test` + `--experimental-strip-types` for the suite) |
| Host agent | `omp` (`@oh-my-pi/pi-coding-agent` >= 16.5.2); peer deps `@earendil-works/pi-{ai,agent-core,coding-agent}` >= 0.80.6, `@sinclair/typebox` |
| Telegram transport | Direct Telegram Bot API over `undici` HTTP calls (long polling, Rich Messages, files, voice) |
| Transport hardening | Dependency `overrides` pin security-patched `protobufjs`, `undici`, `ws`, `brace-expansion` for the transitive tree |
| Package manager | npm (`package-lock.json` is the lockfile CI uses); `bun.lock` is a leftover from upstream |
| CI | GitHub Actions: `Validate` (npm ci + `npm run validate`), `Release` (tag `v*.*.*` → GitHub release from `CHANGELOG.md`) |
The saved config is `<agent dir>/telegram.json`. The agent directory is `PI_CODING_AGENT_DIR` when set, `~/.omp/agent` when the process is `omp`, and `~/.pi/agent` otherwise (`lib/paths.ts`).
## Requirements
- Node.js `>= 22.19.0` (declared in `engines`; verified here on 24.20.0)
- npm `>= 10` for the validation gates (verified on 11.19.0). `bun` also works for running the extension, but the gates are npm-based.
- `omp` `>= 16.5.2` (verified with `omp` 18.4.8) or a Pi runtime exposing the same `@earendil-works/pi-*` extension API
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
## Quick start
The extension is **not published to npm yet** (`npm view @evandrodevbr/oh-my-pi-telegram` returns 404), so clone the repository and install from the local checkout.
```bash
# Local development checkout, run the gates first
git clone https://github.com/evandrodevbr/oh-my-pi-telegram
cd oh-my-pi-telegram
npm ci
npm run validate
# Install into omp from the checkout (verified working; --link is NOT a valid flag)
omp install /absolute/path/to/oh-my-pi-telegram
# Confirm omp sees the extension and reads its `omp` manifest field
omp plugin list --json
omp plugin doctor
```
`omp plugin --help` documents these plugin sources: a local path (`./path`, `../path`, `/abs`, `~/path`, symlinked), `github:user/repo[#ref]` (also `gitlab:`, `bitbucket:`, `codeberg:`, `sourcehut:`), a full git URL (`https://github.com/user/repo`), `name@marketplace`, and npm specs (`pkg`, `[email protected]`). Prefer the local-path form: the package is not on npm, and omp's plugin source resolver currently rejects `npm` sources with `npm plugin sources are not yet supported. Use git-based sources instead.`
Then inside `omp`:
```text
/telegram-setup # paste the bot token (offers a saved token as default)
/telegram-connect # start polling for this instance
```
Finally, open the bot DM and send `/start`. The first Telegram user to write becomes the allowed owner.
Config is written to `<agent-dir>/telegram.json`. The agent directory is `PI_CODING_AGENT_DIR` when set, otherwise the directory the host reports through `getAgentDir()`: `~/.pi/agent` on Pi, and on `omp` its profile-aware directory (`~/.omp/agent` by default). The executable name and `argv` are not inspected.
## Usage
<details>
<summary>Commands</summary>
In the bot DM:
### Telegram commands
| Command | Purpose |
|---|---|
| `/start` | Pair when needed and open the operator menu |
| `/compact` | Confirm and run session compaction when safe |
| --- | --- |
| `/start` | Pair when needed and open the operator menu (status, templates, model, thinking, settings, queue, sections) |
| `/compact` | Confirm and run session compaction |
| `/next` | Dispatch the next queued turn, aborting first if needed |
| `/continue` | Enqueue a priority continuation prompt |
| `/abort` | Abort the active run, keeping the queue |
| `/abort` | Abort the active run, keep the queue |
| `/stop` | Abort the active run and clear waiting Telegram turns |
`/help`, `/status`, `/model`, `/thinking`, `/queue` and `/settings` are hidden shortcuts into the same menus.
`/help`, `/status`, `/model`, `/thinking`, `/queue` and `/settings` are accepted shortcuts into the same menus. Prompt templates registered in `omp` are reachable as `/template_name` commands.
Inside `omp`:
### omp commands
| Command | Purpose |
|---|---|
| `/telegram-setup [profile]` | Save or update the default (or a named-profile) bot token |
| `/telegram-connect [profile]` | Activate a profile and acquire its transport ownership |
| --- | --- |
| `/telegram-setup` | Save or update the default bot token |
| `/telegram-setup <profile>` | Save or update a named profile's bot token |
| `/telegram-connect` | Start polling on the default profile and take transport ownership |
| `/telegram-connect <profile>` | Activate a named profile |
| `/telegram-disconnect` | Stop polling and release ownership |
| `/telegram-status` | Inspect connection, mode, queue, transport and diagnostics |
| `/telegram-status` | Connection, mode, queue, transport and recent diagnostics |
Profile names use lowercase ASCII letters and digits (max 32 characters); `default`, `main` and `active` are reserved.
Profile names: lowercase ASCII letters and digits, up to 32 characters; `default`, `main` and `active` are reserved. Profiles keep isolated polling, diagnostics, Threaded Mode state and local bus transport.
</details>
### Tools available to the agent
<details>
<summary>Environment variables</summary>
| Tool | Purpose |
| --- | --- |
| `telegram_help` | Fetch the bridge contract on demand instead of keeping it in every prompt |
| `telegram_attach` | Attach a generated file to the active Telegram reply, or send it via direct delivery |
| `telegram_message` | Send Markdown text directly to the paired chat or an explicit `chat_id` (`/thread_id`) |
Assistant replies can also carry top-level hidden HTML comments: `<!-- telegram_voice: ... -->` for voice output and `<!-- telegram_button: ... -->` for inline prompt buttons. They are stripped from the visible text.
### Environment configuration
Most controls live in the Telegram menu or in `/telegram-*` commands. Environment variables cover bootstrap and transport boundaries:
| Area | Variables |
|---|---|
| Bot token bootstrap | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, `TELEGRAM_KEY` |
| Network family | `PI_TELEGRAM_NETWORK_FAMILY` = `auto`, `ipv4`, `ipv6` or `ipv4-fallback` (default) |
| --- | --- |
| Bot token bootstrap (used as `/telegram-setup` prefill) | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, `TELEGRAM_KEY` |
| Agent data root | `PI_CODING_AGENT_DIR` |
| Inbound file limit | `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES`, `TELEGRAM_MAX_FILE_SIZE_BYTES` |
| Outbound attachment limit | `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES`, `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES` |
| Telegram network family | `PI_TELEGRAM_NETWORK_FAMILY` = `auto` (default), `ipv4`, `ipv6`, `ipv4-fallback` |
| Inbound file limit | `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES`, `TELEGRAM_MAX_FILE_SIZE_BYTES` (default 50 MiB) |
| Outbound attachment limit | `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES`, `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES` (default 50 MiB) |
| HTTP proxy | Not implemented by the bridge: proxies come from the Node runtime itself (`node --use-env-proxy`, or `NODE_USE_ENV_PROXY=1`, honouring `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`) |
Defaults: inbound temp files in `<agent dir>/tmp/telegram`, and `assistant: { rendering: "rich", draftPreviews: false }`.
Defaults: config in `<agent-dir>/telegram.json`, inbound temp files in `<agent-dir>/tmp/telegram`, `assistant.rendering = "rich"` (native Rich Markdown) with `assistant.draftPreviews = false`, and native Telegram active status for long turns.
</details>
### Extension API
Full reference: [architecture](docs/architecture.md), [public API](docs/public-api.md), [delivery](docs/delivery.md), [inbound](docs/inbound.md), [outbound](docs/outbound.md), [voice](docs/voice.md), [multi-instance bus](docs/multi-instance-bus.md) and the rest in the [docs index](docs/README.md).
Stable package entrypoints (see `exports` in `package.json`):
## Project status
| Subpath | Content |
| --- | --- |
| `.` | Extension entrypoint (`index.ts`) |
| `./inbound`, `./outbound`, `./delivery`, `./activity`, `./updates`, `./commands`, `./sections`, `./status`, `./voice`, `./keyboard` | Public companion-extension APIs |
Fork release `0.22.0-evandro.1`. Per [`CHANGELOG.md`](CHANGELOG.md), the fork re-ran the upstream gates: typecheck, 1,229 of 1,230 tests passing with one platform-only skip, `npm audit` with 0 vulnerabilities and a clean `npm pack --dry-run`. CI (`.github/workflows/validate.yml`) runs `npm ci` and `npm run validate` on Ubuntu with Node 24 for every push and pull request.
Deep `lib/*` imports are intentionally not exported. Contract details are in [`docs/public-api.md`](docs/public-api.md).
- A design spec for an `omp` status-bar segment ([`docs/superpowers/specs/`](docs/superpowers/specs/2026-07-15-telegram-omp-status-design.md)) needs a new `omp` core API and is not implemented here.
- In Threaded Mode, threadless messages sent from the `All` tab are not routed as prompts once bound threads exist; use a bound thread tab.
- `release.yml` needs a `## <version>` heading in `CHANGELOG.md`; the fork entry sits under `## Unreleased` as a `###` heading, so a tag fails that check until the section is promoted.
- Open work (promoted-follower reload evidence, native Windows Threaded Mode follow-ups, Telegram client and Rich Markdown quirks) is tracked in [`BACKLOG.md`](BACKLOG.md).
## Production and release
Development:
There is no build step: `omp` loads the TypeScript sources directly, and `npm pack` ships them.
```bash
npm ci
npm run validate # typecheck + test + audit + pack:check
npm run validate # the gate: typecheck + tests + audit + pack:check
npm run pack:check # npm pack --dry-run, 87 files, no secrets
```
Engineering conventions are in [`AGENTS.md`](AGENTS.md).
Release flow: bump `package.json` version, add a matching `## <version>` section to `CHANGELOG.md`, then push the tag.
```bash
# after bumping the version and adding the CHANGELOG section
git tag v0.22.0-evandro.2 && git push origin v0.22.0-evandro.2
```
`.github/workflows/release.yml` fails fast unless the tag equals the `package.json` version and `CHANGELOG.md` has a non-empty `## <version>` section; it then runs `gh release create` with those notes. Publishing to npm (`npm publish`, `publishConfig.access = public`) has not been done yet.
## Project structure
```
index.ts extension entrypoint: composition root and runtime wiring
api/ public package subpaths (inbound, outbound, delivery, ...)
lib/ 54 domain modules: config, paths, polling, queue, routing,
rendering, outbound, bus leader/follower, locks, voice, menus
tests/ 55 node:test suites (unit + integration, no network)
docs/ architecture and per-surface contracts
.agents/skills/ project-local agent skills (Telegram Bot API reference, domain DAG)
AGENTS.md engineering and runtime conventions (project context, not docs)
BACKLOG.md open, evidence-gated work items
CHANGELOG.md release history used to build GitHub release notes
```
The doc index lives at [`docs/README.md`](docs/README.md).
## Verification
Everything below was executed in this repository on Node 24.20.0 / npm 11.19.0 (Linux, Manjaro), plus the GitHub Actions history of `main`:
| Command | Result |
| --- | --- |
| `npm ci` | exit 0, lockfile consistent, `found 0 vulnerabilities` |
| `npm run typecheck` | exit 0, `tsc --noEmit` |
| `npm test` | exit 0, `tests 1230 / pass 1229 / fail 0 / skipped 1` (the skip is a Windows-only case) |
| `npm run audit` | exit 0, `found 0 vulnerabilities` |
| `npm run pack:check` | exit 0, tarball `evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz` |
| `npm run validate` | exit 0 (the four gates above, in order, exactly as CI runs them) |
| `omp plugin list --json` | exit 0, `omp` 18.1.20 reads the package `omp` manifest (`displayName`, `extensions: ["./index.ts"]`, `compatibleWith`) |
| `omp plugin doctor` | `3 ok, 1 warnings, 0 errors` (warning: no plugin package manifest) |
| `omp install <local path>` | exit 0, `Linked @evandrodevbr/oh-my-pi-telegram from .` (the link was reverted afterwards to leave the machine as it was) |
| `omp install --link <path>` | fails: `Unknown option '--link'` (kept out of this README) |
| ad hoc e2e on `omp` 18.4.8 | throwaway harness, not kept in the repo: real `omp --mode rpc` with a sandboxed `HOME`, a fake Telegram Bot API and a fake OpenAI-compatible model. `/telegram-connect` finds `~/.omp/agent/telegram.json` and nothing is written under `~/.pi`; a Telegram prompt runs a model turn and the reply is delivered; Activity API handlers receive `agent-settled`; `/model` renders and marks the model that was switched locally in `omp` |
The test suite is deterministic and offline: transport calls are injected, so no live Telegram bot is required. Live Telegram paths (long polling, Rich Messages, Threaded Mode threading) are covered by the operator's own smoke sessions, not by this suite; the `Validate` workflow (npm ci + `npm run validate` on Node 24) is green on `main` (run 34838605928, 2026-09-14).
## Current state and limitations
- **Not published to npm**: `@evandrodevbr/oh-my-pi-telegram` returns 404 on the registry. Install from a local checkout; on top of that, `omp` 18.1.20's plugin source resolver rejects `npm` sources (`npm plugin sources are not yet supported. Use git-based sources instead.`).
- **No GitHub release yet**: the only tag, `v0.22.0-evandro.1`, produced a failed `Release` run (`CHANGELOG.md has no section for 0.22.0-evandro.1`, because the fork section was a level-3 heading). The heading is fixed in this commit, but the existing tag still points at the pre-fix commit, so the release must be created manually or the tag moved.
- **Fork lags upstream**: this fork stays on upstream `0.22.0` (SHA `afe09c5`), while `@llblab/pi-telegram` has since published 0.51.6. Upstream changes after 0.22.0 are not included. Upstream 0.51.6 loads in `omp` 18.4.8 but subscribes to five events `omp` never emits (`agent_settled`, `model_select`, `session_compact_failed`, `ui_prompt_start`, `ui_prompt_end`), so it is not a drop-in replacement.
- **Verified on one platform only**: Linux with Node 24.20.0. The suite contains Windows-specific cases (and the single skipped test is one of them), but Windows/macOS were not run here.
- **Requires a live `omp` instance and a real bot token** for end-to-end use; the repository suite does not cover the Telegram API surface, and the `omp` 18.4.8 checks above used a fake Bot API, so live Telegram behavior (Rich Messages, Threaded Mode, voice, files) is unverified on `omp`.
- **LLM/token cost applies**: a Telegram prompt is a normal model turn in the active session, so it inherits that session's post-compaction context.
- **Single-owner by design**: the first Telegram user to message the bot is the owner; other users are ignored. No multi-user or group-chat support.
- **omp host differences**: `omp` emits neither `agent_settled` nor `model_select`. The bridge derives the settled boundary from the first terminal `agent_end` (`willContinue` not `true`) and reads the live model through `ctx.models.current()`. Scoped-model persistence from the Telegram model menu is not offered on `omp`, and its scoped list is read only from a global `enabledModels` array.
- `bun.lock` is still present from upstream while CI validates with `package-lock.json`; the two are not kept in sync.
## Documentation
| Document | Content |
| --- | --- |
| [`docs/README.md`](docs/README.md) | Documentation index |
| [`docs/architecture.md`](docs/architecture.md) | Runtime, domains, queue, transport and Threaded Mode overview |
| [`docs/public-api.md`](docs/public-api.md) | Package entrypoints and stable extension contracts |
| [`docs/delivery.md`](docs/delivery.md) | Target-aware delivery, logical message handles, leader/follower transport |
| [`docs/activity.md`](docs/activity.md) | Normalized agent lifecycle events for companion extensions |
| [`docs/inbound.md`](docs/inbound.md), [`docs/outbound.md`](docs/outbound.md), [`docs/voice.md`](docs/voice.md) | Inbound pipelines, outbound transforms and STT/TTS providers |
| [`docs/sections.md`](docs/sections.md), [`docs/updates.md`](docs/updates.md), [`docs/callback-namespaces.md`](docs/callback-namespaces.md) | Companion UI sections, update routing and callback ownership |
| [`docs/multi-instance-bus.md`](docs/multi-instance-bus.md), [`docs/locks.md`](docs/locks.md) | Leader/follower routing and singleton lock conventions |
| [`docs/ui-style.md`](docs/ui-style.md), [`docs/command-templates.md`](docs/command-templates.md) | Inline UI standards and command-template conventions |
| [`BACKLOG.md`](BACKLOG.md), [`CHANGELOG.md`](CHANGELOG.md) | Open work and delivery history |
## License
MIT, see [`LICENSE`](LICENSE): copyright Evandro Fonseca Junior (evandrodevbr), llblab and Mario Zechner (badlogic). Built on [llblab/pi-telegram](https://github.com/llblab/pi-telegram), itself a fork of [badlogic/pi-telegram](https://github.com/badlogic/pi-telegram); runs inside [Oh My Pi](https://github.com/can1357/oh-my-pi) and talks to the Telegram Bot API. Unofficial project, not affiliated with Telegram or the Oh My Pi maintainers.
MIT. See [`LICENSE`](LICENSE), which keeps the copyright notices of the upstream chain (`badlogic/pi-telegram`, `llblab/pi-telegram`) alongside this fork's.
---
## Review checks (2026-09-30)
<div align="center">
<sub>Made by <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
</div>
Diagnostics report the resolved agent directory, including `omp` and `PI_CODING_AGENT_DIR` overrides, with isolated profile suffixes. Interval time injection recovers after a backward wall-clock adjustment and rejects non-finite intervals. The `brace-expansion` security override is updated to 5.0.12.
Focused offline checks: `node --experimental-strip-types --test tests/time-injection.test.ts tests/paths.test.ts tests/config.test.ts tests/status.test.ts`, followed by `npm run typecheck`. The Domain DAG check remains part of architecture validation. Run `npm run validate` in an environment that permits Unix-domain sockets; this review's restricted environment blocks the bus socket tests. No live bot, Telegram client or Windows IPC session was used.
-32
View File
@@ -1,32 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
<title id="title">oh-my-pi-telegram</title>
<desc id="desc">oh-my-pi-telegram: drive a live omp session from a private Telegram chat. On the right, a simplified Telegram chat shows the /start command, the bot menu with /compact, /next and /stop, and a prompt sent from the phone.</desc>
<rect width="1200" height="420" rx="24" fill="#17212b"/>
<g id="title-block" font-family="system-ui, -apple-system, &quot;Segoe UI&quot;, Roboto, Helvetica, Arial, sans-serif">
<text x="64" y="156" font-size="64" font-weight="700" fill="#f5f5f5">oh-my-pi-telegram</text>
<text x="64" y="214" font-size="28" fill="#f5f5f5">Drive a live omp session from</text>
<text x="64" y="252" font-size="28" fill="#f5f5f5">a private Telegram chat.</text>
<text x="64" y="326" font-size="20" fill="#8a9bad" font-family="ui-monospace, &quot;SF Mono&quot;, Consolas, monospace">TypeScript · Node ≥22.19 · omp ≥16.5.2</text>
</g>
<g id="chat" font-family="system-ui, -apple-system, &quot;Segoe UI&quot;, Roboto, Helvetica, Arial, sans-serif" font-size="20">
<rect x="712" y="44" width="424" height="332" rx="20" fill="#1e2c3a"/>
<rect x="980" y="68" width="132" height="44" rx="14" fill="#2e557c"/>
<text x="1000" y="97" fill="#f5f5f5">/start</text>
<path d="M1074 92 L1079 97 L1088 87 M1082 97 L1091 87" fill="none" stroke="#70baf5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="736" y="132" width="376" height="144" rx="14" fill="#182533"/>
<text x="756" y="174" fill="#70baf5">/compact</text>
<text x="860" y="174" fill="#f5f5f5">Compact current session</text>
<text x="756" y="214" fill="#70baf5">/next</text>
<text x="860" y="214" fill="#f5f5f5">Force next turn</text>
<text x="756" y="254" fill="#70baf5">/stop</text>
<text x="860" y="254" fill="#f5f5f5">Abort Pi &amp; Clear queue</text>
<rect x="928" y="296" width="184" height="44" rx="14" fill="#2e557c"/>
<text x="948" y="325" fill="#f5f5f5">run the tests</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,73 +0,0 @@
# Telegram OMP 17 status indicator
## Goal
Make the Telegram bridge visible in OMP 17's upper status bar, immediately after the cost segment, and correct the OMP installation guidance. Configure a replacement bot token only through the local interactive OMP setup flow.
## Scope
- Add a public OMP extension API for a status-bar segment placed after `cost`.
- Have `@evandrodevbr/oh-my-pi-telegram` register one segment through that API.
- Update the segment when bridge polling starts, stops, fails, or a session is replaced.
- Update the plugin README with OMP 17 plugin commands and secure setup instructions.
## OMP core API
OMP owns status-bar ordering and rendering. The API accepts a keyed text segment and exposes only the supported placement:
- `after: "cost"`
- a stable extension key
- plain text supplied by the extension
OMP sanitizes extension text using the existing status-text sanitizer and renders registered segments after its own `cost` segment, before subsequent built-in segments. An extension clears its segment by passing `undefined`. Extension segments cannot choose arbitrary insertion positions or access the status-line component directly.
The implementation belongs in a versioned OMP source checkout or fork, not the global Bun installation, because `omp update` replaces global package files.
## Telegram bridge behavior
The plugin registers the `telegram` segment once the extension UI is available. It derives the value from the existing bridge status runtime:
| Runtime condition | Status-bar text |
| --- | --- |
| Telegram polling active | `Telegram: conectado` |
| Telegram polling inactive | `Telegram: desconectado` |
The plugin updates the registered segment through its existing status update flow. This covers `/telegram-connect`, `/telegram-disconnect`, polling errors and recovery, lock ownership changes, and session lifecycle updates. It does not make a network request merely to render a status value.
## Configuration
The replacement token is configured only in an interactive local OMP session:
```text
/telegram-setup
/telegram-connect
```
The token must not be put in chat, source code, README examples, committed configuration, test fixtures, logs, or terminal output. The plugin continues using its existing local configuration file and profile behavior.
## README
The primary OMP installation commands become:
```bash
omp plugin install npm:@evandrodevbr/oh-my-pi-telegram
omp plugin install git:github.com/evandrodevbr/oh-my-pi-telegram
omp plugin link /path/to/oh-my-pi-telegram
```
The README states that an OMP session must be restarted after installation or linking, then documents `/telegram-setup`, `/telegram-connect`, `/telegram-disconnect`, and `/telegram-status`.
## Verification
- OMP unit test: a registered extension segment is rendered immediately after `cost`; clearing it removes it; text is sanitized.
- Telegram plugin test: active polling renders `Telegram: conectado`; inactive polling renders `Telegram: desconectado`; the current UI registration API is exercised.
- Plugin type-check and public-package tests pass.
- OMP 17 smoke test: the linked plugin loads and `/telegram-status` returns.
- Manual local configuration: `/telegram-setup` validates the new bot token, `/telegram-connect` activates polling, and the status bar changes state.
## Non-goals
- No direct modification of global Bun-installed OMP files.
- No status segment placement other than after `cost`.
- No retry policy, network health probe, or separate Telegram connection abstraction.
- No token migration or token disclosure.
+8 -4
View File
@@ -51,6 +51,7 @@ type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
// --- Extension Runtime ---
export default function (pi: Pi.ExtensionAPI) {
Paths.setHostAgentDirResolver(Pi.getHostAgentDir);
const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
const {
getCommands,
@@ -397,6 +398,7 @@ export default function (pi: Pi.ExtensionAPI) {
const currentModelRuntime = Model.createCurrentModelRuntime({
getContextModel,
updateStatus,
preferContextModel: Pi.isOmpHost(pi),
});
const queueMutationRuntime = Queue.createTelegramQueueMutationController({
...telegramQueueStore,
@@ -698,10 +700,12 @@ export default function (pi: Pi.ExtensionAPI) {
resolveTimeLine: timeInjectionRuntime.resolveLine,
getThinkingLevel,
setThinkingLevel,
persistScopedModelPatterns: Pi.createScopedModelPatternPersister({
createSettingsManager: Pi.createSettingsManager,
clearCachedModelMenuInputs: modelMenuRuntime.clearCachedInputs,
}),
persistScopedModelPatterns: Pi.isOmpHost(pi)
? undefined
: Pi.createScopedModelPatternPersister({
createSettingsManager: Pi.createSettingsManager,
clearCachedModelMenuInputs: modelMenuRuntime.clearCachedInputs,
}),
setModel,
sendUserMessage,
isIdle,
+1
View File
@@ -639,6 +639,7 @@ export function registerTelegramLifecycleRuntimeHooks({
if (!isSessionContextActive(ctx)) return;
activityRuntime.onAgentSettled();
},
settleOnTerminalAgentEnd: Pi.isOmpHost(pi),
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
isConfigured: configStore.hasBotToken,
isProactivePushEnabled,
+1 -1
View File
@@ -669,7 +669,7 @@ export function resolveTelegramTimeConfig(
? raw.injectionMode
: "hidden";
const interval =
typeof raw?.interval === "number" && raw.interval > 0
typeof raw?.interval === "number" && Number.isFinite(raw.interval) && raw.interval > 0
? raw.interval
: 60 * 60 * 1000;
const timezone = getSystemTimezone();
+10
View File
@@ -23,6 +23,7 @@ import type {
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
} from "./pi.ts";
import { isTerminalAgentEnd } from "./pi.ts";
let resetTransportReplyDedupFn: (() => void) | undefined;
@@ -110,6 +111,12 @@ export interface TelegramLifecycleRegistrationDeps {
event: AgentSettledEvent,
ctx: ExtensionContext,
) => Promise<void> | void;
/**
* Hosts that never emit `agent_settled` (omp) get it synthesized from the
* first terminal `agent_end`. Leave unset on hosts that emit it natively so
* the settle point is not announced early.
*/
settleOnTerminalAgentEnd?: boolean;
}
export interface TelegramSessionLifecycleHooks {
@@ -562,6 +569,9 @@ export function registerTelegramLifecycleHooks(
pi.on("agent_end", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onAgentEnd(event, ctx);
if (deps.settleOnTerminalAgentEnd && isTerminalAgentEnd(event)) {
await deps.onAgentSettled?.({ type: "agent_settled" }, ctx);
}
});
pi.on("agent_settled", async (event, ctx) => {
if (!isActive(ctx)) return;
+16 -2
View File
@@ -55,15 +55,25 @@ export type CurrentModelRuntime<
> = CurrentModelStore<TContext, TModel> &
CurrentModelUpdateRuntime<TContext, TModel>;
/**
* `preferContextModel` makes the host's live context model win over the stored
* one. Pi pushes model changes through `model_select`, so its stored value is
* current; omp has no such event, so its stored value would go stale after a
* local `/model` switch.
*/
export function createCurrentModelStore<
TContext,
TModel extends MenuModel = MenuModel,
>(
getContextModel: (ctx: TContext) => TModel | undefined,
preferContextModel = false,
): CurrentModelStore<TContext, TModel> {
let currentModel: TModel | undefined;
return {
get: (ctx) => currentModel ?? getContextModel(ctx),
get: (ctx) =>
preferContextModel
? (getContextModel(ctx) ?? currentModel)
: (currentModel ?? getContextModel(ctx)),
getStored: () => currentModel,
set: (model) => {
currentModel = model;
@@ -96,8 +106,12 @@ export function createCurrentModelRuntime<
>(deps: {
getContextModel: (ctx: TContext) => TModel | undefined;
updateStatus: (ctx: TContext) => void;
preferContextModel?: boolean;
}): CurrentModelRuntime<TContext, TModel> {
const store = createCurrentModelStore(deps.getContextModel);
const store = createCurrentModelStore(
deps.getContextModel,
deps.preferContextModel,
);
return {
...store,
...createCurrentModelUpdateRuntime({
+28 -21
View File
@@ -1,19 +1,32 @@
/**
* Telegram bridge path resolution for Pi-compatible runtimes
* Zones: telemetry paths, filesystem, runtime identity
* Owns agent-dir detection and extension-local path derivation
* Owns agent-dir resolution and extension-local path derivation
*
* This domain is pure/path-only: it resolves directories and file paths
* from environment and runtime identity. It does not read config, manage
* state, or import broader Telegram domains.
* This domain is pure/path-only: it resolves directories and file paths from
* the environment and the host runtime's own agent directory. It does not read
* config, manage state, or import broader Telegram domains or the Pi SDK.
*/
import { homedir } from "node:os";
import { join, resolve } from "node:path";
export interface TelegramAgentDirResolutionInput {
env?: Partial<Pick<NodeJS.ProcessEnv, "PI_CODING_AGENT_DIR">>;
execPath?: string;
argv?: readonly string[];
}
let hostAgentDirResolver: (() => string) | undefined;
/**
* Register the host runtime's own agent-directory lookup (the Pi SDK
* `getAgentDir()`). It is registered by the composition root instead of
* imported here: importing the Pi SDK from this leaf made every domain that
* touches a path, and every child process the test suite spawns, pay about a
* second of SDK startup.
*/
export function setHostAgentDirResolver(
resolver: (() => string) | undefined,
): void {
hostAgentDirResolver = resolver;
}
/**
@@ -21,23 +34,18 @@ export interface TelegramAgentDirResolutionInput {
*
* Precedence:
* 1. `PI_CODING_AGENT_DIR` env variable, when explicitly set.
* 2. Detect Pi-compatible runtime identity from the executable or argv[1]
* (e.g. OMP vs standard Pi agent).
* 3. Fallback: `~/.pi/agent`.
* 2. The registered host agent directory. Pi returns `~/.pi/agent`; omp's Pi
* shim returns its profile-aware directory (default `~/.omp/agent`). The
* executable and argv are not inspected: omp installed through Bun runs as
* `bun .../dist/cli.js`, which neither contains nor starts with `omp`.
* 3. Fallback when no host is registered: `~/.pi/agent`.
*/
export function resolveAgentDir(
input: TelegramAgentDirResolutionInput = {},
): string {
const env = input.env ?? process.env;
if (env.PI_CODING_AGENT_DIR) return resolve(env.PI_CODING_AGENT_DIR);
const execPath = input.execPath ?? process.execPath;
const argv = input.argv ?? process.argv;
const execBasename = execPath.toLowerCase().split(/[\\/]/u).pop() ?? "";
const argv1Last = (argv[1] ?? "").toLowerCase().split(/[\\/]/u).pop() ?? "";
if (execBasename.startsWith("omp") || argv1Last.startsWith("omp")) {
return join(homedir(), ".omp", "agent");
}
return join(homedir(), ".pi", "agent");
return hostAgentDirResolver?.() ?? join(homedir(), ".pi", "agent");
}
/** Telegram bridge configuration file (<agentDir>/telegram.json). */
@@ -75,11 +83,10 @@ export function getTelegramDiagnosticsDisplayPaths(profileName?: string): {
state: string;
logs: string;
} {
const suffix = getTelegramProfilePathSuffix(profileName);
const profileSlug = suffix.slice(1);
const agentDir = resolveAgentDir();
return {
state: `~/.pi/agent/tmp/telegram/state${suffix}.json`,
logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
state: resolveTelegramProfileTempFilePath("state", "json", agentDir, profileName),
logs: resolveTelegramProfileTempFilePath("logs", "jsonl", agentDir, profileName),
};
}
+85 -2
View File
@@ -20,6 +20,7 @@ import {
type SessionStartEvent,
type SlashCommandInfo,
SettingsManager,
getAgentDir,
} from "@earendil-works/pi-coding-agent";
export type {
@@ -39,6 +40,13 @@ export type {
SlashCommandInfo,
};
/**
* Agent data directory owned by the host runtime. Pi returns its own root and
* omp's legacy Pi shim forwards omp's profile-aware directory, so no argv or
* executable sniffing is needed.
*/
export { getAgentDir as getHostAgentDir };
export interface ToolExecutionStartEvent {
type: "tool_execution_start";
toolCallId: string;
@@ -69,6 +77,26 @@ export interface PiSettingsManager {
setEnabledModels: (patterns: string[] | undefined) => void;
}
/**
* omp's `Settings` is what its legacy shim returns from
* `SettingsManager.create()`. It has no `reload()` or `get/setEnabledModels()`:
* it reloads through `reloadFromDisk()` and keeps `enabledModels` as a
* path-scoped typed setting.
*/
interface OmpSettingsSurface {
reloadFromDisk: () => Promise<void>;
flush: () => Promise<void>;
getGlobalSettings: () => { enabledModels?: unknown };
}
function isOmpSettings(value: object): value is OmpSettingsSurface {
return (
"reloadFromDisk" in value &&
"flush" in value &&
"getGlobalSettings" in value
);
}
export type PiSlashCommandInfo = SlashCommandInfo;
export type PiRunMode = "tui" | "rpc" | "json" | "print";
@@ -150,7 +178,22 @@ export function createExtensionApiRuntimePorts(
}
export function createSettingsManager(cwd: string): PiSettingsManager {
return SettingsManager.create(cwd);
const manager: PiSettingsManager = SettingsManager.create(cwd);
if (!isOmpSettings(manager)) return manager;
return {
reload: () => manager.reloadFromDisk(),
flush: () => manager.flush(),
getEnabledModels: () => {
const enabled = manager.getGlobalSettings().enabledModels;
return Array.isArray(enabled) &&
enabled.every((pattern) => typeof pattern === "string")
? enabled
: undefined;
},
setEnabledModels: () => {
throw new Error("Scoped model persistence is not supported on omp.");
},
};
}
export function createScopedModelPatternPersister(deps: {
@@ -167,16 +210,56 @@ export function createScopedModelPatternPersister(deps: {
};
}
interface OmpModelQuery {
current: () => ExtensionContext["model"];
}
function hasOmpModelQuery(
ctx: ExtensionContext,
): ctx is ExtensionContext & { models: OmpModelQuery } {
return (
"models" in ctx &&
typeof ctx.models === "object" &&
ctx.models !== null &&
"current" in ctx.models &&
typeof ctx.models.current === "function"
);
}
/**
* omp builds command contexts by spreading a base context, which evaluates the
* `model` getter once: a context stored from `/telegram-connect` would report
* the model of that moment forever. omp's `ctx.models.current()` closes over
* the live session model, so prefer it when present.
*/
export function getExtensionContextModel(
ctx: ExtensionContext,
): ExtensionContext["model"] {
return ctx.model;
return hasOmpModelQuery(ctx) ? ctx.models.current() : ctx.model;
}
export function getExtensionContextCwd(ctx: ExtensionContext): string {
return ctx.cwd;
}
/**
* True when the extension API belongs to omp. omp's `ExtensionAPI` carries
* `typebox` and `pi` module facades that upstream Pi's API does not have.
*/
export function isOmpHost(api: ExtensionAPI): boolean {
return "typebox" in api && "pi" in api;
}
/**
* omp marks an `agent_end` with `willContinue: true` whenever it already
* scheduled a retry, compaction continuation, todo/plan reminder, or async
* wake. Any other `agent_end` is terminal, which is what Pi reports through
* `agent_settled`.
*/
export function isTerminalAgentEnd(event: AgentEndEvent): boolean {
return !("willContinue" in event && event.willContinue === true);
}
export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
return ctx.isIdle();
}
+1
View File
@@ -67,6 +67,7 @@ export function createTimeInjectionRuntime(
const nowMs = now.getTime();
if (
previous !== undefined &&
nowMs >= previous &&
nowMs - previous < config.interval
) {
return null;
+10 -19
View File
@@ -963,7 +963,6 @@
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -1363,14 +1362,16 @@
"peer": true
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/buffer-equal-constant-time": {
@@ -1879,7 +1880,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.6.4",
"version": "7.6.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"peer": true,
@@ -1900,19 +1903,6 @@
"node": ">=12.0.0"
}
},
"node_modules/protobufjs/node_modules/@types/node": {
"version": "22.19.19",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/protobufjs/node_modules/@types/node/node_modules/undici-types": {
"version": "6.21.0",
"license": "MIT",
"peer": true
},
"node_modules/retry": {
"version": "0.12.0",
"license": "MIT",
@@ -2037,7 +2027,9 @@
}
},
"node_modules/undici": {
"version": "8.5.0",
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -2048,7 +2040,6 @@
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
+10 -8
View File
@@ -81,25 +81,27 @@
"typescript": "latest"
},
"overrides": {
"protobufjs": "7.6.4",
"undici": "8.5.0",
"protobufjs": "7.6.6",
"undici": "8.10.2",
"brace-expansion": "5.0.12",
"ws": "8.21.0",
"@earendil-works/pi-coding-agent": {
"protobufjs": "7.6.4",
"undici": "8.5.0",
"protobufjs": "7.6.6",
"undici": "8.10.2",
"brace-expansion": "5.0.12",
"ws": "8.21.0",
"@google/genai": {
"protobufjs": "7.6.4",
"protobufjs": "7.6.6",
"ws": "8.21.0"
},
"@earendil-works/pi-ai": {
"protobufjs": "7.6.4",
"protobufjs": "7.6.6",
"ws": "8.21.0",
"@google/genai": {
"protobufjs": "7.6.4",
"protobufjs": "7.6.6",
"ws": "8.21.0"
}
}
}
}
}
}
+6 -1
View File
@@ -1060,7 +1060,12 @@ test("Bus local server memoizes completed and in-flight request results", async
};
const first = sendTelegramBusLocalEnvelope({ socketPath, envelope });
const duplicate = sendTelegramBusLocalEnvelope({ socketPath, envelope });
await new Promise((resolve) => setTimeout(resolve, 5));
// Wait for the first envelope to reach the handler instead of assuming a
// fixed delivery time; the duplicate must still be memoized while in flight.
const deliveryDeadlineMs = Date.now() + 5_000;
while (executions === 0 && Date.now() < deliveryDeadlineMs) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.equal(executions, 1);
release?.();
const [firstResult, duplicateResult] = await Promise.all([first, duplicate]);
+35
View File
@@ -560,3 +560,38 @@ test("Lifecycle helpers register pi hooks and delegate to handlers", async () =>
"agent-end",
]);
});
test("Terminal agent_end synthesizes agent_settled only for hosts that never emit it", async () => {
const cases = [
{ settle: true, event: { messages: [] }, expected: ["agent-end", "agent-settled"] },
{ settle: true, event: { messages: [], willContinue: true }, expected: ["agent-end"] },
{ settle: false, event: { messages: [] }, expected: ["agent-end"] },
];
for (const { settle, event, expected } of cases) {
const harness = createLifecycleApiHarness();
const events: string[] = [];
registerTelegramLifecycleHooks(harness.api, {
settleOnTerminalAgentEnd: settle,
onSessionStart: async () => {},
onSessionShutdown: async () => {},
onBeforeAgentStart: () => undefined,
onModelSelect: () => {},
onAgentStart: async () => {},
onToolExecutionStart: () => {},
onToolExecutionEnd: () => {},
onMessageStart: async () => {},
onMessageUpdate: async () => {},
onAgentEnd: async () => {
events.push("agent-end");
},
onAgentSettled: () => {
events.push("agent-settled");
},
});
await getRequiredLifecycleHandler(harness.handlers, "agent_end")(
event,
createLifecycleContext(),
);
assert.deepEqual(events, expected, JSON.stringify({ settle, event }));
}
});
+12
View File
@@ -83,6 +83,18 @@ test("Current model store prefers explicit Telegram model over context fallback"
assert.equal(store.get({ model: fallback }), fallback);
});
test("Current model store follows the live context model on hosts without model_select", () => {
const stale = { provider: "stale", id: "a", name: "Stale" };
const live = { provider: "live", id: "b", name: "Live" };
const store = createCurrentModelStore<{ model?: typeof live }, typeof live>(
(ctx) => ctx.model,
true,
);
store.set(stale);
assert.equal(store.get({ model: live }), live);
assert.equal(store.get({ model: undefined }), stale);
});
test("Current model runtime combines store fallback and status updates", () => {
const fallback = { provider: "fallback", id: "f", name: "Fallback" };
const selected = { provider: "selected", id: "s", name: "Selected" };
+30 -26
View File
@@ -10,48 +10,52 @@ import test from "node:test";
import {
resolveAgentDir,
setHostAgentDirResolver,
resolveTelegramConfigPath,
resolveTelegramLocksPath,
resolveTelegramTempDir,
resolveTelegramRuntimeLogPath,
getTelegramDiagnosticsDisplayPaths,
resolveTelegramProfileTempFilePath,
} from "../lib/paths.ts";
test("Diagnostics use the resolved agent root and profile suffix", () => {
const agentDir = resolveAgentDir();
for (const profile of [undefined, "work"]) {
assert.deepEqual(getTelegramDiagnosticsDisplayPaths(profile), {
state: resolveTelegramProfileTempFilePath("state", "json", agentDir, profile),
logs: resolveTelegramProfileTempFilePath("logs", "jsonl", agentDir, profile),
});
}
});
await test("resolveAgentDir", async (t) => {
await t.test("returns PI_CODING_AGENT_DIR when env is set", () => {
t.afterEach(() => setHostAgentDirResolver(undefined));
await t.test("PI_CODING_AGENT_DIR wins over the host directory", () => {
setHostAgentDirResolver(() => {
throw new Error("host directory must not be consulted");
});
assert.equal(
resolveAgentDir({
env: { PI_CODING_AGENT_DIR: "/custom/agent/dir" },
execPath: "/usr/bin/omp",
argv: ["omp"],
}),
resolveAgentDir({ env: { PI_CODING_AGENT_DIR: "/custom/agent/dir" } }),
resolve("/custom/agent/dir"),
);
});
await t.test("returns ~/.omp/agent for OMP-compatible runtimes", () => {
assert.equal(
resolveAgentDir({ env: {}, execPath: "/home/user/.local/bin/omp" }),
join(homedir(), ".omp", "agent"),
);
assert.equal(
resolveAgentDir({
env: {},
execPath: "/usr/bin/node",
argv: ["node", "omp"],
}),
join(homedir(), ".omp", "agent"),
);
});
await t.test(
"returns ~/.pi/agent as fallback when no env and no OMP runtime",
"uses the registered host runtime directory when env is unset",
() => {
assert.equal(
resolveAgentDir({ env: {}, execPath: "/usr/bin/node", argv: ["node"] }),
join(homedir(), ".pi", "agent"),
);
setHostAgentDirResolver(() => "/home/user/.omp/agent");
assert.equal(resolveAgentDir({ env: {} }), "/home/user/.omp/agent");
},
);
await t.test("falls back to ~/.pi/agent when no host is registered", () => {
assert.equal(
resolveAgentDir({ env: {} }),
join(homedir(), ".pi", "agent"),
);
});
});
await test("resolveTelegramConfigPath", () => {
+12
View File
@@ -157,3 +157,15 @@ test("Pi context helpers expose model, idle, pending-message, and compact adapte
assert.equal(hasExtensionContextPendingMessages(ctx), false);
assert.deepEqual(events, ["compact", "complete"]);
});
test("Pi context model prefers omp's live models facade over a frozen ctx.model", () => {
const frozen = { provider: "fake", id: "fake-1" };
const live = { provider: "fake", id: "fake-2" };
const ompCtx = {
model: frozen,
models: { current: () => live },
} as unknown as ExtensionContext;
const piCtx = { model: frozen } as unknown as ExtensionContext;
assert.equal(getExtensionContextModel(ompCtx), live);
assert.equal(getExtensionContextModel(piCtx), frozen);
});
+14
View File
@@ -47,6 +47,20 @@ test("Time config rejects non-positive interval", () => {
assert.equal(resolved.interval, 60 * 60 * 1000);
});
test("Time config rejects non-finite intervals", () => {
for (const interval of [NaN, Infinity, -Infinity]) {
assert.equal(resolveTelegramTimeConfig({ injectionMode: "interval", interval }).interval, 3_600_000);
}
});
test("Time injection recovers when the wall clock moves backwards", () => {
const runtime = makeRuntime({ injectionMode: "interval", interval: 60_000, timezone: "UTC" });
assert.ok(runtime.resolveLine(1, new Date("2026-05-16T15:00:00Z")));
assert.ok(runtime.resolveLine(1, new Date("2026-05-16T14:00:00Z")));
assert.equal(runtime.resolveLine(1, new Date("2026-05-16T14:00:30Z")), null);
assert.ok(runtime.resolveLine(1, new Date("2026-05-16T14:01:00Z")));
});
test("Time injection runtime returns null for every call when injectionMode is hidden", () => {
const runtime = makeRuntime({ injectionMode: "hidden", timezone: "UTC" });
for (let i = 0; i < 5; i++) {