pi-code 1.0.50 → 1.0.51
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/commands.ts +2 -2
- package/extensions/hooks/config.ts +44 -4
- package/extensions/internal/bash-rules.ts +13 -3
- package/extensions/internal/mcp-oauth.ts +21 -5
- package/extensions/internal/plugins.ts +14 -0
- package/extensions/mcp/config.ts +4 -2
- package/extensions/mcp/index.ts +18 -2
- package/extensions/mcp/oauth-flow.ts +1 -0
- package/extensions/mcp/transport.ts +8 -3
- package/extensions/output-styles.ts +2 -2
- package/extensions/skills.ts +2 -2
- package/extensions/subagent/agents.ts +5 -2
- package/extensions/subagent/background.ts +3 -1
- package/extensions/subagent/index.ts +9 -2
- package/package.json +1 -1
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 } : {}) } })
|
|
@@ -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
|
}
|
|
@@ -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 {
|
|
@@ -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:
|
|
@@ -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)
|
|
@@ -26,7 +26,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
|
26
26
|
|
|
27
27
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
28
28
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
29
|
-
import { installedPlugins } from './internal/plugins.js'
|
|
29
|
+
import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
30
30
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
31
31
|
import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
|
|
32
32
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
@@ -118,7 +118,7 @@ export function pluginStyleDirs(home: string): string[] {
|
|
|
118
118
|
return installedPlugins(home).flatMap((plugin) => {
|
|
119
119
|
const declared = plugin.manifest.outputStyles
|
|
120
120
|
const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'output-styles']
|
|
121
|
-
return dirs.map((dir) =>
|
|
121
|
+
return dirs.map((dir) => pluginComponentPath(plugin, String(dir))).filter((dir): dir is string => dir !== undefined)
|
|
122
122
|
})
|
|
123
123
|
}
|
|
124
124
|
|
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
|
|
@@ -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
|
|
|
@@ -279,7 +279,9 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
279
279
|
run.live = true
|
|
280
280
|
const killGroup = (signal: NodeJS.Signals): void => {
|
|
281
281
|
try {
|
|
282
|
-
|
|
282
|
+
// A child that never spawned has no pid and no group; the direct kill is all there is.
|
|
283
|
+
if (proc.pid) process.kill(-proc.pid, signal)
|
|
284
|
+
else proc.kill(signal)
|
|
283
285
|
} catch {
|
|
284
286
|
try {
|
|
285
287
|
proc.kill(signal)
|
|
@@ -395,7 +395,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
395
395
|
|
|
396
396
|
const killGroup = (sig: NodeJS.Signals): void => {
|
|
397
397
|
try {
|
|
398
|
-
|
|
398
|
+
// A child that never spawned has no pid and no group; the direct kill is all there is.
|
|
399
|
+
if (proc.pid) process.kill(-proc.pid, sig)
|
|
400
|
+
else proc.kill(sig)
|
|
399
401
|
} catch {
|
|
400
402
|
try {
|
|
401
403
|
proc.kill(sig)
|
|
@@ -1463,6 +1465,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1463
1465
|
// available model list are captured per session so a hook run lands in the right repo.
|
|
1464
1466
|
let hookCwd = process.cwd()
|
|
1465
1467
|
let hookModels: ReadonlyArray<{ id: string }> = []
|
|
1468
|
+
// Captured per session like cwd: a named agent resolved for a fork skill or an agent
|
|
1469
|
+
// hook must respect project trust the way the tool path does, or an unapproved repo's
|
|
1470
|
+
// .claude/agents entry (which wins a name clash) would run on its own say-so.
|
|
1471
|
+
let hookAgentScope: AgentScope = 'user'
|
|
1466
1472
|
|
|
1467
1473
|
// Discovery walks the plugin cache, the builtin dir, and every agent dir, parsing
|
|
1468
1474
|
// each file: dozens of fs ops per call. The roster injection below runs every turn
|
|
@@ -1474,6 +1480,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1474
1480
|
pi.on('session_start', async (_event, ctx) => {
|
|
1475
1481
|
rosterCache = null
|
|
1476
1482
|
hookCwd = ctx.cwd
|
|
1483
|
+
hookAgentScope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
|
|
1477
1484
|
try {
|
|
1478
1485
|
hookModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
1479
1486
|
} catch {
|
|
@@ -1485,7 +1492,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1485
1492
|
if (process.env.PI_CODE_SUBAGENT) throw new Error('agent hooks do not run inside a subagent')
|
|
1486
1493
|
// A context: fork skill names its agent, or runs with the full toolset;
|
|
1487
1494
|
// agent hooks keep the read-only hook shape.
|
|
1488
|
-
const named = request.agent ? discoverAgents(hookCwd,
|
|
1495
|
+
const named = request.agent ? discoverAgents(hookCwd, hookAgentScope).agents.find((a) => a.name === request.agent) : undefined
|
|
1489
1496
|
const agent = named ?? (request.fullTools ? forkAgent(request) : buildHookAgent(request))
|
|
1490
1497
|
const result = await runSingleAgent({
|
|
1491
1498
|
defaultCwd: hookCwd,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.51",
|
|
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",
|