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.
- package/README.md +88 -10
- package/docs/guide/README.md +1 -0
- package/docs/guide/configuration.md +2 -2
- package/docs/guide/differences-from-claude-code.md +13 -4
- package/docs/guide/hooks.md +14 -2
- package/docs/guide/installation.md +54 -6
- package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
- package/docs/guide/reference.md +5 -2
- package/docs/guide/sessions-and-context.md +18 -0
- package/docs/guide/terminal-interface.md +1 -1
- package/docs/guide/tools.md +24 -2
- package/docs/guide/troubleshooting.md +14 -4
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- 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
|
-
*
|
|
36
|
-
* the
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|