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/docs/presubmit-report.md b/docs/presubmit-report.md index 95ae493..e820a07 100644 --- a/docs/presubmit-report.md +++ b/docs/presubmit-report.md @@ -1,48 +1,55 @@ -# Presubmit report: move sessions between parents +# Presubmit report: first-class Pi integration -Date: 2026-08-06 -Base: `origin/main` (`892c26c`) -Scope: bridge reparenting validation/API documentation, dashboard drag-and-drop wiring, and browser acceptance coverage. +Date: 2026-08-08 +Base: `origin/main` (`813789f`) +Scope: native Pi harness support, lifecycle hooks and token usage, bundled model-facing ftown tools, UI/loop/workflow exposure, package assets, and public documentation. ## Gate | Check | Result | Evidence | | --- | --- | --- | -| Bridge unit tests | ✅ | `npm test -- --run`: 526 passed, 0 failed. | -| Bridge typecheck/build | ✅ | `npm run build`: `tsc` completed successfully. | -| Bridge package | ✅ | `npm pack --dry-run`: produced the `ftown-bridge-0.19.5.tgz` manifest. | -| UI unit tests | ✅ | `npm test -- --run`: 129 passed, 0 failed. | -| UI typecheck | ✅ | `npx tsc --noEmit`: no errors. | -| UI production build | ✅ | `npm run build`: completed successfully; existing Edge-runtime and metadata warnings remain. | -| New browser acceptance | ✅ | Focused Playwright run: center-drop reparent and bridge-root detach passed in Chromium. | -| Full browser suite | ❌ | 28 passed, 2 skipped, 1 failed: the WebRTC test selected Cloud instead of P2P. The identical failure was reproduced from a clean `origin/main` worktree, so it is an environmental/baseline failure rather than a regression in this diff. | -| Lint | ⚠️ | The UI lint command launches Next.js's interactive first-time configuration; no repository lint configuration is available for a non-interactive gate. | -| Format | ⚠️ | No enforced repository format-check script is configured. `git diff --check` passes. | +| Bridge unit tests | ✅ | `npm test`: 568 passed, 0 failed. | +| Bridge typecheck/build | ✅ | `npm run build`: TypeScript compilation completed successfully. | +| Bridge package | ✅ | `npm pack --dry-run`: version 0.19.8 includes `pi-extension/ftown.js` and `pi-extension/API.md`. | +| UI unit tests | ✅ | `npm test -- --run`: 133 passed, 0 failed across 12 files. | +| UI production build | ✅ | The E2E-environment production build completed successfully. | +| E2E typecheck | ✅ | The E2E TypeScript check completed without errors. | +| Full browser suite | ❌ | 30 passed, 2 skipped, 1 failed. The unchanged direct-transport WebRTC scenario selected Cloud instead of P2P on this Mac; an isolated retry reproduced it. Linux CI is the release decision for this environment-dependent case. | +| GitHub Linux E2E | ✅ | The `direct-transport-e2e` workflow passed in 4m16s, including the P2P scenario that failed in the local macOS environment. | +| Secret scan | ✅ | Gitleaks found no leaks in the staged diff. | +| Diff integrity | ✅ | `git diff --check` completed successfully. | +| Lint | ⚠️ | `npm run lint` opens Next.js's interactive first-time ESLint setup; the repository has no checked-in non-interactive ESLint configuration and CI does not enforce this command. | ## Scorecard | Aspect | Score | Band | Why | | --- | ---: | --- | --- | -| Functional completeness | 83 | Adequate | Both API and drag workflows are implemented; the new browser happy path now passes. | -| Frontend fluency | 68 | Weak | Drop zones and feedback are coherent, but drag remains mouse-oriented and the mutation has no visible pending/error state. | -| Monorepo awareness | 88 | Strong | Changes use the existing bridge controller/RPC, session store, UI hook, and E2E harness. | -| Convention consistency | 91 | Strong | The implementation follows the established update/save/publish and dashboard state patterns without new dependencies. | -| Code quality | 72 | Adequate | Drop policy is isolated and typed; the large session-list component still owns substantial event wiring. | -| Server communication and data flow | 74 | Adequate | UI, RPC, HTTP controller, persistence, and publication are wired end-to-end with authoritative server validation. | -| Testing | 81 | Adequate | Pure policy, controller, RPC, real HTTP/store, and browser drag behavior are covered. | -| Commit hygiene | 42 | Poor | The grading panel ran before the work was split into reviewable commits; this is corrected before push. | -| Scope and regression discipline | 88 | Strong | No dependencies or unrelated product areas changed; the only full-suite failure reproduces on the base revision. | -| AI-leveraged understanding | 89 | Strong | Existing seams were reused, validation is defense-in-depth, and the separate startup investigation records falsified hypotheses without guessing. | - -Weighted score: **79/100**. The two highest-weight differentiators are monorepo awareness and convention consistency. +| Functional completeness | 90 | Strong | Pi creation, exact resume, lifecycle, usage, loops/workflows, mail, session operations, and extension installation are implemented end to end; the revive-reporting issue found during review was fixed and regression-tested. | +| Frontend fluency | 84 | Adequate | Pi is consistently exposed in creation, models, lists, workflows, and landing-page capabilities, though the existing session presentation component remains dense. | +| Monorepo awareness | 90 | Strong | The change uses the existing harness registry, local bearer API, persistence, publication, mail, loop, UI, and E2E seams. | +| Convention consistency | 89 | Strong | Command construction, native identity persistence, hook processing, UI naming, and package publication follow established project patterns. | +| Code quality | 88 | Strong | Transcript access is realpath-contained, token inputs are normalized, stale credentials are retried correctly, and revive semantics are centralized; the bundled extension is still a large module. | +| Server communication and data flow | 91 | Strong | Native hooks flow through authenticated local routes into serialized persistence/publication, while model tools use explicit schemas, safety boundaries, and mutation deduplication. | +| Testing | 84 | Adequate | Unit and integration coverage spans launch/resume, hooks, tools, package content, path containment, malformed usage, revive semantics, and stale-token fallback; a native Pi-loader smoke test remains desirable. | +| Commit hygiene | 83 | Adequate | The branch has one cohesive conventional feature commit with implementation, contract, assets, and tests, but its 40-file size limits bisectability. | +| Scope and regression discipline | 92 | Strong | Unrelated factory/roadmap work was excluded, no speculative subsystem was added, and the only browser-suite failure is outside the Pi path. | +| AI-leveraged understanding | 92 | Strong | Review findings were repaired at trust boundaries and locked with focused regressions; existing abstractions were extended rather than duplicated. | + +Weighted score: **88/100 (Strong)**. ## Verdict -**NO-GO under the strict local gate**, because the repository's enforced full E2E command exits non-zero. The failure is not caused by this change: it reproduces unchanged on `origin/main`, while the newly added drag-and-reparent browser scenario is green. A clean CI environment should be used as the release decision for this environmental WebRTC case. +**GO.** All Pi-specific tests, builds, package checks, typechecks, security checks, deployment checks, and the GitHub Linux E2E workflow pass. The sole local red result is the environment-dependent WebRTC P2P browser scenario, which receives Cloud on this Mac; the same scenario passes in the merge-gating Linux environment. -## Prioritized follow-ups +## Findings resolved during presubmit -1. Confirm the full E2E suite in CI or resolve the local WebRTC routing condition that makes the baseline select Cloud instead of P2P. -2. Keep backend, frontend/E2E, package-version, and investigation/report changes in separate conventional commits. -3. Add visible failure feedback around `setSessionParent` if reparenting errors need to be recoverable directly from the dashboard. -4. Capture the exact stdout/stderr from the reported `npx -y ftown-bridge@latest` startup failure; the published package starts and reaches pairing in a clean reproduction. +1. Pi revive responses now distinguish builder-managed continuation from custom commands and report `resumed` accurately. +2. Pi transcript usage reads now require a regular file canonically contained under the Pi sessions directory, and malformed, negative, or non-finite token values are ignored. +3. Extension endpoint discovery now treats port and bearer token as one identity, allowing a fresh credential to recover when a restarted bridge reuses a stale port. +4. Agent-native identity documentation now includes Pi session IDs and transcript files. + +## Follow-ups + +1. Add a hermetic smoke test that loads the packaged extension through Pi's native loader. +2. Split the extension into transport, lifecycle/usage, and tool-registration modules after the contract stabilizes. +3. Add durable idempotency for mutating model-tool requests if retries must eventually cover ambiguous server failures. 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" && (