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
@@ -14,11 +14,11 @@
14
14
  * module state — so each re-derives it through `sessionScratchpadDir`.
15
15
  */
16
16
 
17
- import { realpathSync } from "node:fs";
18
17
  import os from "node:os";
19
18
  import { join } from "node:path";
20
19
  import { findGitRoot } from "./git.ts";
21
20
  import { projectSlug } from "./memory.ts";
21
+ import { tryRealpath } from "./paths.ts";
22
22
 
23
23
  /** Pure core, testable: Claude Code's path shape under a One Code-named owner dir. */
24
24
  export function scratchpadDir(
@@ -32,17 +32,18 @@ export function scratchpadDir(
32
32
  }
33
33
 
34
34
  /**
35
- * `/tmp` resolved through its symlink (macOS: `/private/tmp`), so the path in
36
- * the prompt, the path the permission check compares, and the case-folded
37
- * resolved subject all name the same real location. Falls back to os.tmpdir()
38
- * where /tmp does not exist.
35
+ * The temp root in its resolved spelling, so the path in the prompt, the path
36
+ * the permission check compares, and the resolved subject all name the same
37
+ * real location: `/tmp` through its symlink (macOS: `/private/tmp`), and on
38
+ * Windows — which has no `/tmp`; `os.tmpdir()` is `%TEMP%`, Claude Code's own
39
+ * choice there — `%TEMP%` through its 8.3 short names, which Windows often
40
+ * spells it with (`C:\Users\RUNNER~1\…`, `ISURUW~1` for a long user name).
41
+ * A write's subject arrives realpath'd to the long name, so a scratchpad dir
42
+ * kept in the short spelling never contained anything (the runner showed it,
43
+ * findings §22). Falls back to the spelling as given where resolution fails.
39
44
  */
40
45
  function resolveTmpRoot(): string {
41
- try {
42
- return realpathSync("/tmp");
43
- } catch {
44
- return os.tmpdir();
45
- }
46
+ return tryRealpath(process.platform === "win32" ? os.tmpdir() : "/tmp") ?? os.tmpdir();
46
47
  }
47
48
 
48
49
  /** The session's scratchpad, derived the same way by every consumer. */
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Which shell One Code spawns, and how — the one place that knows about Git
3
+ * Bash on Windows and about PowerShell.
4
+ *
5
+ * Claude Code's shape (findings §22, docs/decisions/windows.md): Git for
6
+ * Windows is optional. When a bash is available it drives the `bash` tool,
7
+ * hooks and background shells; `CLAUDE_CODE_GIT_BASH_PATH` (process env, or
8
+ * Claude Code's user-settings `env` block) names a custom bash and is ignored
9
+ * with a warning when it is not a bash/sh binary (CC 2.1.219). PowerShell is
10
+ * `pwsh.exe` then `powershell.exe` on Windows (pi's own resolution), and a
11
+ * `pwsh` on PATH elsewhere — which is how the tool is developed and tested on
12
+ * this Mac (plan §Open questions 2). The argument list is pi's, copied because
13
+ * pi does not export the constant: `-NoProfile -NonInteractive
14
+ * -ExecutionPolicy Bypass -Command`, so the three safety flags are never lost.
15
+ *
16
+ * Resolution goes through pi's exported `getShellConfig` / `getPowerShellConfig`
17
+ * so a bash found here is the same bash pi's own bash tool runs. Both
18
+ * resolvers are injectable for tests (`platform`, `env`, `exists`, `fallback`).
19
+ *
20
+ * `createPowerShellOperations` supplies the spawn for pi's
21
+ * `createPowerShellToolDefinition(cwd, { operations })`: pi's own local
22
+ * operations throw off Windows, so the schema, cwd handling, truncation and
23
+ * rendering stay pi's while the process model is ours (spawn, stream both
24
+ * pipes, process-tree kill on abort/timeout, wait for exit) — the same ~60
25
+ * lines pi runs on Windows, with the same error strings the tool formats.
26
+ */
27
+
28
+ import { type ChildProcess, spawn, type SpawnOptions } from "node:child_process";
29
+ import { constants, existsSync } from "node:fs";
30
+ import { access } from "node:fs/promises";
31
+ import { homedir } from "node:os";
32
+ import { basename } from "node:path";
33
+ import { getPowerShellConfig, getShellConfig } from "@earendil-works/pi-coding-agent";
34
+ import { readSettingsEnv } from "./claude-settings.ts";
35
+ import { detachedSpawnOptions, killProcessTree, waitForChildExit } from "./process-tree.ts";
36
+ import { whichOnPath } from "./which.ts";
37
+
38
+ /** How to start a shell for one command line. */
39
+ export interface ShellSpawn {
40
+ shell: string;
41
+ args: string[];
42
+ /**
43
+ * pi's legacy-WSL form (`C:\Windows\System32\bash.exe`): the command goes in
44
+ * on stdin (`bash -s`) instead of as an argument. Default: argv.
45
+ */
46
+ commandTransport?: "argv" | "stdin";
47
+ }
48
+
49
+ /** pi's PowerShell argument list (utils/shell.ts `POWERSHELL_ARGS`, not exported). */
50
+ export const POWERSHELL_ARGS = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"] as const;
51
+
52
+ /** pi's UTF-8 output prefix for PowerShell (core/tools/powershell.ts), so Windows PowerShell 5.1 does not emit UTF-16. */
53
+ export const POWERSHELL_UTF8_PREFIX = "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n";
54
+
55
+ export const GIT_BASH_PATH_VAR = "CLAUDE_CODE_GIT_BASH_PATH";
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // bash
59
+
60
+ /** A path names a bash or sh binary — `bash`, `sh`, `bash.exe`, `sh.exe`, any case (CC 2.1.219's check). */
61
+ export function isBashBinaryName(path: string): boolean {
62
+ const name = basename(path.replace(/\\/g, "/")).toLowerCase();
63
+ return name === "bash" || name === "sh" || name === "bash.exe" || name === "sh.exe";
64
+ }
65
+
66
+ export interface GitBashOverride {
67
+ path: string;
68
+ source: "env" | "settings";
69
+ }
70
+
71
+ /** The user's `CLAUDE_CODE_GIT_BASH_PATH`, process env first, then Claude Code's user-settings `env` block. */
72
+ export function gitBashOverride(
73
+ env: NodeJS.ProcessEnv = process.env,
74
+ settingsEnv: Record<string, string> = {},
75
+ ): GitBashOverride | undefined {
76
+ const fromEnv = env[GIT_BASH_PATH_VAR]?.trim();
77
+ if (fromEnv) return { path: fromEnv, source: "env" };
78
+ const fromSettings = settingsEnv[GIT_BASH_PATH_VAR]?.trim();
79
+ if (fromSettings) return { path: fromSettings, source: "settings" };
80
+ return undefined;
81
+ }
82
+
83
+ export interface BashResolveInput {
84
+ env?: NodeJS.ProcessEnv;
85
+ /** Claude Code's settings `env` block (user scope only — lib/claude-settings.ts `readSettingsEnv`). */
86
+ settingsEnv?: Record<string, string>;
87
+ exists?: (path: string) => boolean;
88
+ /** pi's `getShellConfig`; injectable so tests never depend on this machine's shells. */
89
+ fallback?: (customShellPath?: string) => ShellSpawn;
90
+ }
91
+
92
+ export interface BashResolution {
93
+ /** Undefined when no bash exists (Windows without Git for Windows). */
94
+ spawn?: ShellSpawn;
95
+ /** An override that was ignored, and why — surfaced to the user once. */
96
+ warning?: string;
97
+ /** Why no bash could be found, in pi's own words. */
98
+ error?: string;
99
+ }
100
+
101
+ export function resolveBashSpawn(input: BashResolveInput = {}): BashResolution {
102
+ const exists = input.exists ?? existsSync;
103
+ const fallback = input.fallback ?? ((custom?: string) => getShellConfig(custom) as ShellSpawn);
104
+ const override = gitBashOverride(input.env, input.settingsEnv);
105
+ let warning: string | undefined;
106
+ if (override) {
107
+ const where = override.source === "env" ? `${GIT_BASH_PATH_VAR}` : `${GIT_BASH_PATH_VAR} (settings.json env)`;
108
+ if (!isBashBinaryName(override.path)) {
109
+ warning = `${where} ignored: "${override.path}" is not a bash or sh binary.`;
110
+ } else if (!exists(override.path)) {
111
+ warning = `${where} ignored: "${override.path}" does not exist.`;
112
+ } else {
113
+ try {
114
+ return { spawn: fallback(override.path) };
115
+ } catch (error) {
116
+ warning = `${where} ignored: ${error instanceof Error ? error.message : String(error)}`;
117
+ }
118
+ }
119
+ }
120
+ try {
121
+ return { spawn: fallback(), warning };
122
+ } catch (error) {
123
+ return { warning, error: error instanceof Error ? error.message : String(error) };
124
+ }
125
+ }
126
+
127
+ let cachedBash: BashResolution | undefined;
128
+
129
+ /**
130
+ * The bash this extension spawns, resolved once per module instance (pi's
131
+ * Windows lookup runs `where`, and hooks resolve on every tool call). The
132
+ * resolution reads Claude Code's user-settings `env` block itself, so every
133
+ * consumer — bash tool, hooks, monitor, PowerShell fallback — sees the same
134
+ * `CLAUDE_CODE_GIT_BASH_PATH`. Note the cache is per extension file: jiti
135
+ * gives each extension its own copy of this module (findings §3), so this is
136
+ * a memo, never a cross-extension channel — each extension resolves once.
137
+ */
138
+ export function bashSpawn(): BashResolution {
139
+ cachedBash ??= resolveBashSpawn({ settingsEnv: readSettingsEnv(homedir()) });
140
+ return cachedBash;
141
+ }
142
+
143
+ /** The resolved bash, or a throw naming the fix (only reachable on Windows without Git for Windows). */
144
+ export function bashSpawnOrThrow(): ShellSpawn {
145
+ const resolved = bashSpawn();
146
+ if (!resolved.spawn) throw new Error(resolved.error ?? "No bash shell found.");
147
+ return resolved.spawn;
148
+ }
149
+
150
+ /** Test seam. */
151
+ export function resetShellSpawnCache(): void {
152
+ cachedBash = undefined;
153
+ cachedPowerShell = undefined;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // PowerShell
158
+
159
+ export interface PowerShellResolveInput {
160
+ platform?: NodeJS.Platform;
161
+ env?: NodeJS.ProcessEnv;
162
+ /** PATH lookup (lib/which.ts `whichOnPath`); injectable for tests. */
163
+ which?: (command: string, env: NodeJS.ProcessEnv) => string | undefined;
164
+ /** pi's `getPowerShellConfig` (Windows only); injectable for tests. */
165
+ windowsFallback?: () => ShellSpawn;
166
+ }
167
+
168
+ /** pi's resolver on Windows (`pwsh.exe` then `powershell.exe`); the first executable `pwsh` on PATH elsewhere. */
169
+ export function resolvePowerShellSpawn(input: PowerShellResolveInput = {}): ShellSpawn | undefined {
170
+ const platform = input.platform ?? process.platform;
171
+ if (platform === "win32") {
172
+ try {
173
+ return (input.windowsFallback ?? (() => getPowerShellConfig() as ShellSpawn))();
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ const pwsh = (input.which ?? ((cmd, env) => whichOnPath(cmd, env, platform)))("pwsh", input.env ?? process.env);
179
+ return pwsh ? { shell: pwsh, args: [...POWERSHELL_ARGS] } : undefined;
180
+ }
181
+
182
+ let cachedPowerShell: { spawn: ShellSpawn | undefined } | undefined;
183
+
184
+ /** The PowerShell this process spawns, resolved once per process. */
185
+ export function powerShellSpawn(): ShellSpawn | undefined {
186
+ cachedPowerShell ??= { spawn: resolvePowerShellSpawn() };
187
+ return cachedPowerShell.spawn;
188
+ }
189
+
190
+ /** Whether the resolved PowerShell is Windows PowerShell 5.1 (`powershell.exe`) or PowerShell 7+ (`pwsh`). */
191
+ export function powerShellEdition(spawnSpec: ShellSpawn | undefined): "core" | "desktop" | "unknown" {
192
+ if (!spawnSpec) return "unknown";
193
+ const name = basename(spawnSpec.shell.replace(/\\/g, "/")).toLowerCase();
194
+ if (name === "pwsh" || name === "pwsh.exe") return "core";
195
+ if (name === "powershell.exe" || name === "powershell") return "desktop";
196
+ return "unknown";
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // spawning
201
+
202
+ /**
203
+ * Start `command` under `spec`. argv transport appends the command to the
204
+ * shell's arguments (`bash -c <cmd>`, `pwsh … -Command <cmd>`); stdin transport
205
+ * writes it to the child's stdin (pi's legacy-WSL bash), which needs stdin
206
+ * piped — callers that must feed their own stdin (hooks) reject that form.
207
+ */
208
+ export function spawnShellCommand(spec: ShellSpawn, command: string, options: SpawnOptions): ChildProcess {
209
+ if (spec.commandTransport === "stdin") {
210
+ const stdio = Array.isArray(options.stdio) ? ["pipe", ...options.stdio.slice(1)] : ["pipe", "pipe", "pipe"];
211
+ const child = spawn(spec.shell, spec.args, { ...options, stdio: stdio as SpawnOptions["stdio"], windowsHide: true });
212
+ child.stdin?.on("error", () => {});
213
+ child.stdin?.end(command);
214
+ return child;
215
+ }
216
+ return spawn(spec.shell, [...spec.args, command], { ...options, windowsHide: true });
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // PowerShell operations for pi's tool definition
221
+
222
+ /** pi's `BashOperations` shape (core/tools/bash.ts), spelled locally so this module has one pi import site. */
223
+ export interface ShellOperations {
224
+ exec: (
225
+ command: string,
226
+ cwd: string,
227
+ options: { onData: (data: Buffer) => void; signal?: AbortSignal; timeout?: number; env?: NodeJS.ProcessEnv },
228
+ ) => Promise<{ exitCode: number | null }>;
229
+ }
230
+
231
+ const MAX_TIMEOUT_MS = 2_147_483_647;
232
+
233
+ /** pi's timeout validation (seconds → ms), same messages. */
234
+ function resolveTimeoutMs(timeout: number | undefined): number | undefined {
235
+ if (timeout === undefined) return undefined;
236
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new Error("Invalid timeout: must be a finite number of seconds");
237
+ const ms = timeout * 1000;
238
+ if (ms > MAX_TIMEOUT_MS) throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_MS / 1000} seconds`);
239
+ return ms;
240
+ }
241
+
242
+ /**
243
+ * Operations for `createPowerShellToolDefinition(cwd, { operations })` backed
244
+ * by the PowerShell `resolve()` names. Mirrors pi's local shell operations
245
+ * (spawn, stream, kill the tree on abort/timeout, wait) with pi's error strings
246
+ * — `aborted`, `timeout:<seconds>` — which the tool definition formats.
247
+ */
248
+ export function createPowerShellOperations(resolve: () => ShellSpawn | undefined = powerShellSpawn): ShellOperations {
249
+ return {
250
+ exec: async (command, cwd, { onData, signal, timeout, env }) => {
251
+ const timeoutMs = resolveTimeoutMs(timeout);
252
+ if (signal?.aborted) throw new Error("aborted");
253
+ const spec = resolve();
254
+ if (!spec) {
255
+ throw new Error(
256
+ process.platform === "win32"
257
+ ? "No PowerShell executable found. Install PowerShell or add powershell.exe/pwsh.exe to PATH."
258
+ : "No PowerShell executable found. Install PowerShell 7 (pwsh) and put it on PATH.",
259
+ );
260
+ }
261
+ try {
262
+ await access(cwd, constants.F_OK);
263
+ } catch {
264
+ throw new Error(`Working directory does not exist: ${cwd}\nCannot execute PowerShell commands.`);
265
+ }
266
+ const child = spawnShellCommand(spec, `${POWERSHELL_UTF8_PREFIX}${command}`, {
267
+ cwd,
268
+ ...detachedSpawnOptions(),
269
+ env: env ?? process.env,
270
+ stdio: ["ignore", "pipe", "pipe"],
271
+ });
272
+ let timedOut = false;
273
+ let timer: NodeJS.Timeout | undefined;
274
+ const onAbort = () => killProcessTree(child, "SIGKILL");
275
+ try {
276
+ if (timeoutMs !== undefined) {
277
+ timer = setTimeout(() => {
278
+ timedOut = true;
279
+ killProcessTree(child, "SIGKILL");
280
+ }, timeoutMs);
281
+ }
282
+ child.stdout?.on("data", onData);
283
+ child.stderr?.on("data", onData);
284
+ if (signal) {
285
+ if (signal.aborted) onAbort();
286
+ else signal.addEventListener("abort", onAbort, { once: true });
287
+ }
288
+ const { code: exitCode } = await waitForChildExit(child);
289
+ if (signal?.aborted) throw new Error("aborted");
290
+ if (timedOut) throw new Error(`timeout:${timeout}`);
291
+ return { exitCode };
292
+ } finally {
293
+ if (timer) clearTimeout(timer);
294
+ signal?.removeEventListener("abort", onAbort);
295
+ }
296
+ },
297
+ };
298
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The shared body of One Code's two shell tools (`bash`, `powershell`):
3
+ * Claude Code's `run_in_background` on top of pi's own shell tool
4
+ * definitions. Each extension supplies what differs — the tool's names and
5
+ * texts, pi's base definition, its per-cwd foreground executor, its
6
+ * pre-execution guard, and the spawn spec for a background run — and this
7
+ * module owns the orchestration: guard → millisecond timeout clamp →
8
+ * foreground delegate, or the background branch (blocking in one-shot modes,
9
+ * detached on the shared background registry with a completion notification
10
+ * otherwise). See extensions/bash/index.ts for why each piece is shaped as
11
+ * it is; extensions/powershell/index.ts is the second consumer.
12
+ */
13
+
14
+ import { mkdirSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
17
+ import type { Static, TObject } from "typebox";
18
+ import { generateTaskId, TASK_REGISTER_CHANNEL } from "../background/registry.ts";
19
+ import { type BashFinishSummary, runBackgroundBashBlocking, startBackgroundBash, tailCap } from "../bash/background.ts";
20
+ import { createTaskNotifier, oneShotNote, sessionOutlivesTurn, shellSummary, type TaskStatus, taskNotification } from "./notifications.ts";
21
+ import { commandToEvaluate, trackOriginalCommands } from "./original-command.ts";
22
+ import { persistIfLarge, sessionResultsDir } from "./persisted-output.ts";
23
+ import type { ShellSpawn } from "./shell-spawn.ts";
24
+ import { ccWrapBuiltinRenderers, linesComponent, resultLines } from "./tui-render.ts";
25
+
26
+ // The completion notification carries status + exit code + where the output is,
27
+ // plus only a short tail: a finished build used to push 30 KB (~8k tokens) into
28
+ // context unasked (review T7). The full tail stays behind task_output / the log.
29
+ export const NOTIFY_OUTPUT_CAP = 2_000;
30
+ /** One-shot modes return the whole output in the result; past this it is persisted (file + preview), never cut. */
31
+ export const ONE_SHOT_OUTPUT_CAP = 30_000;
32
+ /** Claude Code's cap; the tools' `timeout` is milliseconds, as CC's is. */
33
+ export const MAX_TIMEOUT_MS = 600_000;
34
+
35
+ /** The parameter shape both shell tools share (descriptions differ per tool). */
36
+ export interface ShellToolParams {
37
+ command: string;
38
+ timeout?: number;
39
+ description?: string;
40
+ run_in_background?: boolean;
41
+ }
42
+
43
+ /** The parts of pi's shell tool definition the shared body reads (structural, so the concrete generic types assign). */
44
+ export interface ShellToolBase {
45
+ label: string;
46
+ promptSnippet?: string;
47
+ promptGuidelines?: readonly string[];
48
+ executionMode?: ToolDefinition["executionMode"];
49
+ constrainedSampling?: ToolDefinition["constrainedSampling"];
50
+ renderCall?: (args: any, theme: any, context: any) => any;
51
+ renderResult?: (result: any, options: any, theme: any, context: any) => any;
52
+ }
53
+
54
+ /** pi's shell tool executor: `execute(toolCallId, { command, timeout: seconds }, signal, onUpdate, ctx)`. */
55
+ export interface ShellToolExecutor {
56
+ execute: (toolCallId: string, params: { command: string; timeout?: number }, signal: AbortSignal | undefined, onUpdate: any, ctx: ExtensionContext) => Promise<any>;
57
+ }
58
+
59
+ /** What one shell tool's definition supplies; everything else is shared. */
60
+ export interface ShellToolSpec<P extends TObject> {
61
+ /** pi tool name (`bash`, `powershell`). */
62
+ name: string;
63
+ /** Claude Code's label for the `● Label(cmd)` transcript line and the notifications. */
64
+ ccLabel: string;
65
+ description: string;
66
+ parameters: P;
67
+ /** pi's base definition: label, snippet, guidelines, execution mode, renderers. */
68
+ base: ShellToolBase;
69
+ promptGuidelines?: readonly string[];
70
+ /** The per-cwd foreground executor (pi's definition takes seconds). */
71
+ foreground: (cwd: string) => ShellToolExecutor;
72
+ /** Pre-execution guard on the model's ORIGINAL command; a string refuses the call. */
73
+ guard: (command: string, opts: { background: boolean }) => string | undefined;
74
+ /** The spawn for a background run, or undefined when the shell is unavailable. */
75
+ backgroundShell: () => ShellSpawn | undefined;
76
+ /** Rewrites the command for a background spawn (PowerShell prepends its UTF-8 prefix). Default: identity. */
77
+ wrapBackgroundCommand?: (command: string) => string;
78
+ }
79
+
80
+ /**
81
+ * How a shell task ended, read once from the finish summary: CC's `<status>`
82
+ * (a stop is `killed`, a timeout or non-zero exit `failed`) and, for a failure
83
+ * that an exit code does not explain, the detail (the timeout, the signal).
84
+ * `finishLine` (foreground text) and the background notification both build
85
+ * on this, so the two can never disagree.
86
+ */
87
+ export function shellFinish(summary: BashFinishSummary, timeoutSeconds?: number): { status: TaskStatus; detail?: string } {
88
+ if (summary.stopped) return { status: "killed" };
89
+ if (summary.timedOut) return { status: "failed", detail: `timed out after ${timeoutSeconds}s` };
90
+ if (summary.exitCode === 0) return { status: "completed" };
91
+ return { status: "failed", detail: summary.exitCode === null ? `terminated by ${summary.signal ?? "unknown signal"}` : undefined };
92
+ }
93
+
94
+ export function finishLine(summary: BashFinishSummary, timeoutSeconds?: number): string {
95
+ const { status, detail } = shellFinish(summary, timeoutSeconds);
96
+ if (status === "killed") return "stopped";
97
+ if (status === "completed") return "completed";
98
+ return `failed (${detail ?? `exit code ${summary.exitCode}`})`;
99
+ }
100
+
101
+ /** `<sessionDir>/bash/<taskId>/output.log` — one spool location for every shell tool (the shell panel reads it). */
102
+ export function taskLogPath(ctx: ExtensionContext, taskId: string): string | undefined {
103
+ try {
104
+ const dir = join(ctx.sessionManager.getSessionDir(), "bash", taskId);
105
+ mkdirSync(dir, { recursive: true });
106
+ return join(dir, "output.log");
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ export function registerShellTool<P extends TObject>(pi: ExtensionAPI, spec: ShellToolSpec<P>): void {
113
+ // A completion whose output task_output just returned is withdrawn (CC's
114
+ // delivered_as_tool_result) — the model already has it.
115
+ const notifyTask = createTaskNotifier(pi, { withdrawOnDelivery: true });
116
+ // A worktree session cd-wraps input.command; the pre-wrapper original arrives
117
+ // over the bus keyed by toolCallId (never read from params — model-writable).
118
+ const originalCommands = trackOriginalCommands(pi);
119
+ const notify = (text: string, details: Record<string, unknown>) => notifyTask("task-notification", text, details);
120
+ const wrap = spec.wrapBackgroundCommand ?? ((command: string) => command);
121
+
122
+ // `● Label(cmd)` / elbow-indented output, like every other One Code tool.
123
+ // Not passing base.renderCall also drops its timer state, so the misleading
124
+ // "Took Ns" that counted permission-prompt wait disappears.
125
+ const wrapped = ccWrapBuiltinRenderers<{ command?: string }>(spec.ccLabel, spec.base, { title: (a) => a?.command });
126
+
127
+ pi.registerTool({
128
+ name: spec.name,
129
+ label: spec.base.label,
130
+ description: spec.description,
131
+ promptSnippet: spec.base.promptSnippet,
132
+ promptGuidelines: spec.promptGuidelines ?? spec.base.promptGuidelines,
133
+ executionMode: spec.base.executionMode,
134
+ // pi 0.86 samples its own shell tools against a strict JSON schema by
135
+ // default (`{ type: "json_schema", strict: "prefer" }`); inherit that as
136
+ // tool-style does for read/edit/write, so every built-in-shaped tool gets
137
+ // the same treatment. Our parameter objects are strict-compatible, pi
138
+ // strips the `null` a strict optional arrives as before validation, and
139
+ // a provider without strict tools is left alone by "prefer".
140
+ constrainedSampling: spec.base.constrainedSampling,
141
+ renderShell: wrapped.renderShell,
142
+ renderCall: wrapped.renderCall as ToolDefinition<P>["renderCall"],
143
+ renderResult: ((result, options, theme, context) => {
144
+ // A background start returns a model-facing instruction paragraph;
145
+ // the transcript needs one line (Claude Code: "Running in the background").
146
+ const details = result.details as { taskId?: string; logPath?: string } | undefined;
147
+ if (details?.taskId && !context.isError) {
148
+ const line = options.expanded
149
+ ? `Running in the background (task ${details.taskId}${details.logPath ? ` · log: ${details.logPath}` : ""})`
150
+ : "Running in the background (↓ to manage)";
151
+ return linesComponent(() => resultLines(theme as any, line, options.expanded, false));
152
+ }
153
+ return wrapped.renderResult(result, options, theme, context);
154
+ }) as ToolDefinition<P>["renderResult"],
155
+ parameters: spec.parameters,
156
+ async execute(toolCallId, rawParams, signal, onUpdate, ctx) {
157
+ const params = rawParams as Static<P> & ShellToolParams;
158
+ // Guards check the model's original command — a worktree session rewrites
159
+ // input.command to `cd '<wt>' && (…)`, which would hide the real lead.
160
+ const originalCommand = commandToEvaluate(originalCommands, toolCallId, params.command);
161
+ const guardReason = spec.guard(originalCommand, { background: params.run_in_background === true });
162
+ if (guardReason) return { content: [{ type: "text" as const, text: guardReason }], isError: true, details: {} };
163
+ // pi's executor takes seconds; the model-facing unit is milliseconds
164
+ // (Claude Code's), so a habitual `timeout: 120000` is 2 minutes, not
165
+ // 33 hours (review T11).
166
+ const timeoutSeconds =
167
+ params.timeout !== undefined && Number.isFinite(params.timeout) && params.timeout > 0
168
+ ? Math.min(params.timeout, MAX_TIMEOUT_MS) / 1000
169
+ : undefined;
170
+
171
+ if (!params.run_in_background) {
172
+ return spec.foreground(ctx.cwd).execute(toolCallId, { command: params.command, timeout: timeoutSeconds }, signal, onUpdate, ctx);
173
+ }
174
+
175
+ const shell = spec.backgroundShell();
176
+ if (!shell) {
177
+ return {
178
+ content: [{ type: "text" as const, text: `No ${spec.ccLabel} executable is available for a background run.` }],
179
+ isError: true,
180
+ details: {},
181
+ };
182
+ }
183
+ const id = generateTaskId();
184
+ const logPath = taskLogPath(ctx, id);
185
+ const description = params.description || params.command.slice(0, 80);
186
+ const command = wrap(params.command);
187
+
188
+ // One-shot modes (`-p` / `--mode json`) exit when the turn settles: a
189
+ // detached task would be orphaned, and its completion callback would
190
+ // call sendMessage on the disposed session and crash pi (measured,
191
+ // STEERING-REVIEW-2026-09-05 H3). Run blocking there — the same rule the
192
+ // Agent tool applies — and return the output in the result.
193
+ if (!sessionOutlivesTurn(ctx.mode)) {
194
+ const summary = await runBackgroundBashBlocking({ id, command, description, cwd: ctx.cwd, timeoutSeconds, logPath, shell }, signal);
195
+ const output = persistIfLarge(summary.output, { dir: sessionResultsDir(ctx), id: `${spec.name}-${id}`, maxBytes: ONE_SHOT_OUTPUT_CAP });
196
+ return {
197
+ content: [
198
+ {
199
+ type: "text" as const,
200
+ text: `${spec.ccLabel} task ${id} (${description}) ${finishLine(summary, timeoutSeconds)}. ${oneShotNote("command")}${logPath ? ` Log: ${logPath}.` : ""}\n\n${output}`,
201
+ },
202
+ ],
203
+ // No taskId: nothing was registered behind task_output/task_stop, and
204
+ // its presence is what renderResult reads as "running in the background".
205
+ details: { logPath },
206
+ isError: summary.exitCode !== 0 && !summary.stopped,
207
+ };
208
+ }
209
+
210
+ const task = startBackgroundBash({
211
+ id,
212
+ command,
213
+ description,
214
+ cwd: ctx.cwd,
215
+ timeoutSeconds,
216
+ logPath,
217
+ shell,
218
+ onFinished: (finishedTask, summary) => {
219
+ // Claude Code's shell completion names the output file and nothing
220
+ // else — the model reads it (or task_output) when it needs the text.
221
+ // Without a spool file the tail rides `<result>` so nothing is lost.
222
+ const { status, detail } = shellFinish(summary, timeoutSeconds);
223
+ notify(
224
+ taskNotification({
225
+ kind: "shell",
226
+ taskId: id,
227
+ toolUseId: toolCallId,
228
+ outputFile: logPath,
229
+ status,
230
+ summary: shellSummary(description, status, summary.exitCode, detail),
231
+ result: logPath ? undefined : tailCap(summary.output, NOTIFY_OUTPUT_CAP).trim() || undefined,
232
+ }),
233
+ { taskId: id, status: finishedTask.status, exitCode: summary.exitCode, logPath },
234
+ );
235
+ },
236
+ });
237
+ // The shell panel lists the task by the model's command, not a wrapped one.
238
+ task.command = params.command;
239
+ pi.events.emit(TASK_REGISTER_CHANNEL, task);
240
+
241
+ return {
242
+ content: [
243
+ {
244
+ type: "text" as const,
245
+ text: `⏳ ${spec.ccLabel} task ${id} running in background (${description}).\n\nCompletion will arrive as a task notification on its own naming the output file — you do not need to wait for it or poll; keep working.${logPath ? ` To check interim output, read ${logPath}.` : ""} If your next step cannot proceed without the result, task_output with block=true waits for it. Stop with task_stop.`,
246
+ },
247
+ ],
248
+ details: { taskId: id, logPath },
249
+ };
250
+ },
251
+ } as ToolDefinition<P>);
252
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The tools whose subject is a shell command line (pure, no pi imports): the
3
+ * one list the permission matcher (`isShellTool`), the classifier transcript
4
+ * (`{"Bash":…}` / `{"PowerShell":…}` rendering) and the worktree rewriter
5
+ * read, so adding a shell tool is one edit.
6
+ */
7
+
8
+ export const SHELL_TOOLS: ReadonlySet<string> = new Set(["bash", "powershell"]);
9
+
10
+ /** Whether an already-normalized (pi snake_case) tool name is a shell tool. */
11
+ export function isShellToolName(normalizedName: string): boolean {
12
+ return SHELL_TOOLS.has(normalizedName);
13
+ }