pi-code 1.0.50 → 1.0.52
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/claude-rules.ts +41 -6
- package/extensions/commands.ts +2 -2
- package/extensions/env-settings.ts +10 -8
- package/extensions/hooks/config.ts +44 -4
- package/extensions/hooks/decisions.ts +13 -1
- package/extensions/internal/atomic-write.ts +14 -0
- package/extensions/internal/bash-rules.ts +13 -3
- package/extensions/internal/mcp-oauth.ts +27 -6
- package/extensions/internal/plugins.ts +14 -0
- package/extensions/mcp/config.ts +4 -2
- package/extensions/mcp/index.ts +23 -3
- package/extensions/mcp/oauth-flow.ts +1 -0
- package/extensions/mcp/transport.ts +8 -3
- package/extensions/memory.ts +1 -8
- package/extensions/output-styles.ts +32 -16
- package/extensions/skills.ts +2 -2
- package/extensions/status-line.ts +8 -2
- package/extensions/subagent/agents.ts +5 -2
- package/extensions/subagent/background.ts +14 -2
- package/extensions/subagent/index.ts +18 -26
- package/package.json +1 -1
|
@@ -39,12 +39,47 @@ function unquote(value: string): string {
|
|
|
39
39
|
return value.replace(/^["']|["']$/g, '')
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Split a YAML flow sequence on its top-level commas. A comma inside a brace group
|
|
43
|
+
* (`*.{ts,tsx}`, which Claude expands) or inside quotes belongs to its entry, so
|
|
44
|
+
* splitting on every comma produced patterns that match nothing. */
|
|
42
45
|
function splitInline(value: string): string[] {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const entries: string[] = []
|
|
47
|
+
let current = ''
|
|
48
|
+
let depth = 0
|
|
49
|
+
let quote: string | undefined
|
|
50
|
+
for (const char of value.replace(/^\[/, '').replace(/\]$/, '')) {
|
|
51
|
+
if (quote !== undefined) {
|
|
52
|
+
if (char === quote) quote = undefined
|
|
53
|
+
current += char
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (char === '"' || char === "'") {
|
|
57
|
+
quote = char
|
|
58
|
+
current += char
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (char === '{') depth++
|
|
62
|
+
else if (char === '}') depth = Math.max(0, depth - 1)
|
|
63
|
+
else if (char === ',' && depth === 0) {
|
|
64
|
+
entries.push(current)
|
|
65
|
+
current = ''
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
current += char
|
|
69
|
+
}
|
|
70
|
+
entries.push(current)
|
|
71
|
+
return entries.map((entry) => unquote(entry.trim())).filter(Boolean)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A flow sequence that opens on the `paths:` line and closes on a later one, joined
|
|
75
|
+
* back into a single value. An unterminated list runs to the end of the frontmatter. */
|
|
76
|
+
function joinFlowSequence(lines: string[], index: number, opening: string): string {
|
|
77
|
+
let joined = opening
|
|
78
|
+
for (let i = index + 1; i < lines.length; i++) {
|
|
79
|
+
joined += ` ${lines[i].trim()}`
|
|
80
|
+
if (lines[i].includes(']')) break
|
|
81
|
+
}
|
|
82
|
+
return joined
|
|
48
83
|
}
|
|
49
84
|
|
|
50
85
|
function parsePaths(frontmatter: string): string[] {
|
|
@@ -52,7 +87,7 @@ function parsePaths(frontmatter: string): string[] {
|
|
|
52
87
|
const index = lines.findIndex((line) => /^\s*paths\s*:/.test(line))
|
|
53
88
|
if (index === -1) return []
|
|
54
89
|
const inline = lines[index].replace(/^\s*paths\s*:/, '').trim()
|
|
55
|
-
if (inline) return splitInline(inline)
|
|
90
|
+
if (inline) return splitInline(inline.startsWith('[') && !inline.includes(']') ? joinFlowSequence(lines, index, inline) : inline)
|
|
56
91
|
const items: string[] = []
|
|
57
92
|
// Matched with string ops rather than a regex: the equivalent pattern needs two
|
|
58
93
|
// adjacent whitespace quantifiers, which backtracks super-linearly on long lines.
|
package/extensions/commands.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { claudeConfigDir } from './internal/config-dir.js'
|
|
|
50
50
|
import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
|
|
51
51
|
import { capForContext } from './internal/output-guard.js'
|
|
52
52
|
import { matchesPathRules } from './internal/path-rules.js'
|
|
53
|
-
import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
|
|
53
|
+
import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
54
54
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
55
55
|
import { ancestorDirs, repoRoot } from './internal/project-root.js'
|
|
56
56
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
@@ -146,7 +146,7 @@ function pluginCommands(plugins: InstalledPlugin[]): DiscoveredCommand[] {
|
|
|
146
146
|
const found: DiscoveredCommand[] = []
|
|
147
147
|
for (const plugin of plugins) {
|
|
148
148
|
const declared = plugin.manifest.commands
|
|
149
|
-
const dirs = (Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'commands']).map((entry) =>
|
|
149
|
+
const dirs = (Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'commands']).map((entry) => pluginComponentPath(plugin, String(entry))).filter((dir): dir is string => dir !== undefined)
|
|
150
150
|
for (const dir of dirs) {
|
|
151
151
|
for (const command of discoverCommandFiles(dir)) {
|
|
152
152
|
found.push({ name: `${plugin.name}:${command.name}`, filePath: command.filePath, plugin: { root: plugin.root, dataDir: plugin.dataDir, ...(plugin.userConfig ? { userConfig: plugin.userConfig } : {}) } })
|
|
@@ -40,7 +40,7 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-a
|
|
|
40
40
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
41
41
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
42
42
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
43
|
-
import {
|
|
43
|
+
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
44
44
|
|
|
45
45
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
46
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
@@ -137,12 +137,14 @@ export function sanitizeProjectEnv(env: Record<string, string>, warn: (key: stri
|
|
|
137
137
|
return kept
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
/** The project scope's env
|
|
141
|
-
*
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
140
|
+
/** The project scope's env, resolved through the shared settings chain so placement
|
|
141
|
+
* matches every other consumer: the shared settings.json comes from the session's own
|
|
142
|
+
* directory and never an ancestor, settings.local.json from the repository root. Later
|
|
143
|
+
* files win. */
|
|
144
|
+
function projectEnv(cwd: string, home: string): Record<string, string> {
|
|
145
|
+
const merged: Record<string, string> = {}
|
|
146
|
+
for (const file of claudeSettingsChain(cwd, home, true).slice(1)) Object.assign(merged, envFromSettings(readSettingsFile(file)))
|
|
147
|
+
return sanitizeProjectEnv(merged)
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
@@ -157,6 +159,6 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
|
157
159
|
apply(os.homedir(), {})
|
|
158
160
|
|
|
159
161
|
pi.on('session_start', async (_event, ctx: ExtensionContext) => {
|
|
160
|
-
apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd) : {})
|
|
162
|
+
apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd, os.homedir()) : {})
|
|
161
163
|
})
|
|
162
164
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import * as fs from 'node:fs'
|
|
8
8
|
import * as path from 'node:path'
|
|
9
9
|
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
10
|
-
import { type InstalledPlugin, substitutePluginVars } from '../internal/plugins.js'
|
|
10
|
+
import { type InstalledPlugin, pluginComponentPath, substitutePluginVars } from '../internal/plugins.js'
|
|
11
11
|
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
12
12
|
|
|
13
13
|
export interface HookCommand {
|
|
@@ -227,6 +227,44 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
227
227
|
* every hook the plugin declared silently vanished. */
|
|
228
228
|
const jsonEscape = (value: string): string => JSON.stringify(value).slice(1, -1)
|
|
229
229
|
|
|
230
|
+
/** A reference Claude refuses to substitute into anything that reaches a shell. */
|
|
231
|
+
const USER_CONFIG_REF = /\$\{user_config\./
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Drop shell-form hook commands that reference `${user_config.*}`. Claude: "Fields that
|
|
235
|
+
* run in a shell reject `${user_config.*}`: substituting a configured value into a shell
|
|
236
|
+
* command would let the shell run whatever that value contains, so the component fails
|
|
237
|
+
* with an error instead." Exec form (`args`) and every other field still substitute, and
|
|
238
|
+
* the documented alternative is reading CLAUDE_PLUGIN_OPTION_<KEY> from the hook's
|
|
239
|
+
* environment. Sibling hooks in the same file are unaffected: one rejected component
|
|
240
|
+
* costs itself, not the plugin's other hooks.
|
|
241
|
+
*/
|
|
242
|
+
function withoutUserConfigShellCommands(raw: string, source: string): string {
|
|
243
|
+
let parsed: { hooks?: Record<string, unknown> }
|
|
244
|
+
try {
|
|
245
|
+
parsed = JSON.parse(raw)
|
|
246
|
+
} catch {
|
|
247
|
+
return raw // mergeHooksJson reports the parse failure
|
|
248
|
+
}
|
|
249
|
+
let dropped = false
|
|
250
|
+
for (const matchers of Object.values(parsed?.hooks ?? {})) {
|
|
251
|
+
if (!Array.isArray(matchers)) continue
|
|
252
|
+
for (const entry of matchers) {
|
|
253
|
+
const record = entry as { hooks?: unknown }
|
|
254
|
+
if (!Array.isArray(record.hooks)) continue
|
|
255
|
+
const kept = record.hooks.filter((hook) => {
|
|
256
|
+
const candidate = hook as { command?: unknown; args?: unknown }
|
|
257
|
+
if (Array.isArray(candidate.args) || typeof candidate.command !== 'string' || !USER_CONFIG_REF.test(candidate.command)) return true
|
|
258
|
+
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`)
|
|
259
|
+
dropped = true
|
|
260
|
+
return false
|
|
261
|
+
})
|
|
262
|
+
record.hooks = kept
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return dropped ? JSON.stringify(parsed) : raw
|
|
266
|
+
}
|
|
267
|
+
|
|
230
268
|
export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
|
|
231
269
|
for (const plugin of plugins) {
|
|
232
270
|
const declared = plugin.manifest.hooks
|
|
@@ -234,12 +272,14 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
|
|
|
234
272
|
// numeric event keys), so it falls through to the default path rather than
|
|
235
273
|
// silently registering nothing.
|
|
236
274
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
237
|
-
|
|
275
|
+
const inlineSource = `${plugin.name} (plugin.json)`
|
|
276
|
+
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(JSON.stringify({ hooks: declared }), inlineSource), plugin, jsonEscape), inlineSource, sources, `plugin:${plugin.name}`)
|
|
238
277
|
continue
|
|
239
278
|
}
|
|
240
|
-
const file =
|
|
279
|
+
const file = pluginComponentPath(plugin, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
280
|
+
if (file === undefined) continue
|
|
241
281
|
try {
|
|
242
|
-
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin, jsonEscape), file, sources, `plugin:${plugin.name}`)
|
|
282
|
+
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(fs.readFileSync(file, 'utf-8'), file), plugin, jsonEscape), file, sources, `plugin:${plugin.name}`)
|
|
243
283
|
} catch {
|
|
244
284
|
// a plugin without hooks contributes nothing
|
|
245
285
|
}
|
|
@@ -147,7 +147,19 @@ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[],
|
|
|
147
147
|
// Claude shows a `<hook> hook error` notice when {..}-shaped stdout cannot be
|
|
148
148
|
// read as JSON output (exit 2 still blocks and reads its own channels).
|
|
149
149
|
const jsonError = result.code === 2 ? undefined : hookJsonError(result.stdout)
|
|
150
|
-
if (jsonError !== undefined)
|
|
150
|
+
if (jsonError !== undefined) {
|
|
151
|
+
notify(`${commands[i].command} hook error: ${jsonError}`)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
// A non-zero exit that is neither a block (2) nor a timeout, with nothing parseable
|
|
155
|
+
// on stdout, is Claude's non-blocking error: the action proceeds and the notice
|
|
156
|
+
// carries the first line of stderr. Without it a mistyped path in settings.json
|
|
157
|
+
// leaves a policy hook silently disabled, since the shell exits 127 and says so only
|
|
158
|
+
// on stderr. A spawn failure is reported above and skipped here.
|
|
159
|
+
if (!result.spawnFailed && !result.timedOut && result.code !== 0 && result.code !== 2) {
|
|
160
|
+
const firstLine = result.stderr.trim().split('\n')[0]
|
|
161
|
+
notify(`${commands[i].command} hook error: Failed with non-blocking status code: ${firstLine || result.code}`)
|
|
162
|
+
}
|
|
151
163
|
}
|
|
152
164
|
}
|
|
153
165
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace a file through a temp file and a rename, so a crash mid-write cannot leave the
|
|
3
|
+
* target truncated. Used wherever pi-code rewrites a file the user owns and would have to
|
|
4
|
+
* repair by hand: settings and the memory index.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs'
|
|
8
|
+
|
|
9
|
+
/** The tmp name carries the pid so concurrent processes do not collide. */
|
|
10
|
+
export function atomicWriteFile(filePath: string, content: string): void {
|
|
11
|
+
const tmp = `${filePath}.${process.pid}.tmp`
|
|
12
|
+
fs.writeFileSync(tmp, content)
|
|
13
|
+
fs.renameSync(tmp, filePath)
|
|
14
|
+
}
|
|
@@ -14,10 +14,20 @@ import { hasSubstitution, splitSegments } from './shell-split.js'
|
|
|
14
14
|
|
|
15
15
|
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* One rule against one command segment, per Claude's permission table:
|
|
19
|
+
* - `*` stands in for whatever text is in its place.
|
|
20
|
+
* - a trailing `*` with a space before it also matches the bare command, but only when
|
|
21
|
+
* it is the rule's only wildcard (`* --help *` does not match `npm --help`).
|
|
22
|
+
* - that space is part of the rule, so `ls *` does not match `lsof` while `ls*` does.
|
|
23
|
+
* - `:*` is an equivalent spelling of a trailing ` *`, recognized only at the end; a
|
|
24
|
+
* colon anywhere else is a literal character.
|
|
25
|
+
*/
|
|
17
26
|
function matchesRule(segment: string, rule: string): boolean {
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
return segment
|
|
27
|
+
const normalized = rule.endsWith(':*') ? `${rule.slice(0, -2)} *` : rule
|
|
28
|
+
if (normalized.endsWith(' *') && normalized.indexOf('*') === normalized.length - 1 && segment === normalized.slice(0, -2)) return true
|
|
29
|
+
if (normalized.includes('*')) return new RegExp(`^${normalized.split('*').map(escapeRegExp).join('[^]*')}$`).test(segment)
|
|
30
|
+
return segment === normalized
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
export function matchesBashRules(command: string, rules: string[]): boolean {
|
|
@@ -39,10 +39,26 @@ export interface OAuthServerConfig {
|
|
|
39
39
|
authServerMetadataUrl?: string
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** The origin a sign-in belongs to, so the same server name at a different endpoint gets
|
|
43
|
+
* its own store. An unparseable url falls back to its raw text rather than to nothing. */
|
|
44
|
+
function endpointKey(endpoint: string | undefined): string {
|
|
45
|
+
if (!endpoint) return ''
|
|
46
|
+
try {
|
|
47
|
+
return new URL(endpoint).origin
|
|
48
|
+
} catch {
|
|
49
|
+
return endpoint
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
/** A server name is config-controlled text; the digest keeps hostile names inside
|
|
43
|
-
* the store directory and distinct names from colliding after sanitization.
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
* the store directory and distinct names from colliding after sanitization. The endpoint
|
|
55
|
+
* rides the digest so a second project reusing a name cannot read the first one's tokens. */
|
|
56
|
+
function storeFileFor(serverName: string, endpoint?: string): string {
|
|
57
|
+
const digest = crypto
|
|
58
|
+
.createHash('sha256')
|
|
59
|
+
.update(`${serverName}\n${endpointKey(endpoint)}`)
|
|
60
|
+
.digest('hex')
|
|
61
|
+
.slice(0, 8)
|
|
46
62
|
// Collapse disallowed runs to a single hyphen, then strip leading and trailing
|
|
47
63
|
// hyphens by index. The old /^-+|-+$/g trim rescanned on every hyphen of a long run
|
|
48
64
|
// (its trailing-anchored branch backtracks per start position), which is quadratic.
|
|
@@ -66,8 +82,8 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
66
82
|
// page cannot inject an authorization code into this login (RFC 8252 8.9).
|
|
67
83
|
private readonly loginState = crypto.randomBytes(16).toString('hex')
|
|
68
84
|
|
|
69
|
-
constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void, oauth?: OAuthServerConfig) {
|
|
70
|
-
this.storePath = storeFileFor(serverName)
|
|
85
|
+
constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void, oauth?: OAuthServerConfig, endpoint?: string) {
|
|
86
|
+
this.storePath = storeFileFor(serverName, endpoint)
|
|
71
87
|
this.onRedirect = onRedirect
|
|
72
88
|
this.oauth = oauth
|
|
73
89
|
try {
|
|
@@ -228,7 +244,12 @@ export function openBrowser(url: string): void {
|
|
|
228
244
|
else if (process.platform === 'win32') command = 'cmd'
|
|
229
245
|
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
230
246
|
try {
|
|
231
|
-
spawn(command, args, { stdio: 'ignore', detached: true })
|
|
247
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true })
|
|
248
|
+
// A missing launcher (a container or an SSH session with no xdg-open) reports itself
|
|
249
|
+
// asynchronously through 'error'; with no listener node raises it as an
|
|
250
|
+
// uncaughtException and pi exits. The caller has already shown the URL to open by hand.
|
|
251
|
+
child.on('error', () => {})
|
|
252
|
+
child.unref()
|
|
232
253
|
} catch {
|
|
233
254
|
// the notified URL is the fallback
|
|
234
255
|
}
|
|
@@ -243,6 +243,20 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
|
|
|
243
243
|
* substituting into text that is still raw JSON must pass an escapeValue that
|
|
244
244
|
* JSON-escapes: a Windows root (C:\Users\...) inserted verbatim injects invalid
|
|
245
245
|
* escape sequences and the subsequent parse throws. */
|
|
246
|
+
/** A plugin component path, resolved inside the plugin root. Claude "rejects a component
|
|
247
|
+
* path that resolves outside the plugin root, such as `../shared-utils`", so an escaping
|
|
248
|
+
* entry yields undefined and its caller skips that component. The check is lexical, which
|
|
249
|
+
* is the rule as documented: a plugin's own root may itself be a symlink (link mode). */
|
|
250
|
+
export function pluginComponentPath(plugin: Pick<InstalledPlugin, 'name' | 'root'>, declared: string): string | undefined {
|
|
251
|
+
const resolved = path.resolve(plugin.root, declared)
|
|
252
|
+
const inside = path.relative(plugin.root, resolved)
|
|
253
|
+
if (inside !== '' && (inside.startsWith(`..${path.sep}`) || inside === '..' || path.isAbsolute(inside))) {
|
|
254
|
+
console.warn(`pi-code-plugins: plugin ${plugin.name} declares the component path "${declared}", which resolves outside its root; ignoring it`)
|
|
255
|
+
return undefined
|
|
256
|
+
}
|
|
257
|
+
return resolved
|
|
258
|
+
}
|
|
259
|
+
|
|
246
260
|
export function substitutePluginVars(value: string, plugin: InstalledPlugin, escapeValue: (substituted: string) => string = (substituted) => substituted): string {
|
|
247
261
|
return value
|
|
248
262
|
.replaceAll('${CLAUDE_PLUGIN_ROOT}', escapeValue(plugin.root))
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -8,7 +8,7 @@ import * as os from 'node:os'
|
|
|
8
8
|
import * as path from 'node:path'
|
|
9
9
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
10
|
import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
|
|
11
|
-
import type
|
|
11
|
+
import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
|
|
12
12
|
import { findNearestFile } from '../internal/project-root.js'
|
|
13
13
|
|
|
14
14
|
export interface StdioServerConfig {
|
|
@@ -156,7 +156,9 @@ function rawPluginServerEntries(plugin: InstalledPlugin): Record<string, unknown
|
|
|
156
156
|
const servers: Record<string, unknown> = {}
|
|
157
157
|
for (const entry of paths) {
|
|
158
158
|
try {
|
|
159
|
-
const
|
|
159
|
+
const file = pluginComponentPath(plugin, entry)
|
|
160
|
+
if (file === undefined) continue
|
|
161
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
160
162
|
Object.assign(servers, parsed.mcpServers ?? {})
|
|
161
163
|
} catch {
|
|
162
164
|
// Malformed or missing JSON contributes no entries.
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -494,6 +494,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
494
494
|
* factory: pi runs the factory for invocations that never start a session. Names still
|
|
495
495
|
* connected are filtered out, so a later session start only retries servers that failed
|
|
496
496
|
* or whose transport dropped, without duplicate-name warnings. */
|
|
497
|
+
/** A project server's headersHelper, dropped while the project is unapproved. */
|
|
498
|
+
function withoutUntrustedHelper(name: string, config: ServerConfig): ServerConfig {
|
|
499
|
+
if (!('headersHelper' in config) || config.headersHelper === undefined) return config
|
|
500
|
+
console.warn(`pi-code-mcp: headersHelper not run for server ${name}: the project is not trusted yet; connecting with its static headers alone`)
|
|
501
|
+
const { headersHelper: _dropped, ...rest } = config
|
|
502
|
+
return rest as ServerConfig
|
|
503
|
+
}
|
|
504
|
+
|
|
497
505
|
async function connectNormalScopes(ctx: ExtensionContext, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
|
|
498
506
|
// Plugin servers merge under the user scope (plugins are user-installed);
|
|
499
507
|
// the user's own entry wins a name clash with a plugin's. A server toggled off
|
|
@@ -510,7 +518,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
510
518
|
// a deliberate narrowing of Claude's rule to keep the safe default.
|
|
511
519
|
// The stored project decision, read without prompting: consent recorded inside
|
|
512
520
|
// the project only counts once the project itself has been approved.
|
|
513
|
-
const
|
|
521
|
+
const projectApproved = isProjectApprovedSilently(ctx)
|
|
522
|
+
const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), projectApproved)
|
|
514
523
|
// Tag the scope on each project server: a repository-supplied headersHelper runs
|
|
515
524
|
// with credential variables stripped, unlike a user-scope one.
|
|
516
525
|
const projectServers = Object.fromEntries(Object.entries(loadConfigFrom(projectConfigPaths(ctx.cwd))).map(([name, config]) => [name, { ...config, projectScope: true }]))
|
|
@@ -519,7 +528,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
519
528
|
// stays with the local (user-side) definition, so the project's entry is dropped
|
|
520
529
|
// here rather than allowed to shadow it.
|
|
521
530
|
const localNames = localScopeServerNames(os.homedir(), ctx.cwd)
|
|
522
|
-
|
|
531
|
+
// Claude: until the folder is trusted, a project server connects with its static
|
|
532
|
+
// headers alone. Consenting to the server is not consenting to run the command it
|
|
533
|
+
// ships, so the helper is dropped (and named once) while the project is unapproved.
|
|
534
|
+
const consented = Object.fromEntries(
|
|
535
|
+
Object.entries(consentedRaw)
|
|
536
|
+
.filter(([name]) => !localNames.has(name))
|
|
537
|
+
.map(([name, config]) => [name, projectApproved ? config : withoutUntrustedHelper(name, config)]),
|
|
538
|
+
)
|
|
523
539
|
const projectWinners = new Set(Object.keys(consented))
|
|
524
540
|
const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
|
|
525
541
|
// The consented project servers carry no ordering dependency on the user scope:
|
|
@@ -547,8 +563,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
547
563
|
// is why serverToolCount reads from `registered` to recover the true count here.
|
|
548
564
|
status.clear()
|
|
549
565
|
// A same-process session switch (/new, /resume) shut the last session down;
|
|
550
|
-
// this one may reconnect again.
|
|
566
|
+
// this one may reconnect again. projectConnected guards against connecting the project
|
|
567
|
+
// scope twice within one session, so it belongs to the session that set it: leaving it
|
|
568
|
+
// set here left every project server disconnected for the rest of the process, since
|
|
569
|
+
// the shutdown had already dropped their clients.
|
|
551
570
|
shuttingDown = false
|
|
571
|
+
projectConnected = false
|
|
552
572
|
// Claude answers roots/list with the session's launch directory and exports the
|
|
553
573
|
// project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
|
|
554
574
|
sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
|
|
@@ -55,6 +55,7 @@ export async function runInteractiveOAuth(name: string, config: { url: string; o
|
|
|
55
55
|
authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
|
|
56
56
|
},
|
|
57
57
|
config.oauth,
|
|
58
|
+
config.url,
|
|
58
59
|
)
|
|
59
60
|
const { server, port } = await startCallbackServer(provider.savedRedirectPort())
|
|
60
61
|
provider.bindRedirectPort(port)
|
|
@@ -260,8 +260,13 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
|
|
|
260
260
|
const token = resolveBearerToken(config)
|
|
261
261
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
262
262
|
// A headersHelper generates connect-time headers for non-OAuth auth schemes; its
|
|
263
|
-
// JSON stdout merges over the static headers.
|
|
264
|
-
|
|
263
|
+
// JSON stdout merges over the static headers. The command text is NOT interpolated:
|
|
264
|
+
// Claude expands ${VAR} in command, args, env, url and headers, and expanding it here
|
|
265
|
+
// would read the parent environment, which is the credential set a repository- or
|
|
266
|
+
// plugin-supplied helper must not see. The helper's own shell expands it against the
|
|
267
|
+
// stripped environment instead. Plugin path variables are already substituted in
|
|
268
|
+
// mcp/config.ts, which also refuses ${user_config.*} in a helper.
|
|
269
|
+
if (config.headersHelper) Object.assign(headers, await runHeadersHelper(config.headersHelper, helperEnv(name, config)))
|
|
265
270
|
warnMissing()
|
|
266
271
|
// Claude: a configured Authorization header, whether static, a bearer token, or
|
|
267
272
|
// helper output, is the server's authentication; there is no OAuth fallback for it.
|
|
@@ -367,7 +372,7 @@ async function connectHttpFamily(name: string, config: { url: string; oauth?: OA
|
|
|
367
372
|
// dynamic registration, keeping it bound to the real callback port. A
|
|
368
373
|
// pre-configured client (oauth.clientId) rides the silent provider too, so its
|
|
369
374
|
// stored tokens refresh with the configured credentials.
|
|
370
|
-
const silent = hasConfiguredAuth ? undefined : new FileOAuthProvider(name, () => {}, config.oauth)
|
|
375
|
+
const silent = hasConfiguredAuth ? undefined : new FileOAuthProvider(name, () => {}, config.oauth, config.url)
|
|
371
376
|
try {
|
|
372
377
|
const client = newClient()
|
|
373
378
|
await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
|
package/extensions/memory.ts
CHANGED
|
@@ -14,6 +14,7 @@ import * as path from 'node:path'
|
|
|
14
14
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
15
15
|
import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
16
16
|
import { Type } from 'typebox'
|
|
17
|
+
import { atomicWriteFile } from './internal/atomic-write.js'
|
|
17
18
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
18
19
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
19
20
|
import { capForContext } from './internal/output-guard.js'
|
|
@@ -322,14 +323,6 @@ function readIndexQuietly(dir: string): string {
|
|
|
322
323
|
}
|
|
323
324
|
}
|
|
324
325
|
|
|
325
|
-
/** Write through a temp file and rename onto the target, so a crash mid-write cannot
|
|
326
|
-
* truncate it. The tmp name carries the pid so concurrent processes do not collide. */
|
|
327
|
-
function atomicWriteFile(filePath: string, content: string): void {
|
|
328
|
-
const tmp = `${filePath}.${process.pid}.tmp`
|
|
329
|
-
fs.writeFileSync(tmp, content)
|
|
330
|
-
fs.renameSync(tmp, filePath)
|
|
331
|
-
}
|
|
332
|
-
|
|
333
326
|
/** Replace the index through a rename so a crash mid-write cannot truncate it. */
|
|
334
327
|
function writeIndex(indexPath: string, content: string): void {
|
|
335
328
|
atomicWriteFile(indexPath, content)
|
|
@@ -23,10 +23,11 @@ import * as fs from 'node:fs'
|
|
|
23
23
|
import * as os from 'node:os'
|
|
24
24
|
import * as path from 'node:path'
|
|
25
25
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
26
|
+
import { atomicWriteFile } from './internal/atomic-write.js'
|
|
26
27
|
|
|
27
28
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
28
29
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
29
|
-
import { installedPlugins } from './internal/plugins.js'
|
|
30
|
+
import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
30
31
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
31
32
|
import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
|
|
32
33
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
@@ -101,10 +102,10 @@ function isDirectory(target: string): boolean {
|
|
|
101
102
|
*/
|
|
102
103
|
export function styleDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
103
104
|
const dirs = [path.join(claudeConfigDir(home), 'output-styles')]
|
|
104
|
-
// Claude loads every .claude/output-styles between cwd and the repository root,
|
|
105
|
-
//
|
|
106
|
-
// so the
|
|
107
|
-
if (trusted) dirs.push(...ancestorDirs(cwd, path.join('.claude', 'output-styles')))
|
|
105
|
+
// Claude loads every .claude/output-styles between cwd and the repository root, using
|
|
106
|
+
// the one closest to cwd for a name clash. loadStyles keys by name and later dirs win,
|
|
107
|
+
// so the ancestors go farthest first and the nearest lands last.
|
|
108
|
+
if (trusted) dirs.push(...ancestorDirs(cwd, path.join('.claude', 'output-styles')).reverse())
|
|
108
109
|
return dirs.filter((dir) => isDirectory(dir))
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -118,7 +119,7 @@ export function pluginStyleDirs(home: string): string[] {
|
|
|
118
119
|
return installedPlugins(home).flatMap((plugin) => {
|
|
119
120
|
const declared = plugin.manifest.outputStyles
|
|
120
121
|
const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'output-styles']
|
|
121
|
-
return dirs.map((dir) =>
|
|
122
|
+
return dirs.map((dir) => pluginComponentPath(plugin, String(dir))).filter((dir): dir is string => dir !== undefined)
|
|
122
123
|
})
|
|
123
124
|
}
|
|
124
125
|
|
|
@@ -173,16 +174,31 @@ export function styleForName(styles: OutputStyle[], name: string | undefined): O
|
|
|
173
174
|
return name ? styles.find((style) => style.name === name) : undefined
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
/** Record the choice in the local settings file, returning a message to show when it
|
|
178
|
+
* cannot be recorded. The file also carries permissions, hooks and env, so a present but
|
|
179
|
+
* unparseable one is left alone rather than replaced by the style choice alone. */
|
|
180
|
+
function persistActiveStyle(file: string, name: string): string | undefined {
|
|
181
|
+
const label = path.basename(file)
|
|
182
|
+
let raw: string | undefined
|
|
178
183
|
try {
|
|
179
|
-
|
|
180
|
-
} catch {
|
|
181
|
-
//
|
|
184
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Only a missing file means start fresh; anything else is the user's file to keep.
|
|
187
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return `${label} could not be read; the style applies to this session only`
|
|
188
|
+
}
|
|
189
|
+
let config: Record<string, unknown> = {}
|
|
190
|
+
if (raw !== undefined) {
|
|
191
|
+
try {
|
|
192
|
+
const parsed: unknown = JSON.parse(raw)
|
|
193
|
+
if (parsed !== null && typeof parsed === 'object') config = parsed as Record<string, unknown>
|
|
194
|
+
} catch {
|
|
195
|
+
return `${label} is not valid JSON; the style applies to this session only`
|
|
196
|
+
}
|
|
182
197
|
}
|
|
183
198
|
config.outputStyle = name
|
|
184
199
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
185
|
-
|
|
200
|
+
atomicWriteFile(file, `${JSON.stringify(config, null, 2)}\n`)
|
|
201
|
+
return undefined
|
|
186
202
|
}
|
|
187
203
|
|
|
188
204
|
export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
@@ -236,8 +252,8 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
|
236
252
|
return
|
|
237
253
|
}
|
|
238
254
|
activeName = picked.name
|
|
239
|
-
persistActiveStyle(localSettingsPath, picked.name)
|
|
240
|
-
ctx.ui.notify(`Output style set to ${picked.name} (applies next turn)`, 'info')
|
|
255
|
+
const failure = persistActiveStyle(localSettingsPath, picked.name)
|
|
256
|
+
ctx.ui.notify(failure ?? `Output style set to ${picked.name} (applies next turn)`, failure ? 'error' : 'info')
|
|
241
257
|
return
|
|
242
258
|
}
|
|
243
259
|
if (!ctx.hasUI) {
|
|
@@ -253,8 +269,8 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
|
253
269
|
if (!choice) return
|
|
254
270
|
const picked = styles[labels.indexOf(choice)]
|
|
255
271
|
activeName = picked.name
|
|
256
|
-
persistActiveStyle(localSettingsPath, picked.name)
|
|
257
|
-
ctx.ui.notify(`Output style set to ${picked.name} (applies next turn)`, 'info')
|
|
272
|
+
const failure = persistActiveStyle(localSettingsPath, picked.name)
|
|
273
|
+
ctx.ui.notify(failure ?? `Output style set to ${picked.name} (applies next turn)`, failure ? 'error' : 'info')
|
|
258
274
|
},
|
|
259
275
|
})
|
|
260
276
|
}
|
package/extensions/skills.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { runAgent } from './internal/agent-run.js'
|
|
|
29
29
|
import { parseCommandFile } from './internal/command-file.js'
|
|
30
30
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
31
31
|
import { managedSettingsFile } from './internal/managed-settings.js'
|
|
32
|
-
import { installedPlugins } from './internal/plugins.js'
|
|
32
|
+
import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
33
33
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
34
34
|
import { ancestorDirs } from './internal/project-root.js'
|
|
35
35
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
@@ -58,7 +58,7 @@ export function skillDirs(cwd: string, home: string, trusted: boolean): string[]
|
|
|
58
58
|
for (const plugin of installedPlugins(home)) {
|
|
59
59
|
const declared = plugin.manifest.skills
|
|
60
60
|
const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'skills']
|
|
61
|
-
candidates.push(...dirs.map((dir) =>
|
|
61
|
+
candidates.push(...dirs.map((dir) => pluginComponentPath(plugin, String(dir))).filter((dir): dir is string => dir !== undefined))
|
|
62
62
|
}
|
|
63
63
|
// Claude loads skills from every .claude/skills between cwd and the repository
|
|
64
64
|
// root; the list goes nearest-first so findClaudeSkill's first match is the
|
|
@@ -457,14 +457,20 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
457
457
|
// Claude's disableAllHooks also turns off the custom statusLine command; the
|
|
458
458
|
// built-in segment still renders as the fallback.
|
|
459
459
|
config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
|
|
460
|
-
|
|
461
|
-
|
|
460
|
+
// Re-armed rather than armed once: a refreshInterval added or changed mid-session
|
|
461
|
+
// had no effect until the next session, though Claude applies a settings change as
|
|
462
|
+
// soon as the file is saved.
|
|
463
|
+
const armRefresh = (): void => {
|
|
464
|
+
clearInterval(refreshTimer)
|
|
465
|
+
refreshTimer = config?.refreshInterval ? setInterval(() => scheduleRefresh(), config.refreshInterval * 1000) : undefined
|
|
462
466
|
}
|
|
467
|
+
armRefresh()
|
|
463
468
|
// Claude re-runs the script when the statusLine settings change mid-session; a
|
|
464
469
|
// command change re-resolves and re-runs.
|
|
465
470
|
disposeSettingsWatch()
|
|
466
471
|
disposeSettingsWatch = watchSettingsFiles(files, () => {
|
|
467
472
|
config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
|
|
473
|
+
armRefresh()
|
|
468
474
|
scheduleRefresh()
|
|
469
475
|
})
|
|
470
476
|
show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
|
|
@@ -12,7 +12,7 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
|
|
|
12
12
|
// is not merely ignored, it narrows the child's registry.
|
|
13
13
|
import { parseToolGrants } from '../internal/command-file.js'
|
|
14
14
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
15
|
-
import { installedPlugins } from '../internal/plugins.js'
|
|
15
|
+
import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
|
|
16
16
|
import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
|
|
17
17
|
|
|
18
18
|
/**
|
|
@@ -353,7 +353,10 @@ function pluginAgentDirs(home: string): Array<{ dir: string; pluginName: string
|
|
|
353
353
|
return installedPlugins(home).flatMap((plugin) => {
|
|
354
354
|
const declared = plugin.manifest.agents
|
|
355
355
|
const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'agents']
|
|
356
|
-
return dirs
|
|
356
|
+
return dirs
|
|
357
|
+
.map((dir) => pluginComponentPath(plugin, String(dir)))
|
|
358
|
+
.filter((dir): dir is string => dir !== undefined)
|
|
359
|
+
.map((dir) => ({ dir, pluginName: plugin.name }))
|
|
357
360
|
})
|
|
358
361
|
}
|
|
359
362
|
|
|
@@ -177,6 +177,13 @@ export function backgroundStatusText(): string {
|
|
|
177
177
|
return formatStatus(runs.values())
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/** Every run this process knows about, live and finished. The registry is module state
|
|
181
|
+
* that outlives a session switch; a per-session list is not, because pi re-invokes each
|
|
182
|
+
* extension factory per session. */
|
|
183
|
+
export function allBackgroundRuns(): BackgroundRun[] {
|
|
184
|
+
return [...runs.values()]
|
|
185
|
+
}
|
|
186
|
+
|
|
180
187
|
/** A finished run, so a caller can continue its session with a follow-up task. */
|
|
181
188
|
export function backgroundRun(id: string): BackgroundRun | undefined {
|
|
182
189
|
return runs.get(id)
|
|
@@ -200,7 +207,10 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
200
207
|
const rebuilt = withRebuiltPrompt(run.spawn)
|
|
201
208
|
run.spawn = { ...run.spawn, args: rebuilt.args }
|
|
202
209
|
if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
|
|
203
|
-
|
|
210
|
+
// The task prompt is always the final argument: both spawn paths push it last and the
|
|
211
|
+
// invocation helper only prepends. Matching a 'Task: ' prefix missed it whenever
|
|
212
|
+
// SubagentStart hooks placed their context ahead of it, so the resume re-ran the old task.
|
|
213
|
+
const args = rebuilt.args.map((arg, index) => (index === rebuilt.args.length - 1 ? `Task: ${task}` : arg))
|
|
204
214
|
run.state = 'running'
|
|
205
215
|
run.task = task
|
|
206
216
|
run.output = undefined
|
|
@@ -279,7 +289,9 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
279
289
|
run.live = true
|
|
280
290
|
const killGroup = (signal: NodeJS.Signals): void => {
|
|
281
291
|
try {
|
|
282
|
-
|
|
292
|
+
// A child that never spawned has no pid and no group; the direct kill is all there is.
|
|
293
|
+
if (proc.pid) process.kill(-proc.pid, signal)
|
|
294
|
+
else proc.kill(signal)
|
|
283
295
|
} catch {
|
|
284
296
|
try {
|
|
285
297
|
proc.kill(signal)
|
|
@@ -34,7 +34,7 @@ import { runSubagentStartHooks } from '../internal/subagent-hooks.js'
|
|
|
34
34
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
35
35
|
import { skillDirs } from '../skills.js'
|
|
36
36
|
import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, expandMcpToolPatterns, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
37
|
-
import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
37
|
+
import { activeBackgroundRuns, allBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
38
38
|
import { type DisplayItem, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
|
|
39
39
|
import { type AgentWorktree, cleanupAgentWorktree, createAgentWorktree } from './worktree.js'
|
|
40
40
|
|
|
@@ -388,14 +388,19 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
388
388
|
resolve(code ?? 0)
|
|
389
389
|
})
|
|
390
390
|
|
|
391
|
-
proc.on('error', () => {
|
|
391
|
+
proc.on('error', (error: Error) => {
|
|
392
392
|
cleanup()
|
|
393
|
+
// A child that never started leaves no stdout and no stderr, so this message is the
|
|
394
|
+
// only diagnostic; without it the failure reads as "(no output)".
|
|
395
|
+
if (!currentResult.stderr) currentResult.stderr = error.message
|
|
393
396
|
resolve(1)
|
|
394
397
|
})
|
|
395
398
|
|
|
396
399
|
const killGroup = (sig: NodeJS.Signals): void => {
|
|
397
400
|
try {
|
|
398
|
-
|
|
401
|
+
// A child that never spawned has no pid and no group; the direct kill is all there is.
|
|
402
|
+
if (proc.pid) process.kill(-proc.pid, sig)
|
|
403
|
+
else proc.kill(sig)
|
|
399
404
|
} catch {
|
|
400
405
|
try {
|
|
401
406
|
proc.kill(sig)
|
|
@@ -564,7 +569,7 @@ function runOutputTail(run: BackgroundRunView): string | undefined {
|
|
|
564
569
|
/** The /tasks listing: one line per background run, plus the short output tail the
|
|
565
570
|
* registry's own status lines omit. A pure formatter so it tests against a plain list. */
|
|
566
571
|
export function tasksStatusText(runs: ReadonlyArray<BackgroundRunView>): string {
|
|
567
|
-
if (runs.length === 0) return 'No background subagent runs
|
|
572
|
+
if (runs.length === 0) return 'No background subagent runs.'
|
|
568
573
|
return runs
|
|
569
574
|
.map((run) => {
|
|
570
575
|
const plural = run.turns === 1 ? '' : 's'
|
|
@@ -856,7 +861,7 @@ interface BackgroundContext {
|
|
|
856
861
|
projectApproved: boolean
|
|
857
862
|
}
|
|
858
863
|
|
|
859
|
-
async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext
|
|
864
|
+
async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext): Promise<ToolResult> {
|
|
860
865
|
const { agents, defaultCwd, pi, makeDetails, skillRoots, availableModels, projectApproved } = context
|
|
861
866
|
const task = params.task
|
|
862
867
|
const agentName = params.agent
|
|
@@ -953,7 +958,6 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
|
|
|
953
958
|
removeTmpPrompt(tmpPrompt)
|
|
954
959
|
return backgroundCapResult(makeDetails)
|
|
955
960
|
}
|
|
956
|
-
onStarted?.(id)
|
|
957
961
|
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
|
|
958
962
|
return {
|
|
959
963
|
content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
|
|
@@ -1435,21 +1439,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1435
1439
|
if (isMcpToolAliases(data)) setKnownMcpAliases(data)
|
|
1436
1440
|
})
|
|
1437
1441
|
|
|
1438
|
-
// /tasks resolves these against the registry at print time. background.ts owns the
|
|
1439
|
-
// run records but does not enumerate them, so the ids started here are remembered;
|
|
1440
|
-
// a run the registry has since evicted simply drops out of the listing.
|
|
1441
|
-
const startedBackgroundRuns = new Set<string>()
|
|
1442
|
-
|
|
1443
|
-
// The registry self-caps and evicts old runs, so an id kept here after its record is
|
|
1444
|
-
// gone is dead weight. Drop those on every add, bounding the set to the registry's
|
|
1445
|
-
// live capacity rather than letting it grow for the whole session.
|
|
1446
|
-
const rememberBackgroundRun = (id: string): void => {
|
|
1447
|
-
for (const known of startedBackgroundRuns) {
|
|
1448
|
-
if (!backgroundRun(known)) startedBackgroundRuns.delete(known)
|
|
1449
|
-
}
|
|
1450
|
-
startedBackgroundRuns.add(id)
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
1442
|
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
|
|
1454
1443
|
// Runs through driveRun's guard, same as the background-mode callback above.
|
|
1455
1444
|
// The stop event fires here too, so SubagentStop hooks see resumed runs end.
|
|
@@ -1463,6 +1452,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1463
1452
|
// available model list are captured per session so a hook run lands in the right repo.
|
|
1464
1453
|
let hookCwd = process.cwd()
|
|
1465
1454
|
let hookModels: ReadonlyArray<{ id: string }> = []
|
|
1455
|
+
// Captured per session like cwd: a named agent resolved for a fork skill or an agent
|
|
1456
|
+
// hook must respect project trust the way the tool path does, or an unapproved repo's
|
|
1457
|
+
// .claude/agents entry (which wins a name clash) would run on its own say-so.
|
|
1458
|
+
let hookAgentScope: AgentScope = 'user'
|
|
1466
1459
|
|
|
1467
1460
|
// Discovery walks the plugin cache, the builtin dir, and every agent dir, parsing
|
|
1468
1461
|
// each file: dozens of fs ops per call. The roster injection below runs every turn
|
|
@@ -1474,6 +1467,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1474
1467
|
pi.on('session_start', async (_event, ctx) => {
|
|
1475
1468
|
rosterCache = null
|
|
1476
1469
|
hookCwd = ctx.cwd
|
|
1470
|
+
hookAgentScope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
|
|
1477
1471
|
try {
|
|
1478
1472
|
hookModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
1479
1473
|
} catch {
|
|
@@ -1485,7 +1479,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1485
1479
|
if (process.env.PI_CODE_SUBAGENT) throw new Error('agent hooks do not run inside a subagent')
|
|
1486
1480
|
// A context: fork skill names its agent, or runs with the full toolset;
|
|
1487
1481
|
// agent hooks keep the read-only hook shape.
|
|
1488
|
-
const named = request.agent ? discoverAgents(hookCwd,
|
|
1482
|
+
const named = request.agent ? discoverAgents(hookCwd, hookAgentScope).agents.find((a) => a.name === request.agent) : undefined
|
|
1489
1483
|
const agent = named ?? (request.fullTools ? forkAgent(request) : buildHookAgent(request))
|
|
1490
1484
|
const result = await runSingleAgent({
|
|
1491
1485
|
defaultCwd: hookCwd,
|
|
@@ -1578,7 +1572,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1578
1572
|
|
|
1579
1573
|
if (params.resume) {
|
|
1580
1574
|
const onResumed = (run: { id: string; agent: string }): void => {
|
|
1581
|
-
rememberBackgroundRun(run.id)
|
|
1582
1575
|
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })
|
|
1583
1576
|
}
|
|
1584
1577
|
return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, onResumed) }], details: makeDetails('single')([]) }
|
|
@@ -1622,7 +1615,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1622
1615
|
// unavailable tier still falls back to the session model.
|
|
1623
1616
|
const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
1624
1617
|
|
|
1625
|
-
if (wantsBackground(params, agents)) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }
|
|
1618
|
+
if (wantsBackground(params, agents)) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved })
|
|
1626
1619
|
|
|
1627
1620
|
const mode: ModeContext = {
|
|
1628
1621
|
agents,
|
|
@@ -1679,8 +1672,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1679
1672
|
pi.registerCommand('tasks', {
|
|
1680
1673
|
description: 'Show background subagent runs',
|
|
1681
1674
|
handler: async (_args, ctx) => {
|
|
1682
|
-
|
|
1683
|
-
ctx.ui.notify(tasksStatusText(runs), 'info')
|
|
1675
|
+
ctx.ui.notify(tasksStatusText(allBackgroundRuns()), 'info')
|
|
1684
1676
|
},
|
|
1685
1677
|
})
|
|
1686
1678
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.52",
|
|
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",
|