pi-code 1.0.62 → 1.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/extensions/commands.ts +9 -6
  3. package/extensions/context-imports.ts +46 -24
  4. package/extensions/git-checkpoint.ts +141 -21
  5. package/extensions/hooks/config.ts +16 -10
  6. package/extensions/hooks/decisions.ts +9 -9
  7. package/extensions/hooks/index.ts +14 -10
  8. package/extensions/hooks/matcher.ts +12 -7
  9. package/extensions/hooks/runners.ts +17 -29
  10. package/extensions/internal/agent-run.ts +1 -1
  11. package/extensions/internal/bash-rules.ts +1 -2
  12. package/extensions/internal/claude-tool-names.ts +4 -7
  13. package/extensions/internal/command-file.ts +2 -3
  14. package/extensions/internal/external-imports.ts +4 -5
  15. package/extensions/internal/goal-evaluator.ts +4 -3
  16. package/extensions/internal/instruction-events.ts +5 -5
  17. package/extensions/internal/managed-settings.ts +1 -1
  18. package/extensions/internal/mcp-oauth.ts +28 -6
  19. package/extensions/internal/model-complete.ts +7 -1
  20. package/extensions/internal/model-lookup.ts +10 -3
  21. package/extensions/internal/path-rules.ts +51 -41
  22. package/extensions/internal/plugins.ts +5 -5
  23. package/extensions/internal/process-tree.ts +39 -0
  24. package/extensions/internal/project-approval.ts +7 -7
  25. package/extensions/internal/project-root.ts +13 -13
  26. package/extensions/internal/settings-chain.ts +19 -0
  27. package/extensions/internal/settings-watch.ts +3 -1
  28. package/extensions/internal/shell-resolve.ts +19 -6
  29. package/extensions/internal/tool-target.ts +4 -5
  30. package/extensions/internal/values.ts +20 -4
  31. package/extensions/mcp/config.ts +11 -4
  32. package/extensions/mcp/index.ts +78 -19
  33. package/extensions/mcp/policy.ts +14 -12
  34. package/extensions/mcp/transport.ts +12 -8
  35. package/extensions/memory.ts +4 -4
  36. package/extensions/notify.ts +2 -7
  37. package/extensions/output-styles.ts +22 -17
  38. package/extensions/question.ts +142 -12
  39. package/extensions/session-title.ts +5 -0
  40. package/extensions/skills.ts +11 -6
  41. package/extensions/subagent/agents.ts +4 -4
  42. package/extensions/subagent/background.ts +35 -41
  43. package/extensions/subagent/child.ts +19 -4
  44. package/extensions/subagent/modes.ts +18 -13
  45. package/extensions/subagent/params.ts +1 -2
  46. package/extensions/subagent/run.ts +34 -20
  47. package/extensions/thinking.ts +3 -3
  48. package/extensions/web.ts +3 -4
  49. package/package.json +1 -1
@@ -22,14 +22,15 @@
22
22
  import * as fs from 'node:fs'
23
23
  import * as os from 'node:os'
24
24
  import * as path from 'node:path'
25
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
25
+ import { type ExtensionAPI, parseFrontmatter } from '@earendil-works/pi-coding-agent'
26
26
  import { atomicWriteFile } from './internal/atomic-write.js'
27
+ import { isFlagEnabled } from './internal/command-file.js'
27
28
  import { claudeConfigDir } from './internal/config-dir.js'
28
29
  import { readManagedSettings } from './internal/managed-settings.js'
29
30
  import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
30
31
  import { isProjectApproved } from './internal/project-approval.js'
31
- import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
32
- import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
32
+ import { ancestorDirs } from './internal/project-root.js'
33
+ import { claudeSettingsChain, localSettingsFile, readSettingsChain } from './internal/settings-chain.js'
33
34
  import { isDirectory } from './internal/values.js'
34
35
 
35
36
  export interface OutputStyle {
@@ -41,22 +42,27 @@ export interface OutputStyle {
41
42
  forceForPlugin: boolean
42
43
  }
43
44
 
44
- function field(frontmatter: string, key: string): string {
45
- const match = new RegExp(String.raw`^\s*${key}\s*:\s*(.+)$`, 'm').exec(frontmatter)
46
- return match ? match[1].trim().replace(/^["']|["']$/g, '') : ''
45
+ /** A frontmatter field as text, or '' when absent or not scalar. */
46
+ function field(frontmatter: Record<string, unknown>, key: string): string {
47
+ const value = frontmatter[key]
48
+ if (typeof value === 'string') return value.trim()
49
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
50
+ return ''
47
51
  }
48
52
 
49
- /** Parse an output-style markdown file into its name, description, and body. */
53
+ /** Parse an output-style markdown file into its name, description, and body. pi's
54
+ * own YAML frontmatter parser, as the command loader uses: a line regex captured to
55
+ * end of line, so `"Terse" # short` came back as `Terse" # short`. */
50
56
  export function parseStyle(content: string, fallbackName: string): OutputStyle {
51
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
52
- const frontmatter = match ? match[1] : ''
53
- const body = match ? content.slice(match[0].length) : content
57
+ const { frontmatter, body } = parseFrontmatter(content)
54
58
  return {
55
59
  name: field(frontmatter, 'name') || fallbackName,
56
60
  description: field(frontmatter, 'description'),
57
61
  body: body.trim(),
58
- keepCodingInstructions: field(frontmatter, 'keep-coding-instructions') === 'true',
59
- forceForPlugin: field(frontmatter, 'force-for-plugin') === 'true',
62
+ // Both are YAML booleans (Claude: default `false`), so `yes`/`on`/`True` count as
63
+ // set, the same reading commands give their own frontmatter flags.
64
+ keepCodingInstructions: isFlagEnabled(field(frontmatter, 'keep-coding-instructions')),
65
+ forceForPlugin: isFlagEnabled(field(frontmatter, 'force-for-plugin')),
60
66
  }
61
67
  }
62
68
 
@@ -202,11 +208,10 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
202
208
  // Precedence low to high: builtin, plugin, then the user's and project's own
203
209
  // dirs, so a same-named user or project style overrides a plugin's.
204
210
  styles = loadStyles([BUILTIN_STYLES_DIR, ...pluginStyleDirs(home), ...styleDirs(ctx.cwd, home, trusted)])
205
- // Persist the choice where the read chain will find it again: the nearest local
206
- // settings file, else inside the nearest .claude directory, else at cwd.
207
- const nearestLocal = findNearestFile(ctx.cwd, path.join('.claude', 'settings.local.json'))
208
- const claudeDir = findNearestDir(ctx.cwd, '.claude') ?? path.join(ctx.cwd, '.claude')
209
- localSettingsPath = nearestLocal ?? path.join(claudeDir, 'settings.local.json')
211
+ // Persist the choice to the file the read chain reads last, so it is read back:
212
+ // the nearest local file or .claude directory could sit at an intermediate
213
+ // ancestor the chain never consults.
214
+ localSettingsPath = localSettingsFile(ctx.cwd, home)
210
215
  // Claude's force-for-plugin: the first loaded forced plugin style applies
211
216
  // automatically, overriding the outputStyle setting.
212
217
  const forced = forcedPluginStyle(loadStyles(pluginStyleDirs(home)))
@@ -6,9 +6,14 @@
6
6
  * Multiple questions per call are not batched; ask sequentially.
7
7
  */
8
8
 
9
+ import * as os from 'node:os'
10
+ import * as path from 'node:path'
9
11
  import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
10
12
  import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
11
13
  import { Type } from 'typebox'
14
+ import { claudeConfigDir } from './internal/config-dir.js'
15
+ import { readManagedSettings } from './internal/managed-settings.js'
16
+ import { readSettingsFile } from './internal/settings-chain.js'
12
17
 
13
18
  interface OptionWithDesc {
14
19
  label: string
@@ -24,6 +29,8 @@ interface QuestionDetails {
24
29
  answer: string | null
25
30
  wasCustom?: boolean
26
31
  multiSelect?: boolean
32
+ /** Auto-continued on askUserQuestionTimeout rather than answered or cancelled. */
33
+ timedOut?: boolean
27
34
  }
28
35
 
29
36
  // Options with labels and optional descriptions
@@ -70,6 +77,28 @@ function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec
70
77
  const HEADER_MAX = 12
71
78
  export const shortHeader = (header: string | undefined): string | undefined => (header === undefined ? undefined : header.slice(0, HEADER_MAX))
72
79
 
80
+ /** Claude's three accepted askUserQuestionTimeout spellings (`60s`, `5m`, `10m`), as
81
+ * milliseconds. Anything else, including unset, means no auto-continue. */
82
+ export function parseAskUserQuestionTimeout(value: unknown): number | undefined {
83
+ if (typeof value !== 'string') return undefined
84
+ const match = /^(\d+)([sm])$/.exec(value.trim())
85
+ if (!match) return undefined
86
+ const amount = Number(match[1])
87
+ return match[2] === 's' ? amount * 1000 : amount * 60 * 1000
88
+ }
89
+
90
+ /** Claude scopes askUserQuestionTimeout to "User or managed": a project's own
91
+ * settings.json cannot set it, so a checked-out repository can never make the
92
+ * user's own dialogs auto-answer themselves. Managed wins over the user's file, as
93
+ * every managed setting does. `home` defaults to the real one and is a parameter
94
+ * only so a test can point it at a fixture without mocking node:os. */
95
+ export function askUserQuestionTimeoutMs(home: string = os.homedir()): number | undefined {
96
+ const managed = readManagedSettings() as { askUserQuestionTimeout?: unknown }
97
+ const fromManaged = parseAskUserQuestionTimeout(managed.askUserQuestionTimeout)
98
+ if (fromManaged !== undefined) return fromManaged
99
+ return parseAskUserQuestionTimeout(readSettingsFile(path.join(claudeConfigDir(home), 'settings.json'))?.askUserQuestionTimeout)
100
+ }
101
+
73
102
  function checkbox(checked: boolean | undefined): string {
74
103
  if (checked === undefined) return ''
75
104
  return checked ? '[x] ' : '[ ] '
@@ -98,10 +127,13 @@ interface QuestionView {
98
127
  checked: boolean[]
99
128
  editor: Editor
100
129
  theme: Theme
130
+ /** Claude: "You see a countdown for the last 20 seconds." Undefined the rest of
131
+ * the idle window, and always when there is no configured timeout at all. */
132
+ countdownSeconds?: number
101
133
  }
102
134
 
103
135
  function buildQuestionLines(view: QuestionView): string[] {
104
- const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme } = view
136
+ const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds } = view
105
137
  const lines: string[] = []
106
138
  const add = (s: string) => lines.push(truncateToWidth(s, width))
107
139
 
@@ -129,6 +161,9 @@ function buildQuestionLines(view: QuestionView): string[] {
129
161
 
130
162
  lines.push('')
131
163
  add(theme.fg('dim', navHint(editMode, multiSelect)))
164
+ if (countdownSeconds !== undefined) {
165
+ add(theme.fg('warning', ` Auto-continuing in ${countdownSeconds}s if idle · press any key to stay`))
166
+ }
132
167
  add(theme.fg('accent', '─'.repeat(width)))
133
168
 
134
169
  return lines
@@ -202,6 +237,11 @@ export default function question(pi: ExtensionAPI) {
202
237
  return new Text(theme.fg('warning', 'Cancelled'), 0, 0)
203
238
  }
204
239
 
240
+ if (details.timedOut) {
241
+ const already = details.answer ? theme.fg('muted', ` (already selected: ${details.answer})`) : ''
242
+ return new Text(theme.fg('warning', '⏱ Auto-continued (no response)') + already, 0, 0)
243
+ }
244
+
205
245
  if (details.wasCustom) {
206
246
  return new Text(theme.fg('success', '✓ ') + theme.fg('muted', '(wrote) ') + theme.fg('accent', details.answer), 0, 0)
207
247
  }
@@ -240,8 +280,10 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
240
280
 
241
281
  // ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
242
282
  // undefined immediately, which would read as a cancel without ever asking. Ask
243
- // through the dialog primitives there instead.
244
- const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect) : await askViaDialogs(params, ctx, allOptions, multiSelect)
283
+ // through the dialog primitives there instead. askUserQuestionTimeout is a TUI
284
+ // concept (a countdown, a keypress resetting it): the dialog-primitive fallback
285
+ // has no keyboard or visible countdown to drive it, so it is not applied there.
286
+ const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs()) : await askViaDialogs(params, ctx, allOptions, multiSelect)
245
287
 
246
288
  // Build simple options list for details; header/multiSelect appear only when set,
247
289
  // so single-select details are unchanged.
@@ -255,6 +297,18 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
255
297
  }
256
298
  }
257
299
 
300
+ if (result.timedOut) {
301
+ // Claude: "tells Claude you may be away from your keyboard, so Claude proceeds
302
+ // on its own judgment and can re-ask later." Not framed as a cancel: `answer` is
303
+ // '' rather than null, so renderResult and a batch's own null-check both read it
304
+ // as "answered nothing, but not declined" rather than the user having said no.
305
+ const already = multiSelect && result.answer ? ` Already selected: ${result.answer}.` : ''
306
+ return {
307
+ content: [{ type: 'text', text: `No response after the configured idle timeout; the user may be away from the keyboard.${already} Proceed on your own judgment; you can ask again later if needed.` }],
308
+ details: { ...base, answer: result.answer, timedOut: true } as QuestionDetails,
309
+ }
310
+ }
311
+
258
312
  if (result.wasCustom) {
259
313
  return {
260
314
  content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
@@ -268,15 +322,82 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
268
322
  }
269
323
  }
270
324
 
271
- /** Terminal path: the full custom overlay (options list, checkboxes, inline editor). */
272
- function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number } | null> {
273
- return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number } | null) => void) => {
325
+ /** Claude: "You see a countdown for the last 20 seconds." */
326
+ const COUNTDOWN_WINDOW_MS = 20_000
327
+ /** Granularity of the idle-timer tick: fine enough that the countdown's displayed
328
+ * second changes on time, coarse enough not to re-render needlessly often. */
329
+ const IDLE_TICK_MS = 250
330
+
331
+ /** Terminal path: the full custom overlay (options list, checkboxes, inline editor).
332
+ *
333
+ * `timeoutMs`, when set, is Claude's askUserQuestionTimeout: "After a question sits
334
+ * that long with no input, the dialog closes on its own: it submits any options
335
+ * you'd already selected and tells Claude you may be away from your keyboard, so
336
+ * Claude proceeds on its own judgment and can re-ask later. You see a countdown for
337
+ * the last 20 seconds. Press any key to restart the timer." Terminal focus-in
338
+ * restarting the timer, the other documented reset trigger, is not implemented:
339
+ * pi's TUI input stream is not confirmed to carry the terminal's own focus-report
340
+ * escape sequences, and guessing at that risks misreading ordinary input as a
341
+ * focus event on a terminal that reports it differently. Exported so the timer
342
+ * mechanics are testable directly, independent of where timeoutMs itself is read
343
+ * from (askUserQuestionTimeoutMs, tested separately).
344
+ */
345
+ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
346
+ return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null) => void) => {
274
347
  let optionIndex = 0
275
348
  let editMode = false
276
349
  const checked: boolean[] = allOptions.map(() => false)
277
350
  let cachedLines: string[] | undefined
278
351
  let cachedWidth: number | undefined
279
352
 
353
+ // deadline stays undefined for the whole overlay life when no timeout is
354
+ // configured, so every idle-timer branch below is a no-op in that case.
355
+ let deadline: number | undefined = timeoutMs !== undefined ? Date.now() + timeoutMs : undefined
356
+ let idleTimer: ReturnType<typeof setInterval> | undefined
357
+ let lastCountdown: number | undefined
358
+
359
+ function stopIdleTimer(): void {
360
+ if (idleTimer !== undefined) clearInterval(idleTimer)
361
+ idleTimer = undefined
362
+ }
363
+
364
+ /** Every exit path (an answer, a cancel, or the timeout itself) goes through
365
+ * here, so the interval can never outlive the overlay it belongs to. */
366
+ function finish(value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null): void {
367
+ stopIdleTimer()
368
+ done(value)
369
+ }
370
+
371
+ function resetIdleTimer(): void {
372
+ if (timeoutMs === undefined) return
373
+ deadline = Date.now() + timeoutMs
374
+ }
375
+
376
+ function fireTimeout(): void {
377
+ // Claude: "submits any options you'd already selected". Single-select has
378
+ // nothing pre-committed (a selection only exists once Enter confirms it), so
379
+ // its timeout answer is empty rather than whatever option merely had focus.
380
+ const answer = multiSelect ? selectedLabels(allOptions, checked) : ''
381
+ finish({ answer, wasCustom: false, timedOut: true })
382
+ }
383
+
384
+ if (timeoutMs !== undefined) {
385
+ idleTimer = setInterval(() => {
386
+ if (deadline === undefined) return
387
+ const remainingMs = deadline - Date.now()
388
+ if (remainingMs <= 0) {
389
+ fireTimeout()
390
+ return
391
+ }
392
+ const remainingSeconds = Math.ceil(remainingMs / 1000)
393
+ const nextCountdown = remainingMs <= COUNTDOWN_WINDOW_MS ? remainingSeconds : undefined
394
+ if (nextCountdown !== lastCountdown) {
395
+ lastCountdown = nextCountdown
396
+ refresh()
397
+ }
398
+ }, IDLE_TICK_MS)
399
+ }
400
+
280
401
  const editorTheme: EditorTheme = {
281
402
  borderColor: (s) => theme.fg('accent', s),
282
403
  selectList: {
@@ -292,7 +413,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
292
413
  editor.onSubmit = (value) => {
293
414
  const trimmed = value.trim()
294
415
  if (trimmed) {
295
- done({ answer: trimmed, wasCustom: true })
416
+ finish({ answer: trimmed, wasCustom: true })
296
417
  } else {
297
418
  editMode = false
298
419
  editor.setText('')
@@ -306,6 +427,11 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
306
427
  }
307
428
 
308
429
  function handleInput(data: string) {
430
+ // Claude: "Press any key to restart the timer." Every branch below returns
431
+ // through this function, so resetting unconditionally on entry covers all of
432
+ // them, including the ones (arrow keys, space) that never reach `finish`.
433
+ resetIdleTimer()
434
+
309
435
  if (editMode) {
310
436
  if (matchesKey(data, Key.escape)) {
311
437
  editMode = false
@@ -337,7 +463,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
337
463
 
338
464
  if (matchesKey(data, Key.enter)) {
339
465
  if (multiSelect) {
340
- done({ answer: selectedLabels(allOptions, checked), wasCustom: false })
466
+ finish({ answer: selectedLabels(allOptions, checked), wasCustom: false })
341
467
  return
342
468
  }
343
469
  const selected = allOptions[optionIndex]
@@ -345,20 +471,20 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
345
471
  editMode = true
346
472
  refresh()
347
473
  } else {
348
- done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
474
+ finish({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
349
475
  }
350
476
  return
351
477
  }
352
478
 
353
479
  if (matchesKey(data, Key.escape)) {
354
- done(null)
480
+ finish(null)
355
481
  }
356
482
  }
357
483
 
358
484
  function render(width: number): string[] {
359
485
  if (cachedLines && cachedWidth === width) return cachedLines
360
486
  cachedWidth = width
361
- cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
487
+ cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds: lastCountdown })
362
488
  return cachedLines
363
489
  }
364
490
 
@@ -369,6 +495,10 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
369
495
  cachedLines = undefined
370
496
  },
371
497
  handleInput,
498
+ // Belt and suspenders alongside finish()'s own stopIdleTimer: if the host ever
499
+ // tears the overlay down through a path that does not go through `done`
500
+ // (finish's only caller), the interval still gets cleared here.
501
+ dispose: stopIdleTimer,
372
502
  }
373
503
  })
374
504
  }
@@ -376,7 +506,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
376
506
  /** Dialog-primitive fallback for UI without a terminal (RPC mode supports
377
507
  * select/input/notify but not custom components). Mirrors the overlay's result
378
508
  * shape; a dismissed dialog reads as a cancel, same as Escape in the overlay. */
379
- async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number } | null> {
509
+ async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
380
510
  const header = shortHeader(params.header)
381
511
  const title = header ? `[${header}] ${params.question}` : params.question
382
512
  // Number the labels: ctx.ui.select returns the chosen label string, so duplicate
@@ -93,6 +93,11 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
93
93
  })
94
94
 
95
95
  pi.on('agent_settled', async (_event, ctx) => {
96
+ // Claude: "Set to 1 to disable automatic terminal title updates based on conversation
97
+ // context. In Agent SDK and claude -p sessions, this also skips the background
98
+ // small/fast-model request that generates the session title." setSessionName is pi's
99
+ // only title sink, so skipping the call here skips both effects at once.
100
+ if (process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE === '1') return
96
101
  if (titled) return
97
102
  // Never clobber an existing name: a user-chosen or resumed name wins.
98
103
  if (pi.getSessionName?.()) return
@@ -35,10 +35,6 @@ import { claudeSettingsChain, readSettingsChain } from './internal/settings-chai
35
35
  import { SKILL_HOOKS_CHANNEL } from './internal/skill-hooks.js'
36
36
  import { errorMessage, isDirectory, isRecord } from './internal/values.js'
37
37
 
38
- /** Existing `.claude/skills` directories, user first then project. The project
39
- * directory is included only for approved projects: pi's loader surfaces every skill's
40
- * name and description to the model, so an untrusted repository would otherwise get
41
- * text into the prompt without the user ever agreeing to load its config. */
42
38
  /** The extra skill directories a manifest declares, as a list. A string is one entry,
43
39
  * a list is itself, anything else declares none. */
44
40
  function declaredSkillDirs(declared: unknown): string[] {
@@ -46,11 +42,20 @@ function declaredSkillDirs(declared: unknown): string[] {
46
42
  return typeof declared === 'string' ? [declared] : []
47
43
  }
48
44
 
45
+ /** Existing `.claude/skills` directories, user first then project. The project
46
+ * directory is included only for approved projects: pi's loader surfaces every skill's
47
+ * name and description to the model, so an untrusted repository would otherwise get
48
+ * text into the prompt without the user ever agreeing to load its config. */
49
49
  export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
50
50
  // Claude's precedence: enterprise (the skills directory beside the managed
51
51
  // settings file) overrides personal, and personal overrides project; discovery
52
52
  // here is first-match, so higher precedence goes first.
53
- const candidates = [path.join(path.dirname(managedSettingsFile()), '.claude', 'skills'), path.join(claudeConfigDir(home), 'skills')]
53
+ // Claude: "Set to 1 to skip loading skills from the system-wide managed skills
54
+ // directory. Useful for container or CI sessions that should not load
55
+ // operator-provisioned skills." The enterprise dir beside managed-settings.json is
56
+ // that directory here; personal and project skills are unaffected.
57
+ const enterprise = process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS === '1' ? [] : [path.join(path.dirname(managedSettingsFile()), '.claude', 'skills')]
58
+ const candidates = [...enterprise, path.join(claudeConfigDir(home), 'skills')]
54
59
  // Enabled plugins contribute their skills directories. pi's loader names a
55
60
  // skill by its directory, so a plugin skill registers without Claude's
56
61
  // /plugin: prefix; a rename-free approximation, disclosed in the README.
@@ -108,7 +113,7 @@ function skillAt(root: string, dirName: string): { name: string; filePath: strin
108
113
 
109
114
  /** A Claude-contributed skill by the name pi's loader gives it. One directory
110
115
  * level, the standard layout. */
111
- export function findClaudeSkill(name: string, roots: string[]): FoundSkill | undefined {
116
+ function findClaudeSkill(name: string, roots: string[]): FoundSkill | undefined {
112
117
  for (const root of roots) {
113
118
  let entries: fs.Dirent[]
114
119
  try {
@@ -11,6 +11,7 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
11
11
  // is not merely ignored, it narrows the child's registry.
12
12
  import { parseToolGrants } from '../internal/command-file.js'
13
13
  import { claudeConfigDir } from '../internal/config-dir.js'
14
+ import { findModel } from '../internal/model-lookup.js'
14
15
  import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
15
16
  import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
16
17
  import { errorMessage } from '../internal/values.js'
@@ -29,7 +30,7 @@ import { errorMessage } from '../internal/values.js'
29
30
  function parseToolsField(raw: unknown, granting: boolean): string[] | undefined | null {
30
31
  if (raw === undefined) return undefined
31
32
  // Shares the command parser's splitting, so a comma inside an argument scope stays
32
- // inside it here too: `Bash(mv, write, cp)` used to hand the child pi's real `write`.
33
+ // inside it here too: split naively, `Bash(mv, write, cp)` grants the child pi's real `write`.
33
34
  if (raw !== null && !Array.isArray(raw) && typeof raw !== 'string') return null
34
35
  if (Array.isArray(raw) && raw.some((item) => typeof item !== 'string')) return null
35
36
  const grants = parseToolGrants(raw)
@@ -65,10 +66,9 @@ function parseModelAlias(raw: unknown): string | undefined {
65
66
  /** Resolve a Claude tier alias to a concrete model id the user can actually run.
66
67
  * Returning undefined leaves the child on the session model, which is what the
67
68
  * unresolvable case degraded to before and still does. */
68
- export function resolveModelAlias(alias: string | undefined, available: ReadonlyArray<{ id: string; provider?: string }>): string | undefined {
69
+ export function resolveModelAlias(alias: string | undefined, available: ReadonlyArray<{ id: string; name?: string; provider?: string }>): string | undefined {
69
70
  if (!alias || alias === 'inherit') return undefined
70
- const needle = alias.toLowerCase()
71
- return available.find((model) => model.id.toLowerCase().includes(needle))?.id
71
+ return findModel(alias, available)?.id
72
72
  }
73
73
 
74
74
  /** pi's extended thinking levels; Claude's effort values are a subset, so they map 1:1. */
@@ -6,12 +6,13 @@
6
6
  * across a same-process session switch keeps going under the new session.
7
7
  */
8
8
 
9
- import { spawn } from 'node:child_process'
10
9
  import { randomUUID } from 'node:crypto'
11
10
  import * as fs from 'node:fs'
12
11
  import * as os from 'node:os'
13
12
  import * as path from 'node:path'
13
+ import { killProcessTree } from '../internal/process-tree.js'
14
14
  import { errorMessage } from '../internal/values.js'
15
+ import { spawnChild } from './run.js'
15
16
 
16
17
  export interface BackgroundRun {
17
18
  id: string
@@ -209,8 +210,8 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
209
210
  run.spawn = { ...run.spawn, args: rebuilt.args }
210
211
  if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
211
212
  // The task prompt is always the final argument: both spawn paths push it last and the
212
- // invocation helper only prepends. Matching a 'Task: ' prefix missed it whenever
213
- // SubagentStart hooks placed their context ahead of it, so the resume re-ran the old task.
213
+ // invocation helper only prepends. A 'Task: ' prefix match is wrong whenever
214
+ // SubagentStart hooks placed their context ahead of it.
214
215
  const args = rebuilt.args.map((arg, index) => (index === rebuilt.args.length - 1 ? `Task: ${task}` : arg))
215
216
  run.state = 'running'
216
217
  run.task = task
@@ -281,29 +282,41 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
281
282
 
282
283
  /** Spawn the child for a run and wire its lifecycle back onto the record. */
283
284
  function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): void {
284
- const proc = spawn(invocation.command, invocation.args, {
285
- cwd: invocation.cwd,
286
- shell: false,
287
- stdio: ['ignore', 'pipe', 'pipe'],
288
- // Its own group, so cancelling reaches any grandchild the agent spawned.
289
- detached: true,
290
- // The marker lets the child's subagent tool refuse to nest further.
291
- env: { ...process.env, PI_CODE_SUBAGENT: '1', ...invocation.env },
292
- })
293
- run.live = true
294
- const killGroup = (signal: NodeJS.Signals): void => {
285
+ // Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
286
+ let completed = false
287
+ const complete = (): void => {
288
+ if (completed) return
289
+ completed = true
290
+ run.finishedAt = ++finishSequence
291
+ evictFinishedRuns()
292
+ // A run outlives the session that started it, and pi's loader wires assertActive()
293
+ // into every runtime call, so notifying a disposed session throws. This fires from
294
+ // the child's 'close'/'error' listener, where nothing upstream catches: an escaping
295
+ // error reaches Node as an uncaughtException and takes pi down with it. The run
296
+ // state is already recorded by this point, so there is nothing to do but drop the
297
+ // notification for a session that is no longer there to receive it.
295
298
  try {
296
- // A child that never spawned has no pid and no group; the direct kill is all there is.
297
- if (proc.pid) process.kill(-proc.pid, signal)
298
- else proc.kill(signal)
299
+ onComplete(run)
299
300
  } catch {
300
- try {
301
- proc.kill(signal)
302
- } catch {
303
- // already gone
304
- }
301
+ // the session that asked for this run is gone
305
302
  }
306
303
  }
304
+ // The marker in env lets the child's subagent tool refuse to nest further. The run is
305
+ // already registered as running by the caller, so a spawn that throws synchronously
306
+ // (see spawnChild) must settle the record here: left as it was, the phantom held a cap
307
+ // slot with no kill and no eviction for the life of the process.
308
+ const spawned = spawnChild(invocation.command, invocation.args, { cwd: invocation.cwd, env: { ...process.env, PI_CODE_SUBAGENT: '1', ...invocation.env } })
309
+ if ('error' in spawned) {
310
+ run.live = false
311
+ run.state = 'failed'
312
+ run.exitCode = 1
313
+ run.stderr = spawned.error.message
314
+ complete()
315
+ return
316
+ }
317
+ const proc = spawned.proc
318
+ run.live = true
319
+ const killGroup = (signal: NodeJS.Signals): void => killProcessTree(proc, signal)
307
320
  run.kill = () => {
308
321
  killGroup('SIGTERM')
309
322
  // A child ignoring SIGTERM would hold its cap slot and process forever.
@@ -330,25 +343,6 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
330
343
  : undefined,
331
344
  )
332
345
  let stderrTail = ''
333
- // Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
334
- let completed = false
335
- const complete = (): void => {
336
- if (completed) return
337
- completed = true
338
- run.finishedAt = ++finishSequence
339
- evictFinishedRuns()
340
- // A run outlives the session that started it, and pi's loader wires assertActive()
341
- // into every runtime call, so notifying a disposed session throws. This fires from
342
- // the child's 'close'/'error' listener, where nothing upstream catches: an escaping
343
- // error reaches Node as an uncaughtException and takes pi down with it. The run
344
- // state is already recorded by this point, so there is nothing to do but drop the
345
- // notification for a session that is no longer there to receive it.
346
- try {
347
- onComplete(run)
348
- } catch {
349
- // the session that asked for this run is gone
350
- }
351
- }
352
346
  proc.stdout.on('data', (data) => parser.push(data.toString()))
353
347
  // An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
354
348
  // belongs to a detached child, so a pipe read failure would exit pi the same way an
@@ -12,6 +12,7 @@ import * as path from 'node:path'
12
12
 
13
13
  import type { AgentRunRequest } from '../internal/agent-run.js'
14
14
  import { claudeConfigDir } from '../internal/config-dir.js'
15
+ import { sliceBytes } from '../internal/output-guard.js'
15
16
  import { repoRoot } from '../internal/project-root.js'
16
17
  import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
17
18
  import { type AgentConfig, type AgentMemoryScope, expandMcpToolPatterns, withPreloadedSkills } from './agents.js'
@@ -26,8 +27,6 @@ export const AGENT_HOOK_SYSTEM = [
26
27
  'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
27
28
  ].join('\n')
28
29
 
29
- /** A throwaway agent config for one agent-hook run: read-only inspection tools, the
30
- * hook's model (a fast default when unset), and the decision-returning system prompt. */
31
30
  /** The agent a context: fork skill runs as when it names none: full toolset, no
32
31
  * extra system prompt (the child keeps pi's default), the skill content as the
33
32
  * task. */
@@ -42,6 +41,8 @@ export function forkAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt
42
41
  }
43
42
  }
44
43
 
44
+ /** A throwaway agent config for one agent-hook run: read-only inspection tools, the
45
+ * hook's model (a fast default when unset), and the decision-returning system prompt. */
45
46
  export function buildHookAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
46
47
  return {
47
48
  name: 'agent-hook',
@@ -188,10 +189,24 @@ export function agentHooksEnv(agent: AgentConfig, agentId: string): Record<strin
188
189
  return { PI_CODE_AGENT_HOOKS: JSON.stringify({ agent: agent.name, id: agentId, hooks }) }
189
190
  }
190
191
 
192
+ /** This string becomes the whole of one argv element to the spawned child
193
+ * (run.ts, `spawn(..., { shell: false })`). Linux's MAX_ARG_STRLEN, a per-argument
194
+ * limit distinct from the much larger total ARG_MAX, is 128KiB; confirmed on a real
195
+ * Linux host that a single argv string over it fails execve with E2BIG. Neither the
196
+ * model's task text nor a SubagentStart hook's additionalContext is capped
197
+ * upstream, so this is where the assembled string caps itself. The budget leaves
198
+ * headroom under the hard limit for the "Task: " prefix, the notice below, and
199
+ * platforms whose limit differs from Linux's. */
200
+ const TASK_ARGV_MAX_BYTES = 96 * 1024
201
+
191
202
  /** The task argument with any SubagentStart hook context ahead of it, per Claude:
192
203
  * "added to the subagent's context at the start of its conversation, before its
193
- * first prompt". */
204
+ * first prompt". Capped as one combined string, since either the context or the
205
+ * task alone can already be oversized. */
194
206
  export function taskWithStartContext(task: string, contexts: string[]): string {
195
207
  const context = contexts.filter(Boolean).join('\n')
196
- return context ? `${context}\n\nTask: ${task}` : `Task: ${task}`
208
+ const assembled = context ? `${context}\n\nTask: ${task}` : `Task: ${task}`
209
+ if (Buffer.byteLength(assembled, 'utf-8') <= TASK_ARGV_MAX_BYTES) return assembled
210
+ const kept = sliceBytes(assembled, TASK_ARGV_MAX_BYTES)
211
+ return `${kept}\n\n[truncated: too long for the child process to receive]`
197
212
  }