From 1bd1b9246a69f5789b20e6b1ddafc207ff3c8179 Mon Sep 17 00:00:00 2001 From: Foad Kesheh Date: Sat, 8 Aug 2026 09:48:05 -0300 Subject: [PATCH 1/3] feat(pi): add first-class ftown integration Make Pi a native harness with launch, resume, lifecycle hooks, live usage, loops, and workflows. Bundle an authenticated extension for mail, session management, inspection, and structured loop control while excluding raw terminal and command surfaces. Harden transcript containment, token parsing, revive reporting, and stale bridge credential fallback. Include the extension contract, package assets, UI support, and regression coverage. --- README.md | 14 +- bridge/package-lock.json | 4 +- bridge/package.json | 3 +- bridge/pi-extension/API.md | 64 ++ bridge/pi-extension/ftown.js | 740 ++++++++++++++++++ bridge/skills/ftown/references/loops.md | 2 +- .../skills/ftown/references/orchestrator.md | 4 +- bridge/skills/ftown/references/sessions.md | 12 +- bridge/skills/ftown/references/workflows.md | 4 +- bridge/src/agent-commands.test.ts | 43 +- bridge/src/agent-commands.ts | 4 +- bridge/src/create-ftown-session.test.ts | 66 ++ bridge/src/create-ftown-session.ts | 28 +- bridge/src/ftown-sessions-cli.ts | 6 +- bridge/src/harness-registry.test.ts | 24 +- bridge/src/harness-registry.ts | 52 +- bridge/src/hook-usage.test.ts | 51 ++ bridge/src/hook-usage.ts | 72 ++ bridge/src/index.ts | 28 +- bridge/src/local-api-server.test.ts | 16 +- bridge/src/local-api-server.ts | 22 +- bridge/src/pi-extension-installer.test.ts | 21 + bridge/src/pi-extension-installer.ts | 12 + bridge/src/pi-extension.test.ts | 522 ++++++++++++ bridge/src/session-ids.test.ts | 43 + bridge/src/session-ids.ts | 40 +- bridge/src/types.ts | 4 + bridge/src/usage-collector.test.ts | 162 ++++ bridge/src/usage-collector.ts | 172 +++- bridge/src/wire-types.ts | 2 +- bridge/src/workflow-runner-cli.ts | 2 +- e2e/helpers/loops.ts | 2 +- ui/src/app/page.tsx | 23 +- ui/src/components/LoopFormModal.tsx | 1 + ui/src/components/NewSessionModal.tsx | 33 +- ui/src/components/SessionList.tsx | 8 +- ui/src/hooks/useSessions.ts | 6 +- ui/src/lib/agent-commands.test.ts | 12 + ui/src/lib/agent-commands.ts | 8 + ui/src/types.ts | 8 +- 40 files changed, 2240 insertions(+), 100 deletions(-) create mode 100644 bridge/pi-extension/API.md create mode 100644 bridge/pi-extension/ftown.js create mode 100644 bridge/src/hook-usage.test.ts create mode 100644 bridge/src/hook-usage.ts create mode 100644 bridge/src/pi-extension-installer.test.ts create mode 100644 bridge/src/pi-extension-installer.ts create mode 100644 bridge/src/pi-extension.test.ts create mode 100644 bridge/src/session-ids.test.ts create mode 100644 ui/src/lib/agent-commands.test.ts diff --git a/README.md b/README.md index a4dbcd3..640359f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ftown -A remote CLI agent orchestrator that lets you manage and stream **Claude Code** and **Cursor Agent** sessions through a web dashboard. Terminal streaming climbs a transport ladder — local loopback, then WebRTC P2P, then Centrifugo — so output stays on your machine or network whenever possible, and automatically upgrades back to a direct connection once it can. Recurring agent work runs as scheduled **loops**: cron or interval triggers that spawn full sessions with guardrails, instead of a hand-rolled polling script. +A remote CLI agent orchestrator that lets you manage and stream **Claude Code, Cursor Agent, Codex, Grok, Pi, Kimi Code, opencode,** and shell sessions through a web dashboard. Terminal streaming climbs a transport ladder — local loopback, then WebRTC P2P, then Centrifugo — so output stays on your machine or network whenever possible, and automatically upgrades back to a direct connection once it can. Recurring agent work runs as scheduled **loops**: cron or interval triggers that spawn full sessions with guardrails, instead of a hand-rolled polling script. ## Demo @@ -51,8 +51,12 @@ https://github.com/user-attachments/assets/e9c1ce70-70b0-4ba0-81d8-080d4eeef445 ### Everything else -- **Claude Code** and **Cursor Agent** (`agent`) interactive sessions with resume support -- Hook forwarding to the dashboard (Claude `~/.claude/settings.json`, Cursor `~/.cursor/hooks.json`) +- Seven coding-agent CLIs plus raw shells as full interactive sessions +- Parent/child agent trees, durable cross-session mail, and session reparenting +- Native resume support for Claude, Cursor, Codex, and Pi; workdir-based continuation for Kimi Code +- Live per-session token/model usage for harnesses with structured native session logs +- Hook forwarding to the dashboard (Claude/Codex settings, Cursor hooks, and ftown's bundled Pi extension) +- Native Pi tools for session discovery/control, durable ftown mail, token usage, terminal inspection, archives, and loop management - Multiple concurrent sessions with session management - Multi-bridge support (connect multiple machines) - Mobile-optimized responsive UI @@ -75,7 +79,7 @@ ftown-sessions loop create \ ``` - **Schedules** — interval (`--every 30s|5m|2h|1d`) or cron with timezone (`--cron "0 9 * * 1-5" --tz America/New_York`); create via the dashboard's loop modal or the CLI -- **Harness choice** — claude, cursor, codex, opencode, or plain shell, each with its own configurable workdir and model +- **Harness choice** — claude, cursor, codex, grok, pi, kimi-code, opencode, or plain shell, each with its own configurable workdir and model - **Guardrails** — `--preflight ` (a non-zero exit skips the run; its stdout is injected into the prompt via `{{preflight}}`), `--postflight ` (receives run status, session id, and output), `--max-runtime` to force-stop a run - **Overlap & retention** — overlapping runs are skipped by default (`--allow-overlap` to permit them); retention keeps only the newest N runs - **Lifecycle** — pause/resume, fire a one-shot run manually, edit a loop live, and see run history with status dots (running/done/error/skipped/paused) plus next-due time in the dashboard @@ -85,7 +89,7 @@ ftown-sessions loop create \ - Node.js 22+ - PostgreSQL database (e.g., [Neon](https://neon.tech)) - [Centrifugo](https://centrifugal.dev) v5 server -- On bridge machines: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and/or [Cursor CLI](https://cursor.com/docs/cli/overview) (`curl https://cursor.com/install -fsS | bash`, then `agent login`) +- On bridge machines: install and authenticate whichever agent CLIs you plan to run. For Pi: `npm install -g @mariozechner/pi-coding-agent`, then run `pi` and `/login` (or provide a supported provider API key). ## Quick Start diff --git a/bridge/package-lock.json b/bridge/package-lock.json index 310c19e..ae2f9b7 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -1,12 +1,12 @@ { "name": "ftown-bridge", - "version": "0.19.7", + "version": "0.19.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ftown-bridge", - "version": "0.19.7", + "version": "0.19.8", "license": "MIT", "dependencies": { "@xterm/addon-serialize": "^0.14.0", diff --git a/bridge/package.json b/bridge/package.json index f77fb33..2df0057 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -1,6 +1,6 @@ { "name": "ftown-bridge", - "version": "0.19.7", + "version": "0.19.8", "description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay", "type": "module", "main": "dist/index.js", @@ -16,6 +16,7 @@ "dist", "bin", "hooks", + "pi-extension", "skills" ], "scripts": { diff --git a/bridge/pi-extension/API.md b/bridge/pi-extension/API.md new file mode 100644 index 0000000..d46f471 --- /dev/null +++ b/bridge/pi-extension/API.md @@ -0,0 +1,64 @@ +# Pi extension API + +The bundled Pi extension exposes ftown's authenticated local bridge API as a small set of model-callable tools. It uses camel-case JSON, opaque string identifiers, plural resource names, and wrapped success objects. Failures are returned to Pi as a tool error with a sanitized `{ "error": string }` detail object. + +## Resource model + +| Resource | Identity | Relationships | Lifecycle | +| --- | --- | --- | --- | +| Session | Opaque UUID, or a unique exact name for lookup | Optional parent session; owns inbox, usage, screen, and log | Create, inspect, rename, reparent, stop, remove, revive | +| Mail message | Assigned by the session inbox | Sent from one session to another; optional thread | Send, peek/read, delivered | +| Loop | Opaque ID, or a unique exact name for lookup | Owns loop runs | Create, inspect, update, run, delete | + +All tools talk only to the local bridge selected by `FTOWN_HOOK_PORT` or `~/.ftown/bridge.json`. The extension supplies that bridge's bearer token and never sends credentials to the model. + +## Tool contract + +| Tool | Operation | Required input | Result | Mutation | +| --- | --- | --- | --- | --- | +| `ftown_mail` | `send` | `target`, `body` | Stored inbox message | Yes | +| `ftown_mail` | `read` | None | `{ messages }` for the current session | No | +| `ftown_sessions` | `list` | None | `{ sessions }` | No | +| `ftown_sessions` | `archive` | None | `{ archived }` tombstones | No | +| `ftown_sessions` | `get` | `target` | `{ session }` | No | +| `ftown_sessions` | `running` | `target` | `{ sessionId, running }` | No | +| `ftown_sessions` | `usage` | `target` | Session token/model usage | No | +| `ftown_sessions` | `screen` | `target` | Paginated terminal screen | No | +| `ftown_sessions` | `grep` | `target`, `pattern` | Paginated terminal-log matches | No | +| `ftown_session_create` | create | `shell`, `prompt` | Created session | Yes | +| `ftown_session_manage` | `stop` | `target` | Stop acknowledgement | Yes | +| `ftown_session_manage` | `rename` | `target`, `name` | Updated session | Yes | +| `ftown_session_manage` | `reparent` | `target`, `parent` | Updated session | Yes | +| `ftown_session_manage` | `remove` | `target` | Removal acknowledgement | Yes | +| `ftown_session_manage` | `revive` | `target` | Recreated session and resume state | Yes | +| `ftown_loops` | `list` | None | `{ loops }` | No | +| `ftown_loops` | `get` | `target` | `{ loop }` | No | +| `ftown_loops` | `create` | `name`, `task`, `schedule` | `{ loop }` | Yes | +| `ftown_loops` | `update` | `target` and changed fields | `{ loop }` | Yes | +| `ftown_loops` | `delete` | `target` | Removal acknowledgement | Yes | +| `ftown_loops` | `runs` | `target` | Loop run history | No | +| `ftown_loops` | `run_now` | `target` | Requested loop run | Yes | + +`target` accepts an opaque ID or a unique exact name. Mail additionally accepts `parent`. Reparenting with `parent: null` clears the parent. Session and loop creation intentionally accept structured fields only; arbitrary session commands, environment variables, and loop preflight/postflight shell commands are not part of the model-facing contract. + +The extension also registers `/ftown-mail read [--peek]`, `/ftown-mail send `, and `/ftown-sessions` for interactive use. + +## Authorization and safety + +The local bridge bearer token authorizes access to the current ftown user's bridge resources. Session name resolution is performed against that same authenticated bridge. The model can inspect a terminal screen or search its captured log, but it cannot inject raw terminal keystrokes, resize terminals, clear terminal history, or call hook/conversation-resolution internals through these tools. + +Mutation executions are deduplicated in the running extension by `(tool name, Pi tool-call ID)`, including concurrent retries. Failed attempts are not cached. This prevents a retried model tool call from duplicating mail, sessions, management actions, or loop runs; it is not a durable idempotency key across Pi process restarts. + +## Pagination and errors + +Inbox reads accept `limit` from 1 to 100. Screen and log operations accept zero-based `offset`; screen `limit` is 1 to 1,000, log `limit` is 1 to 1,000, and grep context is 0 to 10 lines. Session and loop collection endpoints currently return the bridge's complete local collection and inherit its unpaginated behavior. + +Transport failures, missing/ambiguous names, unavailable parent context, validation failures, and bridge `{ "error": string }` responses become Pi tool errors. No response body, token, stack trace, or upstream URL is exposed to the model. + +## Compatibility + +The tool schemas ship with `ftown-bridge` and follow its semantic version. Adding an optional property or operation is additive. Renaming/removing a tool, operation, required field, or result field is breaking. The underlying local HTTP API is deliberately hidden behind these tool contracts so bridge route changes do not require prompt changes. + +## Consumer walkthrough + +An agent can call `ftown_sessions.list`, use the returned session ID with `ftown_sessions.get` or `usage`, and then send context with `ftown_mail.send`. To delegate, it can create a child with `ftown_session_create`, retain the returned ID, inspect its output, and later rename, reparent, stop, remove, or revive it. For scheduled work, it can create or update a structured loop, request `run_now`, then inspect `runs` without leaving Pi. diff --git a/bridge/pi-extension/ftown.js b/bridge/pi-extension/ftown.js new file mode 100644 index 0000000..551f356 --- /dev/null +++ b/bridge/pi-extension/ftown.js @@ -0,0 +1,740 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +async function defaultReadBridgePointer() { + try { + const raw = await readFile(join(homedir(), '.ftown', 'bridge.json'), 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed.port !== 'number') return null; + return { + port: parsed.port, + token: typeof parsed.token === 'string' ? parsed.token : undefined, + }; + } catch { + return null; + } +} + +function endpoints(env, pointer) { + const candidates = []; + const envPort = Number.parseInt(env.FTOWN_HOOK_PORT ?? '', 10); + if (Number.isInteger(envPort) && envPort > 0) { + candidates.push({ port: envPort, token: env.FTOWN_HOOK_TOKEN }); + } + if ( + pointer?.port + && !candidates.some((candidate) => + candidate.port === pointer.port && candidate.token === pointer.token) + ) { + candidates.push(pointer); + } + return candidates; +} + +function headers(token, json = false, additional) { + const result = new Headers(additional); + if (json) result.set('content-type', 'application/json'); + if (token) result.set('authorization', `Bearer ${token}`); + return result; +} + +function sessionMetadata(ctx) { + return { + session_id: ctx.sessionManager.getSessionId(), + session_file: ctx.sessionManager.getSessionFile(), + cwd: ctx.sessionManager.getCwd(), + }; +} + +function formatMail(message) { + const sender = message.fromName + ? `${message.fromName} (${message.from ?? 'external'})` + : (message.from ?? 'external'); + return `[${message.type ?? 'message'} from ${sender}] ${message.body ?? ''}`; +} + +function collectBranchUsage(ctx) { + let entries; + try { + entries = ctx.sessionManager.getBranch(); + } catch { + return undefined; + } + if (!Array.isArray(entries)) return undefined; + const byModel = new Map(); + for (const entry of entries) { + const message = entry?.type === 'message' ? entry.message : undefined; + const usage = message?.role === 'assistant' ? message.usage : undefined; + if (!usage || !message.model) continue; + const model = message.provider ? `${message.provider}/${message.model}` : message.model; + const current = byModel.get(model) ?? { + model, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; + current.inputTokens += usage.input ?? 0; + current.outputTokens += usage.output ?? 0; + current.cacheReadTokens += usage.cacheRead ?? 0; + current.cacheWriteTokens += usage.cacheWrite ?? 0; + byModel.set(model, current); + } + if (byModel.size === 0) return undefined; + const perModel = [...byModel.values()]; + const sum = (key) => perModel.reduce((total, item) => total + item[key], 0); + const inputTokens = sum('inputTokens'); + const outputTokens = sum('outputTokens'); + const cacheReadTokens = sum('cacheReadTokens'); + const cacheWriteTokens = sum('cacheWriteTokens'); + return { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens, + models: perModel.map((item) => item.model), + perModel, + harness: 'pi', + }; +} + +/** Register ftown lifecycle forwarding and mail delivery on Pi's extension API. */ +export function registerFtownPiExtension(pi, options = {}) { + const env = options.env ?? process.env; + const fetchImpl = options.fetch ?? globalThis.fetch; + const readBridgePointer = options.readBridgePointer ?? defaultReadBridgePointer; + const ftownSessionId = env.FTOWN_SESSION_ID?.trim(); + const mutationResults = new Map(); + + async function executeOnce(toolName, toolCallId, operation) { + if (!toolCallId) return operation(); + const key = `${toolName}:${toolCallId}`; + const existing = mutationResults.get(key); + if (existing) return existing; + + const pending = Promise.resolve().then(operation); + mutationResults.set(key, pending); + if (mutationResults.size > 512) { + mutationResults.delete(mutationResults.keys().next().value); + } + try { + return await pending; + } catch (error) { + mutationResults.delete(key); + throw error; + } + } + + async function request(path, init = {}) { + const pointer = await readBridgePointer(); + let lastResponse = null; + for (const endpoint of endpoints(env, pointer)) { + try { + const response = await fetchImpl(`http://127.0.0.1:${endpoint.port}${path}`, { + ...init, + headers: headers(endpoint.token, init.body !== undefined, init.headers), + }); + if (response.ok) return response; + lastResponse = response; + } catch { + // A tmux-resurrected session may hold a stale port. Try bridge.json next. + } + } + return lastResponse; + } + + async function requestJson(path, init = {}) { + const response = await request(path, init); + if (!response) throw new Error('ftown bridge is unavailable'); + let payload = {}; + try { + payload = await response.json(); + } catch { + // Keep the public error sanitized when the bridge returns a non-JSON body. + } + if (!response.ok) { + const message = typeof payload?.error === 'string' ? payload.error : `ftown API error (${response.status})`; + throw new Error(message); + } + return payload; + } + + async function postHook(eventName, ctx, data = {}) { + if (!ftownSessionId) return; + await request('/hook', { + method: 'POST', + body: JSON.stringify({ + ftown_session_id: ftownSessionId, + ftown_session_source: 'env', + hook_event_name: eventName, + ...sessionMetadata(ctx), + ...data, + }), + }); + } + + async function drainMail() { + if (!ftownSessionId) return []; + const response = await request( + `/api/sessions/${encodeURIComponent(ftownSessionId)}/inbox?wait=0`, + { method: 'GET' }, + ); + if (!response) return []; + try { + const payload = await response.json(); + return Array.isArray(payload?.messages) ? payload.messages : []; + } catch { + return []; + } + } + + async function listSessions() { + const payload = await requestJson('/api/sessions', { method: 'GET' }); + return Array.isArray(payload?.sessions) ? payload.sessions : []; + } + + function resolveSession(sessions, target) { + const normalized = target?.trim(); + if (!normalized) throw new Error('A target session id or name is required'); + if (normalized === 'parent') { + const parent = env.FTOWN_PARENT_SESSION_ID?.trim(); + if (!parent) throw new Error('This Pi session has no ftown parent'); + return sessions.find((session) => session.id === parent) ?? { id: parent, name: parent }; + } + const byId = sessions.find((session) => session.id === normalized); + if (byId) return byId; + const byName = sessions.filter((session) => session.name === normalized); + if (byName.length === 1) return byName[0]; + if (byName.length > 1) throw new Error(`Multiple sessions are named "${normalized}"; use an id`); + throw new Error(`Session not found: ${normalized}`); + } + + async function runMail(params) { + if (params.operation === 'read') { + if (!ftownSessionId) throw new Error('FTOWN_SESSION_ID is unavailable'); + const query = new URLSearchParams({ wait: '0' }); + if (params.peek) query.set('peek', '1'); + if (params.all) query.set('all', '1'); + if (params.limit !== undefined) query.set('limit', String(params.limit)); + return requestJson( + `/api/sessions/${encodeURIComponent(ftownSessionId)}/inbox?${query.toString()}`, + { method: 'GET' }, + ); + } + + const sessions = await listSessions(); + const target = resolveSession(sessions, params.target); + const self = sessions.find((session) => session.id === ftownSessionId); + const body = { + body: params.body, + type: params.type ?? 'message', + from: ftownSessionId ?? 'external', + ...(self?.name ? { fromName: self.name } : {}), + ...(params.threadId ? { threadId: params.threadId } : {}), + }; + return requestJson(`/api/sessions/${encodeURIComponent(target.id)}/inbox`, { + method: 'POST', + body: JSON.stringify(body), + }); + } + + function toolResult(payload) { + return { + content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], + details: payload, + }; + } + + function toolError(error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: 'text', text: message }], + details: { error: message }, + isError: true, + }; + } + + pi.registerTool({ + name: 'ftown_mail', + label: 'ftown mail', + description: 'Send durable mail to another ftown session, or read this session inbox.', + parameters: { + anyOf: [ + { + type: 'object', + properties: { + operation: { const: 'send' }, + target: { type: 'string', description: 'Session id/name, or "parent".' }, + body: { type: 'string', minLength: 1 }, + type: { enum: ['message', 'task', 'result', 'escalation'] }, + threadId: { type: 'string' }, + }, + required: ['operation', 'target', 'body'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + operation: { const: 'read' }, + peek: { type: 'boolean', description: 'Do not mark messages delivered.' }, + all: { type: 'boolean', description: 'Include already-delivered messages.' }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + }, + required: ['operation'], + additionalProperties: false, + }, + ], + }, + async execute(toolCallId, params) { + try { + if (params.operation === 'send') { + return await executeOnce('ftown_mail', toolCallId, async () => + toolResult(await runMail(params))); + } + return toolResult(await runMail(params)); + } catch (error) { + return toolError(error); + } + }, + }); + + pi.registerCommand('ftown-mail', { + description: 'Read ftown mail, or send it with: /ftown-mail send ', + handler: async (args, ctx) => { + const input = args.trim(); + try { + const payload = input === '' || input === 'read' + ? await runMail({ operation: 'read' }) + : input.startsWith('read ') + ? await runMail({ operation: 'read', peek: input.split(/\s+/).includes('--peek') }) + : await (() => { + const match = input.match(/^send\s+(\S+)\s+([\s\S]+)$/); + if (!match) throw new Error('Usage: /ftown-mail read [--peek] | send '); + return runMail({ operation: 'send', target: match[1], body: match[2] }); + })(); + ctx.ui.notify(JSON.stringify(payload, null, 2), 'info'); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error'); + } + }, + }); + + async function runSessions(params) { + if (params.operation === 'archive') { + return requestJson('/api/archive', { method: 'GET' }); + } + const sessions = await listSessions(); + if (params.operation === 'list') return { sessions }; + const session = resolveSession(sessions, params.target); + if (params.operation === 'get') { + return requestJson(`/api/sessions/${encodeURIComponent(session.id)}`, { method: 'GET' }); + } + if (params.operation === 'running') { + return requestJson(`/api/sessions/${encodeURIComponent(session.id)}/running`, { method: 'GET' }); + } + if (params.operation === 'usage') { + return requestJson(`/api/sessions/${encodeURIComponent(session.id)}/usage`, { method: 'GET' }); + } + if (params.operation === 'screen') { + const query = new URLSearchParams({ + offset: String(params.offset ?? 0), + limit: String(params.limit ?? 200), + }); + return requestJson( + `/api/sessions/${encodeURIComponent(session.id)}/screen?${query.toString()}`, + { method: 'GET' }, + ); + } + return requestJson(`/api/sessions/${encodeURIComponent(session.id)}/grep`, { + method: 'POST', + body: JSON.stringify({ + pattern: params.pattern, + offset: params.offset ?? 0, + limit: params.limit ?? 30, + context: params.context ?? 0, + }), + }); + } + + const targetProperty = { type: 'string', description: 'Session id or unique exact name.' }; + const pageProperties = { + offset: { type: 'integer', minimum: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 1000 }, + }; + + pi.registerTool({ + name: 'ftown_sessions', + label: 'ftown sessions', + description: 'List or inspect ftown sessions, running state, archive, token usage, terminal screen, and terminal log matches.', + parameters: { + anyOf: [ + { + type: 'object', properties: { operation: { enum: ['list', 'archive'] } }, + required: ['operation'], additionalProperties: false, + }, + ...['get', 'usage', 'running'].map((operation) => ({ + type: 'object', properties: { operation: { const: operation }, target: targetProperty }, + required: ['operation', 'target'], additionalProperties: false, + })), + { + type: 'object', + properties: { operation: { const: 'screen' }, target: targetProperty, ...pageProperties }, + required: ['operation', 'target'], additionalProperties: false, + }, + { + type: 'object', + properties: { + operation: { const: 'grep' }, target: targetProperty, + pattern: { type: 'string', minLength: 1 }, ...pageProperties, + context: { type: 'integer', minimum: 0, maximum: 10 }, + }, + required: ['operation', 'target', 'pattern'], additionalProperties: false, + }, + ], + }, + async execute(_toolCallId, params) { + try { + return toolResult(await runSessions(params)); + } catch (error) { + return toolError(error); + } + }, + }); + + pi.registerCommand('ftown-sessions', { + description: 'List ftown sessions available on this bridge.', + handler: async (_args, ctx) => { + try { + ctx.ui.notify(JSON.stringify({ sessions: await listSessions() }, null, 2), 'info'); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error'); + } + }, + }); + + pi.registerTool({ + name: 'ftown_session_create', + label: 'create ftown session', + description: 'Create a structured ftown agent session. Does not allow arbitrary commands or environment variables.', + parameters: { + type: 'object', + properties: { + shell: { + enum: ['claude', 'cursor', 'codex', 'grok', 'pi', 'kimi-code', 'opencode', 'shell', 'zai', 'kimi', 'deepseek', 'fireworks'], + }, + prompt: { type: 'string', minLength: 1 }, + workdir: { type: 'string' }, + name: { type: 'string' }, + model: { type: 'string' }, + parent: { type: 'boolean', description: 'Make the current Pi session the parent.' }, + parentId: { type: 'string', description: 'Explicit ftown parent session id.' }, + createWorkdir: { type: 'boolean' }, + orchestrator: { type: 'boolean' }, + }, + required: ['shell', 'prompt'], + additionalProperties: false, + }, + async execute(toolCallId, params) { + try { + return await executeOnce('ftown_session_create', toolCallId, async () => { + const body = { + shellType: params.shell, + prompt: params.prompt, + ...(params.workdir ? { workingDir: params.workdir } : {}), + ...(params.name ? { name: params.name } : {}), + ...(params.model ? { model: params.model } : {}), + ...(params.parentId + ? { parentSessionId: params.parentId } + : params.parent ? { parentSessionId: true } : {}), + ...(params.createWorkdir ? { createMissingWorkingDir: true } : {}), + ...(params.orchestrator ? { orchestrator: true } : {}), + }; + const extraHeaders = params.parent && ftownSessionId + ? { 'x-ftown-session-id': ftownSessionId } + : undefined; + return toolResult(await requestJson('/api/sessions', { + method: 'POST', + headers: extraHeaders, + body: JSON.stringify(body), + })); + }); + } catch (error) { + return toolError(error); + } + }, + }); + + async function runSessionManage(params) { + if (params.operation === 'revive') { + const payload = await requestJson('/api/archive', { method: 'GET' }); + const archived = Array.isArray(payload?.archived) ? payload.archived : []; + const normalized = params.target?.trim(); + const byId = archived.filter((session) => session.id === normalized); + const byName = archived.filter((session) => session.name === normalized); + const session = byId.at(-1) ?? (byName.length === 1 ? byName[0] : undefined); + if (!session) { + if (byName.length > 1) throw new Error(`Multiple archived sessions are named "${normalized}"; use an id`); + throw new Error(`Archived session not found: ${normalized}`); + } + return requestJson(`/api/sessions/${encodeURIComponent(session.id)}/revive`, { method: 'POST' }); + } + const sessions = await listSessions(); + const session = resolveSession(sessions, params.target); + if (params.operation === 'stop') { + if (typeof pi.exec !== 'function') throw new Error('Pi command execution is unavailable'); + const result = await pi.exec( + join(homedir(), '.ftown', 'ftown-sessions'), + ['stop', session.id], + { timeout: 30_000 }, + ); + if (result.code !== 0) throw new Error(result.stderr.trim() || 'Failed to stop ftown session'); + try { + return JSON.parse(result.stdout); + } catch { + return { stopped: true, sessionId: session.id }; + } + } + if (params.operation === 'remove') { + return requestJson(`/api/sessions/${encodeURIComponent(session.id)}`, { method: 'DELETE' }); + } + if (params.operation === 'rename') { + return requestJson(`/api/sessions/${encodeURIComponent(session.id)}`, { + method: 'PATCH', body: JSON.stringify({ name: params.name }), + }); + } + const parent = params.parent === null || params.parent === '' + ? null + : resolveSession(sessions, params.parent).id; + return requestJson(`/api/sessions/${encodeURIComponent(session.id)}`, { + method: 'PATCH', body: JSON.stringify({ parentSessionId: parent }), + }); + } + + pi.registerTool({ + name: 'ftown_session_manage', + label: 'manage ftown session', + description: 'Stop, rename, reparent, remove, or revive an ftown session.', + parameters: { + anyOf: [ + ...['stop', 'remove', 'revive'].map((operation) => ({ + type: 'object', properties: { operation: { const: operation }, target: targetProperty }, + required: ['operation', 'target'], additionalProperties: false, + })), + { + type: 'object', + properties: { + operation: { const: 'rename' }, target: targetProperty, + name: { type: 'string', minLength: 1 }, + }, + required: ['operation', 'target', 'name'], additionalProperties: false, + }, + { + type: 'object', + properties: { + operation: { const: 'reparent' }, target: targetProperty, + parent: { type: ['string', 'null'], description: 'Parent id/name; null clears it.' }, + }, + required: ['operation', 'target', 'parent'], additionalProperties: false, + }, + ], + }, + async execute(toolCallId, params) { + try { + return await executeOnce('ftown_session_manage', toolCallId, async () => + toolResult(await runSessionManage(params))); + } catch (error) { + return toolError(error); + } + }, + }); + + async function listLoops() { + const payload = await requestJson('/api/loops', { method: 'GET' }); + return Array.isArray(payload?.loops) ? payload.loops : []; + } + + function resolveLoop(loops, target) { + const normalized = target?.trim(); + if (!normalized) throw new Error('A loop id or name is required'); + const byId = loops.find((loop) => loop.id === normalized); + if (byId) return byId; + const byName = loops.filter((loop) => loop.name === normalized); + if (byName.length === 1) return byName[0]; + if (byName.length > 1) throw new Error(`Multiple loops are named "${normalized}"; use an id`); + throw new Error(`Loop not found: ${normalized}`); + } + + const loopScheduleProperty = { + anyOf: [ + { + type: 'object', + properties: { + kind: { const: 'interval' }, + everyMs: { type: 'integer', minimum: 1000 }, + }, + required: ['kind', 'everyMs'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { const: 'cron' }, + expression: { type: 'string', minLength: 1 }, + tz: { type: 'string', minLength: 1 }, + }, + required: ['kind', 'expression'], + additionalProperties: false, + }, + ], + }; + const loopDraftProperties = { + name: { type: 'string', minLength: 1 }, + task: { type: 'string', minLength: 1 }, + schedule: loopScheduleProperty, + shell: { enum: ['claude', 'cursor', 'codex', 'grok', 'pi', 'kimi-code', 'opencode', 'shell'] }, + workdir: { type: 'string' }, + model: { type: 'string' }, + enabled: { type: 'boolean' }, + overlapPolicy: { enum: ['skip', 'allow'] }, + retention: { type: ['integer', 'null'], minimum: 0 }, + maxRuntimeMs: { type: 'integer', minimum: 1000 }, + group: { type: 'string' }, + }; + + function loopBody(params, create = false) { + const body = {}; + for (const field of ['name', 'task', 'schedule', 'workdir', 'model', 'enabled', 'overlapPolicy', 'maxRuntimeMs', 'group']) { + if (Object.prototype.hasOwnProperty.call(params, field)) body[field] = params[field]; + } + if (Object.prototype.hasOwnProperty.call(params, 'shell')) body.harness = params.shell; + if (Object.prototype.hasOwnProperty.call(params, 'retention')) { + body.retention = { autoClearAfterRuns: params.retention }; + } + if (create) { + body.harness ??= 'pi'; + body.enabled ??= true; + body.overlapPolicy ??= 'skip'; + body.retention ??= { autoClearAfterRuns: 10 }; + } + return body; + } + + pi.registerTool({ + name: 'ftown_loops', + label: 'ftown loops', + description: 'List, inspect, create, update, delete, or run scheduled ftown loops.', + parameters: { + anyOf: [ + { + type: 'object', properties: { operation: { const: 'list' } }, + required: ['operation'], additionalProperties: false, + }, + ...['get', 'runs', 'run_now', 'delete'].map((operation) => ({ + type: 'object', + properties: { operation: { const: operation }, target: { type: 'string' } }, + required: ['operation', 'target'], additionalProperties: false, + })), + { + type: 'object', + properties: { operation: { const: 'create' }, ...loopDraftProperties }, + required: ['operation', 'name', 'task', 'schedule'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + operation: { const: 'update' }, target: { type: 'string' }, ...loopDraftProperties, + }, + required: ['operation', 'target'], + minProperties: 3, + additionalProperties: false, + }, + ], + }, + async execute(toolCallId, params) { + try { + if (params.operation === 'create') { + return await executeOnce('ftown_loops', toolCallId, async () => + toolResult(await requestJson('/api/loops', { + method: 'POST', body: JSON.stringify(loopBody(params, true)), + }))); + } + const loops = await listLoops(); + if (params.operation === 'list') return toolResult({ loops }); + const loop = resolveLoop(loops, params.target); + const loopPath = `/api/loops/${encodeURIComponent(loop.id)}`; + if (params.operation === 'get') { + return toolResult(await requestJson(loopPath, { method: 'GET' })); + } + if (params.operation === 'runs') { + return toolResult(await requestJson(`${loopPath}/runs`, { method: 'GET' })); + } + const mutate = async () => { + if (params.operation === 'run_now') { + return toolResult(await requestJson(`${loopPath}/run-now`, { method: 'POST' })); + } + if (params.operation === 'delete') { + return toolResult(await requestJson(loopPath, { method: 'DELETE' })); + } + return toolResult(await requestJson(loopPath, { + method: 'PATCH', body: JSON.stringify(loopBody(params)), + })); + }; + return await executeOnce('ftown_loops', toolCallId, mutate); + } catch (error) { + return toolError(error); + } + }, + }); + + async function deliverMail() { + const messages = await drainMail(); + if (messages.length === 0) return; + const formatted = messages.map(formatMail).join('\n'); + pi.sendUserMessage( + `[ftown mail]\n${formatted}\n` + + 'Handle this message and reply with the `ftown_mail` tool where appropriate.', + ); + } + + pi.on('session_start', async (event, ctx) => { + await postHook('SessionStart', ctx, { reason: event.reason }); + await deliverMail(); + }); + + pi.on('before_agent_start', async (event, ctx) => { + await postHook('UserPromptSubmit', ctx, { prompt: event.prompt }); + }); + + pi.on('tool_execution_start', async (event, ctx) => { + await postHook('PreToolUse', ctx, { + tool_call_id: event.toolCallId, + tool_name: event.toolName, + tool_input: event.args, + }); + }); + + pi.on('tool_execution_end', async (event, ctx) => { + await postHook('PostToolUse', ctx, { + tool_call_id: event.toolCallId, + tool_name: event.toolName, + is_error: event.isError, + }); + }); + + pi.on('agent_settled', async (_event, ctx) => { + const usage = collectBranchUsage(ctx); + await postHook('Stop', ctx, usage ? { usage } : {}); + await deliverMail(); + }); + + pi.on('session_shutdown', async (event, ctx) => { + await postHook('SessionEnd', ctx, { reason: event.reason }); + }); +} + +export default function ftownPiExtension(pi) { + registerFtownPiExtension(pi); +} diff --git a/bridge/skills/ftown/references/loops.md b/bridge/skills/ftown/references/loops.md index 1185c33..6ebb85b 100644 --- a/bridge/skills/ftown/references/loops.md +++ b/bridge/skills/ftown/references/loops.md @@ -71,7 +71,7 @@ run-now request never re-creates a deleted loop. | --- | --- | | `--every ` | Interval schedule such as `30s`, `5m`, `2h`, `1d`. Minimum is `1s`. | | `--cron ` / `--tz ` | Cron schedule with optional IANA timezone. | -| `--shell ` | `claude`, `cursor`, `codex`, `opencode`, or `shell`. | +| `--shell ` | `claude`, `cursor`, `codex`, `grok`, `pi`, `kimi-code`, `opencode`, or `shell`. | | `--workdir ` | Working directory for each run. | | `--model ` | Harness model override when supported. | | `--disabled` / `--enabled` | Create/update enabled state. | diff --git a/bridge/skills/ftown/references/orchestrator.md b/bridge/skills/ftown/references/orchestrator.md index ed6fd7a..9791a21 100644 --- a/bridge/skills/ftown/references/orchestrator.md +++ b/bridge/skills/ftown/references/orchestrator.md @@ -39,7 +39,7 @@ fts events --db "$FTS_DB" --after ## Spawning workers -`--shell` accepts `claude`, `cursor`, `codex`, `shell`, `opencode`, and Claude +`--shell` accepts `claude`, `cursor`, `codex`, `grok`, `pi`, `kimi-code`, `shell`, `opencode`, and Claude Code provider flavors such as `zai`, `kimi`, `deepseek`, `fireworks`; `--parent` sets the worker's parent to `$FTOWN_SESSION_ID`. @@ -80,7 +80,7 @@ your turn and let fallback mail wake you instead of running a polling loop. ## Messaging fallback (mail) -Each session has an inbox. Claude and codex sessions receive mail automatically +Each session has an inbox. Claude, codex, and Pi sessions receive mail automatically at turn boundaries via hooks — no keystroke injection. Cursor and shell sessions have no hooks: when idle they get a one-line nudge telling them to run `ftown-harness mail read`, so expect slightly slower pickup there. diff --git a/bridge/skills/ftown/references/sessions.md b/bridge/skills/ftown/references/sessions.md index efd061b..20e57d5 100644 --- a/bridge/skills/ftown/references/sessions.md +++ b/bridge/skills/ftown/references/sessions.md @@ -47,8 +47,8 @@ top-level `~/.ftown/ftown` dispatcher. ~/.ftown/ftown-sessions archive # Recreate a removed session from its tombstone (resumes the agent -# conversation when a claude/cursor/codex session id was recorded; the revived -# session gets a NEW id) +# conversation when a claude/cursor/codex/Pi session id was recorded; Kimi Code +# continues by working directory; the revived session gets a NEW id) ~/.ftown/ftown-sessions revive ``` @@ -62,7 +62,7 @@ schedule syntax, manual runs, pause/resume, and run history, read `tell` posts to the target session's **inbox**. Mail is delivered into the recipient's context automatically at turn boundaries (as `[ftown mail]` -context), so there is no keystroke injection by default. Claude and codex +context), so there is no keystroke injection by default. Claude, codex, and Pi sessions get this hook-based delivery; cursor and shell sessions rely on an idle one-line nudge to run `ftown-harness mail read` instead. @@ -93,15 +93,15 @@ Fan-out targets are messaged sequentially, one JSON result line per target. | Flag | Description | |------|-------------| -| `--shell` | `cursor`, `claude`, `codex`, `shell`, `opencode`, or Claude Code provider flavors `zai`, `kimi`, `deepseek`, `fireworks` (default `claude`) | -| `--prompt` | Initial task — passed as a CLI launch argument to `claude`/Claude provider flavors/`cursor`/`codex` (typed after boot for other shells) | +| `--shell` | `claude`, `cursor`, `codex`, `grok`, `pi`, `kimi-code`, `opencode`, `shell`, or Claude Code provider flavors `zai`, `kimi`, `deepseek`, `fireworks` (default `claude`) | +| `--prompt` | Initial task — passed as a CLI launch argument to `claude`/Claude provider flavors/`cursor`/`codex`/`grok`/`pi` (typed after boot for other shells) | | `--workdir` | Working directory | | `--name` | Dashboard label | | `--command` | Full command override (skips `--shell` builder) | | `--parent` | Set parent to `$FTOWN_SESSION_ID` | | `--parent-id` | Explicit parent session UUID | | `--orchestrator` | Brief the new agent (non-`shell`) to spawn and coordinate sibling sessions | -| `--model` | Cursor / codex model name | +| `--model` | Harness model name or provider/model pattern (when supported) | Provider-flavored shells (`zai`, `kimi`, `deepseek`, `fireworks`) run Claude Code with provider-specific default endpoint/model environment. They require a diff --git a/bridge/skills/ftown/references/workflows.md b/bridge/skills/ftown/references/workflows.md index 1986536..9846236 100644 --- a/bridge/skills/ftown/references/workflows.md +++ b/bridge/skills/ftown/references/workflows.md @@ -181,7 +181,7 @@ Full options: ~/.ftown/ftown-workflows run \ [--args ] # parsed and available as ctx.args in the script [--workdir ] # default working dir for spawned child sessions - [--shell claude|cursor|codex|opencode|shell] + [--shell claude|cursor|codex|pi|opencode|shell] [--concurrency ] # max simultaneous live sessions (default 4) [--timeout ] # per-agent timeout (default 1 800 000 = 30 min) [--max-agents ] # hard budget cap on total spawns @@ -242,7 +242,7 @@ Key options: | `label` | `step-` | step key used for the result file and resume | | `phase` | — | progress grouping shown in logs | | `schema` | — | JSON Schema embedded in the worker prompt; requests JSON result | -| `shell` | run-level default | `claude` / `cursor` / `codex` / `opencode` / `shell` | +| `shell` | run-level default | `claude` / `cursor` / `codex` / `pi` / `opencode` / `shell` | | `model` | — | model override passed to the session | | `workdir` | run-level default | working directory for the child session | | `timeoutMs` | 1 800 000 | wall-clock cap for this step | diff --git a/bridge/src/agent-commands.test.ts b/bridge/src/agent-commands.test.ts index 973d32b..f5e4250 100644 --- a/bridge/src/agent-commands.test.ts +++ b/bridge/src/agent-commands.test.ts @@ -1,7 +1,48 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { buildGrokCommand, buildSessionCommand, shellQuote } from './agent-commands.js'; +import { buildGrokCommand, buildPiCommand, buildSessionCommand, shellQuote } from './agent-commands.js'; + +describe('buildSessionCommand — pi', () => { + it('launches Pi as an interactive coding agent', () => { + assert.strictEqual( + buildSessionCommand({ shellType: 'pi' }), + 'pi --extension "$HOME/.ftown/pi/ftown.js"', + ); + }); + + it('passes the model and initial prompt through Pi CLI arguments', () => { + const options = { model: 'anthropic/claude-sonnet-4', initialPrompt: "review today's diff" }; + assert.strictEqual( + buildSessionCommand({ shellType: 'pi', ...options }), + "pi --extension \"$HOME/.ftown/pi/ftown.js\" --model 'anthropic/claude-sonnet-4' 'review today'\\''s diff'", + ); + assert.strictEqual(buildSessionCommand({ shellType: 'pi', ...options }), buildPiCommand(options)); + }); + + it('continues the workdir session without replaying its original prompt', () => { + assert.strictEqual( + buildSessionCommand({ + shellType: 'pi', + model: 'openai/gpt-5', + initialPrompt: 'do not replay', + resume: true, + }), + "pi --extension \"$HOME/.ftown/pi/ftown.js\" -c --model 'openai/gpt-5'", + ); + }); + + it('resumes the exact native Pi session when its UUID is known', () => { + assert.strictEqual( + buildSessionCommand({ + shellType: 'pi', + piSessionId: '550e8400-e29b-41d4-a716-446655440000', + resume: true, + }), + "pi --extension \"$HOME/.ftown/pi/ftown.js\" --session '550e8400-e29b-41d4-a716-446655440000'", + ); + }); +}); describe('buildSessionCommand — grok', () => { it('launches bare grok with --always-approve when no model/prompt', () => { diff --git a/bridge/src/agent-commands.ts b/bridge/src/agent-commands.ts index 119787c..7010fbe 100644 --- a/bridge/src/agent-commands.ts +++ b/bridge/src/agent-commands.ts @@ -10,6 +10,7 @@ export { buildCursorAgentCommand, buildCodexCommand, buildGrokCommand, + buildPiCommand, buildKimiCodeCommand, } from './harness-registry.js'; @@ -20,10 +21,11 @@ export interface BuildSessionCommandInput { claudeSessionId?: string; cursorSessionId?: string; codexSessionId?: string; + piSessionId?: string; command?: string; /** Initial prompt passed as a CLI argument — avoids racing the TUI with typed input. */ initialPrompt?: string; - /** Resurrection resume — workdir-based harnesses (kimi-code) append their continue flag. */ + /** Resurrection resume — workdir-based harnesses (Pi/kimi-code) append their continue flag. */ resume?: boolean; } diff --git a/bridge/src/create-ftown-session.test.ts b/bridge/src/create-ftown-session.test.ts index 4cd6e52..be2f5a1 100644 --- a/bridge/src/create-ftown-session.test.ts +++ b/bridge/src/create-ftown-session.test.ts @@ -597,6 +597,46 @@ describe('deriveRelaunchCommand — single home of the relaunch heuristic', () = assert.match(derived.command, /--yolo -c$/); }); + it('rebuilds a builder-default Pi command into a workdir-based -c resume command', () => { + const piStored = { + shellType: 'pi' as const, + workingDir: '/tmp/work', + model: 'anthropic/claude-sonnet-4', + claudeSessionId: undefined, + cursorSessionId: undefined, + codexSessionId: undefined, + }; + const piDefault = buildSessionCommand({ shellType: 'pi', model: piStored.model }); + assert.deepEqual(deriveRelaunchCommand({ ...piStored, command: piDefault }), { + command: "pi --extension \"$HOME/.ftown/pi/ftown.js\" -c --model 'anthropic/claude-sonnet-4'", + isCustom: false, + }); + }); + + it('relaunches the exact Pi conversation after its extension reports a native id', () => { + const stored = { + shellType: 'pi' as const, + command: buildSessionCommand({ shellType: 'pi' }), + piSessionId: '550e8400-e29b-41d4-a716-446655440000', + }; + + assert.deepEqual(deriveRelaunchCommand(stored), { + command: "pi --extension \"$HOME/.ftown/pi/ftown.js\" --session '550e8400-e29b-41d4-a716-446655440000'", + isCustom: false, + }); + }); + + it('upgrades a pre-extension Pi command to the hooked resume command', () => { + assert.deepEqual(deriveRelaunchCommand({ + shellType: 'pi', + model: 'openai/gpt-5', + command: "pi --model 'openai/gpt-5'", + }), { + command: "pi --extension \"$HOME/.ftown/pi/ftown.js\" -c --model 'openai/gpt-5'", + isCustom: false, + }); + }); + it('KNOWN LIMITATION: a pre-model-fix claude session (stored command lacks --model) is misclassified as custom and relaunched without --model or --resume', () => { const derived = deriveRelaunchCommand({ ...stored, @@ -625,6 +665,10 @@ describe('canResumeStoredSession — which stored sessions can resume', () => { assert.strictEqual(canResumeStoredSession({ shellType: 'kimi-code', claudeSessionId: ' ' }), true); }); + it('resumes Pi by working directory — no recorded id required', () => { + assert.strictEqual(canResumeStoredSession({ shellType: 'pi' }), true); + }); + it('never resumes plain shells, opencode, or sessions with no recorded id', () => { assert.strictEqual(canResumeStoredSession({ shellType: 'shell', claudeSessionId: 'c' }), false); assert.strictEqual(canResumeStoredSession({ shellType: 'opencode', claudeSessionId: 'c' }), false); @@ -633,6 +677,28 @@ describe('canResumeStoredSession — which stored sessions can resume', () => { }); }); +describe('createFtownSession — Pi launch', () => { + it('passes the task and model on the Pi command line without typed-input races', async () => { + const harness = fakeDeps(); + const session = await createFtownSession(harness.deps, { + shellType: 'pi', + model: 'openai/gpt-5', + prompt: "inspect today's changes", + }); + + assert.strictEqual(session.shellType, 'pi'); + assert.strictEqual( + session.command, + "pi --extension \"$HOME/.ftown/pi/ftown.js\" --model 'openai/gpt-5'", + ); + assert.strictEqual( + harness.runs[0].command, + "pi --extension \"$HOME/.ftown/pi/ftown.js\" --model 'openai/gpt-5' 'inspect today'\\''s changes'", + ); + assert.strictEqual(harness.runs[0].initialInput, undefined); + }); +}); + // All three session-launch entry points — fresh create with a resume id, the // retry_session RPC, and restart resurrection — must hand the runner the same // invocation. This is the lock on "how is a session launched has one answer". diff --git a/bridge/src/create-ftown-session.ts b/bridge/src/create-ftown-session.ts index 0d69380..a6c1955 100644 --- a/bridge/src/create-ftown-session.ts +++ b/bridge/src/create-ftown-session.ts @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; import { existsSync, mkdirSync, statSync } from 'node:fs'; import { basename, resolve } from 'node:path'; -import { buildSessionCommand } from './agent-commands.js'; +import { buildPiCommand, buildSessionCommand } from './agent-commands.js'; import { ensureCodexWorkdirTrust } from './codex-installer.js'; import { HARNESSES, harnessAcceptsPromptAsCliArg, type HarnessSpec } from './harness-registry.js'; import { staggerSpawn } from './spawn-stagger.js'; @@ -36,6 +36,8 @@ export interface CreateFtownSessionInput { claudeSessionId?: string; cursorSessionId?: string; codexSessionId?: string; + piSessionId?: string; + piSessionFile?: string; env?: Record; parentSessionId?: string; initialInput?: string; @@ -257,7 +259,7 @@ async function staggerHarnessSpawn(shellType: ShellType | undefined): Promise; /** @@ -291,14 +293,20 @@ export function deriveRelaunchCommand(session: RelaunchCommandSource): { claudeSessionId: session.claudeSessionId, cursorSessionId: session.cursorSessionId, codexSessionId: session.codexSessionId, - // Workdir-based resume (kimi-code `-c`): no id to carry, so signal resume + piSessionId: session.piSessionId, + // Workdir-based resume (Pi/kimi-code `-c`): no id to carry, so signal resume // explicitly. Id-based harnesses ignore this and key off their id fields. resume: true, }); + const isLegacyPiBuilder = session.shellType === 'pi' && [ + buildPiCommand({ model: session.model, includeFtownExtension: false }), + buildPiCommand({ model: session.model, resume: true, includeFtownExtension: false }), + ].includes(session.command); const isCustom = Boolean(session.command) && session.command !== builderDefault && - session.command !== builderResume; + session.command !== builderResume && + !isLegacyPiBuilder; return { command: isCustom ? session.command : builderResume, isCustom }; } @@ -309,9 +317,9 @@ export function canResumeStoredSession( const shellType = session.shellType ?? 'claude'; if (shellType === 'cursor') return Boolean(session.cursorSessionId?.trim()); if (shellType === 'codex') return Boolean(session.codexSessionId?.trim()); - // kimi-code resumes by working directory (`-c`), so it needs no captured - // session id — a stored kimi-code session is always resumable on restart. - if (shellType === 'kimi-code') return true; + // Pi and kimi-code resume by working directory (`-c`), so they need no + // captured session id and are always resumable on restart. + if (shellType === 'pi' || shellType === 'kimi-code') return true; return shellType !== 'shell' && shellType !== 'opencode' && Boolean(session.claudeSessionId?.trim()); } @@ -452,6 +460,8 @@ export async function createFtownSession( claudeSessionId: input.claudeSessionId, cursorSessionId: input.cursorSessionId, codexSessionId: input.codexSessionId, + piSessionId: input.piSessionId, + piSessionFile: input.piSessionFile, env: sessionEnv, parentSessionId, runtime: deps.runner.getPreferredRuntime(), @@ -582,6 +592,10 @@ export function parseCreateSessionBody( typeof body.cursorSessionId === 'string' ? body.cursorSessionId : undefined, codexSessionId: typeof body.codexSessionId === 'string' ? body.codexSessionId : undefined, + piSessionId: + typeof body.piSessionId === 'string' ? body.piSessionId : undefined, + piSessionFile: + typeof body.piSessionFile === 'string' ? body.piSessionFile : undefined, env: env && typeof env === 'object' ? env : undefined, parentSessionId, initialInput: typeof body.initialInput === 'string' ? body.initialInput : undefined, diff --git a/bridge/src/ftown-sessions-cli.ts b/bridge/src/ftown-sessions-cli.ts index 41e5cfb..c94cbe5 100644 --- a/bridge/src/ftown-sessions-cli.ts +++ b/bridge/src/ftown-sessions-cli.ts @@ -171,7 +171,7 @@ function parseLoopSchedule(args: string[], required: boolean): LoopSchedule | un function parseLoopHarness(raw: string | undefined): LoopHarness { const harness = (raw ?? 'claude') as LoopHarness; - if (!['claude', 'cursor', 'codex', 'grok', 'kimi-code', 'opencode', 'shell'].includes(harness)) { + if (!['claude', 'cursor', 'codex', 'grok', 'pi', 'kimi-code', 'opencode', 'shell'].includes(harness)) { throw new Error(`Invalid --shell "${raw}"`); } return harness; @@ -429,7 +429,7 @@ Inbox options: --json Raw JSON output Create options: - --shell cursor | claude | codex | shell | opencode | zai | kimi | deepseek | fireworks (default: claude) + --shell claude | cursor | codex | grok | pi | kimi-code | opencode | shell | zai | kimi | deepseek | fireworks (default: claude) --prompt Initial message --workdir Working directory --create-workdir Create --workdir if it does not exist @@ -454,7 +454,7 @@ Loop create/update options: --every Interval schedule, e.g. 30s, 5m, 2h --cron Cron schedule, e.g. "*/15 * * * *" --tz Cron timezone - --shell claude | cursor | codex | opencode | shell (default: claude) + --shell claude | cursor | codex | grok | pi | kimi-code | opencode | shell (default: claude) --workdir Working directory --model Agent model --disabled Create/update as disabled diff --git a/bridge/src/harness-registry.test.ts b/bridge/src/harness-registry.test.ts index d84ef3e..fdec4b0 100644 --- a/bridge/src/harness-registry.test.ts +++ b/bridge/src/harness-registry.test.ts @@ -24,21 +24,21 @@ type Equals = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const _shellTypeIsRegistryKeys: Equals = true; const _loopHarnessUnion: Equals< LoopHarness, - 'claude' | 'cursor' | 'codex' | 'shell' | 'grok' | 'kimi-code' | 'opencode' + 'claude' | 'cursor' | 'codex' | 'shell' | 'grok' | 'pi' | 'kimi-code' | 'opencode' > = true; const _workflowShellUnion: Equals< WorkflowShell, - 'claude' | 'cursor' | 'codex' | 'shell' | 'opencode' + 'claude' | 'cursor' | 'codex' | 'pi' | 'shell' | 'opencode' > = true; void _shellTypeIsRegistryKeys; void _loopHarnessUnion; void _workflowShellUnion; describe('harness registry', () => { - it('contains exactly the historical ShellType set', () => { + it('contains the supported ShellType set', () => { assert.deepEqual( [...SHELL_TYPES].sort(), - ['claude', 'codex', 'cursor', 'deepseek', 'fireworks', 'grok', 'kimi', 'kimi-code', 'opencode', 'shell', 'zai'], + ['claude', 'codex', 'cursor', 'deepseek', 'fireworks', 'grok', 'kimi', 'kimi-code', 'opencode', 'pi', 'shell', 'zai'], ); }); @@ -83,24 +83,24 @@ describe('harness registry', () => { } }); - it('derives the historical LOOP_HARNESSES set', () => { + it('derives the loop harness set', () => { assert.deepEqual( [...LOOP_HARNESS_TYPES].sort(), - ['claude', 'codex', 'cursor', 'grok', 'kimi-code', 'opencode', 'shell'], + ['claude', 'codex', 'cursor', 'grok', 'kimi-code', 'opencode', 'pi', 'shell'], ); }); - it('derives the historical WorkflowShell set (grok stays excluded — preserved decision)', () => { + it('derives the workflow shell set (grok stays excluded — preserved decision)', () => { assert.deepEqual( [...WORKFLOW_SHELLS].sort(), - ['claude', 'codex', 'cursor', 'opencode', 'shell'], + ['claude', 'codex', 'cursor', 'opencode', 'pi', 'shell'], ); }); - it('derives the historical HOOKED_SHELL_TYPES set (grok/cursor/opencode/shell stay unhooked)', () => { + it('derives the hooked harness set (Pi uses the bundled ftown extension)', () => { assert.deepEqual( [...HOOKED_SHELL_TYPES].sort(), - ['claude', 'codex', 'deepseek', 'fireworks', 'kimi', 'zai'], + ['claude', 'codex', 'deepseek', 'fireworks', 'kimi', 'pi', 'zai'], ); }); @@ -141,6 +141,10 @@ describe('harness registry', () => { ); }); + it('pi: accepts an initial CLI prompt', () => { + assert.equal(harnessAcceptsPromptAsCliArg('pi', {}), true); + }); + it('shell and opencode: never (prompt is typed into the TUI)', () => { assert.equal(harnessAcceptsPromptAsCliArg('shell', {}), false); assert.equal(harnessAcceptsPromptAsCliArg('opencode', {}), false); diff --git a/bridge/src/harness-registry.ts b/bridge/src/harness-registry.ts index 77ef991..b5746a4 100644 --- a/bridge/src/harness-registry.ts +++ b/bridge/src/harness-registry.ts @@ -24,11 +24,12 @@ export interface BuildCommandInput { claudeSessionId?: string; cursorSessionId?: string; codexSessionId?: string; + piSessionId?: string; /** Initial prompt passed as a CLI argument — avoids racing the TUI with typed input. */ initialPrompt?: string; /** * Relaunch is a resurrection resume. Harnesses that resume by working - * directory rather than by a captured session id (kimi-code, via `-c`) + * directory rather than by a captured session id (Pi/kimi-code, via `-c`) * consult this flag; id-based harnesses ignore it and use their id fields. */ resume?: boolean; @@ -137,6 +138,38 @@ export function buildGrokCommand(options: { return parts.join(' '); } +export function buildPiCommand(options: { + model?: string; + initialPrompt?: string; + resume?: boolean; + piSessionId?: string; + /** Compatibility only: build the command shape stored before ftown's Pi extension shipped. */ + includeFtownExtension?: boolean; +}): string { + // The bridge installs its bundled extension at this stable per-user path. + // Keep $HOME unexpanded until the agent's login shell launches Pi. + const parts = ['pi']; + if (options.includeFtownExtension !== false) { + parts.push('--extension', '"$HOME/.ftown/pi/ftown.js"'); + } + + if (options.piSessionId?.trim()) { + parts.push('--session', shellQuote(options.piSessionId.trim())); + } else if (options.resume) { + parts.push('-c'); + } + + if (options.model?.trim()) { + parts.push('--model', shellQuote(options.model.trim())); + } + + if (!options.resume && !options.piSessionId?.trim() && options.initialPrompt?.trim()) { + parts.push(shellQuote(options.initialPrompt)); + } + + return parts.join(' '); +} + export function buildKimiCodeCommand(options: { model?: string; resume?: boolean }): string { // Absolute path: the kimi-code installer adds ~/.kimi-code/bin to PATH only // via .zshrc (interactive), but ftown launches agents with `zsh -l -c` @@ -247,6 +280,21 @@ export const HARNESSES = { // Preserved decision: grok was absent from WorkflowShell / ftown-workflows SHELLS. validForWorkflow: false, }, + pi: { + // Workdir comes from the runner cwd. Pi resumes the most recent session for + // that working directory with -c and accepts an initial positional prompt. + buildCommand: (input) => + buildPiCommand({ + model: input.model, + initialPrompt: input.initialPrompt, + resume: input.resume, + piSessionId: input.piSessionId, + }), + hooked: true, + promptAsCliArg: true, + validForLoop: true, + validForWorkflow: true, + }, opencode: { buildCommand: () => 'opencode', // Preserved decision: opencode was never in HOOKED_SHELL_TYPES. @@ -296,7 +344,7 @@ export const WORKFLOW_SHELLS = SHELL_TYPES.filter( (type) => HARNESSES[type].validForWorkflow, ) as readonly WorkflowShell[]; -/** Shell types whose mail arrives via the Stop-hook pump (claude/codex + claude flavors). */ +/** Shell types whose mail arrives at turn boundaries through native hooks/extensions. */ export const HOOKED_SHELL_TYPES: ReadonlySet = new Set( SHELL_TYPES.filter((type) => HARNESSES[type].hooked), ); diff --git a/bridge/src/hook-usage.test.ts b/bridge/src/hook-usage.test.ts new file mode 100644 index 0000000..c020685 --- /dev/null +++ b/bridge/src/hook-usage.test.ts @@ -0,0 +1,51 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { HookUsagePersister } from './hook-usage.js'; +import type { Session } from './types.js'; + +describe('HookUsagePersister', () => { + it('persists cumulative Pi usage received from the authenticated extension hook', async () => { + const session: Session = { + id: 'ftown-session', name: 'Pi', command: 'pi', shellType: 'pi', status: 'running', + bridgeId: 'bridge', createdAt: '2026-08-08T00:00:00.000Z', updatedAt: '2026-08-08T00:00:00.000Z', + }; + const saved: Session[] = []; + const published: Session[] = []; + const persister = new HookUsagePersister({ + store: { + loadSession: async () => session, + saveSession: async (next) => { saved.push({ ...next }); }, + } as any, + publishSessionUpdate: async (next) => { published.push({ ...next }); }, + now: () => new Date('2026-08-08T12:00:00.000Z'), + }); + + const usage = await persister.persist({ + sessionId: 'ftown-session', eventName: 'Stop', source: 'env', + data: { + usage: { + inputTokens: 77, outputTokens: 9, cacheReadTokens: 3, cacheWriteTokens: 1, + totalTokens: 999, models: ['openai/gpt-5'], + perModel: [{ + model: 'openai/gpt-5', inputTokens: 77, outputTokens: 9, + cacheReadTokens: 3, cacheWriteTokens: 1, + }], + harness: 'untrusted-value', + }, + }, + }); + + assert.deepEqual(usage, { + inputTokens: 77, outputTokens: 9, cacheReadTokens: 3, cacheWriteTokens: 1, + totalTokens: 90, models: ['openai/gpt-5'], + perModel: [{ + model: 'openai/gpt-5', inputTokens: 77, outputTokens: 9, + cacheReadTokens: 3, cacheWriteTokens: 1, + }], + harness: 'pi', collectedAt: '2026-08-08T12:00:00.000Z', + }); + assert.deepEqual(saved[0].usage, usage); + assert.equal(published.length, 1); + }); +}); diff --git a/bridge/src/hook-usage.ts b/bridge/src/hook-usage.ts new file mode 100644 index 0000000..a0ae683 --- /dev/null +++ b/bridge/src/hook-usage.ts @@ -0,0 +1,72 @@ +import type { HookEvent } from './local-api-server.js'; +import type { SessionStore } from './session-store.js'; +import type { ModelUsage, Session, SessionUsage } from './types.js'; + +export interface HookUsagePersisterDeps { + store: Pick; + publishSessionUpdate: (session: Session) => Promise; + now?: () => Date; +} + +function tokenCount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0; +} + +function modelUsage(value: unknown): ModelUsage | null { + if (!value || typeof value !== 'object') return null; + const raw = value as Record; + if (typeof raw.model !== 'string' || !raw.model) return null; + return { + model: raw.model, + inputTokens: tokenCount(raw.inputTokens), + outputTokens: tokenCount(raw.outputTokens), + cacheReadTokens: tokenCount(raw.cacheReadTokens), + cacheWriteTokens: tokenCount(raw.cacheWriteTokens), + }; +} + +/** Persist cumulative usage emitted by authenticated harness extensions. */ +export class HookUsagePersister { + private readonly now: () => Date; + + constructor(private readonly deps: HookUsagePersisterDeps) { + this.now = deps.now ?? (() => new Date()); + } + + async persist(hookEvent: HookEvent): Promise { + if (hookEvent.eventName !== 'Stop' || hookEvent.source === 'workspace') return undefined; + const raw = hookEvent.data.usage; + if (!raw || typeof raw !== 'object') return undefined; + const session = await this.deps.store.loadSession(hookEvent.sessionId); + if (!session || session.shellType !== 'pi') return undefined; + + const record = raw as Record; + const perModel = Array.isArray(record.perModel) + ? record.perModel.map(modelUsage).filter((value): value is ModelUsage => value !== null) + : []; + const inputTokens = tokenCount(record.inputTokens); + const outputTokens = tokenCount(record.outputTokens); + const cacheReadTokens = tokenCount(record.cacheReadTokens); + const cacheWriteTokens = tokenCount(record.cacheWriteTokens); + const models = perModel.length > 0 + ? perModel.map((item) => item.model) + : Array.isArray(record.models) + ? record.models.filter((value): value is string => typeof value === 'string' && value.length > 0) + : []; + const usage: SessionUsage = { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens, + models, + ...(perModel.length > 0 ? { perModel } : {}), + harness: 'pi', + collectedAt: this.now().toISOString(), + }; + session.usage = usage; + await this.deps.store.saveSession(session); + await this.deps.publishSessionUpdate(session); + return usage; + } +} diff --git a/bridge/src/index.ts b/bridge/src/index.ts index f4c2c75..ab40b2d 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -26,6 +26,7 @@ import { codexBinaryAvailable, ensureCodexHooks } from './codex-installer.js'; import { installHarness, harnessOnPath, pathHint, writeHarnessAgentGuide, agentGuidePath } from './harness-installer.js'; import type { HarnessInstallResult } from './harness-installer.js'; import { installNotifyScript } from './install-notify-script.js'; +import { installPiExtension } from './pi-extension-installer.js'; import { installFtownSkill, removeFtownSkill } from './install-ftown-skill.js'; import { installFtownSessionsCli } from './install-ftown-cli.js'; import { installFtownWorkflowsCli } from './install-ftown-workflows-cli.js'; @@ -40,6 +41,7 @@ import { SessionResurrection } from './session-resurrection.js'; import { TerminalPump } from './terminal-pump.js'; import { collectSessionUsage } from './usage-collector.js'; import { AgentSessionIdPersister } from './session-ids.js'; +import { HookUsagePersister } from './hook-usage.js'; import { fetchBridgeToken, refreshBridgeToken, type BridgeAuthResponse } from './bridge-auth.js'; import { listLoops } from './loop-store.js'; import { LoopScheduler, LOOP_TICK_INTERVAL_MS } from './loop-scheduler.js'; @@ -286,8 +288,11 @@ program const bundledNotifyPath = resolve(__dirname, '..', 'hooks', 'notify.sh'); const notifyScriptPath = installNotifyScript(bundledNotifyPath); + const bundledPiExtensionPath = resolve(__dirname, '..', 'pi-extension', 'ftown.js'); + const piExtensionPath = installPiExtension(bundledPiExtensionPath); installClaudeHooks(notifyScriptPath); installCursorHooks(notifyScriptPath); + console.log(`[Bridge] Pi extension: ${piExtensionPath}`); const wireTerminalInput = (sessionId: string): void => { centrifugo.subscribeToTerminalInput( @@ -354,6 +359,10 @@ program store, publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session), }); + const hookUsagePersister = new HookUsagePersister({ + store, + publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session), + }); // Transport-agnostic controllers: each loop/session operation is defined // once here; the RPC switch below and the local HTTP router are thin @@ -505,16 +514,17 @@ program } localApiServer.on('event', (hookEvent: HookEvent) => { - centrifugo.publishHookEvent(userId, hookEvent.sessionId, { - type: 'hook_event', - eventName: hookEvent.eventName, - data: hookEvent.data, + pump.withSessionWrite(hookEvent.sessionId, async () => { + await agentIdPersister.persist(hookEvent); + const usage = await hookUsagePersister.persist(hookEvent); + await centrifugo.publishHookEvent(userId, hookEvent.sessionId, { + type: 'hook_event', + eventName: hookEvent.eventName, + data: hookEvent.data, + ...(usage ? { usage } : {}), + }); }).catch((err) => { - console.error('[Bridge] Failed to handle hook event:', err); - }); - - pump.withSessionWrite(hookEvent.sessionId, () => agentIdPersister.persist(hookEvent)).catch((err) => { - console.error(`[Bridge] Failed to persist agent session id for ${hookEvent.sessionId}:`, err); + console.error(`[Bridge] Failed to handle hook event for ${hookEvent.sessionId}:`, err); }); }); diff --git a/bridge/src/local-api-server.test.ts b/bridge/src/local-api-server.test.ts index 3e56d31..a3d756d 100644 --- a/bridge/src/local-api-server.test.ts +++ b/bridge/src/local-api-server.test.ts @@ -5,7 +5,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ProviderAuthMissingError, WorkingDirMissingError } from './create-ftown-session.js'; -import { LocalApiServer, providerAuthMissingResponse, workingDirMissingResponse } from './local-api-server.js'; +import { + LocalApiServer, + archivedSessionWasResumed, + providerAuthMissingResponse, + workingDirMissingResponse, +} from './local-api-server.js'; import { deleteLoop, getLoop, listLoops, mutateLoopRuntime } from './loop-store.js'; import { upsertLoopRunRecord } from './loop-run-store.js'; import { SessionStore } from './session-store.js'; @@ -86,6 +91,15 @@ describe('workingDirMissingResponse', () => { }); }); +describe('archivedSessionWasResumed', () => { + it('reports Pi workdir/native continuation as resumed, but not a custom command', () => { + const piSession = { shellType: 'pi' as const }; + + assert.equal(archivedSessionWasResumed(piSession, false), true); + assert.equal(archivedSessionWasResumed(piSession, true), false); + }); +}); + describe('LocalApiServer session parent route', () => { it('moves and detaches a session through PATCH without allowing a parent cycle', async () => { const home = mkdtempSync(join(tmpdir(), 'ftw-session-parent-api-')); diff --git a/bridge/src/local-api-server.ts b/bridge/src/local-api-server.ts index 3829ae3..b18349e 100644 --- a/bridge/src/local-api-server.ts +++ b/bridge/src/local-api-server.ts @@ -19,6 +19,7 @@ import { resolveSessionIdFromHookPayload, } from './session-registry.js'; import { + canResumeStoredSession, createFtownSession, deriveRelaunchCommand, parseCreateSessionBody, @@ -95,6 +96,13 @@ export function workingDirMissingResponse( }; } +export function archivedSessionWasResumed( + session: Pick, + isCustomCommand: boolean, +): boolean { + return !isCustomCommand && canResumeStoredSession(session); +} + function parseBody(req: IncomingMessage): Promise> { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; @@ -597,7 +605,8 @@ export class LocalApiServer extends EventEmitter { (s) => ((tombstone.claudeSessionId && s.claudeSessionId === tombstone.claudeSessionId) || (tombstone.cursorSessionId && s.cursorSessionId === tombstone.cursorSessionId) || - (tombstone.codexSessionId && s.codexSessionId === tombstone.codexSessionId)) && + (tombstone.codexSessionId && s.codexSessionId === tombstone.codexSessionId) || + (tombstone.piSessionId && s.piSessionId === tombstone.piSessionId)) && isLive(s), ); if (conflict) { @@ -629,14 +638,13 @@ export class LocalApiServer extends EventEmitter { claudeSessionId: tombstone.claudeSessionId, cursorSessionId: tombstone.cursorSessionId, codexSessionId: tombstone.codexSessionId, + piSessionId: tombstone.piSessionId, + piSessionFile: tombstone.piSessionFile, parentSessionId, }); - // resumed=false means a fresh conversation (no agent session id was - // recorded before removal); callers should not assume context survived. - // Codex resumes via a `resume ` subcommand instead of a --resume flag. - const resumed = - session.command.includes(' --resume ') || - /(^|\s)codex(\s+\S+)*\s+resume\s/.test(session.command); + // A builder-managed resumable harness preserves conversation context; + // custom commands are rerun verbatim and cannot make that guarantee. + const resumed = archivedSessionWasResumed(tombstone, isCustomCommand); jsonResponse(res, 201, { session: toWireSession(session), resumed }); } catch (err) { if (err instanceof ProviderAuthMissingError) { diff --git a/bridge/src/pi-extension-installer.test.ts b/bridge/src/pi-extension-installer.test.ts new file mode 100644 index 0000000..1340493 --- /dev/null +++ b/bridge/src/pi-extension-installer.test.ts @@ -0,0 +1,21 @@ +import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { installPiExtension } from './pi-extension-installer.js'; + +describe('installPiExtension', () => { + it('installs the bundled extension at the canonical per-user path', () => { + const home = mkdtempSync(join(tmpdir(), 'ftown-pi-extension-')); + const bundled = join(home, 'bundled.js'); + writeFileSync(bundled, 'export default function () {}\n'); + + const installed = installPiExtension(bundled, home); + + assert.equal(installed, join(home, '.ftown', 'pi', 'ftown.js')); + assert.equal(readFileSync(installed, 'utf8'), 'export default function () {}\n'); + assert.equal(statSync(installed).mode & 0o777, 0o600); + }); +}); diff --git a/bridge/src/pi-extension-installer.ts b/bridge/src/pi-extension-installer.ts new file mode 100644 index 0000000..ec1f201 --- /dev/null +++ b/bridge/src/pi-extension-installer.ts @@ -0,0 +1,12 @@ +import { chmodSync, copyFileSync, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +/** Copy the bundled ftown Pi extension to the stable path used by launch commands. */ +export function installPiExtension(bundledPath: string, home: string = homedir()): string { + const destination = join(home, '.ftown', 'pi', 'ftown.js'); + mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }); + copyFileSync(bundledPath, destination); + chmodSync(destination, 0o600); + return destination; +} diff --git a/bridge/src/pi-extension.test.ts b/bridge/src/pi-extension.test.ts new file mode 100644 index 0000000..61a8be1 --- /dev/null +++ b/bridge/src/pi-extension.test.ts @@ -0,0 +1,522 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { registerFtownPiExtension } from '../pi-extension/ftown.js'; + +describe('ftown Pi extension', () => { + it('forwards native lifecycle metadata and turns pending mail into a follow-up', async () => { + const handlers = new Map Promise>(); + const followUps: string[] = []; + const requests: Array<{ url: string; init?: RequestInit }> = []; + const pi = { + on(event: string, handler: (event: unknown, ctx: unknown) => Promise) { + handlers.set(event, handler); + }, + sendUserMessage(message: string) { + followUps.push(message); + }, + registerTool() {}, + registerCommand() {}, + }; + const fetchImpl = async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + if (url.endsWith('/inbox?wait=0')) { + return { + ok: true, + json: async () => ({ + messages: [{ from: 'parent-id', fromName: 'Planner', type: 'task', body: 'Review the API' }], + }), + }; + } + return { ok: true, json: async () => ({ ok: true }) }; + }; + + registerFtownPiExtension(pi, { + env: { + FTOWN_SESSION_ID: 'ftown-session', + FTOWN_HOOK_PORT: '4321', + FTOWN_HOOK_TOKEN: 'secret', + }, + fetch: fetchImpl, + readBridgePointer: async () => null, + }); + + const ctx = { + sessionManager: { + getSessionId: () => 'pi-session-uuid', + getSessionFile: () => '/tmp/pi-session.jsonl', + getCwd: () => '/tmp/project', + getBranch: () => [{ + type: 'message', + message: { + role: 'assistant', + provider: 'openai', + model: 'gpt-5', + usage: { input: 77, output: 9, cacheRead: 3, cacheWrite: 1 }, + }, + }], + }, + }; + await handlers.get('agent_settled')?.({}, ctx); + + assert.equal(requests.length, 2); + assert.equal(requests[0].url, 'http://127.0.0.1:4321/hook'); + assert.deepEqual(JSON.parse(String(requests[0].init?.body)), { + ftown_session_id: 'ftown-session', + ftown_session_source: 'env', + hook_event_name: 'Stop', + session_id: 'pi-session-uuid', + session_file: '/tmp/pi-session.jsonl', + cwd: '/tmp/project', + usage: { + inputTokens: 77, + outputTokens: 9, + cacheReadTokens: 3, + cacheWriteTokens: 1, + totalTokens: 90, + models: ['openai/gpt-5'], + perModel: [{ + model: 'openai/gpt-5', + inputTokens: 77, + outputTokens: 9, + cacheReadTokens: 3, + cacheWriteTokens: 1, + }], + harness: 'pi', + }, + }); + assert.equal(requests[0].init?.headers instanceof Headers, true); + assert.equal((requests[0].init?.headers as Headers).get('authorization'), 'Bearer secret'); + assert.equal( + requests[1].url, + 'http://127.0.0.1:4321/api/sessions/ftown-session/inbox?wait=0', + ); + assert.deepEqual(followUps, [ + '[ftown mail]\n[task from Planner (parent-id)] Review the API\nHandle this message and reply with the `ftown_mail` tool where appropriate.', + ]); + }); + + it('maps Pi session, prompt, and tool events onto ftown hook events', async () => { + const handlers = new Map Promise>(); + const hookPayloads: Array> = []; + const pi = { + on(event: string, handler: (event: any, ctx: any) => Promise) { + handlers.set(event, handler); + }, + sendUserMessage() {}, + registerTool() {}, + registerCommand() {}, + }; + const fetchImpl = async (_url: string, init?: RequestInit) => { + if (init?.body) hookPayloads.push(JSON.parse(String(init.body))); + return { ok: true, json: async () => ({ messages: [] }) }; + }; + registerFtownPiExtension(pi, { + env: { FTOWN_SESSION_ID: 'ftown-session', FTOWN_HOOK_PORT: '4321' }, + fetch: fetchImpl, + readBridgePointer: async () => null, + }); + const ctx = { + sessionManager: { + getSessionId: () => 'pi-session-uuid', + getSessionFile: () => '/tmp/pi-session.jsonl', + getCwd: () => '/tmp/project', + }, + }; + + await handlers.get('session_start')?.({ reason: 'startup' }, ctx); + await handlers.get('before_agent_start')?.({ prompt: 'Ship it' }, ctx); + await handlers.get('tool_execution_start')?.( + { toolCallId: 'call-1', toolName: 'bash', args: { command: 'npm test' } }, + ctx, + ); + await handlers.get('tool_execution_end')?.( + { toolCallId: 'call-1', toolName: 'bash', isError: false }, + ctx, + ); + await handlers.get('session_shutdown')?.({ reason: 'quit' }, ctx); + + assert.deepEqual( + hookPayloads.map((payload) => payload.hook_event_name), + ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'SessionEnd'], + ); + assert.deepEqual(hookPayloads[2], { + ftown_session_id: 'ftown-session', + ftown_session_source: 'env', + hook_event_name: 'PreToolUse', + session_id: 'pi-session-uuid', + session_file: '/tmp/pi-session.jsonl', + cwd: '/tmp/project', + tool_call_id: 'call-1', + tool_name: 'bash', + tool_input: { command: 'npm test' }, + }); + assert.equal(hookPayloads[3].is_error, false); + }); + + it('registers model-callable ftown tools and sends mail through the local API', async () => { + const tools = new Map(); + const commands = new Map(); + const requests: Array<{ url: string; init?: RequestInit }> = []; + const pi = { + on() {}, + sendUserMessage() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + registerCommand(name: string, command: any) { commands.set(name, command); }, + }; + const fetchImpl = async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + if (url.endsWith('/api/sessions')) { + return { + ok: true, + json: async () => ({ sessions: [ + { id: 'ftown-session', name: 'Pi worker', status: 'running' }, + { id: 'target-id', name: 'Planner', status: 'running' }, + ] }), + }; + } + return { ok: true, json: async () => ({ id: 'mail-1' }) }; + }; + + registerFtownPiExtension(pi, { + env: { FTOWN_SESSION_ID: 'ftown-session', FTOWN_HOOK_PORT: '4321' }, + fetch: fetchImpl, + readBridgePointer: async () => null, + }); + + assert.equal(tools.has('ftown_mail'), true); + assert.equal(commands.has('ftown-mail'), true); + + const result = await tools.get('ftown_mail').execute( + 'call-1', + { operation: 'send', target: 'Planner', body: 'Please review', type: 'task' }, + ); + const retried = await tools.get('ftown_mail').execute( + 'call-1', + { operation: 'send', target: 'Planner', body: 'Please review', type: 'task' }, + ); + + assert.equal(requests.length, 2); + assert.equal(requests[1].url, 'http://127.0.0.1:4321/api/sessions/target-id/inbox'); + assert.deepEqual(JSON.parse(String(requests[1].init?.body)), { + body: 'Please review', + type: 'task', + from: 'ftown-session', + fromName: 'Pi worker', + }); + assert.equal(result.isError, undefined); + assert.match(result.content[0].text, /mail-1/); + assert.deepEqual(retried, result); + }); + + it('lists ftown sessions through a model tool and slash command', async () => { + const tools = new Map(); + const commands = new Map(); + const notifications: string[] = []; + const sessions = [ + { id: 's1', name: 'Planner', status: 'running', shellType: 'claude' }, + { id: 's2', name: 'Worker', status: 'completed', shellType: 'pi' }, + ]; + const pi = { + on() {}, sendUserMessage() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + registerCommand(name: string, command: any) { commands.set(name, command); }, + }; + registerFtownPiExtension(pi, { + env: { FTOWN_SESSION_ID: 's2', FTOWN_HOOK_PORT: '4321' }, + fetch: async () => ({ ok: true, json: async () => ({ sessions }) }), + readBridgePointer: async () => null, + }); + + const result = await tools.get('ftown_sessions').execute('call-list', { operation: 'list' }); + assert.deepEqual(result.details, { sessions }); + + await commands.get('ftown-sessions').handler('', { + ui: { notify(message: string) { notifications.push(message); } }, + }); + assert.match(notifications[0], /Planner/); + assert.match(notifications[0], /Worker/); + }); + + it('retries a refreshed bridge token when a restarted bridge reuses the same port', async () => { + const tools = new Map(); + const authorizations: Array = []; + const pi = { + on() {}, sendUserMessage() {}, registerCommand() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + }; + registerFtownPiExtension(pi, { + env: { + FTOWN_SESSION_ID: 'self', FTOWN_HOOK_PORT: '4321', FTOWN_HOOK_TOKEN: 'stale-token', + }, + fetch: async (_url: string, init?: RequestInit) => { + const authorization = (init?.headers as Headers).get('authorization'); + authorizations.push(authorization); + if (authorization === 'Bearer stale-token') { + return { ok: false, status: 401, json: async () => ({ error: 'Unauthorized' }) }; + } + return { ok: true, json: async () => ({ sessions: [{ id: 's1', name: 'Worker' }] }) }; + }, + readBridgePointer: async () => ({ port: 4321, token: 'fresh-token' }), + }); + + const result = await tools.get('ftown_sessions').execute('list-call', { operation: 'list' }); + + assert.equal(result.isError, undefined); + assert.equal(result.details.sessions[0].id, 's1'); + assert.deepEqual(authorizations, ['Bearer stale-token', 'Bearer fresh-token']); + }); + + it('inspects a session usage and terminal log without terminal injection', async () => { + const tools = new Map(); + const requests: Array<{ url: string; init?: RequestInit }> = []; + const pi = { + on() {}, sendUserMessage() {}, registerCommand() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + }; + const fetchImpl = async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + if (url.endsWith('/api/sessions')) { + return { ok: true, json: async () => ({ sessions: [{ id: 's1', name: 'Worker' }] }) }; + } + if (url.endsWith('/usage')) { + return { ok: true, json: async () => ({ usage: { totalTokens: 123 } }) }; + } + return { ok: true, json: async () => ({ matches: [{ lineNumber: 7, text: 'tests passed' }] }) }; + }; + registerFtownPiExtension(pi, { + env: { FTOWN_SESSION_ID: 'self', FTOWN_HOOK_PORT: '4321' }, + fetch: fetchImpl, + readBridgePointer: async () => null, + }); + + const usage = await tools.get('ftown_sessions').execute( + 'usage-call', { operation: 'usage', target: 'Worker' }, + ); + const grep = await tools.get('ftown_sessions').execute( + 'grep-call', { operation: 'grep', target: 's1', pattern: 'passed', limit: 10, context: 2 }, + ); + + assert.equal(usage.details.usage.totalTokens, 123); + assert.equal(requests[1].url, 'http://127.0.0.1:4321/api/sessions/s1/usage'); + assert.equal(requests[3].url, 'http://127.0.0.1:4321/api/sessions/s1/grep'); + assert.deepEqual(JSON.parse(String(requests[3].init?.body)), { + pattern: 'passed', offset: 0, limit: 10, context: 2, + }); + assert.match(grep.content[0].text, /tests passed/); + }); + + it('reports running state and lists archived sessions', async () => { + const tools = new Map(); + const requests: string[] = []; + const pi = { + on() {}, sendUserMessage() {}, registerCommand() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + }; + registerFtownPiExtension(pi, { + env: { FTOWN_SESSION_ID: 'self', FTOWN_HOOK_PORT: '4321' }, + fetch: async (url: string) => { + requests.push(url); + if (url.endsWith('/api/archive')) { + return { ok: true, json: async () => ({ archived: [{ id: 'old-1', name: 'Old worker' }] }) }; + } + if (url.endsWith('/api/sessions')) { + return { ok: true, json: async () => ({ sessions: [{ id: 's1', name: 'Worker' }] }) }; + } + return { ok: true, json: async () => ({ sessionId: 's1', running: true }) }; + }, + readBridgePointer: async () => null, + }); + + const archived = await tools.get('ftown_sessions').execute( + 'archive-call', { operation: 'archive' }, + ); + const running = await tools.get('ftown_sessions').execute( + 'running-call', { operation: 'running', target: 'Worker' }, + ); + + assert.equal(archived.details.archived[0].id, 'old-1'); + assert.equal(running.details.running, true); + assert.equal(requests[0], 'http://127.0.0.1:4321/api/archive'); + assert.equal(requests[2], 'http://127.0.0.1:4321/api/sessions/s1/running'); + }); + + it('creates a structured child session without arbitrary command or env injection', async () => { + const tools = new Map(); + const requests: Array<{ url: string; init?: RequestInit }> = []; + const pi = { + on() {}, sendUserMessage() {}, registerCommand() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + }; + registerFtownPiExtension(pi, { + env: { + FTOWN_SESSION_ID: 'parent-id', FTOWN_HOOK_PORT: '4321', FTOWN_HOOK_TOKEN: 'secret', + }, + fetch: async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + return { ok: true, json: async () => ({ session: { id: 'child-id', name: 'Reviewer' } }) }; + }, + readBridgePointer: async () => null, + }); + + const result = await tools.get('ftown_session_create').execute('create-call', { + shell: 'pi', prompt: 'Review the API', workdir: '/tmp/project', + model: 'openai/gpt-5', name: 'Reviewer', parent: true, + }); + + assert.equal(requests[0].url, 'http://127.0.0.1:4321/api/sessions'); + assert.deepEqual(JSON.parse(String(requests[0].init?.body)), { + shellType: 'pi', prompt: 'Review the API', workingDir: '/tmp/project', + model: 'openai/gpt-5', name: 'Reviewer', parentSessionId: true, + }); + const requestHeaders = requests[0].init?.headers as Headers; + assert.equal(requestHeaders.get('authorization'), 'Bearer secret'); + assert.equal(requestHeaders.get('x-ftown-session-id'), 'parent-id'); + assert.equal(result.details.session.id, 'child-id'); + }); + + it('renames and stops sessions through the structured management tool', async () => { + const tools = new Map(); + const requests: Array<{ url: string; init?: RequestInit }> = []; + const executions: Array<{ command: string; args: string[] }> = []; + const pi = { + on() {}, sendUserMessage() {}, registerCommand() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + async exec(command: string, args: string[]) { + executions.push({ command, args }); + return { stdout: '{"stopped":true}', stderr: '', code: 0, killed: false }; + }, + }; + registerFtownPiExtension(pi, { + env: { FTOWN_SESSION_ID: 'self', FTOWN_HOOK_PORT: '4321' }, + fetch: async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + if (url.endsWith('/api/sessions')) { + return { ok: true, json: async () => ({ sessions: [{ id: 's1', name: 'Worker' }] }) }; + } + return { ok: true, json: async () => ({ session: { id: 's1', name: 'Reviewer' } }) }; + }, + readBridgePointer: async () => null, + }); + + const renamed = await tools.get('ftown_session_manage').execute( + 'rename-call', { operation: 'rename', target: 'Worker', name: 'Reviewer' }, + ); + const stopped = await tools.get('ftown_session_manage').execute( + 'stop-call', { operation: 'stop', target: 's1' }, + ); + + assert.deepEqual(JSON.parse(String(requests[1].init?.body)), { name: 'Reviewer' }); + assert.equal(renamed.details.session.name, 'Reviewer'); + assert.equal(executions[0].command.endsWith('/.ftown/ftown-sessions'), true); + assert.deepEqual(executions[0].args, ['stop', 's1']); + assert.equal(stopped.details.stopped, true); + }); + + it('revives an archived session through the management tool', async () => { + const tools = new Map(); + const requests: Array<{ url: string; init?: RequestInit }> = []; + const pi = { + on() {}, sendUserMessage() {}, registerCommand() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + }; + registerFtownPiExtension(pi, { + env: { FTOWN_SESSION_ID: 'self', FTOWN_HOOK_PORT: '4321' }, + fetch: async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + if (url.endsWith('/api/archive')) { + return { ok: true, json: async () => ({ archived: [{ id: 'old-1', name: 'Old worker' }] }) }; + } + return { ok: true, json: async () => ({ session: { id: 'new-1' }, resumed: true }) }; + }, + readBridgePointer: async () => null, + }); + + const revived = await tools.get('ftown_session_manage').execute( + 'revive-call', { operation: 'revive', target: 'Old worker' }, + ); + + assert.equal(requests[1].url, 'http://127.0.0.1:4321/api/sessions/old-1/revive'); + assert.equal(requests[1].init?.method, 'POST'); + assert.equal(revived.details.resumed, true); + }); + + it('lists loops and requests a manual loop run', async () => { + const tools = new Map(); + const requests: Array<{ url: string; init?: RequestInit }> = []; + const pi = { + on() {}, sendUserMessage() {}, registerCommand() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + }; + registerFtownPiExtension(pi, { + env: { FTOWN_SESSION_ID: 'self', FTOWN_HOOK_PORT: '4321' }, + fetch: async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + if (url.endsWith('/api/loops')) { + return { ok: true, json: async () => ({ loops: [{ id: 'l1', name: 'Nightly review' }] }) }; + } + return { ok: true, json: async () => ({ requested: true, loopId: 'l1' }) }; + }, + readBridgePointer: async () => null, + }); + + const listed = await tools.get('ftown_loops').execute('list-loops', { operation: 'list' }); + const run = await tools.get('ftown_loops').execute( + 'run-loop', { operation: 'run_now', target: 'Nightly review' }, + ); + + assert.equal(listed.details.loops[0].id, 'l1'); + assert.equal(requests[2].url, 'http://127.0.0.1:4321/api/loops/l1/run-now'); + assert.equal(requests[2].init?.method, 'POST'); + assert.equal(run.details.requested, true); + }); + + it('creates, updates, and deletes loops with structured fields', async () => { + const tools = new Map(); + const requests: Array<{ url: string; init?: RequestInit }> = []; + const pi = { + on() {}, sendUserMessage() {}, registerCommand() {}, + registerTool(tool: any) { tools.set(tool.name, tool); }, + }; + registerFtownPiExtension(pi, { + env: { FTOWN_SESSION_ID: 'self', FTOWN_HOOK_PORT: '4321' }, + fetch: async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + if (url.endsWith('/api/loops') && init?.method === 'GET') { + return { ok: true, json: async () => ({ loops: [{ id: 'l1', name: 'Review loop' }] }) }; + } + if (url.endsWith('/api/loops') && init?.method === 'POST') { + return { ok: true, json: async () => ({ loop: { id: 'l1', name: 'Review loop' } }) }; + } + if (init?.method === 'DELETE') { + return { ok: true, json: async () => ({ removed: true, loopId: 'l1' }) }; + } + return { ok: true, json: async () => ({ loop: { id: 'l1', enabled: false } }) }; + }, + readBridgePointer: async () => null, + }); + + const create = await tools.get('ftown_loops').execute('create-loop', { + operation: 'create', name: 'Review loop', task: 'Review open work', + schedule: { kind: 'interval', everyMs: 300_000 }, shell: 'pi', retention: 10, + }); + const update = await tools.get('ftown_loops').execute( + 'update-loop', { operation: 'update', target: 'Review loop', enabled: false }, + ); + const remove = await tools.get('ftown_loops').execute( + 'delete-loop', { operation: 'delete', target: 'l1' }, + ); + + assert.equal(create.details.loop.id, 'l1'); + assert.deepEqual(JSON.parse(String(requests[0].init?.body)), { + name: 'Review loop', task: 'Review open work', + schedule: { kind: 'interval', everyMs: 300_000 }, harness: 'pi', + enabled: true, overlapPolicy: 'skip', retention: { autoClearAfterRuns: 10 }, + }); + assert.equal(requests[2].url, 'http://127.0.0.1:4321/api/loops/l1'); + assert.deepEqual(JSON.parse(String(requests[2].init?.body)), { enabled: false }); + assert.equal(update.details.loop.enabled, false); + assert.equal(requests[4].init?.method, 'DELETE'); + assert.equal(remove.details.removed, true); + }); +}); diff --git a/bridge/src/session-ids.test.ts b/bridge/src/session-ids.test.ts new file mode 100644 index 0000000..4789093 --- /dev/null +++ b/bridge/src/session-ids.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { AgentSessionIdPersister } from './session-ids.js'; +import type { Session } from './types.js'; + +describe('AgentSessionIdPersister — Pi', () => { + it('persists Pi native session identity without claiming a Claude session id', async () => { + const session: Session = { + id: 'ftown-session', + name: 'Pi', + command: 'pi', + status: 'running', + bridgeId: 'bridge', + createdAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + shellType: 'pi', + }; + const saved: Session[] = []; + const persister = new AgentSessionIdPersister({ + store: { + loadSession: async () => session, + saveSession: async (next) => { saved.push({ ...next }); }, + } as any, + publishSessionUpdate: async () => {}, + }); + + await persister.persist({ + sessionId: 'ftown-session', + eventName: 'SessionStart', + source: 'env', + data: { + session_id: 'pi-session-uuid', + session_file: '/tmp/pi-session.jsonl', + }, + }); + + assert.equal(saved.length, 1); + assert.equal(saved[0].piSessionId, 'pi-session-uuid'); + assert.equal(saved[0].piSessionFile, '/tmp/pi-session.jsonl'); + assert.equal(saved[0].claudeSessionId, undefined); + }); +}); diff --git a/bridge/src/session-ids.ts b/bridge/src/session-ids.ts index bb6c371..ab7880f 100644 --- a/bridge/src/session-ids.ts +++ b/bridge/src/session-ids.ts @@ -11,14 +11,17 @@ interface CachedAgentIds { claude?: string; cursor?: string; codex?: string; + pi?: string; + piFile?: string; isCodex?: boolean; + isPi?: boolean; } /** - * Persists agent-native session ids (Claude/Codex session_id, Cursor - * conversation_id) from hook events onto the stored session record, with an - * in-memory cache of the last persisted ids to skip disk reads on the hot - * hook path. + * Persists agent-native session identity (Claude/Codex/Pi session_id, Pi + * session_file, or Cursor conversation_id) from hook events onto the stored + * session record, with an in-memory cache of the last persisted values to skip + * disk reads on the hot hook path. */ export class AgentSessionIdPersister { private readonly cache = new Map(); @@ -32,26 +35,37 @@ export class AgentSessionIdPersister { const rawAgentId = hookEvent.data['session_id']; const rawCursorId = hookEvent.data['conversation_id']; + const rawSessionFile = hookEvent.data['session_file']; // Claude Code AND Codex hooks carry session_id (which field it lands in // depends on the session's shellType); Cursor hooks carry conversation_id. const agentId = typeof rawAgentId === 'string' && rawAgentId ? rawAgentId : undefined; const cursorId = typeof rawCursorId === 'string' && rawCursorId ? rawCursorId : undefined; - if (!agentId && !cursorId) return; + const sessionFile = typeof rawSessionFile === 'string' && rawSessionFile + ? rawSessionFile + : undefined; + if (!agentId && !cursorId && !sessionFile) return; const cached = this.cache.get(hookEvent.sessionId); if (cached - && (!agentId || (cached.isCodex ? cached.codex === agentId : cached.claude === agentId)) - && (!cursorId || cached.cursor === cursorId)) { + && (!agentId || (cached.isPi + ? cached.pi === agentId + : cached.isCodex ? cached.codex === agentId : cached.claude === agentId)) + && (!cursorId || cached.cursor === cursorId) + && (!sessionFile || cached.piFile === sessionFile)) { return; } const session = await this.deps.store.loadSession(hookEvent.sessionId); if (!session) return; const isCodex = session.shellType === 'codex'; + const isPi = session.shellType === 'pi'; let changed = false; if (agentId) { - if (isCodex && session.codexSessionId !== agentId) { + if (isPi && session.piSessionId !== agentId) { + session.piSessionId = agentId; + changed = true; + } else if (isCodex && session.codexSessionId !== agentId) { session.codexSessionId = agentId; changed = true; } else if (!isCodex && session.claudeSessionId !== agentId) { @@ -63,12 +77,19 @@ export class AgentSessionIdPersister { session.cursorSessionId = cursorId; changed = true; } + if (isPi && sessionFile && session.piSessionFile !== sessionFile) { + session.piSessionFile = sessionFile; + changed = true; + } if (!changed) { this.cache.set(hookEvent.sessionId, { claude: session.claudeSessionId, cursor: session.cursorSessionId, codex: session.codexSessionId, + pi: session.piSessionId, + piFile: session.piSessionFile, isCodex, + isPi, }); return; } @@ -80,7 +101,10 @@ export class AgentSessionIdPersister { claude: session.claudeSessionId, cursor: session.cursorSessionId, codex: session.codexSessionId, + pi: session.piSessionId, + piFile: session.piSessionFile, isCodex, + isPi, }); await this.deps.publishSessionUpdate(session); } diff --git a/bridge/src/types.ts b/bridge/src/types.ts index ffb8057..41d21a0 100644 --- a/bridge/src/types.ts +++ b/bridge/src/types.ts @@ -43,6 +43,8 @@ export interface Session { claudeSessionId?: string; cursorSessionId?: string; codexSessionId?: string; + piSessionId?: string; + piSessionFile?: string; env?: Record; parentSessionId?: string; runtime?: SessionRuntime; @@ -192,6 +194,8 @@ export interface CreateSessionPayload { claudeSessionId?: string; cursorSessionId?: string; codexSessionId?: string; + piSessionId?: string; + piSessionFile?: string; parentSessionId?: string; orchestrator?: boolean; suppressBriefing?: boolean; diff --git a/bridge/src/usage-collector.test.ts b/bridge/src/usage-collector.test.ts index 6a25f89..aa28df2 100644 --- a/bridge/src/usage-collector.test.ts +++ b/bridge/src/usage-collector.test.ts @@ -167,6 +167,168 @@ describe('collectSessionUsage — codex extractor', () => { }); }); +describe('collectSessionUsage — Pi extractor', () => { + const workingDir = '/Users/x/projects/pi-demo'; + + it('sums assistant-message usage and attributes it by provider/model', async () => { + const piSessionsDir = join(root, 'pi-sums'); + const dir = join(piSessionsDir, '--Users-x-projects-pi-demo--'); + await mkdir(dir, { recursive: true }); + const lines = [ + JSON.stringify({ type: 'session', version: 3, timestamp: '2026-08-08T10:00:01.000Z', cwd: workingDir }), + JSON.stringify({ type: 'message', id: 'u1', message: { role: 'user', content: 'hello' } }), + JSON.stringify({ + type: 'message', id: 'a1', message: { + role: 'assistant', provider: 'anthropic', model: 'claude-sonnet-4', + usage: { input: 100, output: 20, cacheRead: 300, cacheWrite: 40 }, + }, + }), + 'not json', + JSON.stringify({ + type: 'message', id: 'a2', message: { + role: 'assistant', provider: 'openai', model: 'gpt-5', + usage: { input: 10, output: 5, cacheRead: 2, cacheWrite: 0 }, + }, + }), + ]; + await writeFile(join(dir, '2026-08-08T10-00-01-000Z_pi.jsonl'), lines.join('\n') + '\n'); + + const usage = await collectSessionUsage( + { shellType: 'pi', workingDir, createdAt: '2026-08-08T10:00:00.000Z' }, + { piSessionsDir }, + ); + + assert.ok(usage); + assert.equal(usage.harness, 'pi'); + assert.equal(usage.inputTokens, 110); + assert.equal(usage.outputTokens, 25); + assert.equal(usage.cacheReadTokens, 302); + assert.equal(usage.cacheWriteTokens, 40); + assert.equal(usage.totalTokens, 477); + assert.deepEqual(usage.models, ['anthropic/claude-sonnet-4', 'openai/gpt-5']); + assert.deepEqual(usage.perModel, [ + { + model: 'anthropic/claude-sonnet-4', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 300, + cacheWriteTokens: 40, + }, + { + model: 'openai/gpt-5', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 2, + cacheWriteTokens: 0, + }, + ]); + }); + + it('selects the closest session created after the ftown session in a shared workspace', async () => { + const piSessionsDir = join(root, 'pi-disambig'); + const dir = join(piSessionsDir, '--Users-x-projects-pi-demo--'); + await mkdir(dir, { recursive: true }); + const writePi = async (file: string, timestamp: string, input: number) => { + await writeFile(join(dir, file), [ + JSON.stringify({ type: 'session', version: 3, timestamp, cwd: workingDir }), + JSON.stringify({ + type: 'message', id: `a-${input}`, message: { + role: 'assistant', provider: 'anthropic', model: 'claude-sonnet-4', + usage: { input, output: 1, cacheRead: 0, cacheWrite: 0 }, + }, + }), + ].join('\n') + '\n'); + }; + await writePi('older.jsonl', '2026-08-08T09:59:00.000Z', 1); + await writePi('match.jsonl', '2026-08-08T10:00:02.000Z', 42); + await writePi('later.jsonl', '2026-08-08T10:05:00.000Z', 99); + + const usage = await collectSessionUsage( + { shellType: 'pi', workingDir, createdAt: '2026-08-08T10:00:00.000Z' }, + { piSessionsDir }, + ); + assert.ok(usage); + assert.equal(usage.inputTokens, 42); + }); + + it('uses the native session file from the Pi extension when available', async () => { + const piSessionsDir = join(root, 'pi-native'); + const nativeFile = join(piSessionsDir, 'pi-native-session.jsonl'); + await mkdir(piSessionsDir, { recursive: true }); + await writeFile(nativeFile, [ + JSON.stringify({ type: 'session', version: 3, id: 'pi-native', cwd: '/actual/workdir' }), + JSON.stringify({ + type: 'message', id: 'a1', message: { + role: 'assistant', provider: 'openai', model: 'gpt-5', + usage: { input: 77, output: 9, cacheRead: 3, cacheWrite: 0 }, + }, + }), + ].join('\n') + '\n'); + + const usage = await collectSessionUsage( + { + shellType: 'pi', + workingDir: '/ambiguous/shared/workdir', + piSessionId: 'pi-native', + piSessionFile: nativeFile, + }, + { piSessionsDir }, + ); + + assert.ok(usage); + assert.equal(usage.inputTokens, 77); + assert.equal(usage.outputTokens, 9); + }); + + it('rejects a native session file outside the Pi sessions directory', async () => { + const piSessionsDir = join(root, 'pi-contained'); + const outsideFile = join(root, 'outside-pi-session.jsonl'); + await mkdir(piSessionsDir, { recursive: true }); + await writeFile(outsideFile, JSON.stringify({ + type: 'message', + message: { role: 'assistant', model: 'gpt-5', usage: { input: 999 } }, + }) + '\n'); + + const usage = await collectSessionUsage( + { shellType: 'pi', workingDir, piSessionFile: outsideFile }, + { piSessionsDir }, + ); + + assert.equal(usage, null); + }); + + it('ignores malformed, negative, and non-finite Pi token values', async () => { + const piSessionsDir = join(root, 'pi-malformed'); + const nativeFile = join(piSessionsDir, 'malformed.jsonl'); + await mkdir(piSessionsDir, { recursive: true }); + await writeFile(nativeFile, [ + JSON.stringify({ + type: 'message', message: { + role: 'assistant', provider: 'openai', model: 'gpt-5', + usage: { input: -10, output: '5', cacheRead: null, cacheWrite: 1 / 0 }, + }, + }), + JSON.stringify({ + type: 'message', message: { + role: 'assistant', provider: 'openai', model: 'gpt-5', + usage: { input: 7, output: 2, cacheRead: 1, cacheWrite: 0 }, + }, + }), + ].join('\n') + '\n'); + + const usage = await collectSessionUsage( + { shellType: 'pi', workingDir, piSessionFile: nativeFile }, + { piSessionsDir }, + ); + + assert.ok(usage); + assert.equal(usage.inputTokens, 7); + assert.equal(usage.outputTokens, 2); + assert.equal(usage.cacheReadTokens, 1); + assert.equal(usage.cacheWriteTokens, 0); + }); +}); + function kimiUsageRecord(model: string, usage: Record): string { return JSON.stringify({ type: 'usage.record', model, usage, usageScope: 'turn', time: 1 }); } diff --git a/bridge/src/usage-collector.ts b/bridge/src/usage-collector.ts index e1c1b63..e3e85e9 100644 --- a/bridge/src/usage-collector.ts +++ b/bridge/src/usage-collector.ts @@ -1,8 +1,8 @@ import { createReadStream } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; +import { readFile, readdir, realpath, stat } from 'node:fs/promises'; import { createInterface } from 'node:readline'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve, sep } from 'node:path'; import type { ModelUsage, Session, SessionUsage } from './types.js'; @@ -13,10 +13,10 @@ import type { ModelUsage, Session, SessionUsage } from './types.js'; * not by shellType: provider flavors (zai/kimi/deepseek/fireworks) run the * claude CLI under the hood and get a claudeSessionId persisted by the hook * pipeline, so any session with a claudeSessionId uses the claude extractor - * regardless of shellType. The kimi-code harness has no native session-id field - * on Session, so its extractor keys off workingDir + createdAt instead (see - * collectKimiCodeUsage). Sessions with none of these (cursor, grok, plain shell) - * have no structured usage source and yield null. + * regardless of shellType. Pi records a native id/file when its extension is + * active and falls back to workingDir + createdAt discovery; kimi-code uses the + * same workdir-based discovery model. Sessions with none of these (cursor, + * grok, plain shell) have no structured usage source and yield null. * * TODO(opencode): add an opencode extractor once Session carries an * opencodeSessionId (no such field exists yet). @@ -28,10 +28,10 @@ import type { ModelUsage, Session, SessionUsage } from './types.js'; export type UsageSessionRef = Pick< Session, - 'shellType' | 'claudeSessionId' | 'codexSessionId' | 'workingDir' + 'shellType' | 'claudeSessionId' | 'codexSessionId' | 'piSessionId' | 'piSessionFile' | 'workingDir' > & - // createdAt is only consumed by the kimi-code extractor to disambiguate - // sessions sharing a workingDir; optional so non-kimi callers/tests need not set it. + // createdAt is consumed by workdir-based extractors to disambiguate sessions + // sharing a workingDir; optional so id-based callers/tests need not set it. Partial>; export interface UsageCollectorOptions { @@ -39,6 +39,8 @@ export interface UsageCollectorOptions { claudeProjectsDir?: string; /** Override for tests. Default: ~/.codex/sessions */ codexSessionsDir?: string; + /** Override for tests. Default: ~/.pi/agent/sessions */ + piSessionsDir?: string; /** * Override for tests. Default: ~/.kimi-code. The kimi-code home dir holding * session_index.jsonl and sessions/<...>/session_/. @@ -71,6 +73,14 @@ export async function collectSessionUsage( if (session.codexSessionId) { return await collectCodexUsage(session.codexSessionId, options); } + if (session.shellType === 'pi' && session.workingDir) { + return await collectPiUsage( + session.workingDir, + session.createdAt, + options, + session.piSessionFile, + ); + } if (session.shellType === 'kimi-code' && session.workingDir) { return await collectKimiCodeUsage(session.workingDir, session.createdAt, options); } @@ -80,6 +90,150 @@ export async function collectSessionUsage( } } +function piWorkspaceDirName(workingDir: string): string { + const resolved = resolve(workingDir); + return `--${resolved.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`; +} + +interface PiSessionHeader { + type?: string; + timestamp?: string; + cwd?: string; +} + +interface PiUsageLine { + type?: string; + message?: { + role?: string; + provider?: string; + model?: string; + usage?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + }; + }; +} + +async function resolvePiSessionFile( + baseDir: string, + workingDir: string, + sessionCreatedAt: string | undefined, + deadline: number, +): Promise { + const dir = join(baseDir, piWorkspaceDirName(workingDir)); + let files: string[]; + try { + files = (await readdir(dir)).filter((file) => file.endsWith('.jsonl')); + } catch { + return null; + } + + const candidates: Array<{ path: string; createdAtMs: number }> = []; + for (const file of files) { + const path = join(dir, file); + for await (const raw of jsonlLines(path, deadline)) { + const header = raw as PiSessionHeader; + if (header?.type === 'session' && header.cwd === resolve(workingDir)) { + const createdAtMs = header.timestamp ? Date.parse(header.timestamp) : NaN; + candidates.push({ path, createdAtMs: Number.isNaN(createdAtMs) ? 0 : createdAtMs }); + } + break; + } + } + if (candidates.length === 0) return null; + + const sessionMs = sessionCreatedAt ? Date.parse(sessionCreatedAt) : NaN; + if (!Number.isNaN(sessionMs)) { + const atOrAfter = candidates.filter((candidate) => candidate.createdAtMs >= sessionMs); + if (atOrAfter.length > 0) { + return atOrAfter.reduce((closest, candidate) => + candidate.createdAtMs < closest.createdAtMs ? candidate : closest).path; + } + } + + return candidates.reduce((newest, candidate) => + candidate.createdAtMs >= newest.createdAtMs ? candidate : newest).path; +} + +async function collectPiUsage( + workingDir: string, + sessionCreatedAt: string | undefined, + options: UsageCollectorOptions, + nativeSessionFile?: string, +): Promise { + const baseDir = options.piSessionsDir ?? join(homedir(), '.pi', 'agent', 'sessions'); + const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const candidatePath = nativeSessionFile + ?? await resolvePiSessionFile(baseDir, workingDir, sessionCreatedAt, deadline); + if (!candidatePath) return null; + const filePath = await containedPiSessionFile(baseDir, candidatePath); + if (!filePath) return null; + + let counted = 0; + const byModel = new Map(); + for await (const raw of jsonlLines(filePath, deadline)) { + const entry = raw as PiUsageLine; + const message = entry?.type === 'message' ? entry.message : undefined; + const usage = message?.role === 'assistant' ? message.usage : undefined; + if (!message || !usage || typeof message.model !== 'string' || !message.model) continue; + const model = typeof message.provider === 'string' && message.provider + ? `${message.provider}/${message.model}` + : message.model; + + let acc = byModel.get(model); + if (!acc) { + acc = { model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }; + byModel.set(model, acc); + } + acc.inputTokens += safeTokenCount(usage.input); + acc.outputTokens += safeTokenCount(usage.output); + acc.cacheReadTokens += safeTokenCount(usage.cacheRead); + acc.cacheWriteTokens += safeTokenCount(usage.cacheWrite); + counted += 1; + } + if (counted === 0) return null; + + const perModel = [...byModel.values()]; + const sum = (pick: (model: ModelUsage) => number): number => + perModel.reduce((total, model) => total + pick(model), 0); + const inputTokens = sum((model) => model.inputTokens); + const outputTokens = sum((model) => model.outputTokens); + const cacheReadTokens = sum((model) => model.cacheReadTokens); + const cacheWriteTokens = sum((model) => model.cacheWriteTokens); + + return { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens, + models: perModel.map((model) => model.model), + perModel, + harness: 'pi', + collectedAt: new Date().toISOString(), + }; +} + +function safeTokenCount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0; +} + +async function containedPiSessionFile(baseDir: string, filePath: string): Promise { + try { + const [base, file, info] = await Promise.all([ + realpath(baseDir), + realpath(filePath), + stat(filePath), + ]); + if (!info.isFile() || !file.startsWith(`${base}${sep}`)) return null; + return file; + } catch { + return null; + } +} + /** Stream a JSONL file line by line (files can be tens of MB — never buffer whole). */ async function* jsonlLines(filePath: string, deadline: number): AsyncGenerator { const stream = createReadStream(filePath, { encoding: 'utf8' }); diff --git a/bridge/src/wire-types.ts b/bridge/src/wire-types.ts index 8a8f683..4c982c3 100644 --- a/bridge/src/wire-types.ts +++ b/bridge/src/wire-types.ts @@ -75,7 +75,7 @@ export interface MailMessage { deliveredVia?: string; } -export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'grok' | 'kimi-code' | 'opencode' | 'shell'; +export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'grok' | 'pi' | 'kimi-code' | 'opencode' | 'shell'; export type LoopSchedule = | { kind: 'interval'; everyMs: number } diff --git a/bridge/src/workflow-runner-cli.ts b/bridge/src/workflow-runner-cli.ts index f081422..6a72b9a 100644 --- a/bridge/src/workflow-runner-cli.ts +++ b/bridge/src/workflow-runner-cli.ts @@ -232,7 +232,7 @@ class StderrLogger implements Logger { // gain a runtime import of harness-registry.js), but asserted against the // registry-derived WorkflowShell type so any drift is a compile error. type Equals = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -const SHELLS = ['claude', 'cursor', 'codex', 'opencode', 'shell'] as const satisfies readonly WorkflowShell[]; +const SHELLS = ['claude', 'cursor', 'codex', 'pi', 'opencode', 'shell'] as const satisfies readonly WorkflowShell[]; const _shellsCoverEveryWorkflowShell: Equals = true; void _shellsCoverEveryWorkflowShell; diff --git a/e2e/helpers/loops.ts b/e2e/helpers/loops.ts index 70e62f9..43729dd 100644 --- a/e2e/helpers/loops.ts +++ b/e2e/helpers/loops.ts @@ -6,7 +6,7 @@ import { expect, type Page } from "@playwright/test"; * targeted by their placeholder / options (stable, user-visible anchors). */ -export type LoopHarness = "claude" | "cursor" | "codex" | "grok" | "kimi-code" | "opencode" | "shell"; +export type LoopHarness = "claude" | "cursor" | "codex" | "grok" | "pi" | "kimi-code" | "opencode" | "shell"; export interface CreateLoopViaUiInput { name: string; diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx index a80f29e..6f335d6 100644 --- a/ui/src/app/page.tsx +++ b/ui/src/app/page.tsx @@ -102,6 +102,9 @@ const SUPPORTED_AGENTS = [ { name: "Claude Code", detail: "Anthropic + API providers" }, { name: "Cursor Agent", detail: "agent CLI" }, { name: "Codex", detail: "OpenAI Codex CLI" }, + { name: "Grok", detail: "xAI coding agent CLI" }, + { name: "Pi", detail: "Hooks, ftown tools, and native resume" }, + { name: "Kimi Code", detail: "Moonshot coding agent CLI" }, { name: "opencode", detail: "interactive CLI" }, { name: "Shell", detail: "zsh on bridge" }, ] as const; @@ -120,12 +123,12 @@ const STEPS = [ { n: "3", title: "Orchestrate the swarm", - desc: "Spawn Claude Code, Cursor, Codex, opencode, or a shell. Run them in parallel, resume chats, and drive them all from anywhere.", + desc: "Spawn Claude Code, Cursor, Codex, Grok, Pi, Kimi Code, opencode, or a shell. Run them in parallel, organize agent trees, and drive them all from anywhere.", }, ] as const; const STATS = [ - { value: "5", label: "agent CLIs supported" }, + { value: "7", label: "agent CLIs supported" }, { value: "∞", label: "parallel sessions" }, { value: "100%", label: "self-hosted" }, { value: "MIT", label: "open source" }, @@ -160,7 +163,7 @@ const FEATURES = [ { icon: , title: "Multi-agent orchestration", - desc: "Run Claude Code, Cursor Agent, Codex, opencode, or a raw shell — each as a full interactive TUI streamed to your browser.", + desc: "Run seven coding-agent CLIs plus raw shells as full interactive TUIs. Build parent/child teams, move sessions between groups, and coordinate them through durable mail.", }, { icon: , @@ -175,12 +178,12 @@ const FEATURES = [ { icon: , title: "Resume where you left off", - desc: "Pick up prior Claude or Cursor Agent chats per workspace. Bridge exec lists sessions from the remote machine.", + desc: "Pick up native Claude, Cursor, Codex, and Pi sessions; Kimi Code continues by workspace when a bridge restarts.", }, { icon: , - title: "Hook events in the UI", - desc: "Bridge installs notify hooks into Claude and Cursor configs so tool use and activity show up live in the dashboard.", + title: "Live activity & token usage", + desc: "See agent activity, tool events, models, and token usage while sessions are running—not only after they stop.", }, { icon: , @@ -333,7 +336,8 @@ export default async function LandingPage() {

- ftown streams Claude Code, Cursor, Codex, opencode, and shells from any + ftown streams Claude Code, Cursor, Codex, Grok, Pi, Kimi Code, opencode, + and shells from any machine to your desktop or phone — no SSH, no port forwarding. Self-hosted, on the subscriptions you already pay for. Terminals stay{" "} @@ -525,8 +529,9 @@ export default async function LandingPage() {

Replace "keep an agent awake and polling" hacks with a first-class schedule. Fire a loop on an interval (every 5m) or - cron with a timezone, pick the harness — Claude Code, Cursor, Codex, - opencode, or shell — plus workdir and model. Every fire spawns a full + cron with a timezone, pick the harness — including Pi, Claude Code, + Cursor, Codex, Grok, Kimi Code, opencode, or shell — plus workdir and + model. Every fire spawns a full session grouped under the loop: watch it live, scroll back, or take over.

diff --git a/ui/src/components/LoopFormModal.tsx b/ui/src/components/LoopFormModal.tsx index 8fe5bac..8132bc0 100644 --- a/ui/src/components/LoopFormModal.tsx +++ b/ui/src/components/LoopFormModal.tsx @@ -462,6 +462,7 @@ export function LoopFormModal({ isOpen, onClose, onSubmit, bridges, editingLoop + diff --git a/ui/src/components/NewSessionModal.tsx b/ui/src/components/NewSessionModal.tsx index af837c3..73bfadb 100644 --- a/ui/src/components/NewSessionModal.tsx +++ b/ui/src/components/NewSessionModal.tsx @@ -227,6 +227,7 @@ const VALID_SHELL_TYPES: ShellType[] = [ "cursor", "codex", "grok", + "pi", "kimi-code", "opencode", "shell", @@ -239,13 +240,14 @@ interface LastSessionDefaults { model?: string; } -type TopShell = "claude" | "cursor" | "codex" | "grok" | "kimi-code" | "opencode" | "shell"; +type TopShell = "claude" | "cursor" | "codex" | "grok" | "pi" | "kimi-code" | "opencode" | "shell"; type ClaudeFlavor = "standard" | "zai" | "kimi" | "deepseek" | "fireworks"; function shellTypeToTop(s: ShellType | undefined): { top: TopShell; flavor: ClaudeFlavor } { if (s === "cursor") return { top: "cursor", flavor: "standard" }; if (s === "codex") return { top: "codex", flavor: "standard" }; if (s === "grok") return { top: "grok", flavor: "standard" }; + if (s === "pi") return { top: "pi", flavor: "standard" }; if (s === "kimi-code") return { top: "kimi-code", flavor: "standard" }; if (s === "opencode") return { top: "opencode", flavor: "standard" }; if (s === "shell") return { top: "shell", flavor: "standard" }; @@ -257,7 +259,7 @@ function shellTypeToTop(s: ShellType | undefined): { top: TopShell; flavor: Clau } function resolveShellType(top: TopShell, flavor: ClaudeFlavor): ShellType { - if (top === "cursor" || top === "codex" || top === "grok" || top === "kimi-code" || top === "opencode" || top === "shell") return top; + if (top === "cursor" || top === "codex" || top === "grok" || top === "pi" || top === "kimi-code" || top === "opencode" || top === "shell") return top; if (flavor === "standard") return "claude"; return flavor; } @@ -320,6 +322,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, const [fireworksModels, setFireworksModels] = useState(FIREWORKS_DEFAULT_MODELS); const [zaiModels, setZaiModels] = useState(ZAI_DEFAULT_MODELS); const [grokModel, setGrokModel] = useState(GROK_MODEL_OPTIONS[0]); + const [piModel, setPiModel] = useState(""); const [kimiCodeModel, setKimiCodeModel] = useState(KIMI_CODE_MODEL_OPTIONS[0].value); const [autoCompactWindow, setAutoCompactWindow] = useState(""); const [submitError, setSubmitError] = useState(null); @@ -403,6 +406,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setFireworksModels(getStoredFireworksModels()); setZaiModels(getStoredZaiModels()); setGrokModel(restoredShellType === "grok" && typeof parsed.model === "string" ? parsed.model : GROK_MODEL_OPTIONS[0]); + setPiModel(restoredShellType === "pi" && typeof parsed.model === "string" ? parsed.model : ""); setKimiCodeModel(restoredShellType === "kimi-code" && typeof parsed.model === "string" ? parsed.model : KIMI_CODE_MODEL_OPTIONS[0].value); setAutoCompactWindow(getStoredAutoCompactWindow()); setSubmitError(null); @@ -463,7 +467,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, try { await onSubmit("", { name: name.trim() || undefined, - model: shellType === "grok" ? grokModel : shellType === "kimi-code" ? kimiCodeModel : undefined, + model: shellType === "grok" ? grokModel : shellType === "pi" ? piModel.trim() || undefined : shellType === "kimi-code" ? kimiCodeModel : undefined, workingDir: workingDir.trim() || undefined, bridgeId: effectiveBridgeId || undefined, shellType, @@ -495,6 +499,9 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, if (shellType === "grok") { lastDefaults.model = grokModel; } + if (shellType === "pi") { + lastDefaults.model = piModel.trim() || undefined; + } if (shellType === "kimi-code") { lastDefaults.model = kimiCodeModel; } @@ -508,6 +515,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setTopShell("claude"); setClaudeFlavor("standard"); setGrokModel(GROK_MODEL_OPTIONS[0]); + setPiModel(""); setKimiCodeModel(KIMI_CODE_MODEL_OPTIONS[0].value); setBridgeId(""); setShowSuggestions(false); @@ -516,7 +524,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setSelectedCursorSessionId(null); setSelectedCursorSummary(null); onClose(); - }, [shellType, topShell, claudeFlavor, name, workingDir, effectiveBridgeId, hostname, selectedClaudeSessionId, selectedCursorSessionId, fireworksModels, zaiModels, grokModel, kimiCodeModel, autoCompactWindow, onSubmit, onClose]); + }, [shellType, topShell, claudeFlavor, name, workingDir, effectiveBridgeId, hostname, selectedClaudeSessionId, selectedCursorSessionId, fireworksModels, zaiModels, grokModel, piModel, kimiCodeModel, autoCompactWindow, onSubmit, onClose]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -577,6 +585,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, + @@ -689,6 +698,22 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults,
)} + {shellType === "pi" && ( +
+ + setPiModel(e.target.value)} + placeholder="Optional, e.g. anthropic/claude-sonnet-4" + className={INPUT_CLASS + " text-sm"} + /> +
+ )} + {topShell === "claude" && (