@oh-my-pi/pi-coding-agent 15.3.1 → 15.3.2

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,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.3.2] - 2026-05-25
6
+ ### Added
7
+
8
+ - Added inline `|TEXT` payload support to `»` and `«` hashline insert operations, allowing single-line inserts on the op line and still supporting additional payload lines
9
+ - Added support for using inline payloads with BOF/EOF inserts so `|TEXT` is treated as inserted content at file boundaries
10
+ - Added live nested-`task` rendering: while a subagent is mid-flight, the parent UI now surfaces both completed nested `task` sub-calls and the in-flight nested snapshot (forwarded from `tool_execution_update`), matching the finished-result tree
11
+ - Added `omp auth-gateway check` (and matching `GET /v1/credentials/check` endpoint) — probes each broker-supplied credential against its provider's auth-verifying usage endpoint and prints per-credential health, so when a multi-account pool starts returning 401s you can identify which row in the broker is the bad one. The existing `/v1/usage` endpoint silently drops failed credentials, which is the wrong shape for diagnosing auth — the new endpoint captures errors and surfaces the credential's id, provider, type, email/accountId, and the upstream error string. CLI groups results per-provider, exits non-zero when any credential failed, and supports `--json` for scripting. The probe also exercises OAuth refresh on expired tokens, so a working refresh + working access reports as `ok` and a revoked refresh token reports as `oauth refresh failed: …` instead of being masked by the cached expired access token.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed parsing of inline `|TEXT` payloads containing whitespace on `»` and `«` inserts, which previously failed with unrecognized-op errors
16
+ - Fixed anchored insert handling so an inline `|TEXT` body matching the anchor line is treated as anchor decoration and no longer inserted as a duplicate
17
+
5
18
  ## [15.3.0] - 2026-05-25
6
19
 
7
20
  ### Added
@@ -9,6 +22,8 @@
9
22
  - Added `OMP_NO_WEBP` environment variable to disable WebP encoding in image resize, fixing HTTP 400 errors when attaching browser snapshots to vision models running on local llama.cpp (which uses STB library that lacks WebP support)
10
23
  - Fixed loop mode submitting the next prompt while a background async-job delivery turn (idle flush) was still pending, which could cause the job result to be silently dropped and make the session appear to keep firing while work was ongoing ([#1294](https://github.com/can1357/oh-my-pi/issues/1294))
11
24
  - Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280))
25
+ - Fixed config-only marketplace LSP plugins such as `csharp-lsp` not registering servers with the CLI when the plugin cache has only marketplace metadata and no package code ([#1352](https://github.com/can1357/oh-my-pi/issues/1352)).
26
+ - Fixed JTD-to-JSON-Schema conversion treating user-named properties as nested JTD forms when their keys collided with JTD keywords like `ref`, which broke the built-in explore agent's output validator with `schema_violation: files.0.ref: must not be present` ([#1345](https://github.com/can1357/oh-my-pi/issues/1345))
12
27
  - Fixed extension `ctx.ui.notify()` messages emitted during `session_start` being cleared before the first interactive render ([#1316](https://github.com/can1357/oh-my-pi/issues/1316)).
13
28
  - Fixed append-only context mode not being recomputed after model switches — the mode was frozen at session construction time using the initial model's provider, so `provider.appendOnlyContext=auto` left append-only enabled after switching away from DeepSeek (or disabled after switching to DeepSeek) for the rest of the session
14
29
 
@@ -1,4 +1,4 @@
1
- export type AuthGatewayAction = "serve" | "token" | "status";
1
+ export type AuthGatewayAction = "serve" | "token" | "status" | "check";
2
2
  export interface AuthGatewayCommandArgs {
3
3
  action: AuthGatewayAction;
4
4
  flags: {
@@ -214,6 +214,14 @@ export interface AgentProgress {
214
214
  attempt: number;
215
215
  errorMessage: string;
216
216
  };
217
+ /**
218
+ * Snapshot of the most recent `task` tool call's in-flight `TaskToolDetails`,
219
+ * captured from `tool_execution_update`. Lets the parent UI surface live
220
+ * nested-subagent progress while this agent is still inside its own `task`
221
+ * call. Cleared when the call ends — finalized data lives in
222
+ * `extractedToolData.task` after that.
223
+ */
224
+ inflightTaskDetails?: TaskToolDetails;
217
225
  }
218
226
  /** Result from a single agent execution */
219
227
  export interface SingleResult {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.3.1",
4
+ "version": "15.3.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.3.1",
51
- "@oh-my-pi/pi-agent-core": "15.3.1",
52
- "@oh-my-pi/pi-ai": "15.3.1",
53
- "@oh-my-pi/pi-natives": "15.3.1",
54
- "@oh-my-pi/pi-tui": "15.3.1",
55
- "@oh-my-pi/pi-utils": "15.3.1",
50
+ "@oh-my-pi/omp-stats": "15.3.2",
51
+ "@oh-my-pi/pi-agent-core": "15.3.2",
52
+ "@oh-my-pi/pi-ai": "15.3.2",
53
+ "@oh-my-pi/pi-natives": "15.3.2",
54
+ "@oh-my-pi/pi-tui": "15.3.2",
55
+ "@oh-my-pi/pi-utils": "15.3.2",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -32,7 +32,7 @@ import { getConfigRootDir, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
32
32
  import chalk from "chalk";
33
33
  import { type AuthBrokerClientConfig, resolveAuthBrokerConfig } from "../session/auth-broker-config";
34
34
 
35
- export type AuthGatewayAction = "serve" | "token" | "status";
35
+ export type AuthGatewayAction = "serve" | "token" | "status" | "check";
36
36
 
37
37
  export interface AuthGatewayCommandArgs {
38
38
  action: AuthGatewayAction;
@@ -49,7 +49,7 @@ export interface AuthGatewayCommandArgs {
49
49
  };
50
50
  }
51
51
 
52
- const ACTIONS: readonly AuthGatewayAction[] = ["serve", "token", "status"];
52
+ const ACTIONS: readonly AuthGatewayAction[] = ["serve", "token", "status", "check"];
53
53
 
54
54
  function getTokenFilePath(): string {
55
55
  return path.join(getConfigRootDir(), "auth-gateway.token");
@@ -332,6 +332,9 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
332
332
  case "status":
333
333
  await runStatus(cmd.flags);
334
334
  return;
335
+ case "check":
336
+ await runCheck(cmd.flags);
337
+ return;
335
338
  default: {
336
339
  const _exhaustive: never = cmd.action;
337
340
  throw new Error(`Unknown auth-gateway action: ${String(_exhaustive)}`);
@@ -339,4 +342,70 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
339
342
  }
340
343
  }
341
344
 
345
+ /**
346
+ * `omp auth-gateway check` — probe each broker-supplied credential and print
347
+ * per-credential auth health. Use this when the gateway is returning 401s and
348
+ * you need to find which row in a multi-account pool is the bad one. The
349
+ * aggregate `/v1/usage` endpoint silently drops failed credentials, so a
350
+ * dedicated diagnostic is the only way to see which credentials failed.
351
+ */
352
+ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
353
+ const brokerConfig = await resolveAuthBrokerConfig();
354
+ if (!brokerConfig) {
355
+ throw new Error(
356
+ "`omp auth-gateway check` requires OMP_AUTH_BROKER_URL (or `auth.broker.url`/`auth.broker.token` in config.yml). It probes the same credentials the gateway would serve.",
357
+ );
358
+ }
359
+
360
+ const client = createBrokerClient(brokerConfig);
361
+ const initialSnapshot = await fetchBrokerSnapshot(client);
362
+ const store = new RemoteAuthCredentialStore({ client, initialSnapshot });
363
+ const storage = new AuthStorage(store, { sourceLabel: `broker ${brokerConfig.url}` });
364
+ try {
365
+ await storage.reload();
366
+ const results = await storage.checkCredentials();
367
+
368
+ if (flags.json) {
369
+ process.stdout.write(`${JSON.stringify({ broker: brokerConfig.url, credentials: results }, null, 2)}\n`);
370
+ } else {
371
+ const grouped = new Map<string, typeof results>();
372
+ for (const row of results) {
373
+ const list = grouped.get(row.provider) ?? [];
374
+ list.push(row);
375
+ grouped.set(row.provider, list);
376
+ }
377
+ const providers = [...grouped.keys()].sort();
378
+ process.stdout.write(`broker: ${brokerConfig.url}\n`);
379
+ for (const provider of providers) {
380
+ const rows = grouped.get(provider) ?? [];
381
+ process.stdout.write(`\n${chalk.bold(provider)} (${rows.length})\n`);
382
+ for (const row of rows) {
383
+ const status =
384
+ row.ok === true
385
+ ? chalk.green("ok ")
386
+ : row.ok === false
387
+ ? chalk.red("FAIL ")
388
+ : chalk.yellow("unknown ");
389
+ const identity =
390
+ row.email ?? row.accountId ?? (row.type === "api_key" ? "(api key)" : "(no identity on credential)");
391
+ const remote = row.remoteRefresh ? chalk.dim(" [remote-refresh]") : "";
392
+ const reason = row.reason ? chalk.dim(` — ${row.reason}`) : "";
393
+ process.stdout.write(
394
+ ` ${status} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
395
+ );
396
+ }
397
+ }
398
+ const failed = results.filter(row => row.ok === false).length;
399
+ const unverifiable = results.filter(row => row.ok === null).length;
400
+ const passing = results.filter(row => row.ok === true).length;
401
+ process.stdout.write(
402
+ `\n${chalk.green(`${passing} ok`)}, ${chalk.red(`${failed} failed`)}, ${chalk.yellow(`${unverifiable} unverifiable`)}, ${results.length} total\n`,
403
+ );
404
+ if (failed > 0) process.exitCode = 1;
405
+ }
406
+ } finally {
407
+ storage.close();
408
+ }
409
+ }
410
+
342
411
  export { ACTIONS as AUTH_GATEWAY_ACTIONS };
@@ -38,6 +38,8 @@ export default class AuthGateway extends Command {
38
38
  "# Rotate the gateway bearer token\n omp auth-gateway token --regenerate",
39
39
  "# Run on loopback without any bearer (anyone on this host can call)\n omp auth-gateway serve --no-auth",
40
40
  "# Show local gateway + broker config status\n omp auth-gateway status",
41
+ "# Probe each broker credential to see which one is producing 401s\n omp auth-gateway check",
42
+ "# Same, machine-readable for scripts\n omp auth-gateway check --json",
41
43
  ];
42
44
 
43
45
  async run(): Promise<void> {
@@ -10,7 +10,7 @@ import * as fs from "node:fs/promises";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
 
13
- import { isEnoent, logger } from "@oh-my-pi/pi-utils";
13
+ import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
14
14
 
15
15
  import { cachePlugin } from "./cache";
16
16
  import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
@@ -290,6 +290,7 @@ export class MarketplaceManager {
290
290
  try {
291
291
  version = await this.#resolvePluginVersion(pluginEntry, sourcePath);
292
292
  cachePath = await cachePlugin(sourcePath, this.#opts.pluginsCacheDir, marketplace, name, version);
293
+ await this.#writeEmbeddedLspConfig(pluginEntry, cachePath);
293
294
  } finally {
294
295
  // Clean up temp clone dirs created by resolvePluginSource; leave user-supplied local dirs alone
295
296
  if (tempCloneRoot) {
@@ -342,6 +343,24 @@ export class MarketplaceManager {
342
343
  return installedEntry;
343
344
  }
344
345
 
346
+ async #writeEmbeddedLspConfig(entry: MarketplacePluginEntry, cachePath: string): Promise<void> {
347
+ const lspServers = entry.lspServers;
348
+ if (!lspServers) return;
349
+
350
+ const targetPath = path.join(cachePath, ".lsp.json");
351
+ if (typeof lspServers === "string") {
352
+ const sourcePath = path.resolve(cachePath, lspServers);
353
+ if (!pathIsWithin(cachePath, sourcePath)) {
354
+ throw new Error(`Plugin "${entry.name}" lspServers path escapes the plugin directory`);
355
+ }
356
+ const content = await Bun.file(sourcePath).text();
357
+ await Bun.write(targetPath, content);
358
+ return;
359
+ }
360
+
361
+ await Bun.write(targetPath, `${JSON.stringify({ servers: lspServers }, null, 2)}\n`);
362
+ }
363
+
345
364
  /**
346
365
  * Resolve plugin version from multiple sources:
347
366
  * 1. Catalog entry version (if set)
@@ -1,6 +1,8 @@
1
1
  import { ABORT_MARKER, ABORT_WARNING, BEGIN_PATCH_MARKER, END_PATCH_MARKER, RANGE_INTERIOR_HASH } from "./constants";
2
2
  import {
3
+ computeLineHash,
3
4
  describeAnchorExamples,
5
+ HL_BODY_SEP_RE_RAW,
4
6
  HL_FILE_PREFIX,
5
7
  HL_HASH_CAPTURE_RE_RAW,
6
8
  HL_OP_CHARS,
@@ -74,8 +76,36 @@ function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after
74
76
  return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
75
77
  }
76
78
 
77
- const INSERT_BEFORE_OP_RE = new RegExp(`^${regexEscape(HL_OP_INSERT_BEFORE)}\\s*(\\S+)\\s*$`);
78
- const INSERT_AFTER_OP_RE = new RegExp(`^${regexEscape(HL_OP_INSERT_AFTER)}\\s*(\\S+)\\s*$`);
79
+ /**
80
+ * Decide how to interpret the optional `|TEXT` body captured on an insert
81
+ * op line:
82
+ * - For BOF/EOF cursors the body is always treated as an inline payload
83
+ * line (there's no anchor hash to compare against).
84
+ * - For anchored cursors, compute the hash of TEXT at the anchor's line
85
+ * number. If it matches the anchor's hash, the body is just a verbatim
86
+ * copy of the anchored line — discard it, payload must come from the
87
+ * following lines as usual.
88
+ * - Otherwise the body is the first (or only) payload line for this op.
89
+ */
90
+ function resolveInlineInsertBody(cursor: HashlineCursor, body: string | undefined): string | undefined {
91
+ if (body === undefined) return undefined;
92
+ if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") return body;
93
+ const { line, hash } = cursor.anchor;
94
+ if (computeLineHash(line, body) === hash) return undefined;
95
+ return body;
96
+ }
97
+
98
+ // Insert ops leniently accept a trailing `|TEXT` body on the op line itself
99
+ // (e.g. `»502zk|\tconst foo = ...`). The anchor token excludes `|` so the body
100
+ // is captured separately; resolveInlineInsertBody decides whether to treat the
101
+ // captured text as a verbatim anchor decoration (when its hash matches the
102
+ // anchor's) or as an inline payload line.
103
+ const INSERT_BEFORE_OP_RE = new RegExp(
104
+ `^${regexEscape(HL_OP_INSERT_BEFORE)}\\s*([^|\\s]+)(?:${HL_BODY_SEP_RE_RAW}(.*))?\\s*$`,
105
+ );
106
+ const INSERT_AFTER_OP_RE = new RegExp(
107
+ `^${regexEscape(HL_OP_INSERT_AFTER)}\\s*([^|\\s]+)(?:${HL_BODY_SEP_RE_RAW}(.*))?\\s*$`,
108
+ );
79
109
  const REPLACE_OP_RE = new RegExp(`^${regexEscape(HL_OP_REPLACE)}\\s*([^\\s+<\\-=]\\S*)\\s*$`);
80
110
 
81
111
  function isEnvelopeOrAbortMarkerLine(line: string): boolean {
@@ -158,7 +188,9 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
158
188
  const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
159
189
  if (insertBeforeMatch) {
160
190
  const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
161
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
191
+ const inlineBody = resolveInlineInsertBody(cursor, insertBeforeMatch[2]);
192
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, inlineBody === undefined);
193
+ if (inlineBody !== undefined) pushInsert(cursor, inlineBody, lineNum);
162
194
  for (const text of payload) pushInsert(cursor, text, lineNum);
163
195
  i = nextIndex;
164
196
  continue;
@@ -167,7 +199,9 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
167
199
  const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
168
200
  if (insertAfterMatch) {
169
201
  const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
170
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
202
+ const inlineBody = resolveInlineInsertBody(cursor, insertAfterMatch[2]);
203
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, inlineBody === undefined);
204
+ if (inlineBody !== undefined) pushInsert(cursor, inlineBody, lineNum);
171
205
  for (const text of payload) pushInsert(cursor, text, lineNum);
172
206
  i = nextIndex;
173
207
  continue;
@@ -1,6 +1,6 @@
1
1
  // Auto-generated by scripts/generate-docs-index.ts - DO NOT EDIT
2
2
 
3
- export const EMBEDDED_DOC_FILENAMES: readonly string[] = ["ERRATA-GPT5-HARMONY.md","ai-schema-normalize.md","auth-broker-gateway.md","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","install-id.md","lsp-config.md","marketplace.md","mcp-config.md","mcp-protocol-transports.md","mcp-runtime-lifecycle.md","mcp-server-tool-authoring.md","memory.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","render-mermaid.md","resolve-tool-runtime.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","skills/authoring-extensions.md","skills/authoring-hooks.md","skills/authoring-marketplaces.md","skills/examples/hello-extension/README.md","skills/examples/mini-marketplace/README.md","skills/examples/safety-hook/README.md","slash-command-internals.md","task-agent-discovery.md","theme.md","tools/ask.md","tools/ast-edit.md","tools/ast-grep.md","tools/bash.md","tools/browser.md","tools/calc.md","tools/checkpoint.md","tools/debug.md","tools/edit.md","tools/eval.md","tools/find.md","tools/github.md","tools/inspect_image.md","tools/irc.md","tools/job.md","tools/lsp.md","tools/read.md","tools/recall.md","tools/recipe.md","tools/reflect.md","tools/render_mermaid.md","tools/resolve.md","tools/retain.md","tools/rewind.md","tools/search.md","tools/search_tool_bm25.md","tools/ssh.md","tools/task.md","tools/todo_write.md","tools/web_search.md","tools/write.md","tree.md","ttsr-injection-lifecycle.md","tui-runtime-internals.md","tui.md"];
3
+ export const EMBEDDED_DOC_FILENAMES: readonly string[] = ["ERRATA-GPT5-HARMONY.md","ai-schema-normalize.md","auth-broker-gateway.md","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","install-id.md","keybindings.md","lsp-config.md","marketplace.md","mcp-config.md","mcp-protocol-transports.md","mcp-runtime-lifecycle.md","mcp-server-tool-authoring.md","memory.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","render-mermaid.md","resolve-tool-runtime.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","skills/authoring-extensions.md","skills/authoring-hooks.md","skills/authoring-marketplaces.md","skills/examples/hello-extension/README.md","skills/examples/mini-marketplace/README.md","skills/examples/safety-hook/README.md","slash-command-internals.md","task-agent-discovery.md","theme.md","tools/ask.md","tools/ast-edit.md","tools/ast-grep.md","tools/bash.md","tools/browser.md","tools/calc.md","tools/checkpoint.md","tools/debug.md","tools/edit.md","tools/eval.md","tools/find.md","tools/github.md","tools/inspect_image.md","tools/irc.md","tools/job.md","tools/lsp.md","tools/read.md","tools/recall.md","tools/recipe.md","tools/reflect.md","tools/render_mermaid.md","tools/resolve.md","tools/retain.md","tools/rewind.md","tools/search.md","tools/search_tool_bm25.md","tools/ssh.md","tools/task.md","tools/todo_write.md","tools/web_search.md","tools/write.md","tree.md","ttsr-injection-lifecycle.md","tui-runtime-internals.md","tui.md"];
4
4
 
5
5
  export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
6
6
  "ERRATA-GPT5-HARMONY.md": "# ERRATA — GPT-5 Harmony-Header Leakage\n\n## 1. The problem\n\nOpenAI frames tool calls in the Harmony chat protocol:\n\n```\n<|start|>assistant<|channel|>commentary to=functions.<NAME><|message|>{ARGS}<|call|>\n```\n\n`<|channel|>commentary to=functions.NAME` is the **routing header** —\ncontrol tokens consumed by the runtime to dispatch the call. These\ntokens never appear as content under normal operation; the runtime\nstrips them.\n\nThe defect: gpt-5 models occasionally emit, **as ordinary content\ninside `{ARGS}`**, the **plain-text shadow** of these routing tokens —\nthe same characters without the `<|…|>` brackets — and continue\nproducing more pseudo-routing structure (channel name, body marker,\nmultilingual spam, fake tool-result framing). The contamination lives\ninside the visible tool argument and is dispatched to the tool as if it\nwere intended content.\n\n**Critical detail.** The actual `<|start|>` / `<|channel|>` /\n`<|message|>` / `<|call|>` special tokens almost never appear in tool\nargs. What leaks is the bracket-less spelling — `analysis to=functions.X\ncode …` — because OpenAI applies a logit mask suppressing the\ncontrol-token IDs inside the args region. The mass that would have gone\nto those special tokens redistributes onto the un-bracketed plain-text\nrepresentation the model also learned. This makes the leak structurally\ninvisible to the routing parser and lands it in the tool input verbatim.\n\nManifestation in tool args (real corpus example):\n\n```\n~ add_function(iso, ctx, ns, \"installSystemChangeObserver\",\n os_install_system_change_observer);】【\"】【analysis to=functions.edit\n code above เงินไทยฟรีuser to=functions.edit code …\n```\n\nThe leading code is real and intended. Everything after the first\nnon-Latin token through the next clean structural boundary is corruption.\n\n---\n\n## 2. Observed statistics & failure modes\n\nSource: `~/.omp/stats.db` (`ss_tool_calls`, `ss_assistant_msgs`), through\n2026-05-10. 1.05M tool calls scanned.\n\n### 2.1 Rate\n\n| Model | Leaks in tool args | Calls | per million |\n|------------------|-------------------:|--------:|------------:|\n| gpt-5.4 | 37 | 226,957 | 163 |\n| gpt-5.3-codex | 17 | 112,243 | 151 |\n| gpt-5.5 | 2 | 80,750 | 25 |\n| gpt-5.2-codex | 0 | — | — |\n\nPlus 15 hits in assistant visible text / thinking blobs.\n\n### 2.2 Tool distribution\n\n| Tool | Hits |\n|---------------------|-----:|\n| `edit` | 38 |\n| `eval` | 11 |\n| `report_tool_issue` | 3 |\n| `grep`/`read`/`search`/`yield` | 1 each |\n\nConcentrated in tools with free-form (non-JSON-schema) argument formats.\n\n### 2.3 Leak shape (deterministic)\n\n```\nLEAK ::= JUNK_PREFIX MARKER CHANNEL_BODY (LEAK)?\nMARKER ::= \"to=functions.\" TOOL_NAME\nCHANNEL_BODY ::= \" code \" (SPAM | reasoning_prose | fake_tool_output)*\nJUNK_PREFIX ::= (GLITCH_TOKEN | CHANNEL_WORD | NON_LATIN_RUN | \"}\" | \"】【\")+\n```\n\n**Cascading is common.** Of 96 marker occurrences across 71 contaminated\nrecords, 39 contain ≥2 markers and 7 contain ≥3 — the model emits\nmultiple fake `to=functions.X code …` blocks back-to-back, often with\nfake `code_output\\nCell N:\\n…` framing between them. Once the\nplain-text scaffolding is in the residual stream, the prefix now *looks\nlike* a fresh tool envelope start, so the macro prior over continuations\nkeeps voting for more scaffolding. Self-amplifying.\n\n### 2.4 Glitch tokens\n\nSingle-token identifiers in `o200k_base` whose embeddings appear to be\nnear-init from underrepresentation in post-training. ASCII residue\nimmediately before the marker in the natural corpus:\n\n| Surface string | Single-token | Token ID | Hits in corpus |\n|-------------------|:-:|---------:|---:|\n| `Japgolly` | ✅ | 199,745 | 1 |\n| `Jsii` | ✅ | 114,318 | (subtoken of `Jsii_commentary`) |\n| `Jsii_commentary` | — (3 toks) | — | 2 |\n| `changedFiles` | — (2 toks) | — | 8 |\n| `RTLU` | — (2 toks) | — | 3 |\n\n`Japgolly` is in the last 0.13% of the vocabulary — the same family of\nGitHub-corpus residue that produced `SolidGoldMagikarp` in the 2023\nGPT-2 vocabulary (Rumbelow & Watkins). `SolidGoldMagikarp` itself\ntokenizes to 5 tokens in `o200k_base` — that specific token was retired,\nbut the class wasn't.\n\nFor the multi-token entries, the corpus-level signature is the surface\nstring; the underlying glitch trigger is a sub-token (e.g. `Jsii` inside\n`Jsii_commentary`). The detector list (`G` signal) keys on the surface\nstrings.\n\nStable across unrelated sessions. Treated as a high-precision detector\nsignal.\n\n### 2.5 Channel-word leakage\n\n`analysis` (5), `assistant` (5), `commentary` (3), `user` (1) appear\ndirectly preceding `to=`. Always bare words; never `<|channel|>analysis`\nor any other bracketed form. Consistent with §1 — the brackets are\nmasked, the words are not.\n\n### 2.6 Non-Latin spam residue\n\n96 marker hits, by script: CJK 40, Cyrillic 12, Telugu/Kannada/Malayalam\n18, Thai 8, Georgian 7, Armenian 7, Arabic 1. Recurring fragments are\nChinese gambling SEO (`大发时时彩`, `天天中彩票`), Georgian/Abkhaz junk,\nand Thai casino spam — well-known low-quality crawl residue.\n\nThis is the same script distribution observed in the controlled\nreproduction (§7.3), independent of the prompt's natural language.\n\n### 2.7 Failure-mode breakdown for the `edit` tool\n\nThe `edit` tool exists in two variants in the corpus:\n\n| Variant | Calls | Recovery |\n|--------------------------|------:|----------|\n| Patch-DSL (`§PATH`/anchor/`«»≔` ops) | 27 | **Recoverable** by op-truncation (§3.3) |\n| JSON-schema (`{path,edits:[…]}`) | 11 | **Not recoverable** — contamination is escaped *inside* JSON strings, parser accepts it cleanly, content would be written verbatim into source files |\n\nFor Patch-DSL leaks specifically:\n\n- 20/27 cases: contamination on the last input line; nothing follows.\n- 7/27 cases: contamination mid-input; what follows is one of: a\n duplicate replay of an earlier file/anchor, intended content for a\n *different* tool call (the model started its next call inline), or\n pure hallucination. Post-contamination content is never trustworthy.\n\n### 2.8 Mechanism (confirmed)\n\n**Prior collapse from null-embedding glitch tokens, into a\ncontrol-token-masked basin whose mass redistributes onto the\nplain-text shadow of the Harmony protocol.**\n\nStep by step:\n\n1. The model is mid-`{ARGS}` of a Harmony tool call. The runtime applies\n a logit mask suppressing structural control tokens (`<|channel|>`,\n `<|message|>`, `<|call|>`, `<|start|>`, `<|end|>`) inside the args\n region. Without this mask, normal generation would constantly\n hallucinate envelope-closes; with it, those token IDs have logit\n `-∞` in args.\n2. A glitch token `g` is sampled. By construction `g` was in the BPE\n merge corpus but barely in LM/RL training, so its **input embedding\n `e_g` ≈ near-init noise of small norm**.\n3. At position t+1, the residual update `h_{t+1} ≈ LN(h_t + e_g + Attn +\n MLP)` is dominated by the prefix-derived terms; the just-emitted-token\n signal is effectively absent. Generation diversity normally comes\n from `e_x` steering the residual into different sub-regions —\n stripped here.\n4. The next-token distribution therefore collapses onto the **conditional\n prior over continuations of the prefix, with local conditioning\n removed**. In a tool-calling rollout context, that prior is sharply\n peaked on Harmony scaffolding (control tokens + routing tokens) —\n that's what RL trained.\n5. The mask zeros the control-token IDs. Mass redistributes onto the\n **next-best continuation**: the un-bracketed surface-form spelling of\n the same protocol (`analysis`, `commentary`, ` to=functions.X`,\n ` code `). This spelling is unmasked because those characters are\n ordinary tokens.\n6. Once a few tokens of plain-text scaffolding land in the residual\n stream, the prefix now resembles a fresh envelope start. The macro\n prior keeps voting for more scaffolding. Cascading (§2.3) follows.\n7. Multilingual spam after the marker is the same prior-collapse\n continuation, drawn from the training neighborhood of the glitch\n token (often ESL/auto-generated multilingual web junk — exactly the\n crawl residue in §2.6).\n\n**Two corollaries the corpus data demanded but only the experiment\nexplained:**\n\n- **The brackets never appear** (§1, §2.5). The mask is what makes the\n leak land in plain text instead of as a real envelope-close.\n- **Counterintuitive grammar dependency** (§7.4). The leak is *worse* in\n formats closest to OpenAI's training distribution. Off-distribution\n custom grammars dampen the macro-prior basin; the official\n `*** Begin Patch` format is the strongest collapse target.\n\nThe 2023 SolidGoldMagikarp paper documented mechanism (1)+(2)+(4). The\nnew piece is (5): when constrained decoding masks the natural collapse\ntarget, the mass laundered through the un-masked plain-text shadow\nbecomes a structurally-invisible exfiltration channel.",
@@ -19,6 +19,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
19
19
  "handoff-generation-pipeline.md": "# `/handoff` generation pipeline\n\nThis document describes how the coding-agent implements `/handoff`: trigger path, oneshot generation, session switch, context reinjection, persistence, and UI behavior.\n\n## Scope\n\nCovers:\n\n- Interactive `/handoff` command dispatch\n- `AgentSession.handoff()` lifecycle and state transitions\n- `generateHandoff(...)` request shape\n- How old/new sessions persist handoff data differently\n- UI behavior for success, cancel, and failure\n\nDoes not cover:\n\n- Generic tree navigation/branch internals\n- Non-handoff session commands (`/new`, `/fork`, `/resume`)\n\n## Implementation files\n\n- [`../src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts)\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- [`packages/agent/src/compaction/compaction.ts`](../packages/agent/src/compaction/compaction.ts)\n- [`../src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)\n- [`../src/extensibility/slash-commands.ts`](../packages/coding-agent/src/extensibility/slash-commands.ts)\n\n## Trigger path\n\n1. `/handoff` is declared in builtin slash command metadata (`slash-commands.ts`) with optional inline hint: `[focus instructions]`.\n2. In interactive input handling (`InputController`), submit text matching `/handoff` or `/handoff ...` is intercepted before normal prompt submission.\n3. The editor is cleared and `handleHandoffCommand(customInstructions?)` is called.\n4. `CommandController.handleHandoffCommand` performs a preflight guard using current entries:\n - Counts `type === \"message\"` entries.\n - If `< 2`, it warns: `Nothing to hand off (no messages yet)` and returns.\n\nThe same minimum-content guard exists again inside `AgentSession.handoff()` and throws if violated. This duplicates safety at both UI and session layers.\n\n## End-to-end lifecycle\n\n### 1) Start handoff generation\n\n`AgentSession.handoff(customInstructions?)`:\n\n- Reads current branch entries (`sessionManager.getBranch()`).\n- Validates minimum message count (`>= 2`).\n- Creates `#handoffAbortController` and links any caller-provided abort signal to it.\n- Resolves the current model API key through `ModelRegistry`.\n- Calls `generateHandoff(...)` with:\n - live agent messages (`agent.state.messages`),\n - the current model and API key,\n - the base system prompt (`#baseSystemPrompt`),\n - the live tool array (`agent.state.tools`),\n - optional focus instructions,\n - coding-agent message conversion (`convertToLlm`),\n - provider metadata and `initiatorOverride: \"agent\"`.\n\n`generateHandoff(...)` lives in `packages/agent/src/compaction/compaction.ts` next to summarization. It renders `packages/agent/src/compaction/prompts/handoff-document.md` via `renderHandoffPrompt(...)` with optional `additionalFocus`.\n\n### 2) Generate and capture output\n\n`generateHandoff(...)` converts the existing `AgentMessage[]` history to real LLM `Message[]` history, then appends one trailing agent-attributed `user` message containing the rendered handoff prompt.\n\nThe request uses `completeSimple(...)` directly:\n\n```ts\nawait completeSimple(\n model,\n {\n systemPrompt,\n messages: requestMessages,\n tools,\n },\n {\n apiKey,\n signal,\n reasoning: Effort.High,\n toolChoice: \"none\",\n initiatorOverride,\n metadata,\n },\n);\n```\n\nImportant generation properties:\n\n- The request preserves the live provider cache prefix by reusing the same system prompt, tool definitions, and real message history shape as the active agent.\n- The handoff instruction is a trailing `user` message, not a developer message, so the cached prefix remains aligned with the prior turn.\n- `toolChoice: \"none\"` prevents intentional tool dispatch.\n- The returned assistant content is filtered to text blocks and joined with `\\n`; stray tool-call blocks are ignored if a provider does not honor `toolChoice: \"none\"`.\n- `stopReason === \"error\"` throws a generation error.\n\nNo agent-loop events are used for capture. The handoff path no longer waits for `agent_end` and no longer scans the latest assistant message.\n\n### 3) Cancellation checks\n\nCancellation throws `Error(\"Handoff cancelled\")`; a completed generation with no text returns `undefined`.\n\n- caller signal aborts `#handoffAbortController`\n- `completeSimple(...)` receives the abort signal\n- aborted handoff signal or provider `AbortError` is normalized to `Error(\"Handoff cancelled\")`\n- empty generated text returns `undefined`\n\n`AgentSession.handoff()` always clears `#handoffAbortController` in `finally`.\n\n### 4) New session creation\n\nIf text was generated and not aborted:\n\n1. Flush current session writer (`sessionManager.flush()`).\n2. Cancel session-owned async jobs.\n3. Start a brand-new session with `parentSession` pointing at the previous session file when one exists.\n4. Reset in-memory agent state (`agent.reset()`).\n5. Rebind `agent.sessionId` to the new session id.\n6. Rekey/reset hindsight state for the new session.\n7. Clear queued context arrays (`#steeringMessages`, `#followUpMessages`, `#pendingNextTurnMessages`) and any scheduled hidden next-turn generation.\n8. Reset todo reminder counter.\n\n### 5) Handoff-context injection\n\nThe generated handoff document is wrapped by coding-agent session glue and appended to the new session as a `custom_message` entry:\n\n```text\n<handoff-context>\n...handoff text...\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.\n```\n\nInsertion call:\n\n```ts\nthis.sessionManager.appendCustomMessageEntry(\"handoff\", handoffContent, true, undefined, \"agent\");\n```\n\nSemantics:\n\n- `customType`: `\"handoff\"`\n- `display`: `true` (visible in TUI rebuild)\n- attribution: `\"agent\"`\n- Entry type: `custom_message` (participates in LLM context)\n\n### 6) Rebuild active agent context\n\nAfter injection:\n\n1. `buildDisplaySessionContext()` resolves message list for current leaf.\n2. `agent.replaceMessages(sessionContext.messages)` makes the injected handoff message active context.\n3. Todo phases are synchronized from the new branch.\n4. Method returns `{ document: handoffText, savedPath? }`.\n\nAt this point, the active LLM context in the new session contains the injected handoff message, not the old transcript.\n\n## Persistence model: old session vs new session\n\n### Old session\n\nHandoff generation is a oneshot request, not a visible agent turn. The generated handoff text is not appended to the old session as an assistant message.\n\nResult: the original session keeps its prior transcript unchanged except for data already persisted before handoff began.\n\n### New session\n\nAfter session reset, handoff is persisted as `custom_message` with `customType: \"handoff\"`.\n\n`buildSessionContext()` converts this entry into a runtime custom/user-context message via `createCustomMessage(...)`, so it is included in future prompts from the new session.\n\nAuto-triggered handoffs can additionally write a timestamped `handoff-*.md` artifact under the session artifacts directory when `compaction.handoffSaveToDisk` is enabled. Manual `/handoff` does not write that artifact.\n\n## Controller/UI behavior\n\n`CommandController.handleHandoffCommand` behavior:\n\n- Shows a status loader: `Generating handoff… (esc to cancel)`.\n- Calls `await session.handoff(customInstructions)`.\n- If result is `undefined`: `showError(\"Handoff cancelled\")`.\n- On success:\n - `rebuildChatFromMessages()` (loads new session context, including injected handoff)\n - invalidates status line and editor top border\n - reloads todos\n - appends success chat line: `New session started with handoff context`\n- On exception:\n - if message is `\"Handoff cancelled\"` or error name is `AbortError`: `showError(\"Handoff cancelled\")`\n - otherwise: `showError(\"Handoff failed: <message>\")`\n- Stops the loader, restores the previous Escape handler, and requests render at end.\n\nManual `/handoff` no longer streams the generated document into chat. A cancellable loader remains visible while the oneshot request runs, and the chat is rebuilt after generation completes.\n\n## Cancellation semantics\n\n### Session-level cancellation primitive\n\n`AgentSession` exposes:\n\n- `abortHandoff()` → aborts `#handoffAbortController`\n- `isGeneratingHandoff` → true while controller exists\n\nWhen this abort path is used, the abort signal is passed to `completeSimple(...)`; `handoff()` normalizes the cancellation to `Error(\"Handoff cancelled\")`, and command controller maps it to cancellation UI.\n\n### Interactive `/handoff` path\n\nThe command controller installs a temporary Escape handler for `/handoff` while the loader is visible. Pressing Escape calls `session.abortHandoff()`, which aborts the `completeSimple(...)` request through `#handoffAbortController`.\n\n## Aborted vs failed handoff\n\nCurrent UI classification:\n\n- **Aborted/cancelled**\n - `abortHandoff()` path triggers `\"Handoff cancelled\"`, or\n - thrown `AbortError`\n - UI shows `Handoff cancelled`\n- **Failed**\n - any other thrown error from `handoff()` / `generateHandoff()` / provider request path\n - UI shows `Handoff failed: ...`\n\nAdditional nuance: if generation completes but no text is returned, `handoff()` returns `undefined` and controller currently reports **cancelled**, not **failed**.\n\n## Short-session and minimum-content guardrails\n\nTwo guards prevent low-signal handoffs:\n\n- UI layer (`handleHandoffCommand`): warns and returns early for `< 2` message entries\n- Session layer (`handoff()`): throws the same condition as an error\n\nThis avoids creating a new session with empty/near-empty handoff context.\n\n## State transition summary\n\nHigh-level state flow:\n\n1. Interactive slash command intercepted.\n2. Preflight message-count guard.\n3. `#handoffAbortController` created (`isGeneratingHandoff = true`).\n4. `generateHandoff(...)` issues one `completeSimple(...)` request with live system prompt, tools, message history, and trailing handoff prompt.\n5. Assistant response text blocks are joined; tool-call blocks are discarded.\n6. If missing text → return `undefined`; if aborted → cancellation error path.\n7. If present:\n - flush old session\n - cancel async jobs\n - create new empty session with previous session as parent\n - reset runtime queues/counters\n - append `custom_message(handoff)`\n - optionally save an auto-triggered handoff document under the session artifacts directory when `compaction.handoffSaveToDisk` is enabled\n8. Controller rebuilds chat UI and announces success.\n9. `#handoffAbortController` cleared (`isGeneratingHandoff = false`).\n\n## Known assumptions and limitations\n\n- No structural validation checks that generated markdown follows the requested section format.\n- Missing generated text is reported as cancellation in controller UX.\n- Manual handoff has no streaming visibility; a cancellable loader is shown until the UI updates after generation completes.\n- Auto-triggered handoffs can write a timestamped `handoff-*.md` artifact when `compaction.handoffSaveToDisk` is enabled; write failure is logged and does not fail the handoff.\n",
20
20
  "hooks.md": "# Hooks\n\nThis document describes the **current hook subsystem code** in `src/extensibility/hooks/*`.\n\n## Current status in runtime\n\nThe hook package (`src/extensibility/hooks/`) is still exported and usable as an API surface, but the default CLI runtime now initializes the **extension runner** path. In current startup flow:\n\n- `--hook` is treated as an alias for `--extension` (CLI paths are merged into `additionalExtensionPaths`)\n- tools are wrapped by `ExtensionToolWrapper`, not `HookToolWrapper`\n- context transforms and lifecycle emissions go through `ExtensionRunner`\n\nSo this file documents the hook subsystem implementation itself (types/loader/runner/wrapper), including legacy behavior and constraints.\n\n## Key files\n\n- `src/extensibility/hooks/types.ts` — hook context, event types, and result contracts\n- `src/extensibility/hooks/loader.ts` — module loading and hook discovery bridge\n- `src/extensibility/hooks/runner.ts` — event dispatch, command lookup, error signaling\n- `src/extensibility/hooks/tool-wrapper.ts` — pre/post tool interception wrapper\n- `src/extensibility/hooks/index.ts` — exports/re-exports\n\n## What a hook module is\n\nA hook module must default-export a factory:\n\n```ts\nimport type { HookAPI } from \"@oh-my-pi/pi-coding-agent/extensibility/hooks\";\n\nexport default function hook(pi: HookAPI): void {\n pi.on(\"tool_call\", async (event, ctx) => {\n if (\n event.toolName === \"bash\" &&\n String(event.input.command ?? \"\").includes(\"rm -rf\")\n ) {\n return { block: true, reason: \"blocked by policy\" };\n }\n });\n}\n```\n\nThe factory can:\n\n- register event handlers with `pi.on(...)`\n- send persistent custom messages with `pi.sendMessage(...)`\n- persist non-LLM state with `pi.appendEntry(...)`\n- register slash commands via `pi.registerCommand(...)`\n- register custom message renderers via `pi.registerMessageRenderer(...)`\n- run shell commands via `pi.exec(...)`\n\n## Discovery and loading\n\n`discoverAndLoadHooks(configuredPaths, cwd)` does:\n\n1. Load discovered hooks from capability registry (`loadCapability(\"hooks\")`)\n2. Append explicitly configured paths (deduped by absolute path)\n3. Call `loadHooks(allPaths, cwd)`\n\n`loadHooks` then imports each path and expects a `default` function.\n\n### Path resolution\n\n`loader.ts` resolves hook paths as:\n\n- absolute path: used as-is\n- `~` path: expanded\n- relative path: resolved against `cwd`\n\n### Important legacy mismatch\n\nDiscovery providers for `hookCapability` still model pre/post shell-style hook files (for example `.claude/hooks/pre/*`, `.omp/.../hooks/pre/*`).\n\nThe hook loader here uses dynamic module import and requires a default JS/TS hook factory. If a discovered hook path is not importable as a module, load fails and is reported in `LoadHooksResult.errors`.\n\n## Event surfaces\n\nHook events are strongly typed in `types.ts`.\n\n### Session events\n\n- `session_start`\n- `session_before_switch` → can return `{ cancel?: boolean }`\n- `session_switch`\n- `session_before_branch` → can return `{ cancel?: boolean; skipConversationRestore?: boolean }`\n- `session_branch`\n- `session_before_compact` → can return `{ cancel?: boolean; compaction?: CompactionResult }`\n- `session.compacting` → can return `{ context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }`\n- `session_compact`\n- `session_before_tree` → can return `{ cancel?: boolean; summary?: { summary: string; details?: unknown } }`\n- `session_tree`\n- `session_shutdown`\n\n### Agent/context events\n\n- `context` → can return `{ messages?: Message[] }`\n- `before_agent_start` → can return `{ message?: { customType; content; display; details } }`\n- `agent_start`\n- `agent_end`\n- `turn_start`\n- `turn_end`\n- `auto_compaction_start`\n- `auto_compaction_end`\n- `auto_retry_start`\n- `auto_retry_end`\n- `ttsr_triggered`\n- `todo_reminder`\n\n### Tool events (pre/post model)\n\n- `tool_call` (pre-execution) → can return `{ block?: boolean; reason?: string }`\n- `tool_result` (post-execution) → can return `{ content?; details?; isError? }`\n\nThis is the hook subsystem’s core pre/post interception model.\n\n```text\nHook tool interception flow\n\ntool_call handlers\n │\n ├─ any { block: true }? ── yes ──> throw (tool blocked)\n │\n └─ no\n │\n ▼\n execute underlying tool\n │\n ├─ success ──> tool_result handlers can override { content, details }\n │\n └─ error ──> emit tool_result(isError=true) then rethrow original error\n```\n\n## Execution model and mutation semantics\n\n### 1) Pre-execution: `tool_call`\n\n`HookToolWrapper.execute()` emits `tool_call` before tool execution.\n\n- if any handler returns `{ block: true }`, execution stops\n- if handler throws, wrapper fails closed and blocks execution\n- returned `reason` becomes the thrown error text\n\n### 2) Tool execution\n\nUnderlying tool executes normally if not blocked.\n\n### 3) Post-execution: `tool_result`\n\nAfter success, wrapper emits `tool_result` with:\n\n- `toolName`, `toolCallId`, `input`\n- `content`\n- `details`\n- `isError: false`\n\nIf handler returns overrides:\n\n- `content` can replace result content\n- `details` can replace result details\n\nOn tool failure, wrapper emits `tool_result` with `isError: true` and error text content, then rethrows original error.\n\n### What hooks can mutate\n\n- LLM context for a single call via `context` (`messages` replacement chain)\n- tool output content/details on successful tool calls (`tool_result` path)\n- pre-agent injected message via `before_agent_start`\n- cancellation/custom compaction/tree behavior via `session_before_*` and `session.compacting`\n\n### What hooks cannot mutate in this implementation\n\n- raw tool input parameters in-place (only block/allow on `tool_call`)\n- execution continuation after thrown tool errors (error path rethrows)\n- final success/error status in wrapper behavior (returned `isError` is typed but not applied by `HookToolWrapper`)\n\n## Ordering and conflict behavior\n\n### Discovery-level ordering\n\nCapability providers are priority-sorted (higher first). Dedupe is by capability key, first wins.\n\nFor `hooks`, capability key is `${type}:${tool}:${name}`. Shadowed duplicates from lower-priority providers are marked and excluded from effective discovered list.\n\n### Load order\n\n`discoverAndLoadHooks` builds a flat `allPaths` list, deduped by resolved absolute path, then `loadHooks` iterates in that order.\nFile order within each discovered directory depends on `readdir` output; the hook loader does not perform an additional sort.\n\n### Runtime handler order\n\nInside `HookRunner`, order is deterministic by registration sequence:\n\n1. hooks array order\n2. handler registration order per hook/event\n\nConflict behavior by event type:\n\n- `tool_call`: last returned result wins unless a handler blocks; first block short-circuits\n- `tool_result`: last returned override wins (no short-circuit)\n- `context`: chained; each handler receives prior handler’s message output\n- `before_agent_start`: first returned message is kept; later messages ignored\n- `session_before_*`: latest returned result is tracked; `cancel: true` short-circuits immediately\n- `session.compacting`: latest returned result wins\n\nCommand/renderer conflicts:\n\n- `getCommand(name)` returns first match across hooks (first loaded wins)\n- `getMessageRenderer(customType)` returns first match\n- `getRegisteredCommands()` returns all commands (no dedupe)\n\n## UI interactions (`HookContext.ui`)\n\n`HookUIContext` includes:\n\n- `select`, `confirm`, `input`, `editor`\n- `notify`\n- `setStatus`\n- `custom`\n- `setEditorText`, `getEditorText`\n- `theme` getter\n\n`ctx.hasUI` indicates whether interactive UI is available.\n\nWhen running with no UI, the default no-op context behavior is:\n\n- `select/input/editor` return `undefined`\n- `confirm` returns `false`\n- `notify`, `setStatus`, `setEditorText` are no-ops\n- `getEditorText` returns `\"\"`\n\n### Status line behavior\n\nHook status text set via `ctx.ui.setStatus(key, text)` is:\n\n- stored per key\n- sorted by key name\n- sanitized (`\\r`, `\\n`, `\\t` → spaces; repeated spaces collapsed)\n- joined and width-truncated for display\n\n## Error propagation and fallback\n\n### Load-time\n\n- invalid module or missing default export → captured in `LoadHooksResult.errors`\n- loading continues for other hooks\n\n### Event-time\n\n`HookRunner.emit(...)` catches handler errors for most events and emits `HookError` to listeners (`hookPath`, `event`, `error`), then continues.\n\n`emitToolCall(...)` is stricter: handler errors are not swallowed there; they propagate to caller. In `HookToolWrapper`, this blocks the tool call (fail-safe).\n\n## Realistic API examples\n\n### Block unsafe bash commands\n\n```ts\nimport type { HookAPI } from \"@oh-my-pi/pi-coding-agent/extensibility/hooks\";\n\nexport default function (pi: HookAPI): void {\n pi.on(\"tool_call\", async (event, ctx) => {\n if (event.toolName !== \"bash\") return;\n const cmd = String(event.input.command ?? \"\");\n if (!cmd.includes(\"rm -rf\")) return;\n\n if (!ctx.hasUI) return { block: true, reason: \"rm -rf blocked (no UI)\" };\n const ok = await ctx.ui.confirm(\"Dangerous command\", `Allow: ${cmd}`);\n if (!ok) return { block: true, reason: \"user denied command\" };\n });\n}\n```\n\n### Redact tool output on post-execution\n\n```ts\nimport type { HookAPI } from \"@oh-my-pi/pi-coding-agent/extensibility/hooks\";\n\nexport default function (pi: HookAPI): void {\n pi.on(\"tool_result\", async (event) => {\n if (event.toolName !== \"read\" || event.isError) return;\n\n const redacted = event.content.map((chunk) => {\n if (chunk.type !== \"text\") return chunk;\n return {\n ...chunk,\n text: chunk.text.replaceAll(/API_KEY=\\S+/g, \"API_KEY=[REDACTED]\"),\n };\n });\n\n return { content: redacted };\n });\n}\n```\n\n### Modify model context per LLM call\n\n```ts\nimport type { HookAPI } from \"@oh-my-pi/pi-coding-agent/extensibility/hooks\";\n\nexport default function (pi: HookAPI): void {\n pi.on(\"context\", async (event) => {\n const filtered = event.messages.filter(\n (msg) => !(msg.role === \"custom\" && msg.customType === \"debug-only\"),\n );\n return { messages: filtered };\n });\n}\n```\n\n### Register slash command with command-safe context methods\n\n```ts\nimport type { HookAPI } from \"@oh-my-pi/pi-coding-agent/extensibility/hooks\";\n\nexport default function (pi: HookAPI): void {\n pi.registerCommand(\"handoff\", {\n description: \"Create a new session with setup message\",\n handler: async (_args, ctx) => {\n await ctx.waitForIdle();\n await ctx.newSession({\n parentSession: ctx.sessionManager.getSessionFile(),\n setup: async (sm) => {\n sm.appendMessage({\n role: \"user\",\n content: [\n { type: \"text\", text: \"Continue from prior session summary.\" },\n ],\n timestamp: Date.now(),\n });\n },\n });\n },\n });\n}\n```\n\n## Export surface\n\n`src/extensibility/hooks/index.ts` and the package subpath `@oh-my-pi/pi-coding-agent/extensibility/hooks` export:\n\n- loading APIs (`discoverAndLoadHooks`, `loadHooks`)\n- runner and wrapper (`HookRunner`, `HookToolWrapper`)\n- all hook types\n- `execCommand` re-export\n\nThe package root (`@oh-my-pi/pi-coding-agent`) does not re-export `HookAPI`; import legacy hook types from the hooks subpath.\n",
21
21
  "install-id.md": "# Install ID\n\nA persistent per-install UUID that identifies a single oh-my-pi installation across sessions. Used as a stable correlation key for server-side dedup of telemetry-style pushes (currently the auto-QA grievance flush from `report_tool_issue`).\n\n## API\n\nExported from `@oh-my-pi/pi-utils` (`packages/utils/src/dirs.ts`):\n\n| Symbol | Purpose |\n| --- | --- |\n| `getInstallId(): string` | Returns the install ID, generating and persisting one on first call. Result is cached in-process for the lifetime of the runtime. |\n| `__resetInstallIdCacheForTests(): void` | Clears the in-process cache. Test-only — MUST NOT be called from production code. |\n\nThe returned value is a canonical lowercase RFC 4122 UUID matching `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`.\n\n## Storage\n\n- Path: `<config-root>/install-id` — i.e. `~/.omp/install-id` by default, respecting `PI_CONFIG_DIR` via `getConfigRootDir()`.\n- Format: a single UUID line (trailing `\\n`).\n- Permissions: file is created with mode `0o600`.\n- Lifecycle: independent of `~/.omp/agent/`. Wiping agent state (sessions, settings, DB) does NOT regenerate the install ID; only deleting the `install-id` file itself does.\n\n## Generation and lifecycle\n\n1. First call to `getInstallId()` reads the file. If contents parse as a valid UUID, that value is cached and returned.\n2. Otherwise the helper calls `crypto.randomUUID()` (Node's CSPRNG-backed UUID v4) to mint a new ID.\n3. The new value is written via `open(O_WRONLY | O_CREAT | O_EXCL, 0o600)`. The exclusive-create guard means two processes hitting first-call simultaneously cannot both succeed — the loser sees `EEXIST`, re-reads the winner's file, and adopts that ID.\n4. If the existing file contained non-empty garbage (failed UUID regex), it is `unlink`ed before the exclusive create so `O_EXCL` does not trip on stale data.\n5. Any other write failure (read-only FS, permission error) is swallowed: the freshly generated UUID is still cached in-memory so the rest of the process sees a stable value, and subsequent process launches will retry persistence.\n6. Subsequent in-process calls return the cached value without touching disk. Mutating the file on disk after the first call has no effect until the process restarts (or tests call `__resetInstallIdCacheForTests`).\n\n## Consumers\n\n- `packages/coding-agent/src/tools/report-tool-issue.ts` — included as `installId` in the auto-QA grievance push body so the backend can deduplicate repeated reports from the same install. See `dev.autoqaPush.*` settings and `PI_AUTO_QA_PUSH_*` env vars.\n\nNew consumers MUST treat the value as opaque and MUST NOT derive PII from it; the helper does not mix in hostname, username, or any other host-identifying entropy.\n\n## See also\n\n- [environment-variables.md](environment-variables.md) — `PI_CONFIG_DIR` controls where `install-id` lives.\n- [config-usage.md](config-usage.md) — broader config-root layout.\n",
22
+ "keybindings.md": "# Keybindings\n\nRun `/hotkeys` inside an `omp` session to see the active chords for your current build. The list reflects any remaps loaded from disk and any bindings added by extensions.\n\n## Customize keybindings\n\nUser remaps live in `~/.omp/agent/keybindings.json`. The file is a JSON object whose keys are keybinding action IDs and whose values are either one chord string or an array of chord strings. It is not read from `~/.omp/agent/config.yml`, and there is no nested `keybindings` object.\n\n```json\n{\n \"app.model.cycleForward\": \"Ctrl+P\",\n \"app.model.selectTemporary\": \"Alt+P\",\n \"app.plan.toggle\": \"Alt+Shift+P\"\n}\n```\n\nChord names are case-insensitive and use the same notation shown in the UI, such as `Ctrl+P`, `Alt+Shift+P`, `Shift+Enter`, and `Ctrl+Backspace`.\n\nSet an action to an empty array to disable it:\n\n```json\n{\n \"app.stt.toggle\": []\n}\n```\n\n## Common action IDs\n\n| Action ID | Default | Meaning |\n| --- | --- | --- |\n| `app.model.cycleForward` | `Ctrl+P` | Cycle role models forward |\n| `app.model.cycleBackward` | `Shift+Ctrl+P` | Cycle role models backward |\n| `app.model.selectTemporary` | `Alt+P` | Pick a model temporarily for this session |\n| `app.model.select` | `Ctrl+L` | Open the model selector and set roles |\n| `app.plan.toggle` | `Alt+Shift+P` | Toggle plan mode |\n| `app.history.search` | `Ctrl+R` | Search prompt history |\n| `app.tools.expand` | `Ctrl+O` | Toggle tool-output expansion |\n| `app.thinking.toggle` | `Ctrl+T` | Toggle thinking-block visibility |\n| `app.thinking.cycle` | `Shift+Tab` | Cycle thinking level |\n| `app.editor.external` | `Ctrl+G` | Edit the draft in `$VISUAL` / `$EDITOR` |\n| `app.message.followUp` | `Ctrl+Enter` | Queue a follow-up message |\n| `app.message.dequeue` | `Alt+Up` | Dequeue a queued message back into the editor |\n| `app.clipboard.copyLine` | `Alt+Shift+L` | Copy the current line |\n| `app.clipboard.copyPrompt` | `Alt+Shift+C` | Copy the whole prompt |\n| `app.stt.toggle` | `Alt+H` | Toggle speech-to-text recording |\n\nOlder unqualified action names are migrated when `keybindings.json` is loaded, but new docs and new configs should use the namespaced action IDs above.\n",
22
23
  "lsp-config.md": "# LSP configuration in OMP\n\nThis guide explains how to configure language servers for the OMP coding agent.\n\nSource of truth in code:\n\n- Server config type: `packages/coding-agent/src/lsp/types.ts` (`ServerConfig`)\n- Config loader: `packages/coding-agent/src/lsp/config.ts`\n- Built-in server definitions: `packages/coding-agent/src/lsp/defaults.json`\n\n## Auto-detection\n\nWhen no LSP config file is present, OMP auto-detects servers by intersecting two conditions:\n\n1. The project directory contains at least one of the server's `rootMarkers`.\n2. The server binary is available — checked in project-local bin directories first (e.g., `node_modules/.bin/`, `.venv/bin/`), then `$PATH`.\n\nNo configuration is required for common setups. The built-in server list covers most popular languages; see [`defaults.json`](../packages/coding-agent/src/lsp/defaults.json) for the full set.\n\n## Config file locations\n\nOMP merges LSP config from multiple files, lowest to highest priority:\n\n| Priority | Location |\n|----------|----------|\n| 5 (lowest) | `~/lsp.json`, `~/.lsp.json`, `~/lsp.yaml`, `~/.lsp.yaml` |\n| 4 | Plugin LSP configs (marketplace / `--plugin-dir` roots) |\n| 3 | `~/.omp/agent/lsp.json`, `~/.omp/agent/lsp.yaml`, `~/.claude/lsp.*` |\n| 2 | `<project>/.omp/lsp.json`, `<project>/.omp/lsp.yaml`, `<project>/.claude/lsp.*` |\n| 1 (highest) | `<project>/lsp.json`, `<project>/.lsp.json`, `<project>/lsp.yaml` |\n\nEach location accepts both `.json` and `.yaml` / `.yml` variants, as well as hidden-file versions (`.lsp.json`, `.lsp.yaml`). Files are merged in order: higher-priority files override lower-priority fields for the same server. Servers not mentioned in any override file remain at their built-in defaults.\n\n**Recommended locations:**\n\n- User-wide preferences → `~/.omp/agent/lsp.json`\n- Project-specific overrides → `<project>/.omp/lsp.json`\n\n> **Note:** The presence of any LSP config file disables auto-detection. When at least one file is found, OMP skips the binary-scan phase and loads all servers that have matching `rootMarkers`, an available binary, and are not explicitly `disabled`.\n\n## File shape\n\nBoth JSON and YAML are accepted. The top-level object can use either a `servers` wrapper key or a flat map directly:\n\n```json\n{\n \"servers\": {\n \"server-name\": { ... }\n },\n \"idleTimeoutMs\": 300000\n}\n```\n\nor (flat, without the `servers` wrapper):\n\n```json\n{\n \"server-name\": { ... },\n \"idleTimeoutMs\": 300000\n}\n```\n\nTop-level keys:\n\n- `servers` — map of server name to `ServerConfig` (optional wrapper; flat form is equivalent)\n- `idleTimeoutMs` — shut down idle language servers after this many milliseconds; disabled by default\n\n## ServerConfig fields\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `command` | `string` | yes | Binary name (resolved via PATH/local bins) or absolute path |\n| `args` | `string[]` | no | Arguments passed to the binary |\n| `fileTypes` | `string[]` | yes | File extensions this server handles, e.g. `[\".ts\", \".tsx\"]` |\n| `rootMarkers` | `string[]` | yes | Files/dirs that indicate a project root; glob patterns (e.g. `*.cabal`) are supported |\n| `initOptions` | `object` | no | Sent as `initializationOptions` during LSP handshake |\n| `settings` | `object` | no | Workspace settings pushed via `workspace/didChangeConfiguration` |\n| `disabled` | `boolean` | no | Set to `true` to disable this server entirely |\n| `warmupTimeoutMs` | `number` | no | Startup timeout in ms for this server (overrides the global default) |\n| `isLinter` | `boolean` | no | Mark server as linter/formatter only; excluded from type-intelligence operations (hover, go-to-definition, etc.) |\n| `capabilities` | `object` | no | Opt-in server-specific features; see [Capabilities](#capabilities) |\n\n`resolvedCommand` is populated automatically at runtime — do not set it manually.\n\n### Capabilities\n\nThe `capabilities` object enables optional server-specific features that OMP supports on a per-server basis:\n\n```json\n{\n \"capabilities\": {\n \"flycheck\": true,\n \"ssr\": true,\n \"expandMacro\": true,\n \"runnables\": true,\n \"relatedTests\": true\n }\n}\n```\n\nAll fields are boolean and optional. They are currently used by `rust-analyzer`.\n\n## Common recipes\n\n### Override a built-in server's settings\n\nPartial overrides are merged onto the built-in defaults. You only need to specify the fields you want to change.\n\n```json\n{\n \"servers\": {\n \"typescript-language-server\": {\n \"args\": [\"--stdio\", \"--log-level\", \"4\"]\n }\n }\n}\n```\n\n```yaml\nservers:\n gopls:\n settings:\n gopls:\n gofumpt: false\n staticcheck: false\n```\n\n### Disable a built-in server\n\n```json\n{\n \"servers\": {\n \"eslint\": {\n \"disabled\": true\n }\n }\n}\n```\n\n### Register a custom server\n\nNew servers require `command`, `fileTypes`, and `rootMarkers`. All other fields are optional.\n\n```json\n{\n \"servers\": {\n \"my-lsp\": {\n \"command\": \"my-lsp-server\",\n \"args\": [\"--stdio\"],\n \"fileTypes\": [\".xyz\"],\n \"rootMarkers\": [\".xyz-project\", \".git\"]\n }\n }\n}\n```\n\n### Set a global idle timeout\n\nShut down language servers that have been inactive for more than five minutes:\n\n```json\n{\n \"idleTimeoutMs\": 300000\n}\n```\n\n### Disable a server for one project, keep it globally\n\nPlace the override in `<project>/.omp/lsp.json`:\n\n```json\n{\n \"servers\": {\n \"pylsp\": {\n \"disabled\": true\n }\n }\n}\n```\n\nThe user-level config in `~/.omp/agent/lsp.json` is unaffected; pylsp is only suppressed in this project.\n\n## Built-in server list\n\nThe following servers ship in `defaults.json` and are eligible for auto-detection:\n\n| Server key | Language(s) | Binary |\n|---|---|---|\n| `rust-analyzer` | Rust | `rust-analyzer` |\n| `clangd` | C, C++, ObjC | `clangd` |\n| `zls` | Zig | `zls` |\n| `gopls` | Go | `gopls` |\n| `typescript-language-server` | TypeScript, JavaScript | `typescript-language-server` |\n| `denols` | TypeScript, JavaScript (Deno) | `deno` |\n| `biome` | TS/JS/JSON (linter) | `biome` |\n| `eslint` | TS/JS/Vue/Svelte (linter) | `vscode-eslint-language-server` |\n| `vscode-html-language-server` | HTML | `vscode-html-language-server` |\n| `vscode-css-language-server` | CSS, SCSS, Less | `vscode-css-language-server` |\n| `vscode-json-language-server` | JSON | `vscode-json-language-server` |\n| `tailwindcss` | HTML, CSS, TS/JS | `tailwindcss-language-server` |\n| `svelte` | Svelte | `svelteserver` |\n| `vue-language-server` | Vue | `vue-language-server` |\n| `astro` | Astro | `astro-ls` |\n| `pyright` | Python | `pyright-langserver` |\n| `basedpyright` | Python | `basedpyright-langserver` |\n| `pylsp` | Python | `pylsp` |\n| `ruff` | Python (linter) | `ruff` |\n| `jdtls` | Java | `jdtls` |\n| `kotlin-lsp` | Kotlin | `kotlin-lsp` |\n| `metals` | Scala | `metals` |\n| `hls` | Haskell | `haskell-language-server-wrapper` |\n| `ocamllsp` | OCaml | `ocamllsp` |\n| `elixirls` | Elixir | `elixir-ls` |\n| `erlangls` | Erlang | `erlang_ls` |\n| `gleam` | Gleam | `gleam` |\n| `solargraph` | Ruby | `solargraph` |\n| `ruby-lsp` | Ruby | `ruby-lsp` |\n| `rubocop` | Ruby (linter) | `rubocop` |\n| `bashls` | Bash, Zsh | `bash-language-server` |\n| `lua-language-server` | Lua | `lua-language-server` |\n| `intelephense` | PHP | `intelephense` |\n| `phpactor` | PHP | `phpactor` |\n| `omnisharp` | C# | `omnisharp` |\n| `yamlls` | YAML | `yaml-language-server` |\n| `terraformls` | Terraform | `terraform-ls` |\n| `dockerls` | Dockerfile | `docker-langserver` |\n| `helm-ls` | Helm | `helm_ls` |\n| `nixd` | Nix | `nixd` |\n| `nil` | Nix | `nil` |\n| `ols` | Odin | `ols` |\n| `dartls` | Dart | `dart` |\n| `marksman` | Markdown | `marksman` |\n| `texlab` | LaTeX | `texlab` |\n| `graphql` | GraphQL | `graphql-lsp` |\n| `prismals` | Prisma | `prisma-language-server` |\n| `vimls` | Vim script | `vim-language-server` |\n| `emmet-language-server` | HTML, CSS, JSX | `emmet-language-server` |\n| `sourcekit-lsp` | Swift | `sourcekit-lsp` |\n| `swiftlint` | Swift (linter) | `swiftlint` |\n| `tlaplus` | TLA+ | `tlapm_lsp` |\n",
23
24
  "marketplace.md": "# Marketplace plugin system\n\nThe marketplace system lets you discover, install, and manage plugins from Git-hosted catalogs. It is compatible with the Claude Code plugin registry format.\n\n## Quick start\n\n```\n/marketplace add anthropics/claude-plugins-official\n/marketplace install wordpress.com@claude-plugins-official\n```\n\nOr just type `/marketplace` with no arguments to open the interactive plugin browser.\n\n## Concepts\n\nA **marketplace** is a Git repository (or local directory) containing a catalog file at `.claude-plugin/marketplace.json`. The catalog lists available plugins with their sources, descriptions, and metadata.\n\nA **plugin** is a directory containing skills, commands, hooks, MCP servers, or LSP servers. Plugins are identified by `name@marketplace` (e.g. `code-review@claude-plugins-official`).\n\n**Scopes**: plugins can be installed at two scopes:\n\n- **user** (default) -- available in all projects, stored in `~/.omp/plugins/installed_plugins.json`\n- **project** -- available only in the current project, stored in `.omp/plugins/installed_plugins.json`\n\nProject-scoped installs shadow user-scoped installs of the same plugin.\n\n## Commands\n\n### Interactive mode\n\n| Command | Effect |\n| -------------- | ----------------------------------------- |\n| `/marketplace` | Open interactive plugin browser (install) |\n\n### Marketplace management\n\n| Command | Effect |\n| ---------------------------- | -------------------------------------------- |\n| `/marketplace add <source>` | Add a marketplace source |\n| `/marketplace remove <name>` | Remove a marketplace |\n| `/marketplace update [name]` | Re-fetch catalog(s); omit name to update all |\n| `/marketplace list` | List configured marketplaces |\n\n### Plugin operations\n\n| Command | Effect |\n| ------------------------------------------------------------------------- | ---------------------------------- |\n| `/marketplace discover [marketplace]` | Browse available plugins |\n| `/marketplace install [--force] [--scope user\\|project] name@marketplace` | Install a plugin |\n| `/marketplace uninstall [--scope user\\|project] name@marketplace` | Uninstall a plugin |\n| `/marketplace installed` | List installed marketplace plugins |\n| `/marketplace upgrade [--scope user\\|project] [name@marketplace]` | Upgrade one or all plugins |\n\n### CLI equivalents\n\nThe same operations are available from the command line:\n\n```\nomp plugin marketplace add <source>\nomp plugin marketplace remove <name>\nomp plugin marketplace update [name]\nomp plugin marketplace list\nomp plugin discover [marketplace]\nomp plugin install [--force] [--scope user|project] name@marketplace\nomp plugin uninstall [--scope user|project] name@marketplace\nomp plugin upgrade [--scope user|project] [name@marketplace]\n```\n\n## Marketplace sources\n\nWhen you run `/marketplace add <source>`, the system classifies the source:\n\n| Source format | Type | Example |\n| ------------------------------- | ------------------ | -------------------------------------- |\n| `owner/repo` | GitHub shorthand | `anthropics/claude-plugins-official` |\n| `https://...*.json` | Direct catalog URL | `https://example.com/marketplace.json` |\n| `https://...*.git` or `git@...` | Git repository | `https://github.com/org/repo.git` |\n| `./path` or `~/path` or `/path` | Local directory | `./my-marketplace` |\n\nThe system clones the repository (or reads the local directory), locates `.claude-plugin/marketplace.json`, validates it, and caches the catalog locally.\n\n## Catalog format (marketplace.json)\n\nA marketplace catalog lives at `.claude-plugin/marketplace.json` in the repository root:\n\n```json\n{\n \"$schema\": \"https://anthropic.com/claude-code/marketplace.schema.json\",\n \"name\": \"my-marketplace\",\n \"owner\": {\n \"name\": \"Your Name\",\n \"email\": \"you@example.com\"\n },\n \"description\": \"A collection of plugins\",\n \"plugins\": [\n {\n \"name\": \"my-plugin\",\n \"description\": \"What this plugin does\",\n \"source\": \"./plugins/my-plugin\",\n \"category\": \"development\",\n \"homepage\": \"https://github.com/you/my-plugin\"\n }\n ]\n}\n```\n\n### Required fields\n\n| Field | Description |\n| ------------ | ---------------------------------------------------------------------------------------------------------------- |\n| `name` | Marketplace name. Lowercase alphanumeric, hyphens, and dots. Must start and end with alphanumeric. Max 64 chars. |\n| `owner.name` | Marketplace owner name |\n| `plugins` | Array of plugin entries |\n\n### Plugin entry fields\n\n| Field | Required | Description |\n| ------------- | -------- | ---------------------------------------------------------------- |\n| `name` | yes | Plugin name (same rules as marketplace name) |\n| `source` | yes | Where to find the plugin (see below) |\n| `description` | no | Short description |\n| `version` | no | Version string |\n| `author` | no | `{ name, email? }` |\n| `homepage` | no | URL |\n| `category` | no | Category string (e.g. `development`, `productivity`, `security`) |\n| `tags` | no | Array of string tags |\n| `strict` | no | Boolean |\n| `commands` | no | Slash commands provided |\n| `agents` | no | Agents provided |\n| `hooks` | no | Hook definitions |\n| `mcpServers` | no | MCP server definitions |\n| `lspServers` | no | LSP server definitions |\n\n### Plugin source formats\n\nThe `source` field supports several formats:\n\n**Relative path** (within the marketplace repo):\n\n```json\n\"source\": \"./plugins/my-plugin\"\n```\n\n**Git repository URL**:\n\n```json\n\"source\": {\n \"source\": \"url\",\n \"url\": \"https://github.com/org/repo.git\",\n \"sha\": \"abc123...\"\n}\n```\n\n**GitHub shorthand**:\n\n```json\n\"source\": {\n \"source\": \"github\",\n \"repo\": \"org/repo\",\n \"ref\": \"main\",\n \"sha\": \"abc123...\"\n}\n```\n\n**Git subdirectory** (monorepo):\n\n```json\n\"source\": {\n \"source\": \"git-subdir\",\n \"url\": \"https://github.com/org/monorepo.git\",\n \"path\": \"plugins/my-plugin\",\n \"ref\": \"main\",\n \"sha\": \"abc123...\"\n}\n```\n\n**npm package**:\n\n```json\n\"source\": {\n \"source\": \"npm\",\n \"package\": \"@scope/my-plugin\",\n \"version\": \"1.0.0\"\n}\n```\n\n## On-disk layout\n\n```\n~/.omp/\n marketplaces.json # Registry of added marketplaces\n plugins/\n installed_plugins.json # User-scoped installed plugins\n cache/\n marketplaces/ # Cached marketplace catalogs\n plugins/ # Cached plugin directories\n\n<project>/.omp/\n plugins/\n installed_plugins.json # Project-scoped installed plugins\n```\n\n## Naming rules\n\nMarketplace and plugin names must:\n\n- Start and end with a lowercase letter or digit\n- Contain only lowercase letters, digits, hyphens, and dots\n- Be at most 64 characters\n\nPlugin IDs (`name@marketplace`) must be at most 128 characters total.\n\nValid examples: `my-plugin`, `code-review`, `wordpress.com`, `ai-firstify`\nInvalid examples: `-bad`, `bad-`, `.bad`, `Bad`, `under_score`\n",
24
25
  "mcp-config.md": "# MCP configuration in OMP\n\nThis guide explains how to add, edit, and validate MCP servers for the OMP coding agent.\n\nSource of truth in code:\n\n- Runtime config types: `packages/coding-agent/src/mcp/types.ts`\n- Config writer: `packages/coding-agent/src/mcp/config-writer.ts`\n- Loader + validation: `packages/coding-agent/src/mcp/config.ts`\n- Standalone `mcp.json` discovery: `packages/coding-agent/src/discovery/mcp-json.ts`\n- Schema: `packages/coding-agent/src/config/mcp-schema.json`\n\n## Preferred config locations\n\nOMP can discover MCP servers from multiple tools (`.claude/`, `.cursor/`, `.vscode/`, `opencode.json`, and more), but for OMP-native configuration you should usually use one of these files:\n\n- Project: `.omp/mcp.json`\n- User: `~/.omp/agent/mcp.json`\n\nOMP also accepts fallback standalone files in the project root:\n\n- `mcp.json`\n- `.mcp.json`\n\nUse `.omp/mcp.json` or `~/.omp/agent/mcp.json` when you want OMP to own the configuration. Use root `mcp.json` / `.mcp.json` only when you want a portable fallback file that other MCP clients may also read.\n\n## Add a schema reference\n\nAdd this line at the top of the file for editor autocomplete and validation:\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {}\n}\n```\n\nOMP now writes this automatically when `/mcp add`, `/mcp enable`, `/mcp disable`, `/mcp reauth`, or other config-writing flows create or update an OMP-managed MCP file.\n\n## File shape\n\nOMP supports this top-level structure:\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {\n \"server-name\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"some-mcp-server\"]\n }\n },\n \"disabledServers\": [\"server-name\"]\n}\n```\n\nTop-level keys:\n\n- `$schema` — optional JSON Schema URL for tooling\n- `mcpServers` — map of server name to server config\n- `disabledServers` — user-level denylist used to turn off discovered servers by name; runtime loading reads this list from `~/.omp/agent/mcp.json`\n\nServer names must match `^[a-zA-Z0-9_.-]{1,100}$`.\n\n## Supported server fields\n\nShared fields for every transport:\n\n- `enabled?: boolean` — skip this server when `false`\n- `timeout?: number` — connection timeout in milliseconds\n- `auth?: { ... }` — auth metadata used by OMP for OAuth/API-key flows\n- `oauth?: { ... }` — explicit OAuth client settings used during auth/reauth\n\n### `stdio` transport\n\n`stdio` is the default when `type` is omitted.\n\nRequired:\n\n- `command: string`\n\nOptional:\n\n- `type?: \"stdio\"`\n- `args?: string[]`\n- `env?: Record<string, string>`\n- `cwd?: string`\n\nExample:\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {\n \"filesystem\": {\n \"command\": \"npx\",\n \"args\": [\n \"-y\",\n \"@modelcontextprotocol/server-filesystem\",\n \"/Users/alice/projects\",\n \"/Users/alice/Documents\"\n ]\n }\n }\n}\n```\n\nThis follows the official Filesystem MCP server package (`@modelcontextprotocol/server-filesystem`).\n\n### `http` transport\n\nRequired:\n\n- `type: \"http\"`\n- `url: string`\n\nOptional:\n\n- `headers?: Record<string, string>`\n\nExample:\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {\n \"github\": {\n \"type\": \"http\",\n \"url\": \"https://api.githubcopilot.com/mcp/\"\n }\n }\n}\n```\n\nThis matches GitHub's hosted GitHub MCP server endpoint.\n\n### `sse` transport\n\nRequired:\n\n- `type: \"sse\"`\n- `url: string`\n\nOptional:\n\n- `headers?: Record<string, string>`\n\nExample:\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {\n \"legacy-remote\": {\n \"type\": \"sse\",\n \"url\": \"https://example.com/mcp/sse\"\n }\n }\n}\n```\n\n`sse` is still supported for compatibility, but the MCP spec now prefers Streamable HTTP (`type: \"http\"`) for new servers.\n\n## Auth fields\n\nOMP understands two auth-related objects.\n\n### `auth`\n\n```json\n{\n \"type\": \"oauth\" | \"apikey\",\n \"credentialId\": \"optional-stored-credential-id\",\n \"tokenUrl\": \"optional-token-endpoint\",\n \"clientId\": \"optional-client-id\",\n \"clientSecret\": \"optional-client-secret\"\n}\n```\n\nUse this when OMP should remember how to rehydrate credentials for a server.\n\n### `oauth`\n\n```json\n{\n \"clientId\": \"...\",\n \"clientSecret\": \"...\",\n \"redirectUri\": \"...\",\n \"callbackPort\": 3334,\n \"callbackPath\": \"/oauth/callback\"\n}\n```\n\nUse this when the MCP server requires explicit OAuth client settings.\n\nSlack is the clearest current example. Slack's MCP server is hosted at `https://mcp.slack.com/mcp`, uses Streamable HTTP, and requires confidential OAuth with your Slack app's client credentials.\n\nExample:\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {\n \"slack\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.slack.com/mcp\",\n \"oauth\": {\n \"clientId\": \"YOUR_SLACK_CLIENT_ID\",\n \"clientSecret\": \"YOUR_SLACK_CLIENT_SECRET\"\n },\n \"auth\": {\n \"type\": \"oauth\",\n \"tokenUrl\": \"https://slack.com/api/oauth.v2.user.access\",\n \"clientId\": \"YOUR_SLACK_CLIENT_ID\",\n \"clientSecret\": \"YOUR_SLACK_CLIENT_SECRET\"\n }\n }\n }\n}\n```\n\nRelevant Slack endpoints from Slack's docs:\n\n- MCP endpoint: `https://mcp.slack.com/mcp`\n- Authorization endpoint: `https://slack.com/oauth/v2_user/authorize`\n- Token endpoint: `https://slack.com/api/oauth.v2.user.access`\n\n## Common copy-paste examples\n\n### Filesystem server via stdio\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {\n \"filesystem\": {\n \"command\": \"npx\",\n \"args\": [\n \"-y\",\n \"@modelcontextprotocol/server-filesystem\",\n \"/absolute/path/one\",\n \"/absolute/path/two\"\n ]\n }\n }\n}\n```\n\n### GitHub hosted server via HTTP\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {\n \"github\": {\n \"type\": \"http\",\n \"url\": \"https://api.githubcopilot.com/mcp/\"\n }\n }\n}\n```\n\n### GitHub local server via Docker\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {\n \"github\": {\n \"command\": \"docker\",\n \"args\": [\n \"run\",\n \"-i\",\n \"--rm\",\n \"-e\",\n \"GITHUB_PERSONAL_ACCESS_TOKEN\",\n \"ghcr.io/github/github-mcp-server\"\n ],\n \"env\": {\n \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"GITHUB_PERSONAL_ACCESS_TOKEN\"\n }\n }\n }\n}\n```\n\nThis matches GitHub's official local Docker image `ghcr.io/github/github-mcp-server`.\n\n### Slack hosted server via OAuth\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"mcpServers\": {\n \"slack\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.slack.com/mcp\",\n \"oauth\": {\n \"clientId\": \"YOUR_SLACK_CLIENT_ID\",\n \"clientSecret\": \"YOUR_SLACK_CLIENT_SECRET\"\n },\n \"auth\": {\n \"type\": \"oauth\",\n \"tokenUrl\": \"https://slack.com/api/oauth.v2.user.access\",\n \"clientId\": \"YOUR_SLACK_CLIENT_ID\",\n \"clientSecret\": \"YOUR_SLACK_CLIENT_SECRET\"\n }\n }\n }\n}\n```\n\n## Secrets and variable resolution\n\nThis is the part that usually trips people up.\n\n### In `.omp/mcp.json` and `~/.omp/agent/mcp.json`\n\nBefore OMP launches a stdio server or makes an HTTP/SSE request, it resolves stdio `env` values and HTTP/SSE `headers` values like this:\n\n1. If a value starts with `!`, OMP runs the rest as a shell command with a 10s timeout and uses trimmed stdout.\n2. If the command fails, times out, or prints only whitespace, that `env`/`headers` entry is omitted.\n3. Otherwise OMP checks whether the value names an environment variable.\n4. If that environment variable is set to a non-empty value, OMP uses the environment value; otherwise it uses the string literally.\n\nExamples:\n\n```json\n{\n \"env\": {\n \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"GITHUB_PERSONAL_ACCESS_TOKEN\"\n },\n \"headers\": {\n \"X-MCP-Insiders\": \"true\"\n }\n}\n```\n\nThat means this is valid and convenient for local secrets:\n\n- `\"GITHUB_PERSONAL_ACCESS_TOKEN\": \"GITHUB_PERSONAL_ACCESS_TOKEN\"` → copy from the current shell environment\n- `\"Authorization\": \"Bearer hardcoded-token\"` → use the literal value\n- `\"Authorization\": \"!printf 'Bearer %s' \\\"$GITHUB_TOKEN\\\"\"` → build the header from a command\n\n### In root `mcp.json` and `.mcp.json`\n\nThe standalone fallback loader also expands `${VAR}` and `${VAR:-default}` inside strings during discovery for `command`, `args`, `env`, `cwd`, `url`, `headers`, `auth`, and `oauth`.\n\nExample:\n\n```json\n{\n \"mcpServers\": {\n \"github\": {\n \"type\": \"http\",\n \"url\": \"https://api.githubcopilot.com/mcp/\",\n \"headers\": {\n \"Authorization\": \"Bearer ${GITHUB_TOKEN}\"\n }\n }\n }\n}\n```\n\nIf you want the least surprising OMP behavior, prefer `.omp/mcp.json` or `~/.omp/agent/mcp.json` and use explicit env/header values.\n\n## `disabledServers`\n\n`disabledServers` is read from the user config file (`~/.omp/agent/mcp.json`) when a server is discovered from any source and you want OMP to ignore it without editing that other tool's config.\n\nExample:\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json\",\n \"disabledServers\": [\"github\", \"slack\"]\n}\n```\n\n## `/mcp add` vs editing JSON directly\n\nUse `/mcp add` when you want guided setup.\n\nUse direct JSON editing when:\n\n- you need a transport or auth option the wizard does not prompt for yet\n- you want to paste a server definition from another MCP client\n- you want schema-backed validation in your editor\n\nAfter editing, use:\n\n- `/mcp reload` to rediscover and reconnect servers in the current session\n- `/mcp list` to see which config file a server came from\n- `/mcp test <name>` to test a single server\n- `/mcp reconnect <name>` to reconnect one server without rediscovering all configs\n- `/mcp resources`, `/mcp prompts`, and `/mcp notifications` to inspect non-tool MCP capabilities\n\n## Validation rules OMP enforces\n\nFrom `validateServerConfig()` in `packages/coding-agent/src/mcp/config.ts`:\n\n- `stdio` requires `command`\n- `http` and `sse` require `url`\n- a server cannot set both `command` and `url`\n- unknown `type` values are rejected\n\nPractical implications:\n\n- Omitting `type` means `stdio`\n- If you paste a remote server config and forget `\"type\": \"http\"`, OMP will treat it as `stdio` and complain that `command` is missing\n- `sse` remains valid for compatibility, but new hosted servers should usually be configured as `http`\n\n## Discovery and precedence\n\nOMP does not merge duplicate server definitions across files. Discovery providers are prioritized, and the higher-priority definition wins. Separately, `disabledServers` from `~/.omp/agent/mcp.json` can suppress a discovered server by name.\n\nIn practice:\n\n- prefer `.omp/mcp.json` or `~/.omp/agent/mcp.json` when you want an OMP-specific override\n- keep server names unique across tools when possible\n- use `disabledServers` in the user config when a third-party config keeps reintroducing a server you do not want\n\n## Troubleshooting\n\n### `Server \"name\": stdio server requires \"command\" field`\n\nYou probably omitted `type: \"http\"` on a remote server.\n\n### `Server \"name\": both \"command\" and \"url\" are set`\n\nPick one transport. OMP treats `command` as stdio and `url` as http/sse.\n\n### `/mcp add` worked but the server still does not connect\n\nThe JSON is valid, but the server may still be unreachable. Use `/mcp test <name>` and check whether:\n\n- the binary or Docker image exists\n- required environment variables are set\n- the remote URL is reachable\n- the OAuth or API token is valid\n\n### The server exists in another tool's config but not in OMP\n\nRun `/mcp list`. OMP discovers many third-party MCP files, but project-level loading can also be disabled via the `mcp.enableProjectConfig` setting, and a user-level `disabledServers` entry can suppress a server by name.\n\n## References\n\n- MCP transport spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports\n- Filesystem server package: https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem\n- GitHub MCP server: https://github.com/github/github-mcp-server\n- Slack MCP server docs: https://docs.slack.dev/ai/slack-mcp-server/\n",
package/src/lsp/config.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { $which, isRecord, logger } from "@oh-my-pi/pi-utils";
4
+ import { $which, isRecord, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
5
5
  import { YAML } from "bun";
6
6
  import { getConfigDirPaths } from "../config";
7
- import { getPreloadedPluginRoots } from "../discovery/helpers";
7
+ import { type ClaudePluginRoot, getPreloadedPluginRoots } from "../discovery/helpers";
8
8
  import { BiomeClient } from "./clients/biome-client";
9
9
  import { SwiftLintClient } from "./clients/swiftlint-client";
10
10
  import DEFAULTS from "./defaults.json" with { type: "json" };
@@ -22,8 +22,13 @@ export interface LspConfig {
22
22
 
23
23
  const PID_TOKEN = "$PID";
24
24
 
25
+ interface RawServerConfig extends Partial<ServerConfig> {
26
+ extensionToLanguage?: unknown;
27
+ initializationOptions?: unknown;
28
+ }
29
+
25
30
  interface NormalizedConfig {
26
- servers: Record<string, Partial<ServerConfig>>;
31
+ servers: Record<string, RawServerConfig>;
27
32
  idleTimeoutMs?: number;
28
33
  }
29
34
 
@@ -42,12 +47,12 @@ function normalizeConfig(value: unknown): NormalizedConfig | null {
42
47
  const rawServers = value.servers;
43
48
 
44
49
  if (isRecord(rawServers)) {
45
- return { servers: rawServers as Record<string, Partial<ServerConfig>>, idleTimeoutMs };
50
+ return { servers: rawServers as Record<string, RawServerConfig>, idleTimeoutMs };
46
51
  }
47
52
 
48
53
  const servers = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "idleTimeoutMs")) as Record<
49
54
  string,
50
- Partial<ServerConfig>
55
+ RawServerConfig
51
56
  >;
52
57
 
53
58
  return { servers, idleTimeoutMs };
@@ -58,11 +63,17 @@ function normalizeStringArray(value: unknown): string[] | null {
58
63
  const items = value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
59
64
  return items.length > 0 ? items : null;
60
65
  }
66
+ function normalizeExtensionToFileTypes(value: unknown): string[] | null {
67
+ if (!isRecord(value)) return null;
68
+ const extensions = Object.keys(value).filter(extension => extension.length > 0);
69
+ return extensions.length > 0 ? extensions : null;
70
+ }
61
71
 
62
- function normalizeServerConfig(name: string, config: Partial<ServerConfig>): ServerConfig | null {
72
+ function normalizeServerConfig(name: string, config: RawServerConfig): ServerConfig | null {
63
73
  const command = typeof config.command === "string" && config.command.length > 0 ? config.command : null;
64
- const fileTypes = normalizeStringArray(config.fileTypes);
65
- const rootMarkers = normalizeStringArray(config.rootMarkers);
74
+ const fileTypes =
75
+ normalizeStringArray(config.fileTypes) ?? normalizeExtensionToFileTypes(config.extensionToLanguage);
76
+ const rootMarkers = normalizeStringArray(config.rootMarkers) ?? (config.extensionToLanguage ? ["."] : null);
66
77
 
67
78
  if (!command || !fileTypes || !rootMarkers) {
68
79
  logger.warn("Ignoring invalid LSP server config (missing required fields).", { name });
@@ -72,6 +83,11 @@ function normalizeServerConfig(name: string, config: Partial<ServerConfig>): Ser
72
83
  const args = Array.isArray(config.args)
73
84
  ? config.args.filter((entry): entry is string => typeof entry === "string")
74
85
  : undefined;
86
+ const initOptions = isRecord(config.initOptions)
87
+ ? config.initOptions
88
+ : isRecord(config.initializationOptions)
89
+ ? config.initializationOptions
90
+ : undefined;
75
91
 
76
92
  return {
77
93
  ...config,
@@ -79,6 +95,7 @@ function normalizeServerConfig(name: string, config: Partial<ServerConfig>): Ser
79
95
  args,
80
96
  fileTypes,
81
97
  rootMarkers,
98
+ ...(initOptions ? { initOptions } : {}),
82
99
  };
83
100
  }
84
101
 
@@ -92,7 +109,7 @@ function readConfigFile(filePath: string): NormalizedConfig | null {
92
109
  }
93
110
  }
94
111
 
95
- function coerceServerConfigs(servers: Record<string, Partial<ServerConfig>>): Record<string, ServerConfig> {
112
+ function coerceServerConfigs(servers: Record<string, RawServerConfig>): Record<string, ServerConfig> {
96
113
  const result: Record<string, ServerConfig> = {};
97
114
  for (const [name, config] of Object.entries(servers)) {
98
115
  const normalized = normalizeServerConfig(name, config);
@@ -105,7 +122,7 @@ function coerceServerConfigs(servers: Record<string, Partial<ServerConfig>>): Re
105
122
 
106
123
  function mergeServers(
107
124
  base: Record<string, ServerConfig>,
108
- overrides: Record<string, Partial<ServerConfig>>,
125
+ overrides: Record<string, RawServerConfig>,
109
126
  ): Record<string, ServerConfig> {
110
127
  const merged: Record<string, ServerConfig> = { ...base };
111
128
  for (const [name, config] of Object.entries(overrides)) {
@@ -245,24 +262,71 @@ export function resolveCommand(command: string, cwd: string): string | null {
245
262
  return $which(command);
246
263
  }
247
264
 
265
+ interface ConfigSource {
266
+ read(): NormalizedConfig | null;
267
+ }
268
+
269
+ function fileConfigSource(filePath: string): ConfigSource {
270
+ return {
271
+ read: () => readConfigFile(filePath),
272
+ };
273
+ }
274
+
275
+ function readMarketplaceLspConfig(root: ClaudePluginRoot): NormalizedConfig | null {
276
+ const catalogPaths = [
277
+ path.resolve(root.path, "..", "..", "marketplace.json"),
278
+ path.resolve(root.path, "..", "..", ".claude-plugin", "marketplace.json"),
279
+ ];
280
+
281
+ for (const catalogPath of catalogPaths) {
282
+ try {
283
+ const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf-8")) as unknown;
284
+ if (!isRecord(catalog) || !Array.isArray(catalog.plugins)) continue;
285
+
286
+ for (const plugin of catalog.plugins) {
287
+ if (!isRecord(plugin) || plugin.name !== root.plugin) continue;
288
+
289
+ const lspServers = plugin.lspServers;
290
+ if (typeof lspServers === "string") {
291
+ const configPath = path.resolve(root.path, lspServers);
292
+ if (!pathIsWithin(root.path, configPath)) return null;
293
+ return readConfigFile(configPath);
294
+ }
295
+ if (isRecord(lspServers)) {
296
+ return normalizeConfig({ servers: lspServers });
297
+ }
298
+ return null;
299
+ }
300
+ } catch {}
301
+ }
302
+
303
+ return null;
304
+ }
305
+
306
+ function marketplaceConfigSource(root: ClaudePluginRoot): ConfigSource {
307
+ return {
308
+ read: () => readMarketplaceLspConfig(root),
309
+ };
310
+ }
311
+
248
312
  /**
249
- * Configuration file search paths (in priority order).
313
+ * Configuration sources in priority order.
250
314
  * Supports both visible and hidden variants at each config location.
251
315
  */
252
- function getConfigPaths(cwd: string): string[] {
316
+ function getConfigSources(cwd: string): ConfigSource[] {
253
317
  const filenames = ["lsp.json", ".lsp.json", "lsp.yaml", ".lsp.yaml", "lsp.yml", ".lsp.yml"];
254
- const paths: string[] = [];
318
+ const sources: ConfigSource[] = [];
255
319
 
256
320
  // Project root files (highest priority)
257
321
  for (const filename of filenames) {
258
- paths.push(path.join(cwd, filename));
322
+ sources.push(fileConfigSource(path.join(cwd, filename)));
259
323
  }
260
324
 
261
325
  // Project config directories (.omp/, .pi/, .claude/)
262
326
  const projectDirs = getConfigDirPaths("", { user: false, project: true, cwd });
263
327
  for (const dir of projectDirs) {
264
328
  for (const filename of filenames) {
265
- paths.push(path.join(dir, filename));
329
+ sources.push(fileConfigSource(path.join(dir, filename)));
266
330
  }
267
331
  }
268
332
 
@@ -270,7 +334,7 @@ function getConfigPaths(cwd: string): string[] {
270
334
  const userDirs = getConfigDirPaths("", { user: true, project: false });
271
335
  for (const dir of userDirs) {
272
336
  for (const filename of filenames) {
273
- paths.push(path.join(dir, filename));
337
+ sources.push(fileConfigSource(path.join(dir, filename)));
274
338
  }
275
339
  }
276
340
 
@@ -278,16 +342,17 @@ function getConfigPaths(cwd: string): string[] {
278
342
  const pluginRoots = getPreloadedPluginRoots();
279
343
  for (const root of pluginRoots) {
280
344
  for (const filename of filenames) {
281
- paths.push(path.join(root.path, filename));
345
+ sources.push(fileConfigSource(path.join(root.path, filename)));
282
346
  }
347
+ sources.push(marketplaceConfigSource(root));
283
348
  }
284
349
 
285
350
  // User home root files (lowest priority fallback)
286
351
  for (const filename of filenames) {
287
- paths.push(path.join(os.homedir(), filename));
352
+ sources.push(fileConfigSource(path.join(os.homedir(), filename)));
288
353
  }
289
354
 
290
- return paths;
355
+ return sources;
291
356
  }
292
357
 
293
358
  /**
@@ -324,12 +389,12 @@ function getConfigPaths(cwd: string): string[] {
324
389
  export function loadConfig(cwd: string): LspConfig {
325
390
  let mergedServers = coerceServerConfigs(DEFAULTS);
326
391
 
327
- const configPaths = getConfigPaths(cwd).reverse();
392
+ const configSources = getConfigSources(cwd).reverse();
328
393
  let hasOverrides = false;
329
394
 
330
395
  let idleTimeoutMs: number | undefined;
331
- for (const configPath of configPaths) {
332
- const parsed = readConfigFile(configPath);
396
+ for (const source of configSources) {
397
+ const parsed = source.read();
333
398
  if (!parsed) continue;
334
399
  const hasServerOverrides = Object.keys(parsed.servers).length > 0;
335
400
  if (hasServerOverrides) {
package/src/sdk.ts CHANGED
@@ -1893,7 +1893,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1893
1893
  streamSimple(streamModel, context, {
1894
1894
  ...streamOptions,
1895
1895
  onAuthError: async (provider, oldKey, error) => {
1896
- await modelRegistry.authStorage.invalidateCredentialMatching(provider, oldKey, streamOptions?.signal);
1896
+ await modelRegistry.authStorage.invalidateCredentialMatching(provider, oldKey, {
1897
+ signal: streamOptions?.signal,
1898
+ sessionId: agent.sessionId,
1899
+ });
1897
1900
  logger.debug("Retrying provider request after credential invalidation", {
1898
1901
  provider,
1899
1902
  error: error instanceof Error ? error.message : String(error),
@@ -49,6 +49,7 @@ import {
49
49
  TASK_SUBAGENT_EVENT_CHANNEL,
50
50
  TASK_SUBAGENT_LIFECYCLE_CHANNEL,
51
51
  TASK_SUBAGENT_PROGRESS_CHANNEL,
52
+ type TaskToolDetails,
52
53
  } from "./types";
53
54
 
54
55
  const MCP_CALL_TIMEOUT_MS = 60_000;
@@ -909,6 +910,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
909
910
  if (intent) {
910
911
  progress.lastIntent = intent;
911
912
  }
913
+ // Reset any prior in-flight task snapshot so we don't show stale
914
+ // nested progress when the agent enters a fresh `task` call.
915
+ if (event.toolName === "task") {
916
+ progress.inflightTaskDetails = undefined;
917
+ }
912
918
  break;
913
919
  }
914
920
 
@@ -927,6 +933,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
927
933
  progress.currentTool = undefined;
928
934
  progress.currentToolArgs = undefined;
929
935
  progress.currentToolStartMs = undefined;
936
+ // The finalized TaskToolDetails will be captured below into
937
+ // `extractedToolData.task`; drop the in-flight snapshot so the
938
+ // renderer doesn't double-count it against the final entry.
939
+ if (event.toolName === "task") {
940
+ progress.inflightTaskDetails = undefined;
941
+ }
930
942
 
931
943
  // Check for registered subagent tool handler
932
944
  const handler = subprocessToolRegistry.getHandler(event.toolName);
@@ -979,6 +991,23 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
979
991
  break;
980
992
  }
981
993
 
994
+ case "tool_execution_update": {
995
+ // Surface nested-subagent progress mid-flight. The child task
996
+ // tool emits incremental `onUpdate` calls carrying its current
997
+ // `TaskToolDetails` (results + progress); we stash the latest
998
+ // snapshot so the parent UI can render the in-flight subtree
999
+ // without waiting for the call to finish.
1000
+ if (event.toolName === "task") {
1001
+ const partial = (event as { partialResult?: { details?: unknown } }).partialResult;
1002
+ const details = partial && typeof partial === "object" ? partial.details : undefined;
1003
+ if (details && typeof details === "object" && "results" in (details as TaskToolDetails)) {
1004
+ progress.inflightTaskDetails = details as TaskToolDetails;
1005
+ flushProgress = true;
1006
+ }
1007
+ }
1008
+ break;
1009
+ }
1010
+
982
1011
  case "message_update": {
983
1012
  if (event.message?.role !== "assistant") break;
984
1013
  const assistantEvent = (
@@ -639,7 +639,8 @@ function renderAgentProgress(
639
639
  }
640
640
  }
641
641
 
642
- for (const [toolName, dataArray] of Object.entries(progress.extractedToolData)) {
642
+ for (const toolName in progress.extractedToolData) {
643
+ const dataArray = progress.extractedToolData[toolName];
643
644
  // Handle report_finding with tree formatting
644
645
  if (toolName === "report_finding") {
645
646
  const findings = normalizeReportFindings(dataArray);
@@ -649,6 +650,11 @@ function renderAgentProgress(
649
650
  continue;
650
651
  }
651
652
 
653
+ // Nested `task` data has its own dedicated tree renderer below that
654
+ // also merges in the in-flight snapshot — skip the generic inline
655
+ // path so we don't render twice.
656
+ if (toolName === "task") continue;
657
+
652
658
  const handler = subprocessToolRegistry.getHandler(toolName);
653
659
  if (handler?.renderInline) {
654
660
  const displayCount = expanded ? (dataArray as unknown[]).length : 3;
@@ -671,6 +677,20 @@ function renderAgentProgress(
671
677
  }
672
678
  }
673
679
 
680
+ // Nested `task` tree: completed sub-calls from `extractedToolData.task` plus
681
+ // the in-flight snapshot (if any). Surfacing this in the live view means
682
+ // the user sees deep-tree progress without waiting for this agent to finish
683
+ // its own turn.
684
+ const completedTaskCalls = (progress.extractedToolData?.task as TaskToolDetails[] | undefined) ?? [];
685
+ const inflight = progress.inflightTaskDetails;
686
+ if (completedTaskCalls.length > 0 || inflight) {
687
+ const snapshots = inflight ? [...completedTaskCalls, inflight] : completedTaskCalls;
688
+ const nestedLines = renderNestedTaskTree(snapshots, expanded, theme, spinnerFrame);
689
+ for (const line of nestedLines) {
690
+ lines.push(`${continuePrefix}${line}`);
691
+ }
692
+ }
693
+
674
694
  // Expanded view: recent output and tools
675
695
  if (expanded && progress.status === "running") {
676
696
  const output = progress.recentOutput.join("\n");
@@ -1067,6 +1087,38 @@ function renderNestedTaskResults(detailsList: TaskToolDetails[], expanded: boole
1067
1087
  return lines;
1068
1088
  }
1069
1089
 
1090
+ /**
1091
+ * Render a list of `TaskToolDetails` snapshots — completed (`results[]`) or
1092
+ * in-flight (`progress[]`) — as an interleaved tree. Used by the live progress
1093
+ * view to surface nested subagent activity while this agent is still running.
1094
+ */
1095
+ function renderNestedTaskTree(
1096
+ detailsList: TaskToolDetails[],
1097
+ expanded: boolean,
1098
+ theme: Theme,
1099
+ spinnerFrame?: number,
1100
+ ): string[] {
1101
+ const lines: string[] = [];
1102
+ for (const details of detailsList) {
1103
+ const hasResults = Boolean(details.results && details.results.length > 0);
1104
+ if (hasResults) {
1105
+ details.results.forEach((result, index) => {
1106
+ const isLast = index === details.results.length - 1;
1107
+ lines.push(...renderAgentResult(result, isLast, expanded, theme));
1108
+ });
1109
+ continue;
1110
+ }
1111
+ const inflight = details.progress;
1112
+ if (inflight && inflight.length > 0) {
1113
+ inflight.forEach((prog, index) => {
1114
+ const isLast = index === inflight.length - 1;
1115
+ lines.push(...renderAgentProgress(prog, isLast, expanded, theme, spinnerFrame));
1116
+ });
1117
+ }
1118
+ }
1119
+ return lines;
1120
+ }
1121
+
1070
1122
  subprocessToolRegistry.register<TaskToolDetails>("task", {
1071
1123
  extractData: event => {
1072
1124
  const details = event.result?.details;
package/src/task/types.ts CHANGED
@@ -236,6 +236,14 @@ export interface AgentProgress {
236
236
  attempt: number;
237
237
  errorMessage: string;
238
238
  };
239
+ /**
240
+ * Snapshot of the most recent `task` tool call's in-flight `TaskToolDetails`,
241
+ * captured from `tool_execution_update`. Lets the parent UI surface live
242
+ * nested-subagent progress while this agent is still inside its own `task`
243
+ * call. Cleared when the call ends — finalized data lives in
244
+ * `extractedToolData.task` after that.
245
+ */
246
+ inflightTaskDetails?: TaskToolDetails;
239
247
  }
240
248
 
241
249
  /** Result from a single agent execution */
@@ -180,7 +180,11 @@ function normalizeMixedSchemaNode(schema: unknown): unknown {
180
180
  }
181
181
 
182
182
  if (isJTDSchema(schema)) {
183
- return normalizeMixedSchemaNode(convertSchema(schema));
183
+ // `convertSchema` is itself fully recursive and emits pure JSON Schema, so
184
+ // re-walking the result with `normalizeMixedSchemaNode` is unnecessary and
185
+ // unsafe: it would treat user-named properties whose keys happen to be JTD
186
+ // keywords (e.g. `ref`, `elements`) as nested JTD forms (#1345).
187
+ return convertSchema(schema);
184
188
  }
185
189
 
186
190
  const normalized: Record<string, unknown> = {};
package/src/tools/read.ts CHANGED
@@ -118,22 +118,8 @@ function formatTextWithMode(
118
118
  startNum: number,
119
119
  shouldAddHashLines: boolean,
120
120
  shouldAddLineNumbers: boolean,
121
- truncatedLines?: ReadonlySet<number>,
122
121
  ): string {
123
- if (shouldAddHashLines) {
124
- if (!truncatedLines || truncatedLines.size === 0) return formatHashLines(text, startNum);
125
- // Column-truncated lines hash differently from the on-disk line that the
126
- // edit verifier reads back. Drop the anchor (`LINE|TEXT` instead of
127
- // `LINE+HASH|TEXT`) so the model treats the line as un-anchorable rather
128
- // than copying a hash that will be rejected as stale.
129
- const lines = text.split("\n");
130
- return lines
131
- .map((line, i) => {
132
- const ln = startNum + i;
133
- return truncatedLines.has(ln) ? `${ln}${HL_BODY_SEP}${line}` : formatHashLine(ln, line);
134
- })
135
- .join("\n");
136
- }
122
+ if (shouldAddHashLines) return formatHashLines(text, startNum);
137
123
  if (shouldAddLineNumbers) return prependLineNumbers(text, startNum);
138
124
  return text;
139
125
  }
@@ -1045,14 +1031,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1045
1031
  }
1046
1032
 
1047
1033
  const collectedLines = streamResult.lines;
1048
- const truncatedLineNumbers = new Set<number>();
1049
1034
  if (!rawSelector && maxColumns > 0) {
1050
1035
  for (let i = 0; i < collectedLines.length; i++) {
1051
1036
  const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1052
1037
  if (wasTruncated) {
1053
1038
  collectedLines[i] = text;
1054
1039
  columnTruncated = maxColumns;
1055
- truncatedLineNumbers.add(range.startLine + i);
1056
1040
  }
1057
1041
  }
1058
1042
  }
@@ -1062,15 +1046,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1062
1046
  }
1063
1047
 
1064
1048
  const blockText = collectedLines.join("\n");
1065
- blocks.push(
1066
- formatTextWithMode(
1067
- blockText,
1068
- range.startLine,
1069
- shouldAddHashLines,
1070
- shouldAddLineNumbers,
1071
- truncatedLineNumbers,
1072
- ),
1073
- );
1049
+ blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1074
1050
  }
1075
1051
 
1076
1052
  let outputText = blocks.join("\n\n…\n\n");
@@ -1814,14 +1790,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1814
1790
  // view — column truncation surfaces separately via `.limits()`.
1815
1791
  const rawSelector = isRawSelector(parsed);
1816
1792
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1817
- const truncatedLineNumbers = new Set<number>();
1818
1793
  if (!rawSelector && maxColumns > 0) {
1819
1794
  for (let i = 0; i < collectedLines.length; i++) {
1820
1795
  const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1821
1796
  if (wasTruncated) {
1822
1797
  collectedLines[i] = text;
1823
1798
  columnTruncated = maxColumns;
1824
- truncatedLineNumbers.add(startLineDisplay + i);
1825
1799
  }
1826
1800
  }
1827
1801
  }
@@ -1855,13 +1829,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1855
1829
  let capturedDisplayContent: { text: string; startLine: number } | undefined;
1856
1830
  const formatText = (text: string, startNum: number): string => {
1857
1831
  capturedDisplayContent = { text, startLine: startNum };
1858
- return formatTextWithMode(
1859
- text,
1860
- startNum,
1861
- shouldAddHashLines,
1862
- shouldAddLineNumbers,
1863
- truncatedLineNumbers,
1864
- );
1832
+ return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1865
1833
  };
1866
1834
 
1867
1835
  let outputText: string;