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.
- package/README.md +214 -147
- 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 +189 -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 +9 -5
- 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:
|
|
109
|
+
cwd: location.cwd,
|
|
95
110
|
home: process.env.HOME || process.env.USERPROFILE || "",
|
|
96
|
-
branch:
|
|
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
|
-
|
|
134
|
+
refreshLocation = () => {
|
|
135
|
+
const location = currentLocation();
|
|
136
|
+
refreshPr(location.cwd, location.branch ?? null);
|
|
137
|
+
};
|
|
138
|
+
refreshLocation();
|
|
120
139
|
const stopFollowingBranch = fd.onBranchChange(() => {
|
|
121
|
-
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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(`^(?:${
|
|
96
|
+
regex = new RegExp(`^(?:${alternative})$`, "i");
|
|
101
97
|
} catch {
|
|
102
98
|
regex = undefined;
|
|
103
99
|
}
|
|
104
|
-
const lowered =
|
|
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
|
-
|
|
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
|
+
}
|