pi-code 1.0.61 → 1.0.63

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.
@@ -49,7 +49,7 @@ import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope,
49
49
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
50
50
  import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
51
51
  import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
52
- import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isUnauthorized, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
52
+ import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isUnauthorized, mcpConnectTimeoutMs, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
53
53
 
54
54
  export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
55
55
  // Re-exports for consumers: the module split keeps the extension's public surface
@@ -85,6 +85,10 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
85
85
 
86
86
  export default async function mcpExtension(pi: ExtensionAPI) {
87
87
  const clients = new Map<string, Client>()
88
+ // The session's OAuth UI seams, captured at session_start. The reconnect paths below
89
+ // run outside that handler, and passing undefined there made an INTERACTIVE session
90
+ // report the headless "cannot log in" advice on a re-auth it could actually perform.
91
+ let sessionAuthUi: AuthUi | undefined
88
92
  const status = new Map<string, { state: string; tools: number }>()
89
93
  // Config per server name, kept for call-time timeout tuning: the idle tier follows
90
94
  // the transport kind, and a declared per-server timeout governs the wall budget.
@@ -178,14 +182,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
178
182
  /** Claude's mid-session reconnect for a dropped remote server: five attempts with
179
183
  * a delay doubling from one second. connectServers redoes the full bring-up
180
184
  * (tools, prompts, subscriptions, a fresh onclose) and its duplicate guard skips
181
- * out if another path already reconnected the name. Runs without authUi: a server
182
- * that now needs a login ends failed, and after the fifth failure the last
183
- * attempt's failed status stands, with a session restart as the manual retry. */
185
+ * out if another path already reconnected the name. Uses the session's authUi, so a
186
+ * server that now needs a login can prompt for it in an interactive session; headless
187
+ * still ends failed, and after the fifth failure the last attempt's failed status
188
+ * stands, with a session restart as the manual retry. */
184
189
  async function reconnectWithBackoff(name: string, config: ServerConfig): Promise<void> {
185
190
  for (let attempt = 0; attempt < 5; attempt++) {
186
191
  await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt))
187
192
  if (shuttingDown || clients.has(name)) return
188
- await connectServers({ [name]: config }, undefined, true)
193
+ await connectServers({ [name]: config }, sessionAuthUi, true)
189
194
  if (clients.has(name)) return
190
195
  }
191
196
  }
@@ -200,7 +205,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
200
205
  clients.delete(name)
201
206
  await withTimeout(old.close(), 3000, 'close').catch(() => {})
202
207
  }
203
- await connectServers({ [name]: config }, undefined, true)
208
+ await connectServers({ [name]: config }, sessionAuthUi, true)
204
209
  }
205
210
 
206
211
  /** A tool call with the auth retry: on a 401/403 rejection, reconnect once and
@@ -558,6 +563,23 @@ export default async function mcpExtension(pi: ExtensionAPI) {
558
563
  if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
559
564
  }
560
565
 
566
+ /** Publishes the current alias set and the connection banner. `'connecting'`
567
+ * servers are excluded from both connected and failed counts: they are neither,
568
+ * and while the non-blocking startup race below is still in flight some legitimately
569
+ * are. Called once right after startup (whether or not every server settled by
570
+ * then) and again when a still-connecting tail finishes, so a server that connects
571
+ * late still gets its aliases published and counted. */
572
+ function publishConnectionSummary(ctx: ExtensionContext): void {
573
+ pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
574
+ const connected = [...status.values()].filter((s) => s.state === 'connected')
575
+ const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected' && s.state !== 'connecting')
576
+ if (connected.length > 0 || failed.length > 0) {
577
+ const total = connected.reduce((sum, s) => sum + s.tools, 0)
578
+ const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
579
+ ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
580
+ }
581
+ }
582
+
561
583
  pi.on('session_start', async (_event, ctx) => {
562
584
  // Reset the status map so /mcp and the banner reflect only this session's config: a
563
585
  // server present last session but not this one must not linger as "connected". The
@@ -574,8 +596,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
574
596
  projectConnected = false
575
597
  // Claude answers roots/list with the session's launch directory and exports the
576
598
  // project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
577
- sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
599
+ sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd, sessionId: ctx.sessionManager?.getSessionId?.() }
578
600
  const authUi = authUiFor(ctx)
601
+ sessionAuthUi = authUi
579
602
  // The allow/deny lists filter every scope, including a managed-mcp.json set. They
580
603
  // merge from managed settings plus the trust-gated settings chain, as Claude
581
604
  // documents (a repo's file counts only once the project is approved).
@@ -586,21 +609,27 @@ export default async function mcpExtension(pi: ExtensionAPI) {
586
609
  // file leaves the normal scopes untouched; a present but corrupt file fails closed to an
587
610
  // empty set (see loadManagedMcpServers).
588
611
  const managed = loadManagedMcpServers()
589
- if (managed !== null) {
590
- await connectManagedExclusive(managed, policy, authUi)
591
- } else {
592
- await connectNormalScopes(ctx, policy, authUi)
593
- }
594
-
595
- pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
596
-
597
- const connected = [...status.values()].filter((s) => s.state === 'connected')
598
- const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected')
599
- if (connected.length > 0 || failed.length > 0) {
600
- const total = connected.reduce((sum, s) => sum + s.tools, 0)
601
- const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
602
- ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
603
- }
612
+ const connecting = managed !== null ? connectManagedExclusive(managed, policy, authUi) : connectNormalScopes(ctx, policy, authUi)
613
+ // Claude: "MCP startup is non-blocking by default: servers connect in the
614
+ // background and their tools become available as they finish." A slow or
615
+ // unreachable server no longer costs the whole session's startup its own
616
+ // MCP_TIMEOUT x retries; alwaysLoad-style forced waiting and the ToolSearch/
617
+ // WaitForMcpServers mechanism that lets the model itself wait on a pending
618
+ // server's tools are not implemented, so a call to a tool that has not
619
+ // registered yet still just finds no such tool, same as before it connected.
620
+ //
621
+ // Claude also states non-interactive mode "waits for still-pending servers
622
+ // before the first turn regardless of this variable", because a -p run has no
623
+ // later turn to react to late-arriving tools; a headless ctx (no UI) keeps the
624
+ // prior fully-blocking wait for the same reason.
625
+ const settled = !ctx.hasUI
626
+ ? await connecting.then(() => true)
627
+ : await withTimeout(connecting, mcpConnectTimeoutMs(), 'mcp startup').then(
628
+ () => true,
629
+ () => false,
630
+ )
631
+ publishConnectionSummary(ctx)
632
+ if (!settled) void connecting.then(() => publishConnectionSummary(ctx))
604
633
  })
605
634
 
606
635
  pi.on('session_shutdown', async () => {
@@ -64,7 +64,7 @@ export async function runInteractiveOAuth(name: string, config: { url: string; o
64
64
  config.oauth,
65
65
  config.url,
66
66
  )
67
- const { server, port } = await startCallbackServer(provider.savedRedirectPort())
67
+ const { server, port } = await startCallbackServer(provider.savedRedirectPort(), provider.configuredRedirectPort() !== undefined)
68
68
  provider.bindRedirectPort(port)
69
69
  try {
70
70
  const transport = makeTransport(provider)
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { execFile } from 'node:child_process'
8
+ import * as fs from 'node:fs'
8
9
  import { pathToFileURL } from 'node:url'
9
10
  // SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
10
11
  // the old spec exist, so this stays as a fallback for the migration period.
@@ -54,6 +55,12 @@ function envTimeout(name: string, fallback: number): number {
54
55
  export const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
55
56
  export const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
56
57
 
58
+ // Claude: "How long blocking MCP startup waits, in milliseconds, for the connection
59
+ // batch before snapshotting the tool list (default: 5000)." Distinct from MCP_TIMEOUT,
60
+ // which bounds one server's own connect attempt, not the whole startup batch.
61
+ const DEFAULT_MCP_CONNECT_TIMEOUT_MS = 5000
62
+ export const mcpConnectTimeoutMs = (): number => envTimeout('MCP_CONNECT_TIMEOUT_MS', DEFAULT_MCP_CONNECT_TIMEOUT_MS)
63
+
57
64
  /** Per-server inputs to the idle-window choice: the transport kind picks the default
58
65
  * tier, and a per-server `timeout` of at least 1000 also floors the idle window. */
59
66
  export interface ServerCallTuning {
@@ -136,6 +143,9 @@ function isStdio(config: ServerConfig): config is StdioServerConfig {
136
143
  export interface SessionDirs {
137
144
  projectDir: string
138
145
  launchDir: string
146
+ /** Claude: "An MCP server subprocess retains the ID it was spawned with", so this
147
+ * is captured once here at connect time rather than re-read per call. */
148
+ sessionId?: string
139
149
  }
140
150
 
141
151
  /** A client that, like Claude, declares the roots capability and answers roots/list
@@ -182,15 +192,26 @@ function helperEnv(name: string, config: HttpServerConfig): NodeJS.ProcessEnv {
182
192
  }
183
193
 
184
194
  /** The env a stdio server process starts with: the SDK allowlist, the config's own
185
- * env block, and Claude's path variables (CLAUDE_PROJECT_DIR, and CLAUDE_PLUGIN_ROOT
186
- * for a plugin's server). */
195
+ * env block, and Claude's three path variables. Claude: "All three are exported as
196
+ * environment variables to hook processes and to MCP and LSP server subprocesses." */
187
197
  function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, session?: SessionDirs): Record<string, string> {
188
198
  // CLAUDECODE marks every subprocess; the long-lived server deliberately gets no
189
199
  // CLAUDE_CODE_CHILD_SESSION, which Claude reserves for per-call children.
190
200
  const env: Record<string, string> = { ...getDefaultEnvironment(), CLAUDECODE: '1' }
191
201
  for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
192
202
  if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
203
+ if (session?.sessionId) env.CLAUDE_CODE_SESSION_ID = session.sessionId
193
204
  if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
205
+ // The data dir is "created on first reference"; handing the path to a server is that
206
+ // reference, so the server does not have to mkdir it before using it.
207
+ if (config.pluginDataDir !== undefined) {
208
+ env.CLAUDE_PLUGIN_DATA = config.pluginDataDir
209
+ try {
210
+ fs.mkdirSync(config.pluginDataDir, { recursive: true })
211
+ } catch {
212
+ // The server still starts; one that needs the directory reports its own failure.
213
+ }
214
+ }
194
215
  return env
195
216
  }
196
217
 
@@ -234,7 +255,12 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
234
255
  await connectWithTimeout(client, transport, `connect ${name}`)
235
256
  return client
236
257
  }
237
- const url = new URL(fill(config.url))
258
+ // An absent or blank `url` is a server nobody finished configuring, not a malformed
259
+ // one. `new URL('')` throws "Invalid URL", which reads as a typo in a real address and
260
+ // sends people looking for one; name the actual state instead.
261
+ const rawUrl = fill(config.url ?? '').trim()
262
+ if (rawUrl === '') throw new Error(`${name} is not configured: it has no url`)
263
+ const url = new URL(rawUrl)
238
264
  if (config.type === 'ws' || config.type === 'websocket') {
239
265
  // The SDK's WebSocket transport takes only a url: it carries no headers, bearer
240
266
  // token, or headersHelper output. Warn rather than silently dropping configured
@@ -6,9 +6,14 @@
6
6
  * Multiple questions per call are not batched; ask sequentially.
7
7
  */
8
8
 
9
+ import * as fs from 'node:fs'
10
+ import * as os from 'node:os'
11
+ import * as path from 'node:path'
9
12
  import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
10
13
  import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
11
14
  import { Type } from 'typebox'
15
+ import { claudeConfigDir } from './internal/config-dir.js'
16
+ import { readManagedSettings } from './internal/managed-settings.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,33 @@ 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
+ try {
100
+ const parsed = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8')) as { askUserQuestionTimeout?: unknown }
101
+ return parseAskUserQuestionTimeout(parsed.askUserQuestionTimeout)
102
+ } catch {
103
+ return undefined
104
+ }
105
+ }
106
+
73
107
  function checkbox(checked: boolean | undefined): string {
74
108
  if (checked === undefined) return ''
75
109
  return checked ? '[x] ' : '[ ] '
@@ -98,10 +132,13 @@ interface QuestionView {
98
132
  checked: boolean[]
99
133
  editor: Editor
100
134
  theme: Theme
135
+ /** Claude: "You see a countdown for the last 20 seconds." Undefined the rest of
136
+ * the idle window, and always when there is no configured timeout at all. */
137
+ countdownSeconds?: number
101
138
  }
102
139
 
103
140
  function buildQuestionLines(view: QuestionView): string[] {
104
- const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme } = view
141
+ const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds } = view
105
142
  const lines: string[] = []
106
143
  const add = (s: string) => lines.push(truncateToWidth(s, width))
107
144
 
@@ -129,6 +166,9 @@ function buildQuestionLines(view: QuestionView): string[] {
129
166
 
130
167
  lines.push('')
131
168
  add(theme.fg('dim', navHint(editMode, multiSelect)))
169
+ if (countdownSeconds !== undefined) {
170
+ add(theme.fg('warning', ` Auto-continuing in ${countdownSeconds}s if idle · press any key to stay`))
171
+ }
132
172
  add(theme.fg('accent', '─'.repeat(width)))
133
173
 
134
174
  return lines
@@ -202,6 +242,11 @@ export default function question(pi: ExtensionAPI) {
202
242
  return new Text(theme.fg('warning', 'Cancelled'), 0, 0)
203
243
  }
204
244
 
245
+ if (details.timedOut) {
246
+ const already = details.answer ? theme.fg('muted', ` (already selected: ${details.answer})`) : ''
247
+ return new Text(theme.fg('warning', '⏱ Auto-continued (no response)') + already, 0, 0)
248
+ }
249
+
205
250
  if (details.wasCustom) {
206
251
  return new Text(theme.fg('success', '✓ ') + theme.fg('muted', '(wrote) ') + theme.fg('accent', details.answer), 0, 0)
207
252
  }
@@ -240,8 +285,10 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
240
285
 
241
286
  // ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
242
287
  // 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)
288
+ // through the dialog primitives there instead. askUserQuestionTimeout is a TUI
289
+ // concept (a countdown, a keypress resetting it): the dialog-primitive fallback
290
+ // has no keyboard or visible countdown to drive it, so it is not applied there.
291
+ const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs()) : await askViaDialogs(params, ctx, allOptions, multiSelect)
245
292
 
246
293
  // Build simple options list for details; header/multiSelect appear only when set,
247
294
  // so single-select details are unchanged.
@@ -255,6 +302,18 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
255
302
  }
256
303
  }
257
304
 
305
+ if (result.timedOut) {
306
+ // Claude: "tells Claude you may be away from your keyboard, so Claude proceeds
307
+ // on its own judgment and can re-ask later." Not framed as a cancel: `answer` is
308
+ // '' rather than null, so renderResult and a batch's own null-check both read it
309
+ // as "answered nothing, but not declined" rather than the user having said no.
310
+ const already = multiSelect && result.answer ? ` Already selected: ${result.answer}.` : ''
311
+ return {
312
+ 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.` }],
313
+ details: { ...base, answer: result.answer, timedOut: true } as QuestionDetails,
314
+ }
315
+ }
316
+
258
317
  if (result.wasCustom) {
259
318
  return {
260
319
  content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
@@ -268,15 +327,82 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
268
327
  }
269
328
  }
270
329
 
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) => {
330
+ /** Claude: "You see a countdown for the last 20 seconds." */
331
+ const COUNTDOWN_WINDOW_MS = 20_000
332
+ /** Granularity of the idle-timer tick: fine enough that the countdown's displayed
333
+ * second changes on time, coarse enough not to re-render needlessly often. */
334
+ const IDLE_TICK_MS = 250
335
+
336
+ /** Terminal path: the full custom overlay (options list, checkboxes, inline editor).
337
+ *
338
+ * `timeoutMs`, when set, is Claude's askUserQuestionTimeout: "After a question sits
339
+ * that long with no input, the dialog closes on its own: it submits any options
340
+ * you'd already selected and tells Claude you may be away from your keyboard, so
341
+ * Claude proceeds on its own judgment and can re-ask later. You see a countdown for
342
+ * the last 20 seconds. Press any key to restart the timer." Terminal focus-in
343
+ * restarting the timer, the other documented reset trigger, is not implemented:
344
+ * pi's TUI input stream is not confirmed to carry the terminal's own focus-report
345
+ * escape sequences, and guessing at that risks misreading ordinary input as a
346
+ * focus event on a terminal that reports it differently. Exported so the timer
347
+ * mechanics are testable directly, independent of where timeoutMs itself is read
348
+ * from (askUserQuestionTimeoutMs, tested separately).
349
+ */
350
+ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
351
+ 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
352
  let optionIndex = 0
275
353
  let editMode = false
276
354
  const checked: boolean[] = allOptions.map(() => false)
277
355
  let cachedLines: string[] | undefined
278
356
  let cachedWidth: number | undefined
279
357
 
358
+ // deadline stays undefined for the whole overlay life when no timeout is
359
+ // configured, so every idle-timer branch below is a no-op in that case.
360
+ let deadline: number | undefined = timeoutMs !== undefined ? Date.now() + timeoutMs : undefined
361
+ let idleTimer: ReturnType<typeof setInterval> | undefined
362
+ let lastCountdown: number | undefined
363
+
364
+ function stopIdleTimer(): void {
365
+ if (idleTimer !== undefined) clearInterval(idleTimer)
366
+ idleTimer = undefined
367
+ }
368
+
369
+ /** Every exit path (an answer, a cancel, or the timeout itself) goes through
370
+ * here, so the interval can never outlive the overlay it belongs to. */
371
+ function finish(value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null): void {
372
+ stopIdleTimer()
373
+ done(value)
374
+ }
375
+
376
+ function resetIdleTimer(): void {
377
+ if (timeoutMs === undefined) return
378
+ deadline = Date.now() + timeoutMs
379
+ }
380
+
381
+ function fireTimeout(): void {
382
+ // Claude: "submits any options you'd already selected". Single-select has
383
+ // nothing pre-committed (a selection only exists once Enter confirms it), so
384
+ // its timeout answer is empty rather than whatever option merely had focus.
385
+ const answer = multiSelect ? selectedLabels(allOptions, checked) : ''
386
+ finish({ answer, wasCustom: false, timedOut: true })
387
+ }
388
+
389
+ if (timeoutMs !== undefined) {
390
+ idleTimer = setInterval(() => {
391
+ if (deadline === undefined) return
392
+ const remainingMs = deadline - Date.now()
393
+ if (remainingMs <= 0) {
394
+ fireTimeout()
395
+ return
396
+ }
397
+ const remainingSeconds = Math.ceil(remainingMs / 1000)
398
+ const nextCountdown = remainingMs <= COUNTDOWN_WINDOW_MS ? remainingSeconds : undefined
399
+ if (nextCountdown !== lastCountdown) {
400
+ lastCountdown = nextCountdown
401
+ refresh()
402
+ }
403
+ }, IDLE_TICK_MS)
404
+ }
405
+
280
406
  const editorTheme: EditorTheme = {
281
407
  borderColor: (s) => theme.fg('accent', s),
282
408
  selectList: {
@@ -292,7 +418,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
292
418
  editor.onSubmit = (value) => {
293
419
  const trimmed = value.trim()
294
420
  if (trimmed) {
295
- done({ answer: trimmed, wasCustom: true })
421
+ finish({ answer: trimmed, wasCustom: true })
296
422
  } else {
297
423
  editMode = false
298
424
  editor.setText('')
@@ -306,6 +432,11 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
306
432
  }
307
433
 
308
434
  function handleInput(data: string) {
435
+ // Claude: "Press any key to restart the timer." Every branch below returns
436
+ // through this function, so resetting unconditionally on entry covers all of
437
+ // them, including the ones (arrow keys, space) that never reach `finish`.
438
+ resetIdleTimer()
439
+
309
440
  if (editMode) {
310
441
  if (matchesKey(data, Key.escape)) {
311
442
  editMode = false
@@ -337,7 +468,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
337
468
 
338
469
  if (matchesKey(data, Key.enter)) {
339
470
  if (multiSelect) {
340
- done({ answer: selectedLabels(allOptions, checked), wasCustom: false })
471
+ finish({ answer: selectedLabels(allOptions, checked), wasCustom: false })
341
472
  return
342
473
  }
343
474
  const selected = allOptions[optionIndex]
@@ -345,20 +476,20 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
345
476
  editMode = true
346
477
  refresh()
347
478
  } else {
348
- done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
479
+ finish({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
349
480
  }
350
481
  return
351
482
  }
352
483
 
353
484
  if (matchesKey(data, Key.escape)) {
354
- done(null)
485
+ finish(null)
355
486
  }
356
487
  }
357
488
 
358
489
  function render(width: number): string[] {
359
490
  if (cachedLines && cachedWidth === width) return cachedLines
360
491
  cachedWidth = width
361
- cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
492
+ cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds: lastCountdown })
362
493
  return cachedLines
363
494
  }
364
495
 
@@ -369,6 +500,10 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
369
500
  cachedLines = undefined
370
501
  },
371
502
  handleInput,
503
+ // Belt and suspenders alongside finish()'s own stopIdleTimer: if the host ever
504
+ // tears the overlay down through a path that does not go through `done`
505
+ // (finish's only caller), the interval still gets cleared here.
506
+ dispose: stopIdleTimer,
372
507
  }
373
508
  })
374
509
  }
@@ -376,7 +511,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
376
511
  /** Dialog-primitive fallback for UI without a terminal (RPC mode supports
377
512
  * select/input/notify but not custom components). Mirrors the overlay's result
378
513
  * 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> {
514
+ async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
380
515
  const header = shortHeader(params.header)
381
516
  const title = header ? `[${header}] ${params.question}` : params.question
382
517
  // 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
@@ -39,18 +39,41 @@ import { errorMessage, isDirectory, isRecord } from './internal/values.js'
39
39
  * directory is included only for approved projects: pi's loader surfaces every skill's
40
40
  * name and description to the model, so an untrusted repository would otherwise get
41
41
  * text into the prompt without the user ever agreeing to load its config. */
42
+ /** The extra skill directories a manifest declares, as a list. A string is one entry,
43
+ * a list is itself, anything else declares none. */
44
+ function declaredSkillDirs(declared: unknown): string[] {
45
+ if (Array.isArray(declared)) return declared.map(String)
46
+ return typeof declared === 'string' ? [declared] : []
47
+ }
48
+
42
49
  export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
43
50
  // Claude's precedence: enterprise (the skills directory beside the managed
44
51
  // settings file) overrides personal, and personal overrides project; discovery
45
52
  // here is first-match, so higher precedence goes first.
46
- 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')]
47
59
  // Enabled plugins contribute their skills directories. pi's loader names a
48
60
  // skill by its directory, so a plugin skill registers without Claude's
49
61
  // /plugin: prefix; a rename-free approximation, disclosed in the README.
50
62
  for (const plugin of installedPlugins(home)) {
51
- const declared = plugin.manifest.skills
52
- const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'skills']
63
+ // Claude: "Adds to the default: `skills`. The default `skills/` directory is always
64
+ // scanned, and directories listed in `skills` are loaded alongside it." Treating the
65
+ // declaration as a replacement silently dropped every skill in the conventional
66
+ // location. (The reference's one exception, a marketplace entry whose source resolves
67
+ // to the marketplace root, is a marketplace shape pi-code does not model.)
68
+ const extra = declaredSkillDirs(plugin.manifest.skills)
69
+ const dirs = [...extra, 'skills']
53
70
  candidates.push(...dirs.map((dir) => pluginComponentPath(plugin, String(dir))).filter((dir): dir is string => dir !== undefined))
71
+ // NOT SUPPORTED: Claude's single-skill layout, "a plugin that ships exactly one skill
72
+ // can place SKILL.md directly at the plugin root". skillPaths is handed to pi's own
73
+ // loader, which owns the layout and looks for <dir>/<name>/SKILL.md; adding the plugin
74
+ // root here does not surface root/SKILL.md and does start scanning every sibling
75
+ // directory (hooks/, agents/, commands/) for skills. Supporting it needs a loader that
76
+ // accepts a directory that IS the skill, which is pi's call, not this extension's.
54
77
  }
55
78
  // Claude loads skills from every .claude/skills between cwd and the repository
56
79
  // root; the list goes nearest-first so findClaudeSkill's first match is the
@@ -352,8 +352,10 @@ export default function statusLine(pi: ExtensionAPI) {
352
352
  // Everything below can touch ctx after an await, and every ctx getter throws
353
353
  // once the session is disposed. This promise is started from a timer with no
354
354
  // awaiter, so an escaping rejection becomes an uncaughtException and exits pi.
355
- const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS, undefined, undefined, (kill) => {
356
- killInflight = kill
355
+ const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS, {
356
+ onChild: (kill) => {
357
+ killInflight = kill
358
+ },
357
359
  })
358
360
  // Claude: "Your script can output multiple lines to create a richer display."
359
361
  // pi has one row for every extension status and replaces newlines with spaces
@@ -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'
@@ -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
  }
@@ -56,8 +56,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
56
56
 
57
57
  const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
58
58
  // Runs through driveRun's guard, same as the background-mode callback above.
59
- // The stop event fires here too, so SubagentStop hooks see resumed runs end.
60
- pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
59
+ // The stop event fires here too, so SubagentStop hooks see resumed runs end, and it
60
+ // carries the run's final assistant text: docs/subagents.md states SubagentStop
61
+ // receives last_assistant_message unconditionally, and a resumed run is no exception.
62
+ pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, lastAssistantMessage: run.output })
61
63
  pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
62
64
  }
63
65