pi-code 1.0.68 → 1.0.70
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/context-imports.ts +2 -1
- package/extensions/git-checkpoint.ts +105 -9
- package/extensions/internal/atomic-write.ts +32 -4
- package/extensions/internal/mcp-oauth.ts +11 -3
- package/extensions/internal/shell-split.ts +2 -0
- package/extensions/mcp/transport.ts +7 -2
- package/extensions/plan-mode/utils.ts +100 -9
- package/extensions/skills.ts +3 -1
- package/extensions/status-line.ts +4 -0
- package/extensions/subagent/background.ts +18 -9
- package/extensions/subagent/run.ts +13 -2
- package/package.json +1 -1
|
@@ -601,7 +601,8 @@ function nestedContextBlock(file: string, dir: string, load: NestedLoadContext):
|
|
|
601
601
|
// a body already in the system prompt.
|
|
602
602
|
const seen = new Set([...load.launchLoaded, real])
|
|
603
603
|
const budget = createImportBudget()
|
|
604
|
-
|
|
604
|
+
// The stripped body, as at launch: a commented-out @import never expands.
|
|
605
|
+
const imports = collectImports(body, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
|
|
605
606
|
importer: real,
|
|
606
607
|
isExcluded: (absPath) => isExcludedPath(absPath, load.excludeGlobs, load.home),
|
|
607
608
|
budget,
|
|
@@ -168,6 +168,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
168
168
|
// assistant turns it drives. before_agent_start starts a run; the first turn_start
|
|
169
169
|
// then snapshots and clears this, so turns 2..n skip the wasted git work.
|
|
170
170
|
let runNeedsSnapshot = true
|
|
171
|
+
/** Whether the run about to start came from a prompt (see before_agent_start below). */
|
|
172
|
+
let promptedRun = false
|
|
171
173
|
let shadowDir: string | undefined
|
|
172
174
|
let workTree: string | undefined
|
|
173
175
|
// Absolute paths this session's edit tools targeted: the whole of what a checkpoint
|
|
@@ -219,6 +221,17 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
219
221
|
await mirrorLocalExcludes(ctx)
|
|
220
222
|
}
|
|
221
223
|
|
|
224
|
+
/** /fork writes a new session file and copies the branch's checkpoint entries into it,
|
|
225
|
+
* while the shadow repository is keyed to the session file: the refs those entries name
|
|
226
|
+
* live in the parent's shadow, and a restore from the fork's own answered "invalid
|
|
227
|
+
* reference". A local bare-to-bare fetch brings them across. Best effort: a parent
|
|
228
|
+
* shadow already pruned leaves those checkpoints unrestorable, as they were. */
|
|
229
|
+
async function inheritCheckpointRefs(previousSessionFile: string): Promise<void> {
|
|
230
|
+
const parentShadow = path.join(getAgentDir(), 'checkpoints', sessionSlug(previousSessionFile))
|
|
231
|
+
if (parentShadow === shadowDir || !fs.existsSync(parentShadow)) return
|
|
232
|
+
await gitShadow(['fetch', '--quiet', parentShadow, `+${CHECKPOINT_REF_PREFIX}/*:${CHECKPOINT_REF_PREFIX}/*`])
|
|
233
|
+
}
|
|
234
|
+
|
|
222
235
|
/** git reads ignore rules from the tree's .gitignore files, the user's global excludes,
|
|
223
236
|
* and $GIT_DIR/info/exclude. The shadow is the GIT_DIR here, so the repo's own
|
|
224
237
|
* .git/info/exclude (where secrets and scratch that must never be committed live)
|
|
@@ -308,12 +321,29 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
308
321
|
/** Fold a file's pre-edit content into the run's checkpoint. A file first edited part
|
|
309
322
|
* way through a run is absent from that run's pre-run snapshot, so without this its
|
|
310
323
|
* checkpoint holds no baseline and /rewind cannot undo that first edit. */
|
|
324
|
+
/** `file` named by the real directory it sits in, when a symlink inside the work tree
|
|
325
|
+
* leads there. git refuses a pathspec that passes through a symlink ("beyond a symbolic
|
|
326
|
+
* link"), so the file has to be tracked where it really is. The real directory is mapped
|
|
327
|
+
* back under the work tree as the session spells it: a cwd that is itself a symlinked
|
|
328
|
+
* path (macOS /var) must not push every file outside the tree. */
|
|
329
|
+
function throughSymlinks(file: string): string {
|
|
330
|
+
if (!workTree) return file
|
|
331
|
+
try {
|
|
332
|
+
const realTree = fs.realpathSync(workTree)
|
|
333
|
+
const realDir = fs.realpathSync(path.dirname(file))
|
|
334
|
+
const inside = path.relative(realTree, realDir)
|
|
335
|
+
if (inside.startsWith('..') || path.isAbsolute(inside)) return file
|
|
336
|
+
return path.join(workTree, inside, path.basename(file))
|
|
337
|
+
} catch {
|
|
338
|
+
return file
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
311
342
|
async function captureBaseline(file: string): Promise<void> {
|
|
312
343
|
const rel = workTreePath(file)
|
|
313
344
|
if (!runRef || rel === undefined || !fs.existsSync(file)) return
|
|
314
345
|
if ((await withoutIgnored([rel])).length === 0) return
|
|
315
|
-
|
|
316
|
-
if (add.code !== 0) return
|
|
346
|
+
if (!(await addPaths([rel]))) return
|
|
317
347
|
// A commit, never an amend: the run's checkpoint can be a commit an earlier
|
|
318
348
|
// checkpoint also points at, and rewriting it would strand that one.
|
|
319
349
|
const commit = await commitShadow([])
|
|
@@ -329,13 +359,50 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
329
359
|
return files.code === 0 && files.stdout.trim() === ''
|
|
330
360
|
}
|
|
331
361
|
|
|
362
|
+
/** Stage what `git add` silently skipped. For a path under another repository (a
|
|
363
|
+
* workspace of clones, a submodule, $HOME) it exits 0 and stages nothing, so the edit
|
|
364
|
+
* was never snapshotted while /rewind still reported success. Such a file is written to
|
|
365
|
+
* the index directly, which `checkout` restores like any other entry. A deletion needs
|
|
366
|
+
* nothing here: `git add` does stage the removal of an entry the index already holds. */
|
|
367
|
+
async function stageSkippedPaths(paths: string[]): Promise<void> {
|
|
368
|
+
if (!workTree) return
|
|
369
|
+
const listed = await gitShadow(['ls-files', '-z', '--', ...paths])
|
|
370
|
+
if (listed.code !== 0) return
|
|
371
|
+
const indexed = new Set(listed.stdout.split('\0').filter(Boolean))
|
|
372
|
+
for (const rel of paths) {
|
|
373
|
+
if (!indexed.has(rel) && fs.existsSync(path.join(workTree, rel))) await indexDirectly(rel)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function indexDirectly(rel: string): Promise<void> {
|
|
378
|
+
if (!workTree) return
|
|
379
|
+
const blob = await gitShadow(['hash-object', '-w', '--', rel])
|
|
380
|
+
if (blob.code !== 0) return
|
|
381
|
+
const executable = (fs.statSync(path.join(workTree, rel)).mode & 0o111) !== 0
|
|
382
|
+
await gitShadow(['update-index', '--add', '--cacheinfo', `${executable ? '100755' : '100644'},${blob.stdout.trim()},${rel}`])
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Stage the tracked paths. git fails the whole add when one pathspec is refused, which
|
|
386
|
+
* used to end checkpointing for the session over a single path. On a failure each path
|
|
387
|
+
* is added alone and one git still refuses is dropped from the edit set, so it costs
|
|
388
|
+
* its own checkpoint and nobody else's. False only when nothing could be staged. */
|
|
389
|
+
async function addPaths(paths: string[]): Promise<boolean> {
|
|
390
|
+
if ((await gitShadow(['add', '--', ...paths])).code === 0) {
|
|
391
|
+
await stageSkippedPaths(paths)
|
|
392
|
+
return true
|
|
393
|
+
}
|
|
394
|
+
let staged = 0
|
|
395
|
+
for (const rel of paths) {
|
|
396
|
+
if ((await gitShadow(['add', '--', rel])).code === 0) staged++
|
|
397
|
+
else if (workTree) touched.delete(path.resolve(workTree, rel))
|
|
398
|
+
}
|
|
399
|
+
return staged > 0
|
|
400
|
+
}
|
|
401
|
+
|
|
332
402
|
async function snapshot(): Promise<{ ref: string; createdAt: string } | undefined> {
|
|
333
403
|
const createdAt = new Date().toISOString()
|
|
334
404
|
const paths = await addablePaths()
|
|
335
|
-
if (paths.length > 0)
|
|
336
|
-
const add = await gitShadow(['add', '--', ...paths])
|
|
337
|
-
if (add.code !== 0) return undefined
|
|
338
|
-
}
|
|
405
|
+
if (paths.length > 0 && !(await addPaths(paths))) return undefined
|
|
339
406
|
// Decide "nothing changed" from the index, not from the commit exit code: a commit can
|
|
340
407
|
// also fail on the user's global signing or hooks config, and reusing HEAD then would
|
|
341
408
|
// record a ref that predates the current tree, so /rewind restores the wrong state.
|
|
@@ -386,7 +453,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
386
453
|
ctx.ui.notify('Rewind complete', 'info')
|
|
387
454
|
}
|
|
388
455
|
|
|
389
|
-
pi.on('session_start', async (
|
|
456
|
+
pi.on('session_start', async (event, ctx) => {
|
|
390
457
|
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
391
458
|
// the same instance after turn_start took the pre-run snapshot but before turn_end saved
|
|
392
459
|
// it; that pending ref belongs to the previous session and must not attach to the next
|
|
@@ -394,8 +461,11 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
394
461
|
// own tree even though the prior run left it false.
|
|
395
462
|
pending = undefined
|
|
396
463
|
runNeedsSnapshot = true
|
|
464
|
+
promptedRun = false
|
|
397
465
|
runRef = undefined
|
|
398
466
|
await ensureShadow(ctx)
|
|
467
|
+
const forkedFrom = event.reason === 'fork' ? event.previousSessionFile : undefined
|
|
468
|
+
if (forkedFrom) await inheritCheckpointRefs(forkedFrom)
|
|
399
469
|
touched.clear()
|
|
400
470
|
for (const rel of await committedPaths()) touched.add(path.resolve(ctx.cwd, rel))
|
|
401
471
|
checkpoints.clear()
|
|
@@ -425,13 +495,39 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
425
495
|
// the checkpoint is only keyed and saved at turn_end. The snapshot is awaited here so
|
|
426
496
|
// `git add -A` captures the tree before the model's first edit; turn_end reads the
|
|
427
497
|
// resolved value.
|
|
428
|
-
|
|
498
|
+
// Only a prompt fires before_agent_start. A provider retry, an overflow recovery and a
|
|
499
|
+
// queued follow-up all re-enter through agent.continue(), with agent_start alone.
|
|
500
|
+
pi.on('before_agent_start', async () => {
|
|
501
|
+
promptedRun = true
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
/** The checkpoint a continued run is still working under: it re-entered with no new user
|
|
505
|
+
* message, so its prompt already has one. A snapshot taken there stages every tracked
|
|
506
|
+
* file at its MID-run content into the index the recorded checkpoint is later extended
|
|
507
|
+
* from (a baseline captured afterwards moved the checkpoint onto that content), and a
|
|
508
|
+
* file first edited in that turn had its baseline folded into the discarded ref. */
|
|
509
|
+
function continuedCheckpoint(ctx: ExtensionContext): Checkpoint | undefined {
|
|
510
|
+
// A queued follow-up is a new user message and needs its own snapshot. Optional: the
|
|
511
|
+
// peer range reaches runtimes that may not have the method.
|
|
512
|
+
if (ctx.hasPendingMessages?.()) return undefined
|
|
513
|
+
const target = findLastUserMessage(ctx)
|
|
514
|
+
return target ? checkpoints.get(target.entryId) : undefined
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
pi.on('turn_start', async (_event, ctx) => {
|
|
429
518
|
if (!runNeedsSnapshot) return
|
|
430
519
|
runNeedsSnapshot = false
|
|
520
|
+
const prompted = promptedRun
|
|
521
|
+
promptedRun = false
|
|
431
522
|
// Claude: "Set to 1 to disable file checkpointing. The /rewind command will not be
|
|
432
523
|
// able to restore code changes." No snapshot means turn_end's `if (!snap) return`
|
|
433
524
|
// always fires, so no checkpoint is ever recorded.
|
|
434
525
|
if (process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING === '1') return
|
|
526
|
+
const continued = prompted ? undefined : continuedCheckpoint(ctx)
|
|
527
|
+
if (continued) {
|
|
528
|
+
runRef = continued.ref
|
|
529
|
+
return
|
|
530
|
+
}
|
|
435
531
|
pending = await snapshot()
|
|
436
532
|
})
|
|
437
533
|
|
|
@@ -442,7 +538,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
442
538
|
if (!EDIT_TOOLS.has(event.toolName)) return
|
|
443
539
|
const target = fileToolTarget(event)
|
|
444
540
|
if (target === undefined) return
|
|
445
|
-
const file = path.resolve(ctx.cwd, target)
|
|
541
|
+
const file = throughSymlinks(path.resolve(ctx.cwd, target))
|
|
446
542
|
if (touched.has(file)) return
|
|
447
543
|
touched.add(file)
|
|
448
544
|
await captureBaseline(file)
|
|
@@ -6,9 +6,37 @@
|
|
|
6
6
|
|
|
7
7
|
import * as fs from 'node:fs'
|
|
8
8
|
|
|
9
|
-
/** The
|
|
9
|
+
/** The file a path leads to. A rename onto a symlink replaces the link itself: a settings
|
|
10
|
+
* file a dotfiles setup links into a repository would silently become a regular file while
|
|
11
|
+
* the real one kept the old content. The write goes to the real file, in its own directory
|
|
12
|
+
* so the rename stays on one filesystem. A link that leads nowhere yet is written as is. */
|
|
13
|
+
function throughLinks(filePath: string): string {
|
|
14
|
+
try {
|
|
15
|
+
return fs.realpathSync(filePath)
|
|
16
|
+
} catch {
|
|
17
|
+
return filePath
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The permission bits of `filePath`, or undefined when it does not exist yet. */
|
|
22
|
+
function existingMode(filePath: string): number | undefined {
|
|
23
|
+
try {
|
|
24
|
+
return fs.statSync(filePath).mode & 0o777
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tmp name carries the pid so concurrent processes do not collide. A rename replaces
|
|
31
|
+
* the file's inode, so the replacement is created with the mode of the file it replaces:
|
|
32
|
+
* settings.local.json can hold env secrets, and a fresh temp file is 0644 under the usual
|
|
33
|
+
* umask. Created with that mode, the temp file is never more permissive than the target;
|
|
34
|
+
* the chmod then makes it exact where the umask took bits away. */
|
|
10
35
|
export function atomicWriteFile(filePath: string, content: string): void {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
36
|
+
const target = throughLinks(filePath)
|
|
37
|
+
const tmp = `${target}.${process.pid}.tmp`
|
|
38
|
+
const mode = existingMode(target)
|
|
39
|
+
fs.writeFileSync(tmp, content, mode === undefined ? undefined : { mode })
|
|
40
|
+
if (mode !== undefined) fs.chmodSync(tmp, mode)
|
|
41
|
+
fs.renameSync(tmp, target)
|
|
14
42
|
}
|
|
@@ -286,12 +286,20 @@ export function waitForAuthCode(server: http.Server, timeoutMs: number, expected
|
|
|
286
286
|
})
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
/** Best-effort browser launch; the caller also surfaces the URL as text.
|
|
289
|
+
/** Best-effort browser launch; the caller also surfaces the URL as text.
|
|
290
|
+
*
|
|
291
|
+
* The URL comes from the server's metadata, so two things are not taken on trust. Only a
|
|
292
|
+
* web page is opened: the SDK rejects javascript:, data: and vbscript: alone, and a file:
|
|
293
|
+
* URL or a custom protocol handed to the platform launcher opens a file or starts an
|
|
294
|
+
* application. And on Windows no shell sees it: `cmd /c start` splits its line at every
|
|
295
|
+
* bare `&`, which every authorization URL has, truncating the URL and running the rest as
|
|
296
|
+
* commands. rundll32 takes the URL as one argument. */
|
|
290
297
|
export function openBrowser(url: string): void {
|
|
298
|
+
if (!/^https?:\/\//i.test(url)) return
|
|
291
299
|
let command = 'xdg-open'
|
|
292
300
|
if (process.platform === 'darwin') command = 'open'
|
|
293
|
-
else if (process.platform === 'win32') command = '
|
|
294
|
-
const args = process.platform === 'win32' ? ['
|
|
301
|
+
else if (process.platform === 'win32') command = 'rundll32'
|
|
302
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
|
|
295
303
|
try {
|
|
296
304
|
const child = spawn(command, args, { stdio: 'ignore', detached: true })
|
|
297
305
|
// A missing launcher (a container or an SSH session with no xdg-open) reports itself
|
|
@@ -17,6 +17,8 @@ function separatorAt(command: string, i: number): number {
|
|
|
17
17
|
const pair = command.slice(i, i + 2)
|
|
18
18
|
if (pair === '&&' || pair === '||' || pair === '|&') return 2
|
|
19
19
|
const ch = command[i]
|
|
20
|
+
// The & of a redirection (`2>&1`, `&>file`) belongs to the command, not between two.
|
|
21
|
+
if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) return 0
|
|
20
22
|
return ch === ';' || ch === '|' || ch === '&' || ch === '\n' ? 1 : 0
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -37,12 +37,17 @@ const DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS = 1_800_000
|
|
|
37
37
|
|
|
38
38
|
/** Claude's numeric env vars accept scientific notation and digit-separator spellings
|
|
39
39
|
* (2e3 as 2000, 64_000 as 64000). A non-numeric value is undefined, not zero. */
|
|
40
|
+
/** The longest delay setTimeout holds: it keeps the delay in 32 bits, and a larger one fires
|
|
41
|
+
* after 1 ms. A user's "never" (3000000000) made every call time out at once, so a timeout
|
|
42
|
+
* is clamped to this, about 24.8 days. */
|
|
43
|
+
const MAX_TIMER_MS = 2_147_483_647
|
|
44
|
+
|
|
40
45
|
/** A positive-integer env override, or the default when unset or unparseable. */
|
|
41
46
|
function envTimeout(name: string, fallback: number): number {
|
|
42
47
|
const raw = process.env[name]
|
|
43
48
|
if (raw === undefined) return fallback
|
|
44
49
|
const value = parseNumericEnv(raw)
|
|
45
|
-
return value !== undefined && value > 0 ? Math.floor(value) : fallback
|
|
50
|
+
return value !== undefined && value > 0 ? Math.min(Math.floor(value), MAX_TIMER_MS) : fallback
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
// Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
|
|
@@ -97,7 +102,7 @@ export function callRequestOptions(wall: number, tuning: ServerCallTuning = {}):
|
|
|
97
102
|
* per-server timeout. Per Claude, timeout values below 1000 are ignored and fall
|
|
98
103
|
* through to MCP_TOOL_TIMEOUT. */
|
|
99
104
|
export function serverCallTuning(config: ServerConfig): ServerCallTuning {
|
|
100
|
-
const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
|
|
105
|
+
const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? Math.min(config.timeout, MAX_TIMER_MS) : undefined
|
|
101
106
|
return { stdio: isStdio(config), ...(declared !== undefined ? { serverTimeoutMs: declared } : {}) }
|
|
102
107
|
}
|
|
103
108
|
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { hasSubstitution, splitSegments } from '../internal/shell-split.js'
|
|
7
7
|
|
|
8
|
-
// Destructive commands blocked in plan mode
|
|
8
|
+
// Destructive commands blocked in plan mode. Tested against the segment's command word,
|
|
9
|
+
// the one word that runs: an allowlisted head never executes its arguments, so `code`
|
|
10
|
+
// in a path, `touch` in a quoted pattern or `cp` in a file name is not a command.
|
|
9
11
|
const DESTRUCTIVE_PATTERNS = [
|
|
10
12
|
/\brm\b/i,
|
|
11
13
|
/\brmdir\b/i,
|
|
@@ -21,8 +23,6 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
21
23
|
/\btruncate\b/i,
|
|
22
24
|
/\bdd\b/i,
|
|
23
25
|
/\bshred\b/i,
|
|
24
|
-
/(^|[^<])>(?!>)/,
|
|
25
|
-
/>>/,
|
|
26
26
|
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
27
27
|
/\byarn\s+(add|remove|install|publish)/i,
|
|
28
28
|
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
@@ -40,7 +40,17 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
40
40
|
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
41
41
|
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
42
42
|
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
43
|
-
]
|
|
43
|
+
].map((pattern) => new RegExp(String.raw`^\s*(?:${pattern.source})`, pattern.flags))
|
|
44
|
+
|
|
45
|
+
// A redirect writes wherever it points, from any position in the segment.
|
|
46
|
+
const REDIRECT_PATTERNS = [/(^|[^<])>(?!>)/, />>/]
|
|
47
|
+
|
|
48
|
+
// Redirections that write nothing: onto /dev/null, and a descriptor duplicated onto
|
|
49
|
+
// another (`2>&1`, `>&2`, `>&-`). `>&file` is not one: it writes the file. Every run is
|
|
50
|
+
// bounded: the text is chosen by the model, and an unbounded `\d*` in front of an
|
|
51
|
+
// unanchored match backtracks quadratically (40,000 digits took over a second).
|
|
52
|
+
const DEV_NULL_REDIRECT = /(?:&|\d{0,3})>>?[ \t]{0,8}\/dev\/null(?=\s|$)/g
|
|
53
|
+
const DESCRIPTOR_REDIRECT = /\d{0,3}>&(?:\d{1,3}|-)(?=\s|$)/g
|
|
44
54
|
|
|
45
55
|
// Safe read-only commands allowed in plan mode. Deliberately excludes env/printenv
|
|
46
56
|
// (secret disclosure, and env is an exec wrapper), curl/wget (fetch plus -o writes),
|
|
@@ -55,6 +65,7 @@ const SAFE_PATTERNS = [
|
|
|
55
65
|
/^\s*find\b/,
|
|
56
66
|
/^\s*ls\b/,
|
|
57
67
|
/^\s*pwd\b/,
|
|
68
|
+
/^\s*cd(\s|$)/,
|
|
58
69
|
/^\s*echo\b/,
|
|
59
70
|
/^\s*printf\b/,
|
|
60
71
|
/^\s*wc\b/,
|
|
@@ -92,13 +103,93 @@ const SAFE_PATTERNS = [
|
|
|
92
103
|
/^\s*eza\b/,
|
|
93
104
|
]
|
|
94
105
|
|
|
95
|
-
// find is allowlisted for traversal only; these actions run commands or
|
|
96
|
-
const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
|
|
106
|
+
// find is allowlisted for traversal only; these actions run commands, delete or write.
|
|
107
|
+
const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprint0|fprintf)\b/
|
|
108
|
+
|
|
109
|
+
// Flags that turn an allowlisted read into a write or an execution, per command.
|
|
110
|
+
const UNSAFE_FLAGS: ReadonlyArray<readonly [head: RegExp, flag: RegExp]> = [
|
|
111
|
+
[/^\s*find\b/, FIND_ACTIONS],
|
|
112
|
+
[/^\s*sort\b/, /\s(-[a-zA-Z]*o|--output\b|--compress-program\b)/],
|
|
113
|
+
[/^\s*tree\b/, /\s-[a-zA-Z]*o/],
|
|
114
|
+
[/^\s*rg\b/, /\s--(pre|hostname-bin)\b/],
|
|
115
|
+
[/^\s*git\s/, /\s--output(=|\s|$)/],
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
/** `segment` with its quoted spans removed, so a `>` or a flag inside a pattern reads as
|
|
119
|
+
* text. Follows splitSegments: a backslash outside quotes escapes the next character,
|
|
120
|
+
* and inside quotes only the closing quote matters. */
|
|
121
|
+
function withoutQuoted(segment: string): string {
|
|
122
|
+
let bare = ''
|
|
123
|
+
let quote: string | undefined
|
|
124
|
+
for (let i = 0; i < segment.length; i++) {
|
|
125
|
+
const ch = segment[i]
|
|
126
|
+
if (quote !== undefined) {
|
|
127
|
+
if (ch === quote) quote = undefined
|
|
128
|
+
} else if (ch === "'" || ch === '"') {
|
|
129
|
+
quote = ch
|
|
130
|
+
} else if (ch === '\\') {
|
|
131
|
+
i++
|
|
132
|
+
} else {
|
|
133
|
+
bare += ch
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return bare
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function writesThroughRedirect(segment: string): boolean {
|
|
140
|
+
const bare = withoutQuoted(segment).replace(DEV_NULL_REDIRECT, ' ').replace(DESCRIPTOR_REDIRECT, ' ')
|
|
141
|
+
return REDIRECT_PATTERNS.some((pattern) => pattern.test(bare))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** uniq [INPUT [OUTPUT]] writes its second operand. An option's value (-f N, -s N, -w N)
|
|
145
|
+
* is not an operand. */
|
|
146
|
+
function uniqWrites(args: string[]): boolean {
|
|
147
|
+
let operands = 0
|
|
148
|
+
for (let i = 0; i < args.length; i++) {
|
|
149
|
+
if (['-f', '-s', '-w'].includes(args[i])) i++
|
|
150
|
+
else if (!args[i].startsWith('-')) operands++
|
|
151
|
+
}
|
|
152
|
+
return operands >= 2
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const BRANCH_LISTS = /^(-l|--list|--contains|--no-contains|--merged|--no-merged|--points-at)$/
|
|
156
|
+
const BRANCH_WRITE_LONG_FLAGS = new Set(['--delete', '--move', '--copy', '--force', '--unset-upstream', '--set-upstream-to', '--edit-description', '--track', '--no-track', '--create-reflog'])
|
|
157
|
+
|
|
158
|
+
/** A `git branch` flag that changes a branch: -d -D -m -M -c -C -u -f -t, alone or in a
|
|
159
|
+
* cluster, or the long form of one. */
|
|
160
|
+
function isBranchWriteFlag(arg: string): boolean {
|
|
161
|
+
if (arg.startsWith('--')) return BRANCH_WRITE_LONG_FLAGS.has(arg.split('=')[0])
|
|
162
|
+
return arg.startsWith('-') && /[dDmMcCuft]/.test(arg)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `git branch` lists unless it is given a name to create or a flag that changes one. */
|
|
166
|
+
function gitBranchWrites(args: string[]): boolean {
|
|
167
|
+
if (args.some(isBranchWriteFlag)) return true
|
|
168
|
+
return args.some((arg) => !arg.startsWith('-')) && !args.some((arg) => BRANCH_LISTS.test(arg))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `git remote` lists and shows; every other subcommand edits the configuration. */
|
|
172
|
+
function gitRemoteWrites(args: string[]): boolean {
|
|
173
|
+
const subcommand = args.find((arg) => !arg.startsWith('-'))
|
|
174
|
+
return subcommand !== undefined && !['show', 'get-url'].includes(subcommand)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Whether an allowlisted command carries a flag or operand that makes it write. */
|
|
178
|
+
function writesThroughOperands(segment: string): boolean {
|
|
179
|
+
const bare = withoutQuoted(segment)
|
|
180
|
+
if (UNSAFE_FLAGS.some(([head, flag]) => head.test(segment) && flag.test(bare))) return true
|
|
181
|
+
const [command = '', subcommand = '', ...rest] = segment.trim().split(/\s+/)
|
|
182
|
+
if (command === 'uniq') return uniqWrites([subcommand, ...rest].filter(Boolean))
|
|
183
|
+
if (command !== 'git') return false
|
|
184
|
+
if (subcommand === 'branch') return gitBranchWrites(rest)
|
|
185
|
+
return subcommand === 'remote' && gitRemoteWrites(rest)
|
|
186
|
+
}
|
|
97
187
|
|
|
98
188
|
function isSafeSegment(segment: string): boolean {
|
|
99
|
-
if (DESTRUCTIVE_PATTERNS.some((
|
|
100
|
-
if (
|
|
101
|
-
|
|
189
|
+
if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(segment))) return false
|
|
190
|
+
if (writesThroughRedirect(segment)) return false
|
|
191
|
+
if (!SAFE_PATTERNS.some((pattern) => pattern.test(segment))) return false
|
|
192
|
+
return !writesThroughOperands(segment)
|
|
102
193
|
}
|
|
103
194
|
|
|
104
195
|
/**
|
package/extensions/skills.ts
CHANGED
|
@@ -123,7 +123,9 @@ function findClaudeSkill(name: string, roots: string[]): FoundSkill | undefined
|
|
|
123
123
|
continue
|
|
124
124
|
}
|
|
125
125
|
for (const entry of entries) {
|
|
126
|
-
|
|
126
|
+
// A link counts: pi's loader follows it, and skillAt already turns a link that
|
|
127
|
+
// leads to no SKILL.md (dangling, or to a plain file) into undefined.
|
|
128
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
127
129
|
const skill = skillAt(root, entry.name)
|
|
128
130
|
if (skill?.name === name) return { filePath: skill.filePath, baseDir: path.dirname(skill.filePath) }
|
|
129
131
|
}
|
|
@@ -360,6 +360,10 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
360
360
|
killInflight = kill
|
|
361
361
|
},
|
|
362
362
|
})
|
|
363
|
+
// A newer trigger killed this run and queued its rerun: what a killed script left
|
|
364
|
+
// behind is not its output, and applying it blanked the custom segment until the
|
|
365
|
+
// rerun finished. Claude cancels the in-flight script and shows nothing for it.
|
|
366
|
+
if (rerunQueued) return
|
|
363
367
|
// Claude: "Your script can output multiple lines to create a richer display."
|
|
364
368
|
// pi has one row for every extension status and replaces newlines with spaces
|
|
365
369
|
// before rendering it (footer.js sanitizeStatusText), so the rows are joined
|
|
@@ -11,8 +11,9 @@ import * as fs from 'node:fs'
|
|
|
11
11
|
import * as os from 'node:os'
|
|
12
12
|
import * as path from 'node:path'
|
|
13
13
|
import { killProcessTree } from '../internal/process-tree.js'
|
|
14
|
+
import { sharedSlot } from '../internal/shared-slot.js'
|
|
14
15
|
import { errorMessage } from '../internal/values.js'
|
|
15
|
-
import { spawnChild } from './run.js'
|
|
16
|
+
import { spawnChild, utf8Chunks } from './run.js'
|
|
16
17
|
|
|
17
18
|
export interface BackgroundRun {
|
|
18
19
|
id: string
|
|
@@ -57,7 +58,14 @@ export interface BackgroundSpawn {
|
|
|
57
58
|
maxTurns?: number
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
/** The registry, held on globalThis rather than in module state. /reload and a /resume into
|
|
62
|
+
* another directory clear pi's extension cache and evaluate this module again; a registry
|
|
63
|
+
* that died with the old instance left live children unlisted, uncancellable, and running
|
|
64
|
+
* on past pi's exit, since the quit path signals only what the registry holds. */
|
|
65
|
+
const registry = sharedSlot<{ runs: Map<string, BackgroundRun>; finishSequence: number }>('background-runs')
|
|
66
|
+
const state = registry.get() ?? { runs: new Map<string, BackgroundRun>(), finishSequence: 0 }
|
|
67
|
+
registry.set(state)
|
|
68
|
+
const runs = state.runs
|
|
61
69
|
|
|
62
70
|
/** Cap on simultaneously running background children. */
|
|
63
71
|
export const MAX_BACKGROUND_RUNS = 8
|
|
@@ -76,16 +84,15 @@ export function activeBackgroundRuns(): number {
|
|
|
76
84
|
return [...runs.values()].filter((run) => run.live || run.state === 'running').length
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
let finishSequence = 0
|
|
87
|
+
// state.finishSequence stamps BackgroundRun.finishedAt: a counter rather than a clock, so
|
|
88
|
+
// two runs completing in the same millisecond still evict in their true finish order.
|
|
82
89
|
|
|
83
90
|
/** Test seam: the registry is module state, so tests reset it between cases to
|
|
84
91
|
* stay order-independent. */
|
|
85
92
|
export function resetBackgroundRuns(): void {
|
|
86
93
|
for (const run of runs.values()) removeRebuiltPrompt(run)
|
|
87
94
|
runs.clear()
|
|
88
|
-
finishSequence = 0
|
|
95
|
+
state.finishSequence = 0
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
function evictFinishedRuns(): void {
|
|
@@ -287,7 +294,7 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
287
294
|
const complete = (): void => {
|
|
288
295
|
if (completed) return
|
|
289
296
|
completed = true
|
|
290
|
-
run.finishedAt = ++finishSequence
|
|
297
|
+
run.finishedAt = ++state.finishSequence
|
|
291
298
|
evictFinishedRuns()
|
|
292
299
|
// A run outlives the session that started it, and pi's loader wires assertActive()
|
|
293
300
|
// into every runtime call, so notifying a disposed session throws. This fires from
|
|
@@ -343,13 +350,15 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
343
350
|
: undefined,
|
|
344
351
|
)
|
|
345
352
|
let stderrTail = ''
|
|
346
|
-
|
|
353
|
+
const decodeStdout = utf8Chunks()
|
|
354
|
+
const decodeStderr = utf8Chunks()
|
|
355
|
+
proc.stdout.on('data', (data) => parser.push(decodeStdout(data)))
|
|
347
356
|
// An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
|
|
348
357
|
// belongs to a detached child, so a pipe read failure would exit pi the same way an
|
|
349
358
|
// unguarded completion would. The foreground runner guards its streams the same way.
|
|
350
359
|
proc.stdout.on('error', () => {})
|
|
351
360
|
proc.stderr?.on('data', (data) => {
|
|
352
|
-
stderrTail = (stderrTail + data
|
|
361
|
+
stderrTail = (stderrTail + decodeStderr(data)).slice(-STDERR_TAIL_CHARS)
|
|
353
362
|
})
|
|
354
363
|
proc.stderr?.on('error', () => {})
|
|
355
364
|
proc.on('close', (code) => {
|
|
@@ -12,6 +12,7 @@ import * as fs from 'node:fs'
|
|
|
12
12
|
import * as os from 'node:os'
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
import type { Readable } from 'node:stream'
|
|
15
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
15
16
|
|
|
16
17
|
import type { AgentToolResult } from '@earendil-works/pi-agent-core'
|
|
17
18
|
import type { Message } from '@earendil-works/pi-ai'
|
|
@@ -125,6 +126,14 @@ function appendPartialNote(result: SingleResult): void {
|
|
|
125
126
|
if (result.partial) appendResultNote(result, '[Output is partial: the subagent stopped at its maxTurns limit.]')
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/** A decoder for one child stream. A pipe delivers whatever bytes are ready, so a read can
|
|
130
|
+
* end inside a multi-byte character: decoded on its own, each half became U+FFFD. This one
|
|
131
|
+
* holds the incomplete bytes until the rest arrive. One per stream, never shared. */
|
|
132
|
+
export function utf8Chunks(): (chunk: Buffer | string) => string {
|
|
133
|
+
const decoder = new StringDecoder('utf8')
|
|
134
|
+
return (chunk) => (typeof chunk === 'string' ? chunk : decoder.write(chunk))
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
/** The pi child, or the error spawning it threw synchronously. Node normally
|
|
129
138
|
* reports a spawn failure through the child's async 'error' event, but some
|
|
130
139
|
* failures (confirmed on Linux: posix_spawn detects E2BIG immediately) throw at
|
|
@@ -328,8 +337,10 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
328
337
|
if (onAbort && signal) signal.removeEventListener('abort', onAbort)
|
|
329
338
|
}
|
|
330
339
|
|
|
340
|
+
const decodeStdout = utf8Chunks()
|
|
341
|
+
const decodeStderr = utf8Chunks()
|
|
331
342
|
proc.stdout.on('data', (data) => {
|
|
332
|
-
buffer += data
|
|
343
|
+
buffer += decodeStdout(data)
|
|
333
344
|
const lines = buffer.split('\n')
|
|
334
345
|
buffer = lines.pop() || ''
|
|
335
346
|
for (const line of lines) processLine(line)
|
|
@@ -337,7 +348,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
337
348
|
proc.stdout.on('error', () => {})
|
|
338
349
|
|
|
339
350
|
proc.stderr.on('data', (data) => {
|
|
340
|
-
currentResult.stderr += data
|
|
351
|
+
currentResult.stderr += decodeStderr(data)
|
|
341
352
|
})
|
|
342
353
|
proc.stderr.on('error', () => {})
|
|
343
354
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.70",
|
|
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",
|