one-code-extension 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +88 -10
  2. package/docs/guide/README.md +1 -0
  3. package/docs/guide/configuration.md +2 -2
  4. package/docs/guide/differences-from-claude-code.md +13 -4
  5. package/docs/guide/hooks.md +14 -2
  6. package/docs/guide/installation.md +54 -6
  7. package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
  8. package/docs/guide/reference.md +5 -2
  9. package/docs/guide/sessions-and-context.md +18 -0
  10. package/docs/guide/terminal-interface.md +1 -1
  11. package/docs/guide/tools.md +24 -2
  12. package/docs/guide/troubleshooting.md +14 -4
  13. package/docs/guide/windows.md +184 -0
  14. package/extensions/auto-mode/actions.ts +3 -1
  15. package/extensions/auto-mode/paths.ts +21 -23
  16. package/extensions/auto-mode/safety-floor.ts +44 -6
  17. package/extensions/auto-mode/shell-analysis.ts +25 -10
  18. package/extensions/auto-mode/transcript.ts +15 -5
  19. package/extensions/background/index.ts +83 -34
  20. package/extensions/background/monitor-batch.ts +7 -5
  21. package/extensions/background/wakeup.ts +18 -24
  22. package/extensions/bash/background.ts +19 -10
  23. package/extensions/bash/guards.ts +36 -16
  24. package/extensions/bash/index.ts +36 -166
  25. package/extensions/branding/index.ts +35 -4
  26. package/extensions/btw/index.ts +207 -0
  27. package/extensions/btw/panel.ts +179 -0
  28. package/extensions/btw/prompt.ts +45 -0
  29. package/extensions/clear/index.ts +15 -0
  30. package/extensions/compaction/fit.ts +9 -6
  31. package/extensions/doctor/build.ts +22 -2
  32. package/extensions/doctor/dependencies.ts +63 -31
  33. package/extensions/doctor/fix-prompt.ts +12 -4
  34. package/extensions/doctor/index.ts +1 -0
  35. package/extensions/doctor/models.ts +2 -1
  36. package/extensions/doctor/report.ts +2 -3
  37. package/extensions/doctor/update-lookup.ts +19 -11
  38. package/extensions/effort/index.ts +2 -1
  39. package/extensions/footer/footer-line.ts +19 -0
  40. package/extensions/footer/index.ts +25 -5
  41. package/extensions/hooks/executor.ts +84 -18
  42. package/extensions/hooks/index.ts +1 -0
  43. package/extensions/hooks/matcher.ts +23 -9
  44. package/extensions/hooks/settings.ts +15 -0
  45. package/extensions/lib/branch-restore.ts +12 -0
  46. package/extensions/lib/claude-context.ts +2 -3
  47. package/extensions/lib/claude-settings.ts +19 -0
  48. package/extensions/lib/command-launch.ts +71 -0
  49. package/extensions/lib/deferred.ts +173 -33
  50. package/extensions/lib/git.ts +16 -0
  51. package/extensions/lib/keys.ts +38 -0
  52. package/extensions/lib/local-command.ts +112 -0
  53. package/extensions/lib/model-facts.generated.json +263 -68
  54. package/extensions/lib/model-policy.ts +13 -0
  55. package/extensions/lib/model-tier.ts +16 -5
  56. package/extensions/lib/notifications.ts +547 -53
  57. package/extensions/lib/paths.ts +130 -2
  58. package/extensions/lib/pi-ai-estimate.ts +57 -26
  59. package/extensions/lib/pi-version.ts +1 -1
  60. package/extensions/lib/process-tree.ts +131 -5
  61. package/extensions/lib/reminders.ts +80 -27
  62. package/extensions/lib/scratchpad.ts +11 -10
  63. package/extensions/lib/shell-spawn.ts +298 -0
  64. package/extensions/lib/shell-tool.ts +252 -0
  65. package/extensions/lib/shell-tools.ts +13 -0
  66. package/extensions/lib/side-call.ts +76 -0
  67. package/extensions/lib/tui-render.ts +62 -11
  68. package/extensions/lib/update-check.mjs +155 -0
  69. package/extensions/lib/usage-bus.ts +1 -1
  70. package/extensions/lib/which.ts +62 -0
  71. package/extensions/lib/worktree-channel.ts +18 -0
  72. package/extensions/lib/worktree-isolation.ts +2 -1
  73. package/extensions/lsp/client.ts +14 -3
  74. package/extensions/lsp/index.ts +12 -5
  75. package/extensions/lsp/install-hints.ts +33 -3
  76. package/extensions/lsp/plugin-servers.ts +2 -2
  77. package/extensions/lsp/servers.ts +29 -7
  78. package/extensions/mcp/index.ts +3 -2
  79. package/extensions/memory/entries.ts +9 -6
  80. package/extensions/memory/index.ts +3 -2
  81. package/extensions/memory/open-external.ts +4 -1
  82. package/extensions/model-default/index.ts +21 -2
  83. package/extensions/permissions/index.ts +57 -28
  84. package/extensions/permissions/matcher.ts +120 -33
  85. package/extensions/permissions/modes.ts +14 -6
  86. package/extensions/permissions/powershell-rules.ts +549 -0
  87. package/extensions/permissions/session-grant.ts +9 -9
  88. package/extensions/plugins/index.ts +3 -2
  89. package/extensions/plugins/install/install.ts +1 -1
  90. package/extensions/powershell/description.ts +139 -0
  91. package/extensions/powershell/guards.ts +91 -0
  92. package/extensions/powershell/index.ts +89 -0
  93. package/extensions/powershell/policy.ts +91 -0
  94. package/extensions/recap/index.ts +10 -24
  95. package/extensions/recap/prompt.ts +3 -3
  96. package/extensions/session-title/index.ts +120 -0
  97. package/extensions/session-title/title.ts +111 -0
  98. package/extensions/skill/index.ts +40 -18
  99. package/extensions/skill/invoke.ts +1 -0
  100. package/extensions/subagents/hand-back-review.ts +10 -12
  101. package/extensions/subagents/index.ts +221 -78
  102. package/extensions/subagents/model-select.ts +75 -7
  103. package/extensions/system-prompt/environment.ts +14 -1
  104. package/extensions/system-reminder/index.ts +9 -0
  105. package/extensions/tasks/index.ts +2 -1
  106. package/extensions/tool-search/index.ts +41 -20
  107. package/extensions/workflow/agent-session.ts +6 -4
  108. package/extensions/workflow/index.ts +28 -5
  109. package/extensions/worktree/guards.ts +5 -4
  110. package/extensions/worktree/index.ts +15 -2
  111. package/extensions/worktree/rewrite.ts +17 -0
  112. package/package.json +7 -4
  113. package/types/app.d.ts +18 -2
@@ -10,6 +10,8 @@
10
10
  * `{ name, keywords? }` while extensions are loading (before session_start).
11
11
  */
12
12
 
13
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
14
+ import { toolResultsOnBranch } from "./branch-restore.ts";
13
15
  import { parseClaudeVersion } from "./model-tier.ts";
14
16
  import { normalizeToolName } from "../permissions/matcher.ts";
15
17
 
@@ -106,51 +108,189 @@ export interface DeferrableToolInfo {
106
108
  parameters?: { properties?: unknown; required?: unknown };
107
109
  }
108
110
 
111
+ /** The `tool_search` calls that loaded tools: each call's id → the names it loaded. */
112
+ export type ToolSearchLoads = ReadonlyMap<string, readonly string[]>;
113
+
114
+ /**
115
+ * Every load recorded on a session branch: a `tool_search` result whose
116
+ * persisted `details.added` names the tools it activated. Read once at
117
+ * session start (a resumed session's earlier loads); loads made during the
118
+ * session are captured at the call itself.
119
+ */
120
+ export function toolSearchLoads(branch: readonly SessionEntry[]): Map<string, string[]> {
121
+ const loads = new Map<string, string[]>();
122
+ for (const message of toolResultsOnBranch(branch, TOOL_SEARCH_NAMES)) {
123
+ const added = (message.details as { added?: unknown } | undefined)?.added;
124
+ if (!Array.isArray(added)) continue;
125
+ const names = added.filter((n): n is string => typeof n === "string");
126
+ if (names.length > 0) loads.set(message.toolCallId, names);
127
+ }
128
+ return loads;
129
+ }
130
+ const TOOL_SEARCH_NAMES: ReadonlySet<string> = new Set(["tool_search"]);
131
+
132
+ type WireTool = Record<string, unknown> & { name: string };
133
+ type WireBlock = Record<string, unknown> & { type?: unknown };
134
+ type WireMessage = { role?: unknown; content?: unknown };
135
+
136
+ /** A deferred definition in pi's own `convertTools` shape (findings §7). */
137
+ function deferredDefinition(tool: DeferrableToolInfo): WireTool {
138
+ return {
139
+ name: tool.name,
140
+ description: tool.description ?? "",
141
+ input_schema: {
142
+ type: "object",
143
+ properties: tool.parameters?.properties ?? {},
144
+ required: tool.parameters?.required ?? [],
145
+ },
146
+ defer_loading: true,
147
+ };
148
+ }
149
+
150
+ /** The list with pi's cache breakpoint on its last item (a no-op without one). */
151
+ function withBreakpointOnLast<T extends Record<string, unknown>>(items: T[], cache_control: unknown): T[] {
152
+ if (cache_control === undefined || items.length === 0) return items;
153
+ return [...items.slice(0, -1), { ...items[items.length - 1], cache_control }];
154
+ }
155
+
109
156
  /**
110
- * An Anthropic request with every deferred tool that pi left out appended as a
111
- * `defer_loading: true` definition, sorted by name (pi's own entries stay first
112
- * and untouched). Anthropic keeps deferred definitions out of the cached prefix,
113
- * but ADDING one to `tools` mid-session still re-caches the message history —
114
- * measured 2026-09-04 on Sonnet 5: the request after a `tool_search` load read
115
- * only tools + system (8.7k of 25k tokens); with the full deferred set present
116
- * from request 1 it read all 25k. Unloaded deferred definitions cost no input
117
- * tokens, so sending them always is free and keeps `tools` byte-stable.
157
+ * An Anthropic request whose `tools` array is byte-identical to request 1's,
158
+ * whatever `tool_search` has loaded since — the client side of Anthropic's
159
+ * deferred-tool contract, which pi 0.86 no longer supplies for One Code
160
+ * (rationale and measurements: docs/decisions/caching.md "Loaded deferred
161
+ * tools stay deferred on the wire", findings §7).
162
+ *
163
+ * - Every registry tool `isDeferred` that is not eager on the wire is appended
164
+ * as a `defer_loading: true` definition, sorted by name; pi's own rendering
165
+ * of a later-deferred registry tool is replaced by ours.
166
+ * - A loaded tool pi promoted to eager is demoted back to deferred, the tools
167
+ * cache breakpoint moves to the last remaining eager tool, and the
168
+ * `tool_search` result that loaded it (`loads`) is rewritten to
169
+ * `tool_reference` blocks, its own content moving to trailing sibling blocks
170
+ * that take the result's breakpoint (Anthropic rejects references mixed with
171
+ * ordinary result content). Demotion happens only when that result is on the
172
+ * wire and precedes every `tool_use` of the tool; otherwise the tool stays
173
+ * eager (one accepted cache miss, never a request Anthropic rejects).
174
+ * - pi's own deferred entries that are not registry tools (its
175
+ * `__pi_deferred_placeholder__`) stay in place.
118
176
  *
119
- * Returns undefined (leave the payload alone) when there is nothing to add, the
120
- * request carries no tools, or the tool names on the wire are not the registry's
121
- * (pi's OAuth "stealth" mode renames them to Claude Code casing, and a name that
122
- * cannot be matched must not be duplicated). Schemas follow pi's own
123
- * `convertTools` shape. Never mutates its input.
177
+ * Returns undefined (leave the payload alone) when the request carries no
178
+ * tools or the wire names are not the registry's (pi's OAuth "stealth" mode
179
+ * renames them to Claude Code casing, and an unmatched name must not be
180
+ * duplicated). Never mutates its input.
124
181
  */
125
- export function withDeferredToolDefinitions(
182
+ export function stabilizeDeferredTools(
126
183
  payload: Record<string, unknown>,
127
184
  tools: readonly DeferrableToolInfo[],
128
185
  isDeferred: (name: string) => boolean,
186
+ loads: ToolSearchLoads = new Map(),
129
187
  ): Record<string, unknown> | undefined {
130
188
  const existing = payload.tools;
131
189
  if (!Array.isArray(existing) || existing.length === 0) return undefined;
132
190
  const registry = new Set(tools.map((t) => t.name));
133
- const present = new Set<string>();
134
- for (const tool of existing) {
135
- const name = (tool as { name?: unknown } | null)?.name;
136
- if (typeof name !== "string" || !registry.has(name)) return undefined;
137
- present.add(name);
191
+
192
+ const eager: WireTool[] = [];
193
+ const anchors: WireTool[] = [];
194
+ for (const raw of existing) {
195
+ const tool = raw as WireTool | null;
196
+ if (typeof tool?.name !== "string") return undefined;
197
+ if (!registry.has(tool.name)) {
198
+ if (tool.defer_loading === true) anchors.push(tool);
199
+ else return undefined;
200
+ continue;
201
+ }
202
+ if (tool.defer_loading !== true) eager.push(tool);
203
+ }
204
+
205
+ const messages: WireMessage[] = Array.isArray(payload.messages) ? (payload.messages as WireMessage[]) : [];
206
+ const referencesAt = new Map<string, string[]>();
207
+ const demoted = new Set<string>();
208
+ if (loads.size > 0 && eager.some((tool) => isDeferred(tool.name))) {
209
+ // Message index of each tool's first use and of each tool result: a
210
+ // reference is valid only ahead of every use of the tool.
211
+ const firstUse = new Map<string, number>();
212
+ const resultAt = new Map<string, number>();
213
+ messages.forEach((message, index) => {
214
+ if (!Array.isArray(message?.content)) return;
215
+ for (const block of message.content as WireBlock[]) {
216
+ if (block?.type === "tool_use" && typeof block.name === "string" && !firstUse.has(block.name)) {
217
+ firstUse.set(block.name, index);
218
+ } else if (block?.type === "tool_result" && typeof block.tool_use_id === "string") {
219
+ resultAt.set(block.tool_use_id, index);
220
+ }
221
+ }
222
+ });
223
+ // The earliest on-wire load of each tool.
224
+ const loaderOf = new Map<string, { callId: string; at: number }>();
225
+ for (const [callId, names] of loads) {
226
+ const at = resultAt.get(callId);
227
+ if (at === undefined) continue;
228
+ for (const name of names) {
229
+ const current = loaderOf.get(name);
230
+ if (current === undefined || at < current.at) loaderOf.set(name, { callId, at });
231
+ }
232
+ }
233
+ for (const tool of eager) {
234
+ if (!isDeferred(tool.name)) continue;
235
+ const loader = loaderOf.get(tool.name);
236
+ if (loader === undefined) continue;
237
+ const use = firstUse.get(tool.name);
238
+ if (use !== undefined && use < loader.at) continue;
239
+ demoted.add(tool.name);
240
+ referencesAt.set(loader.callId, [...(referencesAt.get(loader.callId) ?? []), tool.name]);
241
+ }
138
242
  }
139
- const extra = tools
140
- .filter((t) => isDeferred(t.name) && !present.has(t.name))
243
+
244
+ // pi puts the tools breakpoint on the last eager tool; if that one is demoted
245
+ // the breakpoint moves to the new last one, otherwise nothing moves.
246
+ const last = eager.at(-1);
247
+ const keptEager = withBreakpointOnLast(
248
+ eager.filter((tool) => !demoted.has(tool.name)),
249
+ last !== undefined && demoted.has(last.name) ? last.cache_control : undefined,
250
+ );
251
+ if (keptEager.length === 0) return undefined;
252
+ const eagerNames = new Set(keptEager.map((tool) => tool.name));
253
+ const deferred = tools
254
+ .filter((t) => isDeferred(t.name) && !eagerNames.has(t.name))
141
255
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
142
- .map((t) => ({
143
- name: t.name,
144
- description: t.description ?? "",
145
- input_schema: {
146
- type: "object",
147
- properties: t.parameters?.properties ?? {},
148
- required: t.parameters?.required ?? [],
149
- },
150
- defer_loading: true,
151
- }));
152
- if (extra.length === 0) return undefined;
153
- return { ...payload, tools: [...existing, ...extra] };
256
+ .map(deferredDefinition);
257
+ return {
258
+ ...payload,
259
+ tools: [...keptEager, ...anchors, ...deferred],
260
+ messages: referencesAt.size === 0 ? payload.messages : messages.map((m) => withToolReferences(m, referencesAt)),
261
+ };
262
+ }
263
+
264
+ /**
265
+ * The user message with each loading `tool_result` rewritten to carry its
266
+ * `tool_reference` blocks and its original content moved to trailing sibling
267
+ * blocks; a breakpoint on the rewritten result moves to the new last block so
268
+ * the whole message stays inside the cached prefix.
269
+ */
270
+ function withToolReferences(message: WireMessage, referencesAt: ReadonlyMap<string, readonly string[]>): WireMessage {
271
+ if (message?.role !== "user" || !Array.isArray(message.content)) return message;
272
+ const content = message.content as WireBlock[];
273
+ if (!content.some((block) => block?.type === "tool_result" && referencesAt.has(block.tool_use_id as string))) return message;
274
+
275
+ const blocks: WireBlock[] = [];
276
+ const siblings: WireBlock[] = [];
277
+ let movedBreakpoint: unknown;
278
+ for (const block of content) {
279
+ const names = block?.type === "tool_result" ? referencesAt.get(block.tool_use_id as string) : undefined;
280
+ if (!names) {
281
+ blocks.push(block);
282
+ continue;
283
+ }
284
+ const { content: original, cache_control, ...rest } = block;
285
+ if (cache_control !== undefined) movedBreakpoint = cache_control;
286
+ blocks.push({ ...rest, content: names.map((name) => ({ type: "tool_reference", tool_name: name })) });
287
+ if (typeof original === "string") {
288
+ if (original.trim().length > 0) siblings.push({ type: "text", text: original });
289
+ } else if (Array.isArray(original)) {
290
+ siblings.push(...(original as WireBlock[]));
291
+ }
292
+ }
293
+ return { ...message, content: withBreakpointOnLast([...blocks, ...siblings], movedBreakpoint) };
154
294
  }
155
295
 
156
296
  /**
@@ -1,8 +1,24 @@
1
1
  /** Shared git helpers — pure functions only (safe to import across extensions). */
2
2
 
3
+ import { execFile } from "node:child_process";
3
4
  import { existsSync, readFileSync, statSync } from "node:fs";
4
5
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
6
 
7
+ /**
8
+ * Whether the checkout at `cwd` has no uncommitted or untracked changes, or
9
+ * undefined when git could not say (not a repo, git missing). One short
10
+ * `git status --porcelain`, asynchronous so the tool_call hook it runs from
11
+ * does not stall the event loop; used for the classifier transcript's
12
+ * ground-truth line after a `git status` call (permissions/index.ts).
13
+ */
14
+ export function gitStatusClean(cwd: string): Promise<boolean | undefined> {
15
+ return new Promise((resolvePromise) => {
16
+ execFile("git", ["status", "--porcelain"], { cwd, encoding: "utf8", timeout: 5_000, windowsHide: true }, (error, stdout) => {
17
+ resolvePromise(error ? undefined : stdout.trim().length === 0);
18
+ });
19
+ });
20
+ }
21
+
6
22
  /**
7
23
  * The nearest checkout root at or above `startDir`: the directory holding a
8
24
  * `.git` entry (a directory for a main checkout, a file for a linked worktree
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Keys that depend on the platform.
3
+ *
4
+ * pi rebinds a handful of its own shortcuts on Windows and WSL, where
5
+ * alt+enter and ctrl+shift+letter do not reach the process reliably: queue
6
+ * follow-up moves from alt+enter to ctrl+q, fork from ctrl+shift+f to ctrl+f,
7
+ * paste to alt+v, model select to alt+p (pi-coding-agent core/keybindings.js,
8
+ * `useWindowsKeybindings`, which pi does not export — the rule is copied
9
+ * here). So the ctrl+q that cycles permission modes everywhere else collides
10
+ * there: pi skips a `registerShortcut` on a built-in key with a startup
11
+ * warning, and mode cycling had no key at all on Windows (seen on the Vultr
12
+ * VM, 2026-09-19). No ctrl+letter is free on Windows once pi's app and editor
13
+ * bindings are counted, and pi itself reaches for alt there, so mode cycling
14
+ * is alt+m on Windows and WSL — docs/decisions/windows.md.
15
+ */
16
+
17
+ /** pi's rule for its Windows key table: native Windows, or a WSL distro. */
18
+ export function useWindowsKeybindings(platform: string = process.platform, env: NodeJS.ProcessEnv = process.env): boolean {
19
+ return platform === "win32" || (platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP));
20
+ }
21
+
22
+ /** The key that cycles permission modes: ctrl+q, or alt+m where pi owns ctrl+q. */
23
+ export function modeCycleKey(platform: string = process.platform, env: NodeJS.ProcessEnv = process.env): "ctrl+q" | "alt+m" {
24
+ return useWindowsKeybindings(platform, env) ? "alt+m" : "ctrl+q";
25
+ }
26
+
27
+ /**
28
+ * The footer badge's pause icon. Claude Code's is U+23F8 (⏸); Windows Terminal
29
+ * draws that code point with its emoji font, two cells wide and coloured,
30
+ * while pi measures one cell, so the icon overlapped the mode name — and the
31
+ * text-presentation selector (U+FE0E) is ignored there (verified on the Vultr
32
+ * VM, 2026-09-19: rows A and B of the width test). U+2016 (‖) is a plain
33
+ * single-cell glyph in Windows Terminal, so it stands in on Windows and WSL,
34
+ * where the terminal is Windows Terminal either way.
35
+ */
36
+ export function pauseGlyph(platform: string = process.platform, env: NodeJS.ProcessEnv = process.env): "⏸" | "‖" {
37
+ return useWindowsKeybindings(platform, env) ? "‖" : "⏸";
38
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Local-command breadcrumbs — what Claude Code shows the model when the user
3
+ * runs a slash command that the harness handles locally (`/clear`, `/model`, a
4
+ * panel command). Captured from CC 2.1.278 (`captures/sonnet.json`, and this
5
+ * repo's own sessions after a `/clear`; findings §14, 2026-09-19): three
6
+ * synthetic user messages that reach the model as consecutive bare text blocks
7
+ * of the next user message, after the reminder stack and before the user's own
8
+ * text —
9
+ *
10
+ * <local-command-caveat>Caveat: …</local-command-caveat>\n
11
+ * <command-name>/model</command-name>\n
12
+ * <command-message>model</command-message>\n
13
+ * <command-args></command-args>\n
14
+ * <local-command-stdout>Set model to `Sonnet 5` …</local-command-stdout>\n
15
+ *
16
+ * (twelve-space indent on the second and third command lines, verbatim from
17
+ * CC's `formatCommandInputTags`; stdout XML-escaped like CC's `escapeXml`, `&
18
+ * < >` only). `/clear` carries an EMPTY stdout block and opens the new
19
+ * conversation. The caveat tells the model not to act on any of it.
20
+ *
21
+ * One Code emits these through the reminder queue's `user-prepend` placement
22
+ * (`lib/reminders.ts`): pi dispatches an extension command BEFORE its `input`
23
+ * event fires and handles its own built-ins (`/model`, `/name`, …) before
24
+ * either, so there is no single interception point — every One Code local
25
+ * command is registered through `registerLocalCommand`, which announces before
26
+ * the handler runs, and `/model` is announced from the `model_select` event by
27
+ * the extension that persists the choice. pi built-ins we cannot see
28
+ * (`/compact`, `/settings`, …) stay silent. The blocks are pinned in process
29
+ * memory, not written to the session file as CC does — a `--resume` drops them.
30
+ */
31
+
32
+ import { REMINDER_CHANNEL, type ReminderPayload } from "./reminders.ts";
33
+
34
+ /** The slice of pi's ExtensionAPI the announcer needs (its event bus). */
35
+ export interface ReminderEmitter {
36
+ events: { emit(channel: string, data: unknown): unknown };
37
+ }
38
+
39
+ /**
40
+ * How each breadcrumb block begins — what a first message that is NOT the
41
+ * user's topic starts with (the session-title extension skips these, as CC's
42
+ * REPL does before naming a session).
43
+ */
44
+ export const LOCAL_COMMAND_TAG_PREFIXES = ["<local-command-caveat>", "<command-name>", "<command-message>", "<local-command-stdout>"] as const;
45
+
46
+ export const LOCAL_COMMAND_CAVEAT =
47
+ "<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>\n";
48
+
49
+ /** CC's `escapeXml` (utils/xml.ts): `& < >` only. */
50
+ export function escapeXml(s: string): string {
51
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
52
+ }
53
+
54
+ export interface LocalCommand {
55
+ /** Command name without the slash (`clear`, `model`). */
56
+ name: string;
57
+ /** The argument text the user typed after the name; "" when none. */
58
+ args?: string;
59
+ /** The command's printed result; "" (the default) is what `/clear` shows. */
60
+ stdout?: string;
61
+ }
62
+
63
+ /** CC's `formatCommandInputTags` — the command breadcrumb, indentation included. */
64
+ export function commandInputBlock(name: string, args = ""): string {
65
+ return `<command-name>/${name}</command-name>\n <command-message>${name}</command-message>\n <command-args>${args}</command-args>\n`;
66
+ }
67
+
68
+ /** CC's `<local-command-stdout>` message; the content is XML-escaped. */
69
+ export function commandStdoutBlock(stdout = ""): string {
70
+ return `<local-command-stdout>${escapeXml(stdout)}</local-command-stdout>\n`;
71
+ }
72
+
73
+ /** The three bare text blocks a local command leaves for the model, in CC's order. */
74
+ export function localCommandBlocks(command: LocalCommand): string[] {
75
+ return [LOCAL_COMMAND_CAVEAT, commandInputBlock(command.name, (command.args ?? "").trim()), commandStdoutBlock(command.stdout ?? "")];
76
+ }
77
+
78
+ /**
79
+ * Tell the model a local command ran. Call it from the command's handler
80
+ * (before a `/clear` resets the session — the queue survives `newSession()`,
81
+ * so the breadcrumb opens the new conversation exactly as in CC).
82
+ */
83
+ export function announceLocalCommand(pi: ReminderEmitter, command: LocalCommand): void {
84
+ localCommandBlocks(command).forEach((text, index) => {
85
+ // The caveat heads a RUN of commands: a second command before the next
86
+ // prompt joins the first caveat instead of repeating it.
87
+ const payload: ReminderPayload = { text, placement: "user-prepend", raw: true, ...(index === 0 ? { once: true } : {}) };
88
+ pi.events.emit(REMINDER_CHANNEL, payload);
89
+ });
90
+ }
91
+
92
+ type CommandOptions = { handler: (args: string, ctx: any) => Promise<void> | void } & Record<string, unknown>;
93
+
94
+ /**
95
+ * `pi.registerCommand` for a One Code local command: the breadcrumb is
96
+ * announced before the handler runs, so no command can forget it. `/clear`
97
+ * is the one exception — it announces from the NEW session's `session_start`
98
+ * (see clear/index.ts) and registers plainly.
99
+ */
100
+ export function registerLocalCommand<O extends CommandOptions>(
101
+ pi: ReminderEmitter & { registerCommand(name: string, options: O): void },
102
+ name: string,
103
+ options: O,
104
+ ): void {
105
+ pi.registerCommand(name, {
106
+ ...options,
107
+ handler: (args: string, ctx: unknown) => {
108
+ announceLocalCommand(pi, { name, args });
109
+ return options.handler(args, ctx);
110
+ },
111
+ });
112
+ }