pi-code 1.0.63 → 1.0.65
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 +3 -3
- package/extensions/env-settings.ts +5 -2
- package/extensions/git-checkpoint.ts +137 -21
- package/extensions/hooks/config.ts +16 -10
- package/extensions/hooks/decisions.ts +9 -9
- package/extensions/hooks/index.ts +22 -11
- package/extensions/hooks/matcher.ts +12 -7
- package/extensions/hooks/runners.ts +7 -28
- 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 +13 -3
- package/extensions/internal/model-complete.ts +7 -1
- package/extensions/internal/model-lookup.ts +10 -3
- package/extensions/internal/path-rules.ts +10 -13
- 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 +3 -1
- 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 +40 -4
- package/extensions/mcp/policy.ts +14 -12
- package/extensions/mcp/transport.ts +2 -8
- package/extensions/memory.ts +4 -4
- package/extensions/notify.ts +2 -7
- package/extensions/output-styles.ts +22 -17
- package/extensions/question.ts +2 -7
- package/extensions/skills.ts +5 -5
- package/extensions/status-line.ts +4 -0
- package/extensions/subagent/agents.ts +4 -4
- package/extensions/subagent/background.ts +35 -41
- package/extensions/subagent/child.ts +2 -2
- package/extensions/subagent/modes.ts +18 -13
- package/extensions/subagent/params.ts +1 -2
- package/extensions/subagent/run.ts +3 -14
- package/extensions/thinking.ts +3 -3
- package/extensions/web.ts +3 -4
- package/package.json +7 -7
package/extensions/mcp/config.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
import * as fs from 'node:fs'
|
|
7
7
|
import * as os from 'node:os'
|
|
8
8
|
import * as path from 'node:path'
|
|
9
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
9
10
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
11
|
import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
|
|
11
12
|
import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
|
|
12
13
|
import { findNearestFile } from '../internal/project-root.js'
|
|
14
|
+
import { errorMessage } from '../internal/values.js'
|
|
13
15
|
|
|
14
16
|
export interface StdioServerConfig {
|
|
15
17
|
type?: 'stdio'
|
|
@@ -91,7 +93,9 @@ function claudeJsonPath(home: string): string {
|
|
|
91
93
|
/** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
|
|
92
94
|
* tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
|
|
93
95
|
export function userConfigPaths(home: string): string[] {
|
|
94
|
-
|
|
96
|
+
// mcp.json lives in pi's agent directory, which PI_CODING_AGENT_DIR relocates; the
|
|
97
|
+
// other agent-directory readers (trust store, OAuth tokens) already follow it.
|
|
98
|
+
return [claudeJsonPath(home), path.join(getAgentDir(), 'mcp.json')]
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
/** Project-scoped MCP config, each file the nearest of its name at or above cwd
|
|
@@ -107,8 +111,10 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
107
111
|
try {
|
|
108
112
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
109
113
|
Object.assign(servers, parsed.mcpServers ?? {})
|
|
110
|
-
} catch {
|
|
111
|
-
// missing
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// A missing file is the normal case. A present file that does not parse is
|
|
116
|
+
// not: one trailing comma silently disabled every server in it.
|
|
117
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${file}: ${errorMessage(error)}`)
|
|
112
118
|
}
|
|
113
119
|
}
|
|
114
120
|
return servers
|
|
@@ -132,7 +138,8 @@ function projectRecord(home: string, cwd: string): { mcpServers?: Record<string,
|
|
|
132
138
|
try {
|
|
133
139
|
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
|
|
134
140
|
return claudeJson.projects?.[cwd] ?? {}
|
|
135
|
-
} catch {
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${claudeJsonPath(home)}: ${errorMessage(error)}`)
|
|
136
143
|
return {}
|
|
137
144
|
}
|
|
138
145
|
}
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -53,8 +53,7 @@ import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeout
|
|
|
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
|
|
56
|
-
// (imported by the test suite) reachable from this entry point unchanged.
|
|
57
|
-
// settings path helpers now live in the shared internal module.
|
|
56
|
+
// (imported by the test suite) reachable from this entry point unchanged.
|
|
58
57
|
export type { HttpServerConfig, ServerConfig, StdioServerConfig } from './config.js'
|
|
59
58
|
export { expandCwd, interpolateEnv, loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, userConfigPaths, warnOnTypelessUrl } from './config.js'
|
|
60
59
|
export type { McpToolInfo } from './listing.js'
|
|
@@ -85,6 +84,12 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
|
|
|
85
84
|
|
|
86
85
|
export default async function mcpExtension(pi: ExtensionAPI) {
|
|
87
86
|
const clients = new Map<string, Client>()
|
|
87
|
+
// Names with a connect in flight. A name enters `clients` only once its connect
|
|
88
|
+
// resolves, and shutdown clears that map without awaiting anything in flight, so
|
|
89
|
+
// without this set a session switch during a slow connect (or a backoff sleep) let
|
|
90
|
+
// the next session_start connect the same name again; the first client then resolved
|
|
91
|
+
// into an entry the second overwrote and was never closed.
|
|
92
|
+
const connecting = new Set<string>()
|
|
88
93
|
// The session's OAuth UI seams, captured at session_start. The reconnect paths below
|
|
89
94
|
// run outside that handler, and passing undefined there made an INTERACTIVE session
|
|
90
95
|
// report the headless "cannot log in" advice on a re-auth it could actually perform.
|
|
@@ -99,6 +104,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
99
104
|
// Shutdown closes clients while they are still in the map; the onclose handlers
|
|
100
105
|
// must not schedule reconnects for that deliberate teardown.
|
|
101
106
|
let shuttingDown = false
|
|
107
|
+
// Bumped by every session_start and session_shutdown. A late-connecting server's
|
|
108
|
+
// deferred summary captured the ctx of the session that started it; pi disposes that
|
|
109
|
+
// ctx on /new, /resume, /fork and reload (any use of it throws), so the publish must
|
|
110
|
+
// check it still belongs to the live session before touching ctx.
|
|
111
|
+
let sessionGeneration = 0
|
|
102
112
|
const callTuning = (name: string): ServerCallTuning => {
|
|
103
113
|
const config = serverConfigs.get(name)
|
|
104
114
|
return config ? serverCallTuning(config) : {}
|
|
@@ -189,7 +199,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
189
199
|
async function reconnectWithBackoff(name: string, config: ServerConfig): Promise<void> {
|
|
190
200
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
191
201
|
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt))
|
|
192
|
-
if (shuttingDown || clients.has(name)) return
|
|
202
|
+
if (shuttingDown || clients.has(name) || connecting.has(name)) return
|
|
193
203
|
await connectServers({ [name]: config }, sessionAuthUi, true)
|
|
194
204
|
if (clients.has(name)) return
|
|
195
205
|
}
|
|
@@ -410,10 +420,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
410
420
|
console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
|
|
411
421
|
continue
|
|
412
422
|
}
|
|
423
|
+
// The one guard for every connect path (session_start scopes, backoff, auth
|
|
424
|
+
// reconnect): a name still connecting from the previous session is adopted when
|
|
425
|
+
// that connect resolves, not connected again.
|
|
426
|
+
if (connecting.has(name)) continue
|
|
413
427
|
// Seed in config order before connecting: parallel connects settle in completion
|
|
414
428
|
// order, and /mcp plus the session summary iterate the map's insertion order.
|
|
415
429
|
status.set(name, { state: 'connecting', tools: 0 })
|
|
416
430
|
serverConfigs.set(name, config)
|
|
431
|
+
connecting.add(name)
|
|
417
432
|
pending.push([name, config])
|
|
418
433
|
}
|
|
419
434
|
await Promise.all(
|
|
@@ -423,6 +438,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
423
438
|
// First connections retry transient failures (Claude: up to three times for
|
|
424
439
|
// HTTP/SSE); the backoff reconnect below carries its own schedule instead.
|
|
425
440
|
const client = noRetry ? await connect(name, config, authUi, sessionDirs) : await connectWithRetries(name, config, authUi, sessionDirs)
|
|
441
|
+
// A session switch resets shuttingDown before this resolves and adopts the
|
|
442
|
+
// client; still set, pi is exiting and the shutdown handler already closed
|
|
443
|
+
// every client it could see, so this late one must close itself.
|
|
444
|
+
if (shuttingDown) {
|
|
445
|
+
await withTimeout(client.close(), 3000, 'close').catch(() => {})
|
|
446
|
+
status.delete(name)
|
|
447
|
+
return
|
|
448
|
+
}
|
|
426
449
|
clients.set(name, client)
|
|
427
450
|
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
428
451
|
registerTools(name, config, tools)
|
|
@@ -460,6 +483,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
460
483
|
clients.delete(name)
|
|
461
484
|
void leaked.close().catch(() => {})
|
|
462
485
|
}
|
|
486
|
+
} finally {
|
|
487
|
+
connecting.delete(name)
|
|
463
488
|
}
|
|
464
489
|
}),
|
|
465
490
|
)
|
|
@@ -587,6 +612,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
587
612
|
// withdrawn tool keeps its registration and surfaces the server's own error), which
|
|
588
613
|
// is why serverToolCount reads from `registered` to recover the true count here.
|
|
589
614
|
status.clear()
|
|
615
|
+
const generation = ++sessionGeneration
|
|
590
616
|
// A same-process session switch (/new, /resume) shut the last session down;
|
|
591
617
|
// this one may reconnect again. projectConnected guards against connecting the project
|
|
592
618
|
// scope twice within one session, so it belongs to the session that set it: leaving it
|
|
@@ -629,13 +655,23 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
629
655
|
() => false,
|
|
630
656
|
)
|
|
631
657
|
publishConnectionSummary(ctx)
|
|
632
|
-
if (!settled)
|
|
658
|
+
if (!settled) {
|
|
659
|
+
// The rejection path exists for the throw a disposed ctx would raise if the
|
|
660
|
+
// generation check were ever wrong: from a bare `.then` it would be an unhandled
|
|
661
|
+
// rejection, and pi installs no handler, so Node would take the process down.
|
|
662
|
+
void connecting
|
|
663
|
+
.then(() => {
|
|
664
|
+
if (generation === sessionGeneration) publishConnectionSummary(ctx)
|
|
665
|
+
})
|
|
666
|
+
.catch(() => {})
|
|
667
|
+
}
|
|
633
668
|
})
|
|
634
669
|
|
|
635
670
|
pi.on('session_shutdown', async () => {
|
|
636
671
|
// Closing fires each client's onclose while it is still in the map; the flag
|
|
637
672
|
// stops those handlers (and any in-flight backoff loop) from reconnecting.
|
|
638
673
|
shuttingDown = true
|
|
674
|
+
sessionGeneration++
|
|
639
675
|
// Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
|
|
640
676
|
await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
|
|
641
677
|
// Drop the closed clients and their status now rather than waiting on each client's
|
package/extensions/mcp/policy.ts
CHANGED
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import * as fs from 'node:fs'
|
|
8
8
|
import * as path from 'node:path'
|
|
9
|
-
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
9
|
import { managedSettingsFile } from '../internal/managed-settings.js'
|
|
11
|
-
import {
|
|
12
|
-
import { errorMessage } from '../internal/values.js'
|
|
10
|
+
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
11
|
+
import { errorMessage, escapeRegExp } from '../internal/values.js'
|
|
13
12
|
import { interpolateEnv, type ServerConfig } from './config.js'
|
|
14
13
|
|
|
15
14
|
export interface ProjectServerPolicy {
|
|
@@ -38,11 +37,17 @@ export function projectServerPolicy(cwd: string, home: string, projectApproved:
|
|
|
38
37
|
}
|
|
39
38
|
}
|
|
40
39
|
const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
// The files come from the one settings chain, so placement follows Claude's rules
|
|
41
|
+
// everywhere: the project's settings.json is read from cwd only (never an ancestor),
|
|
42
|
+
// and settings.local.json from the main checkout, with the chain's legacy cwd copy
|
|
43
|
+
// read too. Resolving them by nearest-file here gave a subdirectory session an
|
|
44
|
+
// ancestor's project file and a worktree session its own local file instead.
|
|
45
|
+
const [userFile, projectFile, ...localFiles] = claudeSettingsChain(cwd, home, true)
|
|
46
|
+
const userSettings = read(userFile)
|
|
47
|
+
const projectSettings = read(projectFile)
|
|
48
|
+
const localSettings = localFiles.map(read)
|
|
49
|
+
const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...localSettings.flatMap((settings) => names(settings.disabledMcpjsonServers))])
|
|
50
|
+
const consentSources = projectApproved ? [userSettings, ...localSettings] : [userSettings]
|
|
46
51
|
const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
|
|
47
52
|
const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
|
|
48
53
|
return { disabled, consented, consentAll }
|
|
@@ -127,10 +132,7 @@ export function mcpAllowDeny(scopeFiles: string[] = [], managedFile: string = ma
|
|
|
127
132
|
|
|
128
133
|
/** `*` in a policy URL pattern matches any run of characters; everything else is literal. */
|
|
129
134
|
function wildcardRegExp(pattern: string): RegExp {
|
|
130
|
-
const source = pattern
|
|
131
|
-
.split('*')
|
|
132
|
-
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
|
|
133
|
-
.join('.*')
|
|
135
|
+
const source = pattern.split('*').map(escapeRegExp).join('.*')
|
|
134
136
|
return new RegExp(`^${source}$`)
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -18,6 +18,7 @@ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/webso
|
|
|
18
18
|
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
19
19
|
import { FileOAuthProvider, type OAuthServerConfig } from '../internal/mcp-oauth.js'
|
|
20
20
|
import { resolveShell } from '../internal/shell-resolve.js'
|
|
21
|
+
import { parseNumericEnv } from '../internal/values.js'
|
|
21
22
|
import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
|
|
22
23
|
import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
|
|
23
24
|
|
|
@@ -36,19 +37,12 @@ const DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS = 1_800_000
|
|
|
36
37
|
|
|
37
38
|
/** Claude's numeric env vars accept scientific notation and digit-separator spellings
|
|
38
39
|
* (2e3 as 2000, 64_000 as 64000). A non-numeric value is undefined, not zero. */
|
|
39
|
-
function parseNumericEnv(raw: string): number | undefined {
|
|
40
|
-
const cleaned = raw.replaceAll('_', '')
|
|
41
|
-
if (cleaned.trim() === '') return undefined
|
|
42
|
-
const value = Number(cleaned)
|
|
43
|
-
return Number.isFinite(value) ? Math.floor(value) : undefined
|
|
44
|
-
}
|
|
45
|
-
|
|
46
40
|
/** A positive-integer env override, or the default when unset or unparseable. */
|
|
47
41
|
function envTimeout(name: string, fallback: number): number {
|
|
48
42
|
const raw = process.env[name]
|
|
49
43
|
if (raw === undefined) return fallback
|
|
50
44
|
const value = parseNumericEnv(raw)
|
|
51
|
-
return value !== undefined && value > 0 ? value : fallback
|
|
45
|
+
return value !== undefined && value > 0 ? Math.floor(value) : fallback
|
|
52
46
|
}
|
|
53
47
|
|
|
54
48
|
// Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
|
package/extensions/memory.ts
CHANGED
|
@@ -12,7 +12,7 @@ import * as fs from 'node:fs'
|
|
|
12
12
|
import * as os from 'node:os'
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
15
|
-
import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
15
|
+
import { type ExtensionAPI, getAgentDir, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
16
16
|
import { Type } from 'typebox'
|
|
17
17
|
import { atomicWriteFile } from './internal/atomic-write.js'
|
|
18
18
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
@@ -63,7 +63,7 @@ function memoryProject(cwd: string): string {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function memoryDir(cwd: string): string {
|
|
66
|
-
return path.join(
|
|
66
|
+
return path.join(getAgentDir(), 'memory', projectSlug(memoryProject(cwd)))
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** The store location, honoring an `autoMemoryDirectory` override. Claude requires
|
|
@@ -142,7 +142,7 @@ export function stripNonLoaded(text: string): string {
|
|
|
142
142
|
export function migrateLegacyStore(cwd: string): void {
|
|
143
143
|
const current = memoryDir(cwd)
|
|
144
144
|
if (fs.existsSync(current)) return
|
|
145
|
-
const base = path.join(
|
|
145
|
+
const base = path.join(getAgentDir(), 'memory')
|
|
146
146
|
// projectSlug(cwd) differs from current only for a subdirectory session (current is
|
|
147
147
|
// keyed on the repo root); for a repo-root session it equals current and is skipped.
|
|
148
148
|
const candidates = [path.join(base, projectSlug(cwd)), path.join(base, legacySlug(cwd))]
|
|
@@ -177,7 +177,7 @@ export function indexWouldOverflow(index: string, name: string, description: str
|
|
|
177
177
|
/** Where the index stands against the read limits, measured on the loaded content
|
|
178
178
|
* (frontmatter and comments stripped): 'over' past either bound, 'near' within
|
|
179
179
|
* 10% of one, else 'ok'. Claude reminds near a limit and errors over it. */
|
|
180
|
-
|
|
180
|
+
function indexReadState(index: string): 'ok' | 'near' | 'over' {
|
|
181
181
|
const loaded = stripNonLoaded(index)
|
|
182
182
|
const lines = loaded.split('\n').length
|
|
183
183
|
const bytes = Buffer.byteLength(loaded, 'utf-8')
|
package/extensions/notify.ts
CHANGED
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { execFile } from 'node:child_process'
|
|
19
|
-
import * as fs from 'node:fs'
|
|
20
19
|
import * as os from 'node:os'
|
|
21
20
|
import * as path from 'node:path'
|
|
22
21
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
23
22
|
|
|
24
23
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
24
|
+
import { readSettingsFile } from './internal/settings-chain.js'
|
|
25
25
|
|
|
26
26
|
/** How a finished turn is announced, from Claude's `preferredNotifChannel`. */
|
|
27
27
|
export type NotifChannel = 'desktop' | 'bell' | 'both' | 'off'
|
|
@@ -56,12 +56,7 @@ export function isAway(lastInputAt: number | undefined, now: number, thresholdMs
|
|
|
56
56
|
* preference, so only user scope is read; a checked-out repo does not get to silence
|
|
57
57
|
* or change your notifications. */
|
|
58
58
|
function readPreferredNotifChannel(home: string): unknown {
|
|
59
|
-
|
|
60
|
-
const settings = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
|
|
61
|
-
return settings?.preferredNotifChannel
|
|
62
|
-
} catch {
|
|
63
|
-
return undefined
|
|
64
|
-
}
|
|
59
|
+
return readSettingsFile(path.join(claudeConfigDir(home), 'settings.json'))?.preferredNotifChannel
|
|
65
60
|
}
|
|
66
61
|
|
|
67
62
|
function windowsToastScript(title: string, body: string): string {
|
|
@@ -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,7 +6,6 @@
|
|
|
6
6
|
* Multiple questions per call are not batched; ask sequentially.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import * as fs from 'node:fs'
|
|
10
9
|
import * as os from 'node:os'
|
|
11
10
|
import * as path from 'node:path'
|
|
12
11
|
import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
|
|
@@ -14,6 +13,7 @@ import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from
|
|
|
14
13
|
import { Type } from 'typebox'
|
|
15
14
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
16
15
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
16
|
+
import { readSettingsFile } from './internal/settings-chain.js'
|
|
17
17
|
|
|
18
18
|
interface OptionWithDesc {
|
|
19
19
|
label: string
|
|
@@ -96,12 +96,7 @@ export function askUserQuestionTimeoutMs(home: string = os.homedir()): number |
|
|
|
96
96
|
const managed = readManagedSettings() as { askUserQuestionTimeout?: unknown }
|
|
97
97
|
const fromManaged = parseAskUserQuestionTimeout(managed.askUserQuestionTimeout)
|
|
98
98
|
if (fromManaged !== undefined) return fromManaged
|
|
99
|
-
|
|
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
|
-
}
|
|
99
|
+
return parseAskUserQuestionTimeout(readSettingsFile(path.join(claudeConfigDir(home), 'settings.json'))?.askUserQuestionTimeout)
|
|
105
100
|
}
|
|
106
101
|
|
|
107
102
|
function checkbox(checked: boolean | undefined): string {
|
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,6 +42,10 @@ 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
|
|
@@ -113,7 +113,7 @@ function skillAt(root: string, dirName: string): { name: string; filePath: strin
|
|
|
113
113
|
|
|
114
114
|
/** A Claude-contributed skill by the name pi's loader gives it. One directory
|
|
115
115
|
* level, the standard layout. */
|
|
116
|
-
|
|
116
|
+
function findClaudeSkill(name: string, roots: string[]): FoundSkill | undefined {
|
|
117
117
|
for (const root of roots) {
|
|
118
118
|
let entries: fs.Dirent[]
|
|
119
119
|
try {
|
|
@@ -559,5 +559,9 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
559
559
|
pi.on('session_shutdown', async () => {
|
|
560
560
|
clearInterval(refreshTimer)
|
|
561
561
|
clearTimeout(debounceTimer)
|
|
562
|
+
// pi's CLI loads a fresh extension instance for every session replacement, so no later
|
|
563
|
+
// session_start reaches this watcher: left armed, it polls for the life of the process.
|
|
564
|
+
disposeSettingsWatch()
|
|
565
|
+
disposeSettingsWatch = () => {}
|
|
562
566
|
})
|
|
563
567
|
}
|
|
@@ -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
|
|
@@ -27,8 +27,6 @@ export const AGENT_HOOK_SYSTEM = [
|
|
|
27
27
|
'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
|
|
28
28
|
].join('\n')
|
|
29
29
|
|
|
30
|
-
/** A throwaway agent config for one agent-hook run: read-only inspection tools, the
|
|
31
|
-
* hook's model (a fast default when unset), and the decision-returning system prompt. */
|
|
32
30
|
/** The agent a context: fork skill runs as when it names none: full toolset, no
|
|
33
31
|
* extra system prompt (the child keeps pi's default), the skill content as the
|
|
34
32
|
* task. */
|
|
@@ -43,6 +41,8 @@ export function forkAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt
|
|
|
43
41
|
}
|
|
44
42
|
}
|
|
45
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. */
|
|
46
46
|
export function buildHookAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
|
|
47
47
|
return {
|
|
48
48
|
name: 'agent-hook',
|