@oh-my-pi/pi-coding-agent 15.3.0 → 15.3.1
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/dist/types/config/model-registry.d.ts +26 -0
- package/dist/types/modes/components/status-line.d.ts +6 -0
- package/dist/types/session/session-manager.d.ts +10 -0
- package/package.json +7 -7
- package/src/config/model-registry.ts +46 -21
- package/src/hashline/parser.ts +6 -1
- package/src/internal-urls/docs-index.generated.ts +2 -1
- package/src/modes/components/status-line.ts +124 -31
- package/src/modes/utils/context-usage.ts +18 -7
- package/src/session/agent-session.ts +14 -2
- package/src/session/session-manager.ts +68 -3
- package/src/slash-commands/builtin-registry.ts +9 -4
- package/src/utils/clipboard.ts +14 -3
- package/src/utils/image-resize.ts +28 -5
|
@@ -213,6 +213,32 @@ export declare const ModelsConfigFile: ConfigFile<{
|
|
|
213
213
|
exclude?: string[] | undefined;
|
|
214
214
|
} | undefined;
|
|
215
215
|
}>;
|
|
216
|
+
/** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */
|
|
217
|
+
interface ProviderOverride {
|
|
218
|
+
baseUrl?: string;
|
|
219
|
+
headers?: Record<string, string>;
|
|
220
|
+
apiKey?: string;
|
|
221
|
+
authHeader?: boolean;
|
|
222
|
+
compat?: Model<Api>["compat"];
|
|
223
|
+
transport?: Model<Api>["transport"];
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Merge a freshly discovered model with the matching bundled/configured entry
|
|
227
|
+
* (or a runtime provider override when no bundled entry exists).
|
|
228
|
+
*
|
|
229
|
+
* `baseUrl` resolution priority:
|
|
230
|
+
* 1. User-set `providerOverride.baseUrl` (explicit override in models.json)
|
|
231
|
+
* 2. Discovered baseUrl (xiaomi `tp-` token-plan keys resolve to
|
|
232
|
+
* `token-plan-sgp.xiaomimimo.com` at discovery time)
|
|
233
|
+
* 3. Existing bundled baseUrl (the host baked into `models.json`)
|
|
234
|
+
*
|
|
235
|
+
* Without (1), the user's override would lose to discovery; without (2)
|
|
236
|
+
* preferred over (3), the bundled `api.xiaomimimo.com` would shadow the
|
|
237
|
+
* tp- token-plan host and produce 401s on the first stream call.
|
|
238
|
+
* See `xiaomi-tp-discovery-merge.test.ts` and the `refresh()` baseUrl-override
|
|
239
|
+
* regression in `model-registry.test.ts`.
|
|
240
|
+
*/
|
|
241
|
+
export declare function mergeDiscoveredModel<TApi extends Api>(model: Model<TApi>, existing: Model<Api> | undefined, providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "transport">): Model<TApi>;
|
|
216
242
|
export type ProviderDiscoveryStatus = "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated";
|
|
217
243
|
export interface ProviderDiscoveryState {
|
|
218
244
|
provider: string;
|
|
@@ -53,6 +53,12 @@ export declare class StatusLineComponent implements Component {
|
|
|
53
53
|
watchBranch(onBranchChange: () => void): void;
|
|
54
54
|
dispose(): void;
|
|
55
55
|
invalidate(): void;
|
|
56
|
+
/**
|
|
57
|
+
* Background-refresh the Anthropic OAuth quota report. Guarded by a 5-min
|
|
58
|
+
* TTL on both success (cache lifetime) and error (backoff). Exposed
|
|
59
|
+
* (non-private) so unit tests can verify the backoff invariant.
|
|
60
|
+
*/
|
|
61
|
+
refreshUsageInBackground(): void;
|
|
56
62
|
/**
|
|
57
63
|
* Compute the (cached) used-tokens / context-window totals for the
|
|
58
64
|
* status-line context% segment. Exposed (non-private) so unit tests can
|
|
@@ -214,6 +214,16 @@ declare class RecentSessionInfo {
|
|
|
214
214
|
/** Human-readable relative time (e.g., "2 hours ago") */
|
|
215
215
|
get timeAgo(): string;
|
|
216
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Promote orphaned `<basename>.jsonl.<snowflake>.bak` backups created by
|
|
219
|
+
* `#replaceSessionFileAfterEperm` back to their primary path when the primary
|
|
220
|
+
* is missing. This runs once per session-dir scan, before the main `*.jsonl`
|
|
221
|
+
* glob, so a crash between the two renames in the EPERM-rewrite path does not
|
|
222
|
+
* leave the user's last good state stranded outside the loader's view.
|
|
223
|
+
*
|
|
224
|
+
* Exported for testing.
|
|
225
|
+
*/
|
|
226
|
+
export declare function recoverOrphanedBackups(sessionDir: string, storage: SessionStorage): Promise<void>;
|
|
217
227
|
/** Exported for testing */
|
|
218
228
|
export declare function findMostRecentSession(sessionDir: string, storage?: SessionStorage): Promise<string | null>;
|
|
219
229
|
/** Get recent sessions for display in welcome screen */
|
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.1",
|
|
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.1",
|
|
51
|
+
"@oh-my-pi/pi-agent-core": "15.3.1",
|
|
52
|
+
"@oh-my-pi/pi-ai": "15.3.1",
|
|
53
|
+
"@oh-my-pi/pi-natives": "15.3.1",
|
|
54
|
+
"@oh-my-pi/pi-tui": "15.3.1",
|
|
55
|
+
"@oh-my-pi/pi-utils": "15.3.1",
|
|
56
56
|
"@puppeteer/browsers": "^2.13.0",
|
|
57
57
|
"@types/turndown": "5.0.6",
|
|
58
58
|
"@xterm/headless": "^6.0.0",
|
|
@@ -252,6 +252,45 @@ interface ProviderOverride {
|
|
|
252
252
|
transport?: Model<Api>["transport"];
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Merge a freshly discovered model with the matching bundled/configured entry
|
|
257
|
+
* (or a runtime provider override when no bundled entry exists).
|
|
258
|
+
*
|
|
259
|
+
* `baseUrl` resolution priority:
|
|
260
|
+
* 1. User-set `providerOverride.baseUrl` (explicit override in models.json)
|
|
261
|
+
* 2. Discovered baseUrl (xiaomi `tp-` token-plan keys resolve to
|
|
262
|
+
* `token-plan-sgp.xiaomimimo.com` at discovery time)
|
|
263
|
+
* 3. Existing bundled baseUrl (the host baked into `models.json`)
|
|
264
|
+
*
|
|
265
|
+
* Without (1), the user's override would lose to discovery; without (2)
|
|
266
|
+
* preferred over (3), the bundled `api.xiaomimimo.com` would shadow the
|
|
267
|
+
* tp- token-plan host and produce 401s on the first stream call.
|
|
268
|
+
* See `xiaomi-tp-discovery-merge.test.ts` and the `refresh()` baseUrl-override
|
|
269
|
+
* regression in `model-registry.test.ts`.
|
|
270
|
+
*/
|
|
271
|
+
export function mergeDiscoveredModel<TApi extends Api>(
|
|
272
|
+
model: Model<TApi>,
|
|
273
|
+
existing: Model<Api> | undefined,
|
|
274
|
+
providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "transport">,
|
|
275
|
+
): Model<TApi> {
|
|
276
|
+
if (existing) {
|
|
277
|
+
return {
|
|
278
|
+
...model,
|
|
279
|
+
baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
|
|
280
|
+
headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (providerOverride) {
|
|
284
|
+
return {
|
|
285
|
+
...model,
|
|
286
|
+
baseUrl: providerOverride.baseUrl ?? model.baseUrl,
|
|
287
|
+
headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers,
|
|
288
|
+
...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return model;
|
|
292
|
+
}
|
|
293
|
+
|
|
255
294
|
interface DiscoveryProviderConfig {
|
|
256
295
|
provider: string;
|
|
257
296
|
api: Api;
|
|
@@ -1182,27 +1221,13 @@ export class ModelRegistry {
|
|
|
1182
1221
|
return;
|
|
1183
1222
|
}
|
|
1184
1223
|
const discoveredModels = this.#applyHardcodedModelPolicies(
|
|
1185
|
-
discovered.map(model =>
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
};
|
|
1193
|
-
}
|
|
1194
|
-
const providerOverride = this.#providerOverrides.get(model.provider);
|
|
1195
|
-
return providerOverride
|
|
1196
|
-
? {
|
|
1197
|
-
...model,
|
|
1198
|
-
baseUrl: providerOverride.baseUrl ?? model.baseUrl,
|
|
1199
|
-
headers: providerOverride.headers
|
|
1200
|
-
? { ...model.headers, ...providerOverride.headers }
|
|
1201
|
-
: model.headers,
|
|
1202
|
-
...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
|
|
1203
|
-
}
|
|
1204
|
-
: model;
|
|
1205
|
-
}),
|
|
1224
|
+
discovered.map(model =>
|
|
1225
|
+
mergeDiscoveredModel(
|
|
1226
|
+
model,
|
|
1227
|
+
this.find(model.provider, model.id),
|
|
1228
|
+
this.#providerOverrides.get(model.provider),
|
|
1229
|
+
),
|
|
1230
|
+
),
|
|
1206
1231
|
);
|
|
1207
1232
|
const resolved = this.#mergeResolvedModels(this.#models, discoveredModels);
|
|
1208
1233
|
const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays);
|
package/src/hashline/parser.ts
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
} from "./hash";
|
|
11
11
|
import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Leniently accept anchors copied from read/search output:
|
|
14
|
+
// - optional leading line-marker decoration (`*`, `>`, `+`, `-`)
|
|
15
|
+
// - the required `LINE+HASH`
|
|
16
|
+
// - an optional trailing `|TEXT` body (or anything after the hash) so users
|
|
17
|
+
// can paste a full `LINE+HASH|TEXT` line verbatim.
|
|
18
|
+
const LID_CAPTURE_RE = new RegExp(`^\\s*[>+\\-*]*\\s*${HL_HASH_CAPTURE_RE_RAW}(?:\\|.*)?\\s*$`);
|
|
14
19
|
const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
20
|
|
|
16
21
|
function parseLid(raw: string, lineNum: number): Anchor {
|
|
@@ -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","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","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
|
+
"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",
|
|
22
23
|
"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",
|
|
23
24
|
"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",
|
|
24
25
|
"mcp-protocol-transports.md": "# MCP Protocol and Transport Internals\n\nThis document describes how coding-agent implements MCP JSON-RPC messaging and how protocol concerns are split from transport concerns.\n\n## Scope\n\nCovers:\n\n- JSON-RPC request/response and notification flow\n- Server-to-client request handling (`ping`, `roots/list`)\n- Request correlation and lifecycle for stdio and HTTP/SSE transports\n- Timeout, cancellation, and auth-refresh behavior\n- Error propagation and malformed payload handling\n- Transport selection boundaries (`stdio` vs `http`/`sse`)\n- Which reconnect/retry responsibilities are transport-level vs manager/tool-bridge-level\n\nDoes not cover extension authoring UX or command UI.\n\n## Implementation files\n\n- [`src/mcp/types.ts`](../packages/coding-agent/src/mcp/types.ts)\n- [`src/mcp/transports/stdio.ts`](../packages/coding-agent/src/mcp/transports/stdio.ts)\n- [`src/mcp/transports/http.ts`](../packages/coding-agent/src/mcp/transports/http.ts)\n- [`src/mcp/transports/index.ts`](../packages/coding-agent/src/mcp/transports/index.ts)\n- [`src/mcp/json-rpc.ts`](../packages/coding-agent/src/mcp/json-rpc.ts)\n- [`src/mcp/client.ts`](../packages/coding-agent/src/mcp/client.ts)\n- [`src/mcp/manager.ts`](../packages/coding-agent/src/mcp/manager.ts)\n\n## Layer boundaries\n\n### Protocol layer (JSON-RPC + MCP methods)\n\n- Message shapes are defined in `types.ts` (`JsonRpcRequest`, `JsonRpcNotification`, `JsonRpcResponse`, `JsonRpcMessage`).\n- MCP client logic (`client.ts`) decides method order and session handshake:\n 1. `initialize` request\n 2. for HTTP/SSE transports, start the optional background SSE listener after the initialize response has established any session id\n 3. `notifications/initialized` notification\n 4. method calls like `tools/list`, `tools/call`\n\n### Transport layer (`MCPTransport`)\n\n`MCPTransport` abstracts delivery and lifecycle:\n\n- `request(method, params, options?) -> Promise<T>`\n- `notify(method, params?) -> Promise<void>`\n- `close()`\n- `connected`\n- optional callbacks: `onClose`, `onError`, `onNotification`, `onRequest`\n\nTransport implementations own framing and I/O details:\n\n- `StdioTransport`: newline-delimited JSON over subprocess stdio\n- `HttpTransport`: JSON-RPC over HTTP POST, with optional SSE responses/listening\n\n### Manager/client wiring\n\n`connectToServer()` always installs an `onRequest` handler for standard server-to-client requests. `MCPManager` installs notification handlers, OAuth refresh hooks for HTTP OAuth servers, and `onClose` reconnect handling for managed connections.\n\n## Transport selection\n\n`client.ts:createTransport()` chooses transport from config:\n\n- `type` omitted or `\"stdio\"` -> `createStdioTransport`\n- `\"http\"` or `\"sse\"` -> `createHttpTransport`\n\n`\"sse\"` is treated as an HTTP transport variant (same class), not a separate transport implementation.\n\n## JSON-RPC message flow and correlation\n\n## Request IDs\n\nEach transport generates per-request IDs with `Snowflake.next()`. IDs are transport-local correlation tokens.\n\n## Stdio correlation path\n\n- Outbound request is serialized as one JSON object + `\\n`.\n- `#pendingRequests: Map<id, {resolve,reject}>` stores in-flight requests.\n- Read loop parses JSONL from stdout and calls `#handleMessage`.\n- If inbound message has matching `id`, request resolves/rejects.\n- If inbound message has `method` and no `id`, treated as notification and sent to `onNotification`.\n- If inbound message has both `method` and `id`, treated as a server-to-client request and answered through `onRequest`; without a handler the transport replies with JSON-RPC `-32601 Method not found`.\n\nUnknown response IDs are ignored (no rejection, no error callback).\n\n## HTTP correlation path\n\n- Outbound request is HTTP `POST` with JSON body and generated `id`.\n- Non-SSE response path: parse one JSON-RPC response and return `result`/throw on `error`.\n- SSE response path (`Content-Type: text/event-stream`): stream events, return first message whose `id` matches expected request ID and has `result` or `error`.\n- SSE messages with `method` and no `id` are treated as notifications.\n- SSE messages with both `method` and `id` are treated as server-to-client requests and answered with a POSTed JSON-RPC response.\n\nIf SSE stream ends before matching response, request fails with `No response received for request ID ...`. After the matching response is captured, the transport drains remaining SSE messages in the background.\n\n## Notifications\n\nClient emits JSON-RPC notifications via `transport.notify(...)`.\n\n- Stdio: writes notification frame to stdin (`jsonrpc`, `method`, optional `params`) plus newline.\n- HTTP: sends POST body without `id`; success accepts `2xx` or `202 Accepted`.\n\nServer-initiated notifications are surfaced through transport `onNotification`; `MCPManager` consumes known MCP list/update notifications and can forward all notifications through its own callback.\n\n## Stdio transport internals\n\n## Lifecycle and state transitions\n\n- Initial: `connected=false`, `process=null`, pending map empty\n- `connect()`:\n - spawn subprocess with configured command/args/env/cwd\n - mark connected\n - start stdout read loop (`readJsonl`)\n - start stderr loop (read/discard; currently silent)\n- `close()`:\n - mark disconnected\n - reject all pending requests (`Transport closed`)\n - kill subprocess\n - await read loop shutdown\n - emit `onClose`\n\nIf read loop exits unexpectedly, `finally` triggers `#handleClose()` which performs the same pending-request rejection and close callback.\n\n## Timeout and cancellation\n\nPer request:\n\n- timeout defaults to `config.timeout ?? 30000`\n- optional `AbortSignal` from caller\n- abort and timeout both reject the pending promise and clean map entry\n\nCancellation is local only: transport does not send protocol-level cancellation notification to the server.\n\n## Malformed payload handling\n\nIn read loop:\n\n- each parsed JSONL line is passed to `#handleMessage` in `try/catch`\n- malformed/invalid message handling exceptions are dropped (`Skip malformed lines` comment)\n- loop continues, so one bad message does not kill the connection\n\nIf the underlying stream parser throws, `onError` is invoked (when still connected), then connection closes.\n\n## Disconnect/failure behavior\n\nWhen process exits or stream closes:\n\n- all in-flight requests are rejected with `Transport closed`\n- no automatic restart or reconnect\n- higher layers must reconnect by creating a new transport\n\n## Backpressure/streaming notes\n\n- Outbound writes use `stdin.write()` + `flush()` without awaiting drain semantics.\n- There is no explicit queue or high-watermark management in transport.\n- Inbound processing is stream-driven (`for await` over `readJsonl`), one parsed message at a time.\n\n## HTTP/SSE transport internals\n\n## Lifecycle and connection semantics\n\nHTTP transport has logical connection state, but request path is stateless per HTTP call:\n\n- `connect()` sets `connected=true` (no socket/session handshake)\n- optional server session tracking via `Mcp-Session-Id` header\n- `close()` optionally sends `DELETE` with `Mcp-Session-Id`, aborts SSE listener, emits `onClose`\n\nSo `connected` means \"transport usable\", not \"persistent stream established\".\n\n## Session header behavior\n\n- On POST response, if `Mcp-Session-Id` header is present, transport stores it.\n- Subsequent requests/notifications include `Mcp-Session-Id`.\n- `close()` tries to terminate server session with HTTP DELETE; termination failures are ignored.\n\n## Timeout, cancellation, and auth refresh\n\nFor `request()`:\n\n- timeout uses `AbortController` (`config.timeout ?? 30000`)\n- external signal, if provided, is merged via `AbortSignal.any([...])`\n- AbortError handling distinguishes caller abort vs timeout\n\nFor `notify()`:\n\n- timeout uses an internal `AbortController` (`config.timeout ?? 30000`)\n- there is no external abort option on the transport interface\n\nFor HTTP OAuth configs managed by `MCPManager`, `request()` retries once on `HTTP 401`/`403` if token refresh returns replacement headers.\n\n## HTTP error propagation\n\nOn non-OK response:\n\n- response text is included in thrown error (`HTTP <status>: <text>`)\n- if present, auth hints from `WWW-Authenticate` and `Mcp-Auth-Server` are appended\n\nOn JSON-RPC error object:\n\n- throws `MCP error <code>: <message>`\n\nMalformed JSON body (`response.json()` failure) propagates as parse exception.\n\n## SSE behavior and modes\n\nTwo SSE paths exist:\n\n1. **Per-request SSE response** (`#parseSSEResponse`)\n - used when POST response content type is `text/event-stream`\n - consumes stream until matching response id found\n - can process interleaved notifications during same stream\n\n2. **Background SSE listener** (`startSSEListener()`)\n - optional GET listener for server-initiated notifications and server-to-client requests\n - `connectToServer()` starts it for HTTP/SSE transports after `initialize` and before `notifications/initialized`\n - if GET returns `405`, another non-OK status, or no body, listener silently disables itself\n\n## Malformed payload and disconnect handling\n\nSSE JSON parsing errors bubble out of `readSseJson` and reject request/listener.\n\n- Request SSE parse errors reject the active request.\n- Background listener errors trigger `onError` (except AbortError).\n- Transport does not restart the listener itself; managed connections may reconnect through manager `onClose` handling.\n\n## `json-rpc.ts` utility vs transport abstraction\n\n`src/mcp/json-rpc.ts` provides `callMCP()` and `parseSSE()` helpers for direct HTTP MCP calls (used by Exa integration), not the `MCPTransport` abstraction used by `MCPClient`/`MCPManager`.\n\nNotable differences from `HttpTransport`:\n\n- parses entire response text first, then extracts first `data: ` line (`parseSSE`), with JSON fallback\n- no request timeout management, no abort API, no session-id handling, no transport lifecycle\n- returns raw JSON-RPC envelope object\n\nThis path is lightweight but less robust than full transport implementation.\n\n## Retry/reconnect responsibilities\n\n## Transport-level\n\nCurrent transport implementations do **not**:\n\n- retry ordinary failed requests, except the HTTP transport's single OAuth-refresh retry when `onAuthError` is wired\n- reconnect after stdio process exit\n- reconnect SSE listeners by themselves\n- resend in-flight requests after disconnect\n\nThey fail fast and propagate errors.\n\n## Manager/tool-bridge level\n\n`MCPManager` wires `transport.onClose` for managed connections and runs `reconnectServer(name)` when a transport closes unexpectedly. Reconnect tears down the stale connection, re-resolves auth/config values, retries with backoff (`500`, `1000`, `2000`, `4000` ms), reloads tools, and preserves stale tools while reconnecting.\n\n`MCPTool` and `DeferredMCPTool` also attempt one reconnect + retry for retriable connection errors during a tool call. This is tool availability recovery, not transport-level retry.\n\n## Failure scenarios summary\n\n- **Malformed stdio message line**: dropped; stream continues.\n- **Stdio stream/process ends**: transport closes; pending requests rejected as `Transport closed`; manager-managed connections trigger reconnect.\n- **HTTP non-2xx**: request/notify throws HTTP error; managed OAuth requests can refresh auth and retry once on 401/403.\n- **Invalid JSON response**: parse exception propagated.\n- **SSE ends without matching id**: request fails with `No response received for request ID ...`.\n- **Timeout**: transport-specific timeout error.\n- **Caller abort**: AbortError/reason propagated from caller signal where the method accepts one.\n\n## Practical boundary rule\n\nIf the concern is message shape, id correlation, or MCP method ordering, it belongs to protocol/client logic.\n\nIf the concern is framing (JSONL vs HTTP/SSE), stream parsing, fetch/spawn lifecycle, timeout clocks, or connection teardown, it belongs to transport implementation.\n",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
3
|
import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
|
|
3
4
|
import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
4
5
|
import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils";
|
|
@@ -40,9 +41,102 @@ export interface StatusLineSettings {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
43
|
-
//
|
|
44
|
+
// Per-message token cache
|
|
44
45
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Symbol-keyed sidecar tagged onto each `AgentMessage` to memoize its
|
|
49
|
+
* `estimateTokens` result. Keyed by message identity (the object itself);
|
|
50
|
+
* a cheap content fingerprint detects in-place mutations (post-hoc error
|
|
51
|
+
* attachment, retry-truncated branch rebuild, etc.) and forces recompute.
|
|
52
|
+
*
|
|
53
|
+
* Cache lives on the message — multiple `StatusLineComponent` instances
|
|
54
|
+
* share it for free, and entries collect with the message itself when the
|
|
55
|
+
* conversation is replaced or compacted.
|
|
56
|
+
*/
|
|
57
|
+
const kTokenCache = Symbol("statusLine.tokenCache");
|
|
58
|
+
interface TaggedMessage {
|
|
59
|
+
[kTokenCache]?: { fingerprint: string; tokens: number };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cheap structural fingerprint mirroring `estimateTokens`'s content walk.
|
|
64
|
+
* O(blocks) — only reads string `.length` and primitives, never copies or
|
|
65
|
+
* serializes content. Any in-place mutation that alters total tokenized
|
|
66
|
+
* content also alters one of the byte-length sums or block counts captured
|
|
67
|
+
* here, forcing the cached `estimateTokens` value to be recomputed.
|
|
68
|
+
*/
|
|
69
|
+
function messageFingerprint(msg: AgentMessage): string {
|
|
70
|
+
const role = (msg as { role?: string }).role ?? "";
|
|
71
|
+
const ts = (msg as { timestamp?: number }).timestamp ?? 0;
|
|
72
|
+
let textLen = 0;
|
|
73
|
+
let blocks = 0;
|
|
74
|
+
let images = 0;
|
|
75
|
+
if (role === "bashExecution") {
|
|
76
|
+
const b = msg as { command?: unknown; output?: unknown };
|
|
77
|
+
if (typeof b.command === "string") textLen += b.command.length;
|
|
78
|
+
if (typeof b.output === "string") textLen += b.output.length;
|
|
79
|
+
} else if (role === "user") {
|
|
80
|
+
const content = (msg as { content?: unknown }).content;
|
|
81
|
+
if (typeof content === "string") {
|
|
82
|
+
textLen += content.length;
|
|
83
|
+
} else if (Array.isArray(content)) {
|
|
84
|
+
blocks = content.length;
|
|
85
|
+
for (const block of content) {
|
|
86
|
+
if (block?.type === "text" && typeof block.text === "string") textLen += block.text.length;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} else if (role === "assistant") {
|
|
90
|
+
const content = (msg as { content?: unknown }).content;
|
|
91
|
+
if (Array.isArray(content)) {
|
|
92
|
+
blocks = content.length;
|
|
93
|
+
for (const block of content) {
|
|
94
|
+
if (!block || typeof block !== "object") continue;
|
|
95
|
+
const b = block as { type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown };
|
|
96
|
+
if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
|
|
97
|
+
else if (b.type === "thinking" && typeof b.thinking === "string") textLen += b.thinking.length;
|
|
98
|
+
else if (b.type === "toolCall") {
|
|
99
|
+
if (typeof b.name === "string") textLen += b.name.length;
|
|
100
|
+
// Argument bytes vary; a length proxy is enough to detect in-place edits.
|
|
101
|
+
textLen += b.arguments === undefined ? 0 : JSON.stringify(b.arguments).length;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else if (role === "toolResult" || role === "hookMessage") {
|
|
106
|
+
const content = (msg as { content?: unknown }).content;
|
|
107
|
+
if (typeof content === "string") {
|
|
108
|
+
textLen += content.length;
|
|
109
|
+
} else if (Array.isArray(content)) {
|
|
110
|
+
blocks = content.length;
|
|
111
|
+
for (const block of content) {
|
|
112
|
+
if (!block || typeof block !== "object") continue;
|
|
113
|
+
const b = block as { type?: string; text?: string };
|
|
114
|
+
if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
|
|
115
|
+
else if (b.type === "image") images++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else if (role === "branchSummary" || role === "compactionSummary") {
|
|
119
|
+
const s = (msg as { summary?: unknown }).summary;
|
|
120
|
+
if (typeof s === "string") textLen += s.length;
|
|
121
|
+
}
|
|
122
|
+
return `${role}:${ts}:${textLen}:${blocks}:${images}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Token count for a single message, using the per-message sidecar cache.
|
|
127
|
+
* The caller MUST skip caching for the last message during streaming —
|
|
128
|
+
* it may still be growing and its tokens belong recomputed each refresh.
|
|
129
|
+
*/
|
|
130
|
+
function tokensForMessage(msg: AgentMessage): number {
|
|
131
|
+
const fp = messageFingerprint(msg);
|
|
132
|
+
const tagged = msg as TaggedMessage;
|
|
133
|
+
const cached = tagged[kTokenCache];
|
|
134
|
+
if (cached && cached.fingerprint === fp) return cached.tokens;
|
|
135
|
+
const tokens = estimateTokens(msg);
|
|
136
|
+
tagged[kTokenCache] = { fingerprint: fp, tokens };
|
|
137
|
+
return tokens;
|
|
138
|
+
}
|
|
139
|
+
|
|
46
140
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
47
141
|
// StatusLineComponent
|
|
48
142
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -85,14 +179,11 @@ export class StatusLineComponent implements Component {
|
|
|
85
179
|
// TTL design (which re-walked every message on each refresh and produced
|
|
86
180
|
// ~1.1 s sync freezes on 2,000+ message sessions because `updateEditorTopBorder`
|
|
87
181
|
// is called on every agent event in event-controller). The new scheme
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// no longer mutates) once index `i+1` exists. We therefore cache all but
|
|
94
|
-
// the LAST message (which may still be growing during streaming).
|
|
95
|
-
#messageTokenCache: number[] = [];
|
|
182
|
+
// caches by message-object identity (a Symbol-keyed sidecar on each
|
|
183
|
+
// message) plus a cheap content fingerprint, so in-place mutations of
|
|
184
|
+
// an existing message (post-hoc error attachment, retry-truncated
|
|
185
|
+
// branch rebuild, replaceMessages with the same length) are detected
|
|
186
|
+
// and recomputed.
|
|
96
187
|
// Cached non-message total (system prompt + tools + skills). Invalidated
|
|
97
188
|
// when the inputs-identity fingerprint changes (model swap, skill toggle,
|
|
98
189
|
// tool registration).
|
|
@@ -331,7 +422,12 @@ export class StatusLineComponent implements Component {
|
|
|
331
422
|
return null;
|
|
332
423
|
}
|
|
333
424
|
|
|
334
|
-
|
|
425
|
+
/**
|
|
426
|
+
* Background-refresh the Anthropic OAuth quota report. Guarded by a 5-min
|
|
427
|
+
* TTL on both success (cache lifetime) and error (backoff). Exposed
|
|
428
|
+
* (non-private) so unit tests can verify the backoff invariant.
|
|
429
|
+
*/
|
|
430
|
+
refreshUsageInBackground(): void {
|
|
335
431
|
const now = Date.now();
|
|
336
432
|
if (this.#usageInFlight) return;
|
|
337
433
|
if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
|
|
@@ -345,7 +441,11 @@ export class StatusLineComponent implements Component {
|
|
|
345
441
|
this.#usageFetchedAt = Date.now();
|
|
346
442
|
})
|
|
347
443
|
.catch(() => {
|
|
348
|
-
|
|
444
|
+
// Backoff on error: stamp the fetch time so the 5-min TTL guard
|
|
445
|
+
// also acts as an error budget. Without this, every render
|
|
446
|
+
// kicks off another fetch (gated only by #usageInFlight),
|
|
447
|
+
// which hammers the endpoint during a network outage / 5xx.
|
|
448
|
+
this.#usageFetchedAt = Date.now();
|
|
349
449
|
})
|
|
350
450
|
.finally(() => {
|
|
351
451
|
this.#usageInFlight = false;
|
|
@@ -414,29 +514,22 @@ export class StatusLineComponent implements Component {
|
|
|
414
514
|
this.#nonMessageInputsKey = inputsKey;
|
|
415
515
|
}
|
|
416
516
|
|
|
417
|
-
// 2) Message tokens — incremental.
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
//
|
|
424
|
-
// growing during streaming
|
|
425
|
-
// existing message); recomputing it each refresh is one
|
|
426
|
-
// `estimateTokens` call (~0.5 ms) and stays correct.
|
|
427
|
-
while (this.#messageTokenCache.length < Math.max(0, messages.length - 1)) {
|
|
428
|
-
const idx = this.#messageTokenCache.length;
|
|
429
|
-
this.#messageTokenCache.push(estimateTokens(messages[idx]));
|
|
430
|
-
}
|
|
517
|
+
// 2) Message tokens — incremental. The sidecar cache lives on the
|
|
518
|
+
// message object itself (Symbol-keyed), keyed by identity and
|
|
519
|
+
// validated by a cheap content fingerprint. Mutations that
|
|
520
|
+
// replace messages (replaceMessages, branch rebuild, compaction)
|
|
521
|
+
// yield fresh objects → cache miss → recompute. In-place
|
|
522
|
+
// mutations on the same object are caught by fingerprint
|
|
523
|
+
// mismatch. The LAST message is always recomputed because it
|
|
524
|
+
// may still be growing during streaming.
|
|
431
525
|
let messagesTokens = 0;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
messagesTokens += estimateTokens(messages[
|
|
526
|
+
const lastIdx = messages.length - 1;
|
|
527
|
+
for (let i = 0; i < messages.length; i++) {
|
|
528
|
+
messagesTokens += i === lastIdx ? estimateTokens(messages[i]) : tokensForMessage(messages[i]);
|
|
435
529
|
}
|
|
436
530
|
|
|
437
531
|
const usedTokens = this.#nonMessageTokensCache + messagesTokens;
|
|
438
|
-
|
|
439
|
-
return this.#cachedBreakdown;
|
|
532
|
+
return { usedTokens, contextWindow };
|
|
440
533
|
}
|
|
441
534
|
|
|
442
535
|
/**
|
|
@@ -457,7 +550,7 @@ export class StatusLineComponent implements Component {
|
|
|
457
550
|
const state = this.session.state;
|
|
458
551
|
|
|
459
552
|
// Trigger background fetch (5-min TTL); render uses cached value
|
|
460
|
-
this
|
|
553
|
+
this.refreshUsageInBackground();
|
|
461
554
|
|
|
462
555
|
// Get usage statistics
|
|
463
556
|
const aggregateUsageStats = this.session.sessionManager?.getUsageStatistics() ?? {
|
|
@@ -75,12 +75,28 @@ export function estimateToolSchemaTokens(
|
|
|
75
75
|
* messages walked incrementally as new entries append.
|
|
76
76
|
*/
|
|
77
77
|
export function computeNonMessageTokens(session: AgentSession): number {
|
|
78
|
+
const parts = computeNonMessageBreakdown(session);
|
|
79
|
+
return parts.systemPromptTokens + parts.systemContextTokens + parts.toolsTokens + parts.skillsTokens;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Shared helper for the four non-message token totals. Single source of truth
|
|
84
|
+
* for both `computeNonMessageTokens` (status-line incremental cache) and
|
|
85
|
+
* `computeContextBreakdown` (/context panel). The split avoids drift between
|
|
86
|
+
* the two surfaces — they MUST report the same numbers.
|
|
87
|
+
*/
|
|
88
|
+
function computeNonMessageBreakdown(session: AgentSession): {
|
|
89
|
+
skillsTokens: number;
|
|
90
|
+
toolsTokens: number;
|
|
91
|
+
systemContextTokens: number;
|
|
92
|
+
systemPromptTokens: number;
|
|
93
|
+
} {
|
|
78
94
|
const skillsTokens = estimateSkillsTokens(session.skills ?? []);
|
|
79
95
|
const toolsTokens = estimateToolSchemaTokens(session.agent?.state?.tools ?? []);
|
|
80
96
|
const systemPromptParts = session.systemPrompt ?? [];
|
|
81
97
|
const systemContextTokens = countTokens(systemPromptParts.slice(1));
|
|
82
98
|
const systemPromptTokens = Math.max(0, countTokens(systemPromptParts[0] ?? "") - skillsTokens);
|
|
83
|
-
return
|
|
99
|
+
return { skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens };
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
/**
|
|
@@ -91,9 +107,6 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown
|
|
|
91
107
|
const model = session.model;
|
|
92
108
|
const contextWindow = model?.contextWindow ?? 0;
|
|
93
109
|
|
|
94
|
-
const skillsTokens = estimateSkillsTokens(session.skills ?? []);
|
|
95
|
-
const toolsTokens = estimateToolSchemaTokens(session.agent?.state?.tools ?? []);
|
|
96
|
-
|
|
97
110
|
let messagesTokens = 0;
|
|
98
111
|
const convo = session.messages;
|
|
99
112
|
if (convo) {
|
|
@@ -108,9 +121,7 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown
|
|
|
108
121
|
// Tools = JSON tool schema sent separately on the wire
|
|
109
122
|
// Skills = the skill list embedded in the system prompt
|
|
110
123
|
// Messages = conversation messages
|
|
111
|
-
const
|
|
112
|
-
const systemPromptTokens = Math.max(0, countTokens(systemPromptParts?.[0] ?? "") - skillsTokens);
|
|
113
|
-
const systemContextTokens = countTokens(systemPromptParts?.slice(1) ?? []);
|
|
124
|
+
const { skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens } = computeNonMessageBreakdown(session);
|
|
114
125
|
|
|
115
126
|
const categories: CategoryInfo[] = [
|
|
116
127
|
{ id: "systemPrompt", label: "System prompt", tokens: systemPromptTokens, color: "accent", glyph: CELL_FILLED },
|
|
@@ -751,6 +751,9 @@ export class AgentSession {
|
|
|
751
751
|
|
|
752
752
|
// Event subscription state
|
|
753
753
|
#unsubscribeAgent?: () => void;
|
|
754
|
+
#unsubscribeAppendOnly?: () => void;
|
|
755
|
+
/** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
|
|
756
|
+
#lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
|
|
754
757
|
#eventListeners: AgentSessionEventListener[] = [];
|
|
755
758
|
|
|
756
759
|
/** Tracks pending steering messages for UI display. Removed when delivered.
|
|
@@ -1141,7 +1144,7 @@ export class AgentSession {
|
|
|
1141
1144
|
// (session persistence, hooks, auto-compaction, retry logic)
|
|
1142
1145
|
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
|
|
1143
1146
|
// Re-evaluate append-only context mode when the setting changes at runtime.
|
|
1144
|
-
onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
|
|
1147
|
+
this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
|
|
1145
1148
|
}
|
|
1146
1149
|
|
|
1147
1150
|
/** Model registry for API key resolution and model discovery */
|
|
@@ -2785,6 +2788,10 @@ export class AgentSession {
|
|
|
2785
2788
|
await hindsightState?.flushRetainQueue();
|
|
2786
2789
|
hindsightState?.dispose();
|
|
2787
2790
|
this.#disconnectFromAgent();
|
|
2791
|
+
if (this.#unsubscribeAppendOnly) {
|
|
2792
|
+
this.#unsubscribeAppendOnly();
|
|
2793
|
+
this.#unsubscribeAppendOnly = undefined;
|
|
2794
|
+
}
|
|
2788
2795
|
this.#eventListeners = [];
|
|
2789
2796
|
}
|
|
2790
2797
|
|
|
@@ -5980,7 +5987,12 @@ export class AgentSession {
|
|
|
5980
5987
|
*/
|
|
5981
5988
|
#syncAppendOnlyContext(model: Model | null | undefined): void {
|
|
5982
5989
|
const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
|
|
5983
|
-
const
|
|
5990
|
+
const providerId = model?.provider;
|
|
5991
|
+
const enable = setting === "on" || (setting === "auto" && providerId === "deepseek");
|
|
5992
|
+
const prev = this.#lastAppendOnlyResolution;
|
|
5993
|
+
if (prev && prev.enable === enable && prev.providerId === providerId) return;
|
|
5994
|
+
this.#lastAppendOnlyResolution = { enable, providerId };
|
|
5995
|
+
|
|
5984
5996
|
if (enable && !this.agent.appendOnlyContext) {
|
|
5985
5997
|
this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
|
|
5986
5998
|
} else if (enable && this.agent.appendOnlyContext) {
|
|
@@ -942,12 +942,71 @@ function extractFirstUserPrompt(entries: Array<Record<string, unknown>>): string
|
|
|
942
942
|
return undefined;
|
|
943
943
|
}
|
|
944
944
|
|
|
945
|
+
/**
|
|
946
|
+
* Promote orphaned `<basename>.jsonl.<snowflake>.bak` backups created by
|
|
947
|
+
* `#replaceSessionFileAfterEperm` back to their primary path when the primary
|
|
948
|
+
* is missing. This runs once per session-dir scan, before the main `*.jsonl`
|
|
949
|
+
* glob, so a crash between the two renames in the EPERM-rewrite path does not
|
|
950
|
+
* leave the user's last good state stranded outside the loader's view.
|
|
951
|
+
*
|
|
952
|
+
* Exported for testing.
|
|
953
|
+
*/
|
|
954
|
+
export async function recoverOrphanedBackups(sessionDir: string, storage: SessionStorage): Promise<void> {
|
|
955
|
+
let backups: string[];
|
|
956
|
+
try {
|
|
957
|
+
backups = storage.listFilesSync(sessionDir, "*.bak");
|
|
958
|
+
} catch {
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (backups.length === 0) return;
|
|
962
|
+
// For each primary path, pick the newest backup (highest mtime) as the recovery source.
|
|
963
|
+
const candidates = new Map<string, { backup: string; mtimeMs: number }>();
|
|
964
|
+
for (const backup of backups) {
|
|
965
|
+
const name = path.basename(backup);
|
|
966
|
+
// Expect "<primary>.<snowflake>.bak" where <primary> ends in ".jsonl".
|
|
967
|
+
if (!name.endsWith(".bak")) continue;
|
|
968
|
+
const trimmed = name.slice(0, -".bak".length);
|
|
969
|
+
const dotIdx = trimmed.lastIndexOf(".");
|
|
970
|
+
if (dotIdx <= 0) continue;
|
|
971
|
+
const primaryName = trimmed.slice(0, dotIdx);
|
|
972
|
+
if (!primaryName.endsWith(".jsonl")) continue;
|
|
973
|
+
const primaryPath = path.join(sessionDir, primaryName);
|
|
974
|
+
let mtimeMs = 0;
|
|
975
|
+
try {
|
|
976
|
+
mtimeMs = storage.statSync(backup).mtimeMs;
|
|
977
|
+
} catch {
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const existing = candidates.get(primaryPath);
|
|
981
|
+
if (!existing || mtimeMs > existing.mtimeMs) {
|
|
982
|
+
candidates.set(primaryPath, { backup, mtimeMs });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
for (const [primaryPath, { backup }] of candidates) {
|
|
986
|
+
if (storage.existsSync(primaryPath)) continue;
|
|
987
|
+
try {
|
|
988
|
+
await storage.rename(backup, primaryPath);
|
|
989
|
+
logger.warn("Recovered orphaned session backup", {
|
|
990
|
+
sessionFile: primaryPath,
|
|
991
|
+
backupPath: backup,
|
|
992
|
+
});
|
|
993
|
+
} catch (err) {
|
|
994
|
+
logger.warn("Failed to recover orphaned session backup", {
|
|
995
|
+
sessionFile: primaryPath,
|
|
996
|
+
backupPath: backup,
|
|
997
|
+
error: toError(err).message,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
945
1003
|
/**
|
|
946
1004
|
* Reads all session files from the directory and returns them sorted by mtime (newest first).
|
|
947
1005
|
* Uses low-level file I/O to efficiently read only the first 4KB of each file
|
|
948
1006
|
* to extract the JSON header and first user message without loading entire session logs into memory.
|
|
949
1007
|
*/
|
|
950
1008
|
async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
|
|
1009
|
+
await recoverOrphanedBackups(sessionDir, storage);
|
|
951
1010
|
try {
|
|
952
1011
|
const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
|
|
953
1012
|
const sessions: RecentSessionInfo[] = [];
|
|
@@ -2149,10 +2208,14 @@ export class SessionManager {
|
|
|
2149
2208
|
}
|
|
2150
2209
|
// Windows can reject overwrite-style rename with EPERM even after our own writer is closed.
|
|
2151
2210
|
// Move the old session file aside first so a failed retry can roll back to the last good file.
|
|
2211
|
+
// The backup uses a plain `<basename>.<snowflake>.bak` name (no leading dot) so that if the
|
|
2212
|
+
// process crashes between the two renames, `recoverOrphanedBackups` can find it via the
|
|
2213
|
+
// shared `*.bak` glob on both real and in-memory storage backends and promote it back to
|
|
2214
|
+
// the primary on the next session-dir scan.
|
|
2152
2215
|
|
|
2153
2216
|
async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
|
|
2154
2217
|
const dir = path.resolve(targetPath, "..");
|
|
2155
|
-
const backupPath = path.join(dir,
|
|
2218
|
+
const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
|
|
2156
2219
|
try {
|
|
2157
2220
|
await this.storage.rename(targetPath, backupPath);
|
|
2158
2221
|
} catch (err) {
|
|
@@ -2167,13 +2230,14 @@ export class SessionManager {
|
|
|
2167
2230
|
await this.storage.rename(tempPath, targetPath);
|
|
2168
2231
|
} catch (err) {
|
|
2169
2232
|
const replaceError = toError(err);
|
|
2233
|
+
const originalError = toError(renameError);
|
|
2170
2234
|
try {
|
|
2171
2235
|
await this.storage.rename(backupPath, targetPath);
|
|
2172
2236
|
} catch (rollbackErr) {
|
|
2173
2237
|
const rollbackError = toError(rollbackErr);
|
|
2174
2238
|
throw new Error(
|
|
2175
|
-
`Failed to replace session file after EPERM (${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
|
|
2176
|
-
{ cause:
|
|
2239
|
+
`Failed to replace session file after EPERM (original: ${originalError.message}; retry: ${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
|
|
2240
|
+
{ cause: originalError },
|
|
2177
2241
|
);
|
|
2178
2242
|
}
|
|
2179
2243
|
throw replaceError;
|
|
@@ -3244,6 +3308,7 @@ export class SessionManager {
|
|
|
3244
3308
|
): Promise<SessionInfo[]> {
|
|
3245
3309
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3246
3310
|
try {
|
|
3311
|
+
await recoverOrphanedBackups(dir, storage);
|
|
3247
3312
|
const files = storage.listFilesSync(dir, "*.jsonl");
|
|
3248
3313
|
return await collectSessionsFromFiles(files, storage);
|
|
3249
3314
|
} catch {
|
|
@@ -73,9 +73,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
73
73
|
allowArgs: true,
|
|
74
74
|
handleTui: async (command, runtime) => {
|
|
75
75
|
const hadArgs = !!command.args;
|
|
76
|
+
// Capture state BEFORE the call: when plan mode is already active,
|
|
77
|
+
// handlePlanModeCommand may exit it (on confirmed exit) or leave it on (on cancel
|
|
78
|
+
// or warning). In every "already active" case the typed args are NOT consumed,
|
|
79
|
+
// so preserve them in history regardless of the user's confirm/cancel choice.
|
|
80
|
+
const wasPlanModeEnabled = runtime.ctx.planModeEnabled;
|
|
76
81
|
await runtime.ctx.handlePlanModeCommand(command.args || undefined);
|
|
77
|
-
if (hadArgs &&
|
|
78
|
-
// plan was already active — preserve the typed command in input history
|
|
82
|
+
if (hadArgs && wasPlanModeEnabled) {
|
|
79
83
|
runtime.ctx.editor.addToHistory(command.text);
|
|
80
84
|
}
|
|
81
85
|
runtime.ctx.editor.setText("");
|
|
@@ -96,9 +100,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
96
100
|
allowArgs: true,
|
|
97
101
|
handleTui: async (command, runtime) => {
|
|
98
102
|
const hadArgs = !!command.args;
|
|
103
|
+
// Capture state BEFORE the call (see /plan above for rationale).
|
|
104
|
+
const wasGoalModeEnabled = runtime.ctx.goalModeEnabled;
|
|
99
105
|
await runtime.ctx.handleGoalModeCommand(command.args || undefined);
|
|
100
|
-
if (hadArgs &&
|
|
101
|
-
// goal was already active — preserve the typed command in input history
|
|
106
|
+
if (hadArgs && wasGoalModeEnabled) {
|
|
102
107
|
runtime.ctx.editor.addToHistory(command.text);
|
|
103
108
|
}
|
|
104
109
|
runtime.ctx.editor.setText("");
|
package/src/utils/clipboard.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import type { ClipboardImage } from "@oh-my-pi/pi-natives";
|
|
3
3
|
import * as native from "@oh-my-pi/pi-natives";
|
|
4
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
4
5
|
|
|
5
6
|
function hasDisplay(): boolean {
|
|
6
7
|
return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
@@ -80,7 +81,7 @@ if ($img -ne $null) {
|
|
|
80
81
|
}
|
|
81
82
|
`;
|
|
82
83
|
|
|
83
|
-
const POWERSHELL_TIMEOUT_MS =
|
|
84
|
+
const POWERSHELL_TIMEOUT_MS = 8000;
|
|
84
85
|
|
|
85
86
|
/**
|
|
86
87
|
* Read a clipboard image through the Windows host's PowerShell.
|
|
@@ -104,6 +105,12 @@ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
|
|
|
104
105
|
try {
|
|
105
106
|
stdout = await new Response(proc.stdout).text();
|
|
106
107
|
await proc.exited;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// powershell.exe is a Windows process reached over WSL interop; if it
|
|
110
|
+
// doesn't reap cleanly, swallow the error so the dispatcher can fall
|
|
111
|
+
// through to the native bridge instead of throwing.
|
|
112
|
+
logger.warn("clipboard: powershell read failed", { error: String(err) });
|
|
113
|
+
return null;
|
|
107
114
|
} finally {
|
|
108
115
|
clearTimeout(timer);
|
|
109
116
|
}
|
|
@@ -136,8 +143,12 @@ export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
|
|
|
136
143
|
if (isWsl()) {
|
|
137
144
|
const image = await readImageViaPowerShell();
|
|
138
145
|
if (image) return image;
|
|
139
|
-
// Fall through: arboard may still succeed on a future WSLg release
|
|
140
|
-
|
|
146
|
+
// Fall through: arboard may still succeed on a future WSLg release —
|
|
147
|
+
// but only when we actually have a display server. Headless WSL has
|
|
148
|
+
// no display, so arboard would reject anyway.
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!hasDisplay()) {
|
|
141
152
|
return null;
|
|
142
153
|
}
|
|
143
154
|
|
|
@@ -23,16 +23,27 @@ export interface ResizedImage {
|
|
|
23
23
|
// binding constraint once images are downsized to 1568px (Anthropic's internal threshold).
|
|
24
24
|
const DEFAULT_MAX_BYTES = 500 * 1024;
|
|
25
25
|
|
|
26
|
-
const DEFAULT_OPTIONS: Required<ImageResizeOptions
|
|
26
|
+
const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
|
|
27
27
|
// Anthropic's "internal recommended size" — Claude internally caps images at
|
|
28
28
|
// 1568px on the longest edge before vision processing.
|
|
29
29
|
maxWidth: 1568,
|
|
30
30
|
maxHeight: 1568,
|
|
31
31
|
maxBytes: DEFAULT_MAX_BYTES,
|
|
32
32
|
jpegQuality: 80,
|
|
33
|
-
excludeWebP: Bun.env.OMP_NO_WEBP !== undefined,
|
|
34
33
|
};
|
|
35
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Read `OMP_NO_WEBP` per-call so runtime toggles take effect.
|
|
37
|
+
* Only `"1"` and `"true"` (case-insensitive) enable exclusion — an empty string
|
|
38
|
+
* or `"0"` MUST be treated as disabled.
|
|
39
|
+
*/
|
|
40
|
+
function isWebPExcluded(): boolean {
|
|
41
|
+
const raw = Bun.env.OMP_NO_WEBP;
|
|
42
|
+
if (raw === undefined) return false;
|
|
43
|
+
const v = raw.toLowerCase();
|
|
44
|
+
return v === "1" || v === "true";
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
/** Pick the smallest of N encoded buffers. */
|
|
37
48
|
function pickSmallest(...candidates: Array<{ buffer: Uint8Array; mimeType: string }>): {
|
|
38
49
|
buffer: Uint8Array;
|
|
@@ -62,7 +73,8 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
|
|
|
62
73
|
* off the JS thread when the terminal (`.bytes()`) is awaited.
|
|
63
74
|
*/
|
|
64
75
|
export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
|
|
65
|
-
const
|
|
76
|
+
const excludeWebP = options?.excludeWebP ?? isWebPExcluded();
|
|
77
|
+
const opts = { ...DEFAULT_OPTIONS, ...options, excludeWebP };
|
|
66
78
|
const inputBuffer = Buffer.from(img.data, "base64");
|
|
67
79
|
|
|
68
80
|
try {
|
|
@@ -75,7 +87,12 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
75
87
|
// still get JPEG-compressed.
|
|
76
88
|
const originalSize = inputBuffer.length;
|
|
77
89
|
const comfortableSize = opts.maxBytes / 4;
|
|
78
|
-
if (
|
|
90
|
+
if (
|
|
91
|
+
originalWidth <= opts.maxWidth &&
|
|
92
|
+
originalHeight <= opts.maxHeight &&
|
|
93
|
+
originalSize <= comfortableSize &&
|
|
94
|
+
!(opts.excludeWebP && sourceMime === "image/webp")
|
|
95
|
+
) {
|
|
79
96
|
return {
|
|
80
97
|
buffer: inputBuffer,
|
|
81
98
|
mimeType: sourceMime,
|
|
@@ -251,7 +268,13 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
251
268
|
},
|
|
252
269
|
};
|
|
253
270
|
} catch {
|
|
254
|
-
//
|
|
271
|
+
// Bun.Image rejected the input — we cannot decode/re-encode it.
|
|
272
|
+
// When the caller demanded WebP exclusion AND the original is WebP,
|
|
273
|
+
// returning the original buffer would silently violate that contract,
|
|
274
|
+
// so surface an explicit error instead.
|
|
275
|
+
if (excludeWebP && (img.mimeType === "image/webp" || !img.mimeType)) {
|
|
276
|
+
throw new Error("resizeImage: failed to decode image and cannot honor excludeWebP for a WebP source");
|
|
277
|
+
}
|
|
255
278
|
return {
|
|
256
279
|
buffer: inputBuffer,
|
|
257
280
|
mimeType: img.mimeType,
|