diff --git a/README.md b/README.md index 973297c..fc1c7c5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ AgentOnWeb brings your coding workspace onto the page you already have open. Watch a video, browse documentation, or keep a website in view while working with your agent—then switch back to the page without leaving your conversation. -**Use Codex or Claude Code right on the page.** Open your project in the built-in terminal, follow running sessions, and get notified when a task finishes or needs approval. Click a session to return to its terminal. AgentOnWeb also supports the complete **DeepSeek Harness (DSH)** workspace, including conversations, tools, approvals, models and plugins. +**Use Codex, Claude Code or Pi right on the page.** Open your project in the built-in terminal, follow running sessions, and get notified when a task finishes or needs approval. Click a session to return to its terminal. AgentOnWeb also supports the complete **DeepSeek Harness (DSH)** workspace, including conversations, tools, approvals, models and plugins. | Mode | What it does | | --- | --- | @@ -38,9 +38,9 @@ A 100-second walkthrough of DSH setup, Chrome Web Store installation, Connect au | Firefox | [Install from Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/agentonweb/) | | Safari | Coming soon — Mac App Store link will be added after approval. | -## Use Codex and Claude Code +## Use Codex, Claude Code and Pi -For **macOS + Chrome**. Have [Node.js 22.19+](https://nodejs.org/en/download) and your preferred CLI (Codex or Claude Code) installed and signed in. +For **macOS + Chrome**. Have [Node.js 22.19+](https://nodejs.org/en/download) and your preferred CLI (Codex, Claude Code or Pi 1.1+) installed and signed in. ### 1. Set up your terminal @@ -50,7 +50,7 @@ Run this once in your Mac's Terminal: curl -fsSL https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh | bash ``` -The installer sets up the local terminal, automatic startup, Chrome connection and Codex/Claude Code notifications. Existing CLI settings are preserved. +The installer sets up the local terminal, automatic startup, Chrome connection and Codex/Claude Code/Pi notifications. Existing CLI settings are preserved. The install command works from zsh, bash and fish. When Node's command directory is writable and already on PATH, the installer adds an `aow` entry there, so the same terminal can run `aow service` immediately without sourcing a profile. New zsh/bash windows and detected fish installations also get `aow` on their PATH. If that directory is protected or has an unrelated `aow` command, use `"$HOME/.local/bin/aow" service` directly. @@ -67,6 +67,8 @@ For session notifications, open `/hooks` in Codex, review and trust **AgentOnWeb For Claude Code, run `claude` in the same terminal. Setup installs observational hooks in `CLAUDE_CONFIG_DIR/settings.json` (default `~/.claude/settings.json`). Review **AgentOnWeb session status** in Claude Code `/hooks`, then start a new session. To repair or remove only this integration, use `aow claude-hooks install` or `aow claude-hooks uninstall`. Models, permissions, other hooks and history are preserved. +For Pi 1.1+, run `pi` in the same terminal. Setup adds `extensions/agentonweb.ts` under `PI_CODING_AGENT_DIR` (default `~/.pi/agent`), preserving settings and other extensions. Start a new Pi process or use `/reload` to load it. To repair or remove only this integration, use `aow pi-extension install` or `aow pi-extension uninstall`, then `/reload`. Completion waits for Pi's final settlement, and blocking extension dialogs request attention in the browser. + Use **+** in the terminal tab bar to open another project, click a tab to switch, double-click its name to rename it, or click **×** to close it. Hiding the workspace or refreshing the page keeps your sessions running. [Setup and troubleshooting](docs/one-command-setup.md) · [Terminal guide](packages/terminal-host/README.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 4c45560..2f65467 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ AgentOnWeb 将你的编程工作区带到你当前打开的页面上。一边看视频、查阅文档,或保持某个网站在视野中,一边与你的助手协作——然后无需离开对话即可切回原页面。 -**直接在网页上使用 Codex 或 Claude Code。** 在内置终端中打开项目,查看正在运行的会话,在任务完成或需要审批时收到提醒。点击会话即可回到对应终端。AgentOnWeb 也支持完整的 **DeepSeek Harness (DSH)** 工作区,保留会话、工具、审批、模型及插件。 +**直接在网页上使用 Codex、Claude Code 或 Pi。** 在内置终端中打开项目,查看正在运行的会话,在任务完成或需要审批时收到提醒。点击会话即可回到对应终端。AgentOnWeb 也支持完整的 **DeepSeek Harness (DSH)** 工作区,保留会话、工具、审批、模型及插件。 | 模式 | 功能 | | --- | --- | @@ -38,9 +38,9 @@ AgentOnWeb 将你的编程工作区带到你当前打开的页面上。一边看 | Firefox | [从 Firefox 附加组件安装](https://addons.mozilla.org/en-US/firefox/addon/agentonweb/) | | Safari | 即将上线——Mac App Store 链接将在审核通过后添加。 | -## 使用 Codex 和 Claude Code +## 使用 Codex、Claude Code 和 Pi -适用于 **macOS + Chrome**。请先安装 [Node.js 22.19+](https://nodejs.org/en/download),并安装、登录你要使用的 CLI(Codex 或 Claude Code)。 +适用于 **macOS + Chrome**。请先安装 [Node.js 22.19+](https://nodejs.org/en/download),并安装、登录你要使用的 CLI(Codex、Claude Code 或 Pi 1.1+)。 ### 1. 一键配置终端 @@ -50,7 +50,7 @@ AgentOnWeb 将你的编程工作区带到你当前打开的页面上。一边看 curl -fsSL https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh | bash ``` -安装器会配置本机终端、登录自动启动、Chrome 连接和 Codex / Claude Code 通知,并保留已有的 CLI 设置。 +安装器会配置本机终端、登录自动启动、Chrome 连接和 Codex / Claude Code / Pi 通知,并保留已有的 CLI 设置。 安装命令兼容 zsh、bash 和 fish。如果 Node 命令目录已在 PATH 中且可写,安装器会在其中创建 `aow` 入口,同一个终端安装后即可运行 `aow service`,无需 source。新开的 zsh/bash 窗口及检测到的 fish 也会配置 `aow` 路径。如果该目录受保护或已有其他程序的 `aow` 命令,可直接运行 `"$HOME/.local/bin/aow" service`。 @@ -67,6 +67,8 @@ codex 使用 Claude Code 时,在同一终端运行 `claude`。安装器会在 `CLAUDE_CONFIG_DIR/settings.json`(默认 `~/.claude/settings.json`)中添加观察性 hooks。在 Claude Code `/hooks` 中查看 **AgentOnWeb session status**,然后启动新会话。单独修复或移除该集成可运行 `aow claude-hooks install` 或 `aow claude-hooks uninstall`;模型、权限、其他 hooks 和历史记录都会保留。 +使用 Pi 1.1+ 时,在同一终端运行 `pi`。配置会在 `PI_CODING_AGENT_DIR`(默认 `~/.pi/agent`)下添加 `extensions/agentonweb.ts`,保留设置和其他 extensions。重新启动 Pi 或运行 `/reload` 加载。单独修复或移除可运行 `aow pi-extension install` 或 `aow pi-extension uninstall`,然后 `/reload`。完成通知会等待 Pi 的最终结束事件;extension 弹出确认或输入对话框时,浏览器会提示你回到终端处理。 + 点击终端标签栏的 **+** 打开另一个项目,点击标签切换,双击名称重命名,点击 **×** 关闭。隐藏工作区或刷新网页不会中断正在运行的会话。 [安装与常见问题](docs/one-command-setup.md) · [终端指南](packages/terminal-host/README.md) diff --git a/apps/extension/src/agent-activity.ts b/apps/extension/src/agent-activity.ts index c2d644c..5d191d4 100644 --- a/apps/extension/src/agent-activity.ts +++ b/apps/extension/src/agent-activity.ts @@ -66,7 +66,7 @@ export function createAgentActivity(activate: (session: AgentSession) => void, r if (!current) advance(); if (!sorted.length) { const empty = document.createElement("p"); empty.className = "agent-empty"; - empty.textContent = "No agent sessions yet. Run aow setup, review AgentOnWeb session status in your CLI's /hooks, then launch Codex or Claude Code in a local terminal."; + empty.textContent = "No agent sessions yet. Run aow setup, then launch Codex, Claude Code or Pi in a local terminal. For Codex and Claude Code, review AgentOnWeb session status in /hooks."; element.append(empty); } return { count: sorted.filter(item => item.status === "running" || item.status === "approval").length, diff --git a/docs/agent-notifications.md b/docs/agent-notifications.md index d2bb758..86928c6 100644 --- a/docs/agent-notifications.md +++ b/docs/agent-notifications.md @@ -26,6 +26,16 @@ A project or profile that overrides `notify`, disabled/untrusted hooks, or launc Claude Code has no universal user-interrupt hook. An interruption or permission denial that emits no supported event can leave the displayed status approximate until the next prompt or session end; an idle notification clears an otherwise running indicator. A separate Stop hook can continue the turn after our observational hook reports completion; subsequent activity restores Working. The native TUI is authoritative. Disabled hooks, managed policy restrictions or a process that drops the inherited event environment prevent reporting. The integration never changes `disableAllHooks`, permission rules or hook trust. +## Pi + +`aow setup` also installs an observational extension for Pi 1.1+. Use `aow pi-extension install|uninstall` to manage it separately. The installer adds `extensions/agentonweb.ts` under `PI_CODING_AGENT_DIR` (default `~/.pi/agent`), preserves settings and other extensions, backs up replaced AgentOnWeb entries, and refuses to overwrite an unrelated file with that name. It resolves the packaged lightweight `pi-extension.js` through the module URL, including npm bin-symlink installs. Start Pi or use `/reload` after installing/removing it. It registers no handlers outside a host-owned terminal. + +Pi lifecycle events reach the same authenticated per-terminal ingress, ordered and with the same bounded presentation fields and network deadline. `session_start` creates Ready; `before_agent_start` creates a fresh turn and prompt title; `agent_start` and tool execution events show Working. Blocking extension `ui_prompt_start` events show Needs approval; `ui_prompt_end` restores Working or Ready. Tool activity cannot clear a still-open dialog. Confirmation/input decisions stay in Pi; this adapter never returns tool, prompt, context or permission changes. + +Completion uses `agent_settled`, after retries, compaction recovery and queued work finish. `agent_end` alone does not complete a task. Aborted settlement or the last turn's error outcome shows Interrupted without a completion notification. `session_shutdown` ends the old session when Pi quits, reloads, starts a new session, resumes or forks; the following `session_start` binds the new runtime. Pi session IDs are independent of Codex and Claude Code. Provider errors, model responses, tool arguments/results and session files are not forwarded. + +This integration requires Pi's 1.1+ settlement and UI-prompt events. Force-killing Pi without a shutdown event can leave a snapshot until that host terminal closes. Disabled extensions and shells that drop the inherited event environment prevent reporting. + ## Event flow 1. Each host-owned PTY receives its own `AOW_AGENT_ENDPOINT` and `AOW_AGENT_TOKEN`. @@ -53,7 +63,7 @@ The registry is host-memory scoped; it does not reconstruct history after a host ## Extending to other agents -The connector contract `AgentSession` and `SurfaceAdapter.agentSessions` contain no Codex hook names. Another runtime can publish its own normalized sessions through that interface. The Claude Code terminal adapter normalizes its official events in the host. Future adapters should reuse the per-terminal binding and public status contract, not parse colored PTY text or make the frontend know agent-specific hook payloads. +The connector contract `AgentSession` and `SurfaceAdapter.agentSessions` contain no Codex hook names. Another runtime can publish its own normalized sessions through that interface. The Claude Code and Pi terminal adapters normalize their official events in the host. Future adapters should reuse the per-terminal binding and public status contract, not parse colored PTY text or make the frontend know agent-specific hook payloads. ## Verification @@ -61,8 +71,8 @@ The connector contract `AgentSession` and `SurfaceAdapter.agentSessions` contain For the interactive UI fixture: `pnpm build:preview`, `pnpm preview`, then open `http://127.0.0.1:4173/?activity`. The fixture explicitly labels its events as simulated. -Official schemas: [Claude Code hooks](https://code.claude.com/docs/en/hooks), [Claude Code settings](https://code.claude.com/docs/en/settings), [Codex hooks](https://learn.chatgpt.com/docs/hooks), [Codex notify](https://learn.chatgpt.com/docs/config-file/config-advanced#notifications). +Official schemas: [Pi extensions](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md), [Pi event types](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/extensions/types.ts), [Claude Code hooks](https://code.claude.com/docs/en/hooks), [Claude Code settings](https://code.claude.com/docs/en/settings), [Codex hooks](https://learn.chatgpt.com/docs/hooks), [Codex notify](https://learn.chatgpt.com/docs/config-file/config-advanced#notifications). ## One-command setup (macOS + Chrome) -The new `aow setup` entry configures the service, Chrome native pairing and Codex/Claude Code notifications together. Global npm installs run it when lifecycle scripts are permitted; the release shell installer calls it explicitly. Codex hook trust remains a first-use user action. Use matching 0.2.0 host and extension builds; release availability is tracked separately. See [One-command setup (macOS + Chrome)](one-command-setup.md). +The new `aow setup` entry configures the service, Chrome native pairing and Codex/Claude Code/Pi notifications together. Global npm installs run it when lifecycle scripts are permitted; the release shell installer calls it explicitly. Codex hook trust remains a first-use user action. Use matching host and extension builds; release availability is tracked separately. See [One-command setup (macOS + Chrome)](one-command-setup.md). diff --git a/docs/one-command-setup.md b/docs/one-command-setup.md index ff8ac9a..95443ef 100644 --- a/docs/one-command-setup.md +++ b/docs/one-command-setup.md @@ -1,6 +1,6 @@ -# Set up AgentOnWeb for Codex and Claude Code +# Set up AgentOnWeb for Codex, Claude Code and Pi -Use **macOS + Chrome**, with Node.js 22.19+ and Codex or Claude Code installed and signed in. +Use **macOS + Chrome**, with Node.js 22.19+ and Codex, Claude Code or Pi 1.1+ installed and signed in. 1. Install [AgentOnWeb from the Chrome Web Store](https://chromewebstore.google.com/detail/agentonweb/lhbmeokjjcmklamnepcechnpcdjgkcoe). 2. Run this command in your Mac's Terminal: @@ -9,10 +9,10 @@ Use **macOS + Chrome**, with Node.js 22.19+ and Codex or Claude Code installed a curl -fsSL https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh | bash ``` -3. Open a website, click AgentOnWeb and run `codex` or `claude` in its terminal. +3. Open a website, click AgentOnWeb and run `codex`, `claude` or `pi` in its terminal. 4. For session notifications, open Codex `/hooks`, review and trust **AgentOnWeb session status**, then start a new Codex session. -The installer configures your terminal service, automatic startup, Chrome connection and Codex/Claude Code notifications. It installs the terminal package under `~/.local`, adds its command directory to your shell profile, and preserves existing CLI settings with backups. Run it as your own user, without sudo. +The installer configures your terminal service, automatic startup, Chrome connection and Codex/Claude Code/Pi notifications. It installs the terminal package under `~/.local`, adds its command directory to your shell profile, and preserves existing CLI settings with backups. Run it as your own user, without sudo. The command works from zsh, bash and fish. The installer configures both login and interactive zsh/bash windows, including editor terminals, and uses fish's own PATH syntax when fish is installed or selected as your login shell. It respects exported `ZDOTDIR` and `XDG_CONFIG_HOME` settings and preserves the Bash login profile you already use. @@ -48,6 +48,10 @@ Notifications link back to the relevant terminal. Approval decisions stay in Cod Run `aow claude-hooks install`, review **AgentOnWeb session status** in Claude Code `/hooks`, then start a new session inside AgentOnWeb. Use the same `CLAUDE_CONFIG_DIR` as your CLI. The installer backs up `settings.json` and preserves existing hooks, models, permissions and `disableAllHooks`; disabled or policy-restricted hooks remain disabled. Approval decisions stay in Claude Code. +## Pi notifications are missing + +Run `aow pi-extension install`, then start Pi 1.1+ in an AgentOnWeb terminal or run `/reload` in Pi. Use the same `PI_CODING_AGENT_DIR` as your CLI (default `~/.pi/agent`). Check that `extensions/agentonweb.ts` is enabled; `--no-extensions` and excluded resources prevent automatic loading. The extension leaves settings and other extensions intact and stays inactive outside host-owned terminals. Pi's native confirmation and input dialogs stay in the terminal. + ## Update or repair Rerun the installation command to install the current AgentOnWeb terminal package and repair its configuration. Installation leaves active terminal sessions running. To load an updated service, finish your work, then run: @@ -70,6 +74,6 @@ npm uninstall --global --prefix "$HOME/.local" @agentonweb/terminal-host This stops the terminal service, removes Chrome pairing registration and AgentOnWeb hooks, and restores your previous Codex notify command. User settings, history and backups remain. If you installed through npm directly instead of the shell installer, uninstall from the npm prefix you originally used. -If cleanup reports an error, correct it and rerun `aow uninstall` before removing the package. To remove only notifications, use `aow codex-hooks uninstall` or `aow claude-hooks uninstall`. +If cleanup reports an error, correct it and rerun `aow uninstall` before removing the package. To remove only notifications, use `aow codex-hooks uninstall`, `aow claude-hooks uninstall` or `aow pi-extension uninstall`. Use `/reload` in Pi to unload the removed extension. [Back to AgentOnWeb](../README.md) · [Development setup](development.md) diff --git a/docs/verification/pi-2026-10-12.md b/docs/verification/pi-2026-10-12.md new file mode 100644 index 0000000..661baaf --- /dev/null +++ b/docs/verification/pi-2026-10-12.md @@ -0,0 +1,59 @@ +# Pi support verification - 2026-10-12 + +Implemented on `codex/pi-support`, following the Claude Code adapter's authenticated per-terminal event transport and runtime-neutral `AgentSession` contract. + +## Behavior + +- `aow setup` installs the Pi extension; `aow pi-extension install|uninstall` manages it separately. +- The marked loader lives at `PI_CODING_AGENT_DIR/extensions/agentonweb.ts`, defaulting to `~/.pi/agent/extensions/agentonweb.ts`. Tilde paths match Pi's configuration behavior. Settings, models and other extensions are preserved; conflicting unmarked files are refused. +- The shipped `lib/pi-extension.js` observes Pi 1.1+ lifecycle and UI prompt events, preserving event order and failing open on transport errors. It registers no handlers outside host-owned terminals and never changes prompts, tools, context or permission decisions. +- Working, input/confirmation, final completion, interruption, provider error and session shutdown normalize into existing browser presentation. `agent_settled` is the completion boundary; intermediate `agent_end` events do not notify. +- Terminal close removes Pi sessions; reconnect restores the latest snapshot. Codex, Claude Code and Pi session identities remain independent. + +## Validation + +`pnpm release:audit` passed privacy, architecture, TypeScript, the full test suite, Chrome/Firefox/Safari builds and manifests, Safari native validation, reproducible archives and isolated production installation. + +After adding tilde-path coverage and native resume/fork acceptance, `pnpm typecheck`, `pnpm test` and the package audit were rerun. The final suite contains **50 passing files, 269 passing tests and one existing optional DSH test skipped**. + +Native Pi **1.1.0** was exercised in an isolated configuration through its real RPC runtime and the installed extension loader, using a local deterministic OpenAI-compatible fixture rather than a paid provider. It covered actual tool execution, a blocking confirmation dialog, abort, provider failure, new session, resume, fork and orderly shutdown. Settings remained byte-for-byte intact; assistant responses and tool output never entered presentation events. This test is optional when `pi` is absent; `AOW_TEST_PI_BIN` can select a binary. + +The real host/PTY tests exercised all three agents' packaged event reporters, authenticated ingress, notifications after closing the terminal viewer, reconnect snapshots and terminal cleanup. Archive acceptance installed through the npm bin symlink and verified the Pi loader's canonical package path, import, repeat installation and removal. + +`git diff --check` passed. The initial implementation checks were followed by the real browser/TUI acceptance below. + +## Real browser acceptance + +Operated visible **Google Chrome for Testing 153.0.8010.52** with native Computer Use, the branch's **0.3.0** extension build and host, and **Pi 1.1.0** running in the actual browser terminal. The page was a plain local website, not the simulated activity preview. Pi used an isolated configuration and a local deterministic model endpoint, so model costs, credentials and user projects were not involved. + +The existing QA extension copy retained its extension identity. Only its display name and discovery range were adjusted to select the temporary host on port 3849; this avoided connecting the second host to the user's existing terminal service. Session transport, page content script, native wrapper, terminal surface and Pi adapter were the branch's real builds. + +| Scenario | Observed result | +| --- | --- | +| Native startup | Starting `pi` in the embedded shell displayed the actual Pi TUI and a Ready session. | +| Working and modes | A native streaming turn displayed Working. Focus, Chill and Watch preserved the same running process. | +| Completion in Watch | Final native settlement displayed a Completed notification while the terminal iframe was hidden. Clicking it restored the exact Pi terminal and its completed output. | +| Native tool confirmation | An actual Pi extension tool opened a blocking `ctx.ui.confirm` dialog. The page displayed Needs approval and the dialog title. | +| Parallel sessions | A second shell ran a separate Pi turn while the first waited for confirmation. Both titles/states remained visible independently. | +| Correct approval activation | From the second shell and Watch mode, clicking the first session's notification returned to its original native confirmation dialog. Confirming completed only that task; the second remained Working. | +| Refresh and notification deduplication | Refresh restored both existing shells and the same Pi screens. A dismissed completion notification did not replay. | +| Native Escape | Escape aborted the second native run. The page showed Interrupted without a completion notification. | +| Provider error | An intentional local HTTP 400 appeared in Pi, and the page showed Interrupted with the correct new task title. | +| New and resumed sessions | `/new` replaced the old session state with Ready. `/resume` restored the saved native session; its next task completed and notified normally. | +| Extension reload | A native command calling `ctx.reload()` rebound the observer and showed Ready. The following task completed with a new notification. | +| Exit and close | Native Ctrl+D removed the second Pi session while its shell remained. Cancelling a terminal-close confirmation kept both shells. Closing the second shell preserved the first session; closing the first cleared the list and notifications. | +| Extension errors | Chrome's extension error collection was enabled; the extension card showed no collected error entry after acceptance. | + +Local screenshot evidence: [completion in Watch](../../output/pi-browser-qa/screenshots/completed-watch.png), [native approval](../../output/pi-browser-qa/screenshots/approval-native.png), [parallel sessions](../../output/pi-browser-qa/screenshots/parallel-sessions.png), [interruption](../../output/pi-browser-qa/screenshots/interrupted.png), [empty terminal/session state](../../output/pi-browser-qa/screenshots/cleanup.png). + +The first attempted browser was the cached Chrome for Testing **156.0.8078.4**. It stalled navigating to the local HTTP page even with the extension disabled; the same server responded to curl and another Chrome instance. This build was excluded from acceptance. Chrome 153 loaded the page and completed the matrix above. Firefox and Safari remain build/package validated rather than live-browser tested. + +Both QA terminals were closed, the temporary host stopped, and the old QA extension directory was restored byte-for-byte and reloaded. The user's existing terminal service PID 85085 remained running throughout. No product code change was needed during browser acceptance. + +## Delivery boundary + +These checks establish local source, package and Chrome 153 workflow readiness. They do not commit, push, merge, publish to npm/browser stores, change the user's Pi configuration or restart the installed terminal service. Loading the integration requires the updated host and `aow pi-extension install` (or `aow setup`), then starting Pi or running `/reload`. + +Pi 1.1+ is required. Disabling extensions or dropping the inherited event environment prevents reporting. Force-killing Pi without a shutdown event can leave its snapshot until the host terminal closes. + +Sources: [Pi extensions](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md), [Pi event types](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/extensions/types.ts), [event mapping](../agent-notifications.md). diff --git a/packages/terminal-host/README.md b/packages/terminal-host/README.md index 4f00e54..538c06d 100644 --- a/packages/terminal-host/README.md +++ b/packages/terminal-host/README.md @@ -1,16 +1,16 @@ -# AgentOnWeb terminal for Codex and Claude Code +# AgentOnWeb terminal for Codex, Claude Code and Pi -Run Codex or Claude Code in your browser, keep several project terminals open, and return to the right session when a task finishes or needs approval. +Run Codex, Claude Code or Pi in your browser, keep several project terminals open, and return to the right session when a task finishes or needs approval. ## Install on macOS -Install [the Chrome extension](https://chromewebstore.google.com/detail/agentonweb/lhbmeokjjcmklamnepcechnpcdjgkcoe), Node.js 22.19+ and your preferred CLI (Codex or Claude Code), then run: +Install [the Chrome extension](https://chromewebstore.google.com/detail/agentonweb/lhbmeokjjcmklamnepcechnpcdjgkcoe), Node.js 22.19+ and your preferred CLI (Codex, Claude Code or Pi 1.1+), then run: ```sh curl -fsSL https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh | bash ``` -The installer configures the terminal service, Chrome connection and Codex/Claude Code notifications. Open a website, click AgentOnWeb and run: +The installer configures the terminal service, Chrome connection and Codex/Claude Code/Pi notifications. Open a website, click AgentOnWeb and run: ```sh cd ~/your-project @@ -23,6 +23,8 @@ The installer configures zsh, bash and detected fish installations. It also link For Claude Code, run `claude` in the same terminal. Setup installs observational hooks in `CLAUDE_CONFIG_DIR/settings.json` (default `~/.claude/settings.json`). Review **AgentOnWeb session status** in Claude Code `/hooks`, then start a new session. To repair or remove only this integration, use `aow claude-hooks install` or `aow claude-hooks uninstall`. Models, permissions, other hooks and history are preserved. +For Pi 1.1+, run `pi` in the same terminal. Setup adds an observational extension at `PI_CODING_AGENT_DIR/extensions/agentonweb.ts` (default `~/.pi/agent/extensions/agentonweb.ts`). Start a new Pi process or use `/reload`. `aow pi-extension install` repairs it; `aow pi-extension uninstall` removes it, followed by `/reload` to unload it. Settings, models, other extensions and history are preserved. Blocking extension dialogs request attention; completed notifications wait for all automatic work to settle. No hook trust step is needed for Pi. + ## Terminal controls - **+** opens another independent local shell (up to eight). The selector switches between shells without stopping their processes. diff --git a/packages/terminal-host/src/agent-events.ts b/packages/terminal-host/src/agent-events.ts index 298b0c7..380f4a9 100644 --- a/packages/terminal-host/src/agent-events.ts +++ b/packages/terminal-host/src/agent-events.ts @@ -1,5 +1,5 @@ /** Observational hook: no stdout, permission decision, or effect outside AOW terminals. */ -export async function reportAgentEvent(agent: "codex" | "claude-code" = "codex"): Promise { +export async function reportAgentEvent(agent: "codex" | "claude-code" | "pi" = "codex"): Promise { if (!process.env.AOW_AGENT_ENDPOINT || !process.env.AOW_AGENT_TOKEN) return; process.stdin.setEncoding("utf8"); let body = ""; @@ -14,7 +14,7 @@ export async function reportAgentEvent(agent: "codex" | "claude-code" = "codex") return; } } -export async function reportAgentInput(input: any, agent: "codex" | "claude-code" = "codex"): Promise { +export async function reportAgentInput(input: any, agent: "codex" | "claude-code" | "pi" = "codex"): Promise { const endpoint = process.env.AOW_AGENT_ENDPOINT; const token = process.env.AOW_AGENT_TOKEN; if (!endpoint || !token) return; @@ -22,7 +22,7 @@ export async function reportAgentInput(input: any, agent: "codex" | "claude-code if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.pathname !== "/agent-event") return; // Do not forward commands, tool output, or assistant responses to presentation. const payload = { agent, session_id: input.session_id, hook_event_name: input.hook_event_name, turn_id: agent === "claude-code" ? input.prompt_id : input.turn_id, - agent_id: input.agent_id, notification_type: input.notification_type, is_interrupt: input.is_interrupt, source: input.source, cwd: input.cwd, prompt: typeof input.prompt === "string" ? input.prompt.slice(0, 160) : undefined, + agent_id: input.agent_id, notification_type: input.notification_type, is_interrupt: input.is_interrupt, is_idle: agent === "pi" ? input.is_idle : undefined, source: input.source, cwd: input.cwd, prompt: typeof input.prompt === "string" ? input.prompt.slice(0, 160) : undefined, tool_name: input.tool_name, tool_use_id: input.tool_use_id, tool_input: { description: typeof input.tool_input?.description === "string" ? input.tool_input.description.slice(0, 500) : undefined } }; await fetch(url, { method: "POST", redirect: "error", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(1200) }); diff --git a/packages/terminal-host/src/agent-hook.ts b/packages/terminal-host/src/agent-hook.ts index fd9c0fc..6bdde8c 100644 --- a/packages/terminal-host/src/agent-hook.ts +++ b/packages/terminal-host/src/agent-hook.ts @@ -2,6 +2,6 @@ import { reportAgentEvent } from "./agent-events.js"; // Hook startup must not load PTY, terminal emulation or service dependencies. -if (process.argv.length === 3 && (process.argv[2] === "codex" || process.argv[2] === "claude-code")) +if (process.argv.length === 3 && (process.argv[2] === "codex" || process.argv[2] === "claude-code" || process.argv[2] === "pi")) await reportAgentEvent(process.argv[2]).catch(() => {}); process.exit(0); diff --git a/packages/terminal-host/src/agent-sessions.ts b/packages/terminal-host/src/agent-sessions.ts index d6ccf5e..2ec2604 100644 --- a/packages/terminal-host/src/agent-sessions.ts +++ b/packages/terminal-host/src/agent-sessions.ts @@ -7,6 +7,11 @@ const statuses: Record = { PermissionRequest: "approval", TurnComplete: "completed", Interrupt: "interrupted", SessionEnd: "ended", PermissionNotification: "approval", Idle: "idle", }; +const piEvents: Record = { + session_start: "SessionStart", before_agent_start: "UserPromptSubmit", agent_start: "PreToolUse", + tool_execution_start: "PreToolUse", tool_execution_end: "PostToolUse", + ui_prompt_start: "PermissionRequest", ui_prompt_end: "PostToolUse", session_shutdown: "SessionEnd", +}; /** Normalize native hook inputs here; browser clients only see AgentSession. */ export class AgentSessions { @@ -21,11 +26,17 @@ export class AgentSessions { ingest(terminalId: string, input: unknown): boolean { if (!input || typeof input !== "object" || Array.isArray(input)) return false; const data = input as Record; - if (data.agent !== undefined && data.agent !== "codex" && data.agent !== "claude-code") return false; + if (data.agent !== undefined && data.agent !== "codex" && data.agent !== "claude-code" && data.agent !== "pi") return false; const claude = data.agent === "claude-code"; - const agent = claude ? "Claude Code" : "Codex"; + const pi = data.agent === "pi"; + const agent = claude ? "Claude Code" : pi ? "Pi" : "Codex"; let event = text(data.hook_event_name, 80); - if (claude) { + const inputResolved = pi && event === "ui_prompt_end"; + if (pi) { + if (event === "agent_settled") event = data.is_interrupt === true ? "Interrupt" : "TurnComplete"; + else if (inputResolved && data.is_idle === true) event = "Idle"; + else event = Object.hasOwn(piEvents, event) ? piEvents[event]! : ""; + } else if (claude) { if (event === "Stop") event = "TurnComplete"; else if (event === "StopFailure") event = "Interrupt"; else if (event === "PostToolUseFailure") event = data.is_interrupt === true ? "Interrupt" : "PostToolUse"; @@ -35,25 +46,25 @@ export class AgentSessions { const status = statuses[event]; const sessionId = text(data.session_id, 100); if (!Object.hasOwn(statuses, event) || !status || !sessionId || data.agent_id) return false; - const id = `${terminalId}:${claude ? "claude-code:" : ""}${sessionId}`; + const id = `${terminalId}:${claude ? "claude-code:" : pi ? "pi:" : ""}${sessionId}`; const previous = this.#sessions.get(id); - if (claude && previous?.status === "ended" && !["SessionStart", "UserPromptSubmit", "SessionEnd"].includes(event)) return false; + if ((claude || pi) && previous?.status === "ended" && !["SessionStart", "UserPromptSubmit", "SessionEnd"].includes(event)) return false; // Notify can include threads not observed by the root lifecycle hooks. // A completion may finish a known session, but cannot establish one. if (event === "TurnComplete" && !previous) return false; // Claude may omit prompt_id. A second Stop or delayed idle nudge must not // replay completion or erase a known approval/completion state. - if (claude && event === "TurnComplete" && previous?.status === "completed") return true; - if (event === "Idle" && (!previous || previous.status === "approval" || previous.status === "completed" || previous.status === "ended")) return false; + if ((claude || pi) && event === "TurnComplete" && previous?.status === "completed") return true; + if (event === "Idle" && !pi && (!previous || previous.status === "approval" || previous.status === "completed" || previous.status === "ended")) return false; if (event === "PermissionNotification" && previous?.status === "approval") return true; if (event === "SessionStart" && data.source === "compact" && previous) return true; // /exit and resume have their own prompt IDs without a UserPromptSubmit. // These events affect the session, not the preceding model turn. - const lifecycle = claude && (event === "SessionStart" || event === "SessionEnd"); - if (claude && event === "SessionStart") this.#turns.delete(id); + const lifecycle = (claude || pi) && (event === "SessionStart" || event === "SessionEnd"); + if ((claude || pi) && event === "SessionStart") this.#turns.delete(id); const turn = text(data.turn_id, 100); const currentTurn = this.#turns.get(id); - if (!claude && turn === currentTurn && previous && ["completed", "interrupted", "ended"].includes(previous.status) + if (!claude && (!pi || event !== "PermissionRequest") && turn === currentTurn && previous && ["completed", "interrupted", "ended"].includes(previous.status) && ["PreToolUse", "PostToolUse", "PermissionRequest"].includes(event)) return false; // A late completion from the preceding turn must not finish the current one. if (turn && currentTurn && turn !== currentTurn && event !== "UserPromptSubmit" && !lifecycle) return false; @@ -79,7 +90,7 @@ export class AgentSessions { const count = pending.get(toolName)!; if (count === 1) pending.delete(toolName); else pending.set(toolName, count - 1); } - const resolvesApproval = event === "PostToolUse" && (!pending || pending.size === 0); + const resolvesApproval = inputResolved || event === "PostToolUse" && (!pending || pending.size === 0); const nextStatus = previous?.status === "approval" && ["PreToolUse", "PostToolUse"].includes(event) && !resolvesApproval ? "approval" : status; if (nextStatus !== "approval") this.#approvalTools.delete(id); const tool = data.tool_input && typeof data.tool_input === "object" ? data.tool_input as Record : {}; @@ -87,7 +98,7 @@ export class AgentSessions { const detail = event === "PermissionNotification" ? "Review in terminal" : event === "PermissionRequest" ? text(tool.description ?? data.tool_name, 500) : event === "TurnComplete" ? "Turn complete" - : claude && event === "Interrupt" ? "Turn stopped" + : (claude || pi) && event === "Interrupt" ? "Turn stopped" : nextStatus === "approval" ? previous?.detail ?? "Review in terminal" : event === "SessionEnd" ? "Session ended" : text(data.cwd, 500); const permission = event === "PermissionRequest" || event === "PermissionNotification"; diff --git a/packages/terminal-host/src/cli.ts b/packages/terminal-host/src/cli.ts index e156c02..23cd3b4 100644 --- a/packages/terminal-host/src/cli.ts +++ b/packages/terminal-host/src/cli.ts @@ -18,6 +18,7 @@ import { AgentSessions } from "./agent-sessions.js"; import { installCodexHooks, uninstallCodexHooks, reportCodexInput } from "./codex-hooks.js"; import { completionEvent, forwardNotification } from "./codex-notify.js"; import { installClaudeHooks, uninstallClaudeHooks } from "./claude-hooks.js"; +import { installPiExtension, uninstallPiExtension } from "./pi-hooks.js"; import { reportAgentEvent } from "./agent-events.js"; import type { NativeSurface } from "@agentonweb/connector-contract"; @@ -44,8 +45,8 @@ if (inputArgs[0] === "agent-notify") { process.exit(0); } if (inputArgs[0] === "agent-event") { - if (inputArgs.length === 1 || inputArgs.length === 2 && inputArgs[1] === "claude-code") - await reportAgentEvent(inputArgs[1] === "claude-code" ? "claude-code" : "codex").catch(() => {}); + if (inputArgs.length === 1 || inputArgs.length === 2 && (inputArgs[1] === "claude-code" || inputArgs[1] === "pi")) + await reportAgentEvent(inputArgs[1] === "pi" ? "pi" : inputArgs[1] === "claude-code" ? "claude-code" : "codex").catch(() => {}); process.exit(0); } if (inputArgs[0] === "claude-hooks" && inputArgs[1] === "install" && inputArgs.length === 2) { @@ -57,6 +58,15 @@ if (inputArgs[0] === "claude-hooks" && inputArgs[1] === "uninstall" && inputArgs console.log("AgentOnWeb Claude Code hooks removed; other settings preserved."); process.exit(0); } +if (inputArgs[0] === "pi-extension" && inputArgs[1] === "install" && inputArgs.length === 2) { + console.log(`Installed observational extension: ${await installPiExtension()}\nStart Pi 1.1+ inside an AgentOnWeb terminal, or use /reload in Pi.`); + process.exit(0); +} +if (inputArgs[0] === "pi-extension" && inputArgs[1] === "uninstall" && inputArgs.length === 2) { + await uninstallPiExtension(); + console.log("AgentOnWeb Pi extension removed; other settings preserved. Use /reload in Pi to unload it."); + process.exit(0); +} if (inputArgs[0] === "codex-hooks" && inputArgs[1] === "install") { console.log(`Installed observational hooks: ${await installCodexHooks()}\nIn Codex, open /hooks and review/trust AgentOnWeb session status hooks, then start a new session inside an AgentOnWeb terminal.`); process.exit(0); @@ -77,7 +87,7 @@ if (inputArgs[0] === "service") { } if (inputArgs.length === 1 && inputArgs[0] === "--help") { console.log( - "Usage: aow setup | aow uninstall | aow terminal | aow codex-hooks install|uninstall | aow claude-hooks install|uninstall | aow service [open|install|status|uninstall] (default: open)", + "Usage: aow setup | aow uninstall | aow terminal | aow codex-hooks install|uninstall | aow claude-hooks install|uninstall | aow pi-extension install|uninstall | aow service [open|install|status|uninstall] (default: open)", ); process.exit(0); } diff --git a/packages/terminal-host/src/host.integration.test.ts b/packages/terminal-host/src/host.integration.test.ts index ded2a50..659e207 100644 --- a/packages/terminal-host/src/host.integration.test.ts +++ b/packages/terminal-host/src/host.integration.test.ts @@ -408,7 +408,7 @@ it("refreshes other authorized browsers after revocation without stopping their } }, 15000); -it.each(["codex", "claude-code"])("delivers authenticated %s hooks without a terminal viewer and restores the latest snapshot", async (agent) => { +it.each(["codex", "claude-code", "pi"])("delivers authenticated %s events without a terminal viewer and restores the latest snapshot", async (agent) => { directory = await mkdtemp(join(tmpdir(), "aow-agent-events-")); const cli = resolve("packages/terminal-host/lib/cli.js"); host = spawn(process.execPath, [cli, "terminal"], { @@ -455,14 +455,16 @@ it.each(["codex", "claude-code"])("delivers authenticated %s hooks without a ter terminal.on("message", raw => { const m = JSON.parse(String(raw)); if (m.type === "snapshot") epoch = m.epoch; }); await expect.poll(() => Boolean(epoch)).toBe(true); const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; - const report = (event: string) => `printf %s ${quote(JSON.stringify({ hook_event_name: event, session_id: "actual-hook-session", [agent === "claude-code" ? "prompt_id" : "turn_id"]: "turn-one", prompt: "Verify hook transport", tool_name: "Bash" }))} | ${quote(process.execPath)} ${agent === "claude-code" ? `${quote(resolve("packages/terminal-host/lib/agent-hook.js"))} claude-code` : `${quote(cli)} agent-event`}`; - terminal.send(JSON.stringify({ type: "input", epoch, data: `${report("UserPromptSubmit")}\nsleep 0.2\n${report("PermissionRequest")}\nsleep 0.2\n${agent === "claude-code" ? report("Stop") : `${quote(process.execPath)} ${quote(cli)} agent-notify ${quote(JSON.stringify({ type: "agent-turn-complete", "thread-id": "actual-hook-session", "turn-id": "turn-one" }))}`}\n` })); + const report = (event: string) => `printf %s ${quote(JSON.stringify({ hook_event_name: event, session_id: "actual-hook-session", [agent === "claude-code" ? "prompt_id" : "turn_id"]: "turn-one", prompt: "Verify hook transport", tool_name: "Bash" }))} | ${quote(process.execPath)} ${agent !== "codex" ? `${quote(resolve("packages/terminal-host/lib/agent-hook.js"))} ${agent}` : `${quote(cli)} agent-event`}`; + const complete = agent === "claude-code" ? report("Stop") : agent === "pi" ? report("agent_settled") + : `${quote(process.execPath)} ${quote(cli)} agent-notify ${quote(JSON.stringify({ type: "agent-turn-complete", "thread-id": "actual-hook-session", "turn-id": "turn-one" }))}`; + terminal.send(JSON.stringify({ type: "input", epoch, data: `${report(agent === "pi" ? "before_agent_start" : "UserPromptSubmit")}\nsleep 0.2\n${report(agent === "pi" ? "ui_prompt_start" : "PermissionRequest")}\nsleep 0.2\n${complete}\n` })); // Semantic notifications must outlive the xterm viewing connection. terminal.close(); await expect.poll(() => frames.filter(frame => frame.kind === "agent.sessions").some(frame => frame.sessions[0]?.status === "completed"), { timeout: 5000 }).toBe(true); const updates = frames.filter(frame => frame.kind === "agent.sessions").flatMap(frame => frame.sessions); expect(updates.map(item => item.status)).toEqual(expect.arrayContaining(["running", "approval", "completed"])); - expect(updates.every(item => item.terminalId === terminalId && item.agent === (agent === "claude-code" ? "Claude Code" : "Codex"))).toBe(true); + expect(updates.every(item => item.terminalId === terminalId && item.agent === (agent === "claude-code" ? "Claude Code" : agent === "pi" ? "Pi" : "Codex"))).toBe(true); socket.close(); const restored = new WebSocket(connectorUrl, { headers: { Origin: extensionOrigin } }); const snapshots: any[] = []; diff --git a/packages/terminal-host/src/pi-extension.test.ts b/packages/terminal-host/src/pi-extension.test.ts new file mode 100644 index 0000000..9e73531 --- /dev/null +++ b/packages/terminal-host/src/pi-extension.test.ts @@ -0,0 +1,92 @@ +import { afterEach, expect, it, vi } from "vitest"; +import piExtension from "./pi-extension.js"; +import { AgentSessions } from "./agent-sessions.js"; + +afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); + +it("observes Pi lifecycle, waits for final settlement and does not expose model/tool data", async () => { + vi.stubEnv("AOW_AGENT_ENDPOINT", "http://127.0.0.1:1234/agent-event"); + vi.stubEnv("AOW_AGENT_TOKEN", "test-token"); + const store = new AgentSessions(); + const payloads: any[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url, options) => { + const payload = JSON.parse(options.body); + payloads.push(payload); + expect(store.ingest("terminal", payload)).toBe(true); + return { ok: true }; + })); + const handlers = new Map(); + piExtension({ on: (name, handler) => { handlers.set(name, handler); } }); + let session = "one"; + let idle = false; + const ctx = { cwd: "/project", sessionManager: { getSessionId: () => session }, isIdle: () => idle }; + const emit = async (name: string, fields = {}) => { + expect(await handlers.get(name)!({ type: name, ...fields }, ctx)).toBeUndefined(); + }; + const current = () => store.snapshot().find(item => item.id.endsWith(`pi:${session}`))!; + await emit("session_start"); + expect(current()).toMatchObject({ agent: "Pi", status: "idle" }); + await emit("before_agent_start", { prompt: "Pi task", systemPrompt: "private system prompt" }); + await emit("agent_start"); + await emit("ui_prompt_start", { title: "Confirm action" }); + const attention = current().attentionId; + await emit("tool_execution_start", { toolName: "bash", toolCallId: "tool", args: { command: "private command" } }); + await emit("tool_execution_end", { toolName: "bash", toolCallId: "tool", result: "private output" }); + expect(current()).toMatchObject({ status: "approval", detail: "Confirm action", attentionId: attention }); + await emit("ui_prompt_end"); + expect(current().status).toBe("running"); + await emit("turn_end", { outcome: "error", message: { content: "private response" } }); + expect(handlers.has("agent_end")).toBe(false); + expect(current().status).toBe("running"); + // An automatic retry is still working, and may complete successfully. + await emit("agent_start"); + await emit("turn_end", { outcome: "completed" }); + await emit("agent_settled", { aborted: false }); + const completed = current().attentionId; + expect(current()).toMatchObject({ title: "Pi task", status: "completed" }); + await emit("agent_settled", { aborted: false }); + expect(current().attentionId).toBe(completed); + idle = true; + await emit("ui_prompt_start", { title: "Confirm an idle command" }); + expect(current().status).toBe("approval"); + await emit("ui_prompt_end"); + expect(current().status).toBe("idle"); + idle = false; + await emit("before_agent_start", { prompt: "Stop me" }); + await emit("agent_start"); + await emit("agent_settled", { aborted: true }); + expect(current()).toMatchObject({ status: "interrupted", detail: "Turn stopped" }); + await emit("before_agent_start", { prompt: "Fail me" }); + await emit("agent_start"); + await emit("turn_end", { outcome: "error" }); + await emit("agent_settled", { aborted: false }); + expect(current().status).toBe("interrupted"); + await emit("session_shutdown"); + expect(current().status).toBe("ended"); + session = "two"; + idle = true; + await emit("session_start"); + await emit("ui_prompt_start", { title: "Choose a setting" }); + await emit("ui_prompt_end"); + expect(current().status).toBe("idle"); + expect(store.snapshot()).toHaveLength(2); + const serialized = JSON.stringify(payloads); + expect(serialized).not.toContain("private"); + expect(serialized).not.toContain("args"); + expect(serialized).not.toContain("messages"); +}); + +it("does not attach handlers outside a host-owned terminal and fails open when the host is unavailable", async () => { + const handlers = new Map(); + const api = { on: (name: string, handler: Function) => { handlers.set(name, handler); } }; + vi.stubEnv("AOW_AGENT_TOKEN", ""); + piExtension(api); + expect(handlers.size).toBe(0); + vi.stubEnv("AOW_AGENT_ENDPOINT", "http://127.0.0.1:1234/agent-event"); + vi.stubEnv("AOW_AGENT_TOKEN", "test-token"); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Host stopped"))); + piExtension(api); + const ctx = { cwd: "/project", sessionManager: { getSessionId: () => "one" }, isIdle: () => false }; + await expect(handlers.get("session_start")!({}, ctx)).resolves.toBeUndefined(); + await expect(handlers.get("before_agent_start")!({ prompt: "Keep working" }, ctx)).resolves.toBeUndefined(); +}); diff --git a/packages/terminal-host/src/pi-extension.ts b/packages/terminal-host/src/pi-extension.ts new file mode 100644 index 0000000..f4091c2 --- /dev/null +++ b/packages/terminal-host/src/pi-extension.ts @@ -0,0 +1,63 @@ +import { randomUUID } from "node:crypto"; +import { reportAgentInput } from "./agent-events.js"; + +// Only the observational subset is needed; loading this extension requires no Pi dependency. +interface PiContext { + cwd: string; + sessionManager: { getSessionId(): string }; + isIdle(): boolean; +} +interface PiEvent { + prompt?: string; + toolName?: string; + toolCallId?: string; + title?: string; + outcome?: string; + aborted?: boolean; +} +interface PiAPI { + on(event: string, handler: (event: PiEvent, context: PiContext) => Promise): unknown; +} + +export default function piExtension(pi: PiAPI): void { + if (!process.env.AOW_AGENT_ENDPOINT || !process.env.AOW_AGENT_TOKEN) return; + let turn = ""; + let outcome = "completed"; + let queue = Promise.resolve(); + const report = (event: string, ctx: PiContext, fields: object = {}) => { + const input = { session_id: ctx.sessionManager.getSessionId(), hook_event_name: event, + turn_id: turn, cwd: ctx.cwd, ...fields }; + // UI prompt events can overlap tool events. Keep the authenticated ingress ordered. + queue = queue.then(() => reportAgentInput(input, "pi")).catch(() => {}); + return queue; + }; + pi.on("session_start", async (_event, ctx) => { + turn = ""; + await report("session_start", ctx); + }); + pi.on("before_agent_start", async (event, ctx) => { + turn = randomUUID(); + await report("before_agent_start", ctx, { prompt: event.prompt }); + }); + pi.on("agent_start", async (_event, ctx) => { + outcome = "completed"; + await report("agent_start", ctx); + }); + for (const name of ["tool_execution_start", "tool_execution_end"]) { + pi.on(name, async (event, ctx) => { + await report(name, ctx, { tool_name: event.toolName, tool_use_id: event.toolCallId }); + }); + } + pi.on("ui_prompt_start", async (event, ctx) => { + await report("ui_prompt_start", ctx, { tool_name: "user-input", tool_input: { description: event.title } }); + }); + pi.on("ui_prompt_end", async (_event, ctx) => { + await report("ui_prompt_end", ctx, { tool_name: "user-input", is_idle: ctx.isIdle() }); + }); + pi.on("turn_end", async (event) => { outcome = event.outcome ?? "completed"; }); + // agent_end can be followed by retries, compaction or queued work in Pi 1.1+. + pi.on("agent_settled", async (event, ctx) => { + await report("agent_settled", ctx, { is_interrupt: event.aborted === true || outcome !== "completed" }); + }); + pi.on("session_shutdown", async (_event, ctx) => { await report("session_shutdown", ctx); }); +} diff --git a/packages/terminal-host/src/pi-hooks.test.ts b/packages/terminal-host/src/pi-hooks.test.ts new file mode 100644 index 0000000..42a98cf --- /dev/null +++ b/packages/terminal-host/src/pi-hooks.test.ts @@ -0,0 +1,67 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { installPiExtension, uninstallPiExtension } from "./pi-hooks.js"; + +let directory = ""; +afterEach(async () => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); if (directory) await rm(directory, { recursive: true, force: true }); }); + +it("installs only its extension, preserves settings and other extensions, and reinstalls idempotently", async () => { + directory = await mkdtemp(join(tmpdir(), "aow-pi-")); + vi.stubEnv("PI_CODING_AGENT_DIR", directory); + await mkdir(join(directory, "extensions")); + const settings = '{ "model": "user-model", "extensions": ["./custom.ts"] }\n'; + await writeFile(join(directory, "settings.json"), settings); + await writeFile(join(directory, "extensions", "custom.ts"), "export default () => {};\n"); + // The npm bin symlink must not determine the extension's package location. + vi.stubGlobal("process", { ...process, argv: [process.execPath, "/some/npm/bin/aow"] }); + const path = await installPiExtension(); + const source = await readFile(path, "utf8"); + expect(source).toContain(JSON.stringify(fileURLToPath(new URL("./pi-extension.js", import.meta.url)))); + await installPiExtension(); + expect(await readFile(path, "utf8")).toBe(source); + expect(await readdir(join(directory, "extensions"))).toEqual(["agentonweb.ts", "custom.ts"]); + await uninstallPiExtension(); + await uninstallPiExtension(); + expect(await readFile(join(directory, "settings.json"), "utf8")).toBe(settings); + expect(await readFile(join(directory, "extensions", "custom.ts"), "utf8")).toBe("export default () => {};\n"); +}); + +it("refuses to replace or remove an unrelated file with the same name", async () => { + directory = await mkdtemp(join(tmpdir(), "aow-pi-collision-")); + await mkdir(join(directory, "extensions")); + const path = join(directory, "extensions", "agentonweb.ts"); + await writeFile(path, "export default () => {};\n"); + await expect(installPiExtension(directory)).rejects.toThrow("unrelated Pi extension"); + await expect(uninstallPiExtension(directory)).rejects.toThrow("unrelated Pi extension"); + expect(await readFile(path, "utf8")).toBe("export default () => {};\n"); +}); + +it("backs up an older integration when its package path changes", async () => { + directory = await mkdtemp(join(tmpdir(), "aow-pi-upgrade-")); + await mkdir(join(directory, "extensions")); + const path = join(directory, "extensions", "agentonweb.ts"); + const previous = '// AgentOnWeb session status\nexport { default } from "/old/package/pi-extension.js";\n'; + await writeFile(path, previous); + await installPiExtension(directory); + const backup = (await readdir(join(directory, "extensions"))).find(name => name.endsWith(".bak"))!; + expect(await readFile(join(directory, "extensions", backup), "utf8")).toBe(previous); + await uninstallPiExtension(directory); + expect(await readdir(join(directory, "extensions"))).toEqual([backup]); +}); + +it("does not create an agent directory when uninstalling an absent extension", async () => { + directory = await mkdtemp(join(tmpdir(), "aow-pi-absent-")); + await uninstallPiExtension(join(directory, "absent")); + expect(await readdir(directory)).toEqual([]); +}); + +it("expands the agent directory's tilde exactly as Pi does", async () => { + directory = await mkdtemp(join(tmpdir(), "aow-pi-tilde-")); + vi.stubEnv("PI_CODING_AGENT_DIR", "~/" + relative(homedir(), directory)); + expect(await installPiExtension()).toBe(join(directory, "extensions", "agentonweb.ts")); + await uninstallPiExtension(); + expect(await readdir(join(directory, "extensions"))).toEqual([]); +}); diff --git a/packages/terminal-host/src/pi-hooks.ts b/packages/terminal-host/src/pi-hooks.ts new file mode 100644 index 0000000..fa9bfee --- /dev/null +++ b/packages/terminal-host/src/pi-hooks.ts @@ -0,0 +1,39 @@ +import { mkdir, readFile, writeFile, rename, copyFile, unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const marker = "// AgentOnWeb session status\n"; +const directory = () => { + const path = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"); + return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path; +}; + +async function update(configDirectory: string, install: boolean): Promise { + const path = join(configDirectory, "extensions", "agentonweb.ts"); + let source: string | undefined; + try { source = await readFile(path, "utf8"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (source !== undefined && !source.startsWith(marker)) throw new Error(`Refusing to overwrite an unrelated Pi extension: ${path}`); + if (!install) { + if (source !== undefined) await unlink(path); + return path; + } + const entry = fileURLToPath(new URL("./pi-extension.js", import.meta.url)); + const updated = `${marker}export { default } from ${JSON.stringify(entry)};\n`; + if (source === updated) return path; + await mkdir(dirname(path), { recursive: true }); + if (source !== undefined) await copyFile(path, `${path}.agentonweb-${Date.now()}.bak`); + const temp = `${path}.agentonweb.tmp`; + await writeFile(temp, updated, { mode: 0o600 }); + await rename(temp, path); + return path; +} + +export async function installPiExtension(configDirectory = directory()): Promise { + return update(configDirectory, true); +} + +export async function uninstallPiExtension(configDirectory = directory()): Promise { + await update(configDirectory, false); +} diff --git a/packages/terminal-host/src/pi-sessions.test.ts b/packages/terminal-host/src/pi-sessions.test.ts new file mode 100644 index 0000000..ea259e3 --- /dev/null +++ b/packages/terminal-host/src/pi-sessions.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vitest"; +import { AgentSessions } from "./agent-sessions.js"; + +it("isolates Pi from other agents and rejects stale, late and non-native completion events", () => { + const store = new AgentSessions(); + const emit = (hook_event_name: string, fields = {}) => store.ingest("terminal", { + agent: "pi", session_id: "same", hook_event_name, ...fields, + }); + const current = () => store.snapshot().find(item => item.agent === "Pi")!; + expect(emit("agent_settled")).toBe(false); + for (const agent of ["codex", "claude-code", "pi"]) store.ingest("terminal", { + agent, session_id: "same", hook_event_name: agent === "pi" ? "session_start" : "SessionStart", + }); + expect(store.snapshot()).toHaveLength(3); + emit("before_agent_start", { turn_id: "old", prompt: "First task" }); + emit("agent_settled", { turn_id: "old" }); + const attention = current().attentionId; + emit("before_agent_start", { turn_id: "new", prompt: "Second task" }); + expect(emit("agent_settled", { turn_id: "old" })).toBe(false); + expect(current().status).toBe("running"); + for (const name of ["Stop", "TurnComplete", "agent_end", "__proto__", "constructor"]) expect(emit(name)).toBe(false); + emit("agent_settled", { turn_id: "new" }); + expect(current().attentionId).not.toBe(attention); + emit("session_shutdown", { turn_id: "new" }); + expect(emit("agent_settled", { turn_id: "new" })).toBe(false); + emit("session_start"); + expect(current().status).toBe("idle"); + store.removeTerminal("terminal"); + expect(store.snapshot()).toEqual([]); +}); diff --git a/packages/terminal-host/src/pi.integration.test.ts b/packages/terminal-host/src/pi.integration.test.ts new file mode 100644 index 0000000..b0ab97c --- /dev/null +++ b/packages/terminal-host/src/pi.integration.test.ts @@ -0,0 +1,122 @@ +import { expect, it } from "vitest"; +import { spawn, spawnSync } from "node:child_process"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { AgentSessions } from "./agent-sessions.js"; + +const piBin = process.env.AOW_TEST_PI_BIN || "pi"; +const available = spawnSync(piBin, ["--version"], { encoding: "utf8" }).status === 0; + +it.skipIf(!available)("loads the installed extension in native Pi and observes tools, input, abort, error, new, resumed and forked sessions", async () => { + const directory = await mkdtemp(join(tmpdir(), "aow-native-pi-")); + const store = new AgentSessions(); + const events: any[] = []; + let requests = 0; + const server = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + const data = JSON.parse(body); + if (request.url === "/agent-event") { + expect(request.headers.authorization).toBe("Bearer fixture-token"); + events.push(data); + response.writeHead(store.ingest("native-terminal", data) ? 204 : 400).end(); + return; + } + requests++; + const user = data.messages.filter((message: any) => message.role === "user").at(-1); + const prompt = JSON.stringify(user?.content); + if (prompt.includes("Provider error")) { response.writeHead(400).end('{"error":{"message":"Fixture failure"}}'); return; } + response.writeHead(200, { "content-type": "text/event-stream" }); + const send = (delta: object, finish_reason: string | null = null) => response.write(`data: ${JSON.stringify({ + id: "fixture", object: "chat.completion.chunk", created: 1, model: "fixture", + choices: [{ index: 0, delta, finish_reason }], + })}\n\n`); + send({ role: "assistant", content: "" }); + if (prompt.includes("Abort task")) return; // Keep streaming until Pi's native abort closes the request. + if (data.messages.at(-1)?.role !== "tool") { + send({ tool_calls: [{ index: 0, id: "fixture-tool", type: "function", + function: { name: "read", arguments: JSON.stringify({ path: join(directory, "fixture.txt") }) } }] }, "tool_calls"); + } else send({ content: "Fixture complete" }, "stop"); + response.end("data: [DONE]\n\n"); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + let child: ReturnType | undefined; + try { + await writeFile(join(directory, "models.json"), JSON.stringify({ providers: { "aow-fixture": { + api: "openai-completions", baseUrl: origin + "/v1", apiKey: "fixture", + models: [{ id: "fixture", contextWindow: 8192, maxTokens: 128 }], + } } })); + await writeFile(join(directory, "settings.json"), '{"retry":{"enabled":false}}\n'); + await writeFile(join(directory, "fixture.txt"), "Private tool result\n"); + const command = join(directory, "confirm.ts"); + await writeFile(command, 'export default pi => { pi.registerCommand("aow-check", { description: "Local verification", handler: async (_args, ctx) => { await ctx.ui.confirm("Verify input", "Continue?"); } }); };\n'); + const env = { ...process.env, PI_CODING_AGENT_DIR: directory, PI_TELEMETRY: "0", AOW_AGENT_ENDPOINT: origin + "/agent-event", AOW_AGENT_TOKEN: "fixture-token" }; + const install = spawnSync(process.execPath, [resolve("packages/terminal-host/lib/cli.js"), "pi-extension", "install"], { env, encoding: "utf8" }); + expect(install.status, install.stderr).toBe(0); + child = spawn(piBin, ["--mode", "rpc", "--offline", "--no-mcp", "--no-skills", "--no-prompt-templates", "--no-context-files", "--no-approve", "--provider", "aow-fixture", "--model", "fixture", "-e", command], { cwd: directory, env, stdio: ["pipe", "pipe", "pipe"] }); + const exited = once(child, "exit"); + let errors = ""; + child.stderr!.on("data", chunk => { errors += String(chunk); }); + const frames: any[] = []; + createInterface({ input: child.stdout! }).on("line", line => { try { frames.push(JSON.parse(line)); } catch { /* Only RPC records matter. */ } }); + const send = (value: object) => child!.stdin!.write(JSON.stringify(value) + "\n"); + const current = () => store.snapshot().find(item => item.status !== "ended"); + await expect.poll(() => current()?.status, { timeout: 10000 }).toBe("idle"); + send({ id: "first", type: "prompt", message: "Read the fixture" }); + await expect.poll(() => current()?.status, { timeout: 10000 }).toBe("completed"); + expect(events.some(event => event.hook_event_name === "tool_execution_end")).toBe(true); + expect(events.some(event => event.hook_event_name === "agent_settled")).toBe(true); + const firstSession = current()!.id; + send({ id: "state", type: "get_state" }); + await expect.poll(() => frames.some(frame => frame.id === "state" && frame.success)).toBe(true); + const sessionPath = frames.find(frame => frame.id === "state").data.sessionFile; + expect(sessionPath).toEqual(expect.any(String)); + send({ id: "input", type: "prompt", message: "/aow-check" }); + await expect.poll(() => current()?.status).toBe("approval"); + await expect.poll(() => frames.some(frame => frame.type === "extension_ui_request" && frame.method === "confirm")).toBe(true); + const ui = frames.find(frame => frame.type === "extension_ui_request" && frame.method === "confirm"); + send({ type: "extension_ui_response", id: ui.id, confirmed: true }); + await expect.poll(() => current()?.status).toBe("idle"); + const calls = requests; + send({ id: "abort-prompt", type: "prompt", message: "Abort task" }); + await expect.poll(() => requests > calls).toBe(true); + send({ id: "abort", type: "abort" }); + await expect.poll(() => current()?.status).toBe("interrupted"); + send({ id: "error", type: "prompt", message: "Provider error" }); + await expect.poll(() => current()?.title).toBe("Provider error"); + await expect.poll(() => current()?.status).toBe("interrupted"); + send({ id: "new", type: "new_session" }); + await expect.poll(() => current()?.status).toBe("idle"); + expect(current()!.id).not.toBe(firstSession); + expect(store.snapshot().find(item => item.id === firstSession)?.status).toBe("ended"); + send({ id: "resume", type: "switch_session", sessionPath }); + await expect.poll(() => current()?.id).toBe(firstSession); + expect(current()?.status).toBe("idle"); + send({ id: "fork", type: "clone" }); + await expect.poll(() => frames.some(frame => frame.id === "fork" && frame.success)).toBe(true); + await expect.poll(() => current()?.id !== firstSession).toBe(true); + expect(current()?.status).toBe("idle"); + child.stdin!.end(); + expect((await exited)[0], errors).toBe(0); + expect(store.snapshot().every(item => item.status === "ended")).toBe(true); + expect(errors).toBe(""); + expect(JSON.stringify(events)).not.toContain("Private tool result"); + expect(JSON.stringify(events)).not.toContain("Fixture complete"); + expect(await readFile(join(directory, "settings.json"), "utf8")).toBe('{"retry":{"enabled":false}}\n'); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; + } + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); + } +}, 30000); diff --git a/packages/terminal-host/src/setup.test.ts b/packages/terminal-host/src/setup.test.ts index a9454d8..260eef2 100644 --- a/packages/terminal-host/src/setup.test.ts +++ b/packages/terminal-host/src/setup.test.ts @@ -2,11 +2,13 @@ import { afterEach, expect, it, vi } from 'vitest'; import { setup, uninstall } from './setup.js'; import { installCodexHooks, uninstallCodexHooks } from './codex-hooks.js'; import { installClaudeHooks, uninstallClaudeHooks } from './claude-hooks.js'; +import { installPiExtension, uninstallPiExtension } from './pi-hooks.js'; import { uninstallNativeBridge } from './native-bridge.js'; import { serviceCommand } from './service.js'; vi.mock('./codex-hooks.js', () => ({ uninstallCodexHooks: vi.fn(), installCodexHooks: vi.fn() })); vi.mock('./claude-hooks.js', () => ({ uninstallClaudeHooks: vi.fn(), installClaudeHooks: vi.fn() })); +vi.mock('./pi-hooks.js', () => ({ uninstallPiExtension: vi.fn(), installPiExtension: vi.fn() })); vi.mock('./native-bridge.js', () => ({ uninstallNativeBridge: vi.fn(), installNativeBridge: vi.fn(), extensionOrigin: vi.fn(), STORE_EXTENSION_ID: 'test' })); vi.mock('./service.js', () => ({ serviceCommand: vi.fn(), serviceDirectory: '/unused' })); afterEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); vi.unstubAllGlobals(); }); @@ -17,6 +19,7 @@ it('rejects a missing CLI before modifying user integrations', async () => { await expect(setup([])).rejects.toThrow('Reinstall @agentonweb/terminal-host'); expect(installCodexHooks).not.toHaveBeenCalled(); expect(installClaudeHooks).not.toHaveBeenCalled(); + expect(installPiExtension).not.toHaveBeenCalled(); expect(serviceCommand).not.toHaveBeenCalled(); }); @@ -28,6 +31,7 @@ it('removes the native bridge and service even if Codex configuration is malform await expect(uninstall()).rejects.toMatchObject({ errors: [failure] }); expect(uninstallNativeBridge).toHaveBeenCalledOnce(); expect(uninstallClaudeHooks).toHaveBeenCalledOnce(); + expect(uninstallPiExtension).toHaveBeenCalledOnce(); expect(serviceCommand).toHaveBeenCalledWith(['uninstall']); expect(log).not.toHaveBeenCalled(); }); @@ -38,6 +42,18 @@ it('continues all cleanup steps when Claude Code settings are malformed', async vi.mocked(uninstallClaudeHooks).mockRejectedValue(failure); await expect(uninstall()).rejects.toMatchObject({ errors: [failure] }); expect(uninstallCodexHooks).toHaveBeenCalledOnce(); + expect(uninstallPiExtension).toHaveBeenCalledOnce(); + expect(uninstallNativeBridge).toHaveBeenCalledOnce(); + expect(serviceCommand).toHaveBeenCalledWith(['uninstall']); +}); + +it('continues cleanup when the Pi extension path belongs to another file', async () => { + vi.stubGlobal('process', { ...process, platform: 'darwin', getuid: () => 501 }); + const failure = new Error('Unrelated Pi extension'); + vi.mocked(uninstallPiExtension).mockRejectedValue(failure); + await expect(uninstall()).rejects.toMatchObject({ errors: [failure] }); + expect(uninstallCodexHooks).toHaveBeenCalledOnce(); + expect(uninstallClaudeHooks).toHaveBeenCalledOnce(); expect(uninstallNativeBridge).toHaveBeenCalledOnce(); expect(serviceCommand).toHaveBeenCalledWith(['uninstall']); }); diff --git a/packages/terminal-host/src/setup.ts b/packages/terminal-host/src/setup.ts index cef8a5b..83aa7a3 100644 --- a/packages/terminal-host/src/setup.ts +++ b/packages/terminal-host/src/setup.ts @@ -4,6 +4,7 @@ import { constants } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { installCodexHooks, uninstallCodexHooks } from './codex-hooks.js'; import { installClaudeHooks, uninstallClaudeHooks } from './claude-hooks.js'; +import { installPiExtension, uninstallPiExtension } from './pi-hooks.js'; import { installNativeBridge, uninstallNativeBridge, extensionOrigin, STORE_EXTENSION_ID } from './native-bridge.js'; import { serviceCommand, serviceDirectory } from './service.js'; import { cleanup } from './cleanup.js'; @@ -30,6 +31,8 @@ export async function setup(args: string[]) { console.log('Codex hooks installed; existing settings preserved. Any skipped notify configuration is reported above.'); await installClaudeHooks(); console.log('Claude Code hooks installed; existing settings preserved. Review them in /hooks and start a new Claude Code session.'); + await installPiExtension(); + console.log('Pi extension installed; existing settings preserved. Start Pi 1.1+ in an AgentOnWeb terminal, or use /reload in Pi.'); await installNativeBridge(entry, ids); await serviceCommand(['install', '--no-open']); let ready = false; @@ -59,6 +62,7 @@ export async function uninstall(): Promise { await cleanup([ () => uninstallCodexHooks(), () => uninstallClaudeHooks(), + () => uninstallPiExtension(), () => uninstallNativeBridge(), async () => { if (process.platform === 'darwin') await serviceCommand(['uninstall']); }, ], 'AgentOnWeb uninstall is incomplete. All integrations were attempted; fix the reported errors and rerun aow uninstall before removing the npm package.'); diff --git a/scripts/build-terminal-host.mjs b/scripts/build-terminal-host.mjs index 182d301..77c66b4 100644 --- a/scripts/build-terminal-host.mjs +++ b/scripts/build-terminal-host.mjs @@ -5,5 +5,6 @@ const root = new URL('../packages/terminal-host/', import.meta.url); await mkdir(new URL('lib/', root), { recursive: true }); await build({ entryPoints: [new URL('src/cli.ts', root).pathname], bundle: true, platform: 'node', format: 'esm', packages: 'bundle', external: ['node-pty', '@xterm/headless', '@xterm/addon-serialize', 'ws'], outfile: new URL('lib/cli.js', root).pathname }); await build({ entryPoints: [new URL('src/agent-hook.ts', root).pathname], bundle: true, platform: 'node', format: 'esm', outfile: new URL('lib/agent-hook.js', root).pathname }); +await build({ entryPoints: [new URL('src/pi-extension.ts', root).pathname], bundle: true, platform: 'node', format: 'esm', outfile: new URL('lib/pi-extension.js', root).pathname }); await cp(new URL('../apps/terminal-surface/dist/', import.meta.url), new URL('lib/public/', root), { recursive: true }); await chmod(new URL('lib/cli.js', root), 0o755); diff --git a/scripts/verify-terminal-archive.mjs b/scripts/verify-terminal-archive.mjs index 8067b1f..0ffccd0 100644 --- a/scripts/verify-terminal-archive.mjs +++ b/scripts/verify-terminal-archive.mjs @@ -19,7 +19,7 @@ export async function verifyTerminalArchive(archive, root) { const listing = (await execute('tar', ['-tf', archive])).stdout.trim().split('\n').sort(); assert.deepEqual(listing, [ 'package/LICENSE', 'package/README.md', 'package/package.json', 'package/postinstall.mjs', 'package/fix-pty-permissions.mjs', - 'package/lib/cli.js', 'package/lib/agent-hook.js', 'package/lib/public/index.html', 'package/lib/public/main.js', 'package/lib/public/main.css', + 'package/lib/cli.js', 'package/lib/agent-hook.js', 'package/lib/pi-extension.js', 'package/lib/public/index.html', 'package/lib/public/main.js', 'package/lib/public/main.css', ].sort(), 'Unexpected terminal archive contents'); await execute('npm', ['install', '--prefix', directory, '--omit=dev', '--no-package-lock', '--no-audit', '--no-fund', archive], { timeout: 120000, maxBuffer: 4 * 1024 * 1024 }); const installed = resolve(directory, 'node_modules/@agentonweb/terminal-host'); @@ -53,6 +53,18 @@ export async function verifyTerminalArchive(archive, root) { assert.equal(await readFile(settingsPath, 'utf8'), configured); await execute(process.execPath, [bin, 'claude-hooks', 'uninstall'], { cwd: directory, env: hookEnv }); assert.deepEqual(JSON.parse(await readFile(settingsPath, 'utf8')), settings); + const piDirectory = resolve(home, '.pi/agent'); + const piEnv = { ...process.env, PI_CODING_AGENT_DIR: piDirectory }; + await execute(process.execPath, [bin, 'pi-extension', 'install'], { cwd: directory, env: piEnv }); + const piPath = resolve(piDirectory, 'extensions/agentonweb.ts'); + const piSource = await readFile(piPath, 'utf8'); + assert.ok(piSource.includes(JSON.stringify(await realpath(resolve(installed, 'lib/pi-extension.js'))))); + const extension = (await import(resolve(installed, 'lib/pi-extension.js'))).default; + assert.equal(typeof extension, 'function'); + await execute(process.execPath, [bin, 'pi-extension', 'install'], { cwd: directory, env: piEnv }); + assert.equal(await readFile(piPath, 'utf8'), piSource); + await execute(process.execPath, [bin, 'pi-extension', 'uninstall'], { cwd: directory, env: piEnv }); + await assert.rejects(readFile(piPath), { code: 'ENOENT' }); // The smoke test does not change service state or user startup files. host = spawn(process.execPath, [entry, 'terminal'], { cwd: directory, env: { ...process.env, HOME: home, SHELL: '/bin/sh', ENV: undefined, BASH_ENV: undefined, ZDOTDIR: undefined }, stdio: ['ignore', 'pipe', 'pipe'], @@ -103,7 +115,7 @@ export async function verifyTerminalArchive(archive, root) { } }); await waitFor(() => { if (socketError) throw socketError; return text.includes('AOW_PACKED_SHELL_OK'); }); - console.log('Packed terminal host passed: isolated production install, Claude hook install/uninstall, lightweight reporter, browser assets and real shell input/output.'); + console.log('Packed terminal host passed: isolated production install, Claude/Pi integration install/uninstall, lightweight reporters, browser assets and real shell input/output.'); } finally { socket?.terminate(); if (host && host.exitCode === null && host.signalCode === null) {