@oh-my-pi/pi-coding-agent 13.15.2 → 13.16.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +26 -16
  2. package/package.json +7 -7
  3. package/src/config/keybindings.ts +6 -0
  4. package/src/config/model-registry.ts +215 -57
  5. package/src/config/settings-schema.ts +27 -0
  6. package/src/extensibility/extensions/types.ts +6 -1
  7. package/src/extensibility/hooks/types.ts +1 -1
  8. package/src/internal-urls/docs-index.generated.ts +1 -1
  9. package/src/modes/components/custom-editor.ts +6 -4
  10. package/src/modes/components/hook-editor.ts +57 -8
  11. package/src/modes/components/model-selector.ts +48 -29
  12. package/src/modes/components/settings-defs.ts +10 -1
  13. package/src/modes/components/settings-selector.ts +92 -5
  14. package/src/modes/controllers/extension-ui-controller.ts +32 -4
  15. package/src/modes/controllers/input-controller.ts +22 -9
  16. package/src/modes/controllers/selector-controller.ts +2 -2
  17. package/src/modes/interactive-mode.ts +7 -2
  18. package/src/modes/rpc/rpc-mode.ts +78 -30
  19. package/src/modes/rpc/rpc-types.ts +9 -1
  20. package/src/modes/theme/theme.ts +70 -0
  21. package/src/modes/types.ts +6 -1
  22. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  23. package/src/prompts/system/custom-system-prompt.md +5 -0
  24. package/src/prompts/system/system-prompt.md +6 -0
  25. package/src/prompts/tools/ask.md +1 -0
  26. package/src/prompts/tools/hashline.md +20 -5
  27. package/src/sdk.ts +9 -1
  28. package/src/session/agent-session.ts +338 -80
  29. package/src/session/messages.ts +23 -0
  30. package/src/session/session-manager.ts +65 -0
  31. package/src/system-prompt.ts +63 -2
  32. package/src/tools/ask.ts +109 -61
  33. package/src/tools/ast-edit.ts +2 -16
  34. package/src/tools/ast-grep.ts +2 -17
  35. package/src/tools/browser.ts +35 -17
  36. package/src/tools/grep.ts +4 -17
  37. package/src/tools/path-utils.ts +7 -0
  38. package/src/tools/render-utils.ts +27 -0
  39. package/src/tui/tree-list.ts +51 -22
  40. package/src/utils/image-input.ts +11 -1
  41. package/src/web/search/providers/codex.ts +10 -3
@@ -38,7 +38,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
38
38
  "python-repl.md": "# Python Tool and IPython Runtime\n\nThis document describes the current Python execution stack in `packages/coding-agent`.\nIt covers tool behavior, kernel/gateway lifecycle, environment handling, execution semantics, output rendering, and operational failure modes.\n\n## Scope and Key Files\n\n- Tool surface: `src/tools/python.ts`\n- Session/per-call kernel orchestration: `src/ipy/executor.ts`\n- Kernel protocol + gateway integration: `src/ipy/kernel.ts`\n- Shared local gateway coordinator: `src/ipy/gateway-coordinator.ts`\n- Interactive-mode renderer for user-triggered Python runs: `src/modes/components/python-execution.ts`\n- Runtime/env filtering and Python resolution: `src/ipy/runtime.ts`\n\n## What the Python tool is\n\nThe `python` tool executes one or more Python cells through a Jupyter Kernel Gateway-backed kernel (not by spawning `python -c` directly per cell).\n\nTool params:\n\n```ts\n{\n cells: Array<{ code: string; title?: string }>;\n timeout?: number; // seconds, clamped to 1..600, default 30\n cwd?: string;\n reset?: boolean; // reset kernel before first cell only\n}\n```\n\nThe tool is `concurrency = \"exclusive\"` for a session, so calls do not overlap.\n\n## Gateway lifecycle\n\n### Modes\n\nThere are two gateway paths:\n\n1. **External gateway** (`PI_PYTHON_GATEWAY_URL` set)\n - Uses the configured URL directly.\n - Optional auth with `PI_PYTHON_GATEWAY_TOKEN`.\n - No local gateway process is spawned or managed.\n\n2. **Local shared gateway** (default path)\n - Uses a single shared process coordinated under `~/.omp/agent/python-gateway`.\n - Metadata file: `gateway.json`\n - Lock file: `gateway.lock`\n - Spawn command:\n - `python -m kernel_gateway`\n - bound to `127.0.0.1:<allocated-port>`\n - startup health check: `GET /api/kernelspecs`\n\n### Local shared gateway coordination\n\n`acquireSharedGateway()`:\n\n- Takes a file lock (`gateway.lock`) with heartbeat.\n- Reuses `gateway.json` if PID is alive and health check passes.\n- Cleans stale info/PIDs when needed.\n- Starts a new gateway when no healthy one exists.\n\n`releaseSharedGateway()` is currently a no-op (kernel shutdown does not tear down shared gateway).\n\n`shutdownSharedGateway()` explicitly terminates the shared process and clears gateway metadata.\n\n### Important constraint\n\n`python.sharedGateway=false` is rejected at kernel start:\n\n- Error: `Shared Python gateway required; local gateways are disabled`\n- There is no per-process non-shared local gateway mode.\n\n## Kernel lifecycle\n\nEach execution uses a kernel created via `POST /api/kernels` on the selected gateway.\n\nKernel startup sequence:\n\n1. Availability check (`checkPythonKernelAvailability`)\n2. Create kernel (`/api/kernels`)\n3. Open websocket (`/api/kernels/:id/channels`)\n4. Initialize kernel env (`cwd`, env vars, `sys.path`)\n5. Execute `PYTHON_PRELUDE`\n6. Load extension modules from:\n - user: `~/.omp/agent/modules/*.py`\n - project: `<cwd>/.omp/modules/*.py` (overrides same-name user module)\n\nKernel shutdown:\n\n- Deletes remote kernel via `DELETE /api/kernels/:id`\n- Closes websocket\n- Calls shared gateway release hook (no-op today)\n\n## Session persistence semantics\n\n`python.kernelMode` controls kernel reuse:\n\n- `session` (default)\n - Reuses kernel sessions keyed by session identity + cwd.\n - Execution is serialized per session via a queue.\n - Idle sessions are evicted after 5 minutes.\n - At most 4 sessions; oldest is evicted on overflow.\n - Heartbeat checks detect dead kernels.\n - Auto-restart allowed once; repeated crash => hard failure.\n\n- `per-call`\n - Creates a fresh kernel for each execute request.\n - Shuts kernel down after the request.\n - No cross-call state persistence.\n\n### Multi-cell behavior in a single tool call\n\nCells run sequentially in the same kernel instance for that tool call.\n\nIf an intermediate cell fails:\n\n- Earlier cell state remains in memory.\n- Tool returns a targeted error indicating which cell failed.\n- Later cells are not executed.\n\n`reset=true` only applies to the first cell execution in that call.\n\n## Environment filtering and runtime resolution\n\nEnvironment is filtered before launching gateway/kernel runtime:\n\n- Allowlist includes core vars like `PATH`, `HOME`, locale vars, `VIRTUAL_ENV`, `PYTHONPATH`, etc.\n- Allow-prefixes: `LC_`, `XDG_`, `PI_`\n- Denylist strips common API keys (OpenAI/Anthropic/Gemini/etc.)\n\nRuntime selection order:\n\n1. Active/located venv (`VIRTUAL_ENV`, then `<cwd>/.venv`, `<cwd>/venv`)\n2. Managed venv at `~/.omp/python-env`\n3. `python` or `python3` on PATH\n\nWhen a venv is selected, its bin/Scripts path is prepended to `PATH`.\n\nKernel env initialization inside Python also:\n\n- `os.chdir(cwd)`\n- injects provided env map into `os.environ`\n- ensures cwd is in `sys.path`\n\n## Tool availability and mode selection\n\n`python.toolMode` (default `both`) + optional `PI_PY` override controls exposure:\n\n- `ipy-only`\n- `bash-only`\n- `both`\n\n`PI_PY` accepted values:\n\n- `0` / `bash` -> `bash-only`\n- `1` / `py` -> `ipy-only`\n- `mix` / `both` -> `both`\n\nIf Python preflight fails, tool creation degrades to bash-only for that session.\n\n## Execution flow and cancellation/timeout\n\n### Tool-level timeout\n\n`python` tool timeout is in seconds, default 30, clamped to `1..600`.\n\nThe tool combines:\n\n- caller abort signal\n- timeout abort signal\n\nwith `AbortSignal.any(...)`.\n\n### Kernel execution cancellation\n\nOn abort/timeout:\n\n- Execution is marked cancelled.\n- Kernel interrupt is attempted via REST (`POST /interrupt`) and control-channel `interrupt_request`.\n- Result includes `cancelled=true`.\n- Timeout path annotates output as `Command timed out after <n> seconds`.\n\n### stdin behavior\n\nInteractive stdin is not supported.\n\nIf kernel emits `input_request`:\n\n- Tool records `stdinRequested=true`\n- Emits explanatory text\n- Sends empty `input_reply`\n- Execution is treated as failure at executor layer\n\n## Output capture and rendering\n\n### Captured output classes\n\nFrom kernel messages:\n\n- `stream` -> plain text chunks\n- `display_data`/`execute_result` -> rich display handling\n- `error` -> traceback text\n- custom MIME `application/x-omp-status` -> structured status events\n\nDisplay MIME precedence:\n\n1. `text/markdown`\n2. `text/plain`\n3. `text/html` (converted to basic markdown)\n\nAdditionally captured as structured outputs:\n\n- `application/json` -> JSON tree data\n- `image/png` -> image payloads\n- `application/x-omp-status` -> status events\n\n### Storage and truncation\n\nOutput is streamed through `OutputSink` and may be persisted to artifact storage.\n\nTool results can include truncation metadata and `artifact://<id>` for full output recovery.\n\n### Renderer behavior\n\n- Tool renderer (`python.ts`):\n - shows code-cell blocks with per-cell status\n - collapsed preview defaults to 10 lines\n - supports expanded mode for full output and richer status detail\n- Interactive renderer (`python-execution.ts`):\n - used for user-triggered Python execution in TUI\n - collapsed preview defaults to 20 lines\n - clamps very long individual lines to 4000 chars for display safety\n - shows cancellation/error/truncation notices\n\n## External gateway support\n\nSet:\n\n```bash\nexport PI_PYTHON_GATEWAY_URL=\"http://127.0.0.1:8888\"\n# Optional:\nexport PI_PYTHON_GATEWAY_TOKEN=\"...\"\n```\n\nBehavior differences from local shared gateway:\n\n- No local gateway lock/info files\n- No local process spawn/termination\n- Health checks and kernel CRUD run against external endpoint\n- Auth failures are surfaced with explicit token guidance\n\n## Operational troubleshooting (current failure modes)\n\n- **Python tool not available**\n - Check `python.toolMode` / `PI_PY`.\n - If preflight fails, runtime falls back to bash-only.\n\n- **Kernel availability errors**\n - Local mode requires both `kernel_gateway` and `ipykernel` importable in resolved Python runtime.\n - Install with:\n ```bash\n python -m pip install jupyter_kernel_gateway ipykernel\n ```\n\n- **`python.sharedGateway=false` causes startup failure**\n - This is expected with current implementation.\n\n- **External gateway auth/reachability failures**\n - 401/403 -> set `PI_PYTHON_GATEWAY_TOKEN`.\n - timeout/unreachable -> verify URL/network and gateway health.\n\n- **Execution hangs then times out**\n - Increase tool `timeout` (max 600s) if workload is legitimate.\n - For stuck code, cancellation triggers kernel interrupt but user code may still need refactor.\n\n- **stdin/input prompts in Python code**\n - `input()` is not supported interactively in this runtime path; pass data programmatically.\n\n- **Resource exhaustion (`EMFILE` / too many open files)**\n - Session manager triggers shared-gateway recovery (session teardown + shared gateway restart).\n\n- **Working directory errors**\n - Tool validates `cwd` exists and is a directory before execution.\n\n## Relevant environment variables\n\n- `PI_PY` — tool exposure override (`bash-only`/`ipy-only`/`both` mapping above)\n- `PI_PYTHON_GATEWAY_URL` — use external gateway\n- `PI_PYTHON_GATEWAY_TOKEN` — optional external gateway auth token\n- `PI_PYTHON_SKIP_CHECK=1` — bypass Python preflight/warm checks\n- `PI_PYTHON_IPC_TRACE=1` — log kernel IPC send/receive traces\n- `PI_DEBUG_STARTUP=1` — emit startup-stage debug markers\n",
39
39
  "resolve-tool-runtime.md": "# Resolve tool runtime internals\n\nThis document explains how preview/apply workflows are modeled in coding-agent and how custom tools can participate via `pushPendingAction`.\n\n## Scope and key files\n\n- [`src/tools/resolve.ts`](../packages/coding-agent/src/tools/resolve.ts)\n- [`src/tools/pending-action.ts`](../packages/coding-agent/src/tools/pending-action.ts)\n- [`src/tools/ast-edit.ts`](../packages/coding-agent/src/tools/ast-edit.ts)\n- [`src/extensibility/custom-tools/types.ts`](../packages/coding-agent/src/extensibility/custom-tools/types.ts)\n- [`src/extensibility/custom-tools/loader.ts`](../packages/coding-agent/src/extensibility/custom-tools/loader.ts)\n- [`src/sdk.ts`](../packages/coding-agent/src/sdk.ts)\n\n## What `resolve` does\n\n`resolve` is a hidden tool that finalizes a pending preview action.\n\n- `action: \"apply\"` executes `apply(reason)` on the pending action and persists changes.\n- `action: \"discard\"` invokes `reject(reason)` if provided; otherwise drops the action with a default \"Discarded\" message.\n\nIf no pending action exists, `resolve` fails with:\n\n- `No pending action to resolve. Nothing to apply or discard.`\n\n## Pending actions are a stack (LIFO)\n\nPending actions are stored in `PendingActionStore` as a push/pop stack:\n\n- `push(action)` adds a new pending action on top.\n- `peek()` inspects the current top action.\n- `pop()` removes and returns the top action.\n- `hasPending` indicates whether the stack is non-empty.\n\n`resolve` always consumes the **topmost** pending action first (`pop()`), so multiple preview-producing tools resolve in reverse order of registration.\n\n## Built-in producer example (`ast_edit`)\n\n`ast_edit` previews structural replacements first. When the preview has replacements and is not applied yet, it pushes a pending action that contains:\n\n- label (human-readable summary)\n- `sourceToolName` (`ast_edit`)\n- `apply(reason: string)` callback that reruns AST edit with `dryRun: false`\n\n`resolve(action=\"apply\", reason=\"...\")` passes `reason` into this callback.\n\n## Custom tools: `pushPendingAction`\n\nCustom tools can register resolve-compatible pending actions through `CustomToolAPI.pushPendingAction(...)`.\n\n`CustomToolPendingAction`:\n\n- `label: string` (required)\n- `apply(reason: string): Promise<AgentToolResult<unknown>>` (required) — invoked on apply; `reason` is the string passed to `resolve`\n- `reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>` (optional) — invoked on discard; return value replaces the default \"Discarded\" message if provided\n- `details?: unknown` (optional)\n- `sourceToolName?: string` (optional, defaults to `\"custom_tool\"`)\n\n### Minimal usage example\n\n```ts\nimport type { CustomToolFactory } from \"@oh-my-pi/pi-coding-agent\";\n\nconst factory: CustomToolFactory = pi => ({\n\tname: \"batch_rename_preview\",\n\tlabel: \"Batch Rename Preview\",\n\tdescription: \"Previews renames and defers commit to resolve\",\n\tparameters: pi.typebox.Type.Object({\n\t\tfiles: pi.typebox.Type.Array(pi.typebox.Type.String()),\n\t}),\n\n\tasync execute(_toolCallId, params) {\n\t\tconst previewSummary = `Prepared rename plan for ${params.files.length} files`;\n\n\t\tpi.pushPendingAction({\n\t\t\tlabel: `Batch rename: ${params.files.length} files`,\n\t\t\tsourceToolName: \"batch_rename_preview\",\n\t\t\tapply: async (reason) => {\n\t\t\t\t// apply writes here\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Applied batch rename. Reason: ${reason}` }],\n\t\t\t\t};\n\t\t\t},\n\t\t\treject: async (reason) => {\n\t\t\t\t// optional: cleanup or notify on discard\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Discarded batch rename. Reason: ${reason}` }],\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\n\t\treturn {\n\t\t\tcontent: [{ type: \"text\", text: `${previewSummary}. Call resolve to apply or discard.` }],\n\t\t};\n\t},\n});\n\nexport default factory;\n```\n\n## Runtime availability and failures\n\n`pushPendingAction` is wired by the custom tool loader using the active session `PendingActionStore`.\n\nIf the runtime has no pending-action store, `pushPendingAction` throws:\n\n- `Pending action store unavailable for custom tools in this runtime.`\n\n## Tool-choice behavior\n\nWhen `PendingActionStore.hasPending` is true, the agent runtime biases tool choice to `resolve` so pending previews are explicitly finalized before normal tool flow continues.\n\n## Developer guidance\n\n- Use pending actions only for destructive or high-impact operations that should support explicit apply/discard.\n- Keep `label` concise and specific; it is shown in resolve renderer output.\n- Ensure `apply(reason)` is deterministic and idempotent enough for one-shot execution; `reason` is informational and should not change behavior.\n- Implement `reject(reason)` when the discard needs cleanup (temp state, locks, notifications); omit it for stateless previews where the default message suffices.\n- If your tool can stage multiple previews, remember LIFO semantics: latest pushed action resolves first.\n",
40
40
  "rpc.md": "# RPC Protocol Reference\n\nRPC mode runs the coding agent as a newline-delimited JSON protocol over stdio.\n\n- **stdin**: commands (`RpcCommand`) and extension UI responses\n- **stdout**: command responses (`RpcResponse`), session/agent events, extension UI requests\n\nPrimary implementation:\n\n- `src/modes/rpc/rpc-mode.ts`\n- `src/modes/rpc/rpc-types.ts`\n- `src/session/agent-session.ts`\n- `packages/agent/src/agent.ts`\n- `packages/agent/src/agent-loop.ts`\n\n## Startup\n\n```bash\nomp --mode rpc [regular CLI options]\n```\n\nBehavior notes:\n\n- `@file` CLI arguments are rejected in RPC mode.\n- The process reads stdin as JSONL (`readJsonl(Bun.stdin.stream())`).\n- When stdin closes, the process exits with code `0`.\n- Responses/events are written as one JSON object per line.\n\n## Transport and Framing\n\nEach frame is a single JSON object followed by `\\n`.\n\nThere is no envelope beyond the object shape itself.\n\n### Outbound frame categories (stdout)\n\n1. `RpcResponse` (`{ type: \"response\", ... }`)\n2. `AgentSessionEvent` objects (`agent_start`, `message_update`, etc.)\n3. `RpcExtensionUIRequest` (`{ type: \"extension_ui_request\", ... }`)\n4. Extension errors (`{ type: \"extension_error\", extensionPath, event, error }`)\n\n### Inbound frame categories (stdin)\n\n1. `RpcCommand`\n2. `RpcExtensionUIResponse` (`{ type: \"extension_ui_response\", ... }`)\n\n## Request/Response Correlation\n\nAll commands accept optional `id?: string`.\n\n- If provided, normal command responses echo the same `id`.\n- `RpcClient` relies on this for pending-request resolution.\n\nImportant edge behavior from runtime:\n\n- Unknown command responses are emitted with `id: undefined` (even if the request had an `id`).\n- Parse/handler exceptions in the input loop emit `command: \"parse\"` with `id: undefined`.\n- `prompt` and `abort_and_prompt` return immediate success, then may emit a later error response with the **same** id if async prompt scheduling fails.\n\n## Command Schema (canonical)\n\n`RpcCommand` is defined in `src/modes/rpc/rpc-types.ts`:\n\n### Prompting\n\n- `{ id?, type: \"prompt\", message: string, images?: ImageContent[], streamingBehavior?: \"steer\" | \"followUp\" }`\n- `{ id?, type: \"steer\", message: string, images?: ImageContent[] }`\n- `{ id?, type: \"follow_up\", message: string, images?: ImageContent[] }`\n- `{ id?, type: \"abort\" }`\n- `{ id?, type: \"abort_and_prompt\", message: string, images?: ImageContent[] }`\n- `{ id?, type: \"new_session\", parentSession?: string }`\n\n### State\n\n- `{ id?, type: \"get_state\" }`\n\n### Model\n\n- `{ id?, type: \"set_model\", provider: string, modelId: string }`\n- `{ id?, type: \"cycle_model\" }`\n- `{ id?, type: \"get_available_models\" }`\n\n### Thinking\n\n- `{ id?, type: \"set_thinking_level\", level: ThinkingLevel }`\n- `{ id?, type: \"cycle_thinking_level\" }`\n\n### Queue modes\n\n- `{ id?, type: \"set_steering_mode\", mode: \"all\" | \"one-at-a-time\" }`\n- `{ id?, type: \"set_follow_up_mode\", mode: \"all\" | \"one-at-a-time\" }`\n- `{ id?, type: \"set_interrupt_mode\", mode: \"immediate\" | \"wait\" }`\n\n### Compaction\n\n- `{ id?, type: \"compact\", customInstructions?: string }`\n- `{ id?, type: \"set_auto_compaction\", enabled: boolean }`\n\n### Retry\n\n- `{ id?, type: \"set_auto_retry\", enabled: boolean }`\n- `{ id?, type: \"abort_retry\" }`\n\n### Bash\n\n- `{ id?, type: \"bash\", command: string }`\n- `{ id?, type: \"abort_bash\" }`\n\n### Session\n\n- `{ id?, type: \"get_session_stats\" }`\n- `{ id?, type: \"export_html\", outputPath?: string }`\n- `{ id?, type: \"switch_session\", sessionPath: string }`\n- `{ id?, type: \"branch\", entryId: string }`\n- `{ id?, type: \"get_branch_messages\" }`\n- `{ id?, type: \"get_last_assistant_text\" }`\n- `{ id?, type: \"set_session_name\", name: string }`\n\n### Messages\n\n- `{ id?, type: \"get_messages\" }`\n\n## Response Schema\n\nAll command results use `RpcResponse`:\n\n- Success: `{ id?, type: \"response\", command: <command>, success: true, data?: ... }`\n- Failure: `{ id?, type: \"response\", command: string, success: false, error: string }`\n\nData payloads are command-specific and defined in `rpc-types.ts`.\n\n### `get_state` payload\n\n```json\n{\n \"model\": { \"provider\": \"...\", \"id\": \"...\" },\n \"thinkingLevel\": \"off|minimal|low|medium|high|xhigh\",\n \"isStreaming\": false,\n \"isCompacting\": false,\n \"steeringMode\": \"all|one-at-a-time\",\n \"followUpMode\": \"all|one-at-a-time\",\n \"interruptMode\": \"immediate|wait\",\n \"sessionFile\": \"...\",\n \"sessionId\": \"...\",\n \"sessionName\": \"...\",\n \"autoCompactionEnabled\": true,\n \"messageCount\": 0,\n \"queuedMessageCount\": 0\n}\n```\n\n## Event Stream Schema\n\nRPC mode forwards `AgentSessionEvent` objects from `AgentSession.subscribe(...)`.\n\nCommon event types:\n\n- `agent_start`, `agent_end`\n- `turn_start`, `turn_end`\n- `message_start`, `message_update`, `message_end`\n- `tool_execution_start`, `tool_execution_update`, `tool_execution_end`\n- `auto_compaction_start`, `auto_compaction_end`\n- `auto_retry_start`, `auto_retry_end`\n- `ttsr_triggered`\n- `todo_reminder`\n\nExtension runner errors are emitted separately as:\n\n```json\n{ \"type\": \"extension_error\", \"extensionPath\": \"...\", \"event\": \"...\", \"error\": \"...\" }\n```\n\n`message_update` includes streaming deltas in `assistantMessageEvent` (text/thinking/toolcall deltas).\n\n## Prompt/Queue Concurrency and Ordering\n\nThis is the most important operational behavior.\n\n### Immediate ack vs completion\n\n`prompt` and `abort_and_prompt` are **acknowledged immediately**:\n\n```json\n{ \"id\": \"req_1\", \"type\": \"response\", \"command\": \"prompt\", \"success\": true }\n```\n\nThat means:\n\n- command acceptance != run completion\n- final completion is observed via `agent_end`\n\n### While streaming\n\n`AgentSession.prompt()` requires `streamingBehavior` during active streaming:\n\n- `\"steer\"` => queued steering message (interrupt path)\n- `\"followUp\"` => queued follow-up message (post-turn path)\n\nIf omitted during streaming, prompt fails.\n\n### Queue defaults\n\nFrom `packages/agent/src/agent.ts` defaults:\n\n- `steeringMode`: `\"one-at-a-time\"`\n- `followUpMode`: `\"one-at-a-time\"`\n- `interruptMode`: `\"immediate\"`\n\n### Mode semantics\n\n- `set_steering_mode` / `set_follow_up_mode`\n - `\"one-at-a-time\"`: dequeue one queued message per turn\n - `\"all\"`: dequeue entire queue at once\n- `set_interrupt_mode`\n - `\"immediate\"`: tool execution checks steering between tool calls; pending steering can abort remaining tool calls in the turn\n - `\"wait\"`: defer steering until turn completion\n\n## Extension UI Sub-Protocol\n\nExtensions in RPC mode use request/response UI frames.\n\n### Outbound request\n\n`RpcExtensionUIRequest` (`type: \"extension_ui_request\"`) methods:\n\n- `select`, `confirm`, `input`, `editor`\n- `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`\n\nExample:\n\n```json\n{ \"type\": \"extension_ui_request\", \"id\": \"123\", \"method\": \"confirm\", \"title\": \"Confirm\", \"message\": \"Continue?\", \"timeout\": 30000 }\n```\n\n### Inbound response\n\n`RpcExtensionUIResponse` (`type: \"extension_ui_response\"`):\n\n- `{ type: \"extension_ui_response\", id: string, value: string }`\n- `{ type: \"extension_ui_response\", id: string, confirmed: boolean }`\n- `{ type: \"extension_ui_response\", id: string, cancelled: true }`\n\nIf a dialog has a timeout, RPC mode resolves to a default value when timeout/abort fires.\n\n## Error Model and Recoverability\n\n### Command-level failures\n\nFailures are `success: false` with string `error`.\n\n```json\n{ \"id\": \"req_2\", \"type\": \"response\", \"command\": \"set_model\", \"success\": false, \"error\": \"Model not found: provider/model\" }\n```\n\n### Recoverability expectations\n\n- Most command failures are recoverable; process remains alive.\n- Malformed JSONL / parse-loop exceptions emit a `parse` error response and continue reading subsequent lines.\n- Empty `set_session_name` is rejected (`Session name cannot be empty`).\n- Extension UI responses with unknown `id` are ignored.\n- Process termination conditions are stdin close or explicit extension-triggered shutdown.\n\n## Compact Command Flows\n\n### 1) Prompt and stream\n\nstdin:\n\n```json\n{ \"id\": \"req_1\", \"type\": \"prompt\", \"message\": \"Summarize this repo\" }\n```\n\nstdout sequence (typical):\n\n```json\n{ \"id\": \"req_1\", \"type\": \"response\", \"command\": \"prompt\", \"success\": true }\n{ \"type\": \"agent_start\" }\n{ \"type\": \"message_update\", \"assistantMessageEvent\": { \"type\": \"text_delta\", \"delta\": \"...\" }, \"message\": { \"role\": \"assistant\", \"content\": [] } }\n{ \"type\": \"agent_end\", \"messages\": [] }\n```\n\n### 2) Prompt during streaming with explicit queue policy\n\nstdin:\n\n```json\n{ \"id\": \"req_2\", \"type\": \"prompt\", \"message\": \"Also include risks\", \"streamingBehavior\": \"followUp\" }\n```\n\n### 3) Inspect and tune queue behavior\n\nstdin:\n\n```json\n{ \"id\": \"q1\", \"type\": \"get_state\" }\n{ \"id\": \"q2\", \"type\": \"set_steering_mode\", \"mode\": \"all\" }\n{ \"id\": \"q3\", \"type\": \"set_interrupt_mode\", \"mode\": \"wait\" }\n```\n\n### 4) Extension UI round trip\n\nstdout:\n\n```json\n{ \"type\": \"extension_ui_request\", \"id\": \"ui_7\", \"method\": \"input\", \"title\": \"Branch name\", \"placeholder\": \"feature/...\" }\n```\n\nstdin:\n\n```json\n{ \"type\": \"extension_ui_response\", \"id\": \"ui_7\", \"value\": \"feature/rpc-host\" }\n```\n\n## Notes on `RpcClient` helper\n\n`src/modes/rpc/rpc-client.ts` is a convenience wrapper, not the protocol definition.\n\nCurrent helper characteristics:\n\n- Spawns `bun <cliPath> --mode rpc`\n- Correlates responses by generated `req_<n>` ids\n- Dispatches only recognized `AgentEvent` types to listeners\n- Does **not** expose helper methods for every protocol command (for example, `set_interrupt_mode` and `set_session_name` are in protocol types but not wrapped as dedicated methods)\n\nUse raw protocol frames if you need complete surface coverage.",
41
- "rulebook-matching-pipeline.md": "# Rulebook Matching Pipeline\n\nThis document describes how coding-agent discovers rules from supported config formats, normalizes them into a single `Rule` shape, resolves precedence conflicts, and splits the result into:\n\n- **Rulebook rules** (available to the model via system prompt + `rule://` URLs)\n- **TTSR rules** (time-travel stream interruption rules)\n\nIt reflects the current implementation, including partial semantics and metadata that is parsed but not enforced.\n\n## Implementation files\n\n- [`../src/capability/rule.ts`](../packages/coding-agent/src/capability/rule.ts)\n- [`../src/capability/index.ts`](../packages/coding-agent/src/capability/index.ts)\n- [`../src/discovery/index.ts`](../packages/coding-agent/src/discovery/index.ts)\n- [`../src/discovery/helpers.ts`](../packages/coding-agent/src/discovery/helpers.ts)\n- [`../src/discovery/builtin.ts`](../packages/coding-agent/src/discovery/builtin.ts)\n- [`../src/discovery/cursor.ts`](../packages/coding-agent/src/discovery/cursor.ts)\n- [`../src/discovery/windsurf.ts`](../packages/coding-agent/src/discovery/windsurf.ts)\n- [`../src/discovery/cline.ts`](../packages/coding-agent/src/discovery/cline.ts)\n- [`../src/sdk.ts`](../packages/coding-agent/src/sdk.ts)\n- [`../src/system-prompt.ts`](../packages/coding-agent/src/system-prompt.ts)\n- [`../src/internal-urls/rule-protocol.ts`](../packages/coding-agent/src/internal-urls/rule-protocol.ts)\n- [`../src/utils/frontmatter.ts`](../packages/coding-agent/src/utils/frontmatter.ts)\n\n## 1. Canonical rule shape\n\nAll providers normalize source files into `Rule`:\n\n```ts\ninterface Rule {\n name: string;\n path: string;\n content: string;\n globs?: string[];\n alwaysApply?: boolean;\n description?: string;\n ttsrTrigger?: string;\n _source: SourceMeta;\n}\n```\n\nCapability identity is `rule.name` (`ruleCapability.key = rule => rule.name`).\n\nConsequence: precedence and deduplication are **name-based only**. Two different files with the same `name` are considered the same logical rule.\n\n## 2. Discovery sources and normalization\n\n`src/discovery/index.ts` auto-registers providers. For `rules`, current providers are:\n\n- `native` (priority `100`)\n- `cursor` (priority `50`)\n- `windsurf` (priority `50`)\n- `cline` (priority `40`)\n\n### Native provider (`builtin.ts`)\n\nLoads `.omp` rules from:\n\n- project: `<cwd>/.omp/rules/*.{md,mdc}`\n- user: `~/.omp/agent/rules/*.{md,mdc}`\n\nNormalization:\n\n- `name` = filename without `.md`/`.mdc`\n- frontmatter parsed via `parseFrontmatter`\n- `content` = body (frontmatter stripped)\n- `globs`, `alwaysApply`, `description`, `ttsr_trigger` mapped directly\n\nImportant caveat: `globs` is cast as `string[] | undefined` with no element filtering in this provider.\n\n### Cursor provider (`cursor.ts`)\n\nLoads from:\n\n- user: `~/.cursor/rules/*.{mdc,md}`\n- project: `<cwd>/.cursor/rules/*.{mdc,md}`\n\nNormalization (`transformMDCRule`):\n\n- `description`: kept only if string\n- `alwaysApply`: only `true` is preserved (`false` becomes `undefined`)\n- `globs`: accepts array (string elements only) or single string\n- `ttsr_trigger`: string only\n- `name` from filename without extension\n\n### Windsurf provider (`windsurf.ts`)\n\nLoads from:\n\n- user: `~/.codeium/windsurf/memories/global_rules.md` (fixed rule name `global_rules`)\n- project: `<cwd>/.windsurf/rules/*.md`\n\nNormalization:\n\n- `globs`: array-of-string or single string\n- `alwaysApply`, `description` cast from frontmatter\n- `ttsr_trigger`: string only\n- `name` from filename for project rules\n\n### Cline provider (`cline.ts`)\n\nSearches upward from `cwd` for nearest `.clinerules`:\n\n- if directory: loads `*.md` inside it\n- if file: loads single file as rule named `clinerules`\n\nNormalization:\n\n- `globs`: array-of-string or single string\n- `alwaysApply`: only if boolean\n- `description`: string only\n- `ttsr_trigger`: string only\n\n## 3. Frontmatter parsing behavior and ambiguity\n\nAll providers use `parseFrontmatter` (`utils/frontmatter.ts`) with these semantics:\n\n1. Frontmatter is parsed only when content starts with `---` and has a closing `\\n---`.\n2. Body is trimmed after frontmatter extraction.\n3. If YAML parse fails:\n - warning is logged,\n - parser falls back to simple `key: value` line parsing (`^(\\w+):\\s*(.*)$`).\n\nAmbiguity consequences:\n\n- Fallback parser does not support arrays, nested objects, quoting rules, or hyphenated keys.\n- Fallback values become strings (for example `alwaysApply: true` becomes string `\"true\"`), so providers requiring boolean/string types may drop metadata.\n- `ttsr_trigger` works in fallback (underscore key); keys like `thinking-level` would not.\n- Files without valid frontmatter still load as rules with empty metadata and full content body.\n\n## 4. Provider precedence and deduplication\n\n`loadCapability(\"rules\")` (`capability/index.ts`) merges provider outputs and then deduplicates by `rule.name`.\n\n### Precedence model\n\n- Providers are ordered by priority descending.\n- Equal priority keeps registration order (`cursor` before `windsurf` from `discovery/index.ts`).\n- Dedup is first-wins: first encountered rule name is kept; later same-name items are marked `_shadowed` in `all` and excluded from `items`.\n\nEffective rule provider order is currently:\n\n1. `native` (100)\n2. `cursor` (50)\n3. `windsurf` (50)\n4. `cline` (40)\n\n### Intra-provider ordering caveat\n\nWithin a provider, item order comes from `loadFilesFromDir` glob result ordering plus explicit push order. This is deterministic enough for normal use but not explicitly sorted in code.\n\nNotable source-order differences:\n\n- `native` appends project then user config dirs.\n- `cursor` appends user then project results.\n- `windsurf` appends user `global_rules` first, then project rules.\n- `cline` loads only nearest `.clinerules` source.\n\n## 5. Split into Rulebook vs TTSR buckets\n\nAfter rule discovery in `createAgentSession` (`sdk.ts`):\n\n1. All discovered rules are scanned.\n2. Rules with `ttsrTrigger` are registered into `TtsrManager`.\n3. A separate `rulebookRules` list is built with this predicate:\n\n```ts\n!rule.ttsrTrigger && !rule.alwaysApply && !!rule.description\n```\n\n### Bucket behavior\n\n- **TTSR bucket**: any rule with `ttsrTrigger` (description not required).\n- **Rulebook bucket**: must have description, must not be TTSR, must not be `alwaysApply`.\n- A rule with both `ttsrTrigger` and `description` goes to TTSR only.\n- A rule marked `alwaysApply` is currently excluded from rulebook.\n\n## 6. How metadata affects runtime surfaces\n\n### `description`\n\n- Required for inclusion in rulebook.\n- Rendered in system prompt `<rules>` block.\n- Missing description means rule is not available via `rule://` and not listed in system prompt rules.\n\n### `globs`\n\n- Carried through on `Rule`.\n- Rendered as `<glob>...</glob>` entries in the system prompt rules block.\n- Exposed in rules UI state (`extensions` mode list).\n- **Not enforced for automatic matching in this pipeline.** There is no runtime glob matcher selecting rules by current file/tool target.\n\n### `alwaysApply`\n\n- Parsed and preserved by providers.\n- Used in UI display (`\"always\"` trigger label in extensions state manager).\n- Used as an exclusion condition from `rulebookRules`.\n- **Not used to auto-inject content into system prompt in current implementation.**\n\n### `ttsr_trigger`\n\n- Mapped to `rule.ttsrTrigger`.\n- If present, rule is routed to TTSR manager, not rulebook.\n\n## 7. System prompt inclusion path\n\n`buildSystemPromptInternal(..., { rules: rulebookRules })` injects rulebook rules into system prompt templates.\n\nTemplates include:\n\n- `Read rule://<name> when working in matching domain`\n- A `<rules>` block with each rule's `name`, `description`, and optional `<glob>` list\n\nThis is advisory/contextual: prompt text asks the model to read applicable rules, but code does not enforce glob applicability.\n\n## 8. `rule://` internal URL behavior\n\n`RuleProtocolHandler` is registered with:\n\n```ts\nnew RuleProtocolHandler({ getRules: () => rulebookRules })\n```\n\nImplications:\n\n- `rule://<name>` resolves only against **rulebookRules** (not all discovered rules).\n- TTSR-only rules and rules filtered out for missing description/`alwaysApply` are not addressable via `rule://`.\n- Resolution is exact name match.\n- Unknown names return error listing available rule names.\n- Returned content is raw `rule.content` (frontmatter stripped), content type `text/markdown`.\n\n## 9. Known partial / non-enforced semantics\n\n1. Provider descriptions mention legacy files (`.cursorrules`, `.windsurfrules`), but current loader code paths do not actually read those files.\n2. `globs` metadata is surfaced to prompt/UI but not enforced by rule selection logic.\n3. `alwaysApply` does not force inclusion into system prompt; current behavior excludes such rules from `rulebookRules`.\n4. Rule selection for `rule://` is constrained to prefiltered rulebook rules, not the full discovered set.\n5. Discovery warnings (`loadCapability(\"rules\").warnings`) are produced but `createAgentSession` does not currently surface/log them in this path.\n",
41
+ "rulebook-matching-pipeline.md": "# Rulebook Matching Pipeline\n\nThis document describes how coding-agent discovers rules from supported config formats, normalizes them into a single `Rule` shape, resolves precedence conflicts, and splits the result into:\n\n- **Rulebook rules** (available to the model via system prompt + `rule://` URLs)\n- **TTSR rules** (time-travel stream interruption rules)\n\nIt reflects the current implementation, including partial semantics and metadata that is parsed but not enforced.\n\n## Implementation files\n\n- [`../src/capability/rule.ts`](../packages/coding-agent/src/capability/rule.ts)\n- [`../src/capability/index.ts`](../packages/coding-agent/src/capability/index.ts)\n- [`../src/discovery/index.ts`](../packages/coding-agent/src/discovery/index.ts)\n- [`../src/discovery/helpers.ts`](../packages/coding-agent/src/discovery/helpers.ts)\n- [`../src/discovery/builtin.ts`](../packages/coding-agent/src/discovery/builtin.ts)\n- [`../src/discovery/cursor.ts`](../packages/coding-agent/src/discovery/cursor.ts)\n- [`../src/discovery/windsurf.ts`](../packages/coding-agent/src/discovery/windsurf.ts)\n- [`../src/discovery/cline.ts`](../packages/coding-agent/src/discovery/cline.ts)\n- [`../src/sdk.ts`](../packages/coding-agent/src/sdk.ts)\n- [`../src/system-prompt.ts`](../packages/coding-agent/src/system-prompt.ts)\n- [`../src/internal-urls/rule-protocol.ts`](../packages/coding-agent/src/internal-urls/rule-protocol.ts)\n- [`../src/utils/frontmatter.ts`](../packages/coding-agent/src/utils/frontmatter.ts)\n\n## 1. Canonical rule shape\n\nAll providers normalize source files into `Rule`:\n\n```ts\ninterface Rule {\n name: string;\n path: string;\n content: string;\n globs?: string[];\n alwaysApply?: boolean;\n description?: string;\n ttsrTrigger?: string;\n _source: SourceMeta;\n}\n```\n\nCapability identity is `rule.name` (`ruleCapability.key = rule => rule.name`).\n\nConsequence: precedence and deduplication are **name-based only**. Two different files with the same `name` are considered the same logical rule.\n\n## 2. Discovery sources and normalization\n\n`src/discovery/index.ts` auto-registers providers. For `rules`, current providers are:\n\n- `native` (priority `100`)\n- `cursor` (priority `50`)\n- `windsurf` (priority `50`)\n- `cline` (priority `40`)\n\n### Native provider (`builtin.ts`)\n\nLoads `.omp` rules from:\n\n- project: `<cwd>/.omp/rules/*.{md,mdc}`\n- user: `~/.omp/agent/rules/*.{md,mdc}`\n\nNormalization:\n\n- `name` = filename without `.md`/`.mdc`\n- frontmatter parsed via `parseFrontmatter`\n- `content` = body (frontmatter stripped)\n- `globs`, `alwaysApply`, `description`, `ttsr_trigger` mapped directly\n\nImportant caveat: `globs` is cast as `string[] | undefined` with no element filtering in this provider.\n\n### Cursor provider (`cursor.ts`)\n\nLoads from:\n\n- user: `~/.cursor/rules/*.{mdc,md}`\n- project: `<cwd>/.cursor/rules/*.{mdc,md}`\n\nNormalization (`transformMDCRule`):\n\n- `description`: kept only if string\n- `alwaysApply`: only `true` is preserved (`false` becomes `undefined`)\n- `globs`: accepts array (string elements only) or single string\n- `ttsr_trigger`: string only\n- `name` from filename without extension\n\n### Windsurf provider (`windsurf.ts`)\n\nLoads from:\n\n- user: `~/.codeium/windsurf/memories/global_rules.md` (fixed rule name `global_rules`)\n- project: `<cwd>/.windsurf/rules/*.md`\n\nNormalization:\n\n- `globs`: array-of-string or single string\n- `alwaysApply`, `description` cast from frontmatter\n- `ttsr_trigger`: string only\n- `name` from filename for project rules\n\n### Cline provider (`cline.ts`)\n\nSearches upward from `cwd` for nearest `.clinerules`:\n\n- if directory: loads `*.md` inside it\n- if file: loads single file as rule named `clinerules`\n\nNormalization:\n\n- `globs`: array-of-string or single string\n- `alwaysApply`: only if boolean\n- `description`: string only\n- `ttsr_trigger`: string only\n\n## 3. Frontmatter parsing behavior and ambiguity\n\nAll providers use `parseFrontmatter` (`utils/frontmatter.ts`) with these semantics:\n\n1. Frontmatter is parsed only when content starts with `---` and has a closing `\\n---`.\n2. Body is trimmed after frontmatter extraction.\n3. If YAML parse fails:\n - warning is logged,\n - parser falls back to simple `key: value` line parsing (`^(\\w+):\\s*(.*)$`).\n\nAmbiguity consequences:\n\n- Fallback parser does not support arrays, nested objects, quoting rules, or hyphenated keys.\n- Fallback values become strings (for example `alwaysApply: true` becomes string `\"true\"`), so providers requiring boolean/string types may drop metadata.\n- `ttsr_trigger` works in fallback (underscore key); keys like `thinking-level` would not.\n- Files without valid frontmatter still load as rules with empty metadata and full content body.\n\n## 4. Provider precedence and deduplication\n\n`loadCapability(\"rules\")` (`capability/index.ts`) merges provider outputs and then deduplicates by `rule.name`.\n\n### Precedence model\n\n- Providers are ordered by priority descending.\n- Equal priority keeps registration order (`cursor` before `windsurf` from `discovery/index.ts`).\n- Dedup is first-wins: first encountered rule name is kept; later same-name items are marked `_shadowed` in `all` and excluded from `items`.\n\nEffective rule provider order is currently:\n\n1. `native` (100)\n2. `cursor` (50)\n3. `windsurf` (50)\n4. `cline` (40)\n\n### Intra-provider ordering caveat\n\nWithin a provider, item order comes from `loadFilesFromDir` glob result ordering plus explicit push order. This is deterministic enough for normal use but not explicitly sorted in code.\n\nNotable source-order differences:\n\n- `native` appends project then user config dirs.\n- `cursor` appends user then project results.\n- `windsurf` appends user `global_rules` first, then project rules.\n- `cline` loads only nearest `.clinerules` source.\n\n## 5. Split into Rulebook, Always-Apply, and TTSR buckets\n\nAfter rule discovery in `createAgentSession` (`sdk.ts`):\n\n1. All discovered rules are scanned.\n2. Rules with `condition` (frontmatter key; `ttsr_trigger` / `ttsrTrigger` accepted as fallback) are registered into `TtsrManager`.\n3. A separate `rulebookRules` list is built with this predicate:\n\n```ts\n!registeredTtsrRuleNames.has(rule.name) && !rule.alwaysApply && !!rule.description\n```\n\n4. An `alwaysApplyRules` list is built:\n\n```ts\n!registeredTtsrRuleNames.has(rule.name) && rule.alwaysApply === true\n```\n\n### Bucket behavior\n\n- **TTSR bucket**: any rule with `condition` (description not required). Takes priority over other buckets.\n- **Always-apply bucket**: `alwaysApply === true`, not TTSR. Full content injected into system prompt. Resolvable via `rule://`.\n- **Rulebook bucket**: must have description, must not be TTSR, must not be `alwaysApply`. Listed in system prompt by name+description; content read on demand via `rule://`.\n- A rule with both `condition` and `alwaysApply` goes to TTSR only (TTSR takes priority).\n- A rule with both `alwaysApply` and `description` goes to always-apply only (not rulebook).\n\n## 6. How metadata affects runtime surfaces\n\n### `description`\n\n- Required for inclusion in rulebook.\n- Rendered in system prompt `<rules>` block.\n- Missing description means rule is not available via `rule://` and not listed in system prompt rules.\n\n### `globs`\n\n- Carried through on `Rule`.\n- Rendered as `<glob>...</glob>` entries in the system prompt rules block.\n- Exposed in rules UI state (`extensions` mode list).\n- **Not enforced for automatic matching in this pipeline.** There is no runtime glob matcher selecting rules by current file/tool target.\n\n### `alwaysApply`\n\n- Parsed and preserved by providers.\n- Used in UI display (`\"always\"` trigger label in extensions state manager).\n- Used as an exclusion condition from `rulebookRules`.\n- **Full rule content is auto-injected into the system prompt** (before the rulebook rules section).\n- Rule is also addressable via `rule://<name>` for re-reading.\n\n### `ttsr_trigger`\n\n- Mapped to `rule.ttsrTrigger`.\n- If present, rule is routed to TTSR manager, not rulebook.\n\n## 7. System prompt inclusion path\n\n`buildSystemPromptInternal` receives both `rules` (rulebook) and `alwaysApplyRules`.\n\nAlways-apply rules are rendered first, injecting their raw content directly into the prompt.\n\nRulebook rules are rendered in a `# Rules` section with:\n\n- `Read rule://<name> when working in matching domain`\n- Each rule's `name`, `description`, and optional `<glob>` list\n\nThis is advisory/contextual: prompt text asks the model to read applicable rules, but code does not enforce glob applicability.\n\n## 8. `rule://` internal URL behavior\n\n`RuleProtocolHandler` is registered with:\n\n```ts\nnew RuleProtocolHandler({ getRules: () => [...rulebookRules, ...alwaysApplyRules] })\n```\n\nImplications:\n\n- `rule://<name>` resolves against both **rulebookRules** and **alwaysApplyRules**.\n- TTSR-only rules and rules with no description and no `alwaysApply` are not addressable via `rule://`.\n- Resolution is exact name match.\n- Unknown names return error listing available rule names.\n- Returned content is raw `rule.content` (frontmatter stripped), content type `text/markdown`.\n\n## 9. Known partial / non-enforced semantics\n\n1. Provider descriptions mention legacy files (`.cursorrules`, `.windsurfrules`), but current loader code paths do not actually read those files.\n2. `globs` metadata is surfaced to prompt/UI but not enforced by rule selection logic.\n3. Rule selection for `rule://` includes rulebook and always-apply rules, but not TTSR-only rules.\n4. Discovery warnings (`loadCapability(\"rules\").warnings`) are produced but `createAgentSession` does not currently surface/log them in this path.\n",
42
42
  "sdk.md": "# SDK\n\nThe SDK is the in-process integration surface for `@oh-my-pi/pi-coding-agent`.\nUse it when you want direct access to agent state, event streaming, tool wiring, and session control from your own Bun/Node process.\n\nIf you need cross-language/process isolation, use RPC mode instead.\n\n## Installation\n\n```bash\nbun add @oh-my-pi/pi-coding-agent\n```\n\n## Entry points\n\n`@oh-my-pi/pi-coding-agent` exports the SDK APIs from the package root (and also via `@oh-my-pi/pi-coding-agent/sdk`).\n\nCore exports for embedders:\n\n- `createAgentSession`\n- `SessionManager`\n- `Settings`\n- `AuthStorage`\n- `ModelRegistry`\n- `discoverAuthStorage`\n- Discovery helpers (`discoverExtensions`, `discoverSkills`, `discoverContextFiles`, `discoverPromptTemplates`, `discoverSlashCommands`, `discoverCustomTSCommands`, `discoverMCPServers`)\n- Tool factory surface (`createTools`, `BUILTIN_TOOLS`, tool classes)\n\n## Quick start (auto-discovery defaults)\n\n```ts\nimport { createAgentSession } from \"@oh-my-pi/pi-coding-agent\";\n\nconst { session, modelFallbackMessage } = await createAgentSession();\n\nif (modelFallbackMessage) {\n\tprocess.stderr.write(`${modelFallbackMessage}\\n`);\n}\n\nconst unsubscribe = session.subscribe(event => {\n\tif (event.type === \"message_update\" && event.assistantMessageEvent.type === \"text_delta\") {\n\t\tprocess.stdout.write(event.assistantMessageEvent.delta);\n\t}\n});\n\nawait session.prompt(\"Summarize this repository in 3 bullets.\");\nunsubscribe();\nawait session.dispose();\n```\n\n## What `createAgentSession()` discovers by default\n\n`createAgentSession()` follows “provide to override, omit to discover”.\n\nIf omitted, it resolves:\n\n- `cwd`: `getProjectDir()`\n- `agentDir`: `~/.omp/agent` (via `getAgentDir()`)\n- `authStorage`: `discoverAuthStorage(agentDir)`\n- `modelRegistry`: `new ModelRegistry(authStorage)` + `await refresh()`\n- `settings`: `await Settings.init({ cwd, agentDir })`\n- `sessionManager`: `SessionManager.create(cwd)` (file-backed)\n- skills/context files/prompt templates/slash commands/extensions/custom TS commands\n- built-in tools via `createTools(...)`\n- MCP tools (enabled by default)\n- LSP integration (enabled by default)\n\n### Required vs optional inputs\n\nTypically you must provide only what you want to control:\n\n- **Must provide**: nothing for a minimal session\n- **Usually provide explicitly** in embedders:\n\t- `sessionManager` (if you need in-memory or custom location)\n\t- `authStorage` + `modelRegistry` (if you own credential/model lifecycle)\n\t- `model` or `modelPattern` (if deterministic model selection matters)\n\t- `settings` (if you need isolated/test config)\n\n## Session manager behavior (persistent vs in-memory)\n\n`AgentSession` always uses a `SessionManager`; behavior depends on which factory you use.\n\n### File-backed (default)\n\n```ts\nimport { createAgentSession, SessionManager } from \"@oh-my-pi/pi-coding-agent\";\n\nconst { session } = await createAgentSession({\n\tsessionManager: SessionManager.create(process.cwd()),\n});\n\nconsole.log(session.sessionFile); // absolute .jsonl path\n```\n\n- Persists conversation/messages/state deltas to session files.\n- Supports resume/open/list/fork workflows.\n- `session.sessionFile` is defined.\n\n### In-memory\n\n```ts\nimport { createAgentSession, SessionManager } from \"@oh-my-pi/pi-coding-agent\";\n\nconst { session } = await createAgentSession({\n\tsessionManager: SessionManager.inMemory(),\n});\n\nconsole.log(session.sessionFile); // undefined\n```\n\n- No filesystem persistence.\n- Useful for tests, ephemeral workers, request-scoped agents.\n- Session methods still work, but persistence-specific behaviors (file resume/fork paths) are naturally limited.\n\n### Resume/open/list helpers\n\n```ts\nimport { SessionManager } from \"@oh-my-pi/pi-coding-agent\";\n\nconst recent = await SessionManager.continueRecent(process.cwd());\nconst listed = await SessionManager.list(process.cwd());\nconst opened = listed[0] ? await SessionManager.open(listed[0].path) : null;\n```\n\n## Model and auth wiring\n\n`createAgentSession()` uses `ModelRegistry` + `AuthStorage` for model selection and API key resolution.\n\n### Explicit wiring\n\n```ts\nimport {\n\tcreateAgentSession,\n\tdiscoverAuthStorage,\n\tModelRegistry,\n\tSessionManager,\n} from \"@oh-my-pi/pi-coding-agent\";\n\nconst authStorage = await discoverAuthStorage();\nconst modelRegistry = new ModelRegistry(authStorage);\nawait modelRegistry.refresh();\n\nconst available = modelRegistry.getAvailable();\nif (available.length === 0) throw new Error(\"No authenticated models available\");\n\nconst { session } = await createAgentSession({\n\tauthStorage,\n\tmodelRegistry,\n\tmodel: available[0],\n\tthinkingLevel: \"medium\",\n\tsessionManager: SessionManager.inMemory(),\n});\n```\n\n### Selection order when `model` is omitted\n\nWhen no explicit `model`/`modelPattern` is provided:\n\n1. restore model from existing session (if restorable + key available)\n2. settings default model role (`default`)\n3. first available model with valid auth\n\nIf restore fails, `modelFallbackMessage` explains fallback.\n\n### Auth priority\n\n`AuthStorage.getApiKey(...)` resolves in this order:\n\n1. runtime override (`setRuntimeApiKey`)\n2. stored credentials in `agent.db`\n3. provider environment variables\n4. custom-provider resolver fallback (if configured)\n\n## Event subscription model\n\nSubscribe with `session.subscribe(listener)`; it returns an unsubscribe function.\n\n```ts\nconst unsubscribe = session.subscribe(event => {\n\tswitch (event.type) {\n\t\tcase \"agent_start\":\n\t\tcase \"turn_start\":\n\t\tcase \"tool_execution_start\":\n\t\t\tbreak;\n\t\tcase \"message_update\":\n\t\t\tif (event.assistantMessageEvent.type === \"text_delta\") {\n\t\t\t\tprocess.stdout.write(event.assistantMessageEvent.delta);\n\t\t\t}\n\t\t\tbreak;\n\t}\n});\n```\n\n`AgentSessionEvent` includes core `AgentEvent` plus session-level events:\n\n- `auto_compaction_start` / `auto_compaction_end`\n- `auto_retry_start` / `auto_retry_end`\n- `ttsr_triggered`\n- `todo_reminder`\n\n## Prompt lifecycle\n\n`session.prompt(text, options?)` is the primary entry point.\n\nBehavior:\n\n1. optional command/template expansion (`/` commands, custom commands, file slash commands, prompt templates)\n2. if currently streaming:\n\t- requires `streamingBehavior: \"steer\" | \"followUp\"`\n\t- queues instead of throwing work away\n3. if idle:\n\t- validates model + API key\n\t- appends user message\n\t- starts agent turn\n\nRelated APIs:\n\n- `sendUserMessage(content, { deliverAs? })`\n- `steer(text, images?)`\n- `followUp(text, images?)`\n- `sendCustomMessage({ customType, content, ... }, { deliverAs?, triggerTurn? })`\n- `abort()`\n\n## Tools and extension integration\n\n### Built-ins and filtering\n\n- Built-ins come from `createTools(...)` and `BUILTIN_TOOLS`.\n- `toolNames` acts as an allowlist for built-ins.\n- `customTools` and extension-registered tools are still included.\n- Hidden tools (for example `submit_result`) are opt-in unless required by options.\n\n```ts\nconst { session } = await createAgentSession({\n\ttoolNames: [\"read\", \"grep\", \"find\", \"write\"],\n\trequireSubmitResultTool: true,\n});\n```\n\n### Extensions\n\n- `extensions`: inline `ExtensionFactory[]`\n- `additionalExtensionPaths`: load extra extension files\n- `disableExtensionDiscovery`: disable automatic extension scanning\n- `preloadedExtensions`: reuse already loaded extension set\n\n### Runtime tool set changes\n\n`AgentSession` supports runtime activation updates:\n\n- `getActiveToolNames()`\n- `getAllToolNames()`\n- `setActiveToolsByName(names)`\n- `refreshMCPTools(mcpTools)`\n\nSystem prompt is rebuilt to reflect active tool changes.\n\n## Discovery helpers\n\nUse these when you want partial control without recreating internal discovery logic:\n\n- `discoverAuthStorage(agentDir?)`\n- `discoverExtensions(cwd?)`\n- `discoverSkills(cwd?, _agentDir?, settings?)`\n- `discoverContextFiles(cwd?, _agentDir?)`\n- `discoverPromptTemplates(cwd?, agentDir?)`\n- `discoverSlashCommands(cwd?)`\n- `discoverCustomTSCommands(cwd?, agentDir?)`\n- `discoverMCPServers(cwd?)`\n- `buildSystemPrompt(options?)`\n\n## Subagent-oriented options\n\nFor SDK consumers building orchestrators (similar to task executor flow):\n\n- `outputSchema`: passes structured output expectation into tool context\n- `requireSubmitResultTool`: forces `submit_result` tool inclusion\n- `taskDepth`: recursion-depth context for nested task sessions\n- `parentTaskPrefix`: artifact naming prefix for nested task outputs\n\nThese are optional for normal single-agent embedding.\n\n## `createAgentSession()` return value\n\n```ts\ntype CreateAgentSessionResult = {\n\tsession: AgentSession;\n\textensionsResult: LoadExtensionsResult;\n\tsetToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void;\n\tmcpManager?: MCPManager;\n\tmodelFallbackMessage?: string;\n\tlspServers?: Array<{ name: string; status: \"ready\" | \"error\"; fileTypes: string[]; error?: string }>;\n};\n```\n\nUse `setToolUIContext(...)` only if your embedder provides UI capabilities that tools/extensions should call into.\n\n## Minimal controlled embed example\n\n```ts\nimport {\n\tcreateAgentSession,\n\tdiscoverAuthStorage,\n\tModelRegistry,\n\tSessionManager,\n\tSettings,\n} from \"@oh-my-pi/pi-coding-agent\";\n\nconst authStorage = await discoverAuthStorage();\nconst modelRegistry = new ModelRegistry(authStorage);\nawait modelRegistry.refresh();\n\nconst settings = Settings.isolated({\n\t\"compaction.enabled\": true,\n\t\"retry.enabled\": true,\n});\n\nconst { session } = await createAgentSession({\n\tauthStorage,\n\tmodelRegistry,\n\tsettings,\n\tsessionManager: SessionManager.inMemory(),\n\ttoolNames: [\"read\", \"grep\", \"find\", \"edit\", \"write\"],\n\tenableMCP: false,\n\tenableLsp: true,\n});\n\nsession.subscribe(event => {\n\tif (event.type === \"message_update\" && event.assistantMessageEvent.type === \"text_delta\") {\n\t\tprocess.stdout.write(event.assistantMessageEvent.delta);\n\t}\n});\n\nawait session.prompt(\"Find all TODO comments in this repo and propose fixes.\");\nawait session.dispose();\n```\n",
43
43
  "secrets.md": "# Secret Obfuscation\n\nPrevents sensitive values (API keys, tokens, passwords) from being sent to LLM providers. When enabled, secrets are replaced with deterministic placeholders before leaving the process, and restored in tool call arguments returned by the model.\n\n## Enabling\n\nDisabled by default. Toggle via `/settings` UI or directly in `config.yml`:\n\n```yaml\nsecrets:\n enabled: true\n```\n\n## How it works\n\n1. On session startup, secrets are collected from two sources:\n - **Environment variables** matching common secret patterns (`*_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, etc.) with values >= 8 characters\n - **`secrets.yml` files** (see below)\n\n2. Outbound messages to the LLM have all secret values replaced with placeholders like `<<$env:S0>>`, `<<$env:S1>>`, etc.\n\n3. Tool call arguments returned by the model are deep-walked and placeholders are restored to original values before execution.\n\nTwo modes control what happens to each secret:\n\n| Mode | Behavior | Reversible |\n|---|---|---|\n| `obfuscate` (default) | Replaced with indexed placeholder `<<$env:SN>>` | Yes (deobfuscated in tool args) |\n| `replace` | Replaced with deterministic same-length string | No (one-way) |\n\n## secrets.yml\n\nDefine custom secret entries in YAML. Two locations are checked:\n\n| Level | Path | Purpose |\n|---|---|---|\n| Global | `~/.omp/agent/secrets.yml` | Secrets across all projects |\n| Project | `<cwd>/.omp/secrets.yml` | Project-specific secrets |\n\nProject entries override global entries with matching `content`.\n\n### Schema\n\nEach entry in the array has these fields:\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `type` | `\"plain\"` or `\"regex\"` | Yes | Match strategy |\n| `content` | string | Yes | The secret value (plain) or regex pattern (regex) |\n| `mode` | `\"obfuscate\"` or `\"replace\"` | No | Default: `\"obfuscate\"` |\n| `replacement` | string | No | Custom replacement (replace mode only) |\n| `flags` | string | No | Regex flags (regex type only) |\n\n### Examples\n\n#### Plain secrets\n\n```yaml\n# Obfuscate a specific API key (default mode)\n- type: plain\n content: sk-proj-abc123def456\n\n# Replace a database password with a fixed string\n- type: plain\n content: hunter2\n mode: replace\n replacement: \"********\"\n```\n\n#### Regex secrets\n\n```yaml\n# Obfuscate any AWS-style key\n- type: regex\n content: \"AKIA[0-9A-Z]{16}\"\n\n# Case-insensitive match with explicit flags\n- type: regex\n content: \"api[_-]?key\\\\s*=\\\\s*\\\\w+\"\n flags: \"i\"\n\n# Regex literal syntax (pattern and flags in one string)\n- type: regex\n content: \"/bearer\\\\s+[a-zA-Z0-9._~+\\\\/=-]+/i\"\n```\n\nRegex entries always scan globally (the `g` flag is enforced automatically). The regex literal syntax `/pattern/flags` is supported as an alternative to separate `content` + `flags` fields. Escaped slashes within the pattern (`\\\\/`) are handled correctly.\n\n#### Replace mode with regex\n\n```yaml\n# One-way replace connection strings (not reversible)\n- type: regex\n content: \"postgres://[^\\\\s]+\"\n mode: replace\n replacement: \"postgres://***\"\n```\n\n## Interaction with env var detection\n\nEnvironment variables are always collected first. File-defined entries are appended after, so file entries can cover secrets that don't live in env vars (config files, hardcoded values, etc.). If the same value appears in both, the file entry's mode takes precedence.\n\n## Key files\n\n- `src/secrets/index.ts` -- loading, merging, env var collection\n- `src/secrets/obfuscator.ts` -- `SecretObfuscator` class, placeholder generation, message obfuscation\n- `src/secrets/regex.ts` -- regex literal parsing and compilation\n- `src/config/settings-schema.ts` -- `secrets.enabled` setting definition\n",
44
44
  "session-operations-export-share-fork-resume.md": "# Session Operations: export, dump, share, fork, resume/continue\n\nThis document describes operator-visible behavior for session export/share/fork/resume operations as currently implemented.\n\n## Implementation files\n\n- [`../src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.ts)\n- [`../src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)\n- [`../src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)\n- [`../src/export/html/index.ts`](../packages/coding-agent/src/export/html/index.ts)\n- [`../src/export/custom-share.ts`](../packages/coding-agent/src/export/custom-share.ts)\n- [`../src/main.ts`](../packages/coding-agent/src/main.ts)\n\n## Operation matrix\n\n| Operation | Entry path | Session mutation | Session file creation/switch | Output artifact |\n|---|---|---|---|---|\n| `/dump` | Interactive slash command | No | No | Clipboard text |\n| `/export [path]` | Interactive slash command | No | No | HTML file |\n| `--export <session.jsonl> [outputPath]` | CLI startup fast-path | No runtime session mutation | No active session; reads target file | HTML file |\n| `/share` | Interactive slash command | No | No | Temp HTML + share URL/gist |\n| `/fork` | Interactive slash command | Yes (active session identity changes) | Creates new session file and switches current session to it (persistent mode only) | Copies artifact directory to new session namespace when present |\n| `/resume` | Interactive slash command | Yes (active in-memory state replaced) | Switches to selected existing session file | None |\n| `--resume` | CLI startup (picker) | Yes after session creation | Opens selected existing session file | None |\n| `--resume <id|path>` | CLI startup | Yes after session creation | Opens existing session; cross-project case can fork into current project | None |\n| `--continue` | CLI startup | Yes after session creation | Opens terminal breadcrumb or most-recent session; creates new one if none exists | None |\n\n## Export and dump\n\n### `/export [outputPath]` (interactive)\n\nFlow:\n\n1. `InputController` routes `/export...` to `CommandController.handleExportCommand`.\n2. The command splits on whitespace and uses only the first argument after `/export` as `outputPath`.\n3. `AgentSession.exportToHtml()` calls `exportSessionToHtml(sessionManager, state, { outputPath, themeName })`.\n4. On success, UI shows path and opens the file in browser.\n\nBehavior details:\n\n- `--copy`, `clipboard`, and `copy` arguments are explicitly rejected with a warning to use `/dump`.\n- Export embeds session header/entries/leaf plus current `systemPrompt` and tool descriptions from agent state.\n- No session entries are appended during export.\n\nCaveat:\n\n- Argument parsing is whitespace-based (`text.split(/\\s+/)`), so quoted paths with spaces are not preserved as a single path by this command path.\n\n### `--export <inputSessionFile> [outputPath]` (CLI)\n\nFlow in `main.ts`:\n\n1. Handled early (before interactive/session startup).\n2. Calls `exportFromFile(inputPath, outputPath?)`.\n3. `SessionManager.open(inputPath)` loads entries, then HTML is generated and written.\n4. Process prints `Exported to: ...` and exits.\n\nBehavior details:\n\n- Missing input file surfaces as `File not found: <path>`.\n- This path does not create an `AgentSession` and does not mutate any running session.\n\n### `/dump` (interactive clipboard export)\n\nFlow:\n\n1. `CommandController.handleDumpCommand()` calls `session.formatSessionAsText()`.\n2. If empty string, reports `No messages to dump yet.`\n3. Otherwise copies to clipboard via native `copyToClipboard`.\n\nDump content includes:\n\n- System prompt\n- Active model/thinking level\n- Tool definitions + parameters\n- User/assistant messages\n- Thinking blocks and tool calls\n- Tool results and execution blocks (except `excludeFromContext` bash/python entries)\n- Custom/hook/file mention/branch summary/compaction summary entries\n\nNo session persistence changes are made by dumping.\n\n## Share\n\n`/share` is interactive-only and always starts by exporting current session to a temp HTML file.\n\n### Phase 1: temp export\n\n- Temp file path: `${os.tmpdir()}/${Snowflake.next()}.html`\n- Uses `session.exportToHtml(tmpFile)`\n- If export fails (notably in-memory sessions), share ends with error.\n\n### Phase 2: custom share handler (if present)\n\n`loadCustomShare()` checks `~/.omp/agent` for first existing candidate:\n\n- `share.ts`\n- `share.js`\n- `share.mjs`\n\nRequirements:\n\n- Module must default-export a function `(htmlPath) => Promise<CustomShareResult | string | undefined>`.\n\nIf present and valid:\n\n- UI enters `Sharing...` loader state.\n- Handler result interpretation:\n - string => treated as URL, shown and opened\n - object => `url` and/or `message` shown; `url` opened\n - `undefined`/falsy => generic `Session shared`\n- Temp file is removed after completion.\n\nCritical fallback behavior:\n\n- If custom handler exists but loading fails, command errors and returns.\n- If custom handler executes and throws, command errors and returns.\n- In both failure cases, it **does not** fall back to GitHub gist.\n- Gist fallback happens only when no custom share script exists.\n\n### Phase 3: default gist fallback\n\nOnly when no custom share handler is found:\n\n1. Validates `gh auth status`.\n2. Shows `Creating gist...` loader.\n3. Runs `gh gist create --public=false <tmpFile>`.\n4. Parses gist URL, derives gist id, builds preview URL `https://gistpreview.github.io/?<id>`.\n5. Shows both preview and gist URLs; opens preview.\n\nCancellation/abort semantics in share:\n\n- Loader has `onAbort` hook that restores editor UI and reports `Share cancelled`.\n- The underlying `gh gist create` command is not passed an abort signal in this code path; cancellation is UI-level and checked after command returns.\n\n## Fork\n\n`/fork` creates a new session from the current one and switches the active session identity.\n\n### Preconditions and immediate guards\n\n- If agent is streaming, `/fork` is rejected with warning.\n- UI status/loading indicators are cleared before operation.\n\n### Session-level flow\n\n`AgentSession.fork()`:\n\n1. Emits `session_before_switch` with `reason: \"fork\"` (cancellable).\n2. Flushes pending writes.\n3. Calls `SessionManager.fork()`.\n4. Copies artifacts directory from old session namespace to new namespace (best-effort; non-ENOENT copy failures are logged, not fatal).\n5. Updates `agent.sessionId`.\n6. Emits `session_switch` with `reason: \"fork\"`.\n\n`SessionManager.fork()` behavior:\n\n- Requires persistent mode and existing session file.\n- Creates new session id and new JSONL file path.\n- Rewrites header with:\n - new `id`\n - new timestamp\n - `cwd` unchanged\n - `parentSession` set to previous session id\n- Keeps all non-header entries unchanged in the new file.\n\n### Non-persistent behavior\n\n- In-memory session manager returns `undefined` from `fork()`.\n- `AgentSession.fork()` returns `false`.\n- UI reports `Fork failed (session not persisted or cancelled)`.\n\n## Resume and continue\n\n## Interactive `/resume`\n\nFlow:\n\n1. Opens session selector populated via `SessionManager.list(currentCwd, currentSessionDir)`.\n2. On selection, `SelectorController.handleResumeSession(sessionPath)` calls `session.switchSession(sessionPath)`.\n3. UI clears/rebuilds chat and todos, then reports `Resumed session`.\n\nNotes:\n\n- This picker only lists sessions in the current session directory scope.\n- It does not use global cross-project search.\n\n## CLI `--resume`\n\n### `--resume` (no value)\n\n- `main.ts` lists sessions for current cwd/sessionDir and opens picker.\n- Selected path is opened with `SessionManager.open(selectedPath)` before session creation.\n\n### `--resume <value>`\n\n`createSessionManager()` resolution order:\n\n1. If value looks like path (`/`, `\\`, or `.jsonl`), open directly.\n2. Else treat as id prefix:\n - search current scope (`SessionManager.list(cwd, sessionDir)`)\n - if not found and no explicit `sessionDir`, search global (`SessionManager.listAll()`)\n\nCross-project id match behavior:\n\n- If matched session cwd differs from current cwd, CLI asks:\n - `Session found in different project ... Fork into current directory? [y/N]`\n- On yes: `SessionManager.forkFrom(match.path, cwd, sessionDir)` creates a new local forked file.\n- On no/non-TTY default: command errors.\n\n## CLI `--continue`\n\n`SessionManager.continueRecent(cwd, sessionDir)`:\n\n1. Resolves session dir for current cwd.\n2. Reads terminal-scoped breadcrumb first.\n3. Falls back to most recently modified session file.\n4. Opens found session; if none exists, creates new session.\n\nThis is startup-only behavior; there is no interactive `/continue` slash command.\n\n## How session switching actually mutates runtime state\n\n`AgentSession.switchSession(sessionPath)` does the runtime transition used by resume-like operations:\n\n1. Emit `session_before_switch` with `reason: \"resume\"` and `targetSessionFile` (cancellable).\n2. Disconnect agent event subscription and abort in-flight work.\n3. Clear queued steering/follow-up/next-turn messages.\n4. Flush current session manager writes.\n5. `sessionManager.setSessionFile(sessionPath)` and update `agent.sessionId`.\n6. Build session context from loaded entries.\n7. Emit `session_switch` with `reason: \"resume\"`.\n8. Replace agent messages from context.\n9. Restore model (if available in current registry).\n10. Restore or initialize thinking level.\n11. Reconnect agent event subscription.\n\nNo new session file is created by `switchSession()` itself.\n\n## Event emissions and cancellation points\n\n### Switch/fork lifecycle hooks\n\nFor `newSession`, `fork`, and `switchSession`:\n\n- Before event: `session_before_switch`\n - reasons: `new`, `fork`, `resume`\n - cancellable by returning `{ cancel: true }`\n- After event: `session_switch`\n - same reason set\n - includes `previousSessionFile`\n\n`ExtensionRunner.emit()` returns early on the first cancelling before-event result.\n\n### Custom tool `onSession` behavior\n\nSDK bridges extension session events to custom tool `onSession` callbacks:\n\n- `session_switch` -> `onSession({ reason: \"switch\", previousSessionFile })`\n- `session_branch` -> `reason: \"branch\"`\n- `session_start` -> `reason: \"start\"`\n- `session_tree` -> `reason: \"tree\"`\n- `session_shutdown` -> `reason: \"shutdown\"`\n\nThese callbacks are observational; they do not cancel switch/fork.\n\n### Other cancellation surfaces relevant to this doc\n\n- `/fork` is blocked while streaming (user must wait/abort current response first).\n- `/resume` selector can be cancelled by user closing selector.\n- Cross-project `--resume <id>` can be cancelled by declining fork prompt.\n- `/share` has UI abort path (`Share cancelled`) for gist flow; it does not wire process-kill semantics for `gh gist create` in this code path.\n\n## Non-persistent (in-memory) session behavior\n\nWhen session manager is created with `SessionManager.inMemory()` (`--no-session`):\n\n- Session file path is absent.\n- `/export` and `/share` fail with `Cannot export in-memory session to HTML` (propagated to command error UI).\n- `/fork` fails because `SessionManager.fork()` requires persistence.\n- `/dump` still works because it serializes in-memory agent state.\n- CLI resume/continue semantics are bypassed if `--no-session` is set, because manager creation returns in-memory immediately.\n\n## Known implementation caveats (as of current code)\n\n- `SelectorController.handleResumeSession()` does not check the boolean result from `session.switchSession(...)`; a hook-cancelled switch can still proceed through UI \"Resumed session\" repaint/status path.\n- `/share` custom-share failures do not degrade to default gist fallback; they terminate the command with error.\n- `/export` argument tokenization is simplistic and does not preserve quoted paths with spaces.\n",
@@ -11,6 +11,7 @@ type ConfigurableEditorAction = Extract<
11
11
  | "app.model.cycleForward"
12
12
  | "app.model.cycleBackward"
13
13
  | "app.model.select"
14
+ | "app.model.selectTemporary"
14
15
  | "app.tools.expand"
15
16
  | "app.thinking.toggle"
16
17
  | "app.editor.external"
@@ -29,6 +30,7 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
29
30
  "app.model.cycleForward": ["ctrl+p"],
30
31
  "app.model.cycleBackward": ["shift+ctrl+p"],
31
32
  "app.model.select": ["ctrl+l"],
33
+ "app.model.selectTemporary": ["alt+p"],
32
34
  "app.tools.expand": ["ctrl+o"],
33
35
  "app.thinking.toggle": ["ctrl+t"],
34
36
  "app.editor.external": ["ctrl+g"],
@@ -56,7 +58,7 @@ export class CustomEditor extends Editor {
56
58
  onHistorySearch?: () => void;
57
59
  onSuspend?: () => void;
58
60
  onShowHotkeys?: () => void;
59
- onQuickSelectModel?: () => void;
61
+ onSelectModelTemporary?: () => void;
60
62
  /** Called when the configured copy-prompt shortcut is pressed. */
61
63
  onCopyPrompt?: () => void;
62
64
  /** Called when the configured image-paste shortcut is pressed. */
@@ -126,9 +128,9 @@ export class CustomEditor extends Editor {
126
128
  return;
127
129
  }
128
130
 
129
- // Intercept Alt+P for quick model switching
130
- if (matchesKey(data, "alt+p") && this.onQuickSelectModel) {
131
- this.onQuickSelectModel();
131
+ // Intercept configured temporary model selector shortcut
132
+ if (this.#matchesAction(data, "app.model.selectTemporary") && this.onSelectModelTemporary) {
133
+ this.onSelectModelTemporary();
132
134
  return;
133
135
  }
134
136
 
@@ -1,6 +1,10 @@
1
1
  /**
2
- * Multi-line editor component for hooks.
2
+ * Multi-line editor component for hooks and ask custom input.
3
3
  * Supports Ctrl+G for external editor.
4
+ *
5
+ * Two modes:
6
+ * - Default (hook): Enter inserts newline, Ctrl+Enter submits, bordered popup
7
+ * - Prompt-style (ask): Enter submits, Shift+Enter inserts newline, legacy ask chrome
4
8
  */
5
9
  import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
6
10
  import { getEditorTheme, theme } from "../../modes/theme/theme";
@@ -8,11 +12,17 @@ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
8
12
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
9
13
  import { DynamicBorder } from "./dynamic-border";
10
14
 
15
+ export interface HookEditorOptions {
16
+ /** When true, use prompt-style keybindings with the legacy ask prompt chrome. */
17
+ promptStyle?: boolean;
18
+ }
19
+
11
20
  export class HookEditorComponent extends Container {
12
21
  #editor: Editor;
13
22
  #onSubmitCallback: (value: string) => void;
14
23
  #onCancelCallback: () => void;
15
24
  #tui: TUI;
25
+ #promptStyle: boolean;
16
26
 
17
27
  constructor(
18
28
  tui: TUI,
@@ -20,23 +30,29 @@ export class HookEditorComponent extends Container {
20
30
  prefill: string | undefined,
21
31
  onSubmit: (value: string) => void,
22
32
  onCancel: () => void,
33
+ options?: HookEditorOptions,
23
34
  ) {
24
35
  super();
25
36
 
26
37
  this.#tui = tui;
27
38
  this.#onSubmitCallback = onSubmit;
28
39
  this.#onCancelCallback = onCancel;
40
+ this.#promptStyle = options?.promptStyle ?? false;
29
41
 
30
- // Add top border
31
42
  this.addChild(new DynamicBorder());
32
43
  this.addChild(new Spacer(1));
33
44
 
34
- // Add title
45
+ // Title
35
46
  this.addChild(new Text(theme.fg("accent", title), 1, 0));
36
47
  this.addChild(new Spacer(1));
37
48
 
38
- // Create editor
49
+ // Editor
39
50
  this.#editor = new Editor(getEditorTheme());
51
+ if (this.#promptStyle) {
52
+ this.#editor.setBorderVisible(false);
53
+ this.#editor.setPromptGutter("> ");
54
+ this.#editor.disableSubmit = true;
55
+ }
40
56
  if (prefill) {
41
57
  this.#editor.setText(prefill);
42
58
  }
@@ -44,17 +60,50 @@ export class HookEditorComponent extends Container {
44
60
 
45
61
  this.addChild(new Spacer(1));
46
62
 
47
- // Add hint
48
- const hint = "ctrl+enter submit esc cancel ctrl+g external editor";
63
+ // Hint
64
+ const hint = this.#promptStyle
65
+ ? "enter submit esc cancel ctrl+g external editor"
66
+ : "ctrl+enter submit esc cancel ctrl+g external editor";
49
67
  this.addChild(new Text(theme.fg("dim", hint), 1, 0));
50
68
 
51
69
  this.addChild(new Spacer(1));
52
-
53
- // Add bottom border
54
70
  this.addChild(new DynamicBorder());
55
71
  }
56
72
 
57
73
  handleInput(keyData: string): void {
74
+ if (this.#promptStyle) {
75
+ this.#handlePromptStyleInput(keyData);
76
+ } else {
77
+ this.#handleHookStyleInput(keyData);
78
+ }
79
+ }
80
+
81
+ /** Prompt-style: raw Enter submits; Editor owns newline-producing sequences. */
82
+ #handlePromptStyleInput(keyData: string): void {
83
+ // Prompt-style keeps Escape as an explicit cancel key and also honors app.interrupt remaps.
84
+ if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesAppInterrupt(keyData)) {
85
+ this.#onCancelCallback();
86
+ return;
87
+ }
88
+
89
+ // Ctrl+G for external editor
90
+ if (matchesKey(keyData, "ctrl+g")) {
91
+ void this.#openExternalEditor();
92
+ return;
93
+ }
94
+
95
+ // Submit on any plain Enter encoding, including terminals that report unmodified Enter as LF.
96
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
97
+ this.#onSubmitCallback(this.#editor.getText());
98
+ return;
99
+ }
100
+
101
+ // Let Editor handle modified newline-producing variants (Shift+Enter, Ctrl+Enter, Alt+Enter, etc.)
102
+ this.#editor.handleInput(keyData);
103
+ }
104
+
105
+ /** Hook-style: Enter=newline, Ctrl+Enter=submit (original behavior) */
106
+ #handleHookStyleInput(keyData: string): void {
58
107
  // Ctrl+Enter to submit
59
108
  if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") {
60
109
  this.#onSubmitCallback(this.#editor.getText());
@@ -12,7 +12,8 @@ import {
12
12
  type TUI,
13
13
  visibleWidth,
14
14
  } from "@oh-my-pi/pi-tui";
15
- import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRegistry, type ModelRole } from "../../config/model-registry";
15
+ import type { ModelRegistry } from "../../config/model-registry";
16
+ import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry";
16
17
  import { resolveModelRoleValue } from "../../config/model-resolver";
17
18
  import type { Settings } from "../../config/settings";
18
19
  import { type ThemeColor, theme } from "../../modes/theme/theme";
@@ -43,22 +44,13 @@ interface RoleAssignment {
43
44
  thinkingLevel: ThinkingLevel;
44
45
  }
45
46
 
46
- type RoleSelectCallback = (model: Model, role: ModelRole | null, thinkingLevel?: ThinkingLevel) => void;
47
+ type RoleSelectCallback = (model: Model, role: string | null, thinkingLevel?: ThinkingLevel) => void;
47
48
  type CancelCallback = () => void;
48
49
  interface MenuRoleAction {
49
50
  label: string;
50
- role: ModelRole;
51
+ role: string; // now accepts custom role strings
51
52
  }
52
53
 
53
- const MENU_ROLE_ACTIONS: MenuRoleAction[] = MODEL_ROLE_IDS.map(role => {
54
- const roleInfo = MODEL_ROLES[role];
55
- const roleLabel = roleInfo.tag ? `${roleInfo.tag} (${roleInfo.name})` : roleInfo.name;
56
- return {
57
- label: `Set as ${roleLabel}`,
58
- role,
59
- };
60
- });
61
-
62
54
  const ALL_TAB = "ALL";
63
55
 
64
56
  /**
@@ -77,7 +69,7 @@ export class ModelSelectorComponent extends Container {
77
69
  #allModels: ModelItem[] = [];
78
70
  #filteredModels: ModelItem[] = [];
79
71
  #selectedIndex: number = 0;
80
- #roles = {} as Record<ModelRole, RoleAssignment | undefined>;
72
+ #roles = {} as Record<string, RoleAssignment | undefined>;
81
73
  #settings = null as unknown as Settings;
82
74
  #modelRegistry = null as unknown as ModelRegistry;
83
75
  #onSelectCallback = (() => {}) as RoleSelectCallback;
@@ -87,6 +79,8 @@ export class ModelSelectorComponent extends Container {
87
79
  #scopedModels: ReadonlyArray<ScopedModelItem>;
88
80
  #temporaryOnly: boolean;
89
81
 
82
+ #menuRoleActions: MenuRoleAction[] = [];
83
+
90
84
  // Tab state
91
85
  #providers: string[] = [ALL_TAB];
92
86
  #activeTabIndex: number = 0;
@@ -95,7 +89,7 @@ export class ModelSelectorComponent extends Container {
95
89
  #isMenuOpen: boolean = false;
96
90
  #menuSelectedIndex: number = 0;
97
91
  #menuStep: "role" | "thinking" = "role";
98
- #menuSelectedRole: ModelRole | null = null;
92
+ #menuSelectedRole: string | null = null;
99
93
 
100
94
  constructor(
101
95
  tui: TUI,
@@ -103,7 +97,7 @@ export class ModelSelectorComponent extends Container {
103
97
  settings: Settings,
104
98
  modelRegistry: ModelRegistry,
105
99
  scopedModels: ReadonlyArray<ScopedModelItem>,
106
- onSelect: (model: Model, role: ModelRole | null, thinkingLevel?: ThinkingLevel) => void,
100
+ onSelect: (model: Model, role: string | null, thinkingLevel?: ThinkingLevel) => void,
107
101
  onCancel: () => void,
108
102
  options?: { temporaryOnly?: boolean; initialSearchInput?: string },
109
103
  ) {
@@ -118,6 +112,9 @@ export class ModelSelectorComponent extends Container {
118
112
  this.#temporaryOnly = options?.temporaryOnly ?? false;
119
113
  const initialSearchInput = options?.initialSearchInput;
120
114
 
115
+ // Initialize menu role actions (built-in + custom from settings)
116
+ this.#buildMenuRoleActions();
117
+
121
118
  // Load current role assignments from settings
122
119
  this.#loadRoleModels();
123
120
 
@@ -184,22 +181,35 @@ export class ModelSelectorComponent extends Container {
184
181
  });
185
182
  }
186
183
 
184
+ #buildMenuRoleActions(): void {
185
+ this.#menuRoleActions = getKnownRoleIds(this.#settings).map(role => {
186
+ const roleInfo = getRoleInfo(role, this.#settings);
187
+ const roleLabel = roleInfo.tag ? `${roleInfo.tag} (${roleInfo.name})` : roleInfo.name;
188
+ return {
189
+ label: `Set as ${roleLabel}`,
190
+ role,
191
+ };
192
+ });
193
+ }
194
+
187
195
  #loadRoleModels(): void {
188
196
  const allModels = this.#modelRegistry.getAll();
189
197
  const matchPreferences = { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() };
190
- for (const role of MODEL_ROLE_IDS) {
198
+ for (const role of getKnownRoleIds(this.#settings)) {
191
199
  const roleValue = this.#settings.getModelRole(role);
192
200
  if (!roleValue) continue;
193
201
 
194
- const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(roleValue, allModels, {
202
+ const resolved = resolveModelRoleValue(roleValue, allModels, {
195
203
  settings: this.#settings,
196
204
  matchPreferences,
197
205
  });
198
- if (model) {
206
+ if (resolved.model) {
199
207
  this.#roles[role] = {
200
- model,
208
+ model: resolved.model,
201
209
  thinkingLevel:
202
- explicitThinkingLevel && thinkingLevel !== undefined ? thinkingLevel : ThinkingLevel.Inherit,
210
+ resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
211
+ ? resolved.thinkingLevel
212
+ : ThinkingLevel.Inherit,
203
213
  };
204
214
  }
205
215
  }
@@ -470,7 +480,7 @@ export class ModelSelectorComponent extends Container {
470
480
  // Build role badges (inverted: color as background, black text)
471
481
  const roleBadgeTokens: string[] = [];
472
482
  for (const role of MODEL_ROLE_IDS) {
473
- const { tag, color } = MODEL_ROLES[role];
483
+ const { tag, color } = getRoleInfo(role, this.#settings);
474
484
  const assigned = this.#roles[role];
475
485
  if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
476
486
 
@@ -478,6 +488,15 @@ export class ModelSelectorComponent extends Container {
478
488
  const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
479
489
  roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
480
490
  }
491
+ // Custom role badges
492
+ for (const [role, assigned] of Object.entries(this.#roles)) {
493
+ if (role in MODEL_ROLES || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
494
+ const roleInfo = getRoleInfo(role, this.#settings);
495
+ const badgeLabel = roleInfo.tag ?? roleInfo.name;
496
+ const badge = makeInvertedBadge(badgeLabel, roleInfo.color ?? "muted");
497
+ const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
498
+ roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
499
+ }
481
500
  const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : "";
482
501
 
483
502
  let line = "";
@@ -527,11 +546,11 @@ export class ModelSelectorComponent extends Container {
527
546
  return [ThinkingLevel.Inherit, ThinkingLevel.Off, ...getSupportedEfforts(model)];
528
547
  }
529
548
 
530
- #getCurrentRoleThinkingLevel(role: ModelRole): ThinkingLevel {
549
+ #getCurrentRoleThinkingLevel(role: string): ThinkingLevel {
531
550
  return this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
532
551
  }
533
552
 
534
- #getThinkingPreselectIndex(role: ModelRole, model: Model): number {
553
+ #getThinkingPreselectIndex(role: string, model: Model): number {
535
554
  const options = this.#getThinkingLevelsForModel(model);
536
555
  const currentLevel = this.#getCurrentRoleThinkingLevel(role);
537
556
  const foundIndex = options.indexOf(currentLevel);
@@ -569,12 +588,12 @@ export class ModelSelectorComponent extends Container {
569
588
  const label = getThinkingLevelMetadata(thinkingLevel).label;
570
589
  return `${prefix}${label}`;
571
590
  })
572
- : MENU_ROLE_ACTIONS.map((action, index) => {
591
+ : this.#menuRoleActions.map((action, index) => {
573
592
  const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " ";
574
593
  return `${prefix}${action.label}`;
575
594
  });
576
595
 
577
- const selectedRoleName = this.#menuSelectedRole ? MODEL_ROLES[this.#menuSelectedRole].name : "";
596
+ const selectedRoleName = this.#menuSelectedRole ? getRoleInfo(this.#menuSelectedRole, this.#settings).name : "";
578
597
  const headerText =
579
598
  showingThinking && this.#menuSelectedRole
580
599
  ? ` Thinking for: ${selectedRoleName} (${selectedModel.id})`
@@ -674,7 +693,7 @@ export class ModelSelectorComponent extends Container {
674
693
  const optionCount =
675
694
  this.#menuStep === "thinking" && this.#menuSelectedRole !== null
676
695
  ? this.#getThinkingLevelsForModel(selectedModel.model).length
677
- : MENU_ROLE_ACTIONS.length;
696
+ : this.#menuRoleActions.length;
678
697
  if (optionCount === 0) return;
679
698
 
680
699
  if (matchesKey(keyData, "up")) {
@@ -691,7 +710,7 @@ export class ModelSelectorComponent extends Container {
691
710
 
692
711
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
693
712
  if (this.#menuStep === "role") {
694
- const action = MENU_ROLE_ACTIONS[this.#menuSelectedIndex];
713
+ const action = this.#menuRoleActions[this.#menuSelectedIndex];
695
714
  if (!action) return;
696
715
  this.#menuSelectedRole = action.role;
697
716
  this.#menuStep = "thinking";
@@ -712,7 +731,7 @@ export class ModelSelectorComponent extends Container {
712
731
  if (getKeybindings().matches(keyData, "tui.select.cancel")) {
713
732
  if (this.#menuStep === "thinking" && this.#menuSelectedRole !== null) {
714
733
  this.#menuStep = "role";
715
- const roleIndex = MENU_ROLE_ACTIONS.findIndex(action => action.role === this.#menuSelectedRole);
734
+ const roleIndex = this.#menuRoleActions.findIndex(action => action.role === this.#menuSelectedRole);
716
735
  this.#menuSelectedRole = null;
717
736
  this.#menuSelectedIndex = roleIndex >= 0 ? roleIndex : 0;
718
737
  this.#updateMenu();
@@ -728,7 +747,7 @@ export class ModelSelectorComponent extends Container {
728
747
  if (thinkingLevel === ThinkingLevel.Inherit) return modelKey;
729
748
  return `${modelKey}:${thinkingLevel}`;
730
749
  }
731
- #handleSelect(model: Model, role: ModelRole | null, thinkingLevel?: ThinkingLevel): void {
750
+ #handleSelect(model: Model, role: string | null, thinkingLevel?: ThinkingLevel): void {
732
751
  // For temporary role, don't save to settings - just notify caller
733
752
  if (role === null) {
734
753
  this.#onSelectCallback(model, null);
@@ -51,7 +51,11 @@ export interface SubmenuSettingDef extends BaseSettingDef {
51
51
  onPreviewCancel?: (originalValue: string) => void;
52
52
  }
53
53
 
54
- export type SettingDef = BooleanSettingDef | EnumSettingDef | SubmenuSettingDef;
54
+ export interface TextInputSettingDef extends BaseSettingDef {
55
+ type: "text";
56
+ }
57
+
58
+ export type SettingDef = BooleanSettingDef | EnumSettingDef | SubmenuSettingDef | TextInputSettingDef;
55
59
 
56
60
  // ═══════════════════════════════════════════════════════════════════════════
57
61
  // Condition Functions
@@ -465,6 +469,11 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
465
469
  return createSubmenuSettingDef(base, []);
466
470
  }
467
471
 
472
+ // Plain string setting — free-text input field
473
+ if (schemaType === "string") {
474
+ return { ...base, type: "text" };
475
+ }
476
+
468
477
  return null;
469
478
  }
470
479