From 89a57ed539ce6f1883ebf6303c3cf5f014e8f7d8 Mon Sep 17 00:00:00 2001 From: Lanzelot1 <35410605+Lanzelot1@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:55:11 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat(pi):=20@cotal-ai/pi=20=E2=80=94=20nati?= =?UTF-8?q?ve-embed=20coding-agent=20mesh=20peer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embeds MeshAgent in a pi createAgentSession() and drives the loop off the inbox via InboxTurn (ack-on-surface; mid-turn steer). Adds the shared connector-core InboxTurn + ackInbox the embed adapters build on, an examples/03-pi manager, and docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 + docs/agent-frameworks.md | 66 + docs/architecture.md | 4 + examples/03-pi/README.md | 27 + examples/03-pi/package.json | 20 + examples/03-pi/src/manager.ts | 36 + examples/03-pi/tsconfig.json | 8 + extensions/connector-core/inbox-turn.smoke.ts | 120 ++ extensions/connector-core/src/agent.ts | 18 + extensions/connector-core/src/inbox-turn.ts | 122 ++ extensions/connector-core/src/index.ts | 1 + extensions/pi/package.json | 40 + extensions/pi/src/connector.ts | 51 + extensions/pi/src/index.ts | 2 + extensions/pi/src/main.ts | 6 + extensions/pi/src/peer.ts | 220 ++++ extensions/pi/tsconfig.json | 8 + package.json | 1 + pnpm-lock.yaml | 1103 +++++++++++++++++ pnpm-workspace.yaml | 2 + 20 files changed, 1858 insertions(+) create mode 100644 docs/agent-frameworks.md create mode 100644 examples/03-pi/README.md create mode 100644 examples/03-pi/package.json create mode 100644 examples/03-pi/src/manager.ts create mode 100644 examples/03-pi/tsconfig.json create mode 100644 extensions/connector-core/inbox-turn.smoke.ts create mode 100644 extensions/connector-core/src/inbox-turn.ts create mode 100644 extensions/pi/package.json create mode 100644 extensions/pi/src/connector.ts create mode 100644 extensions/pi/src/index.ts create mode 100644 extensions/pi/src/main.ts create mode 100644 extensions/pi/src/peer.ts create mode 100644 extensions/pi/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 10490b76..29fca9f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,9 @@ with no silent fallback. (native in-process plugin injected via `OPENCODE_CONFIG_CONTENT`). - **`@cotal-ai/connector-hermes`** (`extensions/connector-hermes`): the Hermes (Nous Research) adapter; includes a Python sidecar. +- **`@cotal-ai/pi`** (`extensions/pi`): native-embed adapter — embeds `MeshAgent` in the pi + agent's own process and drives its loop straight off the inbox, with true mid-turn `steer()`. + See [docs/agent-frameworks.md](docs/agent-frameworks.md). - **`@cotal-ai/cmux`** (`extensions/cmux`): the cmux integration: a driver over the cmux CLI plus a self-registering `cmux` Runtime and `TerminalLayout` provider. - **`@cotal-ai/tmux`** (`extensions/tmux`): the tmux integration: a driver over the tmux CLI diff --git a/docs/agent-frameworks.md b/docs/agent-frameworks.md new file mode 100644 index 00000000..72b177b3 --- /dev/null +++ b/docs/agent-frameworks.md @@ -0,0 +1,66 @@ +# Agent frameworks + +Besides Claude Code (see [claude-code-integration](claude-code-integration.md)), an agent +built on an embeddable agent library can join a Cotal space as a native lateral peer. The +first such adapter: + +| Extension | Framework | Language | +|---|---|---| +| `@cotal-ai/pi` | [pi coding agent](https://github.com/earendil-works/pi) | TypeScript | + +It joins the same mesh as the host-bound connectors and interoperates over the same +subjects, presence, and delivery modes. + +## The pattern: a native embedded peer + +The adapter embeds a Cotal endpoint in the framework's own process — not a separate +bridge. The shared piece is `MeshAgent` (in `@cotal-ai/connector-core`, the same runtime +behind the Claude Code and Codex connectors): it owns the NATS connection, presence, and a +stream-backed inbox, and emits `"incoming"` for each message. The adapter wires two things +around it: + +1. **Inbound drives the loop.** The peer drives straight off the inbox via an `InboxTurn` — + the inbox is the single source of truth, no parallel buffer. On `"incoming"` it surfaces + the front message, flips presence to `working`, runs the agent, and delivers the reply on + the same delivery mode (DM/anycast → DM the sender by id; channel → multicast back to that + channel), then flips to `idle`. Delivery is **ack-on-surface**: the surfaced run is + `drainInbox`-acked only once the turn completes, so a crash or restart redelivers and + nothing is lost. The loop owns delivery (always routed right, sent once); runs are + serialized; the peer answers DMs and anycasts but only replies on a channel when named + (never its own echoes — ambient chatter is ack-dropped). +2. **Mesh awareness as tools.** The model also gets read/presence tools via the framework's + tool mechanism (`defineTool()` for pi): `cotal_roster` (who's present) and `cotal_status` + (set its own status). Sending is left to the loop, so the model can't mis-route or + duplicate a reply. + +This makes the agent a real peer that wakes on traffic, like Claude Code — not a pull-only +tool caller. + +**pi** is a clean fit, since it ships as an embeddable library: `@cotal-ai/pi` embeds +`MeshAgent` alongside a pi `createAgentSession()` in one process, reads presence straight off +the session's own event stream (`agent_start` → `working`, `tool_execution_start` → the +running tool, `agent_end` → `idle`), and drives the loop with the session's own verbs — +`prompt()` to wake an idle session into a turn and `steer()` to fold a *same-scope* message +into a *live* one (true mid-turn drive, before the next LLM call; a different-scope message +waits for its own turn, so a private DM is never folded into a channel reply), with `abort()` +to interrupt. No external +channel, host process, or keystrokes — inbound `"incoming"` calls a method on the embedded +session. pi has no permission gate, so spawned peers run unattended (sandbox/containerize +per pi's own guidance); it needs Node ≥22.19 and a provider key in the env. + +A `Connector` extension (`buildLaunch`) lets the manager spawn it: it launches the peer via +`tsx` and forwards the launcher's identity (`COTAL_ID`), minted creds (`COTAL_CREDS`), and any +agent file (`COTAL_AGENT_FILE`), so under auth the peer authenticates as the id the manager +provisioned. The connector self-registers on import (`pi`); a composition root just imports it. + +## Running + +The pi connector has an example composition root under `examples/03-pi` (a manager that +imports `@cotal-ai/pi`). See its README to run it; in short: + +```bash +export ANTHROPIC_API_KEY=sk-... +pnpm cotal up +pnpm --filter @cotal-ai/example-03-pi manager +pnpm cotal start --name pi1 --role research --agent pi +``` diff --git a/docs/architecture.md b/docs/architecture.md index 240031ac..1cb0b532 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,6 +170,10 @@ Implementations stay self-contained and never import each other: the `cli` drive purely over the mesh (`start`/`stop`/`ps` control requests), so neither imports the other. Only the example wires them together. +Beyond the host-bound connectors, `@cotal-ai/pi` is a **native-embed** adapter: it embeds a +Cotal endpoint directly in the pi agent's own process and drives its run loop off the inbox — +see [agent-frameworks](agent-frameworks.md). + ## Integration surfaces (Claude Code + OpenCode) Each target agent exposes the same four surfaces. The adapters share one runtime diff --git a/examples/03-pi/README.md b/examples/03-pi/README.md new file mode 100644 index 00000000..e3e9a692 --- /dev/null +++ b/examples/03-pi/README.md @@ -0,0 +1,27 @@ +# Example 03 — pi coding-agent peers + +An agent built with the [pi coding agent](https://github.com/earendil-works/pi) joining a +Cotal space as a native lateral peer. The adapter (`@cotal-ai/pi`) embeds a Cotal endpoint +alongside a pi `createAgentSession()` in one process, reads presence off the session's own +event stream, exposes the mesh to the model as `cotal_*` tools, and drives the session on +inbound messages — `prompt()` wakes an idle session, `steer()` folds a same-scope message +into a live turn (true mid-turn drive) — so it answers DMs and anycasts, and replies on a +channel when mentioned by name. + +## Run + +```bash +pnpm cotal up # local NATS/JetStream (auth on; --open for a dev mesh) +export ANTHROPIC_API_KEY=sk-... # the peer's pi session calls the model (any pi-supported provider key works) +pnpm --filter @cotal-ai/example-03-pi manager # start the manager + +# spawn a peer (either form works) +pnpm cotal start --name pi1 --role research --agent pi +pnpm cotal watch # watch it join and reply +pnpm cotal join --name me --role human # then DM it: /dm pi1 hello +``` + +pi resolves its model and credentials via its own `AuthStorage` (falling back to provider +keys in the environment, e.g. `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`); pi has no permission +gate, so sandbox/containerize a spawned peer per pi's own guidance. Requires Node ≥22.19. See +[docs/agent-frameworks.md](../../docs/agent-frameworks.md) for how the adapter works. diff --git a/examples/03-pi/package.json b/examples/03-pi/package.json new file mode 100644 index 00000000..0f86825d --- /dev/null +++ b/examples/03-pi/package.json @@ -0,0 +1,20 @@ +{ + "name": "@cotal-ai/example-03-pi", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "scripts": { + "manager": "tsx src/manager.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "@cotal-ai/core": "workspace:*", + "@cotal-ai/manager": "workspace:*", + "@cotal-ai/pi": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.22.4" + } +} diff --git a/examples/03-pi/src/manager.ts b/examples/03-pi/src/manager.ts new file mode 100644 index 00000000..e1bce367 --- /dev/null +++ b/examples/03-pi/src/manager.ts @@ -0,0 +1,36 @@ +/** + * Composition root for example 03 (pi coding agent). Runs a manager that spawns + * pi peers into the space. Each spawn is a real pi agent session + * (extensions/pi) that embeds a Cotal endpoint and answers DMs, anycasts, and + * @-mentions on channels — waking an idle session with prompt() and folding + * same-scope traffic into a live turn with steer(). Importing the connector + * self-registers it as "pi"; we also alias it under "cotal" so a bare + * `cotal start --name x` (the default agent type) spawns one too. + */ +import { DEFAULT_SERVER, isReachable, registry, type Connector } from "@cotal-ai/core"; +import { Manager } from "@cotal-ai/manager"; +import { piConnector } from "@cotal-ai/pi"; // self-registers "pi" + +const cotalAlias: Connector = { + kind: "connector", + name: "cotal", + buildLaunch: piConnector.buildLaunch, +}; +registry.register(cotalAlias); + +const space = process.env.COTAL_SPACE?.trim() || "demo"; +const server = process.env.COTAL_SERVERS?.trim() || DEFAULT_SERVER; + +if (!(await isReachable(server))) { + console.error(`Can't reach NATS at ${server}. Run: pnpm cotal up`); + process.exit(1); +} + +const mgr = new Manager({ space, servers: server }); +await mgr.start(); +console.log(`example-03-pi manager up in space "${space}" — connectors: pi, cotal`); +console.log(`console: ${mgr.consoleUrl}`); + +process.on("SIGINT", () => void mgr.stop().then(() => process.exit(0))); +process.on("SIGTERM", () => void mgr.stop().then(() => process.exit(0))); +await new Promise(() => {}); diff --git a/examples/03-pi/tsconfig.json b/examples/03-pi/tsconfig.json new file mode 100644 index 00000000..051d08e2 --- /dev/null +++ b/examples/03-pi/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/extensions/connector-core/inbox-turn.smoke.ts b/extensions/connector-core/inbox-turn.smoke.ts new file mode 100644 index 00000000..78bc3510 --- /dev/null +++ b/extensions/connector-core/inbox-turn.smoke.ts @@ -0,0 +1,120 @@ +/** + * Smoke test for the InboxTurn ack-on-surface helper (no NATS/LLM needed): drives it against + * a fake inbox — including the MAX_INBOX front-eviction the real MeshAgent does — and asserts + * the surface/ack invariants the embed adapters rely on. + * + * pnpm smoke:inbox + */ +import { InboxTurn, type InboxSource } from "./src/inbox-turn.js"; +import type { InboxItem } from "./src/agent.js"; + +function item(id: string, fromId: string, kind: InboxItem["kind"] = "dm"): InboxItem { + return { id, ts: 0, fromId, fromName: fromId, kind, mentionsMe: false, text: id }; +} + +/** A fake inbox that mirrors MeshAgent: ingest force-acks + evicts from the front past `cap`, + * drainInbox acks by position, ackInbox acks by id (no-op for an absent id). */ +class FakeInbox implements InboxSource { + items: InboxItem[] = []; + acked: InboxItem[] = []; + constructor(private cap = Infinity) {} + ingest(it: InboxItem): void { + this.items.push(it); + if (this.items.length > this.cap) { + for (const ev of this.items.splice(0, this.items.length - this.cap)) this.acked.push(ev); + } + } + peekInbox(): InboxItem[] { + return [...this.items]; + } + drainInbox(limit?: number): InboxItem[] { + const n = limit && limit > 0 ? Math.min(limit, this.items.length) : this.items.length; + const taken = this.items.splice(0, n); + this.acked.push(...taken); + return taken; + } + ackInbox(ids: string[]): InboxItem[] { + const wanted = new Set(ids); + const taken: InboxItem[] = []; + this.items = this.items.filter((p) => { + if (!wanted.has(p.id)) return true; + this.acked.push(p); + taken.push(p); + return false; + }); + return taken; + } +} + +function assert(cond: boolean, msg: string): void { + if (!cond) throw new Error(`FAIL: ${msg}`); +} + +const ids = (xs: InboxItem[]): string => xs.map((x) => x.id).join(","); +const sameScope = (a: InboxItem, b: InboxItem): boolean => + a.fromId === b.fromId && a.kind === b.kind; + +// 1) drop leading non-actionable, start on the front, commit acks exactly the origin +{ + const fake = new FakeInbox(); + fake.items = [item("echo", "self"), item("b", "alice")]; + const turn = new InboxTurn(fake); + turn.drop((i) => i.fromId === "self"); + assert(ids(fake.acked) === "echo", "drop ack-drops the self echo"); + assert(turn.start()?.id === "b", "start surfaces the front actionable"); + assert(turn.count === 1, "surfaced exactly the origin"); + turn.commit(); + assert(ids(fake.acked) === "echo,b", "commit acks the origin"); + assert(fake.items.length === 0 && !turn.inFlight, "inbox drained, turn idle"); +} + +// 2) extend folds the front-contiguous same-scope run, stops at a different-scope gap +{ + const fake = new FakeInbox(); + fake.items = [item("1", "alice"), item("2", "alice"), item("3", "bob"), item("4", "alice")]; + const turn = new InboxTurn(fake); + assert(turn.start()?.id === "1", "origin = 1"); + assert(ids(turn.extend(sameScope)) === "2", "folds only contiguous same-scope #2, stops at #3"); + assert(turn.count === 2, "surfaced the 2-message run"); + turn.commit(); + assert(ids(fake.acked) === "1,2", "commit acks exactly the surfaced run [1,2]"); + assert(ids(fake.items) === "3,4", "cross-scope #3 and gapped #4 stay on the stream"); +} + +// 3) abandon acks nothing — the surfaced run redelivers +{ + const fake = new FakeInbox(); + fake.items = [item("x", "alice")]; + const turn = new InboxTurn(fake); + turn.start(); + turn.abandon(); + assert(fake.acked.length === 0, "abandon acks nothing"); + assert(ids(fake.items) === "x" && !turn.inFlight, "item stays on the stream; turn idle"); +} + +// 4) 200+ ambient burst mid-turn: the overflow evicts the in-flight prefix from the front; +// ack-by-id no-ops the evicted origin, acks the surviving folded peer, and never touches +// the newer messages that took the prefix's place +{ + const fake = new FakeInbox(200); + fake.ingest(item("origin", "alice")); + const turn = new InboxTurn(fake); + assert(turn.start()?.id === "origin", "origin surfaced"); + fake.ingest(item("peer", "alice")); + assert(ids(turn.extend(sameScope)) === "peer", "folds the same-scope peer"); + for (let i = 0; i < 199; i++) fake.ingest(item(`amb${i}`, "bob", "channel")); // 201 → evict 1 + assert( + fake.acked.some((x) => x.id === "origin") && fake.items.some((x) => x.id === "peer"), + "overflow evicted+acked the origin; the folded peer survived", + ); + const before = fake.acked.length; + turn.commit(); // ackInbox(["origin","peer"]) + assert(fake.acked.length === before + 1, "commit acks only the survivor — evicted origin no-ops"); + assert(!fake.items.some((x) => x.id === "peer"), "the survivor was acked by id"); + assert( + fake.items.length === 199 && fake.items.every((x) => x.id.startsWith("amb")), + "all 199 newer ambient messages left untouched — none mis-acked", + ); +} + +console.log("INBOX-TURN SMOKE OK ✅"); diff --git a/extensions/connector-core/src/agent.ts b/extensions/connector-core/src/agent.ts index 1a15c35f..28fbb28a 100644 --- a/extensions/connector-core/src/agent.ts +++ b/extensions/connector-core/src/agent.ts @@ -304,6 +304,24 @@ export class MeshAgent extends EventEmitter { return taken.map((p) => p.item); } + /** Ack + remove the buffered messages with these ids, wherever they sit in the inbox. An id + * that's no longer buffered — already acked, e.g. force-evicted by the MAX_INBOX overflow — is + * a harmless no-op. This lets a consumer ack exactly the messages it surfaced, immune to the + * front-eviction that shifts positions out from under {@link drainInbox}. */ + ackInbox(ids: string[]): InboxItem[] { + if (!ids.length) return []; + const wanted = new Set(ids); + const taken: InboxItem[] = []; + this.inbox = this.inbox.filter((p) => { + if (!wanted.has(p.item.id)) return true; + p.ack(); + this.markHandled(p.item.id); + taken.push(p.item); + return false; + }); + return taken; + } + /** Record an id as surfaced/handled, for {@link ingest}'s commit-aware cross-path dedup. Bounded via * two rotating windows: when the live set fills, it becomes the previous window and a fresh one * starts — so memory stays ~2× the cap while the lookup horizon never shrinks below it. */ diff --git a/extensions/connector-core/src/inbox-turn.ts b/extensions/connector-core/src/inbox-turn.ts new file mode 100644 index 00000000..771da05e --- /dev/null +++ b/extensions/connector-core/src/inbox-turn.ts @@ -0,0 +1,122 @@ +import type { InboxItem } from "./agent.js"; + +/** The slice of {@link MeshAgent} an {@link InboxTurn} drives off of. */ +export interface InboxSource { + /** Buffered messages, oldest first, without acking. */ + peekInbox(): InboxItem[]; + /** Ack + remove the front `limit` messages and return them. */ + drainInbox(limit?: number): InboxItem[]; + /** Ack + remove the messages with these ids (any position); an absent id is a no-op. */ + ackInbox(ids: string[]): InboxItem[]; +} + +/** + * Ack-on-surface delivery off a {@link MeshAgent}'s stream-backed inbox. + * + * The inbox is the single source of truth — there is no parallel buffer to drift out of + * sync. A turn *surfaces* messages by id and acks them (via `ackInbox`) only once the turn + * COMPLETES; a crash or interrupt before {@link commit} leaves them on the stream, so they + * redeliver — nothing is acked merely by being read. Acking by id (rather than by front + * position) keeps it correct even when the `MAX_INBOX` overflow force-evicts the in-flight + * prefix from the front mid-turn: those ids are already gone, so the ack no-ops them and + * never touches the newer messages that took their place. + * + * Fits both shapes the embed adapters use: + * - a one-message serialize loop: `start()` → run → `commit()`; + * - pi's same-scope steer loop: `start()` → `extend(match)`* (fold contiguous peers, e.g. + * same-scope messages, as they stream in) → `commit()` on a clean/failed finish, or + * `abandon()` on interrupt. + * + * `cotal_inbox` (where exposed) must stay on `peekInbox` so a model call can't double-drain + * what the loop already surfaced. + */ +export class InboxTurn { + private surfacedIds: string[] = []; + private _origin?: InboxItem; + + constructor(private readonly source: InboxSource) {} + + /** True while a turn holds surfaced-but-unacked messages. */ + get inFlight(): boolean { + return this.surfacedIds.length > 0; + } + + /** The message that opened the current turn (its reply scope), or undefined when idle. */ + get origin(): InboxItem | undefined { + return this._origin; + } + + /** How many messages this turn has surfaced. */ + get count(): number { + return this.surfacedIds.length; + } + + /** + * Ack-drop the leading messages matching `skip` (own echoes, ambient chatter) so they + * neither block the front nor linger to the inbox cap. Only valid with no turn in flight + * (a between-turns, synchronous front trim — no eviction can interleave). + */ + drop(skip: (item: InboxItem) => boolean): void { + if (this.surfacedIds.length) return; + const pending = this.source.peekInbox(); + let n = 0; + while (n < pending.length && skip(pending[n])) n++; + if (n) this.source.drainInbox(n); + } + + /** + * Open a turn on the front message (its origin) and surface it. Returns the origin, or + * undefined if the inbox is empty. Idempotent while a turn is in flight (returns the + * current origin). Call {@link drop} first so the front is the message you mean to answer. + */ + start(): InboxItem | undefined { + if (this.surfacedIds.length) return this._origin; + const front = this.source.peekInbox()[0]; + if (!front) return undefined; + this._origin = front; + this.surfacedIds = [front.id]; + return front; + } + + /** + * Fold the front-contiguous run of not-yet-surfaced messages that `match(item, origin)` + * into this turn, stopping at the first unsurfaced non-match (so a cross-scope message is + * left to open its own turn, preserving FIFO + scope isolation). Already-surfaced messages + * are skipped, so an overflow that evicts part of the prefix can't desync this. Returns the + * newly surfaced messages for the caller to feed in (e.g. via steer). No-op until + * {@link start}. + */ + extend(match: (item: InboxItem, origin: InboxItem) => boolean): InboxItem[] { + if (!this._origin) return []; + const surfaced = new Set(this.surfacedIds); + const run: InboxItem[] = []; + for (const item of this.source.peekInbox()) { + if (surfaced.has(item.id)) continue; // already surfaced (still buffered) — skip + if (!match(item, this._origin)) break; // first unsurfaced non-match → stop at the gap + run.push(item); + this.surfacedIds.push(item.id); + } + return run; + } + + /** + * Ack the surfaced messages by id — the sole ack site. Call on a terminal status that + * should consume them: a clean finish, or a failed/dropped turn (drop, no retry-loop). Ids + * already evicted by the overflow no-op. Do NOT call on interrupt/crash — use + * {@link abandon} so the run redelivers. + */ + commit(): void { + if (this.surfacedIds.length) this.source.ackInbox(this.surfacedIds); + this.reset(); + } + + /** End the turn without acking — the surfaced run stays on the stream and redelivers. */ + abandon(): void { + this.reset(); + } + + private reset(): void { + this.surfacedIds = []; + this._origin = undefined; + } +} diff --git a/extensions/connector-core/src/index.ts b/extensions/connector-core/src/index.ts index ee15298e..0db4edf5 100644 --- a/extensions/connector-core/src/index.ts +++ b/extensions/connector-core/src/index.ts @@ -1,5 +1,6 @@ export * from "./config.js"; export * from "./agent.js"; +export * from "./inbox-turn.js"; export * from "./runtime.js"; export * from "./launch.js"; export * from "./tool-specs.js"; diff --git a/extensions/pi/package.json b/extensions/pi/package.json new file mode 100644 index 00000000..1ed49dcd --- /dev/null +++ b/extensions/pi/package.json @@ -0,0 +1,40 @@ +{ + "name": "@cotal-ai/pi", + "version": "0.1.0", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "tsc -p tsconfig.json", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "@earendil-works/pi-ai": "^0.79.0", + "@earendil-works/pi-coding-agent": "^0.79.0", + "@cotal-ai/connector-core": "workspace:*", + "tsx": "^4.22.4" + }, + "peerDependencies": { + "@cotal-ai/core": "workspace:*" + }, + "devDependencies": { + "@cotal-ai/core": "workspace:*" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/extensions/pi/src/connector.ts b/extensions/pi/src/connector.ts new file mode 100644 index 00000000..cc68b5a4 --- /dev/null +++ b/extensions/pi/src/connector.ts @@ -0,0 +1,51 @@ +import { fileURLToPath } from "node:url"; +import { registry, type Connector, type LaunchOpts, type LaunchSpec } from "@cotal-ai/core"; + +/** The peer loop runs via tsx (resolved from this extension's own node_modules, so it works + * regardless of the spawned process's PATH/cwd). `main` is loaded with the same extension as + * this module — `main.ts` when running from source (dev), `main.js` when running from built + * `dist/` — so the entrypoint resolves to a file that actually exists in either mode. */ +const ext = import.meta.url.endsWith(".ts") ? ".ts" : ".js"; +const TSX = fileURLToPath(new URL("../node_modules/.bin/tsx", import.meta.url)); +const MAIN = fileURLToPath(new URL(`./main${ext}`, import.meta.url)); + +/** Provider API keys pi resolves from the environment (AuthStorage falls back to env). + * Forwarded when present so a spawned peer has credentials for its model. */ +const PROVIDER_KEYS = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "DEEPSEEK_API_KEY", + "MISTRAL_API_KEY", + "OPENROUTER_API_KEY", +]; + +/** + * The pi connector: launches an embedded Cotal peer that runs the pi coding-agent SDK + * loop and answers mesh traffic as a lateral peer. Inbound drives the loop directly + * (prompt to wake, steer to interject mid-turn). Forwards the launcher's identity + + * minted creds so the peer authenticates as `id` under auth. Self-registers on import; + * the manager resolves it by agent type "pi". + */ +export const piConnector: Connector = { + kind: "connector", + name: "pi", + buildLaunch(opts: LaunchOpts): LaunchSpec { + const env: Record = { COTAL_SPACE: opts.space, COTAL_NAME: opts.name }; + if (opts.role) env.COTAL_ROLE = opts.role; + if (opts.id) env.COTAL_ID = opts.id; + if (opts.creds) env.COTAL_CREDS = opts.creds; + if (opts.servers) env.COTAL_SERVERS = opts.servers; + if (opts.configPath) env.COTAL_AGENT_FILE = opts.configPath; + for (const key of PROVIDER_KEYS) { + const value = process.env[key]; + if (value) env[key] = value; + } + return { command: TSX, args: [MAIN], env }; + }, +}; + +registry.register(piConnector); diff --git a/extensions/pi/src/index.ts b/extensions/pi/src/index.ts new file mode 100644 index 00000000..ab6a0934 --- /dev/null +++ b/extensions/pi/src/index.ts @@ -0,0 +1,2 @@ +export * from "./connector.js"; +export { runPiPeer } from "./peer.js"; diff --git a/extensions/pi/src/main.ts b/extensions/pi/src/main.ts new file mode 100644 index 00000000..823a7e73 --- /dev/null +++ b/extensions/pi/src/main.ts @@ -0,0 +1,6 @@ +import { runPiPeer } from "./peer.js"; + +runPiPeer().catch((e) => { + process.stderr.write(`[pi-peer] fatal: ${(e as Error).message}\n`); + process.exit(1); +}); diff --git a/extensions/pi/src/peer.ts b/extensions/pi/src/peer.ts new file mode 100644 index 00000000..9bd35bfb --- /dev/null +++ b/extensions/pi/src/peer.ts @@ -0,0 +1,220 @@ +import { MeshAgent, InboxTurn, configFromEnv } from "@cotal-ai/connector-core"; +import type { InboxItem } from "@cotal-ai/connector-core"; +import { + AuthStorage, + ModelRegistry, + SessionManager, + createAgentSession, + defineTool, +} from "@earendil-works/pi-coding-agent"; +import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; +import { Type } from "@earendil-works/pi-ai"; + +function log(e: unknown): void { + process.stderr.write(`[pi-peer] ${e instanceof Error ? e.message : String(e)}\n`); +} + +/** + * Read-only / awareness tools. Replies are NOT sent by the model — the run loop delivers + * the agent's final text on the right delivery mode (see runPiPeer), so the model can't + * mis-route or duplicate a reply. These just let it see who is present and report its own + * status. Mirrors the openai-agents / vercel-ai adapters. + */ +function buildTools(mesh: MeshAgent) { + const cotal_roster = defineTool({ + name: "cotal_roster", + label: "Cotal roster", + description: "List the peers currently present on the Cotal mesh.", + parameters: Type.Object({}), + execute: async () => { + const peers = mesh.roster(); + const text = peers.length + ? peers + .map((p) => `${p.card.name}${p.card.role ? `/${p.card.role}` : ""} [${p.status}]`) + .join("\n") + : "roster is empty"; + return { content: [{ type: "text", text }], details: {} }; + }, + }); + + const cotal_status = defineTool({ + name: "cotal_status", + label: "Cotal status", + description: "Update this peer's presence status on the mesh.", + parameters: Type.Object({ + status: Type.Union([Type.Literal("idle"), Type.Literal("waiting"), Type.Literal("working")]), + activity: Type.Optional(Type.String()), + }), + execute: async (_id, params) => { + await mesh.setStatus(params.status, params.activity); + return { content: [{ type: "text", text: `status set to ${params.status}` }], details: {} }; + }, + }); + + return [cotal_roster, cotal_status]; +} + +/** Actionable = a DM, an anycast to our role, or a channel message that names us — and not + * our own echo. Pure ambient channel chatter is dropped (acked, never answered). */ +function actionable(mesh: MeshAgent, item: InboxItem): boolean { + if (item.fromId === mesh.id) return false; + return item.kind !== "channel" || item.mentionsMe; +} + +/** The audience a reply goes back to. A channel message is answered ON that channel + * (sender-independent — everyone there already saw it); a DM/anycast is answered privately + * to its sender. Two messages with the same scope can share one turn + reply; mixing scopes + * cannot (a DM folded into a channel turn would broadcast private content), so different- + * scope messages get their own scope-isolated turn. */ +function scopeKey(item: InboxItem): string { + return item.kind === "channel" && item.channel ? `channel:${item.channel}` : `dm:${item.fromId}`; +} + +/** Pull this turn's final assistant text from the agent_end payload (not the session-wide + * last message), so a turn that produced no text never re-delivers a previous reply. */ +function turnReplyText(messages: readonly unknown[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] as { role?: unknown; content?: unknown }; + if (m.role !== "assistant" || !Array.isArray(m.content)) continue; + const text = m.content + .map((p) => + p && typeof p === "object" && (p as { type?: unknown }).type === "text" + ? String((p as { text?: unknown }).text ?? "") + : "", + ) + .join(""); + return text.length ? text : undefined; + } + return undefined; +} + +/** + * Embed a pi coding-agent session in-process and drive it from mesh traffic. This is the + * native-embed pattern (cf. docs/agent-frameworks.md): MeshAgent owns the NATS connection, + * presence, and a stream-backed inbox; pi's loop is driven straight off that inbox via an + * {@link InboxTurn} — `prompt()` wakes an idle session on the front message, `steer()` + * interjects into a live one (true mid-turn drive, pi's distinctive capability), and presence + * is read off the session's event stream. The loop owns reply routing, so the model never + * mis-routes. + * + * Delivery is ack-on-surface: the inbox is the single source of truth (no parallel buffer); + * a turn surfaces a front-contiguous run and `commit()`s (drainInbox-acks) it only once the + * turn completes, so a crash/interrupt redelivers. Each turn is owned by one reply scope — + * a mid-turn message is steered in only when it shares that scope; a different-scope message + * stays on the stream and becomes the next turn's origin — so a private DM is never folded + * into a channel broadcast. + */ +export async function runPiPeer(): Promise { + const mesh = new MeshAgent(configFromEnv()); + mesh.start(); + + const authStorage = AuthStorage.create(); + const { session } = await createAgentSession({ + cwd: process.cwd(), + authStorage, + modelRegistry: ModelRegistry.create(authStorage), + sessionManager: SessionManager.inMemory(), + customTools: buildTools(mesh), + }); + + const turn = new InboxTurn(mesh); + let streaming = false; // gates steer(): only valid once the agent is actually streaming + + const setStatus = (status: "idle" | "working", activity?: string): void => { + void mesh.setStatus(status, activity).catch(() => {}); + }; + + const framed = (item: InboxItem): string => + `from ${item.fromName} via ${item.kind}: ${item.text}`; + + function deliver(to: InboxItem, text: string): void { + if (to.kind === "channel" && to.channel) void mesh.send(text, to.channel).catch(log); + else void mesh.dm(to.fromId, text).catch(log); + } + + /** Start the next turn on the front actionable message, dropping leading non-actionable + * (own echoes, ambient chatter) first. No-op while a turn is in flight. */ + function pump(): void { + if (turn.inFlight) return; + turn.drop((i) => !actionable(mesh, i)); + const origin = turn.start(); + if (!origin) { + setStatus("idle"); + return; + } + streaming = false; + void session.prompt(framed(origin)).catch(onStartError); // wake into a fresh turn + } + + /** Fold any front-contiguous, same-scope actionable messages into the live turn (mid-turn + * steer). A cross-scope or ambient message breaks contiguity and waits for its own turn. */ + function foldSameScope(): void { + if (!turn.origin || !streaming) return; + for (const item of turn.extend((i, o) => actionable(mesh, i) && scopeKey(i) === scopeKey(o))) { + void session.steer(framed(item)).catch(log); + } + } + + function onStartError(e: unknown): void { + log(e); + if (streaming) return; // already running → agent_end will complete the turn + turn.commit(); // pre-flight failure (e.g. no model/key): drop, no retry-loop + setStatus("idle"); + pump(); + } + + mesh.on("incoming", () => { + if (turn.inFlight) foldSameScope(); + else pump(); + }); + mesh.on("wake", () => { + if (!turn.inFlight) pump(); + }); + + session.subscribe((event: AgentSessionEvent) => { + switch (event.type) { + case "agent_start": + streaming = true; + setStatus("working", "thinking"); + foldSameScope(); // flush same-scope peers that landed before streaming began + break; + case "tool_execution_start": + setStatus("working", `running ${event.toolName}`); + break; + case "tool_execution_end": + setStatus("working", "thinking"); // clear the per-tool activity so it can't read stale + break; + case "agent_end": { + if (event.willRetry) break; // transient failure; a retry turn follows + const to = turn.origin; + const reply = turnReplyText(event.messages); + turn.commit(); // ack the surfaced run — clean or failed both consume (no retry-loop) + streaming = false; + if (to && reply) deliver(to, reply); + pump(); // next scope + break; + } + } + }); + + // Drain anything already buffered before the listeners were attached. + pump(); + + async function shutdown(): Promise { + try { + if (turn.inFlight) { + turn.abandon(); // leave the in-flight run on the stream → redeliver, no peer dropped + await session.abort(); + } + session.dispose(); + await mesh.stop(); + } finally { + process.exit(0); + } + } + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); + + // Keep alive. + await new Promise(() => {}); +} diff --git a/extensions/pi/tsconfig.json b/extensions/pi/tsconfig.json new file mode 100644 index 00000000..051d08e2 --- /dev/null +++ b/extensions/pi/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/package.json b/package.json index 75eef978..d91f0c7f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "smoke": "tsx packages/core/smoke.ts", "smoke:auth": "tsx packages/core/smoke-auth.ts", "smoke:orientation": "tsx extensions/connector-core/smoke/orientation.smoke.ts", + "smoke:inbox": "tsx extensions/connector-core/inbox-turn.smoke.ts", "smoke:view": "tsx implementations/cli/smoke/view.smoke.ts", "smoke:members": "tsx packages/core/smoke/members.smoke.ts", "smoke:plane3:auth": "tsx packages/core/smoke/plane3-auth.smoke.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70f74e8c..4936ce80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,22 @@ importers: specifier: ^4.22.4 version: 4.22.4 + examples/03-pi: + dependencies: + '@cotal-ai/core': + specifier: workspace:* + version: link:../../packages/core + '@cotal-ai/manager': + specifier: workspace:* + version: link:../../implementations/manager + '@cotal-ai/pi': + specifier: workspace:* + version: link:../../extensions/pi + devDependencies: + tsx: + specifier: ^4.22.4 + version: 4.22.4 + extensions/cmux: dependencies: '@cotal-ai/core': @@ -200,6 +216,25 @@ importers: specifier: ^4.22.4 version: 4.22.4 + extensions/pi: + dependencies: + '@cotal-ai/connector-core': + specifier: workspace:* + version: link:../connector-core + '@earendil-works/pi-ai': + specifier: ^0.79.0 + version: 0.79.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-coding-agent': + specifier: ^0.79.0 + version: 0.79.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + tsx: + specifier: ^4.22.4 + version: 4.22.4 + devDependencies: + '@cotal-ai/core': + specifier: workspace:* + version: link:../../packages/core + extensions/tmux: dependencies: '@cotal-ai/core': @@ -329,6 +364,116 @@ packages: resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.23': + resolution: {integrity: sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.49': + resolution: {integrity: sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.51': + resolution: {integrity: sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.56': + resolution: {integrity: sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.55': + resolution: {integrity: sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.58': + resolution: {integrity: sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.49': + resolution: {integrity: sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.55': + resolution: {integrity: sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.55': + resolution: {integrity: sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.22': + resolution: {integrity: sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.18': + resolution: {integrity: sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.31': + resolution: {integrity: sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.23': + resolution: {integrity: sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.35': + resolution: {integrity: sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1074.0': + resolution: {integrity: sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.13': + resolution: {integrity: sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.8': + resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.31': + resolution: {integrity: sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -396,6 +541,24 @@ packages: resolution: {integrity: sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==} engines: {node: '>= 20.12.0'} + '@earendil-works/pi-agent-core@0.79.10': + resolution: {integrity: sha512-XKxgdjhcPuyjrthCOFSgfzT3xZ1uBrJ1IMVDxci1to6hIN6BIg9J5iY8q0pGXK1DLgATLP23da+1UyZLwA360Q==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.79.10': + resolution: {integrity: sha512-9jR23tOl0BIUdQMn70Gr72xYBpM7Xgl9Lyv7gAnU1USfkNRuYG/f/edLl+n/Dp/RafDW3JI4DF7y/GhgkORuew==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-coding-agent@0.79.10': + resolution: {integrity: sha512-YxaRhmgyDTvLDdGVbe7YzTHV80oL5mX5odg6EhGHz3w5Wu1Ix8DCw7bhtiOBLGQNFRcknia0zPmVWIj30XP1EA==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-tui@0.79.10': + resolution: {integrity: sha512-FUVOjDn1DVwM1uHD5MNYboXQrXjIDbSt+BQ3py7nQWCY62tKfxgiM1OBMxTcwRWLfSdZHUPpV0hm1loIdUJnPw==} + engines: {node: '>=22.19.0'} + '@eplightning/nats-server-darwin-arm64@2.14.0': resolution: {integrity: sha512-tBCOf4anrlTnbQimh3KSz6C+0IbdJTyfTFLcol2YfGWzfmMgsXqjfrSZvnRnqMK2hWD/TJ6eIhsv3/UlfdTzEg==} engines: {bun: '>=1', deno: '>=2', node: '>=22'} @@ -744,6 +907,15 @@ packages: cpu: [x64] os: [win32] + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -798,6 +970,82 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@mariozechner/clipboard-darwin-arm64@0.3.9': + resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@mariozechner/clipboard-darwin-universal@0.3.9': + resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==} + engines: {node: '>= 10'} + os: [darwin] + + '@mariozechner/clipboard-darwin-x64@0.3.9': + resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@mariozechner/clipboard@0.3.9': + resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} + engines: {node: '>= 10'} + + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -895,6 +1143,84 @@ packages: '@opencode-ai/sdk@1.16.2': resolution: {integrity: sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + + '@smithy/core@3.26.0': + resolution: {integrity: sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.2': + resolution: {integrity: sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.5.2': + resolution: {integrity: sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.8.2': + resolution: {integrity: sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.5.2': + resolution: {integrity: sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.15.0': + resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -913,6 +1239,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -933,6 +1262,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -982,14 +1315,23 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -998,6 +1340,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1072,6 +1417,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1093,6 +1442,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1101,6 +1454,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1181,6 +1537,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -1210,6 +1569,10 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1225,6 +1588,10 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1249,6 +1616,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@7.1.5: + resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -1273,6 +1648,14 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + google-auth-library@10.9.0: + resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1288,14 +1671,29 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hono@4.12.23: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} engines: {node: '>=16.9.0'} + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.2.0: resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} hasBin: true @@ -1308,6 +1706,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} @@ -1375,6 +1777,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -1386,6 +1792,13 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -1400,6 +1813,12 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + kubernetes-types@1.30.0: resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} @@ -1410,10 +1829,18 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1475,6 +1902,15 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true @@ -1502,6 +1938,18 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1521,6 +1969,10 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -1532,6 +1984,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + patch-console@2.0.0: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1575,6 +2030,13 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1626,6 +2088,14 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1637,6 +2107,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -1647,6 +2120,11 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1763,6 +2241,9 @@ packages: resolution: {integrity: sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==} engines: {node: '>=20'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-json-schema-generator@2.9.0: resolution: {integrity: sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==} engines: {node: '>=22.0.0'} @@ -1787,6 +2268,9 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typebox@1.1.38: + resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1798,6 +2282,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@8.5.0: + resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + engines: {node: '>=22.19.0'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -1814,6 +2302,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1868,6 +2360,234 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.13 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.13 + '@aws-sdk/util-locate-window': 3.965.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.13 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.13 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.23 + '@aws-sdk/credential-provider-node': 3.972.58 + '@aws-sdk/eventstream-handler-node': 3.972.22 + '@aws-sdk/middleware-eventstream': 3.972.18 + '@aws-sdk/middleware-websocket': 3.972.31 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.23': + dependencies: + '@aws-sdk/types': 3.973.13 + '@aws-sdk/xml-builder': 3.972.31 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.26.0 + '@smithy/signature-v4': 5.5.2 + '@smithy/types': 4.15.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.51': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/node-http-handler': 4.8.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.56': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/credential-provider-env': 3.972.49 + '@aws-sdk/credential-provider-http': 3.972.51 + '@aws-sdk/credential-provider-login': 3.972.55 + '@aws-sdk/credential-provider-process': 3.972.49 + '@aws-sdk/credential-provider-sso': 3.972.55 + '@aws-sdk/credential-provider-web-identity': 3.972.55 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/credential-provider-imds': 4.4.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.58': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.49 + '@aws-sdk/credential-provider-http': 3.972.51 + '@aws-sdk/credential-provider-ini': 3.972.56 + '@aws-sdk/credential-provider-process': 3.972.49 + '@aws-sdk/credential-provider-sso': 3.972.55 + '@aws-sdk/credential-provider-web-identity': 3.972.55 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/credential-provider-imds': 4.4.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/token-providers': 3.1074.0 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.22': + dependencies: + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.18': + dependencies: + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.31': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/signature-v4': 5.5.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.23': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.23 + '@aws-sdk/signature-v4-multi-region': 3.996.35 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/node-http-handler': 4.8.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.35': + dependencies: + '@aws-sdk/types': 3.973.13 + '@smithy/signature-v4': 5.5.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1074.0': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.13': + dependencies: + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.8': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.31': + dependencies: + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/runtime@7.29.7': {} '@changesets/apply-release-plan@7.1.1': @@ -2025,6 +2745,76 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@earendil-works/pi-agent-core@0.79.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 0.79.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.79.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.21.0)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.79.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-agent-core': 0.79.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.79.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.79.10 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.1.38 + undici: 8.5.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-tui@0.79.10': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + '@eplightning/nats-server-darwin-arm64@2.14.0': optional: true @@ -2199,6 +2989,19 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': + dependencies: + google-auth-library: 10.9.0 + p-retry: 4.6.2 + protobufjs: 7.6.4 + ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@hono/node-server@1.19.14(hono@4.12.23)': dependencies: hono: 4.12.23 @@ -2253,6 +3056,62 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@mariozechner/clipboard-darwin-arm64@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-universal@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-x64@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard@0.3.9': + optionalDependencies: + '@mariozechner/clipboard-darwin-arm64': 0.3.9 + '@mariozechner/clipboard-darwin-universal': 0.3.9 + '@mariozechner/clipboard-darwin-x64': 0.3.9 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-musl': 0.3.9 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 + optional: true + + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/semantic-conventions': 1.41.1 + ws: 8.21.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.23) @@ -2349,6 +3208,86 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/semantic-conventions@1.41.1': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@silvia-odwyer/photon-node@0.3.4': {} + + '@smithy/core@3.26.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.2': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.5.2': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.8.2': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.5.2': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/types@4.15.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@types/json-schema@7.0.15': {} @@ -2367,6 +3306,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/retry@0.12.0': {} + '@types/ws@8.18.1': dependencies: '@types/node': 26.0.0 @@ -2386,6 +3327,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -2421,10 +3364,14 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + bignumber.js@9.3.1: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -2439,6 +3386,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -2447,6 +3396,8 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} + bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: @@ -2505,6 +3456,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2516,6 +3469,8 @@ snapshots: detect-libc@2.1.2: optional: true + diff@8.0.4: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -2526,6 +3481,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@4.0.0-beta.74: @@ -2672,6 +3631,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + extendable-error@0.1.7: {} fast-check@4.8.0: @@ -2704,6 +3665,11 @@ snapshots: dependencies: reusify: 1.1.0 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -2726,6 +3692,10 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -2747,6 +3717,22 @@ snapshots: function-bind@1.1.2: {} + gaxios@7.1.5: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.5 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: @@ -2786,6 +3772,19 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + google-auth-library@10.9.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.5 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -2796,8 +3795,14 @@ snapshots: dependencies: function-bind: 1.1.2 + highlight.js@10.7.3: {} + hono@4.12.23: {} + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -2806,6 +3811,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-id@4.2.0: {} iconv-lite@0.7.2: @@ -2814,6 +3833,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + indent-string@5.0.0: {} inherits@2.0.4: {} @@ -2882,6 +3903,8 @@ snapshots: isexe@2.0.0: {} + jiti@2.7.0: {} + jose@6.2.3: {} js-yaml@3.14.2: @@ -2893,6 +3916,15 @@ snapshots: dependencies: argparse: 2.0.1 + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -2903,6 +3935,17 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + kubernetes-types@1.30.0: {} locate-path@5.0.0: @@ -2911,8 +3954,12 @@ snapshots: lodash.startcase@4.4.0: {} + long@5.3.2: {} + lru-cache@11.5.1: {} + marked@18.0.5: {} + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -2964,6 +4011,14 @@ snapshots: negotiator@1.0.0: {} + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 @@ -2987,6 +4042,11 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openai@6.26.0(ws@8.21.0)(zod@4.4.3): + optionalDependencies: + ws: 8.21.0 + zod: 4.4.3 + outdent@0.5.0: {} p-filter@2.1.0: @@ -3003,6 +4063,11 @@ snapshots: p-map@2.1.0: {} + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + p-try@2.2.0: {} package-manager-detector@0.2.11: @@ -3011,6 +4076,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + patch-console@2.0.0: {} path-exists@4.0.0: {} @@ -3036,6 +4103,26 @@ snapshots: prettier@2.8.8: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + protobufjs@7.6.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 22.20.0 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -3083,6 +4170,10 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + retry@0.12.0: {} + + retry@0.13.1: {} + reusify@1.1.0: {} router@2.2.0: @@ -3099,12 +4190,16 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} scheduler@0.27.0: {} + semver@7.8.0: {} + semver@7.8.5: {} send@1.2.1: @@ -3229,6 +4324,8 @@ snapshots: toml@4.1.1: {} + ts-algebra@2.0.0: {} + ts-json-schema-generator@2.9.0: dependencies: '@types/json-schema': 7.0.15 @@ -3260,12 +4357,16 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typebox@1.1.38: {} + typescript@5.9.3: {} undici-types@6.21.0: {} undici-types@8.3.0: {} + undici@8.5.0: {} + universalify@0.1.2: {} unpipe@1.0.0: {} @@ -3274,6 +4375,8 @@ snapshots: vary@1.1.2: {} + web-streams-polyfill@3.3.3: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0774c3ad..c4976edf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,5 +6,7 @@ packages: - "examples/*/*" - "bin" allowBuilds: + '@google/genai': false esbuild: true msgpackr-extract: false + protobufjs: false From a2e3de83248378765427ceffe40835df37c9d0ee Mon Sep 17 00:00:00 2001 From: Lanzelot1 <35410605+Lanzelot1@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:08:45 -0700 Subject: [PATCH 2/8] docs(readme): add pi to supported agents Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 12 +++++++----- assets/agents/pi.svg | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 assets/agents/pi.svg diff --git a/README.md b/README.md index 9aabe5f7..e954b6fb 100644 --- a/README.md +++ b/README.md @@ -135,14 +135,16 @@ Full index: [docs/examples.md](docs/examples.md). - - - + + + +

Claude Code

installed plugin + hooks

OpenCode

native in-process plugin

Hermes

gateway daemon + plugin

Claude Code

installed plugin + hooks

OpenCode

native in-process plugin

Hermes

gateway daemon + plugin

pi

native embed + live steer
-They attach differently but expose the same `cotal_*` tools, and all three push, so a -peer message wakes an idle agent the instant it arrives. Any agent that implements the +They attach differently but expose the same `cotal_*` tools, and all four push, so a +peer message wakes an idle agent the instant it arrives; pi additionally drives a live +turn, folding a same-scope message into an in-flight one with `steer()`. Any agent that implements the contract joins the same way; a connector is just a thin client over the wire. Want one for an agent that isn't here yet? [Vote for the next connector](https://github.com/Cotal-AI/Cotal/discussions/80). diff --git a/assets/agents/pi.svg b/assets/agents/pi.svg new file mode 100644 index 00000000..c403a4ea --- /dev/null +++ b/assets/agents/pi.svg @@ -0,0 +1,28 @@ + + + + + + + + From 24a1df3ac6513543a06303d2200b317aef04eb91 Mon Sep 17 00:00:00 2001 From: Lanzelot1 <35410605+Lanzelot1@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:14:29 -0700 Subject: [PATCH 3/8] docs(pi): add extension overview README Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/pi/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 extensions/pi/README.md diff --git a/extensions/pi/README.md b/extensions/pi/README.md new file mode 100644 index 00000000..a182b1f4 --- /dev/null +++ b/extensions/pi/README.md @@ -0,0 +1,13 @@ +# @cotal-ai/pi + +The pi adapter: a native mesh peer that embeds a Cotal endpoint inside a [pi coding +agent](https://github.com/earendil-works/pi) process and answers mesh traffic through the +agent's own loop. Reuses `MeshAgent` from +[`@cotal-ai/connector-core`](../connector-core); unlike the host-bound connectors it also +drives a *live* turn — `steer()` folds a same-scope message into an in-flight one. + +**Tier:** `extensions/`. Peer-depends [`@cotal-ai/core`](../../packages/core); self-registers on +import. + +See [docs/agent-frameworks.md](../../docs/agent-frameworks.md) for the native-embed pattern, +and the [root AGENTS.md](../../AGENTS.md) for the tier rules. From 50e70758a351633a58d0536f0395e67b4bed8452 Mon Sep 17 00:00:00 2001 From: Jay the Reaper <198331141+TheReaperJay@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:52:49 +0700 Subject: [PATCH 4/8] feat(pi): PI_PEER_MODE dispatch seam (tui/headless) --- extensions/pi/src/connector.ts | 4 ++++ extensions/pi/src/index.ts | 1 + extensions/pi/src/main.ts | 11 ++++++++++- extensions/pi/src/peer.ts | 6 +++--- extensions/pi/src/tui-client.ts | 12 ++++++++++++ 5 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 extensions/pi/src/tui-client.ts diff --git a/extensions/pi/src/connector.ts b/extensions/pi/src/connector.ts index cc68b5a4..2bdff5e8 100644 --- a/extensions/pi/src/connector.ts +++ b/extensions/pi/src/connector.ts @@ -44,6 +44,10 @@ export const piConnector: Connector = { const value = process.env[key]; if (value) env[key] = value; } + // The connector's own launch-mode knob (headless embedded loop vs. interactive + // TUI host). pi-connector-owned: forwarded by NAME the same way as the model + // keys above, never via ...process.env. See main.ts for the dispatch. + if (process.env.PI_PEER_MODE) env.PI_PEER_MODE = process.env.PI_PEER_MODE; return { command: TSX, args: [MAIN], env }; }, }; diff --git a/extensions/pi/src/index.ts b/extensions/pi/src/index.ts index ab6a0934..cdcd3295 100644 --- a/extensions/pi/src/index.ts +++ b/extensions/pi/src/index.ts @@ -1,2 +1,3 @@ export * from "./connector.js"; export { runPiPeer } from "./peer.js"; +export { runTuiClient } from "./tui-client.js"; diff --git a/extensions/pi/src/main.ts b/extensions/pi/src/main.ts index 823a7e73..5f4aa897 100644 --- a/extensions/pi/src/main.ts +++ b/extensions/pi/src/main.ts @@ -1,6 +1,15 @@ +import { configFromEnv } from "@cotal-ai/connector-core"; import { runPiPeer } from "./peer.js"; +import { runTuiClient } from "./tui-client.js"; -runPiPeer().catch((e) => { +const config = configFromEnv(); +const mode = process.env.PI_PEER_MODE; +if (mode !== undefined && mode !== "" && mode !== "tui" && mode !== "headless") + throw new Error( + `PI_PEER_MODE must be "tui" or "headless" (got ${JSON.stringify(mode)}); unset => headless`, + ); +const entry = mode === "tui" ? runTuiClient : runPiPeer; // "headless" | unset => current path +entry(config).catch((e) => { process.stderr.write(`[pi-peer] fatal: ${(e as Error).message}\n`); process.exit(1); }); diff --git a/extensions/pi/src/peer.ts b/extensions/pi/src/peer.ts index 9bd35bfb..3a6abf19 100644 --- a/extensions/pi/src/peer.ts +++ b/extensions/pi/src/peer.ts @@ -1,5 +1,5 @@ import { MeshAgent, InboxTurn, configFromEnv } from "@cotal-ai/connector-core"; -import type { InboxItem } from "@cotal-ai/connector-core"; +import type { InboxItem, AgentConfig } from "@cotal-ai/connector-core"; import { AuthStorage, ModelRegistry, @@ -104,8 +104,8 @@ function turnReplyText(messages: readonly unknown[]): string | undefined { * stays on the stream and becomes the next turn's origin — so a private DM is never folded * into a channel broadcast. */ -export async function runPiPeer(): Promise { - const mesh = new MeshAgent(configFromEnv()); +export async function runPiPeer(config: AgentConfig = configFromEnv()): Promise { + const mesh = new MeshAgent(config); mesh.start(); const authStorage = AuthStorage.create(); diff --git a/extensions/pi/src/tui-client.ts b/extensions/pi/src/tui-client.ts new file mode 100644 index 00000000..d5a09ebc --- /dev/null +++ b/extensions/pi/src/tui-client.ts @@ -0,0 +1,12 @@ +import { configFromEnv, type AgentConfig } from "@cotal-ai/connector-core"; + +/** + * Interactive mesh-driven peer — the TUI launch mode selected by + * `PI_PEER_MODE=tui`. Spawns `pi --mode rpc` as a child and drives it from the + * mesh while rendering `extension_ui_request` dialogs locally in the pane, so + * any pi extension that calls `ctx.ui.*` (approval gates, prompts, selects, + * editors) becomes live and operator-answerable per-pane. + */ +export async function runTuiClient(_config: AgentConfig = configFromEnv()): Promise { + throw new Error("PI_PEER_MODE=tui: not implemented"); +} \ No newline at end of file From 73d22bd0ae7921af810423565ac51bdd84253300 Mon Sep 17 00:00:00 2001 From: Jay the Reaper <198331141+TheReaperJay@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:50:24 +0700 Subject: [PATCH 5/8] The pi connector currently embeds pi in-process and never binds a UI context, so ctx.hasUI is false and any installed extension that calls ctx.ui.\* silently no-ops. To enable a variant where the agent's UI calls become answerable from the pane the peer runs in, the connector gains a second entry that acts as a host process: it spawns stock `pi --mode rpc` as a child and bridges to it over the NDJSON stdin/stdout protocol pi already exposes. This commit lands the host's spawn + protocol layer, dispatched through the PI_PEER_MODE flag already wired in main.ts. The child is unmodified upstream pi, so installed extensions load exactly as they do in a normal pi session. API keys reach the child via launchEnv({ providerKeys }) rather than inherited wholesale, matching the connector's existing chokepoint. rpc-frames.ts carries the wire shapes (AgentSessionEvent, command acks, extension_ui_request) without importing pi's internal rpc-types module. This is incremental on purpose: the host only spawns a stock rpc child and logs the frames (a test prompt round-trips through agent_start and the full message lifecycle), with mesh driving and the dialog renderer to follow. Locking the wire layer first keeps the next commits scoped to a single concern each. --- extensions/pi/src/connector.ts | 2 +- extensions/pi/src/rpc-frames.ts | 74 +++++++++++++++++++++++++++++++++ extensions/pi/src/tui-client.ts | 48 +++++++++++++++++---- 3 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 extensions/pi/src/rpc-frames.ts diff --git a/extensions/pi/src/connector.ts b/extensions/pi/src/connector.ts index 2bdff5e8..054e1807 100644 --- a/extensions/pi/src/connector.ts +++ b/extensions/pi/src/connector.ts @@ -11,7 +11,7 @@ const MAIN = fileURLToPath(new URL(`./main${ext}`, import.meta.url)); /** Provider API keys pi resolves from the environment (AuthStorage falls back to env). * Forwarded when present so a spawned peer has credentials for its model. */ -const PROVIDER_KEYS = [ +export const PROVIDER_KEYS = [ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", diff --git a/extensions/pi/src/rpc-frames.ts b/extensions/pi/src/rpc-frames.ts new file mode 100644 index 00000000..7e9e25ff --- /dev/null +++ b/extensions/pi/src/rpc-frames.ts @@ -0,0 +1,74 @@ +import type { ChildProcess } from "node:child_process"; +import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; + +/** + * pi RPC protocol frames, as used by the Cotal pi connector's TUI host. + * + * pi in `--mode rpc` reads NDJSON commands from stdin and emits NDJSON events on + * stdout. The agent activity events are raw {@link AgentSessionEvent}s (the + * same type the in-process loop subscribes to); command acknowledgements are + * `response` frames; extension UI calls (ctx.ui.confirm/select/input/editor/…) + * arrive as `extension_ui_request` frames and are answered with + * `extension_ui_response`. Shapes mirrored from the installed pi SDK + * (modes/rpc/rpc-types.ts + core/agent-session.ts) so this module has no + * dependency on pi's internal rpc-types export. + */ + +/** Commands the host writes to the child's stdin. */ +export type RpcCommand = + | { type: "prompt"; message: string; id?: string } + | { type: "steer"; message: string; id?: string } + | { type: "follow_up"; message: string; id?: string } + | { type: "abort"; id?: string } + // Extension UI answers. `confirm` -> {confirmed}; `select`/`input`/`editor` -> {value}; + // a dismissed dialog -> {cancelled: true}. All keyed by the request `id`. + | { type: "extension_ui_response"; id: string; confirmed: boolean } + | { type: "extension_ui_response"; id: string; value: string } + | { type: "extension_ui_response"; id: string; cancelled: true }; + +/** A request from a pi extension for human input (ctx.ui.*). */ +export type RpcExtensionUIRequest = + | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number } + | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number } + | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number } + | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } + | { type: "extension_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error" } + | { type: "extension_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } + | { type: "extension_ui_request"; id: string; method: "setWidget"; widgetKey: string; widgetLines: string[] | undefined; widgetPlacement?: "aboveEditor" | "belowEditor" } + | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } + | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }; + +/** A command acknowledgement. */ +export type RpcResponse = + | { type: "response"; command: string; success: true; id?: string; data?: unknown } + | { type: "response"; command: string; success: false; id?: string; error: string }; + +/** Everything the host reads off the child's stdout. */ +export type RpcFrame = AgentSessionEvent | RpcExtensionUIRequest | RpcResponse | { type: "extension_error"; extensionPath?: string; event?: string; error: string }; + +/** Write one command as an NDJSON line to the child's stdin. */ +export function writeRpc(child: ChildProcess, cmd: RpcCommand): void { + if (!child.stdin) throw new Error("rpc: child has no stdin"); + child.stdin.write(JSON.stringify(cmd) + "\n"); +} + +/** Read NDJSON frames from the child's stdout. Throws on a malformed line (no fallback). */ +export async function* readJsonLines(stream: NodeJS.ReadableStream): AsyncGenerator { + let buf = ""; + for await (const chunk of stream) { + buf += chunk; + let nl: number; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + let parsed: RpcFrame; + try { + parsed = JSON.parse(line) as RpcFrame; + } catch (e) { + throw new Error(`rpc: malformed stdout line: ${(e as Error).message}: ${line}`); + } + yield parsed; + } + } +} \ No newline at end of file diff --git a/extensions/pi/src/tui-client.ts b/extensions/pi/src/tui-client.ts index d5a09ebc..338b0f75 100644 --- a/extensions/pi/src/tui-client.ts +++ b/extensions/pi/src/tui-client.ts @@ -1,12 +1,44 @@ -import { configFromEnv, type AgentConfig } from "@cotal-ai/connector-core"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { MeshAgent, configFromEnv, launchEnv, type AgentConfig } from "@cotal-ai/connector-core"; +import { writeRpc, readJsonLines } from "./rpc-frames.js"; +import { PROVIDER_KEYS } from "./connector.js"; + +// Resolve the `pi` wrapper the same way connector.ts resolves `tsx`: as the +// extension's own node_modules/.bin entry (pnpm provides it for the +// @earendil-works/pi-coding-agent dependency). Spawn PI_CLI directly, matching +// how the connector spawns TSX as the command. +const PI_CLI = fileURLToPath(new URL("../node_modules/.bin/pi", import.meta.url)); /** - * Interactive mesh-driven peer — the TUI launch mode selected by - * `PI_PEER_MODE=tui`. Spawns `pi --mode rpc` as a child and drives it from the - * mesh while rendering `extension_ui_request` dialogs locally in the pane, so - * any pi extension that calls `ctx.ui.*` (approval gates, prompts, selects, - * editors) becomes live and operator-answerable per-pane. + * Interactive mesh-driven peer — the TUI launch mode selected by `PI_PEER_MODE=tui`. + * Spawns stock `pi --mode rpc` as a child and drives it from the mesh while rendering + * `extension_ui_request` dialogs locally in the pane, so any pi extension that calls + * `ctx.ui.*` (approval gates, prompts, selects, editors) becomes live and + * operator-answerable per-pane. */ -export async function runTuiClient(_config: AgentConfig = configFromEnv()): Promise { - throw new Error("PI_PEER_MODE=tui: not implemented"); +export async function runTuiClient(config: AgentConfig = configFromEnv()): Promise { + const mesh = new MeshAgent(config); + mesh.start(); + + const child = spawn(PI_CLI, ["--mode", "rpc", "--no-session"], { + cwd: process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + env: launchEnv({ providerKeys: PROVIDER_KEYS }), + }); + + let stderrBuf = ""; + child.stderr?.on("data", (d: Buffer) => { stderrBuf += d.toString(); }); + child.on("exit", (code) => { + if (stderrBuf) process.stderr.write(`[pi-peer] child stderr:\n${stderrBuf}`); + process.stderr.write(`[pi-peer] child exit ${code}\n`); + process.exit(code ?? 1); + }); + + // Slice 2: prove the bridge round-trips. Send one prompt and log every stdout frame. + // Mesh driving + local dialogs land in slice 3. + writeRpc(child, { type: "prompt", message: "Say hello in one word." }); + for await (const frame of readJsonLines(child.stdout)) { + process.stderr.write(`[pi-peer] ${JSON.stringify(frame)}\n`); + } } \ No newline at end of file From e714118d55de75502f1a5eae57e5d15d99b9884d Mon Sep 17 00:00:00 2001 From: Jay the Reaper <198331141+TheReaperJay@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:11:44 +0700 Subject: [PATCH 6/8] feat(pi): add TUI launch mode for operator-answerable mesh peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PI_PEER_MODE=tui makes a mesh-driven pi peer host a live renderer in its own tmux pane, so ctx.ui.* extension calls (confirm / select / editor / notify) from the agent become operator-answerable per-pane instead of silently denied by the edit guard's hasUI check. The TUI host (tui-client) is a frame-loop state machine that spawns a pi rpc child, drives the mesh inbox turn-by-turn, and renders to a dependency-free terminal renderer (tui-render — Node builtins only, no @earendil-works/pi-tui coupling). It routes every ctx.ui method through the rpc bridge, handles Esc three ways (in-dialog cancel, streaming abort, idle no-op), and on prompt-preflight failure abandons the turn (so JetStream redelivers once auth is restored), surfaces a visible "waiting/misconfigured" status, and halts the pump. Connector fix: run the built dist with node directly instead of tsx. The manager's pty runtime sends SIGTERM on `cotal stop`; a tsx wrapper does not forward that signal to its node child, so the peer was SIGKILLed after the grace window without running its shutdown handler (no mesh disconnect, no raw-mode restore). node receives SIGTERM directly. Verified end-to-end through the real path (supervisor → connector → spawned peer → renderer → edit guard): - inbound DMs drive the agent and stream line-buffered text into the pane - edit / write / bash tool calls surface confirm dialogs answerable via the pane stdin (y approves and the edit runs, n blocks it, Esc cancels) - Esc while the agent is streaming sends an abort and interrupts the turn - killing the rpc child tears the peer host down (presence drops) - `cotal stop` now runs the graceful shutdown path (renderer offline + raw mode restored) instead of SIGKILLing the peer - a reply addressed to a live persistent peer is delivered (no "no peer" error when the sender is present), confirming the round-trip - headless mode (PI_PEER_MODE unset) is unchanged The preflight-fail handler (abandon + halt) guards OAuth credentials that expire mid-session; it is not reachable through normal startup because pi resolves --model only to providers with configured auth and falls back to a scoped model otherwise, and zero configured models makes pi exit before rpc mode starts. --- extensions/pi/src/connector.ts | 19 +- extensions/pi/src/tui-client.ts | 349 +++++++++++++++++++++++++++- extensions/pi/src/tui-render.ts | 398 ++++++++++++++++++++++++++++++++ 3 files changed, 749 insertions(+), 17 deletions(-) create mode 100644 extensions/pi/src/tui-render.ts diff --git a/extensions/pi/src/connector.ts b/extensions/pi/src/connector.ts index 054e1807..5c789785 100644 --- a/extensions/pi/src/connector.ts +++ b/extensions/pi/src/connector.ts @@ -1,13 +1,20 @@ import { fileURLToPath } from "node:url"; import { registry, type Connector, type LaunchOpts, type LaunchSpec } from "@cotal-ai/core"; -/** The peer loop runs via tsx (resolved from this extension's own node_modules, so it works - * regardless of the spawned process's PATH/cwd). `main` is loaded with the same extension as - * this module — `main.ts` when running from source (dev), `main.js` when running from built - * `dist/` — so the entrypoint resolves to a file that actually exists in either mode. */ +/** The peer loop runs via tsx when launched from source (.ts, dev) or via node directly + * when launched from built dist (.js). Using node for the built artifact matters: the + * manager's pty runtime sends SIGTERM to the spawned command on `cotal stop`, and a + * tsx wrapper does not forward that signal to its node child (the child is SIGKILLed + * after the grace window instead of running its shutdown handler). Running the built + * .js with node lets the peer's SIGTERM/SIGINT handlers fire for a clean mesh + * disconnect + raw-mode restore. `main` is loaded with the same extension as this + * module — `main.ts` in dev, `main.js` from built `dist/` — so the entrypoint + * resolves to a file that actually exists in either mode. */ const ext = import.meta.url.endsWith(".ts") ? ".ts" : ".js"; -const TSX = fileURLToPath(new URL("../node_modules/.bin/tsx", import.meta.url)); const MAIN = fileURLToPath(new URL(`./main${ext}`, import.meta.url)); +const CMD = ext === ".ts" + ? fileURLToPath(new URL("../node_modules/.bin/tsx", import.meta.url)) // dev: tsx transpiles .ts + : process.execPath; // built: node runs .js + forwards signals /** Provider API keys pi resolves from the environment (AuthStorage falls back to env). * Forwarded when present so a spawned peer has credentials for its model. */ @@ -48,7 +55,7 @@ export const piConnector: Connector = { // TUI host). pi-connector-owned: forwarded by NAME the same way as the model // keys above, never via ...process.env. See main.ts for the dispatch. if (process.env.PI_PEER_MODE) env.PI_PEER_MODE = process.env.PI_PEER_MODE; - return { command: TSX, args: [MAIN], env }; + return { command: CMD, args: [MAIN], env }; }, }; diff --git a/extensions/pi/src/tui-client.ts b/extensions/pi/src/tui-client.ts index 338b0f75..3038f3d9 100644 --- a/extensions/pi/src/tui-client.ts +++ b/extensions/pi/src/tui-client.ts @@ -1,8 +1,9 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; -import { MeshAgent, configFromEnv, launchEnv, type AgentConfig } from "@cotal-ai/connector-core"; +import { MeshAgent, configFromEnv, launchEnv, type AgentConfig, type InboxItem } from "@cotal-ai/connector-core"; import { writeRpc, readJsonLines } from "./rpc-frames.js"; import { PROVIDER_KEYS } from "./connector.js"; +import { createTuiRenderer, type TuiRenderer } from "./tui-render.js"; // Resolve the `pi` wrapper the same way connector.ts resolves `tsx`: as the // extension's own node_modules/.bin entry (pnpm provides it for the @@ -10,6 +11,48 @@ import { PROVIDER_KEYS } from "./connector.js"; // how the connector spawns TSX as the command. const PI_CLI = fileURLToPath(new URL("../node_modules/.bin/pi", import.meta.url)); +// ---- helpers duplicated from peer.ts (per blueprint: "duplicate first, then +// extract `peer-util.ts` only after both paths are stable and byte-identical"). +// Keeping the duplication inline here keeps the bridge from dragging in a +// share-it-later utility file; the helpers are small and the contract is +// identical to peer.ts. + +/** Actionable = a DM, an anycast to our role, or a channel message that names us. */ +function actionable(mesh: MeshAgent, item: InboxItem): boolean { + if (item.fromId === mesh.id) return false; + return item.kind !== "channel" || item.mentionsMe === true; +} + +/** The audience a reply goes back to. Same-scope messages share a turn. */ +function scopeKey(item: InboxItem): string { + return item.kind === "channel" && item.channel ? `channel:${item.channel}` : `dm:${item.fromId}`; +} + +function framed(item: InboxItem): string { + return `from ${item.fromName} via ${item.kind}: ${item.text}`; +} + +/** Pull this turn's final assistant text from the agent_end messages array. */ +function turnReplyText(messages: readonly unknown[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] as { role?: unknown; content?: unknown }; + if (m.role !== "assistant" || !Array.isArray(m.content)) continue; + const text = m.content + .map((p) => + p && typeof p === "object" && (p as { type?: unknown }).type === "text" + ? String((p as { text?: unknown }).text ?? "") + : "", + ) + .join(""); + return text.length ? text : undefined; + } + return undefined; +} + +function log(e: unknown): void { + process.stderr.write(`[pi-peer] ${e instanceof Error ? e.message : String(e)}\n`); +} + /** * Interactive mesh-driven peer — the TUI launch mode selected by `PI_PEER_MODE=tui`. * Spawns stock `pi --mode rpc` as a child and drives it from the mesh while rendering @@ -21,24 +64,308 @@ export async function runTuiClient(config: AgentConfig = configFromEnv()): Promi const mesh = new MeshAgent(config); mesh.start(); + const renderer: TuiRenderer & { _setStreaming?: (on: boolean) => void } = createTuiRenderer(); + renderer.start(); + const child = spawn(PI_CLI, ["--mode", "rpc", "--no-session"], { cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"], env: launchEnv({ providerKeys: PROVIDER_KEYS }), }); + // turn/pending/streaming/halt — the loop state. + // `InboxTurn` in `extensions/connector-core` — same API as peer.ts. + const { InboxTurn } = await import("@cotal-ai/connector-core"); + const turn = new InboxTurn(mesh); + let pending = false; // prompt sent, awaiting preflight response + let halt = false; // set on preflight failure → STOP pump let stderrBuf = ""; - child.stderr?.on("data", (d: Buffer) => { stderrBuf += d.toString(); }); + + const setMeshStatus = (status: "idle" | "working" | "waiting", activity?: string): void => { + void mesh.setStatus(status, activity).catch(log); + }; + + const deliver = (to: InboxItem, text: string): void => { + if (to.kind === "channel" && to.channel) void mesh.send(text, to.channel).catch(log); + else void mesh.dm(to.fromId, text).catch(log); + }; + + function pump(): void { + if (turn.inFlight || pending || halt) return; + turn.drop((i) => !actionable(mesh, i)); + const origin = turn.start(); + if (!origin) { + setMeshStatus("idle"); + return; + } + pending = true; + renderer.flushTrailing(); + writeRpc(child, { type: "prompt", message: framed(origin) }); + } + + function foldSameScope(): void { + if (!turn.origin || pending) return; + for (const item of turn.extend((i, o) => actionable(mesh, i) && scopeKey(i) === scopeKey(o))) { + void renderer; // mark touched + writeRpc(child, { type: "steer", message: framed(item) }); + } + } + + // ----- child stderr → transcript (line 6 of slice 3 design) ----- + child.stderr?.on("data", (d: Buffer) => { + stderrBuf += d.toString(); + }); + + // ----- child exit → renderer.stop() + parent exits loud with child code ----- child.on("exit", (code) => { - if (stderrBuf) process.stderr.write(`[pi-peer] child stderr:\n${stderrBuf}`); - process.stderr.write(`[pi-peer] child exit ${code}\n`); + if (stderrBuf) renderer.pushError(`child stderr: ${stderrBuf}`); + renderer.pushError(`child exit ${code}`); + renderer.stop(); process.exit(code ?? 1); }); - // Slice 2: prove the bridge round-trips. Send one prompt and log every stdout frame. - // Mesh driving + local dialogs land in slice 3. - writeRpc(child, { type: "prompt", message: "Say hello in one word." }); - for await (const frame of readJsonLines(child.stdout)) { - process.stderr.write(`[pi-peer] ${JSON.stringify(frame)}\n`); - } -} \ No newline at end of file + // ----- Esc while streaming → writeRpc {type:"abort"} ----- + renderer.onAbort(() => { + writeRpc(child, { type: "abort" }); + }); + + // ----- mesh → pump or foldSameScope ----- + mesh.on("incoming", () => { + if (turn.inFlight) foldSameScope(); + else pump(); + }); + mesh.on("wake", () => { + if (!turn.inFlight) pump(); + }); + + // ----- the frame loop ----- + void (async () => { + try { + for await (const frame of readJsonLines(child.stdout)) { + const t = (frame as { type?: string }).type; + switch (t) { + // ---- preflight ack ---- + case "response": { + const r = frame as { command?: string; success?: boolean; error?: string }; + if (r.command === "prompt") { + if (r.success === true) { + pending = false; + // streaming will start when agent_start arrives + } else { + // Prompt preflight failed. This is NOT the no-model / no-key + // startup case: pi resolves --model only to providers with + // configured auth and falls back to a scoped model otherwise, and + // zero configured models makes pi exit(1) before rpc mode starts. + // The reachable trigger is an OAuth credential that expires + // MID-SESSION (short-lived-token providers like GitHub Copilot — + // the reason pi's getApiKey callback exists): the next prompt throws + // "authentication failed" here. Abandon so JetStream redelivers the + // message once auth is restored, surface a visible misconfigured + // status, and halt the pump so we don't burn the inbox retrying. + pending = false; + turn.abandon(); + setMeshStatus("waiting", "misconfigured: preflight failed; inbox will redeliver"); + halt = true; + renderer.pushError(`preflight failed: ${r.error ?? "(no error message)"}`); + } + } + // other command acks (steer, abort, ...) are silent. + break; + } + + // ---- agent activity (AgentEvent union: agent_start/end, turn_start/end, + // message_start/update/end, tool_execution_start/update/end) ---- + case "agent_start": { + renderer._setStreaming?.(true); + setMeshStatus("working", "thinking"); + foldSameScope(); // flush same-scope peers that landed before streaming began + break; + } + case "turn_start": { + // A new turn. Set streaming here too — some providers emit turn_start + // without a preceding agent_start, so relying on agent_start alone + // would leave streaming=false and Esc-while-streaming wouldn't abort. + renderer._setStreaming?.(true); + break; + } + case "turn_end": { + // Turn finished; agent_end (with messages + willRetry) follows and is the + // commit point. No-op here. + break; + } + case "message_start": { + // A message (user/assistant/toolResult) begins. The streamed text lands + // via message_update.text_delta; the final assistant text also appears + // in message_end + agent_end.messages. No-op. + break; + } + case "message_end": { + // Message complete. No-op (text was streamed via message_update; the + // turn's final text is committed from agent_end.messages). + break; + } + case "message_update": { + const ev = frame as { + assistantMessageEvent?: { type?: string; delta?: string }; + }; + const am = ev.assistantMessageEvent; + if (am?.type === "text_delta" && typeof am.delta === "string") { + renderer.pushAssistantText(am.delta); + } + break; + } + case "tool_execution_start": { + const ev = frame as { toolName?: string; args?: Record }; + const name = ev.toolName ?? "tool"; + const args = ev.args ?? {}; + const pathLike = + (typeof args["path"] === "string" ? args["path"] as string : undefined) ?? + (typeof args["file_path"] === "string" ? args["file_path"] as string : undefined); + renderer.pushToolEvent(name, pathLike); + setMeshStatus("working", `running ${name}`); + break; + } + case "tool_execution_update": { + // Partial tool result streaming (e.g. long bash output). No-op — the + // tool_execution_end line + status reset is enough for the pane. + break; + } + case "tool_execution_end": { + setMeshStatus("working", "thinking"); + break; + } + // ---- session metadata (AgentSessionEvent extensions) — no-op, do not + // spam the pane. These are session lifecycle signals, not turn data. ---- + case "queue_update": + case "compaction_start": + case "compaction_end": + case "session_info_changed": + case "thinking_level_changed": + case "auto_retry_start": + case "auto_retry_end": + break; + + // ---- extension UI ---- + case "extension_ui_request": { + const req = frame as { + id: string; + method: string; + title?: string; + message?: string; + options?: string[]; + placeholder?: string; + prefill?: string; + timeout?: number; + statusKey?: string; + statusText?: string | undefined; + widgetKey?: string; + widgetLines?: string[] | undefined; + widgetPlacement?: "aboveEditor" | "belowEditor"; + text?: string; + }; + switch (req.method) { + case "confirm": { + const ok = await renderer.popConfirm(req.title ?? "Confirm", req.message ?? "", { timeout: req.timeout }); + writeRpc(child, { type: "extension_ui_response", id: req.id, confirmed: ok }); + break; + } + case "select": { + const v = await renderer.popSelect(req.title ?? "Select", req.options ?? [], { timeout: req.timeout }); + writeRpc(child, { type: "extension_ui_response", id: req.id, value: v }); + break; + } + case "input": { + const v = await renderer.popInput(req.title ?? "Input", req.placeholder, { timeout: req.timeout }); + writeRpc(child, { type: "extension_ui_response", id: req.id, value: v }); + break; + } + case "editor": { + const v = await renderer.popEditor(req.title ?? "Editor", req.prefill, { timeout: req.timeout }); + // popEditor returns "" on cancel/timeout, the post-edit string otherwise. + // Extension API (per per-edit-approval.ts) treats undefined as cancel + // and string as content — but we never send undefined; we send the + // content (or "" on cancel). The rpc-side handler (rpc-mode.js) maps + // any non-cancelled response to the string value, so passing "" + // resolves to "" on the extension end (which the extension then + // can treat as "empty/cancelled" by convention). + writeRpc(child, { type: "extension_ui_response", id: req.id, value: v }); + break; + } + case "notify": { + // Fire-and-forget. Render in pane. + renderer.pushError(`[notify] ${req.message ?? ""}`.trimEnd()); + break; + } + case "setStatus": { + renderer.setStatus("working", `${req.statusKey}: ${req.statusText ?? ""}`); + break; + } + case "setWidget": + case "setTitle": + case "set_editor_text": + // Render-in-place; no response. + renderer.pushError(`[extension ui: ${req.method}] (render-only)`); + break; + default: + renderer.pushError(`[extension ui: unknown method ${req.method}]`); + } + break; + } + + case "extension_error": { + const err = frame as { error?: string; event?: string; extensionPath?: string }; + renderer.pushError(`extension error (${err.event ?? ""}): ${err.error ?? ""}`); + // do not crash; keep processing. + break; + } + + // ---- turn completion ---- + case "agent_end": { + const ev = frame as { willRetry?: boolean; messages?: readonly unknown[] }; + if (ev.willRetry) { + // Auto-retry follows; do NOT commit, do NOT pump. + break; + } + // Clean finish (or failed-but-terminal — both consume the turn). + renderer._setStreaming?.(false); + renderer.flushTrailing(); + const to = turn.origin; + const reply = turnReplyText(ev.messages ?? []); + turn.commit(); // SOLE ack site + if (to && reply) deliver(to, reply); + setMeshStatus("idle"); + pump(); + break; + } + + default: + renderer.pushError(`[unknown frame: ${JSON.stringify(frame)}]`); + } + } + } catch (e) { + renderer.pushError(`bridge read error: ${(e as Error).message}`); + renderer.stop(); + process.exit(1); + } + })(); + + // ----- crash safety: SIGINT / SIGTERM → renderer.stop() + child.kill + mesh.stop + exit 0 ----- + let shuttingDown = false; + const shutdown = async (): Promise => { + if (shuttingDown) return; + shuttingDown = true; + renderer.stop(); + try { child.kill("SIGTERM"); } catch { /* */ } + try { await mesh.stop(); } catch { /* */ } + process.exit(0); + }; + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); + process.on("exit", () => renderer.stop()); + + // Drain anything already buffered before the listeners were attached. + pump(); + + // Keep alive. + await new Promise(() => {}); +} diff --git a/extensions/pi/src/tui-render.ts b/extensions/pi/src/tui-render.ts new file mode 100644 index 00000000..eaba88ac --- /dev/null +++ b/extensions/pi/src/tui-render.ts @@ -0,0 +1,398 @@ +import { spawn as childSpawn } from "node:child_process"; +import { promises as fsp, mkdtempSync, type Dirent } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as readline from "node:readline"; + +/** Why this file exists: the pi connector's tui mode needs a renderer that + * depends only on Node builtins so it can ride on top of any pi release that + * keeps the rpc wire stable. No @earendil-works/pi-tui dep. No terminal- + * screen takeover — line-oriented, so a terminal crash leaves the pane + * cookable and avoids the restore-rawmode death-spiral that full-screen TUIs + * die in. + * + * Dialog block semantics: only one dialog at a time. While a dialog is open, + * the renderer ignores subsequent `popXxx` calls (returns a never-resolving + * promise) — rpc-mode's extensions block on a Promise per dialog, so child + * emits are inherently serial, but in case a fire-and-forget method arrives + * mid-dialog, render-in-place (pushError / setStatus) doesn't disturb the + * active dialog. */ + +export interface TuiRenderer { + start(): void; + stop(): void; + pushAssistantText(delta: string): void; + flushTrailing(): void; + pushToolEvent(toolName: string, pathLike?: string): void; + pushError(line: string): void; + setStatus(status: string, activity?: string): void; + popConfirm(title: string, message: string, opts?: { timeout?: number }): Promise; + popSelect(title: string, options: string[], opts?: { timeout?: number }): Promise; + popInput(title: string, placeholder?: string, opts?: { timeout?: number }): Promise; + popEditor(title: string, prefill?: string, opts?: { timeout?: number; editorOverride?: string }): Promise; + onAbort(cb: () => void): void; +} + +/** Options for the renderer. `editor` lets the platform override $EDITOR; the + * default picks $VISUAL then $EDITOR then `vi`. */ +export interface TuiRendererOptions { + editor?: string; + out?: NodeJS.WritableStream; + err?: NodeJS.WritableStream; +} + +export function createTuiRenderer(opts: TuiRendererOptions = {}): TuiRenderer { + const out = opts.out ?? process.stdout; + const err = opts.err ?? process.stderr; + + // Raw-mode + keypress state. `keyListener` is the readline-attached keypress + // handler; we keep a reference so stop() can detach. + let raw = false; + let keyListener: ((chunk: string, key: readline.Key) => void) | null = null; + let stopped = false; // stop() is called from multiple shutdown paths; guard the offline line + + // Abort-on-Esc callback: only fires OUTSIDE a dialog while STREAMING. Client + // sets this; renderer fires it; client sends {type:"abort"} to the child. + let onAbortCb: (() => void) | null = null; + + // Dialog state. While `dialogActive` is true, keypresses route to the + // dialog's resolver; outside, plain Esc while STREAMING triggers onAbortCb. + // STREAMING is owned by the client, which calls renderer.markStreaming(true/false). + let streaming = false; + let dialogActive = false; + + // Line buffer for pushAssistantText — accumulates partial lines, writes + // whole lines so the terminal isn't re-painted mid-word. + let trailing = ""; + + function writeLine(line: string): void { + out.write(line.endsWith("\n") ? line : line + "\n"); + } + + function writeRaw(s: string): void { + out.write(s); + } + + function emitError(line: string): void { + err.write(`[pi-peer] ! ${line}\n`); + } + + function setRaw(on: boolean): void { + if (on === raw) return; + if (!process.stdin.isTTY) { + // No TTY (e.g. piped CI): the renderer degrades — dialogs cannot be + // answered interactively and auto-cancel. Throwing would be louder but + // would break scripted use; degrading with a visible banner is friendlier. + raw = false; + return; + } + process.stdin.setRawMode(on); + raw = on; + } + + function attachKeypress(): void { + if (keyListener) return; + readline.emitKeypressEvents(process.stdin); + keyListener = (_chunk, key) => onKey(key); + process.stdin.on("keypress", keyListener); + } + + function detachKeypress(): void { + if (!keyListener) return; + process.stdin.off("keypress", keyListener); + keyListener = null; + } + + function onKey(key: readline.Key): void { + // Plain Esc: in-dialog cancels (resolver handles); OUT-of-dialog while + // STREAMING triggers abort; otherwise ignore (idle Esc is a no-op). + if (key.name === "escape") { + if (dialogActive) { + // dialog resolver handles this — they listen via the raw stream too. + } else if (streaming) { + const cb = onAbortCb; + if (cb) cb(); + } + return; + } + if (key.ctrl && key.name === "c") { + // SIGINT equivalent: tear the renderer down and let the client's handler exit. + // Don't call process.exit here — the client owns lifecycle. + emitError("Ctrl-C in pane (renderer will close)"); + // No-op for now; client decides. We surface the keypress. + } + // Other keys are routed to the active dialog's own listener (via raw stdin) + // — none here at the renderer level. + } + + // restore on every exit / signal path. + function restore(): void { + detachKeypress(); + if (raw) { + try { setRaw(false); } catch { /* terminal may already be gone */ } + } + } + + // Lifecycle: register once at start(); stop() unregisters (idempotent). + let installed = false; + function installLifecycle(): void { + if (installed) return; + installed = true; + process.on("exit", restore); + process.on("SIGINT", restore); + process.on("SIGTERM", restore); + } + + // ----- dialog helpers ----- + + /** Run an interactive dialog: turn on raw mode, push the prompt to the + * pane, attach a one-shot keypress handler that resolves the promise. + * `parse(key)` returns {done, value?}; done=true resolves the dialog. */ + function runDialog( + title: string, + body: () => string, + parse: (key: readline.Key) => { done: true; value: T } | { done: false }, + defaultValue: T, + timeout?: number, + ): Promise { + installLifecycle(); + setRaw(true); + dialogActive = true; + writeLine(`\n┌─ ${title}\n${body()}\n└─`); + writeRaw("> "); + return new Promise((resolve) => { + let settled = false; + const settle = (v: T): void => { + if (settled) return; + settled = true; + dialogActive = false; + writeRaw("\n"); + try { setRaw(false); } catch { /* terminal may already be gone */ } + process.stdin.off("keypress", dialogListener); + resolve(v); + }; + const dialogListener = (_chunk: string, key: readline.Key): void => { + // Esc cancels — confirm→false, select/input/editor→"" (matches rpc-side + // `createDialogPromise(opts, defaultValue, ...)` resolve-on-timeout). + if (key.name === "escape") { settle(defaultValue); return; } + if (key.ctrl && key.name === "c") { settle(defaultValue); return; } + const r = parse(key); + if (r.done) settle(r.value); + }; + process.stdin.on("keypress", dialogListener); + if (timeout !== undefined) { + setTimeout(() => settle(defaultValue), timeout); + } + }); + } + + // ----- public API ----- + + return { + start() { + installLifecycle(); + attachKeypress(); + // Keep raw mode ON for the renderer's lifetime so Esc (and other keystrokes) + // are captured by onKey, not eaten by the cooked-mode line discipline. The + // renderer owns the pane's input surface; dialogs toggle raw mode redundantly + // (idempotent). Ctrl-C in raw mode arrives as a keypress, not SIGINT — the + // supervisor's `cotal stop` is the operator's exit path for a managed peer. + setRaw(true); + writeLine("[pi-peer] renderer online"); + }, + stop() { + // Idempotent: unregisters key listener and exits raw mode. Safe to call + // from process.on("exit") / SIGINT / SIGTERM / finally. Emits a visible + // offline line so the operator sees a clean shutdown (and so the shutdown + // path is observable in tests). + if (!stopped) { writeLine("[pi-peer] renderer offline"); stopped = true; } + restore(); + }, + setStatus(_status, activity) { + // Render the status as a brief annotation line at the bottom of the pane + // (no flicker on repaint). Activity null/undefined clears it. + if (activity === undefined) writeRaw("\n\u001b[2m[working]\u001b[0m\n"); + else writeRaw(`\n\u001b[2m[working: ${activity}]\u001b[0m\n`); + }, + markStreaming(_on: boolean): void { + // The renderer's "streaming" state is set via attachStreaming() so that + // the keypress handler can route Esc to onAbortCb while STREAMING. + // Exposed on the returned object to keep the public surface narrow. + }, + // Internal hooks — exposed via private fields on the returned object below. + _streaming: (on: boolean) => { streaming = on; }, + pushAssistantText(delta) { + // Buffered line rendering: accumulate until a full line, then write. + // flushTrailing() is called by the client on agent_end to handle a final + // partial line. + const i = trailing.length ? trailing + delta : delta; + const parts = i.split("\n"); + trailing = parts.pop() ?? ""; + for (const line of parts) writeLine(line); + }, + flushTrailing() { + if (trailing) { + writeLine(trailing); + trailing = ""; + } + }, + pushToolEvent(toolName, pathLike) { + const target = pathLike ? ` ${pathLike}` : ""; + writeLine(`▸ ${toolName}${target}`); + }, + pushError(line) { + emitError(line); + }, + onAbort(cb) { + onAbortCb = cb; + }, + async popConfirm(title, message, opts) { + // y/Enter → true; n/Esc/Ctrl-C → false. + const defaultV: boolean = false; + return runDialog( + title, + () => `${message}\n(y/n, Esc to cancel)`, + (key) => { + if (key.name === "return" || key.name === "y") return { done: true, value: true }; + if (key.name === "n") return { done: true, value: false }; + return { done: false }; + }, + defaultV, + opts?.timeout, + ); + }, + async popSelect(title, options, opts) { + // arrow keys: up/down move; enter commits; esc cancels. Default value + // "" matches rpc-side `"value" in r ? r.value : undefined` for select. + let idx = 0; + const render = (): void => { + writeRaw("\u001b[2J\u001b[H"); // clear screen, home + writeLine(`┌─ ${title} (↑/↓, Enter, Esc to cancel)`); + options.forEach((o, i) => writeLine(` ${i === idx ? "▶" : " "} ${i + 1}. ${o}`)); + writeRaw("└─"); + }; + const defaultV: string = ""; // undefined if absolute blank + return new Promise((resolve) => { + installLifecycle(); + setRaw(true); + dialogActive = true; + render(); + let settled = false; + const settle = (v: string): void => { + if (settled) return; + settled = true; + dialogActive = false; + writeRaw("\n"); + try { setRaw(false); } catch { /* */ } + process.stdin.off("keypress", dialogListener); + resolve(v); + }; + const dialogListener = (_c: string, key: readline.Key): void => { + if (key.name === "escape") { settle(defaultV); return; } + if (key.ctrl && key.name === "c") { settle(defaultV); return; } + if (key.name === "up") { idx = (idx - 1 + options.length) % options.length; render(); return; } + if (key.name === "down") { idx = (idx + 1) % options.length; render(); return; } + if (key.name === "return") { settle(options[idx] ?? defaultV); return; } + }; + process.stdin.on("keypress", dialogListener); + if (opts?.timeout !== undefined) setTimeout(() => settle(defaultV), opts.timeout); + }); + }, + async popInput(title, placeholder, opts) { + // Raw-mode single-line input so Esc cancels cleanly without readline eating it. + // Handles backspace editing + echo (the cooked tty would do echo; raw doesn't, so we + // echo printable chars ourselves, render backspace as `\b \b`). + const defaultV = ""; // matches rpc-side `"value" in r ? r.value : undefined` for input + installLifecycle(); + setRaw(true); + dialogActive = true; + writeLine(`\n┌─ ${title}${placeholder ? ` (${placeholder})` : ""}`); + writeRaw("└─ > "); + let buf = ""; + const echo = (s: string): void => { + writeRaw(s.replace(/\r/g, "").replace(/\u0007/g, "")); + }; + return new Promise((resolve) => { + let settled = false; + const settle = (v: string): void => { + if (settled) return; + settled = true; + dialogActive = false; + writeRaw("\n"); + try { setRaw(false); } catch { /* */ } + process.stdin.off("keypress", dialogListener); + resolve(v); + }; + const dialogListener = (_c: string, key: readline.Key): void => { + if (key.name === "escape") { settle(defaultV); return; } + if (key.ctrl && key.name === "c") { settle(defaultV); return; } + if (key.name === "return") { writeRaw("\n"); settle(buf); return; } + if (key.name === "backspace") { + if (buf.length) { + buf = buf.slice(0, -1); + writeRaw("\b \b"); + } + return; + } + // Printable: append to buffer + echo. + if (!key.ctrl && key.sequence && key.sequence.length === 1) { + buf += key.sequence; + echo(key.sequence); + } + }; + process.stdin.on("keypress", dialogListener); + if (opts?.timeout !== undefined) setTimeout(() => settle(defaultV), opts.timeout); + }); + }, + async popEditor(title, prefill, opts) { + // Spawn $EDITOR on a tempfile; on save → return content; on :q/non-zero + // exit or Esc → empty string. The tempfile is unlinked on every exit path. + const editor = opts?.editorOverride + ?? process.env.VISUAL + ?? process.env.EDITOR + ?? "vi"; + const tmpDir = mkdtempSync(join(tmpdir(), "pi-edit-")); + const file = join(tmpDir, "edit.md"); + if (prefill) await fsp.writeFile(file, prefill, "utf8"); + else await fsp.writeFile(file, "", "utf8"); + + installLifecycle(); + // Editor runs in normal tty mode (not raw) so vi/nano/etc. work. + dialogActive = true; + writeLine(`┌─ ${title} (external editor: ${editor})`); + + const defaultV = ""; // matches rpc-side editor resolved value + const result = await new Promise((resolve) => { + const proc = childSpawn(editor, [file], { + stdio: "inherit", + env: process.env, + }); + const settle = (v: string): void => { + dialogActive = false; + resolve(v); + }; + proc.on("exit", async (code) => { + if (code !== 0) { settle(defaultV); return; } + try { + const content = await fsp.readFile(file, "utf8"); + settle(content); + } catch { + settle(defaultV); + } + }); + if (opts?.timeout !== undefined) { + setTimeout(() => { try { proc.kill("SIGTERM"); } catch { /* */ } settle(defaultV); }, opts.timeout); + } + }); + try { await fsp.rm(tmpDir, { recursive: true, force: true }); } catch { /* */ } + return result; + }, + // Force-render a status set by client (test path). + _setStreaming(on: boolean): void { streaming = on; }, + } as TuiRenderer & { _setStreaming: (on: boolean) => void }; +} + +/** Note: we mark the returned object with `_setStreaming` so the client can + * transition the streaming flag on agent_start / agent_end. The TypeScript + * intersection isn't visible externally but the runtime hook is there. + * (Avoiding an `as any` cast at call sites that need to drive Esc routing.) */ From bc4229b3cf8d87c99d2701a6b6b4a522e3abe403 Mon Sep 17 00:00:00 2001 From: Jay the Reaper <198331141+TheReaperJay@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:27:16 +0700 Subject: [PATCH 7/8] feat(pi): forward persona + model into spawned pi peers (TUI + headless) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both runtime paths ignored the agent file's persona body and pinned model — the pi connector was the lone holdout among the harness connectors (claude-code, opencode, and hermes all translate AgentDef.persona → system prompt and AgentDef.model → the host's --model via loadAgentFile). A `cotal start --agent pi --name dave` produced a peer that ran as a generic pi, ignoring dave's persona and any pinned model. This closes both paths with one shared parse point and per-path translation. buildLaunch (connector.ts) is the single parse point: it calls loadAgentFile at launch (so a malformed persona fails loud at `cotal start`, matching the siblings) and resolves `opts.model ?? def.model` (`cotal start --model` beats the file's `model:`). It forwards only the short resolved model string (COTAL_MODEL); the persona body never rides in env (ARG_MAX ceiling on spawn) — each runtime path reads it from the already-forwarded COTAL_AGENT_FILE. TUI (tui-client.ts): writes the frontmatter-stripped body to a 0600 tempfile and passes `--append-system-prompt ` — pi reads the file contents (its resolvePromptInput: existsSync → readFileSync), so only the short path is in argv and there is no ARG_MAX ceiling for a large persona. Passes `--model COTAL_MODEL`. The tempfile is unlinked on every exit path (child exit, SIGINT/ SIGTERM shutdown, process exit). Headless (peer.ts): hands the body to DefaultResourceLoader({appendSystemPrompt: [body]}) — pi's in-process equivalent of `--append-system-prompt`, constructed with the same defaults createAgentSession would have used so extension/skill/ theme loading is preserved. Resolves the model via a small resolveModel helper on public ModelRegistry.getAvailable() + Model.provider/Model.id, mirroring pi's internal findExactModelReferenceMatch contract (canonical provider/id → split provider+id → unique bare id, reject ambiguous). Throws on not-found/ambiguous — no fuzzy matching and no silent fallback. Intentional asymmetry, documented in code: TUI accepts fuzzy model patterns (pi's CLI resolves them); headless requires an exact reference and throws on fuzzy/ambiguous. A declarative persona `model:` pin should be an exact reference; fuzzy is an interactive-CLI convenience, and pi does not export a public fuzzy resolver (resolveCliModel is internal and CLI-coupled). If pi later exports one, resolveModel can swap to it. docs/agent-frameworks.md: corrected the now-stale "pi has no permission gate, so spawned peers run unattended" (false since the TUI launch mode made ctx.ui.* operator-answerable per-pane) and "launches the peer via tsx" (now node for built dist, so SIGTERM from `cotal stop` reaches the peer) lines, and added the persona-body→system-prompt + COTAL_MODEL forwarding. Verified end-to-end through the real supervisor→connector→spawned-peer path: a haiku persona produces haiku replies in the TUI pane and lands in the headless session's system prompt; the pinned model is forwarded (COTAL_MODEL in the child env) and honored by pi; a bogus or ambiguous model makes headless throw at startup (no fallback to pi's default); persona tempfiles are cleaned up on peer exit; pnpm smoke:ci green; no @earendil-works/pi-tui render coupling; no deep import of pi internals. --- docs/agent-frameworks.md | 17 +++++--- extensions/pi/src/connector.ts | 25 ++++++++++-- extensions/pi/src/peer.ts | 70 +++++++++++++++++++++++++++++++-- extensions/pi/src/tui-client.ts | 47 ++++++++++++++++++---- 4 files changed, 140 insertions(+), 19 deletions(-) diff --git a/docs/agent-frameworks.md b/docs/agent-frameworks.md index 72b177b3..79c475a0 100644 --- a/docs/agent-frameworks.md +++ b/docs/agent-frameworks.md @@ -45,13 +45,20 @@ into a *live* one (true mid-turn drive, before the next LLM call; a different-sc waits for its own turn, so a private DM is never folded into a channel reply), with `abort()` to interrupt. No external channel, host process, or keystrokes — inbound `"incoming"` calls a method on the embedded -session. pi has no permission gate, so spawned peers run unattended (sandbox/containerize -per pi's own guidance); it needs Node ≥22.19 and a provider key in the env. +session. pi has no built-in permission gate, but in TUI mode (`PI_PEER_MODE=tui`) any pi +extension that calls `ctx.ui.*` (approval gates, prompts, selects, editors) is rendered +live in the peer's tmux pane and becomes operator-answerable per-pane (e.g. an edit-approval +gate); headless peers run unattended (sandbox/containerize per pi's own guidance). It needs +Node ≥22.19 and a provider key in the env. A `Connector` extension (`buildLaunch`) lets the manager spawn it: it launches the peer via -`tsx` and forwards the launcher's identity (`COTAL_ID`), minted creds (`COTAL_CREDS`), and any -agent file (`COTAL_AGENT_FILE`), so under auth the peer authenticates as the id the manager -provisioned. The connector self-registers on import (`pi`); a composition root just imports it. +`tsx` (dev) or `node` (built `dist/`) and forwards the launcher's identity (`COTAL_ID`), minted +creds (`COTAL_CREDS`), and the agent file (`COTAL_AGENT_FILE`). At launch it parses the agent +file and forwards the resolved model (`COTAL_MODEL` — the `cotal start --model` flag takes +precedence over the file's `model:`); each runtime path reads the file's persona body and +injects it as the agent's system prompt, so a spawned peer runs as its declared persona. +Under auth the peer authenticates as the id the manager provisioned. The connector +self-registers on import (`pi`); a composition root just imports it. ## Running diff --git a/extensions/pi/src/connector.ts b/extensions/pi/src/connector.ts index 5c789785..8627c9d2 100644 --- a/extensions/pi/src/connector.ts +++ b/extensions/pi/src/connector.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from "node:url"; -import { registry, type Connector, type LaunchOpts, type LaunchSpec } from "@cotal-ai/core"; +import { loadAgentFile, registry, type Connector, type LaunchOpts, type LaunchSpec } from "@cotal-ai/core"; /** The peer loop runs via tsx when launched from source (.ts, dev) or via node directly * when launched from built dist (.js). Using node for the built artifact matters: the @@ -34,8 +34,14 @@ export const PROVIDER_KEYS = [ * The pi connector: launches an embedded Cotal peer that runs the pi coding-agent SDK * loop and answers mesh traffic as a lateral peer. Inbound drives the loop directly * (prompt to wake, steer to interject mid-turn). Forwards the launcher's identity + - * minted creds so the peer authenticates as `id` under auth. Self-registers on import; - * the manager resolves it by agent type "pi". + * minted creds so the peer authenticates as `id` under auth. Parses the agent file at + * launch (so a malformed persona fails loud at `cotal start`, matching the sibling + * connectors) and forwards the resolved model (`COTAL_MODEL`); the runtime path reads + * the persona body from the agent file and injects it as the system prompt, so a + * spawned peer runs as its declared persona. `PI_PEER_MODE=tui` selects the + * interactive per-pane host (operator-answerable `ctx.ui.*` dialogs); unset runs the + * headless embedded loop. Self-registers on import; the manager resolves it by + * agent type "pi". */ export const piConnector: Connector = { kind: "connector", @@ -46,7 +52,18 @@ export const piConnector: Connector = { if (opts.id) env.COTAL_ID = opts.id; if (opts.creds) env.COTAL_CREDS = opts.creds; if (opts.servers) env.COTAL_SERVERS = opts.servers; - if (opts.configPath) env.COTAL_AGENT_FILE = opts.configPath; + if (opts.configPath) { + env.COTAL_AGENT_FILE = opts.configPath; + // Parse the persona file here (not in the child) so a malformed persona + // fails loud at `cotal start` (matching the sibling connectors) and so the + // `cotal start --model` flag precedence over the file's `model:` resolves + // once, here. Forward only the short resolved model string; the persona + // body is read from disk by whichever runtime path runs (TUI/headless), so + // a large persona never rides in env (ARG_MAX ceiling on spawn). + const def = loadAgentFile(opts.configPath); + const model = opts.model ?? def.model; + if (model) env.COTAL_MODEL = model; + } for (const key of PROVIDER_KEYS) { const value = process.env[key]; if (value) env[key] = value; diff --git a/extensions/pi/src/peer.ts b/extensions/pi/src/peer.ts index 3a6abf19..2bf8e110 100644 --- a/extensions/pi/src/peer.ts +++ b/extensions/pi/src/peer.ts @@ -1,14 +1,18 @@ import { MeshAgent, InboxTurn, configFromEnv } from "@cotal-ai/connector-core"; import type { InboxItem, AgentConfig } from "@cotal-ai/connector-core"; +import { loadAgentFile } from "@cotal-ai/core"; import { AuthStorage, + DefaultResourceLoader, ModelRegistry, SessionManager, + SettingsManager, createAgentSession, defineTool, + getAgentDir, } from "@earendil-works/pi-coding-agent"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; -import { Type } from "@earendil-works/pi-ai"; +import { Type, type Api, type Model } from "@earendil-works/pi-ai"; function log(e: unknown): void { process.stderr.write(`[pi-peer] ${e instanceof Error ? e.message : String(e)}\n`); @@ -88,6 +92,40 @@ function turnReplyText(messages: readonly unknown[]): string | undefined { return undefined; } +/** + * Resolve a persona's pinned `model:` string to a pi {@link Model} object using + * only public API (`ModelRegistry.getAvailable` + the public `Model.provider`/ + * `Model.id` fields). Exact-match only — mirrors pi's internal + * `findExactModelReferenceMatch` contract: canonical `provider/id`, then a split + * `provider`+`id`, then a bare `id` (rejected if ambiguous across providers). No + * fuzzy/partial matching: that is pi's CLI convenience (internal, not exported) + * and not appropriate for a declarative persona pin. Throws on not-found/ + * ambiguous so a bad pin fails loud at startup instead of silently falling back + * to pi's default model. (TUI mode passes the string to pi's CLI which fuzzy- + * resolves it; headless runs in-process where only exact resolution is public.) + */ +function resolveModel(ref: string, registry: ModelRegistry): Model { + const avail = registry.getAvailable(); + const r = ref.trim().toLowerCase(); + if (!r) throw new Error("pi peer: empty persona model"); + // canonical "provider/id" + let m = avail.find((x) => `${x.provider}/${x.id}`.toLowerCase() === r); + // split provider + id + if (!m && r.includes("/")) { + const slash = ref.indexOf("/"); + const provider = ref.slice(0, slash).trim().toLowerCase(); + const id = ref.slice(slash + 1).trim().toLowerCase(); + if (provider && id) m = avail.find((x) => x.provider.toLowerCase() === provider && x.id.toLowerCase() === id); + } + // bare id — must be unique across providers + if (!m) { + const ids = avail.filter((x) => x.id.toLowerCase() === r); + if (ids.length === 1) m = ids[0]; + } + if (!m) throw new Error(`pi peer: persona model "${ref}" not found or ambiguous — use provider/id`); + return m; +} + /** * Embed a pi coding-agent session in-process and drive it from mesh traffic. This is the * native-embed pattern (cf. docs/agent-frameworks.md): MeshAgent owns the NATS connection, @@ -109,12 +147,38 @@ export async function runPiPeer(config: AgentConfig = configFromEnv()): Promise< mesh.start(); const authStorage = AuthStorage.create(); + const modelRegistry = ModelRegistry.create(authStorage); + + // Persona + model from the agent file. buildLaunch already parsed the file for + // launch-time validation + `--model` precedence and forwarded COTAL_MODEL (the + // resolved model string). The persona body is read here, in-process, and handed + // to the ResourceLoader as appendSystemPrompt — the in-process equivalent of + // TUI's --append-system-prompt. Construct the loader with the same defaults + // createAgentSession would have used (cwd/agentDir/settingsManager) so passing + // our own doesn't silently drop extension/skill/theme loading. + const def = process.env.COTAL_AGENT_FILE ? loadAgentFile(process.env.COTAL_AGENT_FILE) : undefined; + const cwd = process.cwd(); + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.create(cwd, agentDir); + const resourceLoader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager, + appendSystemPrompt: def?.persona ? [def.persona] : undefined, + }); + await resourceLoader.reload(); + const model = process.env.COTAL_MODEL ? resolveModel(process.env.COTAL_MODEL, modelRegistry) : undefined; + const { session } = await createAgentSession({ - cwd: process.cwd(), + cwd, + agentDir, authStorage, - modelRegistry: ModelRegistry.create(authStorage), + modelRegistry, + settingsManager, sessionManager: SessionManager.inMemory(), customTools: buildTools(mesh), + resourceLoader, + model, }); const turn = new InboxTurn(mesh); diff --git a/extensions/pi/src/tui-client.ts b/extensions/pi/src/tui-client.ts index 3038f3d9..57e9adf4 100644 --- a/extensions/pi/src/tui-client.ts +++ b/extensions/pi/src/tui-client.ts @@ -1,5 +1,9 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadAgentFile } from "@cotal-ai/core"; import { MeshAgent, configFromEnv, launchEnv, type AgentConfig, type InboxItem } from "@cotal-ai/connector-core"; import { writeRpc, readJsonLines } from "./rpc-frames.js"; import { PROVIDER_KEYS } from "./connector.js"; @@ -11,11 +15,12 @@ import { createTuiRenderer, type TuiRenderer } from "./tui-render.js"; // how the connector spawns TSX as the command. const PI_CLI = fileURLToPath(new URL("../node_modules/.bin/pi", import.meta.url)); -// ---- helpers duplicated from peer.ts (per blueprint: "duplicate first, then -// extract `peer-util.ts` only after both paths are stable and byte-identical"). -// Keeping the duplication inline here keeps the bridge from dragging in a -// share-it-later utility file; the helpers are small and the contract is -// identical to peer.ts. +// ---- helpers duplicated from peer.ts. Kept inline rather than shared via a +// `peer-util.ts` because the two paths diverge in places (e.g. `actionable` +// writes `mentionsMe === true` here vs bare `mentionsMe` in peer.ts, and `framed` +// is a top-level function here vs an inline arrow there) — forcing a shared module +// would paper over those intentional differences. Small, contract-stable helpers; +// revisit the extraction only if both paths converge to byte-identical bodies. /** Actionable = a DM, an anycast to our role, or a channel message that names us. */ function actionable(mesh: MeshAgent, item: InboxItem): boolean { @@ -67,7 +72,33 @@ export async function runTuiClient(config: AgentConfig = configFromEnv()): Promi const renderer: TuiRenderer & { _setStreaming?: (on: boolean) => void } = createTuiRenderer(); renderer.start(); - const child = spawn(PI_CLI, ["--mode", "rpc", "--no-session"], { + // Persona body → tempfile → --append-system-prompt . pi reads the file + // contents (resource-loader resolvePromptInput: existsSync → readFileSync), so + // only the short temp path rides in argv — no ARG_MAX ceiling for a large + // persona. The body is frontmatter-stripped via loadAgentFile; the raw persona + // file has `---` metadata that must not enter the system prompt. The resolved + // model string (COTAL_MODEL) is forwarded by buildLaunch; pi's CLI resolves it + // (exact + fuzzy) the same as any `pi --model` invocation. + let personaDir: string | undefined; + const agentFile = process.env.COTAL_AGENT_FILE; + if (agentFile) { + const def = loadAgentFile(agentFile); + if (def.persona) { + personaDir = mkdtempSync(join(tmpdir(), "cotal-persona-")); + writeFileSync(join(personaDir, "persona.md"), def.persona, { mode: 0o600 }); + } + } + const cleanupPersona = (): void => { + if (personaDir) { + try { rmSync(personaDir, { recursive: true, force: true }); } catch { /* */ } + personaDir = undefined; + } + }; + + const childArgs = ["--mode", "rpc", "--no-session"]; + if (personaDir) childArgs.push("--append-system-prompt", join(personaDir, "persona.md")); + if (process.env.COTAL_MODEL) childArgs.push("--model", process.env.COTAL_MODEL); + const child = spawn(PI_CLI, childArgs, { cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"], env: launchEnv({ providerKeys: PROVIDER_KEYS }), @@ -120,6 +151,7 @@ export async function runTuiClient(config: AgentConfig = configFromEnv()): Promi child.on("exit", (code) => { if (stderrBuf) renderer.pushError(`child stderr: ${stderrBuf}`); renderer.pushError(`child exit ${code}`); + cleanupPersona(); renderer.stop(); process.exit(code ?? 1); }); @@ -355,13 +387,14 @@ export async function runTuiClient(config: AgentConfig = configFromEnv()): Promi if (shuttingDown) return; shuttingDown = true; renderer.stop(); + cleanupPersona(); try { child.kill("SIGTERM"); } catch { /* */ } try { await mesh.stop(); } catch { /* */ } process.exit(0); }; process.on("SIGINT", () => void shutdown()); process.on("SIGTERM", () => void shutdown()); - process.on("exit", () => renderer.stop()); + process.on("exit", () => { cleanupPersona(); renderer.stop(); }); // Drain anything already buffered before the listeners were attached. pump(); From cdc9b48d7a8123f5acc063798a92145a88ab85ca Mon Sep 17 00:00:00 2001 From: Jay the Reaper <198331141+TheReaperJay@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:19:46 +0700 Subject: [PATCH 8/8] feat(pi): read peerMode from agent file frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pi connector's launch mode (TUI vs headless) was only settable via the PI_PEER_MODE env var, forcing the operator to type it on every spawn. Add a persistent per-agent knob: an unmodelled `peerMode:` frontmatter key swept into AgentDef.meta — the same passthrough the OpenCode connector uses for `face:` — read by buildLaunch alongside the model it already parses from the agent file. PI_PEER_MODE env still wins as the operator one-off override and is forwarded verbatim (the peer validates it); only "tui" is forwarded, since "headless" or unset leaves the var absent and the peer defaults to headless (main.ts). An invalid file value fails loud at buildLaunch, naming the agent file, rather than silently degrading. Core is untouched — AgentDef.meta exists precisely so a connector can read its own launcher hints without core knowing each one. --- docs/agent-frameworks.md | 9 +++++---- extensions/pi/src/connector.ts | 22 ++++++++++++++++------ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/agent-frameworks.md b/docs/agent-frameworks.md index 79c475a0..5cc4d227 100644 --- a/docs/agent-frameworks.md +++ b/docs/agent-frameworks.md @@ -45,10 +45,11 @@ into a *live* one (true mid-turn drive, before the next LLM call; a different-sc waits for its own turn, so a private DM is never folded into a channel reply), with `abort()` to interrupt. No external channel, host process, or keystrokes — inbound `"incoming"` calls a method on the embedded -session. pi has no built-in permission gate, but in TUI mode (`PI_PEER_MODE=tui`) any pi -extension that calls `ctx.ui.*` (approval gates, prompts, selects, editors) is rendered -live in the peer's tmux pane and becomes operator-answerable per-pane (e.g. an edit-approval -gate); headless peers run unattended (sandbox/containerize per pi's own guidance). It needs +session. pi has no built-in permission gate, but in TUI mode (`PI_PEER_MODE=tui`, or the +agent file's `peerMode: tui` frontmatter hint) any pi extension that calls `ctx.ui.*` +(approval gates, prompts, selects, editors) is rendered live in the peer's tmux pane and +becomes operator-answerable per-pane (e.g. an edit-approval gate); headless peers +(unset/`headless`) run unattended (sandbox/containerize per pi's own guidance). It needs Node ≥22.19 and a provider key in the env. A `Connector` extension (`buildLaunch`) lets the manager spawn it: it launches the peer via diff --git a/extensions/pi/src/connector.ts b/extensions/pi/src/connector.ts index 8627c9d2..d1172a66 100644 --- a/extensions/pi/src/connector.ts +++ b/extensions/pi/src/connector.ts @@ -38,9 +38,9 @@ export const PROVIDER_KEYS = [ * launch (so a malformed persona fails loud at `cotal start`, matching the sibling * connectors) and forwards the resolved model (`COTAL_MODEL`); the runtime path reads * the persona body from the agent file and injects it as the system prompt, so a - * spawned peer runs as its declared persona. `PI_PEER_MODE=tui` selects the - * interactive per-pane host (operator-answerable `ctx.ui.*` dialogs); unset runs the - * headless embedded loop. Self-registers on import; the manager resolves it by + * spawned peer runs as its declared persona. `PI_PEER_MODE=tui` (or the agent file's `peerMode: tui` frontmatter hint) + * selects the interactive per-pane host (operator-answerable `ctx.ui.*` dialogs); + * unset/`headless` runs the headless embedded loop. Self-registers on import; the manager resolves it by * agent type "pi". */ export const piConnector: Connector = { @@ -63,14 +63,24 @@ export const piConnector: Connector = { const def = loadAgentFile(opts.configPath); const model = opts.model ?? def.model; if (model) env.COTAL_MODEL = model; + // Launch mode (headless embedded loop vs. interactive TUI host). The agent + // file's `peerMode:` frontmatter key is the persistent per-agent knob — an + // unmodelled scalar swept into AgentDef.meta, the same passthrough the + // OpenCode connector uses for `face:`, so core stays ignorant of it. An + // operator one-off still wins: `PI_PEER_MODE` env overrides the file and is + // forwarded verbatim (the peer validates it, see main.ts). Only "tui" is + // worth forwarding — "headless"/unset leave the var absent so the peer + // defaults to headless; an invalid file value fails loud here, not silently. + const peerMode = def.meta?.peerMode; + if (peerMode !== undefined && peerMode !== "tui" && peerMode !== "headless") + throw new Error(`agent file ${opts.configPath}: peerMode must be "tui" or "headless" (got ${JSON.stringify(peerMode)})`); + if (!process.env.PI_PEER_MODE && peerMode === "tui") env.PI_PEER_MODE = "tui"; } for (const key of PROVIDER_KEYS) { const value = process.env[key]; if (value) env[key] = value; } - // The connector's own launch-mode knob (headless embedded loop vs. interactive - // TUI host). pi-connector-owned: forwarded by NAME the same way as the model - // keys above, never via ...process.env. See main.ts for the dispatch. + // Operator one-off override, forwarded by NAME (never via ...process.env). if (process.env.PI_PEER_MODE) env.PI_PEER_MODE = process.env.PI_PEER_MODE; return { command: CMD, args: [MAIN], env }; },