pi-code 1.0.64 → 1.0.66
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/extensions/env-settings.ts +12 -3
- package/extensions/hooks/config.ts +6 -3
- package/extensions/hooks/index.ts +12 -3
- package/extensions/hooks/matcher.ts +5 -3
- package/extensions/internal/agent-run.ts +6 -3
- package/extensions/internal/mcp-call.ts +5 -2
- package/extensions/internal/model-complete.ts +3 -0
- package/extensions/internal/project-approval.ts +16 -0
- package/extensions/internal/settings-watch.ts +8 -2
- package/extensions/internal/shared-slot.ts +23 -0
- package/extensions/internal/subagent-hooks.ts +5 -2
- package/extensions/mcp/config.ts +17 -3
- package/extensions/output-styles.ts +23 -11
- package/extensions/question.ts +3 -0
- package/extensions/status-line.ts +11 -2
- package/extensions/subagent/run.ts +18 -10
- package/package.json +7 -7
|
@@ -38,7 +38,7 @@ import * as path from 'node:path'
|
|
|
38
38
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
39
39
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
40
40
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
41
|
-
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
41
|
+
import { approvalRecheck, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
42
42
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
43
43
|
import { watchSettingsFiles } from './internal/settings-watch.js'
|
|
44
44
|
import { isRecord } from './internal/values.js'
|
|
@@ -160,7 +160,12 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
|
160
160
|
pi.on('session_start', async (_event, ctx: ExtensionContext) => {
|
|
161
161
|
const home = os.homedir()
|
|
162
162
|
const approved = isProjectApprovedSilently(ctx)
|
|
163
|
-
|
|
163
|
+
// The watcher's reapply closes over the cwd value, never ctx: the poll has no awaiter,
|
|
164
|
+
// and every getter of a replaced session's ctx throws, which would exit pi.
|
|
165
|
+
const cwd = ctx.cwd
|
|
166
|
+
// A reload asks again rather than reusing `approved`: see approvalRecheck.
|
|
167
|
+
const stillApproved = approvalRecheck(ctx)
|
|
168
|
+
const reapply = (): void => apply(home, approved && stillApproved() ? projectEnv(cwd, home) : {})
|
|
164
169
|
reapply()
|
|
165
170
|
// Claude: "Claude Code watches your settings files and reloads them when they change,
|
|
166
171
|
// so it applies most edits to the running session without a restart." `env` is not
|
|
@@ -168,11 +173,15 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
|
168
173
|
// edit has to reach process.env now rather than at the next session. applyEnvSettings
|
|
169
174
|
// tracks what it owns, so a key removed from the file is restored, not left behind.
|
|
170
175
|
disposeWatch()
|
|
171
|
-
disposeWatch = watchSettingsFiles(claudeSettingsChain(
|
|
176
|
+
disposeWatch = watchSettingsFiles(claudeSettingsChain(cwd, home, approved), reapply)
|
|
172
177
|
})
|
|
173
178
|
|
|
174
179
|
pi.on('session_shutdown', async () => {
|
|
175
180
|
disposeWatch()
|
|
176
181
|
disposeWatch = () => {}
|
|
182
|
+
// pi's CLI loads a fresh extension instance for every session replacement, and its
|
|
183
|
+
// empty `owned` cannot restore what this one set: everything is handed back here, and
|
|
184
|
+
// the next instance re-applies managed and user env at factory time, as at startup.
|
|
185
|
+
applyEnvSettings({}, process.env, owned)
|
|
177
186
|
})
|
|
178
187
|
}
|
|
@@ -251,9 +251,12 @@ function withoutUserConfigShellCommands(raw: string, source: string): string {
|
|
|
251
251
|
for (const matchers of Object.values(parsed?.hooks ?? {})) {
|
|
252
252
|
if (!Array.isArray(matchers)) continue
|
|
253
253
|
for (const entry of matchers) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
// A malformed entry is left for mergeHooksJson to report by name; this pass must
|
|
255
|
+
// not throw on it, because the inline-manifest caller has no catch.
|
|
256
|
+
if (!isRecord(entry) || !Array.isArray(entry.hooks)) continue
|
|
257
|
+
const record = entry
|
|
258
|
+
const kept = (record.hooks as unknown[]).filter((hook) => {
|
|
259
|
+
if (!isRecord(hook)) return true
|
|
257
260
|
const candidate = hook as { command?: unknown; args?: unknown }
|
|
258
261
|
if (Array.isArray(candidate.args) || typeof candidate.command !== 'string' || !USER_CONFIG_REF.test(candidate.command)) return true
|
|
259
262
|
console.warn(`pi-code-hooks: ignoring a hook in ${source}: a shell-form command cannot reference \${user_config.*}; use exec form with "args", or read CLAUDE_PLUGIN_OPTION_<KEY> from the environment`)
|
|
@@ -103,7 +103,7 @@ import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
|
103
103
|
import { resolveModelOverride } from '../internal/model-lookup.js'
|
|
104
104
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
105
105
|
import { installedPlugins, managedForceEnabled } from '../internal/plugins.js'
|
|
106
|
-
import { isProjectApproved } from '../internal/project-approval.js'
|
|
106
|
+
import { approvalRecheck, isProjectApproved } from '../internal/project-approval.js'
|
|
107
107
|
import { repoRoot } from '../internal/project-root.js'
|
|
108
108
|
import { watchSettingsFiles } from '../internal/settings-watch.js'
|
|
109
109
|
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
@@ -453,9 +453,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
453
453
|
// subdirectory session too.
|
|
454
454
|
projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
455
455
|
resolveConfig(ctx.cwd, trusted)
|
|
456
|
-
// Claude picks up direct settings edits mid-session via a file watcher.
|
|
456
|
+
// Claude picks up direct settings edits mid-session via a file watcher. The reload
|
|
457
|
+
// closes over the cwd value, never ctx: the poll has no awaiter, and every getter of
|
|
458
|
+
// a replaced session's ctx throws, which would exit pi as an uncaughtException.
|
|
459
|
+
const watchCwd = ctx.cwd
|
|
460
|
+
// The reload asks again rather than reusing `trusted`: see approvalRecheck.
|
|
461
|
+
const stillApproved = approvalRecheck(ctx)
|
|
457
462
|
disposeSettingsWatch()
|
|
458
|
-
disposeSettingsWatch = watchSettingsFiles(hookFiles(
|
|
463
|
+
disposeSettingsWatch = watchSettingsFiles(hookFiles(watchCwd, os.homedir(), trusted), () => resolveConfig(watchCwd, trusted && stillApproved()))
|
|
459
464
|
// A disabled or managed-only resolution leaves config empty (or managed-only),
|
|
460
465
|
// so the SessionStart run below fires exactly what remains active.
|
|
461
466
|
pendingSessionContext = []
|
|
@@ -759,6 +764,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
759
764
|
})
|
|
760
765
|
|
|
761
766
|
pi.on('session_shutdown', async (event, ctx) => {
|
|
767
|
+
// pi's CLI loads a fresh extension instance for every session replacement, so no later
|
|
768
|
+
// session_start reaches this watcher: left armed, it polls for the life of the process.
|
|
769
|
+
disposeSettingsWatch()
|
|
770
|
+
disposeSettingsWatch = () => {}
|
|
762
771
|
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
763
772
|
// SessionEnd rides Claude's short shared budget (see sessionEndTimeoutMs) so a
|
|
764
773
|
// slow hook cannot stall session exit, /new or /resume.
|
|
@@ -135,9 +135,11 @@ function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: H
|
|
|
135
135
|
if (raw.spent === true) continue
|
|
136
136
|
const hook = withCommand(raw)
|
|
137
137
|
// Claude runs a handler defined in more than one settings file once; a
|
|
138
|
-
// plugin's or skill's copy of the same handler stays separate
|
|
139
|
-
//
|
|
140
|
-
|
|
138
|
+
// plugin's or skill's copy of the same handler stays separate. Handlers that
|
|
139
|
+
// share a command but differ in what they run or when (http headers, exec-form
|
|
140
|
+
// args, an `if` filter, an mcp_tool input) are different handlers: `if` is applied
|
|
141
|
+
// after this, so collapsing two filters left the other tool call with no guard.
|
|
142
|
+
const key = `${hook.origin ?? 'settings'}\n${hook.command}\n${JSON.stringify([hook.headers, hook.args, hook.if, hook.input])}`
|
|
141
143
|
if (seen.has(key)) continue
|
|
142
144
|
seen.add(key)
|
|
143
145
|
result.push(hook)
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* are skipped rather than failing the event.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { sharedSlot } from './shared-slot.js'
|
|
13
|
+
|
|
12
14
|
export interface AgentRunRequest {
|
|
13
15
|
/** The agent's task prompt, with `$ARGUMENTS` already substituted. */
|
|
14
16
|
prompt: string
|
|
@@ -29,20 +31,21 @@ export interface AgentRunRequest {
|
|
|
29
31
|
/** Run a subagent to completion and return its final assistant text. */
|
|
30
32
|
export type AgentRunner = (request: AgentRunRequest) => Promise<string>
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
const slot = sharedSlot<AgentRunner>('agent-runner')
|
|
33
35
|
|
|
34
36
|
/** The subagent extension registers its runner here; pass undefined to clear it. */
|
|
35
37
|
export function setAgentRunner(fn: AgentRunner | undefined): void {
|
|
36
|
-
|
|
38
|
+
slot.set(fn)
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** Test seam: whether a runner is registered. */
|
|
40
42
|
export function hasAgentRunner(): boolean {
|
|
41
|
-
return
|
|
43
|
+
return slot.get() !== undefined
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/** Run a subagent for an agent hook. Rejects when no runner is registered. */
|
|
45
47
|
export function runAgent(request: AgentRunRequest): Promise<string> {
|
|
48
|
+
const runner = slot.get()
|
|
46
49
|
if (!runner) return Promise.reject(new Error('no subagent runner registered for agent hooks'))
|
|
47
50
|
return runner(request)
|
|
48
51
|
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* callMcpTool. It is the direct-call analogue of the MCP_TOOLS_CHANNEL alias bus.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { sharedSlot } from './shared-slot.js'
|
|
11
|
+
|
|
10
12
|
export interface McpToolResult {
|
|
11
13
|
text: string
|
|
12
14
|
isError: boolean
|
|
@@ -14,15 +16,16 @@ export interface McpToolResult {
|
|
|
14
16
|
|
|
15
17
|
export type McpToolCaller = (server: string, tool: string, input: Record<string, unknown>) => Promise<McpToolResult>
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
const slot = sharedSlot<McpToolCaller>('mcp-tool-caller')
|
|
18
20
|
|
|
19
21
|
/** The mcp extension registers its caller here; pass undefined to clear it. */
|
|
20
22
|
export function setMcpToolCaller(fn: McpToolCaller | undefined): void {
|
|
21
|
-
|
|
23
|
+
slot.set(fn)
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/** Invoke a tool on a connected MCP server. Throws when no server is connected. */
|
|
25
27
|
export function callMcpTool(server: string, tool: string, input: Record<string, unknown>): Promise<McpToolResult> {
|
|
28
|
+
const caller = slot.get()
|
|
26
29
|
if (!caller) return Promise.reject(new Error(`no MCP server connected to serve ${server}/${tool}`))
|
|
27
30
|
return caller(server, tool, input)
|
|
28
31
|
}
|
|
@@ -72,5 +72,8 @@ export async function completeText(model: Model<Api>, prompt: string, options: C
|
|
|
72
72
|
messages: [{ role: 'user', content: prompt, timestamp: Date.now() }],
|
|
73
73
|
}
|
|
74
74
|
const message = await complete(model, context, { maxTokens: options.maxTokens ?? 1024, signal: options.signal })
|
|
75
|
+
// pi-ai reports a provider failure or a fired signal by resolving, never rejecting: the
|
|
76
|
+
// message then has no text, and returned as an answer it is indistinguishable from one.
|
|
77
|
+
if (message.stopReason === 'error' || message.stopReason === 'aborted') throw new Error(message.errorMessage ?? `completion ${message.stopReason}`)
|
|
75
78
|
return { text: assistantText(message), usage: message.usage }
|
|
76
79
|
}
|
|
@@ -129,6 +129,22 @@ export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'is
|
|
|
129
129
|
return deps.savedDecision(ctx.cwd) === true
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A re-check of the approval decision for code that outlives session_start, such as a
|
|
134
|
+
* settings watcher's reload. The answer given at session_start cannot be reused: a
|
|
135
|
+
* repository with nothing Claude-shaped reads as approved without a question, so config
|
|
136
|
+
* that appears later (a branch checkout, an unpacked archive) would inherit an answer
|
|
137
|
+
* nobody gave. ctx is read once, here: the returned function holds values only, because
|
|
138
|
+
* a poll outlives the session and every getter of a replaced session's ctx throws. It
|
|
139
|
+
* never prompts, so a project that turned Claude-shaped mid-session stays out until the
|
|
140
|
+
* next session asks.
|
|
141
|
+
*/
|
|
142
|
+
export function approvalRecheck(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'>, deps: ApprovalDeps = defaultDeps): () => boolean {
|
|
143
|
+
const cwd = ctx.cwd
|
|
144
|
+
const piTrusted = ctx.isProjectTrusted?.() === true
|
|
145
|
+
return () => isProjectApprovedSilently({ cwd, isProjectTrusted: () => piTrusted }, deps)
|
|
146
|
+
}
|
|
147
|
+
|
|
132
148
|
/**
|
|
133
149
|
* The approval decision for a file that is itself the thing to gate.
|
|
134
150
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as fs from 'node:fs'
|
|
9
|
-
import { parseNumericEnv } from './values.js'
|
|
9
|
+
import { errorMessage, parseNumericEnv } from './values.js'
|
|
10
10
|
|
|
11
11
|
// Captured at module load: the poll must run on real time even under a test's
|
|
12
12
|
// fake timers (the stat watcher it replaced lived in libuv and was immune too);
|
|
@@ -37,7 +37,13 @@ export function watchSettingsFiles(files: string[], reload: () => void): () => v
|
|
|
37
37
|
const next = files.map(snapshot)
|
|
38
38
|
if (next.some((content, index) => content !== last[index])) {
|
|
39
39
|
last = next
|
|
40
|
-
|
|
40
|
+
// The poll has no awaiter: a throw here is an uncaughtException, and pi exits on
|
|
41
|
+
// one. A reload that cannot cope with what it read costs that reload, not the session.
|
|
42
|
+
try {
|
|
43
|
+
reload()
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.warn(`pi-code: settings reload failed: ${errorMessage(error)}`)
|
|
46
|
+
}
|
|
41
47
|
}
|
|
42
48
|
}, interval)
|
|
43
49
|
// A watcher alone must never keep a one-shot run alive.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A process-wide slot for a function one extension registers and another calls.
|
|
3
|
+
*
|
|
4
|
+
* pi loads every extension entry through its own module graph, so a module-level `let`
|
|
5
|
+
* in a file two extensions import is a separate variable in each: the setter and the
|
|
6
|
+
* caller never meet, and the seam reads as "nothing registered" for the life of the
|
|
7
|
+
* process. `globalThis` is the one object the graphs share, and a registered symbol
|
|
8
|
+
* resolves to the same key from every copy of this module. pi's event bus carries the
|
|
9
|
+
* cross-extension data (see mcp-alias); a slot is for a call that returns a result.
|
|
10
|
+
*
|
|
11
|
+
* Each session replacement loads fresh extension instances, which register again and
|
|
12
|
+
* replace the previous instance's function.
|
|
13
|
+
*/
|
|
14
|
+
export function sharedSlot<T>(name: string): { get: () => T | undefined; set: (value: T | undefined) => void } {
|
|
15
|
+
const key = Symbol.for(`pi-code.${name}`)
|
|
16
|
+
const holder = globalThis as Record<symbol, unknown>
|
|
17
|
+
return {
|
|
18
|
+
get: () => holder[key] as T | undefined,
|
|
19
|
+
set: (value) => {
|
|
20
|
+
holder[key] = value
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -6,17 +6,20 @@
|
|
|
6
6
|
* pattern as mcp-call.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { sharedSlot } from './shared-slot.js'
|
|
10
|
+
|
|
9
11
|
export type SubagentStartHookRunner = (agentType: string, agentId: string) => Promise<string[]>
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
const slot = sharedSlot<SubagentStartHookRunner>('subagent-start-hook-runner')
|
|
12
14
|
|
|
13
15
|
export function setSubagentStartHookRunner(fn: SubagentStartHookRunner | undefined): void {
|
|
14
|
-
|
|
16
|
+
slot.set(fn)
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/** Context strings SubagentStart hooks contribute; empty when no runner is
|
|
18
20
|
* registered or the runner fails (hooks must never block a spawn). */
|
|
19
21
|
export async function runSubagentStartHooks(agentType: string, agentId: string): Promise<string[]> {
|
|
22
|
+
const runner = slot.get()
|
|
20
23
|
if (!runner) return []
|
|
21
24
|
try {
|
|
22
25
|
return await runner(agentType, agentId)
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { claudeConfigDir } from '../internal/config-dir.js'
|
|
|
11
11
|
import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
|
|
12
12
|
import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
|
|
13
13
|
import { findNearestFile } from '../internal/project-root.js'
|
|
14
|
-
import { errorMessage } from '../internal/values.js'
|
|
14
|
+
import { errorMessage, isRecord } from '../internal/values.js'
|
|
15
15
|
|
|
16
16
|
export interface StdioServerConfig {
|
|
17
17
|
type?: 'stdio'
|
|
@@ -105,12 +105,26 @@ export function projectConfigPaths(cwd: string): string[] {
|
|
|
105
105
|
return ['.mcp.json', path.join('.pi', 'mcp.json')].map((rel) => findNearestFile(cwd, rel) ?? path.join(cwd, rel))
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** The object entries of a raw `mcpServers` map. A string or null entry (a JSON "comment"
|
|
109
|
+
* key, a nulled-out server) would throw in the connect batch outside its per-server catch
|
|
110
|
+
* and sink every server with it, the project trust prompt included, so it is dropped here
|
|
111
|
+
* by name. */
|
|
112
|
+
function serverEntries(raw: unknown, source: string): Record<string, ServerConfig> {
|
|
113
|
+
const servers: Record<string, ServerConfig> = {}
|
|
114
|
+
for (const [name, entry] of Object.entries(isRecord(raw) ? raw : {})) {
|
|
115
|
+
// Shape only: the fields are validated where a transport is chosen.
|
|
116
|
+
if (isRecord(entry)) servers[name] = entry as unknown as ServerConfig
|
|
117
|
+
else console.warn(`pi-code-mcp: ignoring mcpServers entry "${name}" in ${source}: not an object`)
|
|
118
|
+
}
|
|
119
|
+
return servers
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
109
123
|
const servers: Record<string, ServerConfig> = {}
|
|
110
124
|
for (const file of files) {
|
|
111
125
|
try {
|
|
112
126
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
113
|
-
Object.assign(servers, parsed
|
|
127
|
+
Object.assign(servers, serverEntries(parsed?.mcpServers, file))
|
|
114
128
|
} catch (error) {
|
|
115
129
|
// A missing file is the normal case. A present file that does not parse is
|
|
116
130
|
// not: one trailing comma silently disabled every server in it.
|
|
@@ -128,7 +142,7 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
128
142
|
*/
|
|
129
143
|
export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
|
|
130
144
|
const servers = loadConfigFrom(userConfigPaths(home))
|
|
131
|
-
Object.assign(servers, projectRecord(home, cwd).mcpServers
|
|
145
|
+
Object.assign(servers, serverEntries(projectRecord(home, cwd).mcpServers, claudeJsonPath(home)))
|
|
132
146
|
return servers
|
|
133
147
|
}
|
|
134
148
|
|
|
@@ -31,7 +31,7 @@ import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
|
31
31
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
32
32
|
import { ancestorDirs } from './internal/project-root.js'
|
|
33
33
|
import { claudeSettingsChain, localSettingsFile, readSettingsChain } from './internal/settings-chain.js'
|
|
34
|
-
import { isDirectory } from './internal/values.js'
|
|
34
|
+
import { errorMessage, isDirectory } from './internal/values.js'
|
|
35
35
|
|
|
36
36
|
export interface OutputStyle {
|
|
37
37
|
name: string
|
|
@@ -121,6 +121,25 @@ export function pluginStyleDirs(home: string): string[] {
|
|
|
121
121
|
})
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** One style file, or undefined when it cannot be used. A directory named *.md or an
|
|
125
|
+
* unreadable file is skipped silently. pi's frontmatter parser is strict YAML and throws:
|
|
126
|
+
* one unparseable file must cost that style, not every style of the session. */
|
|
127
|
+
function readStyleFile(dir: string, entry: string): OutputStyle | undefined {
|
|
128
|
+
const file = path.join(dir, entry)
|
|
129
|
+
let content: string
|
|
130
|
+
try {
|
|
131
|
+
content = fs.readFileSync(file, 'utf-8')
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return parseStyle(content, entry.replace(/\.md$/, ''))
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.warn(`pi-code-output-styles: skipping ${file}: ${errorMessage(error).split('\n')[0]}`)
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
124
143
|
/** All output styles, project entries overriding user entries of the same name. */
|
|
125
144
|
export function loadStyles(dirs: string[]): OutputStyle[] {
|
|
126
145
|
const byName = new Map<string, OutputStyle>()
|
|
@@ -131,16 +150,9 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
|
|
|
131
150
|
} catch {
|
|
132
151
|
continue
|
|
133
152
|
}
|
|
134
|
-
for (const entry of entries) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
content = fs.readFileSync(path.join(dir, entry), 'utf-8')
|
|
139
|
-
} catch {
|
|
140
|
-
continue // a directory named *.md or an unreadable file must not take down session start
|
|
141
|
-
}
|
|
142
|
-
const style = parseStyle(content, entry.replace(/\.md$/, ''))
|
|
143
|
-
byName.set(style.name, style)
|
|
153
|
+
for (const entry of entries.filter((name) => name.endsWith('.md'))) {
|
|
154
|
+
const style = readStyleFile(dir, entry)
|
|
155
|
+
if (style) byName.set(style.name, style)
|
|
144
156
|
}
|
|
145
157
|
}
|
|
146
158
|
return [...byName.values()]
|
package/extensions/question.ts
CHANGED
|
@@ -190,6 +190,9 @@ export default function question(pi: ExtensionAPI) {
|
|
|
190
190
|
description:
|
|
191
191
|
'Ask the user a question and let them pick from options. Use when you need user input to proceed. Pass question and options, for example {"question": "Which one?", "options": [{"label": "alpha"}, {"label": "beta"}]}. To ask 2-4 questions at once, pass questions instead, with the same fields per entry.',
|
|
192
192
|
parameters: QuestionParams,
|
|
193
|
+
// The host shows a custom dialog in one editor slot, so two questions running in
|
|
194
|
+
// parallel leave the first unanswerable and the run unable to finish.
|
|
195
|
+
executionMode: 'sequential',
|
|
193
196
|
|
|
194
197
|
async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
|
|
195
198
|
const specs = questionList(rawParams as Partial<QuestionSpec> & { questions?: QuestionSpec[] })
|
|
@@ -35,7 +35,7 @@ import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks/index.js
|
|
|
35
35
|
import { claudeEffortLevel } from './internal/effort.js'
|
|
36
36
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
37
37
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
38
|
-
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
38
|
+
import { approvalRecheck, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
39
39
|
import { gitRoot, repoRoot } from './internal/project-root.js'
|
|
40
40
|
import { readSettingsChain } from './internal/settings-chain.js'
|
|
41
41
|
import { watchSettingsFiles } from './internal/settings-watch.js'
|
|
@@ -503,10 +503,15 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
503
503
|
armRefresh()
|
|
504
504
|
// Claude re-runs the script when the statusLine settings change mid-session; a
|
|
505
505
|
// command change re-resolves and re-runs.
|
|
506
|
+
// The reload asks again rather than reusing `trusted` (see approvalRecheck), and reads
|
|
507
|
+
// the chain that answer allows: a statusLine command is executed, not just displayed.
|
|
508
|
+
const watchCwd = ctx.cwd
|
|
509
|
+
const stillApproved = approvalRecheck(ctx)
|
|
506
510
|
disposeSettingsWatch()
|
|
507
511
|
disposeSettingsWatch = watchSettingsFiles(files, () => {
|
|
508
512
|
const previousCommand = config?.command
|
|
509
|
-
|
|
513
|
+
const liveFiles = hookFiles(watchCwd, os.homedir(), trusted && stillApproved())
|
|
514
|
+
config = readDisableAllHooks(liveFiles) ? undefined : readStatusLineConfig(liveFiles)
|
|
510
515
|
armRefresh()
|
|
511
516
|
// The debounce batches rapid triggers, but a command the user just edited has
|
|
512
517
|
// nothing to batch with: run it now so the result of the edit is immediate.
|
|
@@ -559,5 +564,9 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
559
564
|
pi.on('session_shutdown', async () => {
|
|
560
565
|
clearInterval(refreshTimer)
|
|
561
566
|
clearTimeout(debounceTimer)
|
|
567
|
+
// pi's CLI loads a fresh extension instance for every session replacement, so no later
|
|
568
|
+
// session_start reaches this watcher: left armed, it polls for the life of the process.
|
|
569
|
+
disposeSettingsWatch()
|
|
570
|
+
disposeSettingsWatch = () => {}
|
|
562
571
|
})
|
|
563
572
|
}
|
|
@@ -133,16 +133,22 @@ function appendPartialNote(result: SingleResult): void {
|
|
|
133
133
|
* try/catch around an inline spawn() call loses. */
|
|
134
134
|
export function spawnChild(command: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): { proc: ChildProcessByStdio<null, Readable, Readable> } | { error: Error } {
|
|
135
135
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
136
|
+
const proc = spawn(command, args, {
|
|
137
|
+
...options,
|
|
138
|
+
shell: false,
|
|
139
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
140
|
+
// Its own group, so an abort reaches grandchildren too: killing only the
|
|
141
|
+
// direct child orphans a build or dev server the agent started.
|
|
142
|
+
detached: true,
|
|
143
|
+
})
|
|
144
|
+
// A third failure shape: at the descriptor limit (EMFILE, ENFILE) node returns a child
|
|
145
|
+
// with no stdio and emits 'error' on the next tick. The callers wire stdout first, so
|
|
146
|
+
// the deferred event would find no listener, and an unhandled 'error' exits pi.
|
|
147
|
+
if (!proc.stdout) {
|
|
148
|
+
proc.on('error', () => {})
|
|
149
|
+
return { error: new Error('spawn returned no stdio: the file descriptor limit is likely exhausted (EMFILE)') }
|
|
145
150
|
}
|
|
151
|
+
return { proc }
|
|
146
152
|
} catch (error) {
|
|
147
153
|
return { error: error as Error }
|
|
148
154
|
}
|
|
@@ -338,7 +344,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
338
344
|
proc.on('close', (code) => {
|
|
339
345
|
cleanup()
|
|
340
346
|
if (buffer.trim()) processLine(buffer)
|
|
341
|
-
|
|
347
|
+
// A capped run was stopped by this module's own SIGTERM, which pi's print mode
|
|
348
|
+
// answers with exit 143: that is not the agent failing, as background.ts also holds.
|
|
349
|
+
resolve(currentResult.partial ? 0 : (code ?? 0))
|
|
342
350
|
})
|
|
343
351
|
|
|
344
352
|
proc.on('error', (error: Error) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.66",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -63,15 +63,15 @@
|
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "^2.5.4",
|
|
66
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
67
|
-
"@earendil-works/pi-ai": "^0.
|
|
68
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
69
|
-
"@earendil-works/pi-tui": "^0.
|
|
66
|
+
"@earendil-works/pi-agent-core": "^0.85.1",
|
|
67
|
+
"@earendil-works/pi-ai": "^0.85.1",
|
|
68
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
69
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
70
70
|
"@types/node": "^26.1.1",
|
|
71
|
-
"@vitest/coverage-v8": "^
|
|
71
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
72
72
|
"fast-check": "^4.9.0",
|
|
73
73
|
"knip": "^6.34.0",
|
|
74
74
|
"typescript": "^7.0.2",
|
|
75
|
-
"vitest": "^
|
|
75
|
+
"vitest": "^5.0.0"
|
|
76
76
|
}
|
|
77
77
|
}
|