diff --git a/artifacts/issue-3803-ooo-bridge-test-report.json b/artifacts/issue-3803-ooo-bridge-test-report.json new file mode 100644 index 0000000000..406bccf7fd --- /dev/null +++ b/artifacts/issue-3803-ooo-bridge-test-report.json @@ -0,0 +1,102 @@ +{ + "schemaVersion": 1, + "kind": "api-package-test-report", + "issue": 3803, + "baseline": { + "gjcBase": "9477947f8b89b74bf3efcc4c1bf7c4591a0558ec", + "ouroborosRelease": "v0.50.7", + "ouroborosCommit": "cb658aa819bfabafecbbe91bc36327f10691171b", + "ouroborosWheelSha256": "df42f4ef10e032f2edc3249534bf91e8612dee789dfc3517895a9eb2df7f82c4", + "standaloneBridgeCommit": "4311fefd49e9c6781c4d1111b8dd3f758e7d8974", + "standaloneBridgeSha256": "2b0e1e25ac145331f112da629076875542db6f6e63c3c17adcd6770a4dcaf7bd" + }, + "verification": [ + { + "command": "bun test packages/coding-agent/test/ooo-bridge-extension-contract.test.ts packages/coding-agent/test/ooo-bridge-runner-redteam.test.ts packages/coding-agent/test/ooo-bridge-installed-flow.test.ts packages/coding-agent/test/extensions-discovery.test.ts packages/coding-agent/test/extensions-runner.test.ts", + "status": "passed", + "result": "100 pass, 0 fail, 295 expect() calls" + }, + { + "command": "bun --cwd=packages/coding-agent run check", + "status": "passed", + "result": "Biome checked 2514 files; TypeScript noEmit completed successfully" + }, + { + "command": "bun run check:public-sync", + "status": "passed", + "result": "Public docs/site/version surfaces are in sync" + }, + { + "command": "bun run generate-docs-index", + "status": "passed", + "result": "Embedded docs index regenerated with 120 documents" + }, + { + "command": "git diff --check", + "status": "passed", + "result": "No whitespace errors" + } + ], + "adversarialCases": [ + { + "id": "queued-explicit-generation-fence", + "status": "passed", + "evidence": "Deferred MCP operations hold queued explicit interview starts while actual AgentSession session_switch and InputController /clear resets advance the lifecycle generation; predecessor entries settle handled without issuing another MCP call." + }, + { + "id": "session-switch-disposal", + "status": "passed", + "evidence": "The installed extension registers session_switch disposal. A real AgentSession new-session transition with the same ExtensionRunner clears the old Ouroboros session before InputController submits ordinary successor-session input." + }, + { + "id": "clear-control-disposal", + "status": "passed", + "evidence": "InputController executes the real /clear path through AgentSession.clearContext after the bridge resets, and subsequent ordinary input is not sent to the prior Ouroboros session_id." + }, + { + "id": "startup-overlap-serialization", + "status": "passed", + "evidence": "Two non-awaited InputController submissions during interview startup remain claimed; the second waits for the first session_id and cannot fall through to the model." + }, + { + "id": "continuation-overlap-serialization", + "status": "passed", + "evidence": "Two concurrent continuation answers issue one MCP call at a time. The second starts only after the first settles and uses the latest correlated session state." + }, + { + "id": "late-mcp-settlement-fence", + "status": "passed", + "evidence": "Runner timeout aborts the handler signal, disconnects the MCP transport, and a late question cannot recreate interview state or capture the next ordinary prompt." + }, + { + "id": "dead-transport-release", + "status": "passed", + "evidence": "An MCP tool failure clears the interview session and cached connection; ordinary input passes through and a later explicit interview opens a fresh connection." + }, + { + "id": "built-in-control-bypass", + "status": "passed", + "evidence": "Non-session slash controls plus bare dot and c bypass active capture; session-changing controls reset state before their built-in action." + }, + { + "id": "compiled-one-file-install", + "status": "passed", + "evidence": "A real Bun compiled loader loads the isolated standalone extension without peer node_modules and registers both input and session_switch handlers." + }, + { + "id": "visible-correlated-flow", + "status": "passed", + "evidence": "The installed example renders the first question, sends the next answer with the same session_id, renders completion, disconnects, and returns ordinary prompts to GJC." + }, + { + "id": "compatible-cli-override", + "status": "passed", + "evidence": "OUROBOROS_CLI selects the executable for both MCP serving and non-interview dispatch." + }, + { + "id": "unsupported-dispatch", + "status": "passed", + "evidence": "Exit code 78 remains pass-through for non-interview exact-prefix dispatch." + } + ] +} diff --git a/docs/ooo-bridge-extension-contract.md b/docs/ooo-bridge-extension-contract.md index 00748680a0..5f8b8b4238 100644 --- a/docs/ooo-bridge-extension-contract.md +++ b/docs/ooo-bridge-extension-contract.md @@ -25,30 +25,71 @@ The extension runner already treats `InputEventResult.handled === true` as termi ## Dispatch and result semantics -`createOuroborosOooBridge()` is a small specialization of `createExactPrefixCommandBridge()`: +`createOuroborosOooBridge()` has two bounded paths: -- command: `ouroboros` -- arguments: `dispatch`, then the full submitted input text -- recursion guard variable: the Ouroboros bridge recursion-depth environment variable +- `ooo interview [topic]` starts `ouroboros_interview` through a lazily connected `ouroboros mcp serve --runtime gjc` stdio server. +- While that interview is active, subsequent ordinary interactive input is claimed as an answer with the same `session_id`. A completed result clears the correlation and closes the MCP connection. +- Other exact-prefix `ooo ...` commands run `ouroboros dispatch --runtime gjc ` through `createExactPrefixCommandBridge()`. +- `OUROBOROS_CLI` overrides the executable for both paths; otherwise the command is `ouroboros`. -- continue/pass-through exit code: `78` +Successful handled text is returned as `{ handled: true, text }`. The interactive input controller renders that text as a visible custom message before clearing the composer, so the first interview question, continuation questions, completion result, and successful non-interview command output reach the user. -Exit-code mapping: +Command-dispatch exit mapping remains: | Dispatch result | GJC input result | | --- | --- | -| `0` | `{ handled: true }`; do not send input to the model. | +| `0` | `{ handled: true, text? }`; render non-empty stdout (or stderr when stdout is empty) and do not send the input to the model. | | `78` | `{}`; continue/pass-through so GJC processes the input normally. | | any other non-zero | Surface an extension error notification using stderr, then stdout, then a generic exit-code message, and return `{ handled: true }`; the failed `ooo` command is terminal and is not sent to the model. | -## Recursion guard +MCP interview errors are notified and handled. A non-terminal response must contain a valid `interview_*` session ID in MCP `_meta` (with the visible `Session ...` text accepted as a compatibility fallback); otherwise the bridge fails closed instead of accepting an uncorrelated answer. + +Runner timeout aborts the handler context signal. The bridge passes that signal to MCP connection/tool calls and generation-fences every post-await state mutation, so a late settlement cannot recreate correlation after the runner has fallen through. Any MCP connection or tool failure clears the interview session and cached transport before notifying; a later ordinary prompt therefore passes through, while a new explicit `ooo interview` reconnects cleanly. + +Slash-prefixed UI commands bypass interview capture. The bare continue controls `.` and `c` also remain GJC controls; other ordinary text remains a valid interview answer. -Before dispatch, the helper increments the Ouroboros bridge recursion-depth environment variable and restores its previous value after dispatch finishes. A current numeric depth of `0` or `1` is dispatchable, which preserves concurrent independent interactive inputs while marking child dispatcher processes with depth `1`. A current numeric depth greater than `1`, or any non-empty non-numeric value, returns `{}` without dispatching. +The installed example also registers `session_switch` disposal because GJC reuses one `ExtensionRunner` across `/new`, `/drop`, resume, and fork transitions. Session-changing input controls reset immediately, including `/clear`, and the lifecycle hook covers identity changes initiated outside the input path. Interview startup and continuation calls share one FIFO operation chain: a second submission during startup is claimed and waits for the session ID, while overlapping answers issue one MCP call at a time against the latest settled state. Every queue entry is bound to the lifecycle generation at submission, so resets consume predecessor-generation entries—including explicit `ooo interview` starts—without calling MCP in the successor session. + +## Recursion guard -This means the bridge allows exactly one inherited bridge-marked dispatcher level and blocks recursive re-entry from deeper bridge-marked children. The guard also passes through `event.source === "extension"` to avoid extension-originated messages re-entering the bridge. +Before command dispatch, the exact-prefix helper increments the Ouroboros bridge recursion-depth environment variable and restores its previous value after dispatch finishes. A current numeric depth of `0` or `1` is dispatchable. A current numeric depth greater than `1`, or any non-empty non-numeric value, returns `{}` without dispatching. The guard also passes through `event.source === "extension"` to avoid extension-originated messages re-entering the bridge. ## Installation and discovery +### Pinned Ouroboros baseline + +This path is verified against [Q00/ouroboros `v0.50.7`](https://github.com/Q00/ouroboros/releases/tag/v0.50.7). Install its MCP profile at the exact version, then configure GJC: + +```bash +uv tool install 'ouroboros-ai[mcp]==0.50.7' +ouroboros setup --runtime gjc +``` + +`pipx install 'ouroboros-ai[mcp]==0.50.7'` is equivalent. Do not pipe a mutable branch installer into a shell. Pin source audits to commit `cb658aa819bfabafecbbe91bc36327f10691171b`. The release asset `ouroboros_ai-0.50.7-py3-none-any.whl` has SHA-256 `df42f4ef10e032f2edc3249534bf91e8612dee789dfc3517895a9eb2df7f82c4`; compare a downloaded asset with that digest before installation. + +### Verified GJC bridge installation + +Ouroboros setup installs its own managed GJC bridge. Replace it with the standalone GJC bridge from immutable commit `4311fefd49e9c6781c4d1111b8dd3f758e7d8974`, whose example file has SHA-256 `2b0e1e25ac145331f112da629076875542db6f6e63c3c17adcd6770a4dcaf7bd`: + +```bash +curl -fL https://raw.githubusercontent.com/Yeachan-Heo/gajae-code/4311fefd49e9c6781c4d1111b8dd3f758e7d8974/packages/coding-agent/examples/extensions/ooo-bridge.ts -o /tmp/gjc-ooo-bridge.ts +shasum -a 256 /tmp/gjc-ooo-bridge.ts +mkdir -p "${HOME}/${GJC_CONFIG_DIR:-.gjc}/agent/extensions/ouroboros-ooo-bridge" && cp /tmp/gjc-ooo-bridge.ts "${HOME}/${GJC_CONFIG_DIR:-.gjc}/agent/extensions/ouroboros-ooo-bridge/index.ts" +``` + +The `shasum` output must match the published example digest before the copy. The example has no runtime imports: it obtains the bundled bridge helper from the injected extension API, so the copied file works in compiled GJC binaries without extension-local `node_modules`. For project-only installation, copy the same verified file to `.gjc/extensions/ouroboros-ooo-bridge/index.ts`. Start a new GJC session after installation, then run: + +```text +ooo interview "I want to build a task management CLI" +``` + +Set `OUROBOROS_CLI=/absolute/path/to/ouroboros` when the executable is outside `PATH`. + +### Native interview versus external Ouroboros interview + +- `/skill:deep-interview` is GJC's bundled native interview workflow. It includes Ouroboros-inspired behavior but does not invoke the external CLI. +- `ooo interview` is the external integration. It calls Ouroboros's MCP interview tool, renders each question in GJC, correlates ordinary answers by Ouroboros session ID, and stops claiming input when the interview completes. + The canonical install location is the agent extensions directory discovered by the native GJC provider: - user-level: `$HOME/${GJC_CONFIG_DIR:-.gjc}/agent/extensions` diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1cdd922540..42b1677a7c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Interactive turns now announce their state as an OSC 777 sequence (`notify;Terax;gjc;working|attention|finished`), so a hosting terminal can follow the agent without polling. Terminals that do not parse it discard it like any unknown OSC, and print/RPC mode stdout is untouched. +### Added + +- Added a verified, copy-installable `ooo` bridge example: `ooo interview` renders Ouroboros MCP questions in GJC, serializes startup and follow-up answers by session ID, cancellation-fences late settlement, disposes state on GJC session changes and `/clear`, drops queued predecessor-generation starts, releases dead transports and controls, honors `OUROBOROS_CLI`, and loads dependency-free in compiled binaries (#3803). + ### Fixed - Telegram daemon restart now revokes every persisted callback alias before polling. Reconnecting sessions must replay a pending ask to receive fresh, owner-bound aliases; old controls remain stale, and their keyboards are best-effort terminalized when the original Telegram message id is available. Shutdown now fences new session messages and drains every admitted handler before final callback persistence and ownership release, preventing a successful send racing shutdown from publishing alias state after a successor takes ownership (#3727). diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 2655743309..1b6371d4a6 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -14,6 +14,39 @@ mkdir -p .gjc/extensions cp packages/coding-agent/examples/extensions/pirate.ts .gjc/extensions/ ``` +### Enable the Ouroboros `ooo` bridge + +Install the version-pinned Ouroboros `v0.50.7` MCP profile, then configure its GJC runtime: + +```bash +uv tool install 'ouroboros-ai[mcp]==0.50.7' +ouroboros setup --runtime gjc +``` + +`pipx install 'ouroboros-ai[mcp]==0.50.7'` is the equivalent pipx installation. Do not pipe a mutable branch installer into a shell. Pin source audits to commit `cb658aa819bfabafecbbe91bc36327f10691171b`. The [v0.50.7 release](https://github.com/Q00/ouroboros/releases/tag/v0.50.7) publishes `ouroboros_ai-0.50.7-py3-none-any.whl` with SHA-256 `df42f4ef10e032f2edc3249534bf91e8612dee789dfc3517895a9eb2df7f82c4`; verify downloaded release assets before installing them. + +Ouroboros setup installs its own managed bridge. Replace that file with this standalone GJC bridge, which preserves the interview session across serialized follow-up answers, disposes it on GJC session switches, and drops queued predecessor-session starts. Download the example from immutable GJC commit `4311fefd49e9c6781c4d1111b8dd3f758e7d8974` and verify it before installation: + +```bash +curl -fL https://raw.githubusercontent.com/Yeachan-Heo/gajae-code/4311fefd49e9c6781c4d1111b8dd3f758e7d8974/packages/coding-agent/examples/extensions/ooo-bridge.ts -o /tmp/gjc-ooo-bridge.ts +shasum -a 256 /tmp/gjc-ooo-bridge.ts +mkdir -p "${HOME}/${GJC_CONFIG_DIR:-.gjc}/agent/extensions/ouroboros-ooo-bridge" && cp /tmp/gjc-ooo-bridge.ts "${HOME}/${GJC_CONFIG_DIR:-.gjc}/agent/extensions/ouroboros-ooo-bridge/index.ts" +``` + +The `shasum` output must match `2b0e1e25ac145331f112da629076875542db6f6e63c3c17adcd6770a4dcaf7bd` before the copy. The file has no runtime package imports and uses the host API injected by GJC, so compiled binaries do not require a peer `node_modules` directory beside the installation. + +For a project-only installation, copy the same verified file to `.gjc/extensions/ouroboros-ooo-bridge/index.ts`. Start a new GJC session after installation, then enter: + +```text +ooo interview "I want to build a task management CLI" +``` + +The first question is rendered in GJC. While that interview remains active, ordinary interactive input is sent as the answer with the same Ouroboros session ID; completion clears the correlation and returns subsequent ordinary prompts to GJC. Other `ooo ...` commands continue through `ouroboros dispatch --runtime gjc`, including exit-code `78` pass-through. + +Set `OUROBOROS_CLI=/absolute/path/to/ouroboros` when the executable is outside `PATH`. Missing executable, MCP startup, and dispatch failures produce an error notification for the claimed input without preventing GJC startup or ordinary prompts. + +This external path is separate from GJC's native `/skill:deep-interview`: the native skill runs GJC's bundled interview workflow, while `ooo interview` delegates to the installed Ouroboros MCP interview tool. + ## Examples ### Custom Tools & API @@ -39,10 +72,11 @@ cp packages/coding-agent/examples/extensions/pirate.ts .gjc/extensions/ ### External Dependencies -| Extension | Description | -| ----------------- | ------------------------------------------------------------------------- | -| `chalk-logger.ts` | Uses chalk from parent node_modules (demonstrates jiti module resolution) | -| `with-deps/` | Extension with its own package.json and dependencies | +| Extension | Description | +| ----------------- | ---------------------------------------------------------------------------- | +| `chalk-logger.ts` | Uses chalk from parent node_modules (demonstrates jiti module resolution) | +| `ooo-bridge.ts` | Opt-in `ooo ...` input bridge to the installed Ouroboros CLI and MCP runtime | +| `with-deps/` | Extension with its own package.json and dependencies | ## Writing Extensions diff --git a/packages/coding-agent/examples/extensions/ooo-bridge.ts b/packages/coding-agent/examples/extensions/ooo-bridge.ts new file mode 100644 index 0000000000..11bc34c61f --- /dev/null +++ b/packages/coding-agent/examples/extensions/ooo-bridge.ts @@ -0,0 +1,15 @@ +interface OooBridgeExtensionAPI { + pi: unknown; + on(event: "input" | "session_switch", handler: (event: unknown, context: unknown) => unknown): void; +} + +interface OooBridgeHost { + createOuroborosOooBridge(): ((event: unknown, context: unknown) => unknown) & { reset(): Promise }; +} + +export default function (pi: OooBridgeExtensionAPI) { + const host = pi.pi as OooBridgeHost; + const bridge = host.createOuroborosOooBridge(); + pi.on("input", bridge); + pi.on("session_switch", () => bridge.reset()); +} diff --git a/packages/coding-agent/src/extensibility/extensions/index.ts b/packages/coding-agent/src/extensibility/extensions/index.ts index abd324df4e..89a498d52e 100644 --- a/packages/coding-agent/src/extensibility/extensions/index.ts +++ b/packages/coding-agent/src/extensibility/extensions/index.ts @@ -9,6 +9,7 @@ export { loadExtensionFromFactory, loadExtensions, } from "./loader"; +export * from "./ouroboros-ooo-bridge"; export * from "./prefix-command-bridge"; export * from "./runner"; // Type guards diff --git a/packages/coding-agent/src/extensibility/extensions/ouroboros-ooo-bridge.ts b/packages/coding-agent/src/extensibility/extensions/ouroboros-ooo-bridge.ts new file mode 100644 index 0000000000..bdd2004118 --- /dev/null +++ b/packages/coding-agent/src/extensibility/extensions/ouroboros-ooo-bridge.ts @@ -0,0 +1,235 @@ +import type { MCPServerConnection, MCPToolCallResult } from "../../runtime-mcp"; +import { callTool, connectToServer, disconnectServer } from "../../runtime-mcp"; +import { createExactPrefixCommandBridge } from "./prefix-command-bridge"; +import type { ExtensionContext, InputEvent, InputEventResult } from "./types"; + +const OUROBOROS_CLI_ENV = "OUROBOROS_CLI"; +const INTERVIEW_COMMAND = "ooo interview"; +const INTERVIEW_TOOL = "ouroboros_interview"; +const INTERVIEW_SESSION_PATTERN = /^interview_[A-Za-z0-9_-]+$/; + +interface OuroborosOooBridgeOptions { + connect?: typeof connectToServer; + callTool?: typeof callTool; + disconnect?: typeof disconnectServer; +} + +interface InterviewState { + sessionId: string; +} + +interface OuroborosOooBridgeHandler { + (event: InputEvent, ctx: ExtensionContext): Promise; + reset(): Promise; +} + +function resolveOuroborosCommand(): string { + return process.env[OUROBOROS_CLI_ENV]?.trim() || "ouroboros"; +} + +function interviewArgument(text: string): string | undefined { + if (text === INTERVIEW_COMMAND) return ""; + if (text.startsWith(`${INTERVIEW_COMMAND} `) || text.startsWith(`${INTERVIEW_COMMAND}\t`)) { + return text.slice(INTERVIEW_COMMAND.length).trim(); + } + return undefined; +} + +function isOooCommand(text: string): boolean { + return text === "ooo" || text.startsWith("ooo ") || text.startsWith("ooo\t"); +} + +function isBuiltInControlInput(text: string): boolean { + return text === "." || text === "c" || text.startsWith("/"); +} + +function resetsInterviewState(text: string): boolean { + return /^\/(?:clear|drop|exit|new|quit)(?:\s|$)/.test(text); +} + +function resultText(result: MCPToolCallResult): string { + return result.content + .filter(content => content.type === "text") + .map(content => content.text) + .join("\n\n") + .trim(); +} + +function resultMeta(result: MCPToolCallResult): Record { + return result._meta ?? {}; +} + +function resultSessionId(result: MCPToolCallResult, text: string): string | undefined { + const metadataSessionId = resultMeta(result).session_id; + if (typeof metadataSessionId === "string" && INTERVIEW_SESSION_PATTERN.test(metadataSessionId)) { + return metadataSessionId; + } + const textSessionId = /\bSession(?: ID)?:\s*(interview_[A-Za-z0-9_-]+)/.exec(text)?.[1]; + return textSessionId && INTERVIEW_SESSION_PATTERN.test(textSessionId) ? textSessionId : undefined; +} + +function resultCompleted(result: MCPToolCallResult): boolean { + const meta = resultMeta(result); + return meta.completed === true || meta.phase === "complete"; +} + +export function createOuroborosOooBridge(options: OuroborosOooBridgeOptions = {}): OuroborosOooBridgeHandler { + const connect = options.connect ?? connectToServer; + const invoke = options.callTool ?? callTool; + const disconnect = options.disconnect ?? disconnectServer; + let interview: InterviewState | undefined; + let interviewCaptureActive = false; + let activeConnection: MCPServerConnection | undefined; + let pendingConnection: Promise | undefined; + let activeOperationAbort: AbortController | undefined; + let lifecycleGeneration = 0; + let operationTail: Promise = Promise.resolve(); + + const commandBridge = createExactPrefixCommandBridge({ + prefix: "ooo", + command: resolveOuroborosCommand(), + args: ["dispatch", "--runtime", "gjc"], + }); + + function assertCurrent(generation: number, signal: AbortSignal | undefined): void { + if (generation !== lifecycleGeneration || signal?.aborted) { + throw signal?.reason instanceof Error ? signal.reason : new Error("Ouroboros interview operation cancelled"); + } + } + + async function disconnectSafely(connection: MCPServerConnection | undefined): Promise { + if (!connection) return; + try { + await disconnect(connection); + } catch { + // State is already fenced. A dead transport must not keep ordinary input captured. + } + } + + async function resetInterview(): Promise { + lifecycleGeneration++; + interviewCaptureActive = false; + const operationAbort = activeOperationAbort; + activeOperationAbort = undefined; + operationAbort?.abort(new Error("Ouroboros interview reset")); + const connectionToClose = activeConnection; + interview = undefined; + activeConnection = undefined; + pendingConnection = undefined; + await disconnectSafely(connectionToClose); + } + + async function connection(ctx: ExtensionContext, generation: number): Promise { + assertCurrent(generation, ctx.signal); + if (activeConnection) return activeConnection; + const pending = + pendingConnection ?? + connect( + "ouroboros-ooo-bridge", + { + type: "stdio", + command: resolveOuroborosCommand(), + args: ["mcp", "serve", "--runtime", "gjc"], + cwd: ctx.cwd, + }, + { signal: ctx.signal }, + ); + pendingConnection = pending; + try { + const connected = await pending; + try { + assertCurrent(generation, ctx.signal); + } catch (error) { + await disconnectSafely(connected); + throw error; + } + activeConnection = connected; + return connected; + } finally { + if (pendingConnection === pending) pendingConnection = undefined; + } + } + + async function runInterview(text: string, ctx: ExtensionContext): Promise { + const operationAbort = new AbortController(); + activeOperationAbort = operationAbort; + const operationSignal = ctx.signal ? AbortSignal.any([ctx.signal, operationAbort.signal]) : operationAbort.signal; + const operationContext: ExtensionContext = { ...ctx, signal: operationSignal }; + const generation = lifecycleGeneration; + const abortHandler = () => { + void resetInterview(); + }; + ctx.signal?.addEventListener("abort", abortHandler, { once: true }); + try { + const interviewConnection = await connection(operationContext, generation); + const commandArgument = interviewArgument(text); + const args: Record = { cwd: ctx.cwd }; + if (interview) { + args.session_id = interview.sessionId; + const answer = commandArgument === undefined ? text.trim() : commandArgument; + if (answer) args.answer = answer; + } else { + args.initial_context = commandArgument ?? ""; + } + + const result = await invoke(interviewConnection, INTERVIEW_TOOL, args, { signal: operationSignal }); + assertCurrent(generation, operationSignal); + const output = resultText(result); + if (result.isError) throw new Error(output || "Ouroboros interview failed"); + + const sessionId = resultSessionId(result, output); + if (!resultCompleted(result)) { + if (!sessionId) throw new Error("Ouroboros interview response did not include a session ID"); + interview = { sessionId }; + } else { + await resetInterview(); + } + return output ? { handled: true, text: output } : { handled: true }; + } catch (error) { + await resetInterview(); + if (!ctx.signal?.aborted) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui?.notify(message, "error"); + } + return { handled: true }; + } finally { + ctx.signal?.removeEventListener("abort", abortHandler); + if (activeOperationAbort === operationAbort) activeOperationAbort = undefined; + } + } + + function enqueueInterview( + text: string, + ctx: ExtensionContext, + explicitInterview: boolean, + ): Promise { + const submissionGeneration = lifecycleGeneration; + const operation = operationTail.then(async () => { + if (submissionGeneration !== lifecycleGeneration) return { handled: true }; + if (!explicitInterview && !interviewCaptureActive && !interview) return { handled: true }; + return runInterview(text, ctx); + }); + operationTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + const handler = async (event: InputEvent, ctx: ExtensionContext): Promise => { + if (event.source !== undefined && event.source !== "interactive") return {}; + if (isBuiltInControlInput(event.text)) { + if (resetsInterviewState(event.text)) await resetInterview(); + return {}; + } + const argument = interviewArgument(event.text); + const explicitInterview = argument !== undefined; + if (explicitInterview) interviewCaptureActive = true; + if (explicitInterview || ((interviewCaptureActive || interview) && !isOooCommand(event.text))) { + return enqueueInterview(event.text, ctx, explicitInterview); + } + return commandBridge(event, ctx); + }; + + return Object.assign(handler, { reset: resetInterview }); +} diff --git a/packages/coding-agent/src/extensibility/extensions/prefix-command-bridge.ts b/packages/coding-agent/src/extensibility/extensions/prefix-command-bridge.ts index cf6f52341c..e9f7651781 100644 --- a/packages/coding-agent/src/extensibility/extensions/prefix-command-bridge.ts +++ b/packages/coding-agent/src/extensibility/extensions/prefix-command-bridge.ts @@ -86,7 +86,10 @@ export function createExactPrefixCommandBridge(options: ExactPrefixCommandBridge process.env[recursionEnv] = nextRecursionDepth(recursionEnv); try { const result = await dispatch(options.command, [...args, event.text], ctx, { timeout }); - if (result.code === 0) return { handled: true }; + if (result.code === 0) { + const output = result.stdout.trim() || result.stderr.trim(); + return output ? { handled: true, text: output } : { handled: true }; + } if (result.code === continueExitCode) return {}; const output = @@ -118,11 +121,3 @@ export function createExactPrefixCommandBridge(options: ExactPrefixCommandBridge } }; } - -export function createOuroborosOooBridge() { - return createExactPrefixCommandBridge({ - prefix: "ooo", - command: "ouroboros", - args: ["dispatch"], - }); -} diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 0f409da3a9..23ac33a39a 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -688,12 +688,14 @@ export class ExtensionRunner { ext: Extension, timeoutMs: number, ): Promise { - let timeout: ReturnType | undefined; + let timeout: NodeJS.Timeout | undefined; + const abortController = new AbortController(); + const handlerContext: ExtensionContext = { ...ctx, signal: abortController.signal }; try { const timeoutPromise = new Promise(resolve => { timeout = setTimeout(() => resolve(EXTENSION_HANDLER_TIMEOUT), timeoutMs); }); - const handlerResult = await Promise.race([Promise.resolve(handler(event, ctx)), timeoutPromise]); + const handlerResult = await Promise.race([Promise.resolve(handler(event, handlerContext)), timeoutPromise]); if (timeout !== undefined) { clearTimeout(timeout); timeout = undefined; @@ -701,6 +703,7 @@ export class ExtensionRunner { if (handlerResult === EXTENSION_HANDLER_TIMEOUT) { const error = `handler timed out after ${timeoutMs}ms`; + abortController.abort(new Error(error)); logger.warn("Extension handler timed out", { extensionPath: ext.path, event: event.type, diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 665a86cab8..749e5c795b 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -352,6 +352,8 @@ export interface ExtensionContext { hasUI: boolean; /** Current working directory */ cwd: string; + /** Aborted when the runner stops waiting for this handler, including handler timeout. */ + signal?: AbortSignal; /** Session manager (read-only) */ sessionManager: ReadonlySessionManager; /** Session classification supplied by the SDK for extension policy decisions. */ diff --git a/packages/coding-agent/src/internal-urls/docs-index.generated.ts b/packages/coding-agent/src/internal-urls/docs-index.generated.ts index 5ce4566d91..c5f18428e3 100644 --- a/packages/coding-agent/src/internal-urls/docs-index.generated.ts +++ b/packages/coding-agent/src/internal-urls/docs-index.generated.ts @@ -58,7 +58,7 @@ export const EMBEDDED_DOCS: Readonly> = { "notebook-tool-runtime.md": "# Notebook tool runtime internals\n\nThis document describes the current `notebook` tool implementation and its relationship to the kernel-backed Python runtime.\n\nThe critical distinction: **`notebook` is a JSON notebook editor, not a notebook executor**. It edits `.ipynb` cell sources directly; it does not start or talk to a Python kernel.\n\n## Implementation files\n\n- [`src/tools/notebook.ts`](../packages/coding-agent/src/tools/notebook.ts)\n- [`src/eval/py/executor.ts`](../packages/coding-agent/src/eval/py/executor.ts)\n- [`src/eval/py/kernel.ts`](../packages/coding-agent/src/eval/py/kernel.ts)\n- [`src/session/streaming-output.ts`](../packages/coding-agent/src/session/streaming-output.ts)\n- [`src/tools/eval.ts`](../packages/coding-agent/src/tools/eval.ts)\n\n## 1) Runtime boundary: editing vs executing\n\n## `notebook` tool (`src/tools/notebook.ts`)\n\n- Supports `action: edit | insert | delete` on a `.ipynb` file.\n- Resolves path relative to session CWD (`resolveToCwd`).\n- Loads notebook JSON, validates `cells` array, validates `cell_index` bounds.\n- Applies source edits in-memory and writes full notebook JSON back with `JSON.stringify(notebook, null, 1)`.\n- Returns textual summary + structured `details` (`action`, `cellIndex`, `cellType`, `totalCells`, `cellSource`).\n\nNo kernel lifecycle exists in this tool:\n\n- no gateway acquisition\n- no kernel session ID\n- no `execute_request`\n- no stream chunks from kernel channels\n- no rich display capture (`image/png`, JSON display, status MIME)\n\n## Notebook-like execution path (`src/tools/eval.ts` + `src/eval/py/*`)\n\nWhen the agent needs to run cell-style Python code (sequential cells, persistent state, rich displays), that goes through the **`eval` tool** with `language: \"python\"`, not `notebook`.\n\nThat path is where kernel modes, restart/cancel behavior, chunk streaming, and output artifact truncation live.\n\n## 2) Notebook cell handling semantics (`notebook` tool)\n\n## Source normalization\n\n`content` is split into `source: string[]` with newline preservation:\n\n- each non-final line keeps trailing `\\n`\n- final line has no forced trailing newline\n\nThis mirrors notebook JSON conventions and avoids accidental line concatenation on later edits.\n\n## Action behavior\n\n- `edit`\n - replaces `cells[cell_index].source`\n - preserves existing `cell_type`\n- `insert`\n - inserts at `[0..cellCount]`\n - `cell_type` defaults to `code`\n - code cells initialize `execution_count: null` and `outputs: []`\n - markdown cells initialize only `metadata` + `source`\n- `delete`\n - removes `cells[cell_index]`\n - returns removed `source` in details for renderer preview\n\n## Error surfaces\n\nHard failures are thrown for:\n\n- missing notebook file\n- invalid JSON\n- missing/non-array `cells`\n- out-of-range index (insert and non-insert have different valid ranges)\n- missing `content` for `edit`/`insert`\n\nThese become `Error:` tool responses upstream; renderer uses notebook path + formatted error text.\n\n## 3) Kernel session semantics (where they actually exist)\n\nKernel semantics are implemented in `executePython` / `PythonKernel` and apply to the Python backend of the `eval` tool.\n\n## Modes\n\n`PythonKernelMode`:\n\n- `session` (default)\n - kernels cached in `kernelSessions` map\n - max 4 sessions; oldest evicted on overflow\n - idle/dead cleanup every 30s, timeout after 5 minutes\n - per-session queue serializes execution (`session.queue`)\n- `per-call`\n - creates kernel for request\n - executes\n - always shuts down kernel in `finally`\n\n## Reset behavior\n\n`eval` passes `reset` only for the first cell in a multi-cell Python call; later cells always run with `reset: false`.\n\n## Kernel death / restart / retry\n\nIn session mode (`withKernelSession`):\n\n- dead kernel detected by heartbeat (`kernel.isAlive()` check every 5s) or execute failure.\n- pre-run dead state triggers `restartKernelSession`.\n- execute-time crash path retries once: restart kernel, rerun handler.\n- `restartCount > 1` in same session throws `Python kernel restarted too many times in this session`.\n\nStartup retry behavior:\n\n- shared gateway kernel creation retries once on `SharedGatewayCreateError` with HTTP 5xx.\n\nResource exhaustion recovery:\n\n- detects `EMFILE`/`ENFILE`/\"Too many open files\" style failures\n- clears tracked sessions\n- calls `shutdownSharedGateway()`\n- retries kernel session creation once\n\n## 4) Environment/session variable injection\n\nKernel startup receives the optional session file path from executor:\n\n- `GJC_SESSION_FILE` (session state file path)\n\n`PythonKernel.#initializeKernelEnvironment(...)` then runs init script inside kernel to:\n\n- `os.chdir(cwd)`\n- inject env entries into `os.environ`\n- prepend cwd to `sys.path` if missing\n\nImplication:\n\n- prelude helpers that read session context rely on this env var in Python process state.\n\n## 5) Streaming/chunk and display handling (kernel-backed path)\n\nThe kernel client processes Jupyter protocol messages per execution:\n\n- `stream` -> text chunk to `onChunk`\n- `execute_result` / `display_data` ->\n - display text chosen by MIME precedence: `text/markdown` > `text/plain` > converted `text/html`\n - structured outputs captured separately:\n - `application/json` -> `{ type: \"json\" }`\n - `image/png` -> `{ type: \"image\" }`\n - `application/x-gjc-status` -> `{ type: \"status\" }` (no text emission)\n- `error` -> traceback text pushed to chunk stream + structured error metadata\n- `input_request` -> emits stdin warning text, sends empty `input_reply`, marks stdin requested\n- completion waits for both `execute_reply` and kernel `status=idle`\n\nCancellation/timeout:\n\n- abort signal triggers `interrupt()` (REST `/interrupt` + control-channel `interrupt_request`)\n- result marks `cancelled=true`\n- timeout path annotates output with `Command timed out after seconds`\n\n## 6) Truncation and artifact behavior\n\n`OutputSink` in `src/session/streaming-output.ts` is used by kernel execution paths (`executeWithKernel`):\n\n- sanitizes every chunk (`sanitizeText`)\n- tracks total/output lines and bytes\n- optional artifact spill file (`artifactPath`, `artifactId`)\n- when in-memory buffer exceeds threshold (`DEFAULT_MAX_BYTES` unless overridden):\n - marks truncated\n - keeps tail bytes in memory (UTF-8 safe boundary)\n - can spill full stream to artifact sink\n\n`dump()` returns:\n\n- visible output text (possibly tail-truncated)\n- truncation flag + counts\n- artifact ID (for `artifact://` references)\n\n`eval` converts this metadata into result truncation notices and TUI warnings.\n\n`notebook` tool does **not** use `OutputSink`; it has no stream/artifact truncation pipeline because it does not execute code.\n\n## 7) Renderer assumptions and formatting\n\n## Notebook renderer (`notebookToolRenderer`)\n\n- call view: status line with action + notebook path + cell/type metadata\n- result view:\n - success summary derived from `details`\n - `cellSource` rendered via `renderCodeCell`\n - markdown cells set language hint `markdown`; other cells have no explicit language override\n - collapsed code preview limit is `PREVIEW_LIMITS.COLLAPSED_LINES * 2`\n - supports expanded mode via shared render options\n - uses render cache keyed by width + expanded state\n\nError rendering assumption:\n\n- if first text content starts with `Error:`, renderer formats as notebook error block.\n\n## Python renderer (for actual execution output)\n\nKernel-backed execution rendering expects:\n\n- per-cell status transitions (`pending/running/complete/error`)\n- optional structured status event section\n- optional JSON output trees\n- truncation warnings + optional `artifact://` pointer\n\nThis renderer behavior is unrelated to `notebook` JSON editing results except that both reuse shared TUI primitives.\n\n## 8) Divergence from eval Python backend behavior\n\nIf \"plain Python execution\" means the `eval` tool with `language: \"python\"`:\n\n- `eval` executes code in a kernel, persists state by mode, streams chunks, captures rich displays, handles interrupts/timeouts, and supports output truncation/artifacts.\n- `notebook` performs deterministic notebook JSON mutations only; no execution, no kernel state, no chunk stream, no display outputs, no artifact pipeline.\n\nIf a workflow needs both:\n\n1. edit notebook source with `notebook`\n2. execute code cells via `eval` with `language: \"python\"` (manually passing code), not through `notebook`\n\nCurrent implementation does not provide a single tool that both mutates `.ipynb` and executes notebook cells through kernel context.\n", "onboarding-packet.md": "# Gajae-Code Onboarding Packet\n\nThis packet is a docs-only, public-safe context seed for the `gajae-code` repository as inspected on 2026-06-01. It is intentionally a no-new-skill artifact: not a new workflow skill, command, agent, configuration surface, issue template, or runtime behavior.\n\n## Purpose in one paragraph\n\nGajae-Code is the `gjc` coding-agent CLI and supporting monorepo. The product centers on a small public workflow loop: clarify with `deep-interview`, plan with `ralplan`, execute and verify through `ultragoal`, and use `team` only when parallel tmux workers are useful. The main product package is `packages/coding-agent/`; supporting packages provide LLM/provider access, agent runtime, TUI rendering, native helpers, stats, utilities, benchmarks, and SDK machine interfaces.\n\n## Fixed public surface\n\nKeep this invariant front-and-center when onboarding to the repo:\n\n- Default workflow skills: `deep-interview`, `ralplan`, `team`, `ultragoal`.\n- Public role agents: `executor`, `architect`, `planner`, `critic`.\n- Bundled default workflow skill sources live under `packages/coding-agent/src/defaults/gjc/skills/`.\n- Bundled role-agent prompt sources live under `packages/coding-agent/src/prompts/agents/`.\n- Runtime state, specs, plans, goals, team state, and local overrides belong under `.gjc/` for the product and `.omx/` only for this agent-run orchestration.\n\nDo not add a fifth default skill, fifth public role agent, new command, new config surface, or feature-intake behavior unless that product decision has already been made and the default-surface gates are updated.\n\n## Primary entrypoints\n\n| Area | Repo-relative path | Why it matters |\n| ---------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- |\n| CLI bootstrap | `packages/coding-agent/src/cli.ts` | Registers top-level CLI commands and routes default launch behavior. |\n| Session launch | `packages/coding-agent/src/main.ts` | Converts CLI/runtime settings into agent-session creation and mode dispatch. |\n| Agent assembly | `packages/coding-agent/src/sdk/session.ts` | Loads settings, default skills, rules, tools, auth/model state, system prompt, and agent runtime. |\n| Built-in tools | `packages/coding-agent/src/tools/index.ts` | Registers file, shell, edit, search, browser, task/subagent, and related public coding-harness tools. Memory backends are private integrations, not public tools. |\n| Default skills | `packages/coding-agent/src/defaults/gjc-defaults.ts` | Embeds and installs the four default workflow skills plus deep-interview fragments. |\n| Role agents | `packages/coding-agent/src/task/agents.ts` | Embeds bundled task-agent prompts; tests enforce public role-agent expectations. |\n| Product overview | `README.md` | Explains installation, product story, fixed workflow surface, and development entry commands. |\n| Architecture map | `docs/codebase-overview.md` | Public package map and runtime-flow reference. |\n\n## Package map\n\n- `packages/coding-agent/` — main `gjc` CLI, workflows, session runtime, tool registry, discovery, settings, prompts, and tests.\n- `packages/ai/` — provider/model boundary, streaming, auth, model registry, retries, and provider integrations.\n- `packages/agent/` — stateful agent loop and append-only context runtime.\n- `packages/tui/` — terminal UI framework and rendering primitives.\n- `packages/natives/` plus `crates/*` — native helpers, Rust/N-API bindings, shell/PTY, text search, AST, filesystem, and media utilities.\n- `packages/utils/` — shared TypeScript utilities, logging, formatting, process helpers, JSON/frontmatter, and sanitization.\n- `packages/stats/` — local observability dashboard and session/model usage aggregation.\n- `packages/typescript-edit-benchmark/` — TypeScript edit benchmark tooling.\n- External machine clients use the SDK WebSocket interface documented in `docs/sdk.md`; `--mode rpc`, `--mode rpc-ui`, and `--mode bridge` were removed.\n\n## Build, test, and validation commands\n\nPrefer targeted checks first, then broader checks when code changes justify them. For this docs-only packet, lightweight validation is enough.\n\n| Command | Scope | When to use |\n| --------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------- |\n| `bun install` | Workspace dependencies | Initial local setup. |\n| `bun run install:defaults` | Local default install | Installs source-bundled default workflow definitions for local development. |\n| `bun packages/coding-agent/src/cli.ts --help` | CLI smoke/discovery | Fast source checkout CLI sanity check. |\n| `bun run check:ts` | Type/lint/default UI checks | Broad TypeScript validation; heavier than docs-only changes. |\n| `bun run test` | Full TS + Rust tests | Broad regression check; use for runtime/product changes. |\n| `bun run ci:test:smoke` | CLI version/help/stats worker smoke | Useful before release/install changes. |\n| `bun scripts/check-visible-definitions.ts` | Default surface gate | Required after workflow-definition changes. |\n| `bun scripts/verify-g002-gates.ts` | Rebrand/default-surface gate | Required after workflow-definition or public-surface changes. |\n| `bun scripts/rebrand-inventory.ts --strict` | Rebrand inventory gate | Required after workflow-definition or public-surface changes. |\n| `bun test packages/coding-agent/test/default-gjc-definitions.test.ts` | Four-skills/four-agents contract | Required after default workflow/agent surface changes. |\n\nRepository rule: do not run `tsc` or `npx tsc`; use the Bun scripts above.\n\n## Danger zones\n\n- **Default surface expansion:** `packages/coding-agent/src/defaults/gjc/skills/`, `packages/coding-agent/src/defaults/gjc-defaults.ts`, `packages/coding-agent/src/prompts/agents/`, and model-assignment tests are contract-heavy. Changes here can accidentally alter the fixed four-skills/four-agents shape.\n- **CLI commands:** `packages/coding-agent/src/cli.ts` and `packages/coding-agent/src/commands/` define visible behavior. Adding commands or aliases is a product-surface change.\n- **Runtime/session assembly:** `packages/coding-agent/src/main.ts`, `packages/coding-agent/src/sdk/session.ts`, discovery, settings, tools, and system-prompt paths can affect every session.\n- **TUI/logging:** Avoid `console.log`, `console.warn`, or `console.error` inside `packages/coding-agent/`; use the centralized logger to avoid corrupting TUI rendering.\n- **Secrets/auth/config:** Keep `docs/secrets.md`, auth broker/gateway code, settings, and environment-variable docs public-safe. Do not expose tokens or private infrastructure.\n- **Native/Rust build:** `packages/natives/` and `crates/*` can require platform-specific toolchains and CI artifact behavior.\n- **Generated model data:** Do not edit `packages/ai/src/models.json` directly; update generators/descriptors/resolvers and regenerate with `bun --cwd=packages/ai run generate-models`.\n\n## Unknowns worth preserving\n\n- Which onboarding packet shape will be most useful for future `gjc` context ingestion is still an experiment, not a product contract.\n- Public issue #158 / `gajae-deep-onboarding` context is summarized only from the user-provided prompt in this run; this packet does not add issue intake or feature workflow behavior.\n- Full CI may depend on runner/system dependencies and native artifacts; docs-only changes usually do not need the full matrix locally.\n- Some packages contain internal or hidden utility prompts/agents beyond the four public role agents. Public-facing docs should keep the four-role contract clear.\n\n## First safe tasks for a new contributor or agent\n\n1. Read `README.md`, `docs/codebase-overview.md`, and this packet.\n2. Run `bun packages/coding-agent/src/cli.ts --help` for a fast CLI surface check after dependencies are installed.\n3. For docs-only edits, run formatting/check commands that do not mutate runtime behavior.\n4. For default-surface edits, run the four required gates listed in the command table before claiming completion.\n5. For package code edits, start with the nearest package test, then escalate to `bun run check:ts` or `bun run test` as risk increases.\n6. Before changing `packages/coding-agent/src/defaults/gjc/skills/`, `packages/coding-agent/src/prompts/agents/`, `packages/coding-agent/src/commands/`, or config/settings paths, write down whether the change alters public surface area.\n\n## Context seed checklist\n\nA future agent can use this packet as context if it preserves these constraints:\n\n- Keep changes public-safe and repo-relative.\n- Prefer docs and tests over new runtime abstractions for onboarding experiments.\n- Treat the fixed four-skills/four-agents shape as a product constraint.\n- Verify claims with repo files before summarizing them.\n- Report validation evidence and caveats instead of implying hidden automation.\n", "onboarding-receipt.md": "# Onboarding Packet Receipt\n\n- Date: 2026-06-01\n- Scope: docs-only no-new-skill onboarding packet experiment for this repository.\n- Output files:\n - `docs/onboarding-packet.md`\n - `docs/onboarding-receipt.md`\n- Public-safe boundary: no secrets, tokens, hidden prompts, private infrastructure, internal ops, or private paths beyond repo-relative paths.\n- Product boundary: no new skill, command, agent slot, issue, config, or runtime behavior.\n\n## Evidence inspected\n\n- `README.md`\n- `docs/codebase-overview.md`\n- `package.json`\n- `packages/coding-agent/package.json`\n- `packages/coding-agent/src/cli.ts`\n- `packages/coding-agent/src/main.ts`\n- `packages/coding-agent/src/sdk/session.ts`\n- `packages/coding-agent/src/defaults/gjc-defaults.ts`\n- `packages/coding-agent/src/task/agents.ts`\n- `packages/coding-agent/test/default-gjc-definitions.test.ts`\n- `.github/workflows/ci.yml`\n- `.github/workflows/dev-ci.yml`\n\n## Result\n\nThe packet records repo purpose, package layout, main entrypoints, build/test commands, danger zones, unknowns, and first safe tasks without changing the product surface. It is suitable as a public context seed for future onboarding experiments, not as a feature intake mechanism.\n\n## Caveats\n\n- The attempted `omx question --input '' --json` interview round failed before user input because the runtime reported no attached tmux client; no human answer was inferred from that failed call.\n- Public issue context is limited to the user-provided prompt summary for this run.\n- Full CI was not required for the docs-only artifact unless later code/runtime files change.\n", - "ooo-bridge-extension-contract.md": "# Ouroboros `ooo` bridge extension contract\n\nGJC exposes the `ooo` bridge through the existing extension input-event surface. It is not a default workflow skill, hook, slash command, or built-in agent.\n\n## Interception surface\n\nExtensions register an `input` handler:\n\n```ts\nimport { createOuroborosOooBridge } from \"@gajae-code/coding-agent/extensibility/extensions\";\n\nexport default function activate(gjc) {\n gjc.on(\"input\", createOuroborosOooBridge());\n}\n```\n\nThe handler matches only the bare exact prefix:\n\n- `ooo`\n- `ooo ...`\n\nIt does not match embedded or longer-token text such as `please ooo status`, `oooo`, or `/ooo`.\n\nThe extension runner already treats `InputEventResult.handled === true` as terminal: the input is not sent through normal model flow. An empty result (`{}`) means continue/pass-through, preserving existing chained input handlers and normal prompt handling.\n\n## Dispatch and result semantics\n\n`createOuroborosOooBridge()` is a small specialization of `createExactPrefixCommandBridge()`:\n\n- command: `ouroboros`\n- arguments: `dispatch`, then the full submitted input text\n- recursion guard variable: the Ouroboros bridge recursion-depth environment variable\n\n- continue/pass-through exit code: `78`\n\nExit-code mapping:\n\n| Dispatch result | GJC input result |\n| --- | --- |\n| `0` | `{ handled: true }`; do not send input to the model. |\n| `78` | `{}`; continue/pass-through so GJC processes the input normally. |\n| any other non-zero | Surface an extension error notification using stderr, then stdout, then a generic exit-code message, and return `{ handled: true }`; the failed `ooo` command is terminal and is not sent to the model. |\n\n## Recursion guard\n\nBefore dispatch, the helper increments the Ouroboros bridge recursion-depth environment variable and restores its previous value after dispatch finishes. A current numeric depth of `0` or `1` is dispatchable, which preserves concurrent independent interactive inputs while marking child dispatcher processes with depth `1`. A current numeric depth greater than `1`, or any non-empty non-numeric value, returns `{}` without dispatching.\n\nThis means the bridge allows exactly one inherited bridge-marked dispatcher level and blocks recursive re-entry from deeper bridge-marked children. The guard also passes through `event.source === \"extension\"` to avoid extension-originated messages re-entering the bridge.\n\n## Installation and discovery\n\nThe canonical install location is the agent extensions directory discovered by the native GJC provider:\n\n- user-level: `$HOME/${GJC_CONFIG_DIR:-.gjc}/agent/extensions`\n- project-level: `/.gjc/extensions`\n\nFor native discovery, install one of:\n\n- `extensions/.ts` or `extensions/.js`\n- `extensions//index.ts` or `extensions//index.js`\n- `extensions//package.json` declaring extension entries\n\nThe loader scans one level under each `extensions` directory. Complex packages should use a package manifest instead of relying on recursive discovery.\n\n`GJC_CONFIG_DIR` selects the **home-relative** config directory name: the config root is `/`, defaulting to `~/.gjc`. It does not select a project directory — the project-level path is the constant `.gjc` (`discovery/helpers.ts`, `getProjectAgentDir()`), so `GJC_CONFIG_DIR` never moves it. `GJC_CODING_AGENT_DIR` overrides the agent directory **path** rather than naming one under `$HOME`; it is resolved with `path.resolve`, so an absolute value is used as-is and a relative value is resolved against the current working directory.\n\nDiscovery is the exception to that second override. The native provider builds its user-level root from `GJC_CONFIG_DIR` alone (`//agent`) and never consults `getAgentDir()`, so an operator who sets `GJC_CODING_AGENT_DIR` moves the agent directory for the rest of the product but **not** for extension, skill, rule, or hook discovery.\n\nHooks are not the input bridge surface: `packages/coding-agent/src/capability/hook.ts` defines pre/post tool hooks only.\n", + "ooo-bridge-extension-contract.md": "# Ouroboros `ooo` bridge extension contract\n\nGJC exposes the `ooo` bridge through the existing extension input-event surface. It is not a default workflow skill, hook, slash command, or built-in agent.\n\n## Interception surface\n\nExtensions register an `input` handler:\n\n```ts\nimport { createOuroborosOooBridge } from \"@gajae-code/coding-agent/extensibility/extensions\";\n\nexport default function activate(gjc) {\n gjc.on(\"input\", createOuroborosOooBridge());\n}\n```\n\nThe handler matches only the bare exact prefix:\n\n- `ooo`\n- `ooo ...`\n\nIt does not match embedded or longer-token text such as `please ooo status`, `oooo`, or `/ooo`.\n\nThe extension runner already treats `InputEventResult.handled === true` as terminal: the input is not sent through normal model flow. An empty result (`{}`) means continue/pass-through, preserving existing chained input handlers and normal prompt handling.\n\n## Dispatch and result semantics\n\n`createOuroborosOooBridge()` has two bounded paths:\n\n- `ooo interview [topic]` starts `ouroboros_interview` through a lazily connected `ouroboros mcp serve --runtime gjc` stdio server.\n- While that interview is active, subsequent ordinary interactive input is claimed as an answer with the same `session_id`. A completed result clears the correlation and closes the MCP connection.\n- Other exact-prefix `ooo ...` commands run `ouroboros dispatch --runtime gjc ` through `createExactPrefixCommandBridge()`.\n- `OUROBOROS_CLI` overrides the executable for both paths; otherwise the command is `ouroboros`.\n\nSuccessful handled text is returned as `{ handled: true, text }`. The interactive input controller renders that text as a visible custom message before clearing the composer, so the first interview question, continuation questions, completion result, and successful non-interview command output reach the user.\n\nCommand-dispatch exit mapping remains:\n\n| Dispatch result | GJC input result |\n| --- | --- |\n| `0` | `{ handled: true, text? }`; render non-empty stdout (or stderr when stdout is empty) and do not send the input to the model. |\n| `78` | `{}`; continue/pass-through so GJC processes the input normally. |\n| any other non-zero | Surface an extension error notification using stderr, then stdout, then a generic exit-code message, and return `{ handled: true }`; the failed `ooo` command is terminal and is not sent to the model. |\n\nMCP interview errors are notified and handled. A non-terminal response must contain a valid `interview_*` session ID in MCP `_meta` (with the visible `Session ...` text accepted as a compatibility fallback); otherwise the bridge fails closed instead of accepting an uncorrelated answer.\n\nRunner timeout aborts the handler context signal. The bridge passes that signal to MCP connection/tool calls and generation-fences every post-await state mutation, so a late settlement cannot recreate correlation after the runner has fallen through. Any MCP connection or tool failure clears the interview session and cached transport before notifying; a later ordinary prompt therefore passes through, while a new explicit `ooo interview` reconnects cleanly.\n\nSlash-prefixed UI commands bypass interview capture. The bare continue controls `.` and `c` also remain GJC controls; other ordinary text remains a valid interview answer.\n\nThe installed example also registers `session_switch` disposal because GJC reuses one `ExtensionRunner` across `/new`, `/drop`, resume, and fork transitions. Session-changing input controls reset immediately, including `/clear`, and the lifecycle hook covers identity changes initiated outside the input path. Interview startup and continuation calls share one FIFO operation chain: a second submission during startup is claimed and waits for the session ID, while overlapping answers issue one MCP call at a time against the latest settled state. Every queue entry is bound to the lifecycle generation at submission, so resets consume predecessor-generation entries—including explicit `ooo interview` starts—without calling MCP in the successor session.\n\n## Recursion guard\n\nBefore command dispatch, the exact-prefix helper increments the Ouroboros bridge recursion-depth environment variable and restores its previous value after dispatch finishes. A current numeric depth of `0` or `1` is dispatchable. A current numeric depth greater than `1`, or any non-empty non-numeric value, returns `{}` without dispatching. The guard also passes through `event.source === \"extension\"` to avoid extension-originated messages re-entering the bridge.\n\n## Installation and discovery\n\n### Pinned Ouroboros baseline\n\nThis path is verified against [Q00/ouroboros `v0.50.7`](https://github.com/Q00/ouroboros/releases/tag/v0.50.7). Install its MCP profile at the exact version, then configure GJC:\n\n```bash\nuv tool install 'ouroboros-ai[mcp]==0.50.7'\nouroboros setup --runtime gjc\n```\n\n`pipx install 'ouroboros-ai[mcp]==0.50.7'` is equivalent. Do not pipe a mutable branch installer into a shell. Pin source audits to commit `cb658aa819bfabafecbbe91bc36327f10691171b`. The release asset `ouroboros_ai-0.50.7-py3-none-any.whl` has SHA-256 `df42f4ef10e032f2edc3249534bf91e8612dee789dfc3517895a9eb2df7f82c4`; compare a downloaded asset with that digest before installation.\n\n### Verified GJC bridge installation\n\nOuroboros setup installs its own managed GJC bridge. Replace it with the standalone GJC bridge from immutable commit `4311fefd49e9c6781c4d1111b8dd3f758e7d8974`, whose example file has SHA-256 `2b0e1e25ac145331f112da629076875542db6f6e63c3c17adcd6770a4dcaf7bd`:\n\n```bash\ncurl -fL https://raw.githubusercontent.com/Yeachan-Heo/gajae-code/4311fefd49e9c6781c4d1111b8dd3f758e7d8974/packages/coding-agent/examples/extensions/ooo-bridge.ts -o /tmp/gjc-ooo-bridge.ts\nshasum -a 256 /tmp/gjc-ooo-bridge.ts\nmkdir -p \"${HOME}/${GJC_CONFIG_DIR:-.gjc}/agent/extensions/ouroboros-ooo-bridge\" && cp /tmp/gjc-ooo-bridge.ts \"${HOME}/${GJC_CONFIG_DIR:-.gjc}/agent/extensions/ouroboros-ooo-bridge/index.ts\"\n```\n\nThe `shasum` output must match the published example digest before the copy. The example has no runtime imports: it obtains the bundled bridge helper from the injected extension API, so the copied file works in compiled GJC binaries without extension-local `node_modules`. For project-only installation, copy the same verified file to `.gjc/extensions/ouroboros-ooo-bridge/index.ts`. Start a new GJC session after installation, then run:\n\n```text\nooo interview \"I want to build a task management CLI\"\n```\n\nSet `OUROBOROS_CLI=/absolute/path/to/ouroboros` when the executable is outside `PATH`.\n\n### Native interview versus external Ouroboros interview\n\n- `/skill:deep-interview` is GJC's bundled native interview workflow. It includes Ouroboros-inspired behavior but does not invoke the external CLI.\n- `ooo interview` is the external integration. It calls Ouroboros's MCP interview tool, renders each question in GJC, correlates ordinary answers by Ouroboros session ID, and stops claiming input when the interview completes.\n\nThe canonical install location is the agent extensions directory discovered by the native GJC provider:\n\n- user-level: `$HOME/${GJC_CONFIG_DIR:-.gjc}/agent/extensions`\n- project-level: `/.gjc/extensions`\n\nFor native discovery, install one of:\n\n- `extensions/.ts` or `extensions/.js`\n- `extensions//index.ts` or `extensions//index.js`\n- `extensions//package.json` declaring extension entries\n\nThe loader scans one level under each `extensions` directory. Complex packages should use a package manifest instead of relying on recursive discovery.\n\n`GJC_CONFIG_DIR` selects the **home-relative** config directory name: the config root is `/`, defaulting to `~/.gjc`. It does not select a project directory — the project-level path is the constant `.gjc` (`discovery/helpers.ts`, `getProjectAgentDir()`), so `GJC_CONFIG_DIR` never moves it. `GJC_CODING_AGENT_DIR` overrides the agent directory **path** rather than naming one under `$HOME`; it is resolved with `path.resolve`, so an absolute value is used as-is and a relative value is resolved against the current working directory.\n\nDiscovery is the exception to that second override. The native provider builds its user-level root from `GJC_CONFIG_DIR` alone (`//agent`) and never consults `getAgentDir()`, so an operator who sets `GJC_CODING_AGENT_DIR` moves the agent directory for the rest of the product but **not** for extension, skill, rule, or hook discovery.\n\nHooks are not the input bridge surface: `packages/coding-agent/src/capability/hook.ts` defines pre/post tool hooks only.\n", "perf-profiling-corpus.md": "# Perf profiling corpus\n\nThe profiling corpus is the **successor** to the static [`cpu-hotspot-map.json`](./cpu-hotspot-map.json) ranking (see [`hotspot-map-successor.md`](./hotspot-map-successor.md)). The static map ranked hotspots by complexity × trigger frequency but never measured real CPU self-time. The corpus replaces that guess with measured, separated evidence and is the source of future perf prioritization.\n\nImplementation:\n\n- Schema + evidence taxonomy + validation: `packages/coding-agent/bench/perf-corpus-schema.ts`\n- Runner: `packages/coding-agent/bench/perf-corpus.bench.ts`\n- Threshold/evidence ledger: `packages/coding-agent/bench/perf-threshold.ledger.ts`\n- Tests: `packages/coding-agent/test/perf-corpus.test.ts`\n- Deterministic memory surface workloads: `packages/coding-agent/bench/memory-baseline-workloads.ts`\n\n## Evidence taxonomy\n\nEach metric and optimization claim is classified by **evidence class**. These classes must never be conflated:\n\n| Class | Meaning | Sufficient for CPU self-time? |\n|---|---|---|\n| `wall-clock-proxy` | elapsed time around a phase/operation | No |\n| `process-cpu-usage` | `process.cpuUsage()` user/system deltas | No |\n| `profiler-self-time` | profiler/sampled attribution of self-time to a symbol | **Yes (required)** |\n| `rss-memory` | RSS/heap baseline/growth/return | No (memory only) |\n| `byte-parity` | golden rendered/persisted/provider/materialized comparisons | n/a (safety) |\n| `ledger-approved-threshold` | human-approved threshold change | n/a (process) |\n\nOptimization **status vocabulary** for a hotspot:\n\n- `CPU-self-time confirmed` — requires `profiler-self-time` evidence (an `artifactPath` or non-empty `samples`).\n- `fallback-toggle-confirmed` — comparable before/after or feature/fallback-toggle evidence proves an end-to-end win without byte changes.\n- `covered-current` — the corpus exercises the path but has no comparable before/after evidence.\n- `not-visible` — the path was not exercised or showed no measurable impact.\n- `needs-trace-coverage` — the corpus lacks fixture coverage for the path.\n\nA v1–v3 win is **never** called \"confirmed\" from current-only coverage. `validatePerfCorpusReport()` enforces this: a `CPU-self-time confirmed` classification is rejected unless the report carries profiler self-time evidence.\n\n## Schema (gjc.perf-corpus/2)\n\n`PerfCorpusReport` keeps the evidence classes as **separate named fields** per fixture:\n\n- `wallClockPhase: Record`\n- `processCpuUsage: Record`\n- `profilerSelfTime: { profiler, artifactPath?, samples? }`\n- `rssMemory: { baselineBytes, peakBytes?, growthBytes, returnBytes, ... }`\n- `byteParity: { renderedGolden?, persistedJsonlGolden?, providerPayloadGolden?, materializedSessionGolden? }`\n- `memoryBaseline?: { surface, profile, iterations, operations, operationsPerSecond, samples, postTeardown, rssSlopeBytesPerSecond, heapSlopeBytesPerSecond, processTreeBaselineRssBytes, processTreePostTeardownRssBytes, processTreeSampler }`\n- `runner: { command, argv, environment, platform, arch, bunVersion?, ci?, profile, durationTargetMs?, memoryIsolation, iterationsTarget, gcExposed, memoryChildGcExposed, memoryChildExecArgv }` pins the actual parent argv, normalized workload controls, isolation, parent GC availability, and the fixed isolated-child runtime flags separately.\n- `gitSha` is the full checked-out `HEAD` when Git is available, with `GITHUB_SHA` used only as a fallback; `gitDirty` explicitly marks tracked or untracked worktree changes so local evidence cannot silently masquerade as a clean commit. The runner captures SHA and the complete porcelain worktree fingerprint before and after the workloads and rejects any in-flight source-state change.\n- Every detailed sample separates `rssBytes`, `heapUsedBytes`, `heapTotalBytes`, `externalBytes`, `arrayBuffersBytes`, and `activeResourceCount`.\n\n`hotspotClassifications: HotspotClassification[]` carry `{ hotspotId, status, evidenceClass, artifactRefs, notes }`. The current v1–v3 reclassification lives in `V1_V3_RECLASSIFICATION`; no entry is `CPU-self-time confirmed` because no profiler artifacts have been captured yet.\n\n## Privacy rules\n\n- Never commit raw private session transcripts.\n- Default fixtures are `synthetic` (deterministic PRNG, no real data).\n- `sanitized-real` / `dogfood-redacted` fixtures are allowed only with documented redaction in `privacy.redactionNotes`; `privacy.rawPrivateTranscriptCommitted` must be `false`.\n\n## Commands\n\n```bash\n# Emit a corpus report (stable JSON)\nbun packages/coding-agent/bench/perf-corpus.bench.ts\n\n# Run the corpus schema/classification/ledger tests\nbun test packages/coding-agent/test/perf-corpus.test.ts\n```\n\n```bash\n# Emit the detailed short memory profile with explicit GC return samples\nbun --smol --expose-gc packages/coding-agent/bench/perf-corpus.bench.ts\n\n# Opt into the longer bounded soak profile\nGJC_MEMORY_PROFILE=soak bun --smol --expose-gc packages/coding-agent/bench/perf-corpus.bench.ts\n\n# Override the per-surface duration (250–60000 ms) and minimum iterations\nGJC_MEMORY_PROFILE=soak GJC_MEMORY_DURATION_MS=10000 GJC_MEMORY_ITERATIONS=100000 bun --smol --expose-gc packages/coding-agent/bench/perf-corpus.bench.ts\n```\n\n## Profiler-artifact expectations\n\nThe base runner attaches no profiler (`profilerSelfTime.profiler: \"none\"`), so it can never promote a hotspot to `CPU-self-time confirmed`. To confirm CPU self-time:\n\n1. Capture a profiler artifact (e.g. a `.cpuprofile`) while running the relevant fixture.\n2. Record it in the fixture's `profilerSelfTime` as `{ profiler, artifactPath, samples }`.\n3. Set the hotspot classification to `CPU-self-time confirmed` with `evidenceClass: \"profiler-self-time\"` and the artifact in `artifactRefs`.\n4. `validatePerfCorpusReport()` will then accept the claim.\n\n## Threshold-promotion process\n\nWall-clock and RSS thresholds are noisy. Promotion is gradual:\n\n1. **Advisory** — reported in the corpus JSON / console; never fails CI. All thresholds start here (`APPLIED_PERF_THRESHOLDS`, `advisoryOrEnforced: \"advisory\"`, `varianceCharacterized: false`).\n2. **Opt-in numeric** — exercised under `PI_TUI_PERF_GATES=1` (see `packages/tui/test/perf-gates.test.ts`).\n3. **Enforced** — a hard CI gate, allowed only with `varianceCharacterized: true`, passed before/after `benchmarkEvidence`, and human approval. `validatePerfThresholdLedger()` rejects enforced thresholds lacking this evidence.\n\nHeld thresholds (`HELD_PERF_THRESHOLDS`) name candidates that need variance characterization before enforcement.\n\n## Memory baseline protocol\n\nDetailed memory fixtures cover seven explicit surfaces: CLI startup/configuration, AgentSession-style message/context lifecycle, blob/external buffers, worker generations, Telegram reconnect/queue settlement, TUI render/dispose churn, and shared/native transfer boundaries. The fixtures are synthetic lifecycle proxies: they establish a reproducible allocation and teardown envelope but do not by themselves prove a production leak. A production optimization claim still requires a workload adapter that exercises the implicated owner and a same-host before/after artifact.\nThe command-line runner executes each memory surface in a fresh Bun subprocess and records `runner.memoryIsolation: \"process-per-surface\"` so allocator high-water state from one fixture cannot contaminate the next surface's baseline. Programmatic `runPerfCorpusBenchmark()` defaults to in-process fixtures and records `\"in-process\"` for focused contract tests; pass `{ isolatedMemory: true }` for acceptance-equivalent evidence. Process-tree RSS snapshots exclude the `ps` sampler process and degrade both endpoints to `\"unavailable\"` when either snapshot fails. The process-tree baseline is captured after GC, followed by another GC that clears sampler allocations before the local baseline and workload begin. Soak workloads use single-iteration batches so approximately 50 ms sampling cannot be hidden behind a large synchronous chunk. Post-teardown return fields remain `null` when GC is unavailable.\n\nUse the `short` profile for deterministic contract and shape checks; its bounded iteration window intentionally reports `null` slopes when less than 250 ms is observed. Use `soak` for repeated sampling and slope characterization. For decision evidence:\nThe soak default runs each surface for at least one second and samples at approximately 50 ms intervals. `GJC_MEMORY_DURATION_MS` accepts 250–60000 ms and `GJC_MEMORY_ITERATIONS` accepts 1–10000000; record overrides with the artifact.\n\n1. Pin the source SHA, Bun version, platform/architecture, profile, fixture inputs, and command.\n2. Run at least five short repetitions and three independent soak repetitions on an otherwise idle runner.\n3. Exclude warm-up from slope decisions and report the raw samples, median, p95, variance/confidence interval, peak, and post-teardown values. The runner discards the first quarter of the observed window, capped at 250 ms, before calculating a slope and requires at least 250 ms of steady-state samples.\n4. Interpret heap, external/array-buffer, RSS, and process-tree evidence separately. A high post-GC RSS with a returned heap may be allocator high-water residency, not a reachability leak.\n5. Do not enforce a numeric threshold until variance is characterized and recorded in the threshold ledger. A claimed optimization needs either a statistically supported improvement on the same workload or removal of a reproducible unbounded slope.\n6. Treat active handles and post-teardown residue as lifecycle signals, not byte-parity proof. Behavior, transcript/blob integrity, throughput, and latency remain independent gates.\n\nThe default fixtures contain no user or provider data. Raw private transcripts remain prohibited.\n\n## Memory retention & fail-closed materialization\n\nResident-memory retention (hotspots M01–M05) was bounded in Optimization Suite v3 (#548): `EphemeralBlobStore` externalizes large resident text to a session-scoped disk cache with an 8 MiB LRU buffer budget, `getEntries()`/`buildSessionContext()` are served from revision-keyed WeakRef caches and return caller-owned clones, and `captureState`/`restoreState` bump revision domains. Materialization is split by byte sensitivity:\n\n- **Resident byte-sensitive TEXT** (`resolveTextBlobSync`) is **fail-closed**: a missing resident blob throws `ResidentBlobMissingError` rather than degrading, so a missing blob can never silently leak a `blob:sha256:` reference into provider payloads, UI, or exports.\n- **Persisted images** (`resolveImageData`/`resolveImageDataUrl` and sync variants) are the **legacy persisted-image compatibility boundary**: a missing blob warns and returns the reference as-is so legacy-session resume degrades gracefully. New byte-sensitive resident data must NOT use this warn-and-return path.\n\nThis contract is locked by `packages/coding-agent/test/resident-materialization.test.ts`. Retained growth and post-GC return are measured by `packages/coding-agent/bench/session-memory.bench.ts` (emits the corpus `rssMemory` shape).\n\n**Measured deferral:** further memory rewrites beyond these byte-parity-preserving bounds are deferred to corpus prioritization. Per [`native-ffi-optimization-policy.md`](./native-ffi-optimization-policy.md) and the byte-parity principle, speculative memory rewrites wait for profiler/RSS corpus evidence rather than being undertaken on a static-ranking guess.\n\n## Authenticated sealed-corpus result\n\n- Evidence status: `SUFFICIENT_EVIDENCE`\n- Action decision: `ACTION`\n- Action family: `sustained-heap-growth`\n- Measurement head: `ae37704ea58c5181043ef2a325c3aa1878884c25`\n- Admission: short 5/5, soak 24/24\n- `agent-session` endpoint median: 2232879.966 B/s, BCa lower 2198738.248, Theil-Sen median 917654.71\n- `tui` endpoint median: 170829.216 B/s, BCa lower 154600.451, Theil-Sen median 4391.02\n- p95: `OMITTED_IMPOSSIBLE` (24 blocks insufficient for 95% empirical coverage per exact-order-statistic method)\n- All five preregistered limitations preserved\n- JS heap separated from process RSS/external/native; no production leak or causal site claimed\n- Raw corpus retained outside git, read-only, access-restricted, hash-bound by external receipt\n- Published files:\n - `artifacts/perf-corpus-memory-evidence-report.json`\n - `artifacts/perf-corpus-memory-evidence-manifest.json`\n - `artifacts/perf-corpus-memory-evidence-notebook.ipynb`\n", "porting-from-pi-mono.md": "# Porting From pi-mono: A Practical Merge Guide\n\nThis guide is a repeatable checklist for porting changes from pi-mono into this repo.\nUse it for any merge: single file, feature branch, or full release sync.\n\n## Last Sync Point (historical upstream marker)\n\n**Commit:** `b21b42d032919de2f2e6920a76fa9a37c3920c0a`\n**Date:** 2026-03-22\n\nUpdate this section after each sync; do not reuse the previous range. This commit is an upstream pi-mono marker and may not exist in this repo's local object database.\n\nWhen starting a new sync, generate patches from this commit forward in a pi-mono checkout or remote that contains the commit:\n\n```bash\ngit format-patch b21b42d032919de2f2e6920a76fa9a37c3920c0a..HEAD --stdout > changes.patch\n```\n\n## 0) Define the scope\n\n- Identify the upstream reference (commit, tag, or PR).\n- List the packages or folders you plan to touch.\n- Decide which features are in-scope and which are intentionally skipped.\n\n## 1) Bring code over safely\n\n- Prefer a clean, focused diff rather than a wholesale copy.\n- Avoid copying built artifacts or generated files.\n- If upstream added new files, add them explicitly and review contents.\n\n## 2) Match import extension conventions\n\nMost runtime TypeScript sources omit `.js` in internal imports, but several current entrypoints and tool modules keep `.js` for ESM/runtime compatibility. Follow the surrounding file and package export style; do not blanket-strip or blanket-add extensions.\n\n- In `packages/coding-agent` runtime sources, prefer extensionless internal imports when the surrounding module does, but preserve existing `.js` imports in files that already require them.\n- In `packages/tui/test` and `packages/natives/bench`, keep `.js` where surrounding files already use it.\n- Keep real file extensions when required by tooling or import assertions (e.g., `.json`, `.css`, `.md` text embeds).\n- Example: `import { x } from \"./foo.js\";` → `import { x } from \"./foo\";` only when that package/file convention is extensionless.\n\n## 3) Replace import scopes\n\nUpstream uses different package scopes. Replace them consistently.\n\n- Replace old scopes with the local scope used here.\n- Examples (adjust to match the actual packages you are porting):\n - `@mariozechner/gajae-code` → `@gajae-code/coding-agent`\n - `@mariozechner/pi-agent-core` → `@gajae-code/agent-core`\n - `@mariozechner/pi-tui` → `@gajae-code/tui`\n - `@mariozechner/pi-ai` → `@gajae-code/ai`\n\n## 4) Use Bun APIs where they improve on Node\n\nWe run on Bun, but the current source intentionally mixes Bun APIs with small Node standard-library APIs. Replace Node APIs only when Bun provides a clearer, safer, or simpler implementation; do not mechanically rewrite every Node import.\n\n**Prefer replacing when porting new code:**\n\n- Process spawning: prefer Bun Shell `$` for simple commands; use `Bun.spawn`/`Bun.spawnSync` for streaming or process control. Keep existing `child_process` only where its exact semantics are needed.\n- HTTP clients: `node-fetch`, `axios` → native `fetch`\n- SQLite: `better-sqlite3` → `bun:sqlite`\n- Env loading: `dotenv` → Bun loads `.env` automatically\n- Runtime text/assets: prefer Bun imports such as `with { type: \"text\" }` or `Bun.file()` over copy steps or bundled fallback file reads.\n\n**DO NOT replace (these work fine in Bun):**\n\n- `os.homedir()` — do NOT replace with `Bun.env.HOME` or literal `\"~\"`\n- `os.tmpdir()` — do NOT replace with `Bun.env.TMPDIR || \"/tmp\"` or hardcoded paths\n- `fs.mkdtempSync()` — do NOT replace with manual path construction\n- `path.join()`, `path.resolve()`, etc. — these are fine\n\n**Import style:** Use the `node:` prefix for Node standard-library imports. Namespace imports are common, but named imports are acceptable where the surrounding code already uses them.\n\n**Additional Bun conventions:**\n\n- Prefer Bun Shell `$` for short, non-streaming commands; use `Bun.spawn` only when you need streaming I/O or process control.\n- Use `Bun.file()`/`Bun.write()` for simple files and `node:fs/promises` for directory-oriented operations. Existing synchronous `node:fs` calls are acceptable when the calling flow is intentionally synchronous.\n- Avoid `Bun.file().exists()` checks; use `isEnoent` handling in try/catch.\n- Prefer `Bun.sleep(ms)` over `setTimeout` wrappers.\n\n**Wrong:**\n\n```typescript\n// BROKEN: env vars may be undefined, \"~\" is not expanded\nconst home = Bun.env.HOME || \"~\";\nconst tmp = Bun.env.TMPDIR || \"/tmp\";\n```\n\n**Correct:**\n\n```typescript\nimport * as os from \"node:os\";\nimport * as fs from \"node:fs\";\nimport * as path from \"node:path\";\n\nconst configDir = path.join(os.homedir(), \".config\", \"myapp\");\nconst tempDir = fs.mkdtempSync(path.join(os.tmpdir(), \"myapp-\"));\n```\n\n## 5) Prefer Bun embeds (no copying)\n\nDo not add new runtime asset copy steps. Keep assets in repo and prefer Bun embeds/imports; preserve existing explicit generation workflows such as `packages/coding-agent/src/export/html/template.generated.ts`.\n\n- If upstream copies assets into a dist folder, replace with Bun-friendly embeds.\n- Prompts are static `.md` files; use Bun text imports (`with { type: \"text\" }`) and Handlebars instead of inline prompt strings.\n- Use `import.meta.dir` + `Bun.file` to load adjacent non-text resources.\n- Keep assets in-repo and let the bundler include them.\n- Eliminate copy scripts unless the user explicitly requests them or the package already has an intentional generation step.\n- If upstream reads a bundled fallback file at runtime, replace filesystem reads with a Bun text embed import unless the current package already uses a generated asset pipeline.\n - Example (provider instructions fallback):\n - `const FALLBACK_PROMPT_PATH = join(import.meta.dir, \"openai-code-instructions.md\");` -> removed\n - `import FALLBACK_INSTRUCTIONS from \"./openai-code-instructions.md\" with { type: \"text\" };`\n - Use `return FALLBACK_INSTRUCTIONS;` instead of `readFileSync(FALLBACK_PROMPT_PATH, \"utf8\")`\n\n## 6) Port `package.json` carefully\n\nTreat `package.json` as a contract. Merge intentionally.\n\n- Keep existing `name`, `version`, `type`, `exports`, and `bin` unless the port requires changes.\n- Replace npm/node scripts with Bun equivalents (e.g., `bun check`, `bun test`).\n- Ensure dependencies use the correct scope.\n- Do not downgrade dependencies to fix type errors; upgrade instead.\n- Validate workspace package links and `peerDependencies`.\n\n## 7) Align code style and tooling\n\n- Keep existing formatting conventions.\n- Do not introduce `any` unless required.\n- Avoid dynamic imports unless they are required for optional dependencies, startup cost, or runtime-only modules; prefer top-level imports otherwise.\n- Never build prompts in code; prompts are static `.md` files rendered with Handlebars.\n- In `packages/coding-agent`, use `logger` from `@gajae-code/utils` for internal/runtime logging; CLI command files may use `console.*` for intentional user-facing output.\n- Use `Promise.withResolvers()` instead of `new Promise((resolve, reject) => ...)`.\n- Prefer ES `#` private fields for new encapsulated state. Constructor parameter properties already exist in current code and are acceptable; do not churn unrelated access modifiers while porting.\n- Prefer existing helpers and utilities over new ad-hoc code.\n Preserve Bun-first infrastructure changes already made in this repo:\n - Runtime is Bun (no Node entry points for the main CLI).\n - Package manager is Bun (no npm lockfiles).\n - Heavy Node APIs should not be introduced casually; current source still uses selected Node APIs (`node:crypto`, `node:readline`, synchronous `node:fs`, and `child_process`) where they fit provider, CLI, or process-control semantics.\n - Lightweight Node APIs (`os.homedir`, `os.tmpdir`, `fs.mkdtempSync`, `path.*`) are kept.\n - CLI shebangs use `bun` (not `node`, not `tsx`).\n - TypeScript packages generally use source files directly; `@gajae-code/natives` exports generated native bindings from `packages/natives/native`.\n - CI workflows run Bun for install/check/test.\n\n## 8) Remove old compatibility layers\n\nUnless requested, remove upstream compatibility shims.\n\n- Delete old APIs that were replaced.\n- Update all call sites to the new API directly.\n- Do not keep `*_v2` or parallel versions.\n\n## 9) Update docs and references\n\n- Replace pi-mono repo links where appropriate.\n- Update examples to use Bun and correct package scopes.\n- Ensure README instructions still match the current repo behavior.\n\n## 10) Validate the port\n\nRun the standard checks after changes:\n\n- `bun check`\n\nIf the repo already has failing checks unrelated to your changes, call that out.\nTests use Bun's runner (not Vitest), but only run `bun test` when explicitly requested.\n\n## 11) Protect improved features (regression trap list)\n\nIf you already improved behavior locally, treat those as **non‑negotiable**. Before porting, write down\nthe improvements and add explicit checks so they don’t get lost in the merge.\n\n- **Freeze the expected behavior**: add a short “before/after” note for each improvement (inputs, outputs,\n defaults, edge cases). This prevents silent rollback.\n- **Map old → new APIs**: if upstream renamed concepts (hooks → extensions, custom tools → tools, etc.),\n ensure every old entry point still wires through. One missed flag or export equals lost functionality.\n- **Verify exports**: check `package.json` `exports`, public types, and barrel files. Upstream ports often\n forget to re-export local additions.\n- **Cover non‑happy paths**: if you fixed error handling, timeouts, or fallback logic, add a test or at\n least a manual checklist that exercises those paths.\n- **Check defaults and config merge order**: improvements often live in defaults. Confirm new defaults\n didn’t revert (e.g., new config precedence, disabled features, tool lists).\n- **Audit env/shell behavior**: if you fixed execution or sandboxing, verify the new path still uses your\n sanitized env and does not reintroduce alias/function overrides.\n- **Re-run targeted samples**: keep a minimal set of \"known good\" examples and run them after the port\n (CLI flags, extension registration, tool execution).\n\n## 12) Detect and handle reworked code\n\nBefore porting a file, check if upstream significantly refactored it:\n\n```bash\n# Compare the file you're about to port against what you have locally\ngit diff HEAD upstream/main -- path/to/file.ts\n```\n\nIf the diff shows the file was **reworked** (not just patched):\n\n- New abstractions, renamed concepts, merged modules, changed data flow\n\nThen you must **read the new implementation thoroughly** before porting. Blind merging of reworked code loses functionality because:\n\nNote: interactive mode was recently split into controllers/utils/types. When backporting related changes, port updates into the individual files we created and ensure `interactive-mode.ts` wiring stays in sync.\n\n1. **Defaults change silently** - A new variable `defaultFoo = [a, b]` may replace an old `getAllFoo()` that returned `[a, b, c, d, e]`.\n\n2. **API options get dropped** - When systems merge (e.g., `hooks` + `customTools` → `extensions`), old options may not wire through to the new implementation.\n\n3. **Code paths go stale** - A renamed concept (e.g., `hookMessage` → `custom`) needs updates in every switch statement, type guard, and handler—not just the definition.\n\n4. **Context/capabilities shrink** - Old APIs may have exposed `{ logger, typebox, pi }` that new APIs forgot to include.\n\n### Semantic porting process\n\nWhen upstream reworked a module:\n\n1. **Read the old implementation** - Understand what it did, what options it accepted, what it exposed.\n\n2. **Read the new implementation** - Understand the new abstractions and how they map to old behavior.\n\n3. **Verify feature parity** - For each capability in the old code, confirm the new code preserves it or explicitly removes it.\n\n4. **Grep for stragglers** - Search for old names/concepts that may have been missed in switch statements, handlers, UI components.\n\n5. **Test the boundaries** - CLI flags, SDK options, event handlers, default values—these are where regressions hide.\n\n### Quick checks\n\n```bash\n# Find all uses of an old concept that may need updating\nrg \"oldConceptName\" --type ts\n\n# Compare default values between versions\ngit show upstream/main:path/to/file.ts | rg \"default|DEFAULT\"\n\n# Check if all enum/union values have handlers\nrg \"case \\\"\" path/to/file.ts\n```\n\n## 13) Quick audit checklist\n\nUse this as a final pass before you finish:\n\n- [ ] Import extensions follow the local package convention (no blanket `.js` stripping)\n- [ ] No newly introduced Node-only APIs unless they match an existing justified pattern\n- [ ] All package scopes updated\n- [ ] `package.json` scripts use Bun\n- [ ] Prompts are `.md` text imports (no inline prompt strings)\n- [ ] No internal/runtime `console.*` in coding-agent; CLI user-facing output is intentional\n- [ ] Assets load via Bun embed/import patterns, or through an existing intentional generation pipeline\n- [ ] Tests or checks run (or explicitly noted as blocked)\n- [ ] No functionality regressions (see sections 11-12)\n\n## 14) Commit message format\n\nWhen committing a backport, follow the repo format `(scope): ` and keep the commit\nrange in the title.\n\n```\nfix(coding-agent): backported pi-mono changes (..)\n\npackages/:\n- : \n- : (# by @)\n\npackages/:\n- : \n```\n\n**Example:**\n\n```\nfix(coding-agent): backported pi-mono changes (9f3eef65f..52532c7c0)\n\npackages/ai:\n- fix: handle \"sensitive\" stop reason from Anthropic API\n- fix: normalize tool call IDs with special characters for Responses API\n- fix: add overflow detection for Bedrock, MiniMax, Kimi providers\n- fix: 429 status is rate limiting, not context overflow\n\npackages/tui:\n- fix: refactored autocomplete state tracking\n- fix: file autocomplete should not trigger on empty text\n- fix: configurable autocomplete max visible items\n- fix: improved table column width calculation with word-aware wrapping\n\npackages/coding-agent:\n- fix: preserve external config.yml edits on save (#1046 by @nicobailonMD)\n- fix: resolve macOS NFD and curly quote variants in file paths\n```\n\n**Rules:**\n\n- Group changes by package\n- Use conventional commit types (`fix`, `feat`, `refactor`, `perf`, `docs`)\n- Include upstream issue/PR numbers and contributor attribution for external contributions\n- The commit range in the title helps track sync points\n\n## 15) Intentional Divergences\n\nOur fork has architectural decisions that differ from upstream. **Do not port these upstream patterns:**\n\n### UI Architecture\n\n| Upstream | Our Fork | Reason |\n| ------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------- |\n| `FooterDataProvider` class | `StatusLineComponent` | Simpler, integrated status line |\n| `ctx.ui.setHeader()` / `ctx.ui.setFooter()` | No-op stubs in current extension contexts | Not currently wired to replace the TUI status/header UI |\n| `ctx.ui.setEditorComponent()` | No-op stubs in current extension contexts | Custom editor replacement is not currently wired |\n| `InteractiveModeOptions` options object | Positional constructor args (options type still exported) | Keep constructor signature; update the type when upstream adds fields |\n\n### Component Naming\n\n| Upstream | Our Fork |\n| ---------------------------- | ----------------------- |\n| `extension-input.ts` | `hook-input.ts` |\n| `extension-selector.ts` | `hook-selector.ts` |\n| `ExtensionInputComponent` | `HookInputComponent` |\n| `ExtensionSelectorComponent` | `HookSelectorComponent` |\n\n### API Naming\n\n| Upstream | Our Fork | Notes |\n| ---------------------------------------- | ---------------------------------------- | ----------------------------------------- |\n| `sessionManager.appendSessionInfo(name)` | `sessionManager.setSessionName(name)` | We use `sessionName` throughout |\n| `sessionManager.getSessionName()` | `sessionManager.getSessionName()` | Same (we unified to match upstream's RPC) |\n| `agent.sessionName` / `setSessionName()` | `agent.sessionName` / `setSessionName()` | Same |\n\n### File Consolidation\n\n| Upstream | Our Fork | Reason |\n| -------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------- |\n| `clipboard.ts` + `clipboard-image.ts` (tool files) | `src/utils/clipboard.ts` backed by `@gajae-code/natives` | Native implementation with a small TS wrapper |\n\n### Test Framework\n\n| Upstream | Our Fork |\n| ------------------------- | ----------------------------- |\n| `vitest` with `vi.mock()` | `bun:test` with `vi` from bun |\n| `node:test` assertions | `expect()` matchers |\n\n### Tool Architecture\n\n| Upstream | Our Fork | Notes |\n| ----------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |\n| `createTool(cwd: string, options?)` | `createTools(session: ToolSession)` via `BUILTIN_TOOLS` registry | Tool factories accept `ToolSession` and can return `null` |\n| Per-tool `*Operations` interfaces | Only current per-tool override interfaces remain (for example `FindOperations`) | Used for SSH/remote overrides where present |\n| Node.js `fs/promises` everywhere | Bun file APIs for simple file writes/reads, `node:fs/promises` for dirs, selected sync `node:fs` where needed | Prefer Bun APIs when they simplify |\n\n### Auth Storage\n\n| Upstream | Our Fork | Notes |\n| ------------------------------- | ------------------------------------------- | -------------------------------------------- |\n| `proper-lockfile` + `auth.json` | `agent.db` (bun:sqlite) | Credentials stored exclusively in `agent.db` |\n| Single credential per provider | Multi-credential with round-robin selection | Session affinity and backoff logic preserved |\n\n### Extensions\n\n| Upstream | Our Fork |\n| ----------------------------- | ------------------------------------------------- |\n| `jiti` for TypeScript loading | Native Bun `import()` |\n| `pkg.pi` manifest field | `pkg.gjc` preferred; fallback to `pkg.pi` remains |\n\n### Skip These Upstream Features\n\nWhen porting, **skip** these files/features entirely:\n\n- `footer-data-provider.ts` — we use StatusLineComponent\n- `clipboard-image.ts` — image clipboard support is exposed through `src/utils/clipboard.ts` backed by `@gajae-code/natives`\n- GitHub workflow files — we have our own CI\n- `models.generated.ts` — auto-generated, regenerate locally (as models.json instead)\n\n### Features We Added (Preserve These)\n\nThese exist in our fork but not upstream. **Never overwrite:**\n\n- `StatusLineComponent` in interactive mode\n- Multi-credential auth with session affinity\n- Capability-based discovery system (`defineCapability`, `registerProvider`, `loadCapability`, `skillCapability`, etc.)\n- MCP/Exa/SSH integrations\n- LSP writethrough for format-on-save\n- Bash interception (`checkBashInterception`)\n- Fuzzy path suggestions in read tool\n", "porting-to-natives.md": "# Porting to pi-natives (N-API) — Field Notes\n\nThis is a practical guide for moving hot paths into `crates/pi-natives` and wiring them through the generated native package entrypoint. It exists to avoid the same failures happening twice.\n\n## When to port\n\nPort when any of these are true:\n\n- The hot path runs in render loops, tight UI updates, or large batches.\n- JS allocations dominate (string churn, regex backtracking, large arrays).\n- You already have a JS baseline and can benchmark both versions side by side.\n- The work is CPU-bound or blocking I/O that can run on the libuv thread pool.\n- The work is async I/O that can run on Tokio's runtime (for example shell execution).\n\nRust is reserved for native bindings, native OS/process/filesystem integration, and measured hot paths. New crates or Rust source trees must have an explicit native/performance rationale in `scripts/check-rust-scope.ts`; keep product policy, orchestration, and glue code in TypeScript unless the benchmark or native boundary justifies moving it.\n\nAvoid ports that depend on JS-only state or dynamic imports. N-API exports should be data-in/data-out. Long-running work should go through `task::blocking` (CPU-bound/blocking I/O) or `task::future` (async I/O) with cancellation where the caller needs `timeoutMs` or `AbortSignal`.\n\n> **Optimization ports need evidence first.** A native port proposed to optimize a *leftover algorithmic hot path* must clear the gates in [`native-ffi-optimization-policy.md`](./native-ffi-optimization-policy.md) (corpus evidence, `profilerSelfTime` attribution, measured FFI overhead, representative p50/p95 win, byte parity, documented rollback cost). New OS/process/native-primitive bindings follow this guide as usual.\n\n## Current package shape\n\n`@gajae-code/natives` no longer has a `packages/natives/src/` TypeScript wrapper layer. The package root points at generated native artifacts:\n\n- runtime entry: `packages/natives/native/index.js`\n- types entry: `packages/natives/native/index.d.ts`\n- loader helpers: `packages/natives/native/loader-state.js`\n- embedded manifest: `packages/natives/native/embedded-addon.js`\n\nConsumers import directly from `@gajae-code/natives`. The generated declarations are produced during `bun --cwd=packages/natives run build`.\n\n## Anatomy of a native export\n\n**Rust side:**\n\n- Implementation lives in `crates/pi-natives/src/.rs`.\n- If you add a new module, register it in `crates/pi-natives/src/lib.rs`.\n- Export with `#[napi]`; snake_case exports are converted to camelCase automatically. Use explicit JS names only for true aliases/non-default names. Use `#[napi(object)]` for object-shaped structs.\n- For CPU-bound or blocking work, use `task::blocking(tag, cancel_token, work)`.\n- For async work that needs Tokio, use `task::future(env, tag, work)`.\n- Pass a `CancelToken` when the API exposes `timeoutMs` or `AbortSignal`, and call `heartbeat()` inside long loops.\n\n**Package/build side:**\n\n- `packages/natives/scripts/build-native.ts` runs napi-rs, installs the `.node` artifact, copies generated `index.js`/`index.d.ts`, and appends enum runtime exports.\n- `packages/natives/native/index.js` is the loader that chooses a candidate `.node` file and returns the loaded addon.\n- `packages/natives/package.json` exposes only the package root (`@gajae-code/natives`).\n\n**Consumer side:**\n\n- Update direct imports/callsites in `packages/coding-agent` or `packages/tui` when the new export replaces a JS implementation.\n- Keep higher-level policy in consumers unless it belongs in the native primitive itself.\n\n## Porting checklist\n\n1. **Add the Rust implementation**\n\n- Put the core logic in a plain Rust function.\n- If it is a new module, add it to `crates/pi-natives/src/lib.rs`.\n- Expose it with `#[napi]` so the default snake_case -> camelCase mapping stays consistent.\n- Keep signatures owned and simple: `String`, `Vec`, `Uint8Array`, `Either`, or `#[napi(object)]` structs.\n- For CPU-bound or blocking work, use `task::blocking`; for async work, use `task::future`.\n- If exposing cancellation, include `timeout_ms: Option` and `signal: Option>` in options, create `CancelToken::new(...)`, and heartbeat in long loops.\n\n2. **Build generated bindings**\n\n- Run `bun --cwd=packages/natives run build`.\n- Confirm the generated `packages/natives/native/index.d.ts` includes the new export with the intended JS name/signature.\n- Confirm `packages/natives/native/index.js` still has generated enum exports appended when enum changes are involved.\n\n3. **Update consumers**\n\n- Import the new export directly from `@gajae-code/natives`.\n- Replace only callsites where the native implementation is faster/equivalent and preserves behavior.\n- Remove obsolete JS implementation code in the same change when the native path becomes canonical.\n\n4. **Add benchmarks**\n\n- Put benchmarks next to the owning package (`packages/tui/bench`, `packages/natives/bench`, or `packages/coding-agent/bench`).\n- Include a JS baseline and native version in the same run.\n- Use `Bun.nanoseconds()` and a fixed iteration count.\n- Keep benchmark inputs realistic for the hot path.\n\n5. **Run focused verification**\n\n- Build the native package.\n- Run the benchmark.\n- Run the narrow tests or scenario covering the changed export/callsites.\n\n## Pain points and how to avoid them\n\n### 1) Stale platform/variant artifacts\n\nThe loader probes platform-tagged artifacts in deterministic order. For x64, selected variant candidates are tried before the unsuffixed default fallback:\n\n- `modern`: `pi_natives.-modern.node`, then `...-baseline.node`, then `pi_natives..node`.\n- `baseline`: `pi_natives.-baseline.node`, then `pi_natives..node`.\n\nNon-x64 uses `pi_natives..node`.\n\nCompiled binaries also probe `//...` and a legacy user-data directory before package/executable locations. If any earlier candidate is stale, a new export may appear missing.\n\n**Fix:** remove stale candidate/cache files and rebuild.\n\n```bash\nrm packages/natives/native/pi_natives.-.node\nrm packages/natives/native/pi_natives.--modern.node\nrm packages/natives/native/pi_natives.--baseline.node\nbun --cwd=packages/natives run build\n```\n\nFor compiled binaries, delete the versioned addon cache shown in the loader error (normally under `~/.gjc/natives/` unless `$XDG_DATA_HOME/gjc` is used).\n\n### 2) Generated types do not match loaded binary\n\nThis can happen when `native/index.d.ts` was regenerated but the `.node` file being loaded is stale or from a different platform/variant.\n\nVerify the loaded export set from the actual candidate path:\n\n```bash\nbun -e 'const tag = `${process.platform}-${process.arch}`; const mod = require(`./packages/natives/native/pi_natives.${tag}.node`); console.log(Object.keys(mod).sort())'\n```\n\nFix the build/candidate mismatch. Do not paper over it with optional consumer checks if the export is required.\n\n### 3) Rust signature mismatch\n\nKeep N-API signatures simple and owned. Avoid borrowed references like `&str` in public exports. If you need structured data, use `#[napi(object)]` structs. If you need callbacks, use napi-rs `ThreadsafeFunction` and keep callback error/value behavior explicit.\n\n### 4) Enum runtime exports\n\nnapi-rs declarations alone are not enough for JS callers that use enum objects at runtime. `scripts/gen-enums.ts` appends enum objects to `native/index.js`. If you add or change a native enum, verify both `native/index.d.ts` and the generated enum export block in `native/index.js`.\n\n### 5) Benchmarking mistakes\n\n- Do not compare different inputs or allocations.\n- Keep JS and native using identical input arrays.\n- Run both in the same benchmark file to avoid skew.\n- Include enough iterations to smooth startup noise, but keep inputs realistic.\n\n## Benchmark template\n\n```ts\nconst ITERATIONS = 2000;\n\nfunction bench(name: string, fn: () => void): number {\n const start = Bun.nanoseconds();\n for (let i = 0; i < ITERATIONS; i++) fn();\n const elapsed = (Bun.nanoseconds() - start) / 1e6;\n console.log(\n `${name}: ${elapsed.toFixed(2)}ms total (${(elapsed / ITERATIONS).toFixed(6)}ms/op)`,\n );\n return elapsed;\n}\n\nbench(\"feature/js\", () => {\n jsImpl(sample);\n});\n\nbench(\"feature/native\", () => {\n nativeImpl(sample);\n});\n```\n\n## Verification checklist\n\n- Generated `native/index.d.ts` includes the new export and intended TS signature.\n- The loaded `.node` file's `Object.keys(require(candidate))` includes the new export.\n- Runtime enum objects are present when the change adds/changes enums.\n- Bench numbers are recorded in the PR/notes.\n- Call sites are updated only if native is faster/equal and behavior-compatible.\n- Obsolete JS code is removed when the native implementation becomes canonical.\n\n## Rule of thumb\n\n- If native is slower, do not switch callsites. Keep or remove the export based on whether it has a near-term owner.\n- If native is faster and behavior-compatible, switch callsites and keep a benchmark to catch regressions.\n", diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 267cdbbe0c..e6be005462 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -822,6 +822,17 @@ export class InputController { if (runner?.hasHandlers("input")) { const result = await runner.emitInput(text, inputImages, "interactive"); if (result?.handled) { + const handledText = result.text === undefined ? "" : sanitizeText(result.text).trim(); + if (handledText) { + this.ctx.addMessageToChat({ + role: "custom", + customType: "extension-input-result", + content: handledText, + display: true, + timestamp: Date.now(), + }); + this.ctx.ui.requestRender(); + } if (this.#canModifyComposer(composer)) { this.ctx.editor.setText(""); } diff --git a/packages/coding-agent/src/runtime-mcp/types.ts b/packages/coding-agent/src/runtime-mcp/types.ts index 9173e99f42..4effeab5c4 100644 --- a/packages/coding-agent/src/runtime-mcp/types.ts +++ b/packages/coding-agent/src/runtime-mcp/types.ts @@ -217,6 +217,8 @@ export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent; export interface MCPToolCallResult { content: MCPContent[]; isError?: boolean; + /** MCP result metadata, encoded as `_meta` on the wire. */ + _meta?: Record; } // ============================================================================= diff --git a/packages/coding-agent/test/fixtures/ooo-bridge-compiled-loader.ts b/packages/coding-agent/test/fixtures/ooo-bridge-compiled-loader.ts new file mode 100644 index 0000000000..c3060adb4b --- /dev/null +++ b/packages/coding-agent/test/fixtures/ooo-bridge-compiled-loader.ts @@ -0,0 +1,18 @@ +import { loadExtensions } from "../../src/extensibility/extensions/loader"; + +const [extensionPath, cwd] = Bun.argv.slice(-2); +if (!extensionPath || !cwd) throw new Error("expected extension path and cwd"); + +const loaded = await loadExtensions([extensionPath], cwd); +const extension = loaded.extensions[0]; +const handlerCount = extension?.handlers.get("input")?.length ?? 0; +const sessionSwitchHandlerCount = extension?.handlers.get("session_switch")?.length ?? 0; +await Bun.write( + Bun.stdout, + JSON.stringify({ + errors: loaded.errors, + extensionCount: loaded.extensions.length, + handlerCount, + sessionSwitchHandlerCount, + }), +); diff --git a/packages/coding-agent/test/ooo-bridge-extension-contract.test.ts b/packages/coding-agent/test/ooo-bridge-extension-contract.test.ts index 4036db4319..2606545bb9 100644 --- a/packages/coding-agent/test/ooo-bridge-extension-contract.test.ts +++ b/packages/coding-agent/test/ooo-bridge-extension-contract.test.ts @@ -1,23 +1,29 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import type { ImageContent } from "@gajae-code/ai"; +import type { ExtensionAPI } from "@gajae-code/coding-agent"; import type { ExecResult } from "@gajae-code/coding-agent/exec/exec"; import { createExactPrefixCommandBridge, createOuroborosOooBridge, type ExtensionContext, + type ExtensionHandler, type InputEvent, + type InputEventResult, OOO_BRIDGE_RECURSION_ENV, OOO_BRIDGE_TIMEOUT_ENV, } from "@gajae-code/coding-agent/extensibility/extensions"; +import activateOooBridge from "../examples/extensions/ooo-bridge"; +import type { MCPRequestOptions, MCPServerConnection, MCPToolCallResult } from "../src/runtime-mcp"; +import * as runtimeMcpModule from "../src/runtime-mcp"; function input(text: string, source?: InputEvent["source"], images?: ImageContent[]): InputEvent { return { type: "input", text, source, images } as InputEvent; } -function context(): ExtensionContext { +function context(notify: (message: string, level: "info" | "warning" | "error") => void = () => {}): ExtensionContext { return { cwd: "/tmp", - ui: { notify: () => {} }, + ui: { notify }, } as unknown as ExtensionContext; } @@ -44,15 +50,16 @@ describe("ooo bridge extension contract", () => { vi.restoreAllMocks(); delete process.env[OOO_BRIDGE_RECURSION_ENV]; delete process.env[OOO_BRIDGE_TIMEOUT_ENV]; + delete process.env.OUROBOROS_CLI; }); - it("routes exact-prefix ooo input to ouroboros dispatch and handles exit zero", async () => { - const { handler, dispatchSpy } = createHandler(0); + it("routes exact-prefix ooo input to ouroboros dispatch and returns successful output", async () => { + const { handler, dispatchSpy } = createHandler(0, "visible output"); const ctx = context(); const result = await handler(input("ooo status", "interactive"), ctx); - expect(result).toEqual({ handled: true }); + expect(result).toEqual({ handled: true, text: "visible output" }); expect(dispatchSpy).toHaveBeenCalledWith("ouroboros", ["dispatch", "ooo status"], ctx, { timeout: undefined }); }); @@ -146,6 +153,26 @@ describe("ooo bridge extension contract", () => { expect(notifySpy).toHaveBeenCalledWith("dispatch failed", "error"); }); + it("handles a missing ouroboros executable without passing the input to the model", async () => { + const dispatcher = { run: async () => Promise.reject(new Error('Executable not found in $PATH: "ouroboros"')) }; + const notifyTarget = { notify: (_message: string, _type?: "info" | "warning" | "error") => {} }; + const notifySpy = vi.spyOn(notifyTarget, "notify"); + const handler = createExactPrefixCommandBridge({ + prefix: "ooo", + command: "ouroboros", + args: ["dispatch"], + dispatch: dispatcher.run, + }); + + expect( + await handler(input("ooo interview", "interactive"), { + ...context(), + ui: notifyTarget, + } as ExtensionContext), + ).toEqual({ handled: true }); + expect(notifySpy).toHaveBeenCalledWith('Executable not found in $PATH: "ouroboros"', "error"); + }); + it("dispatch exception or timeout is handled and notified instead of falling through", async () => { process.env[OOO_BRIDGE_TIMEOUT_ENV] = "5"; const dispatcher = { run: async () => Promise.reject(new Error("handler timed out after 5ms")) }; @@ -230,14 +257,179 @@ describe("ooo bridge extension contract", () => { handler(input("ooo two", "interactive"), context()), ]); - expect(first).toEqual({ handled: true }); - expect(second).toEqual({ handled: true }); + expect(first).toEqual({ handled: true, text: "ooo one" }); + expect(second).toEqual({ handled: true, text: "ooo two" }); expect(dispatchSpy.mock.calls.map(call => call[1])).toEqual([ ["dispatch", "ooo one"], ["dispatch", "ooo two"], ]); }); + it("ships an example that registers ooo interview through the compatible CLI override", async () => { + process.env.OUROBOROS_CLI = "/opt/ouroboros/bin/ouroboros"; + const connection = { name: "ouroboros-ooo-bridge" } as MCPServerConnection; + const connectSpy = vi.spyOn(runtimeMcpModule, "connectToServer").mockResolvedValue(connection); + const callSpy = vi.spyOn(runtimeMcpModule, "callTool").mockResolvedValue({ + content: [{ type: "text", text: "Session interview_abc123\n\nWhat should it build?" }], + _meta: { session_id: "interview_abc123", phase: "start" }, + } as MCPToolCallResult); + const registrations: Array<{ event: string; handler: unknown }> = []; + activateOooBridge({ + on(event: string, handler: unknown): void { + registrations.push({ event, handler }); + }, + pi: { createOuroborosOooBridge }, + } as unknown as ExtensionAPI); + + expect(registrations.map(registration => registration.event)).toEqual(["input", "session_switch"]); + const handler = registrations.find(registration => registration.event === "input")?.handler as ExtensionHandler< + InputEvent, + InputEventResult + >; + const ctx = context(); + + expect(await handler(input("ooo interview Build a CLI", "interactive"), ctx)).toEqual({ + handled: true, + text: "Session interview_abc123\n\nWhat should it build?", + }); + expect(connectSpy).toHaveBeenCalledWith( + "ouroboros-ooo-bridge", + { + type: "stdio", + command: "/opt/ouroboros/bin/ouroboros", + args: ["mcp", "serve", "--runtime", "gjc"], + cwd: ctx.cwd, + }, + { signal: expect.any(AbortSignal) }, + ); + expect(callSpy).toHaveBeenCalledWith( + connection, + "ouroboros_interview", + { + cwd: ctx.cwd, + initial_context: "Build a CLI", + }, + { signal: expect.any(AbortSignal) }, + ); + }); + + it("correlates ordinary answers to one interview and stops claiming input after completion", async () => { + const connection = { name: "ouroboros-ooo-bridge" } as MCPServerConnection; + const connect = vi.fn(async () => connection); + const disconnect = vi.fn(async () => {}); + const invoke = vi + .fn< + ( + _connection: MCPServerConnection, + _tool: string, + _args?: Record, + _options?: MCPRequestOptions, + ) => Promise + >() + .mockResolvedValueOnce({ + content: [{ type: "text", text: "Session interview_roundtrip\n\nWhich platforms?" }], + _meta: { session_id: "interview_roundtrip", phase: "start" }, + }) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "Interview completed. Session ID: interview_roundtrip" }], + _meta: { session_id: "interview_roundtrip", phase: "complete", completed: true }, + }); + const handler = createOuroborosOooBridge({ connect, callTool: invoke, disconnect }); + const ctx = context(); + + expect(await handler(input("ooo interview Build a CLI", "interactive"), ctx)).toEqual({ + handled: true, + text: "Session interview_roundtrip\n\nWhich platforms?", + }); + expect(await handler(input("Linux and macOS", "interactive"), ctx)).toEqual({ + handled: true, + text: "Interview completed. Session ID: interview_roundtrip", + }); + expect(invoke.mock.calls.map(call => call[2])).toEqual([ + { cwd: ctx.cwd, initial_context: "Build a CLI" }, + { cwd: ctx.cwd, session_id: "interview_roundtrip", answer: "Linux and macOS" }, + ]); + expect(connect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledWith(connection); + expect(await handler(input("normal prompt", "interactive"), ctx)).toEqual({}); + expect(invoke).toHaveBeenCalledTimes(2); + }); + + it("clears interview and cached transport after an MCP failure", async () => { + const firstConnection = { name: "first" } as MCPServerConnection; + const secondConnection = { name: "second" } as MCPServerConnection; + const connect = vi.fn(async () => (connect.mock.calls.length === 1 ? firstConnection : secondConnection)); + const disconnect = vi.fn(async () => {}); + const invoke = vi + .fn() + .mockResolvedValueOnce({ + content: [{ type: "text", text: "Session interview_failure\n\nFirst question?" }], + _meta: { session_id: "interview_failure", phase: "start" }, + }) + .mockRejectedValueOnce(new Error("dead transport")) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "Session interview_fresh\n\nFresh question?" }], + _meta: { session_id: "interview_fresh", phase: "start" }, + }); + const handler = createOuroborosOooBridge({ connect, callTool: invoke, disconnect }); + const notify = vi.fn(); + const ctx = context(notify); + + await handler(input("ooo interview Initial", "interactive"), ctx); + expect(await handler(input("answer", "interactive"), ctx)).toEqual({ handled: true }); + expect(notify).toHaveBeenCalledWith("dead transport", "error"); + expect(disconnect).toHaveBeenCalledWith(firstConnection); + expect(await handler(input("ordinary prompt", "interactive"), ctx)).toEqual({}); + await handler(input("ooo interview Fresh", "interactive"), ctx); + expect(connect).toHaveBeenCalledTimes(2); + expect(invoke.mock.calls[2]?.[2]).toEqual({ cwd: ctx.cwd, initial_context: "Fresh" }); + }); + + it("bypasses UI controls, resets session controls, and preserves ordinary answers", async () => { + const connection = { name: "controls" } as MCPServerConnection; + const invoke = vi + .fn() + .mockResolvedValueOnce({ + content: [{ type: "text", text: "Session interview_controls\n\nChoose a target?" }], + _meta: { session_id: "interview_controls", phase: "start" }, + }) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "Session interview_fresh_controls\n\nChoose again?" }], + _meta: { session_id: "interview_fresh_controls", phase: "start" }, + }) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "Interview completed. Session ID: interview_fresh_controls" }], + _meta: { session_id: "interview_fresh_controls", phase: "complete", completed: true }, + }); + const disconnect = vi.fn(async () => {}); + const handler = createOuroborosOooBridge({ + connect: vi.fn(async () => connection), + callTool: invoke, + disconnect, + }); + const ctx = context(); + + await handler(input("ooo interview Controls", "interactive"), ctx); + for (const control of ["/help", ".", "c"]) { + expect(await handler(input(control, "interactive"), ctx)).toEqual({}); + } + expect(invoke).toHaveBeenCalledTimes(1); + expect(await handler(input("/clear", "interactive"), ctx)).toEqual({}); + expect(disconnect).toHaveBeenCalledWith(connection); + expect(await handler(input("ordinary prompt", "interactive"), ctx)).toEqual({}); + + await handler(input("ooo interview Fresh controls", "interactive"), ctx); + expect(await handler(input("Linux", "interactive"), ctx)).toEqual({ + handled: true, + text: "Interview completed. Session ID: interview_fresh_controls", + }); + expect(invoke.mock.calls[2]?.[2]).toEqual({ + cwd: ctx.cwd, + session_id: "interview_fresh_controls", + answer: "Linux", + }); + }); + it("canonical ouroboros helper uses the same exact-prefix contract", async () => { const handler = createOuroborosOooBridge(); expect(await handler(input("not ooo", "interactive"), context())).toEqual({}); diff --git a/packages/coding-agent/test/ooo-bridge-installed-flow.test.ts b/packages/coding-agent/test/ooo-bridge-installed-flow.test.ts new file mode 100644 index 0000000000..f103f145bb --- /dev/null +++ b/packages/coding-agent/test/ooo-bridge-installed-flow.test.ts @@ -0,0 +1,357 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentMessage } from "@gajae-code/agent-core"; +import { Agent } from "@gajae-code/agent-core"; +import { getBundledModel } from "@gajae-code/ai"; +import { ModelRegistry } from "../src/config/model-registry"; +import { Settings } from "../src/config/settings"; +import { loadExtensions } from "../src/extensibility/extensions/loader"; +import { ExtensionRunner } from "../src/extensibility/extensions/runner"; +import { InputController } from "../src/modes/controllers/input-controller"; +import type { InteractiveModeContext } from "../src/modes/types"; +import type { MCPServerConnection, MCPToolCallResult } from "../src/runtime-mcp"; +import * as runtimeMcpModule from "../src/runtime-mcp"; +import { AgentSession } from "../src/session/agent-session"; +import { AuthStorage } from "../src/session/auth-storage"; +import { SessionManager } from "../src/session/session-manager"; + +function result(text: string, meta: Record): MCPToolCallResult { + return { + content: [{ type: "text", text }], + _meta: meta, + }; +} + +describe("installed ooo bridge flow", () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.OUROBOROS_CLI; + delete process.env.GJC_NO_TITLE; + }); + + it("renders the first question, correlates the next answer, and renders termination", async () => { + process.env.OUROBOROS_CLI = "/opt/ouroboros/bin/ouroboros"; + const connection = { name: "ouroboros-ooo-bridge" } as MCPServerConnection; + const connectSpy = vi.spyOn(runtimeMcpModule, "connectToServer").mockResolvedValue(connection); + const callSpy = vi + .spyOn(runtimeMcpModule, "callTool") + .mockResolvedValueOnce( + result("Session interview_e2e\n\nWhat platforms should the CLI support?", { + session_id: "interview_e2e", + phase: "start", + }), + ) + .mockResolvedValueOnce( + result("Interview completed. Session ID: interview_e2e", { + session_id: "interview_e2e", + phase: "complete", + completed: true, + }), + ); + const disconnectSpy = vi.spyOn(runtimeMcpModule, "disconnectServer").mockResolvedValue(); + const examplePath = path.resolve(import.meta.dirname, "../examples/extensions/ooo-bridge.ts"); + const loaded = await loadExtensions([examplePath], "/tmp/ooo-installed-flow"); + expect(loaded.errors).toEqual([]); + expect(loaded.extensions).toHaveLength(1); + const runner = new ExtensionRunner( + loaded.extensions, + loaded.runtime, + "/tmp/ooo-installed-flow", + {} as never, + {} as never, + ); + const visibleMessages: AgentMessage[] = []; + const editor = {} as InteractiveModeContext["editor"]; + const ctx = { + session: { + extensionRunner: runner, + isStreaming: false, + queuedMessageCount: 0, + }, + pendingImages: [], + hasActiveBtw: () => false, + editor, + addMessageToChat(message: AgentMessage) { + visibleMessages.push(message); + return []; + }, + ui: { requestRender: vi.fn() }, + } as unknown as InteractiveModeContext; + const controller = new InputController(ctx); + const composer = { ownsComposer: false, editor }; + + await controller.submitText("ooo interview Build a CLI", composer); + expect(visibleMessages.at(-1)).toMatchObject({ + role: "custom", + customType: "extension-input-result", + content: "Session interview_e2e\n\nWhat platforms should the CLI support?", + display: true, + }); + + await controller.submitText("Linux and macOS", composer); + expect(visibleMessages.at(-1)).toMatchObject({ + role: "custom", + customType: "extension-input-result", + content: "Interview completed. Session ID: interview_e2e", + display: true, + }); + expect(callSpy.mock.calls.map(call => call[2])).toEqual([ + { cwd: "/tmp/ooo-installed-flow", initial_context: "Build a CLI" }, + { cwd: "/tmp/ooo-installed-flow", session_id: "interview_e2e", answer: "Linux and macOS" }, + ]); + expect(connectSpy).toHaveBeenCalledWith( + "ouroboros-ooo-bridge", + { + type: "stdio", + command: "/opt/ouroboros/bin/ouroboros", + args: ["mcp", "serve", "--runtime", "gjc"], + cwd: "/tmp/ooo-installed-flow", + }, + { signal: expect.any(AbortSignal) }, + ); + expect(disconnectSpy).toHaveBeenCalledWith(connection); + expect(await runner.emitInput("ordinary prompt", undefined, "interactive")).toEqual({}); + expect(callSpy).toHaveBeenCalledTimes(2); + }); + + it("claims a second InputController submission while interview startup is pending", async () => { + const connection = { name: "startup-overlap" } as MCPServerConnection; + const startup = Promise.withResolvers(); + const answer = Promise.withResolvers(); + vi.spyOn(runtimeMcpModule, "connectToServer").mockResolvedValue(connection); + const callSpy = vi + .spyOn(runtimeMcpModule, "callTool") + .mockImplementationOnce(() => startup.promise) + .mockImplementationOnce(() => answer.promise); + vi.spyOn(runtimeMcpModule, "disconnectServer").mockResolvedValue(); + const examplePath = path.resolve(import.meta.dirname, "../examples/extensions/ooo-bridge.ts"); + const loaded = await loadExtensions([examplePath], "/tmp/ooo-startup-overlap"); + const runner = new ExtensionRunner( + loaded.extensions, + loaded.runtime, + "/tmp/ooo-startup-overlap", + {} as never, + {} as never, + ); + const editor = {} as InteractiveModeContext["editor"]; + const onInputCallback = vi.fn(); + const ctx = { + session: { extensionRunner: runner, isStreaming: false, isCompacting: false, queuedMessageCount: 0 }, + pendingImages: [], + hasActiveBtw: () => false, + editor, + addMessageToChat: vi.fn(() => []), + flushPendingBashComponents: vi.fn(), + onInputCallback, + ui: { requestRender: vi.fn() }, + } as unknown as InteractiveModeContext; + const controller = new InputController(ctx); + const composer = { ownsComposer: false, editor }; + + const firstSubmit = controller.submitText("ooo interview Slow startup", composer); + await Bun.sleep(0); + const secondSubmit = controller.submitText("Linux", composer); + let secondSettled = false; + void secondSubmit.then(() => { + secondSettled = true; + }); + await Bun.sleep(10); + expect(callSpy).toHaveBeenCalledTimes(1); + expect(secondSettled).toBe(false); + expect(onInputCallback).not.toHaveBeenCalled(); + + startup.resolve( + result("Session interview_startup_overlap\n\nWhich platform?", { + session_id: "interview_startup_overlap", + phase: "start", + }), + ); + await firstSubmit; + await Bun.sleep(0); + expect(callSpy).toHaveBeenCalledTimes(2); + expect(callSpy.mock.calls[1]?.[2]).toEqual({ + cwd: "/tmp/ooo-startup-overlap", + session_id: "interview_startup_overlap", + answer: "Linux", + }); + + answer.resolve( + result("Interview completed. Session ID: interview_startup_overlap", { + session_id: "interview_startup_overlap", + phase: "complete", + completed: true, + }), + ); + await secondSubmit; + expect(onInputCallback).not.toHaveBeenCalled(); + }); + + it("drops queued explicit starts across AgentSession switch and InputController clear resets", async () => { + process.env.GJC_NO_TITLE = "1"; + const connection = { name: "session-controls" } as MCPServerConnection; + vi.spyOn(runtimeMcpModule, "connectToServer").mockResolvedValue(connection); + const pendingBeforeSwitch = Promise.withResolvers(); + const pendingBeforeClear = Promise.withResolvers(); + const callSpy = vi + .spyOn(runtimeMcpModule, "callTool") + .mockResolvedValueOnce( + result("Session interview_before_new\n\nOld question?", { + session_id: "interview_before_new", + phase: "start", + }), + ) + .mockImplementationOnce(() => pendingBeforeSwitch.promise) + .mockResolvedValueOnce( + result("Session interview_before_clear\n\nAnother question?", { + session_id: "interview_before_clear", + phase: "start", + }), + ) + .mockImplementationOnce(() => pendingBeforeClear.promise); + const disconnectSpy = vi.spyOn(runtimeMcpModule, "disconnectServer").mockResolvedValue(); + const examplePath = path.resolve(import.meta.dirname, "../examples/extensions/ooo-bridge.ts"); + const loaded = await loadExtensions([examplePath], "/tmp/ooo-session-controls"); + const runner = new ExtensionRunner( + loaded.extensions, + loaded.runtime, + "/tmp/ooo-session-controls", + {} as never, + {} as never, + ); + const authStorage = await AuthStorage.create(":memory:"); + const modelRegistry = new ModelRegistry(authStorage); + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model"); + const sessionManager = SessionManager.inMemory("/tmp/ooo-session-controls"); + const settings = Settings.isolated(); + const session = new AgentSession({ + agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] } }), + sessionManager, + settings, + modelRegistry, + extensionRunner: runner, + }); + try { + const editor = { setText: vi.fn(), addToHistory: vi.fn() } as unknown as InteractiveModeContext["editor"]; + const onInputCallback = vi.fn(); + const ctx = { + session, + sessionManager, + settings, + pendingImages: [], + hasActiveBtw: () => false, + editor, + addMessageToChat: vi.fn(() => []), + flushPendingBashComponents: vi.fn(), + onInputCallback, + handleClearCommand: () => session.newSession(), + handleContextClearCommand: async () => { + await session.clearContext(); + }, + startPendingSubmission: ({ text }: { text: string }) => ({ text, cancelled: false, started: false }), + ui: { requestRender: vi.fn() }, + } as unknown as InteractiveModeContext; + const controller = new InputController(ctx); + const composer = { ownsComposer: false, editor }; + + await controller.submitText("ooo interview Before new", composer); + const answerBeforeSwitch = controller.submitText("pending answer before new", composer); + await Bun.sleep(0); + const queuedExplicitBeforeSwitch = controller.submitText("ooo interview queued before new", composer); + await Bun.sleep(10); + expect(callSpy).toHaveBeenCalledTimes(2); + await session.newSession(); + pendingBeforeSwitch.resolve( + result("Session interview_before_new\n\nStale successor question?", { + session_id: "interview_before_new", + phase: "answer", + }), + ); + await Promise.all([answerBeforeSwitch, queuedExplicitBeforeSwitch]); + expect(callSpy).toHaveBeenCalledTimes(2); + await controller.submitText("ordinary after new", composer); + expect(onInputCallback).toHaveBeenCalledTimes(1); + + await controller.submitText("ooo interview Before clear", composer); + const answerBeforeClear = controller.submitText("pending answer before clear", composer); + await Bun.sleep(0); + const queuedExplicitBeforeClear = controller.submitText("ooo interview queued before clear", composer); + await Bun.sleep(10); + expect(callSpy).toHaveBeenCalledTimes(4); + await controller.submitText("/clear", composer); + pendingBeforeClear.resolve( + result("Session interview_before_clear\n\nStale clear question?", { + session_id: "interview_before_clear", + phase: "answer", + }), + ); + await Promise.all([answerBeforeClear, queuedExplicitBeforeClear]); + expect(callSpy).toHaveBeenCalledTimes(4); + await controller.submitText("ordinary after clear", composer); + expect(onInputCallback).toHaveBeenCalledTimes(2); + expect(disconnectSpy).toHaveBeenCalledTimes(2); + } finally { + await session.dispose(); + authStorage.close(); + } + }); + + it.skipIf(process.platform !== "linux" || process.arch !== "x64")( + "loads the copied one-file extension from a compiled binary without peer node_modules", + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-ooo-compiled-")); + try { + const extensionDir = path.join(root, "extensions", "ouroboros-ooo-bridge"); + const projectDir = path.join(root, "project"); + await fs.mkdir(extensionDir, { recursive: true }); + await fs.mkdir(projectDir, { recursive: true }); + const installedExtension = path.join(extensionDir, "index.ts"); + const examplePath = path.resolve(import.meta.dirname, "../examples/extensions/ooo-bridge.ts"); + await Bun.write(installedExtension, await Bun.file(examplePath).arrayBuffer()); + expect(await Bun.file(path.join(extensionDir, "node_modules")).exists()).toBe(false); + + const executable = path.join(root, "compiled-loader"); + const nativeName = "pi_natives.linux-x64-modern.node"; + const nativeSource = path.resolve(import.meta.dirname, `../../natives/native/${nativeName}`); + await Bun.write(path.join(root, nativeName), await Bun.file(nativeSource).arrayBuffer()); + const fixture = path.resolve(import.meta.dirname, "fixtures/ooo-bridge-compiled-loader.ts"); + const compile = Bun.spawn( + [process.execPath, "build", fixture, "--compile", "--external", "mupdf", "--outfile", executable], + { + cwd: path.resolve(import.meta.dirname, "../../.."), + stdout: "pipe", + stderr: "pipe", + }, + ); + const [compileExit, compileStderr] = await Promise.all([ + compile.exited, + new Response(compile.stderr).text(), + ]); + expect(compileExit, compileStderr).toBe(0); + + const run = Bun.spawn([executable, installedExtension, projectDir], { + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", + }); + const [runExit, stdout, stderr] = await Promise.all([ + run.exited, + new Response(run.stdout).text(), + new Response(run.stderr).text(), + ]); + expect(runExit, stderr).toBe(0); + expect(JSON.parse(stdout)).toEqual({ + errors: [], + extensionCount: 1, + handlerCount: 1, + sessionSwitchHandlerCount: 1, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); diff --git a/packages/coding-agent/test/ooo-bridge-runner-redteam.test.ts b/packages/coding-agent/test/ooo-bridge-runner-redteam.test.ts index 28549a1881..02ebead680 100644 --- a/packages/coding-agent/test/ooo-bridge-runner-redteam.test.ts +++ b/packages/coding-agent/test/ooo-bridge-runner-redteam.test.ts @@ -3,14 +3,21 @@ import type { ImageContent } from "@gajae-code/ai"; import type { ExecResult } from "@gajae-code/coding-agent/exec/exec"; import { createExactPrefixCommandBridge, + createOuroborosOooBridge, + EXTENSION_HANDLER_TIMEOUT_MS, type Extension, + type ExtensionHandler, ExtensionRunner, type ExtensionRuntime, + type InputEvent, + type InputEventResult, OOO_BRIDGE_RECURSION_ENV, OOO_BRIDGE_TIMEOUT_ENV, + testSetExtensionHandlerTimeoutMs, } from "@gajae-code/coding-agent/extensibility/extensions"; +import type { MCPRequestOptions, MCPServerConnection, MCPToolCallResult } from "../src/runtime-mcp"; -function extensionWith(handler: ReturnType): Extension { +function extensionWith(handler: ExtensionHandler): Extension { return { path: "ooo-bridge-redteam-test", resolvedPath: "ooo-bridge-redteam-test", @@ -23,7 +30,7 @@ function extensionWith(handler: ReturnType): ExtensionRunner { +function runnerWith(handler: ExtensionHandler): ExtensionRunner { return new ExtensionRunner( [extensionWith(handler)], { flagValues: new Map(), pendingProviderRegistrations: [] } as unknown as ExtensionRuntime, @@ -38,6 +45,7 @@ describe("ooo bridge runner red-team", () => { vi.restoreAllMocks(); delete process.env[OOO_BRIDGE_RECURSION_ENV]; delete process.env[OOO_BRIDGE_TIMEOUT_ENV]; + testSetExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS); }); it("installed bridge returns handled for terminal dispatch errors instead of passing input to the model", async () => { @@ -97,4 +105,104 @@ describe("ooo bridge runner red-team", () => { expect(await runner.emitInput("ooo status", undefined, "extension")).toEqual({}); expect(dispatchSpy).toHaveBeenCalledTimes(1); }); + + it("aborts and fences an MCP call that settles after the runner timeout", async () => { + const connection = { name: "late-settlement" } as MCPServerConnection; + const deferred = Promise.withResolvers(); + let observedSignal: AbortSignal | undefined; + const invoke = vi.fn( + ( + _connection: MCPServerConnection, + _tool: string, + _args?: Record, + options?: MCPRequestOptions, + ) => { + observedSignal = options?.signal; + return deferred.promise; + }, + ); + const disconnect = vi.fn(async () => {}); + const handler = createOuroborosOooBridge({ + connect: vi.fn(async () => connection), + callTool: invoke, + disconnect, + }); + const runner = runnerWith(handler); + testSetExtensionHandlerTimeoutMs(5); + + expect(await runner.emitInput("ooo interview Slow", undefined, "interactive")).toEqual({}); + expect(observedSignal?.aborted).toBe(true); + expect(disconnect).toHaveBeenCalledWith(connection); + + deferred.resolve({ + content: [{ type: "text", text: "Session interview_late\n\nLate question?" }], + _meta: { session_id: "interview_late", phase: "start" }, + }); + await Bun.sleep(10); + + expect(await runner.emitInput("ordinary prompt", undefined, "interactive")).toEqual({}); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + it("serializes overlapping continuation answers for one interview session", async () => { + const connection = { name: "serialized-answers" } as MCPServerConnection; + const firstAnswer = Promise.withResolvers(); + const secondAnswer = Promise.withResolvers(); + const invoke = vi + .fn< + ( + _connection: MCPServerConnection, + _tool: string, + _args?: Record, + _options?: MCPRequestOptions, + ) => Promise + >() + .mockResolvedValueOnce({ + content: [{ type: "text", text: "Session interview_serial\n\nFirst question?" }], + _meta: { session_id: "interview_serial", phase: "start" }, + }) + .mockImplementationOnce(() => firstAnswer.promise) + .mockImplementationOnce(() => secondAnswer.promise); + const runner = runnerWith( + createOuroborosOooBridge({ + connect: vi.fn(async () => connection), + callTool: invoke, + disconnect: vi.fn(async () => {}), + }), + ); + + await runner.emitInput("ooo interview Serialize", undefined, "interactive"); + const first = runner.emitInput("first answer", undefined, "interactive"); + await Bun.sleep(0); + const second = runner.emitInput("second answer", undefined, "interactive"); + await Bun.sleep(10); + expect(invoke).toHaveBeenCalledTimes(2); + expect(invoke.mock.calls[1]?.[2]).toEqual({ + cwd: "/tmp", + session_id: "interview_serial", + answer: "first answer", + }); + + firstAnswer.resolve({ + content: [{ type: "text", text: "Session interview_serial\n\nSecond question?" }], + _meta: { session_id: "interview_serial", phase: "answer" }, + }); + await first; + await Bun.sleep(0); + expect(invoke).toHaveBeenCalledTimes(3); + expect(invoke.mock.calls[2]?.[2]).toEqual({ + cwd: "/tmp", + session_id: "interview_serial", + answer: "second answer", + }); + + secondAnswer.resolve({ + content: [{ type: "text", text: "Interview completed. Session ID: interview_serial" }], + _meta: { session_id: "interview_serial", phase: "complete", completed: true }, + }); + expect(await second).toEqual({ + handled: true, + text: "Interview completed. Session ID: interview_serial", + }); + }); });