pi-code 1.0.48 → 1.0.50
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/extensions/claude-rules.ts +2 -1
- package/extensions/commands.ts +2 -2
- package/extensions/context-imports.ts +13 -12
- package/extensions/git-checkpoint.ts +26 -0
- package/extensions/hooks/config.ts +4 -0
- package/extensions/hooks/index.ts +4 -2
- package/extensions/hooks/runners.ts +35 -9
- package/extensions/internal/command-file.ts +68 -61
- package/extensions/internal/output-guard.ts +6 -5
- package/extensions/internal/shell-resolve.ts +121 -0
- package/extensions/internal/strip-comments.ts +3 -3
- package/extensions/mcp/transport.ts +10 -3
- package/extensions/plan-mode/index.ts +3 -1
- package/extensions/question.ts +1 -1
- package/extensions/subagent/background.ts +33 -10
- package/extensions/todo.ts +6 -2
- package/package.json +1 -1
|
@@ -68,7 +68,8 @@ function parsePaths(frontmatter: string): string[] {
|
|
|
68
68
|
|
|
69
69
|
/** Split YAML-ish frontmatter off the front of a rule file, extracting `paths`. */
|
|
70
70
|
export function parseFrontmatter(content: string): Frontmatter {
|
|
71
|
-
|
|
71
|
+
// A leading byte order mark (Windows editors add one) is part of the header, not the body.
|
|
72
|
+
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content)
|
|
72
73
|
if (!match) return { paths: [], body: content }
|
|
73
74
|
return { paths: parsePaths(match[1]), body: content.slice(match[0].length) }
|
|
74
75
|
}
|
package/extensions/commands.ts
CHANGED
|
@@ -197,7 +197,7 @@ function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlu
|
|
|
197
197
|
|
|
198
198
|
/** The exec seam expandCommand runs spans through; pi itself satisfies it. */
|
|
199
199
|
interface SpanRunner {
|
|
200
|
-
exec(command: string, args: string[], options?: { cwd?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number }>
|
|
200
|
+
exec(command: string, args: string[], options?: { cwd?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
/**
|
|
@@ -231,7 +231,7 @@ export async function expandCommand(runner: SpanRunner, parsed: ParsedCommand, a
|
|
|
231
231
|
// pwsh cannot merge a native command's stderr in-script (spanExec sets
|
|
232
232
|
// mergeStreams), so it is appended here; the sh script merges via 2>&1.
|
|
233
233
|
const stdout = run.mergeStreams ? result.stdout + result.stderr : result.stdout
|
|
234
|
-
return { stdout, stderr: result.stderr, code: result.code }
|
|
234
|
+
return { stdout, stderr: result.stderr, code: result.code, killed: result.killed }
|
|
235
235
|
}
|
|
236
236
|
: async () => ({ stdout: SHELL_DISABLED_PLACEHOLDER, stderr: '', code: 0 })
|
|
237
237
|
let expanded = await expandDynamicContent(withVars, ctx.cwd, exec, parsed.shell === 'powershell' ? 'powershell' : 'bash')
|
|
@@ -72,12 +72,13 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
|
72
72
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
73
73
|
import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
|
|
74
74
|
import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
|
|
75
|
+
import { sliceBytes } from './internal/output-guard.js'
|
|
75
76
|
import { globToRegExpSource } from './internal/path-rules.js'
|
|
76
77
|
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
77
78
|
import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
78
79
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
79
80
|
import { statToken } from './internal/stat-token.js'
|
|
80
|
-
import { fenceMarker, stripBlockComments } from './internal/strip-comments.js'
|
|
81
|
+
import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
|
|
81
82
|
|
|
82
83
|
/** Claude documents "a maximum depth of four hops" for recursive imports. */
|
|
83
84
|
const MAX_IMPORT_DEPTH = 4
|
|
@@ -145,16 +146,14 @@ export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES
|
|
|
145
146
|
* imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
|
|
146
147
|
function importTargets(content: string): string[] {
|
|
147
148
|
const targets: string[] = []
|
|
148
|
-
//
|
|
149
|
-
// example may
|
|
150
|
-
let fence:
|
|
149
|
+
// CommonMark fences: closed only by the same character in a run at least as long
|
|
150
|
+
// as the opener, so a backtick example may hold tilde lines or shorter fences.
|
|
151
|
+
let fence: Fence | null = null
|
|
151
152
|
for (const line of content.split('\n')) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
if (fence !== null) continue
|
|
153
|
+
const trimmed = line.trimStart()
|
|
154
|
+
const step = stepFence(fence, trimmed, fenceMarker(trimmed))
|
|
155
|
+
fence = step.fence
|
|
156
|
+
if (step.fenced) continue
|
|
158
157
|
// Backreference so a multi-backtick span (``literal `@x` backticks``) strips whole.
|
|
159
158
|
const withoutSpans = line.replace(/(`+)[^`]*?\1/g, '')
|
|
160
159
|
for (const match of withoutSpans.matchAll(/(^|\s)@(\S+)/g)) targets.push(match[2])
|
|
@@ -222,8 +221,10 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
|
|
|
222
221
|
const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
|
|
223
222
|
if (!file) continue
|
|
224
223
|
scan.budget.files -= 1
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
// The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
|
|
225
|
+
// at three times the budget without ever reaching the truncation marker.
|
|
226
|
+
const kept = sliceBytes(file.body, scan.budget.bytes)
|
|
227
|
+
scan.budget.bytes -= Buffer.byteLength(kept)
|
|
227
228
|
const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
|
|
228
229
|
// Comments are stripped before the scan for further imports, so a
|
|
229
230
|
// commented-out @import stays dead at every depth, matching the top level
|
|
@@ -194,6 +194,32 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
194
194
|
}
|
|
195
195
|
// Written on every start, so repos that predate the sidecar pick it up too.
|
|
196
196
|
rememberWorkTree(shadowDir, ctx.cwd)
|
|
197
|
+
await mirrorLocalExcludes(ctx.cwd)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** git reads ignore rules from the tree's .gitignore files, the user's global excludes,
|
|
201
|
+
* and $GIT_DIR/info/exclude. The shadow is the GIT_DIR here, so the repo's own
|
|
202
|
+
* .git/info/exclude (where secrets and scratch that must never be committed live)
|
|
203
|
+
* would be snapshotted and restored. Mirror it into the shadow on every start; the
|
|
204
|
+
* global excludes stay untouched (core.excludesFile is single-valued, so pointing it
|
|
205
|
+
* at the repo file would replace them). */
|
|
206
|
+
async function mirrorLocalExcludes(cwd: string): Promise<void> {
|
|
207
|
+
if (!shadowDir) return
|
|
208
|
+
// Resolved through git so a linked worktree maps to its common dir; outside a repo
|
|
209
|
+
// git exits 128 and there is nothing to mirror.
|
|
210
|
+
const located = await pi.exec('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd })
|
|
211
|
+
const target = path.join(shadowDir, 'info', 'exclude')
|
|
212
|
+
try {
|
|
213
|
+
const source = located.code === 0 ? path.resolve(cwd, located.stdout.trim()) : undefined
|
|
214
|
+
if (source && fs.existsSync(source)) {
|
|
215
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
216
|
+
fs.copyFileSync(source, target)
|
|
217
|
+
} else {
|
|
218
|
+
fs.rmSync(target, { force: true })
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// A failed mirror only means local excludes are not honored this session.
|
|
222
|
+
}
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
/** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
|
|
@@ -16,6 +16,10 @@ export interface HookCommand {
|
|
|
16
16
|
/** exec-form: spawn `command` directly with these args and no shell (shell-form when
|
|
17
17
|
* absent). $ARGUMENTS in each arg is replaced with the event JSON. */
|
|
18
18
|
args?: string[]
|
|
19
|
+
/** Claude's `shell`: "bash" (the default) or "powershell". The default is /bin/sh off
|
|
20
|
+
* Windows; on Windows Git Bash, then PowerShell when Git Bash is absent. Ignored when
|
|
21
|
+
* `args` is set. */
|
|
22
|
+
shell?: string
|
|
19
23
|
timeout?: number
|
|
20
24
|
/** Claude's background contract, honored on `type: "command"` hooks only: `async` runs
|
|
21
25
|
* without blocking its event and with no timeout enforced; `asyncRewake` also runs in
|
|
@@ -72,7 +72,9 @@
|
|
|
72
72
|
* agent-frontmatter hooks arrive via PI_CODE_AGENT_HOOKS (Stop pre-converted to
|
|
73
73
|
* SubagentStop, fired at the child's own agent end) and die with the process.
|
|
74
74
|
*
|
|
75
|
-
* Hook commands run
|
|
75
|
+
* Hook commands run through the platform shell (`sh -c`; on Windows Git Bash, or
|
|
76
|
+
* PowerShell when Git Bash is absent, see internal/shell-resolve) with the event JSON
|
|
77
|
+
* on stdin. A PreToolUse
|
|
76
78
|
* hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
|
|
77
79
|
* `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
|
|
78
80
|
* `{"decision": "block"}`).
|
|
@@ -271,7 +273,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
271
273
|
if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
|
|
272
274
|
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
273
275
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
274
|
-
return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
|
|
276
|
+
return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild, hook.shell)
|
|
275
277
|
}
|
|
276
278
|
// Claude's `once` (skill-frontmatter hooks only): removed after the first
|
|
277
279
|
// successful run; a failure, block, or timeout leaves it in place.
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { type ChildProcess, spawn } from 'node:child_process'
|
|
8
|
+
import * as path from 'node:path'
|
|
8
9
|
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
9
10
|
import { runAgent } from '../internal/agent-run.js'
|
|
10
11
|
import { callMcpTool } from '../internal/mcp-call.js'
|
|
11
12
|
import { completeText } from '../internal/model-complete.js'
|
|
13
|
+
import { resolveShell } from '../internal/shell-resolve.js'
|
|
12
14
|
import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
|
|
13
15
|
|
|
14
16
|
// Claude's defaults vary by type and event (600s for command/http/mcp_tool, 30s
|
|
@@ -41,7 +43,7 @@ export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number
|
|
|
41
43
|
* `args` array it becomes the exec path: `command` is spawned directly with those args.
|
|
42
44
|
* `onChild` hands the caller a kill for the spawned tree, so a background hook that is
|
|
43
45
|
* still running at session end can be reaped (Claude kills async hooks at teardown). */
|
|
44
|
-
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[], onChild?: (kill: () => void) => void) => Promise<HookRunResult>
|
|
46
|
+
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[], onChild?: (kill: () => void) => void, shell?: string) => Promise<HookRunResult>
|
|
45
47
|
|
|
46
48
|
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
47
49
|
const MAX_TIMEOUT_S = 2_147_483
|
|
@@ -80,6 +82,15 @@ const TIMEOUT_EXIT_CODE = 124
|
|
|
80
82
|
* direct child alone leaves a grandchild alive holding stdout/stderr.
|
|
81
83
|
*/
|
|
82
84
|
function killTree(child: ChildProcess): void {
|
|
85
|
+
if (process.platform === 'win32') {
|
|
86
|
+
// Windows has no process groups: taskkill /T ends the shell's whole tree. By
|
|
87
|
+
// absolute path, so a writable PATH entry cannot stand in for it. If taskkill itself
|
|
88
|
+
// cannot start, the direct kill is all that is left.
|
|
89
|
+
const taskkill = path.join(process.env.SystemRoot ?? String.raw`C:\Windows`, 'System32', 'taskkill.exe')
|
|
90
|
+
if (child.pid) spawn(taskkill, ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill('SIGKILL'))
|
|
91
|
+
else child.kill('SIGKILL')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
83
94
|
try {
|
|
84
95
|
// Negative pid targets the whole process group, which `detached` gave the shell.
|
|
85
96
|
if (child.pid) {
|
|
@@ -92,11 +103,19 @@ function killTree(child: ChildProcess): void {
|
|
|
92
103
|
child.kill('SIGKILL')
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
|
|
106
|
+
/** The shell invocation for a shell-form command, or undefined when this machine has no
|
|
107
|
+
* shell for it (Windows with neither Git Bash nor PowerShell). */
|
|
108
|
+
function shellInvocation(command: string, shell: string | undefined): { file: string; spawnArgs: string[] } | undefined {
|
|
109
|
+
const resolved = resolveShell(shell)
|
|
110
|
+
return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild, shell) =>
|
|
96
114
|
new Promise((resolve) => {
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
115
|
+
// /bin/sh by absolute path off Windows, so the shell can't be resolved through an
|
|
116
|
+
// attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
|
|
117
|
+
// Bash lookup. `detached` makes the shell its own process group leader so the
|
|
118
|
+
// timeout can kill the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
|
|
100
119
|
// reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
|
|
101
120
|
// subprocess Claude spawns, so it is set on the child unconditionally.
|
|
102
121
|
// CLAUDE_CODE_CHILD_SESSION marks per-call children (hook and status line
|
|
@@ -110,11 +129,18 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, p
|
|
|
110
129
|
// An exec-form hook (an `args` array) spawns the executable directly with those args
|
|
111
130
|
// and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
|
|
112
131
|
// each arg is replaced with the event JSON by a replacer function (so $$/$& in the
|
|
113
|
-
// payload survive verbatim). Without args
|
|
132
|
+
// payload survive verbatim). Without args the command string goes to the platform's
|
|
133
|
+
// shell, or to PowerShell when the hook says `shell: "powershell"`. Both share the
|
|
114
134
|
// same detached process group, so killTree reaches the descendants either way.
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
135
|
+
const target = Array.isArray(args) ? { file: command, spawnArgs: args.map((arg) => substituteArguments(arg, payload)) } : shellInvocation(command, shell)
|
|
136
|
+
if (!target) {
|
|
137
|
+
// Marked like a spawn failure so a gated event fails closed rather than reading as an allow.
|
|
138
|
+
resolve({ code: 0, stdout: '', stderr: 'no shell found: install Git for Windows or PowerShell', timedOut: false, spawnFailed: true })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
// On Windows `detached` means DETACHED_PROCESS, which gives a console child its own
|
|
142
|
+
// window; windowsHide keeps every hook invisible (a no-op elsewhere).
|
|
143
|
+
const child = spawn(target.file, target.spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true, env })
|
|
118
144
|
onChild?.(() => killTree(child))
|
|
119
145
|
let stdout = ''
|
|
120
146
|
let stderr = ''
|
|
@@ -13,8 +13,8 @@ import * as fs from 'node:fs'
|
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
|
|
15
15
|
import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
16
|
-
|
|
17
16
|
import { splitSegments } from './shell-split.js'
|
|
17
|
+
import { type Fence, fenceMarker, stepFence } from './strip-comments.js'
|
|
18
18
|
|
|
19
19
|
/** The pi file tools a Claude path rule can govern. */
|
|
20
20
|
export type PathRuleTool = 'read' | 'edit' | 'write'
|
|
@@ -421,7 +421,7 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
|
|
|
421
421
|
return found
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
|
|
424
|
+
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
|
|
425
425
|
|
|
426
426
|
/** PowerShell single-quote escaping: inside a '...' literal the only special
|
|
427
427
|
* characters are the quote delimiters themselves, written doubled. PowerShell's
|
|
@@ -433,26 +433,9 @@ export function powershellQuote(value: string): string {
|
|
|
433
433
|
return value.replaceAll(/['‘’‚‛]/g, '$&$&')
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
/** First PowerShell binary found on PATH, or undefined when none is installed. */
|
|
441
|
-
export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
|
|
442
|
-
const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
|
|
443
|
-
for (const candidate of powershellCandidates(platform)) {
|
|
444
|
-
for (const dir of dirs) {
|
|
445
|
-
const full = path.join(dir, candidate)
|
|
446
|
-
try {
|
|
447
|
-
fs.accessSync(full, fs.constants.X_OK)
|
|
448
|
-
if (fs.statSync(full).isFile()) return full
|
|
449
|
-
} catch {
|
|
450
|
-
// not here; keep looking
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
return undefined
|
|
455
|
-
}
|
|
436
|
+
import { bashBinary } from './shell-resolve.js'
|
|
437
|
+
|
|
438
|
+
export { resolvePowershellBinary } from './shell-resolve.js'
|
|
456
439
|
|
|
457
440
|
export interface SpanExec {
|
|
458
441
|
command: string
|
|
@@ -464,42 +447,55 @@ export interface SpanExec {
|
|
|
464
447
|
mergeStreams?: boolean
|
|
465
448
|
}
|
|
466
449
|
|
|
450
|
+
/** The sh invocation for a span: CLAUDE_PROJECT_DIR and CLAUDECODE=1 exported in-script
|
|
451
|
+
* (pi.exec takes no env; CLAUDECODE marks every subprocess Claude spawns), stderr merged
|
|
452
|
+
* with 2>&1. The group opens with a `:` null command: `{ }` around an empty or
|
|
453
|
+
* comment-only span is a hard sh syntax error (exit 2) that aborted the whole
|
|
454
|
+
* invocation, and `:` keeps such a span the harmless no-op it was on HEAD while the
|
|
455
|
+
* group still merges stderr for real spans. */
|
|
456
|
+
function shSpan(binary: string, projectDir: string, script: string): SpanExec {
|
|
457
|
+
const quoted = projectDir.replaceAll("'", String.raw`'\''`)
|
|
458
|
+
return { command: binary, args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** The PowerShell invocation for a span. No in-script 2>&1: under pwsh 7 it does not
|
|
462
|
+
* merge a native command's stderr on a script block, so mergeStreams has the caller
|
|
463
|
+
* append it. The trailing exit forwards a failed native command's code, which pwsh
|
|
464
|
+
* -Command otherwise swallows (the process exited 0 and a failure never aborted the
|
|
465
|
+
* invocation). An empty or cmdlet-only span leaves $LASTEXITCODE unset and exits 0.
|
|
466
|
+
* Residual gap vs sh: a failing cmdlet sets no exit code, so it cannot abort; its
|
|
467
|
+
* error text still reaches the model through the merged stderr. */
|
|
468
|
+
function powershellSpan(binary: string, projectDir: string, script: string): SpanExec {
|
|
469
|
+
const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
|
|
470
|
+
return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
|
|
471
|
+
}
|
|
472
|
+
|
|
467
473
|
/**
|
|
468
|
-
* The exec invocation for one injected span, honoring the `shell:` frontmatter
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
474
|
+
* The exec invocation for one injected span, honoring the `shell:` frontmatter per
|
|
475
|
+
* Claude's shell matrix (skills.md). `powershell` runs through a PowerShell binary when
|
|
476
|
+
* one resolves. Otherwise the span runs through bash: /bin/sh off Windows, Git Bash on
|
|
477
|
+
* Windows. Without Git Bash, a skill that declared `shell: bash` fails before any
|
|
478
|
+
* command runs ("requires bash"), an undeclared one falls to PowerShell, and with
|
|
479
|
+
* neither shell the invocation fails. Both paths export CLAUDE_PROJECT_DIR (each
|
|
480
|
+
* shell's own quoting) and merge stderr into stdout, as the Bash tool does when it
|
|
481
|
+
* runs these for Claude: the sh script in-line with 2>&1, the pwsh path via
|
|
482
|
+
* mergeStreams in the caller.
|
|
475
483
|
*
|
|
476
|
-
* The
|
|
477
|
-
* imported binding,
|
|
484
|
+
* The resolvers are parameters so a caller (or test) controls the lookups: the
|
|
485
|
+
* PowerShell one is passed as an imported binding, the bash one defaults to the
|
|
486
|
+
* platform rule.
|
|
478
487
|
*/
|
|
479
|
-
export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined): SpanExec {
|
|
488
|
+
export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined, resolveBash: () => string | undefined = bashBinary): SpanExec {
|
|
480
489
|
if (shell === 'powershell') {
|
|
481
490
|
const binary = resolveBinary()
|
|
482
|
-
if (binary !== undefined)
|
|
483
|
-
// CLAUDECODE=1 marks every subprocess Claude spawns; pi.exec takes no env, so
|
|
484
|
-
// it is exported in the script alongside CLAUDE_PROJECT_DIR.
|
|
485
|
-
const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
|
|
486
|
-
// No in-script 2>&1: under pwsh 7 it does not merge a native command's
|
|
487
|
-
// stderr on a script block, so mergeStreams has the caller append it. The
|
|
488
|
-
// trailing exit forwards a failed native command's code, which pwsh
|
|
489
|
-
// -Command otherwise swallows (the process exited 0 and a failure never
|
|
490
|
-
// aborted the invocation). An empty or cmdlet-only span leaves
|
|
491
|
-
// $LASTEXITCODE unset and exits 0. Residual gap vs sh: a failing cmdlet
|
|
492
|
-
// sets no exit code, so it cannot abort; its error text still reaches the
|
|
493
|
-
// model through the merged stderr.
|
|
494
|
-
return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
|
|
495
|
-
}
|
|
491
|
+
if (binary !== undefined) return powershellSpan(binary, projectDir, script)
|
|
496
492
|
}
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
493
|
+
const bash = resolveBash()
|
|
494
|
+
if (bash !== undefined) return shSpan(bash, projectDir, script)
|
|
495
|
+
if (shell === 'bash') throw new Error('shell: bash requires Git Bash, which was not found (install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH)')
|
|
496
|
+
const binary = resolveBinary()
|
|
497
|
+
if (binary !== undefined) return powershellSpan(binary, projectDir, script)
|
|
498
|
+
throw new Error('no shell found for the injected commands: install Git for Windows or PowerShell')
|
|
503
499
|
}
|
|
504
500
|
|
|
505
501
|
interface FenceBlock {
|
|
@@ -511,20 +507,27 @@ interface FenceBlock {
|
|
|
511
507
|
}
|
|
512
508
|
|
|
513
509
|
/** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
|
|
514
|
-
* fence, while a ```! fence is itself a placeholder that executes.
|
|
510
|
+
* fence, while a ```! fence is itself a placeholder that executes. Fences follow
|
|
511
|
+
* CommonMark: any indentation, closed only by the opener's character in a run at
|
|
512
|
+
* least as long, so a tilde line or a shorter fence inside stays content. */
|
|
515
513
|
function fenceBlocks(body: string): FenceBlock[] {
|
|
516
514
|
const blocks: FenceBlock[] = []
|
|
517
|
-
|
|
515
|
+
let fence: Fence | null = null
|
|
518
516
|
let open: { index: number; exec: boolean; contentStart: number } | undefined
|
|
519
|
-
let
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
517
|
+
let offset = 0
|
|
518
|
+
for (const line of body.split('\n')) {
|
|
519
|
+
const trimmed = line.trimStart()
|
|
520
|
+
const step = stepFence(fence, trimmed, fenceMarker(trimmed))
|
|
521
|
+
const lineEnd = offset + line.length
|
|
522
|
+
if (fence === null && step.fence !== null) {
|
|
523
|
+
// Only the exact, unindented ```! opener executes, as Claude documents it.
|
|
524
|
+
open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
|
|
525
|
+
} else if (fence !== null && step.fence === null && open !== undefined) {
|
|
526
|
+
blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
|
|
525
527
|
open = undefined
|
|
526
528
|
}
|
|
527
|
-
|
|
529
|
+
fence = step.fence
|
|
530
|
+
offset = lineEnd + 1
|
|
528
531
|
}
|
|
529
532
|
// An unterminated fence protects to the end of the body rather than executing.
|
|
530
533
|
if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
|
|
@@ -563,6 +566,10 @@ export function benignExitOne(command: string, shell: SpanShell = 'bash'): boole
|
|
|
563
566
|
* documents: the model never sees a half-expanded body. */
|
|
564
567
|
async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
|
|
565
568
|
const result = await exec(command)
|
|
569
|
+
// A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
|
|
570
|
+
// so the code alone would paste the partial output as a success. Claude kills a span
|
|
571
|
+
// at the Bash timeout and that failure aborts the invocation.
|
|
572
|
+
if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
|
|
566
573
|
if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
|
|
567
574
|
throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
|
|
568
575
|
}
|
|
@@ -15,12 +15,13 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '
|
|
|
15
15
|
/**
|
|
16
16
|
* Trim `text` to a byte budget. `String.slice` counts UTF-16 units, so slicing a CJK
|
|
17
17
|
* string by a byte budget keeps up to three times the bytes asked for; cutting the
|
|
18
|
-
* encoded buffer is exact. A character straddling the cut
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* encoded buffer is exact. A character straddling the cut is dropped (the streaming
|
|
19
|
+
* decoder holds back an incomplete sequence instead of emitting U+FFFD), so the
|
|
20
|
+
* result is whole characters within the budget. Shorter input comes back whole and a
|
|
21
|
+
* negative budget yields nothing, so callers need no length check of their own.
|
|
21
22
|
*/
|
|
22
|
-
function sliceBytes(text: string, maxBytes: number): string {
|
|
23
|
-
return Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes))
|
|
23
|
+
export function sliceBytes(text: string, maxBytes: number): string {
|
|
24
|
+
return new TextDecoder().decode(Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)), { stream: true })
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Trim `text` to pi's documented tool-output budget, noting what was dropped. */
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which shell runs a command string, per Claude's contract. hooks.md: a shell-form
|
|
3
|
+
* command is "passed to a shell: `sh -c` on macOS and Linux, Git Bash on Windows, or
|
|
4
|
+
* PowerShell when Git Bash isn't installed", and a hook's `shell: "powershell"` runs it
|
|
5
|
+
* via PowerShell (pwsh, then Windows PowerShell 5.1). statusline.md applies the same
|
|
6
|
+
* Git Bash-then-PowerShell rule to statusLine commands. troubleshoot-install.md: when
|
|
7
|
+
* CLAUDE_CODE_GIT_BASH_PATH is unset, Git Bash is looked for in `C:\Program Files\Git`
|
|
8
|
+
* and `C:\Program Files (x86)\Git`, then through the `git` on PATH (the `bin\bash.exe`
|
|
9
|
+
* of that installation), skipping a git in the launch directory or below it under
|
|
10
|
+
* node_modules or a virtual environment. env-vars.md: the override is ignored unless
|
|
11
|
+
* the file exists and is named bash.exe, sh.exe, bash, or sh.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'node:fs'
|
|
15
|
+
import * as path from 'node:path'
|
|
16
|
+
|
|
17
|
+
export interface ResolvedShell {
|
|
18
|
+
kind: 'bash' | 'powershell'
|
|
19
|
+
file: string
|
|
20
|
+
/** The argv that runs `command` through this shell. */
|
|
21
|
+
argsFor: (command: string) => string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GIT_BASH_NAMES = new Set(['bash.exe', 'sh.exe', 'bash', 'sh'])
|
|
25
|
+
const DEFAULT_GIT_ROOTS = [String.raw`C:\Program Files\Git`, String.raw`C:\Program Files (x86)\Git`]
|
|
26
|
+
const PROJECT_TOOLING_DIRS = new Set(['node_modules', '.venv', 'venv'])
|
|
27
|
+
|
|
28
|
+
const isFile = (file: string): boolean => {
|
|
29
|
+
try {
|
|
30
|
+
return fs.statSync(file).isFile()
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The PowerShell names worth trying: pwsh everywhere it installs, plus the
|
|
37
|
+
* Windows spellings on win32, where powershell.exe ships with the OS. */
|
|
38
|
+
const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
|
|
39
|
+
|
|
40
|
+
/** First PowerShell binary found on PATH, or undefined when none is installed. */
|
|
41
|
+
export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
|
|
42
|
+
const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
|
|
43
|
+
for (const candidate of powershellCandidates(platform)) {
|
|
44
|
+
for (const dir of dirs) {
|
|
45
|
+
const full = path.join(dir, candidate)
|
|
46
|
+
try {
|
|
47
|
+
fs.accessSync(full, fs.constants.X_OK)
|
|
48
|
+
if (fs.statSync(full).isFile()) return full
|
|
49
|
+
} catch {
|
|
50
|
+
// not here; keep looking
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A git in the launch directory itself, or below it under node_modules or a virtual
|
|
58
|
+
* environment, is a project's own tooling rather than the user's Git installation. */
|
|
59
|
+
function isProjectTooling(dir: string, cwd: string): boolean {
|
|
60
|
+
const relative = path.relative(cwd, dir)
|
|
61
|
+
if (relative === '') return true
|
|
62
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) return false
|
|
63
|
+
return relative.split(path.sep).some((segment) => PROJECT_TOOLING_DIRS.has(segment))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Git Bash in Claude's documented order, or undefined when none is installed.
|
|
67
|
+
* `installRoots` is the default install location list, a parameter so tests on a
|
|
68
|
+
* Windows host that has Git there can still exercise the later rules. */
|
|
69
|
+
export function resolveGitBash(env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots: string[] = DEFAULT_GIT_ROOTS): string | undefined {
|
|
70
|
+
const override = env.CLAUDE_CODE_GIT_BASH_PATH
|
|
71
|
+
if (override && GIT_BASH_NAMES.has(path.basename(override).toLowerCase()) && isFile(override)) return override
|
|
72
|
+
for (const root of installRoots) {
|
|
73
|
+
const bash = path.join(root, 'bin', 'bash.exe')
|
|
74
|
+
if (isFile(bash)) return bash
|
|
75
|
+
}
|
|
76
|
+
for (const dir of (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean)) {
|
|
77
|
+
if (!isFile(path.join(dir, 'git.exe')) || isProjectTooling(dir, cwd)) continue
|
|
78
|
+
// Git for Windows puts git.exe in cmd\ (or bin\); bash.exe lives in bin\ of the same install.
|
|
79
|
+
const bash = path.join(path.dirname(dir), 'bin', 'bash.exe')
|
|
80
|
+
if (isFile(bash)) return bash
|
|
81
|
+
}
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Claude rewrites these three placeholders in a PowerShell shell-form command to
|
|
86
|
+
* PowerShell's `${env:NAME}` form; the bare `$NAME` spelling is left alone (PowerShell
|
|
87
|
+
* reads it as an undefined variable, which Claude only warns about). */
|
|
88
|
+
export const toPowershellPlaceholders = (command: string): string => command.replace(/\$\{(CLAUDE_PROJECT_DIR|CLAUDE_PLUGIN_ROOT|CLAUDE_PLUGIN_DATA)\}/g, (_match, name: string) => `\${env:${name}}`)
|
|
89
|
+
|
|
90
|
+
/** The bash for an injected command span: /bin/sh off Windows, Git Bash on Windows
|
|
91
|
+
* (undefined when it is not installed). */
|
|
92
|
+
export function bashBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots?: string[]): string | undefined {
|
|
93
|
+
return platform === 'win32' ? resolveGitBash(env, cwd, installRoots) : '/bin/sh'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const bashShell = (file: string): ResolvedShell => ({ kind: 'bash', file, argsFor: (command) => ['-c', command] })
|
|
97
|
+
|
|
98
|
+
const powershellShell = (file: string): ResolvedShell => ({
|
|
99
|
+
kind: 'powershell',
|
|
100
|
+
file,
|
|
101
|
+
// -Command swallows a native command's exit code unless it is forwarded; a script
|
|
102
|
+
// that calls `exit` itself never reaches the trailer, so its own code stands.
|
|
103
|
+
argsFor: (command) => ['-NoProfile', '-NonInteractive', '-Command', `${toPowershellPlaceholders(command)}\nexit $LASTEXITCODE`],
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The shell for a command string. `preferred` is a hook's `shell` field: "powershell"
|
|
108
|
+
* runs through PowerShell where one is installed. Otherwise /bin/sh off Windows; on
|
|
109
|
+
* Windows Git Bash, then PowerShell. Undefined means nothing on this machine can run
|
|
110
|
+
* it (Windows with neither Git Bash nor PowerShell installed).
|
|
111
|
+
*/
|
|
112
|
+
export function resolveShell(preferred: string | undefined, platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots?: string[]): ResolvedShell | undefined {
|
|
113
|
+
if (preferred === 'powershell') {
|
|
114
|
+
const powershell = resolvePowershellBinary(platform, env)
|
|
115
|
+
if (powershell) return powershellShell(powershell)
|
|
116
|
+
}
|
|
117
|
+
const bash = bashBinary(platform, env, cwd, installRoots)
|
|
118
|
+
if (bash) return bashShell(bash)
|
|
119
|
+
const powershell = resolvePowershellBinary(platform, env)
|
|
120
|
+
return powershell ? powershellShell(powershell) : undefined
|
|
121
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* fenced code block (backtick or tilde).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
/** The fence a line opens or closes, if any
|
|
14
|
+
/** The fence a line opens or closes, if any. */
|
|
15
15
|
export function fenceMarker(lineStart: string): string | null {
|
|
16
16
|
if (lineStart.startsWith('```')) return '`'
|
|
17
17
|
if (lineStart.startsWith('~~~')) return '~'
|
|
@@ -29,14 +29,14 @@ function fenceLength(lineStart: string, marker: string): number {
|
|
|
29
29
|
// is at least as long as the opener, so both are tracked: a shorter same-char
|
|
30
30
|
// fence line (the classic 3-backtick block quoted inside a 4-backtick one) is
|
|
31
31
|
// content, not a closer.
|
|
32
|
-
interface Fence {
|
|
32
|
+
export interface Fence {
|
|
33
33
|
marker: string
|
|
34
34
|
length: number
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** The fence state after a line, plus whether the line is fenced code (opener,
|
|
38
38
|
* body, or closer) and so emitted verbatim rather than scanned for comments. */
|
|
39
|
-
function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
|
|
39
|
+
export function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
|
|
40
40
|
if (marker !== null && fence === null) {
|
|
41
41
|
return { fence: { marker, length: fenceLength(trimmed, marker) }, fenced: true }
|
|
42
42
|
}
|
|
@@ -16,6 +16,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
|
|
16
16
|
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
|
|
17
17
|
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
18
18
|
import { FileOAuthProvider, type OAuthServerConfig } from '../internal/mcp-oauth.js'
|
|
19
|
+
import { resolveShell } from '../internal/shell-resolve.js'
|
|
19
20
|
import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
|
|
20
21
|
import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
|
|
21
22
|
|
|
@@ -306,11 +307,17 @@ export async function connectWithRetries(name: string, config: ServerConfig, aut
|
|
|
306
307
|
}
|
|
307
308
|
|
|
308
309
|
/** Run a headersHelper command and parse its JSON stdout into headers, under the
|
|
309
|
-
* environment helperEnv built.
|
|
310
|
-
*
|
|
310
|
+
* environment helperEnv built. It runs through the platform shell (/bin/sh; Git Bash
|
|
311
|
+
* or PowerShell on Windows). A failure, a 10s timeout, or a machine with no shell
|
|
312
|
+
* yields no extra headers rather than blocking the connection. */
|
|
311
313
|
function runHeadersHelper(command: string, env: NodeJS.ProcessEnv): Promise<Record<string, string>> {
|
|
312
314
|
return new Promise((resolve) => {
|
|
313
|
-
|
|
315
|
+
const shell = resolveShell(undefined)
|
|
316
|
+
if (!shell) {
|
|
317
|
+
resolve({})
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
execFile(shell.file, shell.argsFor(command), { timeout: 10_000, env }, (error, stdout) => {
|
|
314
321
|
resolve(error ? {} : parseHelperHeaders(stdout))
|
|
315
322
|
})
|
|
316
323
|
})
|
|
@@ -429,7 +429,9 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
429
429
|
planModeEnabled = true
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
|
|
432
|
+
// The current branch only: getEntries() lists every branch in the file, so after a
|
|
433
|
+
// rewind past a plan it would resurrect the abandoned plan and its tool restriction.
|
|
434
|
+
const entries = ctx.sessionManager.getBranch()
|
|
433
435
|
|
|
434
436
|
// Restore persisted state
|
|
435
437
|
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean; savedTools?: string[] } } | undefined
|
package/extensions/question.ts
CHANGED
|
@@ -179,7 +179,7 @@ export default function question(pi: ExtensionAPI) {
|
|
|
179
179
|
|
|
180
180
|
renderCall(args, theme, _context) {
|
|
181
181
|
const multi = args.multiSelect === true
|
|
182
|
-
const heading = args.header ? `[${args.header}] ` : ''
|
|
182
|
+
const heading = args.header ? `[${shortHeader(String(args.header))}] ` : ''
|
|
183
183
|
let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', heading + String(args.question ?? ''))
|
|
184
184
|
const opts = Array.isArray(args.options) ? args.options : []
|
|
185
185
|
if (opts.length) {
|
|
@@ -24,6 +24,8 @@ export interface BackgroundRun {
|
|
|
24
24
|
stderr?: string
|
|
25
25
|
/** Claude's partial marker: the run stopped at its maxTurns limit. */
|
|
26
26
|
partial?: boolean
|
|
27
|
+
/** Temp dir holding a prompt file rebuilt for a resume; removed with the run. */
|
|
28
|
+
rebuiltPromptDir?: string
|
|
27
29
|
/** Set while running so the run can be cancelled; cleared on completion. */
|
|
28
30
|
kill?: () => void
|
|
29
31
|
/** True until the child process actually closes: a cancelled child that ignores
|
|
@@ -65,7 +67,7 @@ export const MAX_FINISHED_RUNS = 20
|
|
|
65
67
|
/** Grace between the cancel SIGTERM and the SIGKILL that ends a child ignoring it. */
|
|
66
68
|
const CANCEL_KILL_GRACE_MS = 5000
|
|
67
69
|
|
|
68
|
-
/**
|
|
70
|
+
/** Characters of stderr kept per run, enough for the boot error without buffering logs. */
|
|
69
71
|
const STDERR_TAIL_CHARS = 2048
|
|
70
72
|
|
|
71
73
|
export function activeBackgroundRuns(): number {
|
|
@@ -79,6 +81,7 @@ let finishSequence = 0
|
|
|
79
81
|
/** Test seam: the registry is module state, so tests reset it between cases to
|
|
80
82
|
* stay order-independent. */
|
|
81
83
|
export function resetBackgroundRuns(): void {
|
|
84
|
+
for (const run of runs.values()) removeRebuiltPrompt(run)
|
|
82
85
|
runs.clear()
|
|
83
86
|
finishSequence = 0
|
|
84
87
|
}
|
|
@@ -86,7 +89,10 @@ export function resetBackgroundRuns(): void {
|
|
|
86
89
|
function evictFinishedRuns(): void {
|
|
87
90
|
const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
|
|
88
91
|
finished.sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0))
|
|
89
|
-
for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS)))
|
|
92
|
+
for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) {
|
|
93
|
+
removeRebuiltPrompt(stale)
|
|
94
|
+
runs.delete(stale.id)
|
|
95
|
+
}
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
/** Line-by-line parser keeping only the last assistant text and a turn count, so a
|
|
@@ -162,6 +168,8 @@ export function cancelAllBackgroundRuns(): number {
|
|
|
162
168
|
for (const id of Array.from(runs.keys())) {
|
|
163
169
|
if (cancelBackgroundRun(id) === 'cancelled') count++
|
|
164
170
|
}
|
|
171
|
+
// Called at quit: nothing in this registry is resumable once pi exits.
|
|
172
|
+
for (const run of runs.values()) removeRebuiltPrompt(run)
|
|
165
173
|
return count
|
|
166
174
|
}
|
|
167
175
|
|
|
@@ -190,38 +198,53 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
190
198
|
// Persisted so the rebuild happens once: rebuilding per resume leaked one temp
|
|
191
199
|
// prompt dir every follow-up.
|
|
192
200
|
const rebuilt = withRebuiltPrompt(run.spawn)
|
|
193
|
-
run.spawn = { ...run.spawn, args: rebuilt }
|
|
194
|
-
|
|
201
|
+
run.spawn = { ...run.spawn, args: rebuilt.args }
|
|
202
|
+
if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
|
|
203
|
+
const args = rebuilt.args.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
|
|
195
204
|
run.state = 'running'
|
|
196
205
|
run.task = task
|
|
197
206
|
run.output = undefined
|
|
198
207
|
run.exitCode = undefined
|
|
199
208
|
run.stderr = undefined
|
|
200
209
|
run.finishedAt = undefined
|
|
210
|
+
// Both belong to the child that ran: a resume that dies before its first turn would
|
|
211
|
+
// otherwise report the previous count, and a clean follow-up to a maxTurns-capped run
|
|
212
|
+
// would still be offered as partial.
|
|
213
|
+
run.turns = 0
|
|
214
|
+
run.partial = undefined
|
|
201
215
|
driveRun(run, { ...run.spawn, args }, onComplete)
|
|
202
216
|
return 'resumed'
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
/** Re-point --system-prompt at a fresh file when the original is gone
|
|
206
|
-
|
|
219
|
+
/** Re-point --system-prompt at a fresh file when the original is gone; `dir` is the
|
|
220
|
+
* temp dir created for it, which the run then owns. */
|
|
221
|
+
function withRebuiltPrompt(spawnSpec: BackgroundSpawn): { args: string[]; dir?: string } {
|
|
207
222
|
const flag = spawnSpec.args.indexOf('--system-prompt')
|
|
208
|
-
if (flag === -1 || !spawnSpec.promptBody) return spawnSpec.args
|
|
223
|
+
if (flag === -1 || !spawnSpec.promptBody) return { args: spawnSpec.args }
|
|
209
224
|
const current = spawnSpec.args[flag + 1]
|
|
210
|
-
if (current && fs.existsSync(current)) return spawnSpec.args
|
|
225
|
+
if (current && fs.existsSync(current)) return { args: spawnSpec.args }
|
|
211
226
|
try {
|
|
212
227
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-subagent-'))
|
|
213
228
|
const file = path.join(dir, 'prompt.md')
|
|
214
229
|
fs.writeFileSync(file, spawnSpec.promptBody, { mode: 0o600 })
|
|
215
230
|
const rebuilt = [...spawnSpec.args]
|
|
216
231
|
rebuilt[flag + 1] = file
|
|
217
|
-
return rebuilt
|
|
232
|
+
return { args: rebuilt, dir }
|
|
218
233
|
} catch {
|
|
219
234
|
// Cannot rewrite it: drop the pair rather than hand pi a path it will treat as
|
|
220
235
|
// prompt text, which would replace the agent persona with a temp path.
|
|
221
|
-
return spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1)
|
|
236
|
+
return { args: spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1) }
|
|
222
237
|
}
|
|
223
238
|
}
|
|
224
239
|
|
|
240
|
+
/** The rebuilt prompt lives as long as its run can be resumed, so it goes when the run
|
|
241
|
+
* leaves the registry: eviction, quit, or the test reset. */
|
|
242
|
+
function removeRebuiltPrompt(run: BackgroundRun): void {
|
|
243
|
+
if (!run.rebuiltPromptDir) return
|
|
244
|
+
fs.rmSync(run.rebuiltPromptDir, { recursive: true, force: true })
|
|
245
|
+
run.rebuiltPromptDir = undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
225
248
|
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void, presetId?: string): string | null {
|
|
226
249
|
// Checked here, synchronously with registration: callers await temp-file writes
|
|
227
250
|
// between any check of their own and this call, so a parallel tool-call batch
|
package/extensions/todo.ts
CHANGED
|
@@ -387,8 +387,12 @@ export default function todoExtension(pi: ExtensionAPI) {
|
|
|
387
387
|
content: [{ type: 'text' as const, text }],
|
|
388
388
|
details,
|
|
389
389
|
})
|
|
390
|
-
|
|
391
|
-
|
|
390
|
+
// pi keeps a result's details by reference in the live session, and start/complete
|
|
391
|
+
// mutate the todo objects in place, so each result carries its own copies: without
|
|
392
|
+
// them a rewind replays, and the TUI re-renders, today's statuses on earlier calls.
|
|
393
|
+
const snapshot = () => todos.map((t) => ({ ...t }))
|
|
394
|
+
const ok = (action: TodoAction, text: string) => toolMessage(text, { action, todos: snapshot(), nextId })
|
|
395
|
+
const fail = (action: TodoAction, error: string) => toolMessage(`Error: ${error}`, { action, todos: snapshot(), nextId, error })
|
|
392
396
|
|
|
393
397
|
const handleAdd = (params: TodoParamsType) => {
|
|
394
398
|
if (!params.text) return fail('add', 'text required for add')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.50",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|