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.
@@ -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
- return value
44
- .replace(/^\[|\]$/g, '')
45
- .split(',')
46
- .map((entry) => unquote(entry.trim()))
47
- .filter(Boolean)
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.
@@ -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) => path.resolve(plugin.root, String(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 { findNearestFile } from './internal/project-root.js'
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: .claude/settings.json with settings.local.json overlaid,
141
- * each the nearest of its name at or above cwd (matching the hooks settings chain). */
142
- function projectEnv(cwd: string): Record<string, string> {
143
- const base = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json')))
144
- const local = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json')))
145
- return sanitizeProjectEnv({ ...base, ...local })
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
- mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin, jsonEscape), `${plugin.name} (plugin.json)`, sources, `plugin:${plugin.name}`)
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 = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
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) notify(`${commands[i].command} hook error: ${jsonError}`)
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
- if (rule.endsWith(':*')) return segment.startsWith(rule.slice(0, -2))
19
- if (rule.includes('*')) return new RegExp(`^${rule.split('*').map(escapeRegExp).join('[^]*')}$`).test(segment)
20
- return segment === rule
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
- function storeFileFor(serverName: string): string {
45
- const digest = crypto.createHash('sha256').update(serverName).digest('hex').slice(0, 8)
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 }).unref()
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))
@@ -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 { InstalledPlugin } from '../internal/plugins.js'
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 parsed = JSON.parse(fs.readFileSync(path.resolve(plugin.root, entry), 'utf-8'))
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.
@@ -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 projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
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
- const consented = Object.fromEntries(Object.entries(consentedRaw).filter(([name]) => !localNames.has(name)))
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
- if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper), helperEnv(name, config)))
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)
@@ -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
- // using the one closest to cwd for a name clash: styleForName is first-match,
106
- // so the nearest directory goes first.
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) => path.resolve(plugin.root, String(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
- function persistActiveStyle(file: string, name: string): void {
177
- let config: Record<string, unknown> = {}
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
- config = JSON.parse(fs.readFileSync(file, 'utf-8'))
180
- } catch {
181
- // start from an empty config when the file is missing or invalid
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
- fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`)
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
  }
@@ -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) => path.resolve(plugin.root, String(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
- if (config?.refreshInterval) {
461
- refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
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.map((dir) => ({ dir: path.resolve(plugin.root, String(dir)), pluginName: plugin.name }))
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
- const args = rebuilt.args.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
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
- process.kill(-proc.pid!, signal)
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
- process.kill(-proc.pid!, sig)
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 in this session.'
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, onStarted?: (id: string) => void): Promise<ToolResult> {
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, 'both').agents.find((a) => a.name === request.agent) : undefined
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 }, (id) => rememberBackgroundRun(id))
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
- const runs = [...startedBackgroundRuns].map((id) => backgroundRun(id)).filter((run): run is BackgroundRun => run !== undefined)
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.50",
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",