one-code-extension 0.3.0 → 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 (119) hide show
  1. package/README.md +189 -94
  2. package/docs/guide/README.md +3 -2
  3. package/docs/guide/bring-your-claude-code-setup.md +7 -5
  4. package/docs/guide/configuration.md +7 -5
  5. package/docs/guide/differences-from-claude-code.md +25 -14
  6. package/docs/guide/doctor.md +5 -3
  7. package/docs/guide/hooks.md +20 -6
  8. package/docs/guide/installation.md +62 -12
  9. package/docs/guide/permissions-modes-and-auto-mode.md +46 -28
  10. package/docs/guide/providers-and-models.md +12 -10
  11. package/docs/guide/reference.md +9 -4
  12. package/docs/guide/sessions-and-context.md +23 -3
  13. package/docs/guide/skills-plugins-and-mcp.md +7 -5
  14. package/docs/guide/subagents-and-workflows.md +13 -11
  15. package/docs/guide/tasks-and-background-work.md +10 -8
  16. package/docs/guide/terminal-interface.md +7 -5
  17. package/docs/guide/tools.md +29 -5
  18. package/docs/guide/troubleshooting.md +26 -14
  19. package/docs/guide/windows.md +184 -0
  20. package/extensions/auto-mode/actions.ts +3 -1
  21. package/extensions/auto-mode/paths.ts +21 -23
  22. package/extensions/auto-mode/safety-floor.ts +44 -6
  23. package/extensions/auto-mode/shell-analysis.ts +25 -10
  24. package/extensions/auto-mode/transcript.ts +15 -5
  25. package/extensions/background/index.ts +83 -34
  26. package/extensions/background/monitor-batch.ts +7 -5
  27. package/extensions/background/wakeup.ts +18 -24
  28. package/extensions/bash/background.ts +19 -10
  29. package/extensions/bash/guards.ts +36 -16
  30. package/extensions/bash/index.ts +36 -166
  31. package/extensions/branding/index.ts +35 -4
  32. package/extensions/btw/index.ts +207 -0
  33. package/extensions/btw/panel.ts +179 -0
  34. package/extensions/btw/prompt.ts +45 -0
  35. package/extensions/clear/index.ts +15 -0
  36. package/extensions/compaction/fit.ts +9 -6
  37. package/extensions/doctor/build.ts +22 -2
  38. package/extensions/doctor/dependencies.ts +63 -31
  39. package/extensions/doctor/fix-prompt.ts +12 -4
  40. package/extensions/doctor/index.ts +1 -0
  41. package/extensions/doctor/models.ts +2 -1
  42. package/extensions/doctor/report.ts +2 -3
  43. package/extensions/doctor/update-lookup.ts +19 -11
  44. package/extensions/effort/index.ts +2 -1
  45. package/extensions/footer/footer-line.ts +19 -0
  46. package/extensions/footer/index.ts +25 -5
  47. package/extensions/hooks/executor.ts +84 -18
  48. package/extensions/hooks/index.ts +1 -0
  49. package/extensions/hooks/matcher.ts +23 -9
  50. package/extensions/hooks/settings.ts +15 -0
  51. package/extensions/lib/branch-restore.ts +12 -0
  52. package/extensions/lib/claude-context.ts +2 -3
  53. package/extensions/lib/claude-settings.ts +19 -0
  54. package/extensions/lib/command-launch.ts +71 -0
  55. package/extensions/lib/deferred.ts +173 -33
  56. package/extensions/lib/git.ts +16 -0
  57. package/extensions/lib/keys.ts +38 -0
  58. package/extensions/lib/local-command.ts +112 -0
  59. package/extensions/lib/model-facts.generated.json +263 -68
  60. package/extensions/lib/model-policy.ts +13 -0
  61. package/extensions/lib/model-tier.ts +16 -5
  62. package/extensions/lib/notifications.ts +547 -53
  63. package/extensions/lib/paths.ts +130 -2
  64. package/extensions/lib/pi-ai-estimate.ts +57 -26
  65. package/extensions/lib/pi-version.ts +1 -1
  66. package/extensions/lib/process-tree.ts +131 -5
  67. package/extensions/lib/reminders.ts +80 -27
  68. package/extensions/lib/scratchpad.ts +11 -10
  69. package/extensions/lib/shell-spawn.ts +298 -0
  70. package/extensions/lib/shell-tool.ts +252 -0
  71. package/extensions/lib/shell-tools.ts +13 -0
  72. package/extensions/lib/side-call.ts +76 -0
  73. package/extensions/lib/tui-render.ts +62 -11
  74. package/extensions/lib/update-check.mjs +155 -0
  75. package/extensions/lib/usage-bus.ts +1 -1
  76. package/extensions/lib/which.ts +62 -0
  77. package/extensions/lib/worktree-channel.ts +18 -0
  78. package/extensions/lib/worktree-isolation.ts +2 -1
  79. package/extensions/lsp/client.ts +14 -3
  80. package/extensions/lsp/index.ts +12 -5
  81. package/extensions/lsp/install-hints.ts +33 -3
  82. package/extensions/lsp/plugin-servers.ts +2 -2
  83. package/extensions/lsp/servers.ts +29 -7
  84. package/extensions/mcp/index.ts +3 -2
  85. package/extensions/memory/entries.ts +9 -6
  86. package/extensions/memory/index.ts +3 -2
  87. package/extensions/memory/open-external.ts +4 -1
  88. package/extensions/model-default/index.ts +21 -2
  89. package/extensions/permissions/index.ts +57 -28
  90. package/extensions/permissions/matcher.ts +120 -33
  91. package/extensions/permissions/modes.ts +14 -6
  92. package/extensions/permissions/powershell-rules.ts +549 -0
  93. package/extensions/permissions/session-grant.ts +9 -9
  94. package/extensions/plugins/index.ts +3 -2
  95. package/extensions/plugins/install/install.ts +1 -1
  96. package/extensions/powershell/description.ts +139 -0
  97. package/extensions/powershell/guards.ts +91 -0
  98. package/extensions/powershell/index.ts +89 -0
  99. package/extensions/powershell/policy.ts +91 -0
  100. package/extensions/recap/index.ts +10 -24
  101. package/extensions/recap/prompt.ts +3 -3
  102. package/extensions/session-title/index.ts +120 -0
  103. package/extensions/session-title/title.ts +111 -0
  104. package/extensions/skill/index.ts +40 -18
  105. package/extensions/skill/invoke.ts +1 -0
  106. package/extensions/subagents/hand-back-review.ts +10 -12
  107. package/extensions/subagents/index.ts +221 -78
  108. package/extensions/subagents/model-select.ts +75 -7
  109. package/extensions/system-prompt/environment.ts +14 -1
  110. package/extensions/system-reminder/index.ts +9 -0
  111. package/extensions/tasks/index.ts +2 -1
  112. package/extensions/tool-search/index.ts +41 -20
  113. package/extensions/workflow/agent-session.ts +6 -4
  114. package/extensions/workflow/index.ts +28 -5
  115. package/extensions/worktree/guards.ts +5 -4
  116. package/extensions/worktree/index.ts +15 -2
  117. package/extensions/worktree/rewrite.ts +17 -0
  118. package/package.json +7 -4
  119. package/types/app.d.ts +18 -2
@@ -17,11 +17,19 @@ import { readFileSync, statSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { CC_HOOK_EVENTS, type CcHookEvent } from "./protocol.ts";
19
19
 
20
+ /** Claude Code's per-hook `shell` field: which interpreter runs the command string. */
21
+ export type HookShell = "bash" | "powershell";
22
+
20
23
  export interface HookCommand {
21
24
  type: "command";
22
25
  command: string;
23
26
  /** Seconds, CC convention. */
24
27
  timeout?: number;
28
+ /**
29
+ * Unset means the platform default: bash where one exists, PowerShell on
30
+ * Windows without Git Bash (Claude Code's rule — executor.ts `defaultHookShell`).
31
+ */
32
+ shell?: HookShell;
25
33
  }
26
34
 
27
35
  export interface HookMatcherEntry {
@@ -97,10 +105,17 @@ export function parseHooksBlock(raw: unknown, origin: string, diagnostics: strin
97
105
  diagnostics.push(`${origin}: ${event} hook of type "${String(candidate?.type)}" skipped (only "command" is supported)`);
98
106
  continue;
99
107
  }
108
+ const { shell } = hook as { shell?: unknown };
109
+ let hookShell: HookShell | undefined;
110
+ if (shell !== undefined) {
111
+ if (shell === "bash" || shell === "powershell") hookShell = shell;
112
+ else diagnostics.push(`${origin}: ${event} hook shell "${String(shell)}" ignored (use "bash" or "powershell")`);
113
+ }
100
114
  hooks.push({
101
115
  type: "command",
102
116
  command: candidate.command,
103
117
  timeout: typeof candidate.timeout === "number" && candidate.timeout > 0 ? candidate.timeout : undefined,
118
+ shell: hookShell,
104
119
  });
105
120
  }
106
121
  if (hooks.length > 0) {
@@ -18,6 +18,18 @@
18
18
 
19
19
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
20
20
 
21
+ /** A tool result as the session stores it (pi's `ToolResultMessage` reaches us only through `SessionEntry`). */
22
+ export type BranchToolResult = Extract<Extract<SessionEntry, { type: "message" }>["message"], { role: "toolResult" }>;
23
+
24
+ /** Every tool result on the branch from one of `toolNames`, oldest first. */
25
+ export function* toolResultsOnBranch(branch: readonly SessionEntry[], toolNames: ReadonlySet<string>): Generator<BranchToolResult> {
26
+ for (const entry of branch) {
27
+ if (entry.type !== "message") continue;
28
+ const msg = entry.message;
29
+ if (msg.role === "toolResult" && toolNames.has(msg.toolName)) yield msg;
30
+ }
31
+ }
32
+
21
33
  export function restoreLatestDetails<T>(
22
34
  branch: SessionEntry[],
23
35
  toolNames: ReadonlySet<string>,
@@ -34,7 +34,7 @@
34
34
  import { existsSync, readdirSync, statSync } from "node:fs";
35
35
  import { dirname, isAbsolute, join } from "node:path";
36
36
  import { tryReadFile } from "./plugins.ts";
37
- import { expandTilde } from "./paths.ts";
37
+ import { absoluteFrom, expandTilde } from "./paths.ts";
38
38
 
39
39
  /** One CLAUDE.md-family file as it appears in the block. `content` is raw (untrimmed). */
40
40
  export interface ContextFile {
@@ -116,8 +116,7 @@ function firstOneCodeFile(dir: string): string | null {
116
116
 
117
117
  /** Resolve an `@import` reference: `~`/`~/` → home, absolute as-is, else relative to `baseDir`. */
118
118
  function resolveImportPath(ref: string, baseDir: string, home: string): string {
119
- const p = expandTilde(ref, home);
120
- return isAbsolute(p) ? p : join(baseDir, p);
119
+ return absoluteFrom(baseDir, expandTilde(ref, home));
121
120
  }
122
121
 
123
122
  /**
@@ -46,6 +46,25 @@ export function readSettingsFile(path: string): ClaudeSettingsFile | undefined {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * Claude Code's `env` block from the USER settings file only: the string
51
+ * values it would export into every session. Read for `CLAUDE_CODE_GIT_BASH_PATH`
52
+ * (lib/shell-spawn.ts). Project and local files are deliberately not merged —
53
+ * a checked-in `env` that pointed the shell at a `tools/bash` inside the repo
54
+ * would run that binary for every hook and background command, and the file
55
+ * is the repository's, not the user's (Claude Code gates the same block behind
56
+ * its project-trust prompt).
57
+ */
58
+ export function readSettingsEnv(home: string): Record<string, string> {
59
+ const env = readSettingsFile(claudeUserSettingsPath(home))?.env;
60
+ if (!env || typeof env !== "object" || Array.isArray(env)) return {};
61
+ const out: Record<string, string> = {};
62
+ for (const [key, value] of Object.entries(env as Record<string, unknown>)) {
63
+ if (typeof value === "string") out[key] = value;
64
+ }
65
+ return out;
66
+ }
67
+
49
68
  /**
50
69
  * Claude Code's plugin enabled-state map (`{"name@marketplace": boolean}`),
51
70
  * merged across the three settings files — later files win per key.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * How to spawn a user-named command on this platform (pure apart from the
3
+ * PATH lookup it is given). For every bare `spawn(command, args)` of a command
4
+ * the user or a config names — a language server, `$EDITOR` — never for the
5
+ * shell tools, which go through lib/shell-spawn.ts.
6
+ *
7
+ * On Windows an npm-installed tool is a `.cmd` shim
8
+ * (`typescript-language-server.cmd`, VS Code's `code.cmd`): `whichOnPath`
9
+ * finds it through PATHEXT, but a bare `spawn(command)` does not — libuv tries
10
+ * the name and `.com`/`.exe` only, so the spawn fails with ENOENT (findings
11
+ * §22) — and Node refuses to run a batch file without a shell anyway (20.12+,
12
+ * CVE-2024-27980). Such a shim is started through `cmd.exe /d /s /c` with the
13
+ * command line quoted the way Node's own `shell: true` does, and the arguments
14
+ * passed verbatim so Node does not quote them a second time. Anything that
15
+ * resolves to a real executable, or does not resolve at all (the caller's
16
+ * ENOENT handling then applies — lsp/install-hints.ts for servers), is spawned
17
+ * as given. Elsewhere the launch is the command and arguments unchanged.
18
+ */
19
+
20
+ import { system32Path } from "./paths.ts";
21
+ import { whichOnPath } from "./which.ts";
22
+
23
+ export interface CommandLaunch {
24
+ command: string;
25
+ args: string[];
26
+ /** Set for a cmd.exe launch: the single command-line argument is already quoted. */
27
+ windowsVerbatimArguments?: boolean;
28
+ }
29
+
30
+ export interface CommandLaunchOptions {
31
+ platform?: string;
32
+ which?: (command: string, env: NodeJS.ProcessEnv, platform: string) => string | undefined;
33
+ }
34
+
35
+ /** A batch-file shim, by extension (the filesystem folds case; PATHEXT spells them upper-case). */
36
+ export function isBatchFile(path: string): boolean {
37
+ return /\.(cmd|bat)$/i.test(path);
38
+ }
39
+
40
+ /**
41
+ * One argument for a cmd.exe command line: quoted when it holds whitespace or
42
+ * a cmd metacharacter, with the C runtime's rules for what the program the
43
+ * shim runs will read back — an inner quote is `\"`, and a run of backslashes
44
+ * directly before a quote (an inner one, or the closing one after a path that
45
+ * ends in `\`) is doubled, or it would escape that quote. A literal `%` cannot
46
+ * be protected: cmd.exe expands `%NAME%` before it reads the quotes, the same
47
+ * limit Node's `shell: true` has, and `.cmd` shims are only reached this way.
48
+ */
49
+ export function quoteForCmd(arg: string): string {
50
+ if (arg === "") return '""';
51
+ if (!/[\s"&|<>^()]/.test(arg)) return arg;
52
+ const body = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1");
53
+ return `"${body}"`;
54
+ }
55
+
56
+ export function commandLaunch(command: string, args: string[], env: NodeJS.ProcessEnv, opts: CommandLaunchOptions = {}): CommandLaunch {
57
+ const platform = opts.platform ?? process.platform;
58
+ if (platform !== "win32") return { command, args };
59
+ const resolved = (opts.which ?? whichOnPath)(command, env, platform);
60
+ if (!resolved) return { command, args };
61
+ if (!isBatchFile(resolved)) return { command: resolved, args };
62
+ const line = [resolved, ...args].map(quoteForCmd).join(" ");
63
+ // cmd.exe is located from the PROCESS environment, never from `env`: a
64
+ // server's config may set its own variables (a plugin's `.lsp.json` env
65
+ // block), and `SystemRoot` among them must not redirect the interpreter.
66
+ return {
67
+ command: system32Path("cmd.exe"),
68
+ args: ["/d", "/s", "/c", `"${line}"`],
69
+ windowsVerbatimArguments: true,
70
+ };
71
+ }
@@ -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
+ }