@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 +15 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +1 -1
- package/dist/types/task/types.d.ts +8 -0
- package/package.json +7 -7
- package/src/cli/auth-gateway-cli.ts +71 -2
- package/src/commands/auth-gateway.ts +2 -0
- package/src/extensibility/plugins/marketplace/manager.ts +20 -1
- package/src/hashline/parser.ts +38 -4
- package/src/internal-urls/docs-index.generated.ts +2 -1
- package/src/lsp/config.ts +87 -22
- package/src/sdk.ts +4 -1
- package/src/task/executor.ts +29 -0
- package/src/task/render.ts +53 -1
- package/src/task/types.ts +8 -0
- package/src/tools/jtd-to-json-schema.ts +5 -1
- package/src/tools/read.ts +3 -35
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
|
|
|
@@ -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.
|
|
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.
|
|
51
|
-
"@oh-my-pi/pi-agent-core": "15.3.
|
|
52
|
-
"@oh-my-pi/pi-ai": "15.3.
|
|
53
|
-
"@oh-my-pi/pi-natives": "15.3.
|
|
54
|
-
"@oh-my-pi/pi-tui": "15.3.
|
|
55
|
-
"@oh-my-pi/pi-utils": "15.3.
|
|
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)
|
package/src/hashline/parser.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
65
|
-
|
|
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,
|
|
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,
|
|
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
|
|
313
|
+
* Configuration sources in priority order.
|
|
250
314
|
* Supports both visible and hidden variants at each config location.
|
|
251
315
|
*/
|
|
252
|
-
function
|
|
316
|
+
function getConfigSources(cwd: string): ConfigSource[] {
|
|
253
317
|
const filenames = ["lsp.json", ".lsp.json", "lsp.yaml", ".lsp.yaml", "lsp.yml", ".lsp.yml"];
|
|
254
|
-
const
|
|
318
|
+
const sources: ConfigSource[] = [];
|
|
255
319
|
|
|
256
320
|
// Project root files (highest priority)
|
|
257
321
|
for (const filename of filenames) {
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
+
sources.push(fileConfigSource(path.join(os.homedir(), filename)));
|
|
288
353
|
}
|
|
289
354
|
|
|
290
|
-
return
|
|
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
|
|
392
|
+
const configSources = getConfigSources(cwd).reverse();
|
|
328
393
|
let hasOverrides = false;
|
|
329
394
|
|
|
330
395
|
let idleTimeoutMs: number | undefined;
|
|
331
|
-
for (const
|
|
332
|
-
const parsed =
|
|
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,
|
|
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),
|
package/src/task/executor.ts
CHANGED
|
@@ -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 = (
|
package/src/task/render.ts
CHANGED
|
@@ -639,7 +639,8 @@ function renderAgentProgress(
|
|
|
639
639
|
}
|
|
640
640
|
}
|
|
641
641
|
|
|
642
|
-
for (const
|
|
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
|
-
|
|
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;
|