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.
- package/README.md +2 -0
- package/extensions/commands.ts +45 -11
- package/extensions/context-imports.ts +43 -21
- package/extensions/env-settings.ts +19 -1
- package/extensions/git-checkpoint.ts +4 -0
- package/extensions/hooks/claude-tools.ts +9 -7
- package/extensions/hooks/config.ts +20 -3
- package/extensions/hooks/index.ts +14 -3
- package/extensions/hooks/matcher.ts +36 -8
- package/extensions/hooks/runners.ts +45 -2
- package/extensions/internal/claude-tool-names.ts +70 -0
- package/extensions/internal/command-file.ts +50 -38
- package/extensions/internal/mcp-oauth.ts +29 -4
- package/extensions/internal/path-rules.ts +62 -32
- package/extensions/internal/plugins.ts +11 -0
- package/extensions/internal/scope-rules.ts +117 -0
- package/extensions/internal/shell-resolve.ts +16 -5
- package/extensions/internal/web-transport.ts +7 -0
- package/extensions/mcp/config.ts +5 -1
- package/extensions/mcp/index.ts +51 -22
- package/extensions/mcp/oauth-flow.ts +1 -1
- package/extensions/mcp/transport.ts +29 -3
- package/extensions/question.ts +147 -12
- package/extensions/session-title.ts +5 -0
- package/extensions/skills.ts +26 -3
- package/extensions/status-line.ts +4 -2
- package/extensions/subagent/child.ts +17 -2
- package/extensions/subagent/index.ts +4 -2
- package/extensions/subagent/run.ts +32 -7
- package/extensions/thinking.ts +14 -8
- package/extensions/web.ts +74 -21
- package/package.json +1 -1
package/extensions/mcp/index.ts
CHANGED
|
@@ -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.
|
|
182
|
-
* that now needs a login
|
|
183
|
-
*
|
|
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 },
|
|
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 },
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
186
|
-
*
|
|
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
|
-
|
|
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
|
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 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
|
-
|
|
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
|
-
/**
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/extensions/skills.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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,
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|