@oh-my-pi/pi-coding-agent 12.14.2 → 12.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+
6
+ ### Changed
7
+
8
+ - Updated browser tool prompt to bias towards `observe` over `screenshot` by default
9
+
10
+ ## [12.15.0] - 2026-02-20
11
+
12
+ ### Added
13
+
14
+ - Added `includeDisabled` parameter to `listAuthCredentials()` to optionally retrieve disabled credentials
15
+ - Added `disableAuthCredential()` method for soft-deleting auth credentials while preserving database records
16
+
17
+ ### Changed
18
+
19
+ - Changed auth credential removal to use soft-delete (disable) instead of hard-delete when OAuth refresh fails, keeping credentials in database for audit purposes
20
+ - Changed default value of `tools.intentTracing` setting from false to true
21
+
5
22
  ## [12.14.1] - 2026-02-19
6
23
 
7
24
  ### Fixed
@@ -4854,4 +4871,4 @@ Initial public release.
4854
4871
  - Git branch display in footer
4855
4872
  - Message queueing during streaming responses
4856
4873
  - OAuth integration for Gmail and Google Calendar access
4857
- - HTML export with syntax highlighting and collapsible sections
4874
+ - HTML export with syntax highlighting and collapsible sections
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "12.14.2",
3
+ "version": "12.15.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,7 +70,6 @@
70
70
  "files": [
71
71
  "src",
72
72
  "scripts",
73
- "docs",
74
73
  "examples",
75
74
  "README.md",
76
75
  "CHANGELOG.md"
@@ -79,18 +78,19 @@
79
78
  "check": "tsgo -p tsconfig.json",
80
79
  "format-prompts": "bun scripts/format-prompts.ts",
81
80
  "generate-docs-index": "bun scripts/generate-docs-index.ts",
81
+ "prepack": "bun scripts/generate-docs-index.ts",
82
82
  "build:binary": "cd ../.. && bun --cwd=packages/stats scripts/generate-client-bundle.ts && bun --cwd=packages/natives run embed:native && bun build --compile --define PI_COMPILED=true --root . ./packages/coding-agent/src/cli.ts --outfile packages/coding-agent/dist/omp && bun --cwd=packages/natives run embed:native --reset && bun --cwd=packages/stats scripts/generate-client-bundle.ts --reset",
83
83
  "generate-template": "bun scripts/generate-template.ts",
84
84
  "test": "bun test"
85
85
  },
86
86
  "dependencies": {
87
87
  "@mozilla/readability": "0.6.0",
88
- "@oh-my-pi/omp-stats": "12.14.2",
89
- "@oh-my-pi/pi-agent-core": "12.14.2",
90
- "@oh-my-pi/pi-ai": "12.14.2",
91
- "@oh-my-pi/pi-natives": "12.14.2",
92
- "@oh-my-pi/pi-tui": "12.14.2",
93
- "@oh-my-pi/pi-utils": "12.14.2",
88
+ "@oh-my-pi/omp-stats": "12.15.0",
89
+ "@oh-my-pi/pi-agent-core": "12.15.0",
90
+ "@oh-my-pi/pi-ai": "12.15.0",
91
+ "@oh-my-pi/pi-natives": "12.15.0",
92
+ "@oh-my-pi/pi-tui": "12.15.0",
93
+ "@oh-my-pi/pi-utils": "12.15.0",
94
94
  "@sinclair/typebox": "^0.34.48",
95
95
  "@xterm/headless": "^6.0.0",
96
96
  "ajv": "^8.18.0",
@@ -6,7 +6,6 @@ import * as path from "node:path";
6
6
  const docsDir = new URL("../../../docs/", import.meta.url).pathname;
7
7
  const outputPath = new URL("../src/internal-urls/docs-index.generated.ts", import.meta.url).pathname;
8
8
 
9
-
10
9
  const glob = new Glob("**/*.md");
11
10
  const entries: string[] = [];
12
11
  for await (const relativePath of glob.scan(docsDir)) {
@@ -14,17 +13,28 @@ for await (const relativePath of glob.scan(docsDir)) {
14
13
  }
15
14
  entries.sort();
16
15
 
17
- const docs = [...entries];
18
-
19
- const mapEntries = await Promise.all(
20
- docs.map(async (relativePath) => {
21
- const content = await Bun.file(path.join(docsDir, relativePath)).text();
22
- return `\t"${relativePath}": ${JSON.stringify(content)},`;
23
- }),
16
+ const docsWithContent = await Promise.all(
17
+ entries.map(async (relativePath) => ({
18
+ relativePath,
19
+ content: await Bun.file(path.join(docsDir, relativePath)).text(),
20
+ }))
24
21
  );
25
22
 
26
-
27
- const output = `// Auto-generated by scripts/generate-docs-index.ts - DO NOT EDIT\n\nexport const EMBEDDED_DOCS: Readonly<Record<string, string>> = {\n${mapEntries.join("\n")}\n};\n\nexport const EMBEDDED_DOC_FILENAMES = Object.keys(EMBEDDED_DOCS).sort();\n`;
23
+ const filenamesLiteral = JSON.stringify(entries);
24
+
25
+ const mapEntries = docsWithContent
26
+ .map(({ relativePath, content }) => `\t${JSON.stringify(relativePath)}: ${JSON.stringify(content)},`)
27
+ .join("\n");
28
+ const output = [
29
+ "// Auto-generated by scripts/generate-docs-index.ts - DO NOT EDIT",
30
+ "",
31
+ `export const EMBEDDED_DOC_FILENAMES: readonly string[] = ${filenamesLiteral};`,
32
+ "",
33
+ `export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {`,
34
+ `${mapEntries}`,
35
+ `};`,
36
+ "",
37
+ ].join("\n");
28
38
 
29
39
  await Bun.write(outputPath, output);
30
- console.log(`Generated ${path.relative(process.cwd(), outputPath)} (${docs.length} docs)`);
40
+ console.log(`Generated ${path.relative(process.cwd(), outputPath)} (${entries.length} docs)`);
@@ -504,7 +504,7 @@ export const SETTINGS_SCHEMA = {
504
504
  },
505
505
  "tools.intentTracing": {
506
506
  type: "boolean",
507
- default: false,
507
+ default: true,
508
508
  ui: {
509
509
  tab: "tools",
510
510
  label: "Intent tracing",
@@ -1,5 +1,7 @@
1
1
  // Auto-generated by scripts/generate-docs-index.ts - DO NOT EDIT
2
2
 
3
+ export const EMBEDDED_DOC_FILENAMES: readonly string[] = ["bash-tool-runtime.md","blob-artifact-architecture.md","compaction.md","config-usage.md","custom-tools.md","environment-variables.md","extension-loading.md","extensions.md","fs-scan-cache-architecture.md","gemini-manifest-extensions.md","handoff-generation-pipeline.md","hooks.md","mcp-protocol-transports.md","mcp-runtime-lifecycle.md","mcp-server-tool-authoring.md","models.md","natives-addon-loader-runtime.md","natives-architecture.md","natives-binding-contract.md","natives-build-release-debugging.md","natives-media-system-utils.md","natives-rust-task-cancellation.md","natives-shell-pty-process.md","natives-text-search-pipeline.md","non-compaction-retry-policy.md","notebook-tool-runtime.md","plugin-manager-installer-plumbing.md","porting-from-pi-mono.md","porting-to-natives.md","provider-streaming-internals.md","python-repl.md","rpc.md","rulebook-matching-pipeline.md","sdk.md","secrets.md","session-operations-export-share-fork-resume.md","session-switching-and-recent-listing.md","session-tree-plan.md","session.md","skills.md","slash-command-internals.md","task-agent-discovery.md","theme.md","tree.md","ttsr-injection-lifecycle.md","tui-runtime-internals.md","tui.md"];
4
+
3
5
  export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
4
6
  "bash-tool-runtime.md": "# Bash tool runtime\n\nThis document describes the **`bash` tool** runtime path used by agent tool calls, from command normalization to execution, truncation/artifacts, and rendering.\n\nIt also calls out where behavior diverges in interactive TUI, print mode, RPC mode, and user-initiated bang (`!`) shell execution.\n\n## Scope and runtime surfaces\n\nThere are two different bash execution surfaces in coding-agent:\n\n1. **Tool-call surface** (`toolName: \"bash\"`): used when the model calls the bash tool.\n - Entry point: `BashTool.execute()`.\n2. **User bang-command surface** (`!cmd` from interactive input or RPC `bash` command): session-level helper path.\n - Entry point: `AgentSession.executeBash()`.\n\nBoth eventually use `executeBash()` in `src/exec/bash-executor.ts` for non-PTY execution, but only the tool-call path runs normalization/interception and tool renderer logic.\n\n## End-to-end tool-call pipeline\n\n## 1) Input normalization and parameter merge\n\n`BashTool.execute()` first normalizes the raw command via `normalizeBashCommand()`:\n\n- extracts trailing `| head -n N`, `| head -N`, `| tail -n N`, `| tail -N` into structured limits,\n- trims trailing/leading whitespace,\n- keeps internal whitespace intact.\n\nThen it merges extracted limits with explicit tool args:\n\n- explicit `head`/`tail` args override extracted values,\n- extracted values are fallback only.\n\n### Caveat\n\n`bash-normalize.ts` comments mention stripping `2>&1`, but current implementation does not remove it. Runtime behavior is still correct (stdout/stderr are already merged), but the normalization behavior is narrower than comments suggest.\n\n## 2) Optional interception (blocked-command path)\n\nIf `bashInterceptor.enabled` is true, `BashTool` loads rules from settings and runs `checkBashInterception()` against the normalized command.\n\nInterception behavior:\n\n- command is blocked **only** when:\n - regex rule matches, and\n - the suggested tool is present in `ctx.toolNames`.\n- invalid regex rules are silently skipped.\n- on block, `BashTool` throws `ToolError` with message:\n - `Blocked: ...`\n - original command included.\n\nDefault rule patterns (defined in code) target common misuses:\n\n- file readers (`cat`, `head`, `tail`, ...)\n- search tools (`grep`, `rg`, ...)\n- file finders (`find`, `fd`, ...)\n- in-place editors (`sed -i`, `perl -i`, `awk -i inplace`)\n- shell redirection writes (`echo ... > file`, heredoc redirection)\n\n### Caveat\n\n`InterceptionResult` includes `suggestedTool`, but `BashTool` currently surfaces only the message text (no structured suggested-tool field in `details`).\n\n## 3) CWD validation and timeout clamping\n\n`cwd` is resolved relative to session cwd (`resolveToCwd`), then validated via `stat`:\n\n- missing path -> `ToolError(\"Working directory does not exist: ...\")`\n- non-directory -> `ToolError(\"Working directory is not a directory: ...\")`\n\nTimeout is clamped to `[1, 3600]` seconds and converted to milliseconds.\n\n## 4) Artifact allocation + environment injection\n\nBefore execution, the tool allocates an artifact path/id (best-effort) and injects `$ARTIFACTS` env when session artifacts dir is available.\n\n- artifact allocation failure is non-fatal (execution continues without artifact spill file),\n- artifact id/path are passed into execution path for full-output persistence on truncation.\n\n## 5) PTY vs non-PTY execution selection\n\n`BashTool` chooses PTY execution only when all are true:\n\n- `bash.virtualTerminal === \"on\"`\n- `PI_NO_PTY !== \"1\"`\n- tool context has UI (`ctx.hasUI === true` and `ctx.ui` set)\n\nOtherwise it uses non-interactive `executeBash()`.\n\nThat means print mode and non-UI RPC/tool contexts always use non-PTY.\n\n## Non-interactive execution engine (`executeBash`)\n\n## Shell session reuse model\n\n`executeBash()` caches native `Shell` instances in a process-global map keyed by:\n\n- shell path,\n- configured command prefix,\n- snapshot path,\n- serialized shell env,\n- optional agent session key.\n\nFor session-level executions, `AgentSession.executeBash()` passes `sessionKey: this.sessionId`, isolating reuse per session.\n\nTool-call path does **not** pass `sessionKey`, so reuse scope is based on shell config/snapshot/env.\n\n## Shell config and snapshot behavior\n\nAt each call, executor loads settings shell config (`shell`, `env`, optional `prefix`).\n\nIf selected shell includes `bash`, it attempts `getOrCreateSnapshot()`:\n\n- snapshot captures aliases/functions/options from user rc,\n- snapshot creation is best-effort,\n- failure falls back to no snapshot.\n\nIf `prefix` is configured, command becomes:\n\n```text\n<prefix> <command>\n```\n\n## Streaming and cancellation\n\n`Shell.run()` streams chunks to callback. Executor pipes each chunk into `OutputSink` and optional `onChunk` callback.\n\nCancellation:\n\n- aborted signal triggers `shellSession.abort(...)`,\n- timeout from native result is mapped to `cancelled: true` + annotation text,\n- explicit cancellation similarly returns `cancelled: true` + annotation.\n\nNo exception is thrown inside executor for timeout/cancel; it returns structured `BashResult` and lets caller map error semantics.\n\n## Interactive PTY path (`runInteractiveBashPty`)\n\nWhen PTY is enabled, tool runs `runInteractiveBashPty()` which opens an overlay console component and drives a native `PtySession`.\n\nBehavior highlights:\n\n- xterm-headless virtual terminal renders viewport in overlay,\n- keyboard input is normalized (including Kitty sequences and application cursor mode handling),\n- `esc` while running kills the PTY session,\n- terminal resize propagates to PTY (`session.resize(cols, rows)`).\n\nEnvironment hardening defaults are injected for unattended runs:\n\n- pagers disabled (`PAGER=cat`, `GIT_PAGER=cat`, etc.),\n- editor prompts disabled (`GIT_EDITOR=true`, `EDITOR=true`, ...),\n- terminal/auth prompts reduced (`GIT_TERMINAL_PROMPT=0`, `SSH_ASKPASS=/usr/bin/false`, `CI=1`),\n- package-manager/tool automation flags for non-interactive behavior.\n\nPTY output is normalized (`CRLF`/`CR` to `LF`, `sanitizeText`) and written into `OutputSink`, including artifact spill support.\n\nOn PTY startup/runtime error, sink receives `PTY error: ...` line and command finalizes with undefined exit code.\n\n## Output handling: streaming, truncation, artifact spill\n\nBoth PTY and non-PTY paths use `OutputSink`.\n\n## OutputSink semantics\n\n- keeps an in-memory UTF-8-safe tail buffer (`DEFAULT_MAX_BYTES`, currently 50KB),\n- tracks total bytes/lines seen,\n- if artifact path exists and output overflows (or file already active), writes full stream to artifact file,\n- when memory threshold overflows, trims in-memory buffer to tail (UTF-8 boundary safe),\n- marks `truncated` when overflow/file spill occurs.\n\n`dump()` returns:\n\n- `output` (possibly annotated prefix),\n- `truncated`,\n- `totalLines/totalBytes`,\n- `outputLines/outputBytes`,\n- `artifactId` if artifact file was active.\n\n### Long-output caveat\n\nRuntime truncation is byte-threshold based in `OutputSink` (50KB default). It does not enforce a hard 2000-line cap in this code path.\n\n## Live tool updates\n\nFor non-PTY execution, `BashTool` uses a separate `TailBuffer` for partial updates and emits `onUpdate` snapshots while command is running.\n\nFor PTY execution, live rendering is handled by custom UI overlay, not by `onUpdate` text chunks.\n\n## Result shaping, metadata, and error mapping\n\nAfter execution:\n\n1. `cancelled` handling:\n - if abort signal is aborted -> throw `ToolAbortError` (abort semantics),\n - else -> throw `ToolError` (treated as tool failure).\n2. PTY `timedOut` -> throw `ToolError`.\n3. apply head/tail filters to final output text (`applyHeadTail`, head then tail).\n4. empty output becomes `(no output)`.\n5. attach truncation metadata via `toolResult(...).truncationFromSummary(result, { direction: \"tail\" })`.\n6. exit-code mapping:\n - missing exit code -> `ToolError(\"... missing exit status\")`\n - non-zero exit -> `ToolError(\"... Command exited with code N\")`\n - zero exit -> success result.\n\nSuccess payload structure:\n\n- `content`: text output,\n- `details.meta.truncation` when truncated, including:\n - `direction`, `truncatedBy`, total/output line+byte counts,\n - `shownRange`,\n - `artifactId` when available.\n\nBecause built-in tools are wrapped with `wrapToolWithMetaNotice()`, truncation notice text is appended to final text content automatically (for example: `Full: artifact://<id>`).\n\n## Rendering paths\n\n## Tool-call renderer (`bashToolRenderer`)\n\n`bashToolRenderer` is used for tool-call messages (`toolCall` / `toolResult`):\n\n- collapsed mode shows visual-line-truncated preview,\n- expanded mode shows all currently available output text,\n- warning line includes truncation reason and `artifact://<id>` when truncated,\n- timeout value (from args) is shown in footer metadata line.\n\n### Caveat: full artifact expansion\n\n`BashRenderContext` has `isFullOutput`, but current renderer context builder does not set it for bash tool results. Expanded view still uses the text already in result content (tail/truncated output) unless another caller provides full artifact content.\n\n## User bang-command component (`BashExecutionComponent`)\n\n`BashExecutionComponent` is for user `!` commands in interactive mode (not model tool calls):\n\n- streams chunks live,\n- collapsed preview keeps last 20 logical lines,\n- line clamp at 4000 chars per line,\n- shows truncation + artifact warnings when metadata is present,\n- marks cancelled/error/exit state separately.\n\nThis component is wired by `CommandController.handleBashCommand()` and fed from `AgentSession.executeBash()`.\n\n## Mode-specific behavior differences\n\n| Surface | Entry path | PTY eligible | Live output UX | Error surfacing |\n| ------------------------------ | ----------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------ |\n| Interactive tool call | `BashTool.execute` | Yes, when `bash.virtualTerminal=on` and UI exists and `PI_NO_PTY!=1` | PTY overlay (interactive) or streamed tail updates | Tool errors become `toolResult.isError` |\n| Print mode tool call | `BashTool.execute` | No (no UI context) | No TUI overlay; output appears in event stream/final assistant text flow | Same tool error mapping |\n| RPC tool call (agent tooling) | `BashTool.execute` | Usually no UI -> non-PTY | Structured tool events/results | Same tool error mapping |\n| Interactive bang command (`!`) | `AgentSession.executeBash` + `BashExecutionComponent` | No (uses executor directly) | Dedicated bash execution component | Controller catches exceptions and shows UI error |\n| RPC `bash` command | `rpc-mode` -> `session.executeBash` | No | Returns `BashResult` directly | Consumer handles returned fields |\n\n## Operational caveats\n\n- Interceptor only blocks commands when suggested tool is currently available in context.\n- If artifact allocation fails, truncation still occurs but no `artifact://` back-reference is available.\n- Shell session cache has no explicit eviction in this module; lifetime is process-scoped.\n- PTY and non-PTY timeout surfaces differ:\n - PTY exposes explicit `timedOut` result field,\n - non-PTY maps timeout into `cancelled + annotation` summary.\n\n## Implementation files\n\n- [`src/tools/bash.ts`](../packages/coding-agent/src/tools/bash.ts) — tool entrypoint, normalization/interception, PTY/non-PTY selection, result/error mapping, bash tool renderer.\n- [`src/tools/bash-normalize.ts`](../packages/coding-agent/src/tools/bash-normalize.ts) — command normalization and post-run head/tail filtering.\n- [`src/tools/bash-interceptor.ts`](../packages/coding-agent/src/tools/bash-interceptor.ts) — interceptor rule matching and blocked-command messages.\n- [`src/exec/bash-executor.ts`](../packages/coding-agent/src/exec/bash-executor.ts) — non-PTY executor, shell session reuse, cancellation wiring, output sink integration.\n- [`src/tools/bash-interactive.ts`](../packages/coding-agent/src/tools/bash-interactive.ts) — PTY runtime, overlay UI, input normalization, non-interactive env defaults.\n- [`src/session/streaming-output.ts`](../packages/coding-agent/src/session/streaming-output.ts) — `OutputSink` truncation/artifact spill and summary metadata.\n- [`src/tools/output-utils.ts`](../packages/coding-agent/src/tools/output-utils.ts) — artifact allocation helpers and streaming tail buffer.\n- [`src/tools/output-meta.ts`](../packages/coding-agent/src/tools/output-meta.ts) — truncation metadata shape + notice injection wrapper.\n- [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts) — session-level `executeBash`, message recording, abort lifecycle.\n- [`src/modes/components/bash-execution.ts`](../packages/coding-agent/src/modes/components/bash-execution.ts) — interactive `!` command execution component.\n- [`src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.ts) — wiring for interactive `!` command UI stream/update completion.\n- [`src/modes/rpc/rpc-mode.ts`](../packages/coding-agent/src/modes/rpc/rpc-mode.ts) — RPC `bash` and `abort_bash` command surface.\n- [`src/internal-urls/artifact-protocol.ts`](../packages/coding-agent/src/internal-urls/artifact-protocol.ts) — `artifact://<id>` resolution.\n",
5
7
  "blob-artifact-architecture.md": "# Blob and artifact storage architecture\n\nThis document describes how coding-agent stores large/binary payloads outside session JSONL, how truncated tool output is persisted, and how internal URLs (`artifact://`, `agent://`) resolve back to stored data.\n\n## Why two storage systems exist\n\nThe runtime uses two different persistence mechanisms for different data shapes:\n\n- **Content-addressed blobs** (`blob:sha256:<hash>`): global, binary-oriented storage used to externalize large image base64 payloads from persisted session entries.\n- **Session-scoped artifacts** (files under `<sessionFile-without-.jsonl>/`): per-session text files used for full tool outputs and subagent outputs.\n\nThey are intentionally separate:\n\n- blob storage optimizes deduplication and stable references by content hash,\n- artifact storage optimizes append-only session tooling and human/tool retrieval by local IDs.\n\n## Storage boundaries and on-disk layout\n\n## Blob store boundary (global)\n\n`SessionManager` constructs `BlobStore(getBlobsDir())`, so blob files live in a shared global blob directory (not in a session folder).\n\nBlob file naming:\n\n- file path: `<blobsDir>/<sha256-hex>`\n- no extension\n- reference string stored in entries: `blob:sha256:<sha256-hex>`\n\nImplications:\n\n- same binary content across sessions resolves to the same hash/path,\n- writes are idempotent at the content level,\n- blobs can outlive any individual session file.\n\n## Artifact boundary (session-local)\n\n`ArtifactManager` derives artifact directory from session file path:\n\n- session file: `.../<timestamp>_<sessionId>.jsonl`\n- artifacts directory: `.../<timestamp>_<sessionId>/` (strip `.jsonl`)\n\nArtifact types share this directory:\n\n- truncated tool output files: `<numericId>.<toolType>.log` (for `artifact://`)\n- subagent output files: `<outputId>.md` (for `agent://`)\n\n## ID and name allocation schemes\n\n## Blob IDs: content hash\n\n`BlobStore.put()` computes SHA-256 over raw binary bytes and returns:\n\n- `hash`: hex digest,\n- `path`: `<blobsDir>/<hash>`,\n- `ref`: `blob:sha256:<hash>`.\n\nNo session-local counter is used.\n\n## Artifact IDs: session-local monotonic integer\n\n`ArtifactManager` scans existing `*.log` artifact files on first use to find max existing numeric ID and sets `nextId = max + 1`.\n\nAllocation behavior:\n\n- file format: `{id}.{toolType}.log`\n- IDs are sequential strings (`\"0\"`, `\"1\"`, ...)\n- resume does not overwrite existing artifacts because scan happens before allocation.\n\nIf artifact directory is missing, scanning yields empty list and allocation starts from `0`.\n\n## Agent output IDs (`agent://`)\n\n`AgentOutputManager` allocates IDs for subagent outputs as `<index>-<requestedId>` (optionally nested under parent prefix, e.g. `0-Parent.1-Child`). It scans existing `.md` files on initialization to continue from the next index on resume.\n\n## Persistence dataflow\n\n## 1) Session entry persistence rewrite path\n\nBefore session entries are written (`#rewriteFile` / incremental persist), `SessionManager` calls `prepareEntryForPersistence()` (via `truncateForPersistence`).\n\nKey behaviors:\n\n1. **Large string truncation**: oversized strings are cut and suffixed with `\"[Session persistence truncated large content]\"`.\n2. **Transient field stripping**: `partialJson` and `jsonlEvents` are removed from persisted entries.\n3. **Image externalization to blobs**:\n - only applies to image blocks in `content` arrays,\n - only when `data` is not already a blob ref,\n - only when base64 length is at least threshold (`BLOB_EXTERNALIZE_THRESHOLD = 1024`),\n - replaces inline base64 with `blob:sha256:<hash>`.\n\nThis keeps session JSONL compact while preserving recoverability.\n\n## 2) Session load rehydration path\n\nWhen opening a session (`setSessionFile`), after migrations, `SessionManager` runs `resolveBlobRefsInEntries()`.\n\nFor each message/custom-message image block with `blob:sha256:<hash>`:\n\n- reads blob bytes from blob store,\n- converts bytes back to base64,\n- mutates in-memory entry to inline base64 for runtime consumers.\n\nIf blob is missing:\n\n- `resolveImageData()` logs warning,\n- returns original ref string unchanged,\n- load continues (no hard crash).\n\n## 3) Tool output spill/truncation path\n\n`OutputSink` powers streaming output in bash/python/ssh and related executors.\n\nBehavior:\n\n1. Every chunk is sanitized and appended to in-memory tail buffer.\n2. When in-memory bytes exceed spill threshold (`DEFAULT_MAX_BYTES`, 50KB), sink marks output truncated.\n3. If an artifact path is available, sink opens a file writer and writes:\n - existing buffered content once,\n - all subsequent chunks.\n4. In-memory buffer is always trimmed to tail window for display.\n5. `dump()` returns summary including `artifactId` only when file sink was successfully created.\n\nPractical effect:\n\n- UI/tool return shows truncated tail,\n- full output is preserved in artifact file and referenced as `artifact://<id>`.\n\nIf file sink creation fails (I/O error, missing path, etc.), sink silently falls back to in-memory truncation only; full output is not persisted.\n\n## URL access model\n\n## `blob:` references\n\n`blob:sha256:<hash>` is a persistence reference inside session entry payloads, not an internal URL scheme handled by the router. Resolution is done by `SessionManager` during session load.\n\n## `artifact://<id>`\n\nHandled by `ArtifactProtocolHandler`:\n\n- requires active session artifact directory,\n- ID must be numeric,\n- resolves by matching filename prefix `<id>.`,\n- returns raw text (`text/plain`) from the matched `.log` file,\n- when missing, error includes list of available artifact IDs.\n\nMissing directory behavior:\n\n- if artifacts directory does not exist, throws `No artifacts directory found`.\n\n## `agent://<id>`\n\nHandled by `AgentProtocolHandler` over `<artifactsDir>/<id>.md`:\n\n- plain form returns markdown text,\n- `/path` or `?q=` forms perform JSON extraction,\n- path and query extraction cannot be combined,\n- if extraction requested, file content must parse as JSON.\n\nMissing directory behavior:\n\n- throws `No artifacts directory found`.\n\nMissing output behavior:\n\n- throws `Not found: <id>` with available IDs from existing `.md` files.\n\nRead tool integration:\n\n- `read` supports offset/limit pagination for non-extraction internal URL reads,\n- rejects `offset/limit` when `agent://` extraction is used.\n\n## Resume, fork, and move semantics\n\n## Resume\n\n- `ArtifactManager` scans existing `{id}.*.log` files on first allocation and continues numbering.\n- `AgentOutputManager` scans existing `.md` output IDs and continues numbering.\n- `SessionManager` rehydrates blob refs to base64 on load.\n\n## Fork\n\n`SessionManager.fork()` creates a new session file with new session ID and `parentSession` link, then returns old/new file paths. Artifact copying is handled by `AgentSession.fork()`:\n\n- attempts recursive copy of old artifact directory to new artifact directory,\n- missing old directory is tolerated,\n- non-ENOENT copy errors are logged as warnings and fork still completes.\n\nID implications after fork:\n\n- if copy succeeded, artifact counters in new session continue after max copied ID,\n- if copy failed/skipped, new session artifact IDs start from `0`.\n\nBlob implications after fork:\n\n- blobs are global and content-addressed, so no blob directory copy is required.\n\n## Move to new cwd\n\n`SessionManager.moveTo()` renames both session file and artifact directory to the new default session directory, with rollback logic if a later step fails. This preserves artifact identity while relocating session scope.\n\n## Failure handling and fallback paths\n\n| Case | Behavior |\n| --- | --- |\n| Blob file missing during rehydration | Warn and keep `blob:sha256:` ref string in-memory |\n| Blob read ENOENT via `BlobStore.get` | Returns `null` |\n| Artifact directory missing (`ArtifactManager.listFiles`) | Returns empty list (allocation can start fresh) |\n| Artifact directory missing (`artifact://` / `agent://`) | Throws explicit `No artifacts directory found` |\n| Artifact ID not found | Throws with available IDs listing |\n| OutputSink artifact writer init fails | Continues with tail-only truncation (no full-output artifact) |\n| No session file (some task paths) | Task tool falls back to temp artifacts directory for subagent outputs |\n\n## Binary blob externalization vs text-output artifacts\n\n- **Blob externalization** is for binary image payloads inside persisted session entry content; it replaces inline base64 in JSONL with stable content refs.\n- **Artifacts** are plain text files for execution output and subagent output; they are addressable by session-local IDs through internal URLs.\n\nThe two systems intersect only indirectly (both reduce session JSONL bloat) but have different identity, lifetime, and retrieval paths.\n\n## Implementation files\n\n- [`src/session/blob-store.ts`](../packages/coding-agent/src/session/blob-store.ts) — blob reference format, hashing, put/get, externalize/resolve helpers.\n- [`src/session/artifacts.ts`](../packages/coding-agent/src/session/artifacts.ts) — session artifact directory model and numeric artifact ID allocation.\n- [`src/session/streaming-output.ts`](../packages/coding-agent/src/session/streaming-output.ts) — `OutputSink` truncation/spill-to-file behavior and summary metadata.\n- [`src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts) — persistence transforms, blob rehydration on load, session fork/move interactions.\n- [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts) — artifact directory copy during interactive fork.\n- [`src/tools/output-utils.ts`](../packages/coding-agent/src/tools/output-utils.ts) — tool artifact manager bootstrap and per-tool artifact path allocation.\n- [`src/internal-urls/artifact-protocol.ts`](../packages/coding-agent/src/internal-urls/artifact-protocol.ts) — `artifact://` resolver.\n- [`src/internal-urls/agent-protocol.ts`](../packages/coding-agent/src/internal-urls/agent-protocol.ts) — `agent://` resolver + JSON extraction.\n- [`src/sdk.ts`](../packages/coding-agent/src/sdk.ts) — internal URL router wiring and artifacts-dir resolver.\n- [`src/task/output-manager.ts`](../packages/coding-agent/src/task/output-manager.ts) — session-scoped agent output ID allocation for `agent://`.\n- [`src/task/executor.ts`](../packages/coding-agent/src/task/executor.ts) — subagent output artifact writes (`<id>.md`) and temp artifact directory fallback.",
@@ -49,5 +51,3 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
49
51
  "tui-runtime-internals.md": "# TUI runtime internals\n\nThis document maps the non-theme runtime path from terminal input to rendered output in interactive mode. It focuses on behavior in `packages/tui` and its integration from `packages/coding-agent` controllers.\n\n## Runtime layers and ownership\n\n- **`packages/tui` engine**: terminal lifecycle, stdin normalization, focus routing, render scheduling, differential painting, overlay composition, hardware cursor placement.\n- **`packages/coding-agent` interactive mode**: builds component tree, binds editor callbacks and keymaps, reacts to agent/session events, and translates domain state (streaming, tool execution, retries, plan mode) into UI components.\n\nBoundary rule: the TUI engine is message-agnostic. It only knows `Component.render(width)`, `handleInput(data)`, focus, and overlays. Agent semantics stay in interactive controllers.\n\n## Implementation files\n\n- [`../src/modes/interactive-mode.ts`](../packages/coding-agent/src/modes/interactive-mode.ts)\n- [`../src/modes/controllers/event-controller.ts`](../packages/coding-agent/src/modes/controllers/event-controller.ts)\n- [`../src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts)\n- [`../src/modes/components/custom-editor.ts`](../packages/coding-agent/src/modes/components/custom-editor.ts)\n- [`../../tui/src/tui.ts`](../packages/tui/src/tui.ts)\n- [`../../tui/src/terminal.ts`](../packages/tui/src/terminal.ts)\n- [`../../tui/src/editor-component.ts`](../packages/tui/src/editor-component.ts)\n- [`../../tui/src/stdin-buffer.ts`](../packages/tui/src/stdin-buffer.ts)\n- [`../../tui/src/components/loader.ts`](../packages/tui/src/components/loader.ts)\n\n## Boot and component tree assembly\n\n`InteractiveMode` constructs `TUI(new ProcessTerminal(), showHardwareCursor)` and creates persistent containers:\n\n- `chatContainer`\n- `pendingMessagesContainer`\n- `statusContainer`\n- `todoContainer`\n- `statusLine`\n- `editorContainer` (holds `CustomEditor`)\n\n`init()` wires the tree in that order, focuses the editor, registers input handlers via `InputController`, starts TUI, and requests a forced render.\n\nA forced render (`requestRender(true)`) resets previous-line caches and cursor bookkeeping before repainting.\n\n## Terminal lifecycle and stdin normalization\n\n`ProcessTerminal.start()`:\n\n1. Enables raw mode and bracketed paste.\n2. Attaches resize handler.\n3. Creates a `StdinBuffer` to split partial escape chunks into complete sequences.\n4. Queries Kitty keyboard protocol support (`CSI ? u`), then enables protocol flags if supported.\n5. On Windows, attempts VT input enablement via `kernel32` mode flags.\n\n`StdinBuffer` behavior:\n\n- Buffers fragmented escape sequences (CSI/OSC/DCS/APC/SS3).\n- Emits `data` only when a sequence is complete or timeout-flushed.\n- Detects bracketed paste and emits a `paste` event with raw pasted text.\n\nThis prevents partial escape chunks from being misinterpreted as normal keypresses.\n\n## Input routing and focus model\n\nInput path:\n\n`stdin -> ProcessTerminal -> StdinBuffer -> TUI.#handleInput -> focusedComponent.handleInput`\n\nRouting details:\n\n1. TUI runs registered input listeners first (`addInputListener`), allowing consume/transform behavior.\n2. TUI handles global debug shortcut (`shift+ctrl+d`) before component dispatch.\n3. If focused component belongs to an overlay that is now hidden/invisible, TUI reassigns focus to next visible overlay or saved pre-overlay focus.\n4. Key release events are filtered unless focused component sets `wantsKeyRelease = true`.\n5. After dispatch, TUI schedules render.\n\n`setFocus()` also toggles `Focusable.focused`, which controls whether components emit `CURSOR_MARKER` for hardware cursor placement.\n\n## Key handling split: editor vs controller\n\n`CustomEditor` intercepts high-priority combos first (escape, ctrl-c/d/z, ctrl-v, ctrl-p variants, ctrl-t, alt-up, extension custom keys) and delegates the rest to base `Editor` behavior (text editing, history, autocomplete, cursor movement).\n\n`InputController.setupKeyHandlers()` then binds editor callbacks to mode actions:\n\n- cancellation / mode exits on `Escape`\n- shutdown on double `Ctrl+C` or empty-editor `Ctrl+D`\n- suspend/resume on `Ctrl+Z`\n- slash-command and selector hotkeys\n- follow-up/dequeue toggles and expansion toggles\n\nThis keeps key parsing/editor mechanics in `packages/tui` and mode semantics in coding-agent controllers.\n\n## Render loop and diffing strategy\n\n`TUI.requestRender()` is debounced to one render per tick using `process.nextTick`. Multiple state changes in the same turn coalesce.\n\n`#doRender()` pipeline:\n\n1. Render root component tree to `newLines`.\n2. Composite visible overlays (if any).\n3. Extract and strip `CURSOR_MARKER` from visible viewport lines.\n4. Append segment reset suffixes for non-image lines.\n5. Choose full repaint vs differential patch:\n - first frame\n - width change\n - shrink with `clearOnShrink` enabled and no overlays\n - edits above previous viewport\n6. For differential updates, patch only changed line range and clear stale trailing lines when needed.\n7. Reposition hardware cursor for IME support.\n\nRender writes use synchronized output mode (`CSI ? 2026 h/l`) to reduce flicker/tearing.\n\n## Render safety constraints\n\nCritical safety checks in `TUI`:\n\n- Non-image rendered lines must not exceed terminal width; overflow throws and writes crash diagnostics.\n- Overlay compositing includes defensive truncation and post-composite width verification.\n- Width changes force full redraw because wrapping semantics change.\n- Cursor position is clamped before movement.\n\nThese constraints are runtime enforcement, not just conventions.\n\n## Resize handling\n\nResize events are event-driven from `ProcessTerminal` to `TUI.requestRender()`.\n\nEffects:\n\n- Any width change triggers full redraw.\n- Viewport/top tracking (`#previousViewportTop`, `#maxLinesRendered`) avoids invalid relative cursor math when content or terminal size changes.\n- Overlay visibility can depend on terminal dimensions (`OverlayOptions.visible`); focus is corrected when overlays become non-visible after resize.\n\n## Streaming and incremental UI updates\n\n`EventController` subscribes to `AgentSessionEvent` and updates UI incrementally:\n\n- `agent_start`: starts loader in `statusContainer`.\n- `message_start` assistant: creates `streamingComponent` and mounts it.\n- `message_update`: updates streaming assistant content; creates/updates tool execution components as tool calls appear.\n- `tool_execution_update/end`: updates tool result components and completion state.\n- `message_end`: finalizes assistant stream, handles aborted/error annotations, marks pending tool args complete on normal stop.\n- `agent_end`: stops loaders, clears transient stream state, flushes deferred model switch, issues completion notification if backgrounded.\n\nRead-tool grouping is intentionally stateful (`#lastReadGroup`) to coalesce consecutive read tool calls into one visual block until a non-read break occurs.\n\n## Status and loader orchestration\n\nStatus lane ownership:\n\n- `statusContainer` holds transient loaders (`loadingAnimation`, `autoCompactionLoader`, `retryLoader`).\n- `statusLine` renders persistent status/hooks/plan indicators and drives editor top border updates.\n\nLoader behavior:\n\n- `Loader` updates every 80ms via interval and requests render each frame.\n- Escape handlers are temporarily overridden during auto-compaction and auto-retry to cancel those operations.\n- On end/cancel paths, controllers restore prior escape handlers and stop/clear loader components.\n\n## Mode transitions and backgrounding\n\n### Bash/Python input modes\n\nInput text prefixes toggle editor border mode flags:\n\n- `!` -> bash mode\n- `$` (non-template literal prefix) -> python mode\n\nEscape exits inactive mode by clearing editor text and restoring border color; when execution is active, escape aborts the running task instead.\n\n### Plan mode\n\n`InteractiveMode` tracks plan mode flags, status-line state, active tools, and model switching. Enter/exit updates session mode entries and status/UI state, including deferred model switch if streaming is active.\n\n### Suspend/resume (`Ctrl+Z`)\n\n`InputController.handleCtrlZ()`:\n\n1. Registers one-shot `SIGCONT` handler to restart TUI and force render.\n2. Stops TUI before suspend.\n3. Sends `SIGTSTP` to process group.\n\n### Background mode (`/background` or `/bg`)\n\n`handleBackgroundCommand()`:\n\n- Rejects when idle.\n- Switches tool UI context to non-interactive (`hasUI=false`) so interactive UI tools fail fast.\n- Stops loaders/status line and unsubscribes foreground event handler.\n- Subscribes background event handler (primarily waits for `agent_end`).\n- Stops TUI and sends `SIGTSTP` (POSIX job control path).\n\nOn `agent_end` in background with no queued work, controller sends completion notification and shuts down.\n\n## Cancellation paths\n\nPrimary cancellation inputs:\n\n- `Escape` during active stream loader: restores queued messages to editor and aborts agent.\n- `Escape` during bash/python execution: aborts running command.\n- `Escape` during auto-compaction/retry: invokes dedicated abort methods through temporary escape handlers.\n- `Ctrl+C` single press: clear editor; double press within 500ms: shutdown.\n\nCancellation is state-conditional; same key can mean abort, mode-exit, selector trigger, or no-op depending on runtime state.\n\n## Event-driven vs throttled behavior\n\nEvent-driven updates:\n\n- Agent session events (`EventController`)\n- Key input callbacks (`InputController`)\n- terminal resize callback\n- theme/branch watchers in `InteractiveMode`\n\nThrottled/debounced paths:\n\n- TUI rendering is tick-debounced (`requestRender` coalescing).\n- Loader animation is fixed-interval (80ms), each frame requesting render.\n- Editor autocomplete updates (inside `Editor`) use debounce timers, reducing recompute churn during typing.\n\nThe runtime therefore mixes event-driven state transitions with bounded render cadence to keep interactivity responsive without repaint storms.\n",
50
52
  "tui.md": "# TUI integration for extensions and custom tools\n\nThis document covers the **current** TUI contract used by `packages/coding-agent` and `packages/tui` for extension UI, custom tool UI, and custom renderers.\n\n## What this subsystem is\n\nThe runtime has two layers:\n\n- **Rendering engine (`packages/tui`)**: differential terminal renderer, input dispatch, focus, overlays, cursor placement.\n- **Integration layer (`packages/coding-agent`)**: mounts extension/custom-tool components, wires keybindings/theme, and restores editor state.\n\n## Runtime behavior by mode\n\n| Mode | `ctx.ui.custom(...)` availability | Notes |\n| --- | --- | --- |\n| Interactive TUI | Supported | Component is mounted in the editor area, focused, and must call `done(result)` to resolve. |\n| Background/headless | Not interactive | UI context is no-op (`hasUI === false`). |\n| RPC mode | Not supported | `custom()` returns `Promise<never>` and does not mount TUI components. |\n\nIf your extension/tool can run in non-interactive mode, guard with `ctx.hasUI` / `pi.hasUI`.\n\n## Core component contract (`@oh-my-pi/pi-tui`)\n\n`packages/tui/src/tui.ts` defines:\n\n```ts\nexport interface Component {\n render(width: number): string[];\n handleInput?(data: string): void;\n wantsKeyRelease?: boolean;\n invalidate(): void;\n}\n```\n\n`Focusable` is separate:\n\n```ts\nexport interface Focusable {\n focused: boolean;\n}\n```\n\nCursor behavior uses `CURSOR_MARKER` (not `getCursorPosition`). Focused components emit the marker in rendered text; `TUI` extracts it and positions the hardware cursor.\n\n## Rendering constraints (terminal safety)\n\nYour `render(width)` output must be terminal-safe:\n\n1. **Never exceed `width` on any line**. The renderer throws if a non-image line overflows.\n2. **Measure visual width**, not string length: use `visibleWidth()`.\n3. **Truncate/wrap ANSI-aware text** with `truncateToWidth()` / `wrapTextWithAnsi()`.\n4. **Sanitize tabs/content** from external sources using `replaceTabs()` (and higher-level sanitizers in coding-agent render paths).\n\nMinimal pattern:\n\n```ts\nimport { replaceTabs, truncateToWidth } from \"@oh-my-pi/pi-tui\";\n\nrender(width: number): string[] {\n return this.lines.map(line => truncateToWidth(replaceTabs(line), width));\n}\n```\n\n## Input handling and keybindings\n\n### Raw key matching\n\nUse `matchesKey(data, \"...\")` for navigation keys and combos.\n\n### Respect user-configured app keybindings\n\nExtension UI factories receive a `KeybindingsManager` (interactive mode) so you can honor mapped actions instead of hardcoding keys:\n\n```ts\nif (keybindings.matches(data, \"interrupt\")) {\n done(undefined);\n return;\n}\n```\n\n### Key release/repeat events\n\nKey release events are filtered unless your component sets:\n\n```ts\nwantsKeyRelease = true;\n```\n\nThen use `isKeyRelease()` / `isKeyRepeat()` if needed.\n\n## Focus, overlays, and cursor\n\n- `TUI.setFocus(component)` routes input to that component.\n- Overlay APIs exist in `TUI` (`showOverlay`, `OverlayHandle`), but extension `ctx.ui.custom` mounting in interactive mode currently replaces the editor component area directly.\n- The `custom(..., options?: { overlay?: boolean })` option exists in extension types; interactive extension mounting currently ignores this option.\n\n## Mount points and return contracts\n\n## 1) Extension UI (`ExtensionUIContext`)\n\nCurrent signature (`extensibility/extensions/types.ts`):\n\n```ts\ncustom<T>(\n factory: (\n tui: TUI,\n theme: Theme,\n keybindings: KeybindingsManager,\n done: (result: T) => void,\n ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,\n options?: { overlay?: boolean },\n): Promise<T>\n```\n\nBehavior in interactive mode (`extension-ui-controller.ts`):\n\n- Saves editor text.\n- Replaces editor component with your component.\n- Focuses your component.\n- On `done(result)`: calls `component.dispose?.()`, restores editor + text, focuses editor, resolves promise.\n\nSo `done(...)` is mandatory for completion.\n\n## 2) Hook/custom-tool UI context (legacy typing)\n\n`HookUIContext.custom` is typed as `(tui, theme, done)` in hook/custom-tool types.\nUnderlying interactive implementation calls factories with `(tui, theme, keybindings, done)`. JS consumers can use the extra arg; type-level compatibility still reflects the 3-arg legacy signature.\n\nCustom tools typically use the same UI entrypoint via the factory-scoped `pi.ui` object, then return the selected value in normal tool content:\n\n```ts\nasync execute(toolCallId, params, onUpdate, ctx, signal) {\n if (!pi.hasUI) {\n return { content: [{ type: \"text\", text: \"UI unavailable\" }] };\n }\n\n const picked = await pi.ui.custom<string | undefined>((tui, theme, done) => {\n const component = new MyPickerComponent(done, signal);\n return component;\n });\n\n return { content: [{ type: \"text\", text: picked ? `Picked: ${picked}` : \"Cancelled\" }] };\n}\n```\n\n\n## 3) Custom tool call/result renderers\n\nCustom tools and extension tools can return components from:\n\n- `renderCall(args, theme)`\n- `renderResult(result, options, theme, args?)`\n\n`options` currently includes:\n\n- `expanded: boolean`\n- `isPartial: boolean`\n- `spinnerFrame?: number`\n\nThese renderers are mounted by `ToolExecutionComponent`.\n\n## Lifecycle and cancellation\n\n- `dispose()` is optional at type level but should be implemented when you own timers, subprocesses, watchers, sockets, or overlays.\n- `done(...)` should be called exactly once from your component flow.\n- For cancellable long-running UI, pair `CancellableLoader` with `AbortSignal` and call `done(...)` from `onAbort`.\n\nExample cancellation pattern:\n\n```ts\nconst loader = new CancellableLoader(tui, theme.fg(\"accent\"), theme.fg(\"muted\"), \"Working...\");\nloader.onAbort = () => done(undefined);\nvoid doWork(loader.signal).then(result => done(result));\nreturn loader;\n```\n\n## Realistic custom component example (extension command)\n\n```ts\nimport type { Component } from \"@oh-my-pi/pi-tui\";\nimport { SelectList, matchesKey, replaceTabs, truncateToWidth } from \"@oh-my-pi/pi-tui\";\nimport { getSelectListTheme, type ExtensionAPI } from \"@oh-my-pi/pi-coding-agent\";\n\nclass Picker implements Component {\n list: SelectList;\n keybindings: any;\n done: (value: string | undefined) => void;\n\n constructor(\n items: Array<{ value: string; label: string }>,\n keybindings: any,\n done: (value: string | undefined) => void,\n ) {\n this.list = new SelectList(items, 8, getSelectListTheme());\n this.keybindings = keybindings;\n this.done = done;\n this.list.onSelect = item => this.done(item.value);\n this.list.onCancel = () => this.done(undefined);\n }\n\n handleInput(data: string): void {\n if (this.keybindings.matches(data, \"interrupt\")) {\n this.done(undefined);\n return;\n }\n this.list.handleInput(data);\n }\n\n render(width: number): string[] {\n return this.list.render(width).map(line => truncateToWidth(replaceTabs(line), width));\n }\n\n invalidate(): void {\n this.list.invalidate();\n }\n}\n\nexport default function extension(pi: ExtensionAPI): void {\n pi.registerCommand(\"pick-model\", {\n description: \"Pick a model profile\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) return;\n\n const selected = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {\n const items = [\n { value: \"fast\", label: theme.fg(\"accent\", \"Fast\") },\n { value: \"balanced\", label: \"Balanced\" },\n { value: \"quality\", label: \"Quality\" },\n ];\n return new Picker(items, keybindings, done);\n });\n\n if (selected) ctx.ui.notify(`Selected profile: ${selected}`, \"info\");\n },\n });\n}\n```\n\n## Key implementation files\n\n- `packages/tui/src/tui.ts` — `Component`, `Focusable`, cursor marker, focus, overlay, input dispatch.\n- `packages/tui/src/utils.ts` — width/truncation/sanitization primitives.\n- `packages/tui/src/keys.ts` / `keybindings.ts` — key parsing and configurable action mapping.\n- `packages/coding-agent/src/modes/controllers/extension-ui-controller.ts` — interactive mounting/unmounting for extension/hook/custom-tool UI.\n- `packages/coding-agent/src/extensibility/extensions/types.ts` — extension UI and renderer contracts.\n- `packages/coding-agent/src/extensibility/hooks/types.ts` — hook UI contract (legacy custom signature).\n- `packages/coding-agent/src/extensibility/custom-tools/types.ts` — custom tool execute/render contracts.\n- `packages/coding-agent/src/modes/components/tool-execution.ts` — mounting `renderCall`/`renderResult` components and partial-state options.\n- `packages/coding-agent/src/tools/context.ts` — tool UI context propagation (`hasUI`, `ui`).\n",
51
53
  };
52
-
53
- export const EMBEDDED_DOC_FILENAMES = Object.keys(EMBEDDED_DOCS).sort();
@@ -123,27 +123,54 @@ export class RpcClient {
123
123
  stdin: "pipe",
124
124
  });
125
125
 
126
- // Process lines in background
126
+ // Wait for the "ready" signal or process exit
127
+ const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
128
+ let readySettled = false;
129
+
130
+ // Process lines in background, intercepting the ready signal
127
131
  const lines = readJsonl(this.#process.stdout, this.#abortController.signal);
128
132
  void (async () => {
129
133
  for await (const line of lines) {
134
+ if (!readySettled && isRecord(line) && line.type === "ready") {
135
+ readySettled = true;
136
+ readyResolve();
137
+ continue;
138
+ }
130
139
  this.#handleLine(line);
131
140
  }
132
- })().catch(() => {});
141
+ // Stream ended without ready signal — process exited
142
+ if (!readySettled) {
143
+ readySettled = true;
144
+ readyReject(new Error(`Agent process exited before ready. Stderr: ${this.#process?.peekStderr() ?? ""}`));
145
+ }
146
+ })().catch((err: Error) => {
147
+ if (!readySettled) {
148
+ readySettled = true;
149
+ readyReject(err);
150
+ }
151
+ });
133
152
 
134
- // Wait a moment for process to initialize
135
- await Bun.sleep(100);
153
+ // Also race against process exit (in case stdout closes before we read it)
154
+ void this.#process.exited.then((exitCode: number) => {
155
+ if (!readySettled) {
156
+ readySettled = true;
157
+ readyReject(
158
+ new Error(`Agent process exited with code ${exitCode}. Stderr: ${this.#process?.peekStderr() ?? ""}`),
159
+ );
160
+ }
161
+ });
136
162
 
137
- try {
138
- const exitCode = await Promise.race([this.#process.exited, Bun.sleep(500).then(() => null)]);
139
- if (exitCode !== null) {
140
- throw new Error(
141
- `Agent process exited immediately with code ${exitCode}. Stderr: ${this.#process.peekStderr()}`,
163
+ // Timeout to prevent hanging forever
164
+ void Bun.sleep(30000).then(() => {
165
+ if (!readySettled) {
166
+ readySettled = true;
167
+ readyReject(
168
+ new Error(`Timeout waiting for agent to become ready. Stderr: ${this.#process?.peekStderr() ?? ""}`),
142
169
  );
143
170
  }
144
- } catch {
145
- // Process still running, which is what we want
146
- }
171
+ });
172
+
173
+ await readyPromise;
147
174
  }
148
175
 
149
176
  /**
@@ -36,6 +36,8 @@ export type {
36
36
  * Listens for JSON commands on stdin, outputs events and responses on stdout.
37
37
  */
38
38
  export async function runRpcMode(session: AgentSession): Promise<never> {
39
+ // Signal to RPC clients that the server is ready to accept commands
40
+ process.stdout.write(`${JSON.stringify({ type: "ready" })}\n`);
39
41
  const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
40
42
  process.stdout.write(`${JSON.stringify(obj)}\n`);
41
43
  };
@@ -663,10 +663,8 @@ export function applyHashlineEdits(
663
663
  if (edit.content.length === 0) {
664
664
  throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst');
665
665
  }
666
- if (edit.before.line !== edit.after.line + 1) {
667
- throw new Error(
668
- `insert requires adjacent anchors (after ${edit.after.line}, before ${edit.before.line})`,
669
- );
666
+ if (edit.before.line <= edit.after.line) {
667
+ throw new Error(`insert requires after (${edit.after.line}) < before (${edit.before.line})`);
670
668
  }
671
669
  const afterValid = validateRef(edit.after);
672
670
  const beforeValid = validateRef(edit.before);
@@ -6,21 +6,28 @@ Use this tool to navigate, click, type, scroll, drag, query DOM content, and cap
6
6
  - Use `action: "open"` to start a new headless browser session (or implicitly launch on first action)
7
7
  - Use `action: "goto"` with `url` to navigate
8
8
  - Use `action: "observe"` to capture a numbered accessibility snapshot with URL/title/viewport/scroll info
9
- - Prefer `click_id`, `type_id`, or `fill_id` actions using the returned `element_id` values
10
- - Optional flags: `include_all` to include non-interactive nodes, `viewport_only` to limit to visible elements
9
+ - Prefer `click_id`, `type_id`, or `fill_id` actions using the returned `element_id` values
10
+ - Optional flags: `include_all` to include non-interactive nodes, `viewport_only` to limit to visible elements
11
11
  - Use `action: "click"`, `"type"`, `"fill"`, `"press"`, `"scroll"`, or `"drag"` for selector-based interactions
12
- - Prefer ARIA or text selectors (e.g. `p-aria/[name="Sign in"]`, `p-text/Continue`) over brittle CSS
12
+ - Prefer ARIA or text selectors (e.g. `p-aria/[name="Sign in"]`, `p-text/Continue`) over brittle CSS
13
13
  - Use `action: "click_id"`, `"type_id"`, or `"fill_id"` to interact with observed elements without selectors
14
14
  - Use `action: "wait_for_selector"` before interacting when the page is dynamic
15
15
  - Use `action: "evaluate"` with `script` to run a JavaScript expression in the page context
16
16
  - Use `action: "get_text"`, `"get_html"`, or `"get_attribute"` for DOM queries
17
- - For batch queries, pass `args: [{ selector, attribute? }]` to get an array of results (attribute required for `get_attribute`)
17
+ - For batch queries, pass `args: [{ selector, attribute? }]` to get an array of results (attribute required for `get_attribute`)
18
18
  - Use `action: "extract_readable"` to return reader-mode content (title/byline/excerpt/text or markdown)
19
- - Set `format` to `"markdown"` (default) or `"text"`
19
+ - Set `format` to `"markdown"` (default) or `"text"`
20
20
  - Use `action: "screenshot"` to capture images (optionally with `selector` to capture a single element)
21
21
  - Use `action: "close"` to release the browser when done
22
22
  </instruction>
23
23
 
24
+ <critical>
25
+ **Default to `observe`, not `screenshot`.**
26
+ - `observe` is cheaper, faster, and returns structured data — use it to understand page state, find elements, and plan interactions.
27
+ - Only use `screenshot` when visual appearance matters (verifying layout, debugging CSS, capturing a visual artifact for the user).
28
+ - Never screenshot just to "see what's on the page" — `observe` gives you that with element IDs you can act on immediately.
29
+ </critical>
30
+
24
31
  <output>
25
32
  Returns text output for navigation and DOM queries, and image output for screenshots. Screenshots can optionally be saved to disk via the `path` parameter.
26
33
  </output>
@@ -37,7 +37,7 @@ export interface StoredAuthCredential {
37
37
  }
38
38
 
39
39
  /** Bump when schema changes require migration */
40
- const SCHEMA_VERSION = 3;
40
+ const SCHEMA_VERSION = 4;
41
41
 
42
42
  /**
43
43
  * Type guard for plain objects.
@@ -124,11 +124,14 @@ export class AgentStorage {
124
124
  #deleteExpiredCacheStmt: Statement;
125
125
  #listAuthStmt: Statement;
126
126
  #listAuthByProviderStmt: Statement;
127
+ #listActiveAuthStmt: Statement;
128
+ #listActiveAuthByProviderStmt: Statement;
127
129
  #insertAuthStmt: Statement;
128
130
  #updateAuthStmt: Statement;
129
131
  #deleteAuthStmt: Statement;
130
132
  #deleteAuthByProviderStmt: Statement;
131
133
  #countAuthStmt: Statement;
134
+ #disableAuthStmt: Statement;
132
135
  #upsertModelUsageStmt: Statement;
133
136
  #listModelUsageStmt: Statement;
134
137
  #modelUsageCache: string[] | null = null;
@@ -165,6 +168,12 @@ export class AgentStorage {
165
168
  this.#listAuthByProviderStmt = this.#db.prepare(
166
169
  "SELECT id, provider, credential_type, data FROM auth_credentials WHERE provider = ? ORDER BY id ASC",
167
170
  );
171
+ this.#listActiveAuthStmt = this.#db.prepare(
172
+ "SELECT id, provider, credential_type, data FROM auth_credentials WHERE disabled = 0 ORDER BY id ASC",
173
+ );
174
+ this.#listActiveAuthByProviderStmt = this.#db.prepare(
175
+ "SELECT id, provider, credential_type, data FROM auth_credentials WHERE provider = ? AND disabled = 0 ORDER BY id ASC",
176
+ );
168
177
  this.#insertAuthStmt = this.#db.prepare(
169
178
  "INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?) RETURNING id",
170
179
  );
@@ -173,6 +182,9 @@ export class AgentStorage {
173
182
  );
174
183
  this.#deleteAuthStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
175
184
  this.#deleteAuthByProviderStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE provider = ?");
185
+ this.#disableAuthStmt = this.#db.prepare(
186
+ "UPDATE auth_credentials SET disabled = 1, updated_at = unixepoch() WHERE id = ?",
187
+ );
176
188
  this.#countAuthStmt = this.#db.prepare("SELECT COUNT(*) as count FROM auth_credentials");
177
189
 
178
190
  this.#upsertModelUsageStmt = this.#db.prepare(
@@ -198,6 +210,7 @@ CREATE TABLE IF NOT EXISTS auth_credentials (
198
210
  provider TEXT NOT NULL,
199
211
  credential_type TEXT NOT NULL,
200
212
  data TEXT NOT NULL,
213
+ disabled INTEGER NOT NULL DEFAULT 0,
201
214
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
202
215
  updated_at INTEGER NOT NULL DEFAULT (unixepoch())
203
216
  );
@@ -281,10 +294,21 @@ CREATE TABLE settings (
281
294
  current: versionRow.version,
282
295
  expected: SCHEMA_VERSION,
283
296
  });
297
+ this.#migrateSchema(versionRow.version);
284
298
  }
285
299
  this.#db.prepare("INSERT OR REPLACE INTO schema_version(version) VALUES (?)").run(SCHEMA_VERSION);
286
300
  }
287
301
 
302
+ #migrateSchema(fromVersion: number): void {
303
+ if (fromVersion < 4) {
304
+ // v3 → v4: Add disabled column to auth_credentials
305
+ const cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
306
+ if (!cols.some(c => c.name === "disabled")) {
307
+ this.#db.exec("ALTER TABLE auth_credentials ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0");
308
+ }
309
+ }
310
+ }
311
+
288
312
  /**
289
313
  * Returns singleton instance for the given database path, creating if needed.
290
314
  * Retries on SQLITE_BUSY with exponential backoff.
@@ -429,14 +453,19 @@ CREATE TABLE settings (
429
453
 
430
454
  /**
431
455
  * Lists auth credentials, optionally filtered by provider.
456
+ * Only returns active (non-disabled) credentials by default.
432
457
  * @param provider - Optional provider name to filter by
458
+ * @param includeDisabled - If true, includes disabled credentials
433
459
  * @returns Array of stored credentials with their database IDs
434
460
  */
435
- listAuthCredentials(provider?: string): StoredAuthCredential[] {
436
- const rows =
437
- (provider
438
- ? (this.#listAuthByProviderStmt.all(provider) as AuthRow[])
439
- : (this.#listAuthStmt.all() as AuthRow[])) ?? [];
461
+ listAuthCredentials(provider?: string, includeDisabled = false): StoredAuthCredential[] {
462
+ const rows = includeDisabled
463
+ ? ((provider
464
+ ? (this.#listAuthByProviderStmt.all(provider) as AuthRow[])
465
+ : (this.#listAuthStmt.all() as AuthRow[])) ?? [])
466
+ : ((provider
467
+ ? (this.#listActiveAuthByProviderStmt.all(provider) as AuthRow[])
468
+ : (this.#listActiveAuthStmt.all() as AuthRow[])) ?? []);
440
469
 
441
470
  const results: StoredAuthCredential[] = [];
442
471
  for (const row of rows) {
@@ -498,6 +527,19 @@ CREATE TABLE settings (
498
527
  }
499
528
  }
500
529
 
530
+ /**
531
+ * Disables an auth credential by ID (soft-delete).
532
+ * Disabled credentials are excluded from normal listing but remain in the database.
533
+ * @param id - Database row ID of the credential to disable
534
+ */
535
+ disableAuthCredential(id: number): void {
536
+ try {
537
+ this.#disableAuthStmt.run(id);
538
+ } catch (error) {
539
+ logger.warn("AgentStorage disableAuthCredential failed", { id, error: String(error) });
540
+ }
541
+ }
542
+
501
543
  /**
502
544
  * Deletes all auth credentials for a provider.
503
545
  * @param provider - Provider name whose credentials should be deleted
@@ -547,13 +547,14 @@ export class AuthStorage {
547
547
  }
548
548
 
549
549
  /**
550
- * Removes credential at index (used when OAuth refresh fails).
551
- * Cleans up provider entry if last credential removed.
550
+ * Disables credential at index (used when OAuth refresh fails).
551
+ * The credential remains in the database but is excluded from active queries.
552
+ * Cleans up provider entry if last credential disabled.
552
553
  */
553
554
  #removeCredentialAt(provider: string, index: number): void {
554
555
  const entries = this.#getStoredCredentials(provider);
555
556
  if (index < 0 || index >= entries.length) return;
556
- this.storage.deleteAuthCredential(entries[index].id);
557
+ this.storage.disableAuthCredential(entries[index].id);
557
558
  const updated = entries.filter((_value, idx) => idx !== index);
558
559
  this.#setStoredCredentials(provider, updated);
559
560
  this.#resetProviderAssignments(provider);
@@ -1339,7 +1340,7 @@ export class AuthStorage {
1339
1340
  // Keep credentials for transient errors (network, 5xx) and block temporarily
1340
1341
  const isDefinitiveFailure =
1341
1342
  /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(errorMsg) ||
1342
- (/401|403/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
1343
+ (/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
1343
1344
 
1344
1345
  logger.warn("OAuth token refresh failed", {
1345
1346
  provider,
@@ -379,6 +379,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
379
379
  status: "running",
380
380
  task,
381
381
  description: options.description,
382
+ lastIntent: undefined,
382
383
  recentTools: [],
383
384
  recentOutput: [],
384
385
  toolCount: 0,
@@ -645,14 +646,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
645
646
  }
646
647
  break;
647
648
 
648
- case "tool_execution_start":
649
+ case "tool_execution_start": {
649
650
  progress.toolCount++;
650
651
  progress.currentTool = event.toolName;
651
652
  progress.currentToolArgs = extractToolArgsPreview(
652
653
  (event as { toolArgs?: Record<string, unknown> }).toolArgs || event.args || {},
653
654
  );
654
655
  progress.currentToolStartMs = now;
656
+ const intent = event.intent?.trim();
657
+ if (intent) {
658
+ progress.lastIntent = intent;
659
+ }
655
660
  break;
661
+ }
656
662
 
657
663
  case "tool_execution_end": {
658
664
  if (progress.currentTool) {
@@ -1174,6 +1180,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1174
1180
  agentSource: agent.source,
1175
1181
  task,
1176
1182
  description: options.description,
1183
+ lastIntent: progress.lastIntent,
1177
1184
  exitCode,
1178
1185
  output: truncatedOutput,
1179
1186
  stderr,
@@ -535,8 +535,9 @@ function renderAgentProgress(
535
535
  if (progress.status === "running") {
536
536
  if (progress.currentTool) {
537
537
  let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", progress.currentTool)}`;
538
- if (progress.currentToolArgs) {
539
- toolLine += `: ${theme.fg("dim", truncateToWidth(progress.currentToolArgs, 40))}`;
538
+ const toolDetail = progress.lastIntent ?? progress.currentToolArgs;
539
+ if (toolDetail) {
540
+ toolLine += `: ${theme.fg("dim", truncateToWidth(replaceTabs(toolDetail), 40))}`;
540
541
  }
541
542
  if (progress.currentToolStartMs) {
542
543
  const elapsed = Date.now() - progress.currentToolStartMs;
@@ -549,8 +550,9 @@ function renderAgentProgress(
549
550
  // Show most recent completed tool when idle between tools
550
551
  const recent = progress.recentTools[0];
551
552
  let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("dim", recent.tool)}`;
552
- if (recent.args) {
553
- toolLine += `: ${theme.fg("dim", truncateToWidth(recent.args, 40))}`;
553
+ const toolDetail = progress.lastIntent ?? recent.args;
554
+ if (toolDetail) {
555
+ toolLine += `: ${theme.fg("dim", truncateToWidth(replaceTabs(toolDetail), 40))}`;
554
556
  }
555
557
  lines.push(toolLine);
556
558
  }
package/src/task/types.ts CHANGED
@@ -140,6 +140,7 @@ export interface AgentProgress {
140
140
  status: "pending" | "running" | "completed" | "failed" | "aborted";
141
141
  task: string;
142
142
  description?: string;
143
+ lastIntent?: string;
143
144
  currentTool?: string;
144
145
  currentToolArgs?: string;
145
146
  currentToolStartMs?: number;
@@ -161,6 +162,7 @@ export interface SingleResult {
161
162
  agentSource: AgentSource;
162
163
  task: string;
163
164
  description?: string;
165
+ lastIntent?: string;
164
166
  exitCode: number;
165
167
  output: string;
166
168
  stderr: string;