one-code-extension 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +214 -147
  2. package/docs/guide/README.md +1 -0
  3. package/docs/guide/configuration.md +2 -2
  4. package/docs/guide/differences-from-claude-code.md +13 -4
  5. package/docs/guide/hooks.md +14 -2
  6. package/docs/guide/installation.md +54 -6
  7. package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
  8. package/docs/guide/reference.md +5 -2
  9. package/docs/guide/sessions-and-context.md +18 -0
  10. package/docs/guide/terminal-interface.md +1 -1
  11. package/docs/guide/tools.md +24 -2
  12. package/docs/guide/troubleshooting.md +14 -4
  13. package/docs/guide/windows.md +189 -0
  14. package/extensions/auto-mode/actions.ts +3 -1
  15. package/extensions/auto-mode/paths.ts +21 -23
  16. package/extensions/auto-mode/safety-floor.ts +44 -6
  17. package/extensions/auto-mode/shell-analysis.ts +25 -10
  18. package/extensions/auto-mode/transcript.ts +15 -5
  19. package/extensions/background/index.ts +83 -34
  20. package/extensions/background/monitor-batch.ts +7 -5
  21. package/extensions/background/wakeup.ts +18 -24
  22. package/extensions/bash/background.ts +19 -10
  23. package/extensions/bash/guards.ts +36 -16
  24. package/extensions/bash/index.ts +36 -166
  25. package/extensions/branding/index.ts +35 -4
  26. package/extensions/btw/index.ts +207 -0
  27. package/extensions/btw/panel.ts +179 -0
  28. package/extensions/btw/prompt.ts +45 -0
  29. package/extensions/clear/index.ts +15 -0
  30. package/extensions/compaction/fit.ts +9 -6
  31. package/extensions/doctor/build.ts +22 -2
  32. package/extensions/doctor/dependencies.ts +63 -31
  33. package/extensions/doctor/fix-prompt.ts +12 -4
  34. package/extensions/doctor/index.ts +1 -0
  35. package/extensions/doctor/models.ts +2 -1
  36. package/extensions/doctor/report.ts +2 -3
  37. package/extensions/doctor/update-lookup.ts +19 -11
  38. package/extensions/effort/index.ts +2 -1
  39. package/extensions/footer/footer-line.ts +19 -0
  40. package/extensions/footer/index.ts +25 -5
  41. package/extensions/hooks/executor.ts +84 -18
  42. package/extensions/hooks/index.ts +1 -0
  43. package/extensions/hooks/matcher.ts +23 -9
  44. package/extensions/hooks/settings.ts +15 -0
  45. package/extensions/lib/branch-restore.ts +12 -0
  46. package/extensions/lib/claude-context.ts +2 -3
  47. package/extensions/lib/claude-settings.ts +19 -0
  48. package/extensions/lib/command-launch.ts +71 -0
  49. package/extensions/lib/deferred.ts +173 -33
  50. package/extensions/lib/git.ts +16 -0
  51. package/extensions/lib/keys.ts +38 -0
  52. package/extensions/lib/local-command.ts +112 -0
  53. package/extensions/lib/model-facts.generated.json +263 -68
  54. package/extensions/lib/model-policy.ts +13 -0
  55. package/extensions/lib/model-tier.ts +16 -5
  56. package/extensions/lib/notifications.ts +547 -53
  57. package/extensions/lib/paths.ts +130 -2
  58. package/extensions/lib/pi-ai-estimate.ts +57 -26
  59. package/extensions/lib/pi-version.ts +1 -1
  60. package/extensions/lib/process-tree.ts +131 -5
  61. package/extensions/lib/reminders.ts +80 -27
  62. package/extensions/lib/scratchpad.ts +11 -10
  63. package/extensions/lib/shell-spawn.ts +298 -0
  64. package/extensions/lib/shell-tool.ts +252 -0
  65. package/extensions/lib/shell-tools.ts +13 -0
  66. package/extensions/lib/side-call.ts +76 -0
  67. package/extensions/lib/tui-render.ts +62 -11
  68. package/extensions/lib/update-check.mjs +155 -0
  69. package/extensions/lib/usage-bus.ts +1 -1
  70. package/extensions/lib/which.ts +62 -0
  71. package/extensions/lib/worktree-channel.ts +18 -0
  72. package/extensions/lib/worktree-isolation.ts +2 -1
  73. package/extensions/lsp/client.ts +14 -3
  74. package/extensions/lsp/index.ts +12 -5
  75. package/extensions/lsp/install-hints.ts +33 -3
  76. package/extensions/lsp/plugin-servers.ts +2 -2
  77. package/extensions/lsp/servers.ts +29 -7
  78. package/extensions/mcp/index.ts +3 -2
  79. package/extensions/memory/entries.ts +9 -6
  80. package/extensions/memory/index.ts +3 -2
  81. package/extensions/memory/open-external.ts +4 -1
  82. package/extensions/model-default/index.ts +21 -2
  83. package/extensions/permissions/index.ts +57 -28
  84. package/extensions/permissions/matcher.ts +120 -33
  85. package/extensions/permissions/modes.ts +14 -6
  86. package/extensions/permissions/powershell-rules.ts +549 -0
  87. package/extensions/permissions/session-grant.ts +9 -9
  88. package/extensions/plugins/index.ts +3 -2
  89. package/extensions/plugins/install/install.ts +1 -1
  90. package/extensions/powershell/description.ts +139 -0
  91. package/extensions/powershell/guards.ts +91 -0
  92. package/extensions/powershell/index.ts +89 -0
  93. package/extensions/powershell/policy.ts +91 -0
  94. package/extensions/recap/index.ts +10 -24
  95. package/extensions/recap/prompt.ts +3 -3
  96. package/extensions/session-title/index.ts +120 -0
  97. package/extensions/session-title/title.ts +111 -0
  98. package/extensions/skill/index.ts +40 -18
  99. package/extensions/skill/invoke.ts +1 -0
  100. package/extensions/subagents/hand-back-review.ts +10 -12
  101. package/extensions/subagents/index.ts +221 -78
  102. package/extensions/subagents/model-select.ts +75 -7
  103. package/extensions/system-prompt/environment.ts +14 -1
  104. package/extensions/system-reminder/index.ts +9 -0
  105. package/extensions/tasks/index.ts +2 -1
  106. package/extensions/tool-search/index.ts +41 -20
  107. package/extensions/workflow/agent-session.ts +6 -4
  108. package/extensions/workflow/index.ts +28 -5
  109. package/extensions/worktree/guards.ts +5 -4
  110. package/extensions/worktree/index.ts +15 -2
  111. package/extensions/worktree/rewrite.ts +17 -0
  112. package/package.json +9 -5
  113. package/types/app.d.ts +18 -2
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { costOf, usageEntryCost } from "../lib/usage-bus.ts";
15
+ import type { WorktreeLocation } from "../lib/worktree-channel.ts";
15
16
 
16
17
  export type Paint = (color: string, text: string) => string;
17
18
 
@@ -38,6 +39,18 @@ export interface FooterData {
38
39
  effort?: string;
39
40
  }
40
41
 
42
+ /**
43
+ * Where the footer says the session is: the active worktree's path and branch
44
+ * while `enter_worktree` is in effect (the process cwd never changes — the
45
+ * worktree extension rewrites tool inputs instead — so pi's own cwd and branch
46
+ * would keep naming the original checkout), else the process cwd and its
47
+ * branch.
48
+ */
49
+ export function footerLocation(cwd: string, branch: string | undefined, worktree: WorktreeLocation | null | undefined): { cwd: string; branch?: string } {
50
+ if (worktree) return { cwd: worktree.path, branch: worktree.branch };
51
+ return { cwd, branch };
52
+ }
53
+
41
54
  /** pi's token formatter, replicated so a "1.1M" here reads like pi's own. */
42
55
  export function formatTokens(count: number): string {
43
56
  if (count < 1000) return count.toString();
@@ -197,6 +210,12 @@ export function computeMainUsage(entries: readonly unknown[]): { cost: number; c
197
210
  cost += costOf(entry.message.usage);
198
211
  } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
199
212
  cost += costOf(entry.usage);
213
+ } else if (entry.type === "usage" && entry.usage) {
214
+ // pi 0.86's model-attributed usage outside the LLM context — today its
215
+ // cache-warming refreshes (`kind: "cache_warm"`), billed as a cache read
216
+ // of the full context plus one output token. pi's /session counts them;
217
+ // so does the footer.
218
+ cost += costOf(entry.usage);
200
219
  } else {
201
220
  // Out-of-band calls (subagents, classifier, readers, recap, setup)
202
221
  // persisted by recordUsage — the all-in half of the total.
@@ -22,7 +22,8 @@ import { linesComponent, safeThemePaint } from "../lib/tui-render.ts";
22
22
  import { formatModel, isRealModel } from "../permissions/modes.ts";
23
23
  import { ULTRACODE_STATUS_KEY } from "../effort/slider.ts";
24
24
  import { USAGE_CHANNEL } from "../lib/usage-bus.ts";
25
- import { buildFooterLines, computeMainUsage, type FooterData } from "./footer-line.ts";
25
+ import { buildFooterLines, computeMainUsage, footerLocation, type FooterData } from "./footer-line.ts";
26
+ import { WORKTREE_CHANNEL, type WorktreeLocation } from "../lib/worktree-channel.ts";
26
27
  import { fetchPrNumber } from "./pr.ts";
27
28
 
28
29
  export default function footerExtension(pi: ExtensionAPI) {
@@ -40,6 +41,16 @@ export default function footerExtension(pi: ExtensionAPI) {
40
41
 
41
42
  /** Invalidate the memoized line and repaint; set once the footer mounts. */
42
43
  let repaint = () => {};
44
+ /** The active worktree session, if any; shown in place of the process cwd + branch. */
45
+ let worktree: WorktreeLocation | null = null;
46
+ /** Re-point the PR lookup at the location currently shown; set once the footer mounts. */
47
+ let refreshLocation = () => {};
48
+
49
+ pi.events.on(WORKTREE_CHANNEL, (data: unknown) => {
50
+ worktree = data as WorktreeLocation | null;
51
+ refreshLocation();
52
+ repaint();
53
+ });
43
54
 
44
55
  const recomputeMain = (ctx: ExtensionContext) => {
45
56
  lastCtx = ctx;
@@ -85,15 +96,19 @@ export default function footerExtension(pi: ExtensionAPI) {
85
96
  onBranchChange(cb: () => void): () => void;
86
97
  };
87
98
 
99
+ /** The path + branch shown, and the PR looked up: the worktree's while one is active. */
100
+ const currentLocation = () => footerLocation(ctx.cwd, fd.getGitBranch() ?? undefined, worktree);
101
+
88
102
  const snapshot = (): FooterData => {
89
103
  const usage = ctx.getContextUsage();
90
104
  const ultracode = fd.getExtensionStatuses().get(ULTRACODE_STATUS_KEY);
91
105
  const effort = ultracode ?? safeThinkingLevel(ctx);
92
106
  const model = ctx.model;
107
+ const location = currentLocation();
93
108
  return {
94
- cwd: ctx.cwd,
109
+ cwd: location.cwd,
95
110
  home: process.env.HOME || process.env.USERPROFILE || "",
96
- branch: fd.getGitBranch() ?? undefined,
111
+ branch: location.branch,
97
112
  contextTokens: usage?.tokens ?? undefined,
98
113
  contextWindow: usage?.contextWindow,
99
114
  contextPercent: usage?.percent,
@@ -116,9 +131,13 @@ export default function footerExtension(pi: ExtensionAPI) {
116
131
  // only disposes the component, so the subscription must be released
117
132
  // here — otherwise each replaced footer keeps firing its `gh pr list`
118
133
  // against a stale cwd on every branch change.
119
- refreshPr(ctx.cwd, fd.getGitBranch());
134
+ refreshLocation = () => {
135
+ const location = currentLocation();
136
+ refreshPr(location.cwd, location.branch ?? null);
137
+ };
138
+ refreshLocation();
120
139
  const stopFollowingBranch = fd.onBranchChange(() => {
121
- refreshPr(ctx.cwd, fd.getGitBranch());
140
+ refreshLocation();
122
141
  repaint();
123
142
  });
124
143
 
@@ -126,6 +145,7 @@ export default function footerExtension(pi: ExtensionAPI) {
126
145
  dispose: () => {
127
146
  stopFollowingBranch();
128
147
  repaint = () => {};
148
+ refreshLocation = () => {};
129
149
  },
130
150
  });
131
151
  });
@@ -9,11 +9,18 @@
9
9
  * a JSON payload from stdin. (Ported from pi-code's runHookCommand, MIT — see
10
10
  * docs/decisions.md.)
11
11
  *
12
+ * The interpreter is Claude Code's: bash (Git Bash on Windows, honouring
13
+ * `CLAUDE_CODE_GIT_BASH_PATH`) unless the hook says `shell: "powershell"`, or
14
+ * the machine is a Windows box without Git Bash — lib/shell-spawn.ts resolves
15
+ * both through pi's own shell lookup.
16
+ *
12
17
  * Hardening carried over:
13
- * - absolute /bin/sh, so a repo-local `sh` on PATH can't hijack the hook
18
+ * - an absolute interpreter path, so a repo-local `sh` on PATH can't hijack
19
+ * the hook
14
20
  * - detached:true makes the shell a process-group leader; timeout SIGKILLs
15
21
  * the negative pid so grandchildren holding the stdio pipes die too —
16
22
  * otherwise `close` never fires and the promise hangs past the timeout
23
+ * (Windows: `taskkill /T /F` on the leader's pid does the same)
17
24
  * - setEncoding("utf8") so multi-byte characters can't be split across chunks
18
25
  * - output capped at 1MB per stream
19
26
  * - stdin errors ignored (a hook that exits without reading stdin — `exit 2`
@@ -38,8 +45,36 @@
38
45
  * mechanism for the LSP client). A ref'd child holds the loop until `close`.
39
46
  */
40
47
 
41
- import { spawn } from "node:child_process";
42
- import { killProcessTree } from "../lib/process-tree.ts";
48
+ import type { ChildProcess } from "node:child_process";
49
+ import { detachedSpawnOptions, killProcessTree, waitForChildExit } from "../lib/process-tree.ts";
50
+ import { bashSpawn, POWERSHELL_UTF8_PREFIX, powerShellSpawn, type ShellSpawn, spawnShellCommand } from "../lib/shell-spawn.ts";
51
+ import type { HookShell } from "./settings.ts";
52
+
53
+ /**
54
+ * Claude Code's default: hooks run in bash (Git Bash on Windows) when one
55
+ * exists, otherwise in PowerShell — which only happens on Windows without Git
56
+ * for Windows, since every other platform resolves a bash.
57
+ */
58
+ export function defaultHookShell(): HookShell {
59
+ return bashSpawn().spawn ? "bash" : "powershell";
60
+ }
61
+
62
+ /** The interpreter for one hook, or the reason none can run it. */
63
+ export function hookShellSpawn(shell: HookShell | undefined): { spec?: ShellSpawn; shell?: HookShell; error?: string } {
64
+ const want = shell ?? defaultHookShell();
65
+ if (want === "powershell") {
66
+ const spec = powerShellSpawn();
67
+ return spec ? { spec, shell: want } : { error: "hook needs PowerShell but no pwsh/powershell executable was found on PATH" };
68
+ }
69
+ const resolved = bashSpawn();
70
+ if (!resolved.spawn) return { error: resolved.error ?? "hook needs bash but none was found" };
71
+ if (resolved.spawn.commandTransport === "stdin") {
72
+ // pi's legacy-WSL launcher takes its script on stdin, which the hook
73
+ // payload already occupies.
74
+ return { error: `hook cannot run under ${resolved.spawn.shell} (a WSL launcher); set CLAUDE_CODE_GIT_BASH_PATH to Git Bash's bash.exe` };
75
+ }
76
+ return { spec: resolved.spawn, shell: want };
77
+ }
43
78
 
44
79
  export interface HookRunResult {
45
80
  /**
@@ -80,6 +115,8 @@ export interface HookRunOptions {
80
115
  * stdout/stderr are therefore empty for a detached hook — nothing reads them.
81
116
  */
82
117
  detached?: boolean;
118
+ /** The hook's `shell` field; unset → `defaultHookShell()`. */
119
+ shell?: HookShell;
83
120
  }
84
121
 
85
122
  const MAX_OUTPUT_BYTES = 1_000_000;
@@ -92,11 +129,28 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
92
129
  const started = Date.now();
93
130
 
94
131
  return new Promise((resolve) => {
95
- let child: ReturnType<typeof spawn>;
132
+ let child: ChildProcess;
133
+ const failed = (spawnError: string) =>
134
+ resolve({ exitCode: null, timedOut: false, spawnError, stdout: "", stderr: "", durationMs: Date.now() - started });
135
+ const { spec, shell, error } = hookShellSpawn(opts.shell);
136
+ if (!spec) {
137
+ failed(error ?? "no shell available for the hook");
138
+ return;
139
+ }
140
+ // Windows PowerShell 5.1 (`powershell.exe`) defaults its console to
141
+ // UTF-16, which would corrupt this stream once decoded as UTF-8 below
142
+ // (setEncoding("utf8")) — the same fix every other PowerShell execution
143
+ // path applies (lib/shell-spawn.ts, extensions/powershell/index.ts).
144
+ const runCommand = shell === "powershell" ? `${POWERSHELL_UTF8_PREFIX}${command}` : command;
96
145
  try {
97
- child = spawn("/bin/sh", ["-c", command], {
146
+ // An absolute interpreter path (pi's resolver never yields a bare name
147
+ // where a real bash exists), so a repo-local `sh` on PATH cannot hijack
148
+ // the hook; the shell leads its own process group so a timeout kills
149
+ // grandchildren too (Windows: taskkill /T — lib/process-tree.ts).
150
+ child = spawnShellCommand(spec, runCommand, {
98
151
  cwd: opts.cwd,
99
- detached: true,
152
+ // A fire-and-forget hook must outlive a parent that is exiting.
153
+ ...detachedSpawnOptions({ outlivesParent: opts.detached }),
100
154
  stdio: opts.detached ? ["pipe", "ignore", "ignore"] : ["pipe", "pipe", "pipe"],
101
155
  env: { ...process.env, CLAUDE_PROJECT_DIR: opts.projectDir ?? opts.cwd },
102
156
  });
@@ -149,21 +203,33 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
149
203
  resolve({ ...result, durationMs: Date.now() - started });
150
204
  };
151
205
 
152
- child.on("error", (error) => {
153
- finish({ exitCode: null, timedOut, spawnError: error.message, stdout, stderr });
154
- });
155
- child.on("close", (code) => {
156
- // A killed process reports null here, EXCEPT when the shell outlived the
157
- // group kill just long enough to reap its child and exit 128+9 itself
158
- // (see exitCode's contract). Normalizing keeps "we killed it" from ever
159
- // looking like an ordinary non-zero exit, which fails OPEN downstream.
160
- finish({ exitCode: timedOut ? null : code, timedOut, stdout, stderr });
161
- });
206
+ // Settles on exit plus a short stdio grace, not on `close`: a grandchild
207
+ // the kill missed could otherwise hold the pipes — and this promise — open
208
+ // for its whole life (lib/process-tree.ts). A killed process reports null,
209
+ // EXCEPT when the shell outlived the group kill just long enough to reap
210
+ // its child and exit 128+9 itself (see exitCode's contract). Normalizing
211
+ // keeps "we killed it" from ever looking like an ordinary non-zero exit,
212
+ // which fails OPEN downstream.
213
+ waitForChildExit(child).then(
214
+ ({ code }) => finish({ exitCode: timedOut ? null : code, timedOut, stdout, stderr }),
215
+ (error: Error) => finish({ exitCode: null, timedOut, spawnError: error.message, stdout, stderr }),
216
+ );
162
217
 
163
218
  // A hook that never reads stdin (e.g. plain `exit 2`) closes the pipe
164
219
  // early; the resulting EPIPE must not take the extension down.
165
220
  child.stdin?.on("error", () => {});
166
- child.stdin?.write(stdinJson.endsWith("\n") ? stdinJson : `${stdinJson}\n`);
167
- child.stdin?.end();
221
+ const payload = stdinJson.endsWith("\n") ? stdinJson : `${stdinJson}\n`;
222
+ if (opts.detached) {
223
+ // Close the pipe the moment the payload is written, not with a graceful
224
+ // shutdown: on Windows libuv's pipe shutdown stays pending until the
225
+ // reader has drained the data, and a hook that never reads stdin would
226
+ // keep the process alive for its whole run (the probe test measured a
227
+ // `sleep 3` SessionEnd hook holding a one-shot open for 3 s). The data
228
+ // already written stays readable by the hook after the handle closes.
229
+ child.stdin?.write(payload, () => child.stdin?.destroy());
230
+ } else {
231
+ child.stdin?.write(payload);
232
+ child.stdin?.end();
233
+ }
168
234
  });
169
235
  }
@@ -196,6 +196,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
196
196
  const run = await runHookCommand(hook.command, stdin, {
197
197
  cwd: ctx.cwd,
198
198
  timeoutSeconds: hook.timeout,
199
+ shell: hook.shell,
199
200
  projectDir: ctx.cwd,
200
201
  // SessionEnd is fire-and-forget at shutdown: it must not hold a
201
202
  // one-shot process open. Every other hook is awaited work that
@@ -15,6 +15,7 @@ import { ccAliasesForTool } from "../permissions/matcher.ts";
15
15
  */
16
16
  const CC_CANONICAL: Record<string, string> = {
17
17
  bash: "Bash",
18
+ powershell: "PowerShell",
18
19
  read: "Read",
19
20
  edit: "Edit",
20
21
  write: "Write",
@@ -88,19 +89,32 @@ export function toolMatchCandidates(nativeName: string): string[] {
88
89
  return [...new Set([nativeName, ccToolName(nativeName), ...ccAliasesForTool(nativeName)])];
89
90
  }
90
91
 
91
- /**
92
- * Whether a CC matcher applies to any candidate spelling. Anchored and
93
- * case-insensitive (`^(?:matcher)$`, the CC semantics); an invalid regex
94
- * falls back to case-insensitive exact comparison rather than throwing.
95
- */
96
- export function matcherApplies(matcher: string | undefined, candidates: string[]): boolean {
97
- if (matcher === undefined || matcher === "" || matcher === "*") return true;
92
+ /** One matcher spelling: a regex when it compiles, else a case-insensitive exact match. */
93
+ function matchesAlternative(alternative: string, candidates: string[]): boolean {
98
94
  let regex: RegExp | undefined;
99
95
  try {
100
- regex = new RegExp(`^(?:${matcher})$`, "i");
96
+ regex = new RegExp(`^(?:${alternative})$`, "i");
101
97
  } catch {
102
98
  regex = undefined;
103
99
  }
104
- const lowered = matcher.toLowerCase();
100
+ const lowered = alternative.toLowerCase();
105
101
  return candidates.some((candidate) => (regex ? regex.test(candidate) : candidate.toLowerCase() === lowered));
106
102
  }
103
+
104
+ /**
105
+ * Whether a CC matcher applies to any candidate spelling. Anchored and
106
+ * case-insensitive (`^(?:matcher)$`, the CC semantics); an invalid regex
107
+ * falls back to case-insensitive exact comparison rather than throwing. The
108
+ * whole matcher is tried first — so a regex that legitimately contains a
109
+ * comma (a `{m,n}` quantifier, a character class) still compiles and matches
110
+ * as written. Only once that fails is a comma-separated list
111
+ * (`Bash,PowerShell`) tried part by part — the spelling Claude Code's Windows
112
+ * docs use beside `Bash|PowerShell`.
113
+ */
114
+ export function matcherApplies(matcher: string | undefined, candidates: string[]): boolean {
115
+ if (matcher === undefined || matcher === "" || matcher === "*") return true;
116
+ if (matchesAlternative(matcher, candidates)) return true;
117
+ if (!matcher.includes(",")) return false;
118
+ const alternatives = matcher.split(",").map((part) => part.trim()).filter(Boolean);
119
+ return alternatives.some((alternative) => matchesAlternative(alternative, candidates));
120
+ }
@@ -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
+ }