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.
- package/README.md +1 -1
- package/extensions/commands.ts +9 -6
- package/extensions/context-imports.ts +46 -24
- package/extensions/git-checkpoint.ts +141 -21
- package/extensions/hooks/config.ts +16 -10
- package/extensions/hooks/decisions.ts +9 -9
- package/extensions/hooks/index.ts +14 -10
- package/extensions/hooks/matcher.ts +12 -7
- package/extensions/hooks/runners.ts +17 -29
- package/extensions/internal/agent-run.ts +1 -1
- package/extensions/internal/bash-rules.ts +1 -2
- package/extensions/internal/claude-tool-names.ts +4 -7
- package/extensions/internal/command-file.ts +2 -3
- package/extensions/internal/external-imports.ts +4 -5
- package/extensions/internal/goal-evaluator.ts +4 -3
- package/extensions/internal/instruction-events.ts +5 -5
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +28 -6
- package/extensions/internal/model-complete.ts +7 -1
- package/extensions/internal/model-lookup.ts +10 -3
- package/extensions/internal/path-rules.ts +51 -41
- package/extensions/internal/plugins.ts +5 -5
- package/extensions/internal/process-tree.ts +39 -0
- package/extensions/internal/project-approval.ts +7 -7
- package/extensions/internal/project-root.ts +13 -13
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/settings-watch.ts +3 -1
- package/extensions/internal/shell-resolve.ts +19 -6
- package/extensions/internal/tool-target.ts +4 -5
- package/extensions/internal/values.ts +20 -4
- package/extensions/mcp/config.ts +11 -4
- package/extensions/mcp/index.ts +78 -19
- package/extensions/mcp/policy.ts +14 -12
- package/extensions/mcp/transport.ts +12 -8
- package/extensions/memory.ts +4 -4
- package/extensions/notify.ts +2 -7
- package/extensions/output-styles.ts +22 -17
- package/extensions/question.ts +142 -12
- package/extensions/session-title.ts +5 -0
- package/extensions/skills.ts +11 -6
- package/extensions/subagent/agents.ts +4 -4
- package/extensions/subagent/background.ts +35 -41
- package/extensions/subagent/child.ts +19 -4
- package/extensions/subagent/modes.ts +18 -13
- package/extensions/subagent/params.ts +1 -2
- package/extensions/subagent/run.ts +34 -20
- package/extensions/thinking.ts +3 -3
- package/extensions/web.ts +3 -4
- 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
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
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)))
|
package/extensions/question.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/extensions/skills.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)`
|
|
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
|
-
|
|
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.
|
|
213
|
-
// SubagentStart hooks placed their context ahead of it
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
if (proc.pid) process.kill(-proc.pid, signal)
|
|
298
|
-
else proc.kill(signal)
|
|
299
|
+
onComplete(run)
|
|
299
300
|
} catch {
|
|
300
|
-
|
|
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
|
-
|
|
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
|
}
|