pi-code 1.0.69 → 1.0.71
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/commands.ts +19 -6
- package/extensions/context-imports.ts +2 -1
- package/extensions/hooks/index.ts +40 -22
- 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/internal/turn-override.ts +5 -4
- 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 +5 -3
- package/extensions/subagent/run.ts +13 -2
- package/extensions/thinking.ts +7 -1
- package/package.json +1 -1
package/extensions/commands.ts
CHANGED
|
@@ -335,12 +335,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
335
335
|
// setModel can reject (e.g. auth resolution fails), and a floated rejection would
|
|
336
336
|
// escape as unhandled; surface it as a no-op instead of leaving the session silently
|
|
337
337
|
// on the command's override model.
|
|
338
|
-
set: (model) => {
|
|
338
|
+
set: async (model) => {
|
|
339
339
|
// A refused switch leaves the turn on the session model rather than the one the
|
|
340
|
-
// command named, and the reply gives no sign of it, so the refusal is reported.
|
|
341
|
-
|
|
340
|
+
// command named, and the reply gives no sign of it, so the refusal is reported. The
|
|
341
|
+
// promise never rejects, so a caller that does not await it (agent_settled) cannot
|
|
342
|
+
// float a rejection; the shutdown handler awaits it so the exit cannot outrun it.
|
|
343
|
+
try {
|
|
344
|
+
await pi.setModel(model as Parameters<typeof pi.setModel>[0])
|
|
345
|
+
} catch (error) {
|
|
342
346
|
console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${errorMessage(error)}`)
|
|
343
|
-
}
|
|
347
|
+
}
|
|
344
348
|
},
|
|
345
349
|
})
|
|
346
350
|
/** The thinking level to restore after a command's `effort:` override drove its run,
|
|
@@ -363,14 +367,23 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
363
367
|
pendingAgentRules = undefined
|
|
364
368
|
pendingSkillRules = undefined
|
|
365
369
|
pendingPathRules = undefined
|
|
366
|
-
modelOverride.settle()
|
|
367
|
-
effortOverride.settle()
|
|
370
|
+
void modelOverride.settle()
|
|
371
|
+
void effortOverride.settle()
|
|
368
372
|
if (pendingRestore) {
|
|
369
373
|
pi.setActiveTools(pendingRestore)
|
|
370
374
|
pendingRestore = undefined
|
|
371
375
|
}
|
|
372
376
|
})
|
|
373
377
|
|
|
378
|
+
// agent_settled never fires when pi is quit mid-run (double Ctrl+C, Ctrl+D, a closed
|
|
379
|
+
// terminal, SIGTERM), and the switch is already in the transcript: `pi -c` resumed on the
|
|
380
|
+
// command's override model at its raised level. A run that settled has nothing armed, so
|
|
381
|
+
// this restores nothing twice.
|
|
382
|
+
pi.on('session_shutdown', async () => {
|
|
383
|
+
await modelOverride.settle()
|
|
384
|
+
await effortOverride.settle()
|
|
385
|
+
})
|
|
386
|
+
|
|
374
387
|
// The active-tool set has no argument dimension, so a scoped grant hands the turn
|
|
375
388
|
// the whole tool; the scope is enforced here instead, when the call arrives. Same
|
|
376
389
|
// steering-not-sandbox caveat as plan mode's guard.
|
|
@@ -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,
|
|
@@ -223,6 +223,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
223
223
|
let managedHooksOnly = false
|
|
224
224
|
/** Skill hooks registered this session, re-applied when a settings edit reloads. */
|
|
225
225
|
const registeredSkillHooks: Array<{ skillName: string; hooks: Record<string, unknown> }> = []
|
|
226
|
+
/** Whether this process is a subagent's child. Claude runs settings hooks inside a
|
|
227
|
+
* subagent for tool events, but Stop is the main agent's, a subagent completes with
|
|
228
|
+
* SubagentStop, and a subagent run is neither a session (SessionStart, SessionEnd) nor
|
|
229
|
+
* a prompt the user submitted (UserPromptSubmit). The child is a full pi process loading
|
|
230
|
+
* the same settings, so without this every one of them fired in it. */
|
|
231
|
+
const inSubagentChild = (): boolean => process.env.PI_CODE_SUBAGENT === '1'
|
|
226
232
|
/** Stops the settings watcher of the previous session. */
|
|
227
233
|
let disposeSettingsWatch: () => void = () => {}
|
|
228
234
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
@@ -466,7 +472,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
466
472
|
pendingSessionContext = []
|
|
467
473
|
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
468
474
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
469
|
-
if (event.reason === 'reload') return
|
|
475
|
+
if (event.reason === 'reload' || inSubagentChild()) return
|
|
470
476
|
const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
|
|
471
477
|
const commands = matchingCommands(config.SessionStart, source.names)
|
|
472
478
|
const payload = { hook_event_name: 'SessionStart', source: source.value }
|
|
@@ -593,7 +599,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
593
599
|
pi.on('input', async (event, ctx) => {
|
|
594
600
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
595
601
|
// prompts the user submitted.
|
|
596
|
-
if (event.source === 'extension') return { action: 'continue' }
|
|
602
|
+
if (event.source === 'extension' || inSubagentChild()) return { action: 'continue' }
|
|
597
603
|
// The user typed, so the pending idle_prompt no longer applies.
|
|
598
604
|
cancelIdlePrompt()
|
|
599
605
|
// Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
|
|
@@ -605,12 +611,15 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
605
611
|
ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
|
|
606
612
|
return { action: 'handled' }
|
|
607
613
|
}
|
|
608
|
-
// Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
|
|
609
|
-
// pi's seam for rewriting the submitted text. The prompt itself always survives:
|
|
610
614
|
// "UserPromptSubmit: can't replace the prompt; it only injects additionalContext
|
|
611
|
-
// alongside it"
|
|
615
|
+
// alongside it", as a system reminder with no visible transcript entry. So the context
|
|
616
|
+
// is a hidden message next to the prompt and the prompt text is left alone: prepended,
|
|
617
|
+
// it moved a `/skill:` or `/template` invocation off position 0 and pi stopped
|
|
618
|
+
// expanding it. pi appends a "nextTurn" message to this very prompt (after this handler
|
|
619
|
+
// runs); one typed mid-stream never reaches before_agent_start, so it is queued in the
|
|
620
|
+
// prompt's own mode. suppressOriginalPrompt scopes to the block message, which never
|
|
612
621
|
// carries the prompt here, so it needs nothing of its own.
|
|
613
|
-
if (decision.context)
|
|
622
|
+
if (decision.context) pi.sendMessage({ customType: 'claude-hook-context', content: decision.context, display: false }, { deliverAs: event.streamingBehavior ?? 'nextTurn' })
|
|
614
623
|
return { action: 'continue' }
|
|
615
624
|
})
|
|
616
625
|
|
|
@@ -630,6 +639,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
630
639
|
* turn cancels it. Observational only; exit codes and JSON output are ignored. */
|
|
631
640
|
const armIdlePrompt = (ctx: ExtensionContext): void => {
|
|
632
641
|
cancelIdlePrompt()
|
|
642
|
+
if (inSubagentChild()) return
|
|
633
643
|
const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
|
|
634
644
|
if (notifyCommands.length > 0) {
|
|
635
645
|
const runner = boundRunner(ctx)
|
|
@@ -639,21 +649,26 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
639
649
|
idlePromptTimer.unref?.()
|
|
640
650
|
}
|
|
641
651
|
}
|
|
652
|
+
/** In a subagent child, the agent-frontmatter Stop hooks were converted to SubagentStop
|
|
653
|
+
* and fire at the child's own end, notify-style, before the Stop early-returns, which do
|
|
654
|
+
* not apply to them. Only the agent's own frontmatter hooks (stamped agent:<name>): the
|
|
655
|
+
* settings-level SubagentStop is fired by the parent when the child completes, so the
|
|
656
|
+
* child firing it too ran it twice. */
|
|
657
|
+
const fireChildSubagentStop = async (event: { messages?: Array<{ role: string; content: unknown }> }, ctx: ExtensionContext): Promise<void> => {
|
|
658
|
+
if (!agentIdentity) return
|
|
659
|
+
const subStop = matchingCommands(config.SubagentStop, agentIdentity.agent).filter((command) => command.origin?.startsWith('agent:'))
|
|
660
|
+
if (subStop.length === 0) return
|
|
661
|
+
const subText = lastAssistantText(event.messages ?? [])
|
|
662
|
+
const subPayload = { hook_event_name: 'SubagentStop', agent_type: agentIdentity.agent, ...(agentIdentity.id ? { agent_id: agentIdentity.id } : {}), stop_hook_active: false, ...(subText ? { last_assistant_message: subText } : {}) }
|
|
663
|
+
await runNotifyHooks(subStop, subPayload, boundRunner(ctx)).catch(() => {})
|
|
664
|
+
}
|
|
665
|
+
|
|
642
666
|
pi.on('agent_end', async (event, ctx) => {
|
|
643
667
|
armIdlePrompt(ctx)
|
|
668
|
+
await fireChildSubagentStop(event as { messages?: Array<{ role: string; content: unknown }> }, ctx)
|
|
644
669
|
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
// Stop early-returns, which do not apply to them.
|
|
648
|
-
if (agentIdentity) {
|
|
649
|
-
const subStop = matchingCommands(config.SubagentStop, agentIdentity.agent)
|
|
650
|
-
if (subStop.length > 0) {
|
|
651
|
-
const subText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
|
|
652
|
-
const subPayload = { hook_event_name: 'SubagentStop', agent_type: agentIdentity.agent, ...(agentIdentity.id ? { agent_id: agentIdentity.id } : {}), stop_hook_active: false, ...(subText ? { last_assistant_message: subText } : {}) }
|
|
653
|
-
await runNotifyHooks(subStop, subPayload, boundRunner(ctx)).catch(() => {})
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
670
|
+
// Stop is the main agent's event; the child's completion was handled above.
|
|
671
|
+
if (inSubagentChild()) return
|
|
657
672
|
// Stop has no matcher support (a stray matcher is ignored, as Claude documents)
|
|
658
673
|
// and an `if`-carrying hook never runs on a non-tool event.
|
|
659
674
|
const commands = allCommands(config.Stop).filter((command) => passesIfFilter(command, undefined))
|
|
@@ -771,13 +786,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
771
786
|
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
772
787
|
// SessionEnd rides Claude's short shared budget (see sessionEndTimeoutMs) so a
|
|
773
788
|
// slow hook cannot stall session exit, /new or /resume.
|
|
774
|
-
const sessionEndCommands = matchingCommands(config.SessionEnd, reason.names).filter((command) => passesIfFilter(command, undefined))
|
|
789
|
+
const sessionEndCommands = inSubagentChild() ? [] : matchingCommands(config.SessionEnd, reason.names).filter((command) => passesIfFilter(command, undefined))
|
|
775
790
|
const runner = boundRunner(ctx)
|
|
776
791
|
const results = await Promise.all(sessionEndCommands.map((command) => runner(command, { hook_event_name: 'SessionEnd', reason: reason.value }, sessionEndTimeoutMs(command))))
|
|
777
792
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
778
|
-
// Claude
|
|
779
|
-
//
|
|
780
|
-
|
|
793
|
+
// Claude: "In non-interactive mode with the -p flag, Claude Code kills any async hook
|
|
794
|
+
// still running at teardown". Only there: an interactive session leaves them running, so
|
|
795
|
+
// an async SessionEnd hook, started by this very shutdown, can finish instead of being
|
|
796
|
+
// killed a microtask after it spawned. Their delivery targets a disposed context and is
|
|
797
|
+
// caught (see deliverBackgroundResult).
|
|
798
|
+
if (ctx.hasUI !== true) for (const kill of backgroundKills) kill()
|
|
781
799
|
backgroundKills.clear()
|
|
782
800
|
})
|
|
783
801
|
|
|
@@ -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
|
|
|
@@ -26,14 +26,15 @@ export interface TurnOverride<T> {
|
|
|
26
26
|
reset(): void
|
|
27
27
|
/** Restore the captured prior via `set` and clear the capture. A conditional override
|
|
28
28
|
* stands down when the current value has moved off the target. No-op when nothing is
|
|
29
|
-
* armed.
|
|
30
|
-
|
|
29
|
+
* armed. Returns what `set` returns, so a caller that must not exit before an async
|
|
30
|
+
* restore has landed (a shutdown) can await it. */
|
|
31
|
+
settle(): void | Promise<void>
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export function createTurnOverride<T>(opts: {
|
|
34
35
|
/** Applies the restore value. Consumers wrap their setter here (async-with-catch for
|
|
35
36
|
* commands' model, plain for the thinking level). */
|
|
36
|
-
set: (value: T) => void
|
|
37
|
+
set: (value: T) => void | Promise<void>
|
|
37
38
|
/** Reads the current value, for a conditional restore. Only consulted when
|
|
38
39
|
* `conditional` is set. */
|
|
39
40
|
get?: () => T | undefined
|
|
@@ -67,7 +68,7 @@ export function createTurnOverride<T>(opts: {
|
|
|
67
68
|
const current = opts.get?.()
|
|
68
69
|
if (current !== undefined && current !== movedTo) return
|
|
69
70
|
}
|
|
70
|
-
opts.set(restore)
|
|
71
|
+
return opts.set(restore)
|
|
71
72
|
},
|
|
72
73
|
}
|
|
73
74
|
}
|
|
@@ -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
|
|
@@ -13,7 +13,7 @@ import * as path from 'node:path'
|
|
|
13
13
|
import { killProcessTree } from '../internal/process-tree.js'
|
|
14
14
|
import { sharedSlot } from '../internal/shared-slot.js'
|
|
15
15
|
import { errorMessage } from '../internal/values.js'
|
|
16
|
-
import { spawnChild } from './run.js'
|
|
16
|
+
import { spawnChild, utf8Chunks } from './run.js'
|
|
17
17
|
|
|
18
18
|
export interface BackgroundRun {
|
|
19
19
|
id: string
|
|
@@ -350,13 +350,15 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
350
350
|
: undefined,
|
|
351
351
|
)
|
|
352
352
|
let stderrTail = ''
|
|
353
|
-
|
|
353
|
+
const decodeStdout = utf8Chunks()
|
|
354
|
+
const decodeStderr = utf8Chunks()
|
|
355
|
+
proc.stdout.on('data', (data) => parser.push(decodeStdout(data)))
|
|
354
356
|
// An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
|
|
355
357
|
// belongs to a detached child, so a pipe read failure would exit pi the same way an
|
|
356
358
|
// unguarded completion would. The foreground runner guards its streams the same way.
|
|
357
359
|
proc.stdout.on('error', () => {})
|
|
358
360
|
proc.stderr?.on('data', (data) => {
|
|
359
|
-
stderrTail = (stderrTail + data
|
|
361
|
+
stderrTail = (stderrTail + decodeStderr(data)).slice(-STDERR_TAIL_CHARS)
|
|
360
362
|
})
|
|
361
363
|
proc.stderr?.on('error', () => {})
|
|
362
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/extensions/thinking.ts
CHANGED
|
@@ -91,11 +91,17 @@ export default function thinkingExtension(pi: ExtensionAPI) {
|
|
|
91
91
|
// the keyword in the prompt.
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
// agent_settled never fires when pi is quit mid-run, and the escalation is already in the
|
|
95
|
+
// transcript: `pi -c` would resume at the raised level for good.
|
|
96
|
+
pi.on('session_shutdown', async () => {
|
|
97
|
+
await override.settle()
|
|
98
|
+
})
|
|
99
|
+
|
|
94
100
|
pi.on('agent_settled', () => {
|
|
95
101
|
// Restore the pre-escalation level, but only if nothing else moved it since (a
|
|
96
102
|
// command's effort restore, a manual change): the conditional override stands down in
|
|
97
103
|
// that case and restores unconditionally when the level cannot be read, the prior
|
|
98
104
|
// best-effort behavior.
|
|
99
|
-
override.settle()
|
|
105
|
+
void override.settle()
|
|
100
106
|
})
|
|
101
107
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.71",
|
|
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",
|