pi-code 1.0.51 → 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.
@@ -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
  }
@@ -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
+ }
@@ -244,7 +244,12 @@ export function openBrowser(url: string): void {
244
244
  else if (process.platform === 'win32') command = 'cmd'
245
245
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
246
246
  try {
247
- 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()
248
253
  } catch {
249
254
  // the notified URL is the fallback
250
255
  }
@@ -563,8 +563,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
563
563
  // is why serverToolCount reads from `registered` to recover the true count here.
564
564
  status.clear()
565
565
  // A same-process session switch (/new, /resume) shut the last session down;
566
- // 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.
567
570
  shuttingDown = false
571
+ projectConnected = false
568
572
  // Claude answers roots/list with the session's launch directory and exports the
569
573
  // project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
570
574
  sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
@@ -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,6 +23,7 @@ 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'
@@ -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
 
@@ -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
  }
@@ -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', '○')))
@@ -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
@@ -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,8 +388,11 @@ 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
 
@@ -566,7 +569,7 @@ function runOutputTail(run: BackgroundRunView): string | undefined {
566
569
  /** The /tasks listing: one line per background run, plus the short output tail the
567
570
  * registry's own status lines omit. A pure formatter so it tests against a plain list. */
568
571
  export function tasksStatusText(runs: ReadonlyArray<BackgroundRunView>): string {
569
- if (runs.length === 0) return 'No background subagent runs in this session.'
572
+ if (runs.length === 0) return 'No background subagent runs.'
570
573
  return runs
571
574
  .map((run) => {
572
575
  const plural = run.turns === 1 ? '' : 's'
@@ -858,7 +861,7 @@ interface BackgroundContext {
858
861
  projectApproved: boolean
859
862
  }
860
863
 
861
- async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext, onStarted?: (id: string) => void): Promise<ToolResult> {
864
+ async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext): Promise<ToolResult> {
862
865
  const { agents, defaultCwd, pi, makeDetails, skillRoots, availableModels, projectApproved } = context
863
866
  const task = params.task
864
867
  const agentName = params.agent
@@ -955,7 +958,6 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
955
958
  removeTmpPrompt(tmpPrompt)
956
959
  return backgroundCapResult(makeDetails)
957
960
  }
958
- onStarted?.(id)
959
961
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
960
962
  return {
961
963
  content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
@@ -1437,21 +1439,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
1437
1439
  if (isMcpToolAliases(data)) setKnownMcpAliases(data)
1438
1440
  })
1439
1441
 
1440
- // /tasks resolves these against the registry at print time. background.ts owns the
1441
- // run records but does not enumerate them, so the ids started here are remembered;
1442
- // a run the registry has since evicted simply drops out of the listing.
1443
- const startedBackgroundRuns = new Set<string>()
1444
-
1445
- // The registry self-caps and evicts old runs, so an id kept here after its record is
1446
- // gone is dead weight. Drop those on every add, bounding the set to the registry's
1447
- // live capacity rather than letting it grow for the whole session.
1448
- const rememberBackgroundRun = (id: string): void => {
1449
- for (const known of startedBackgroundRuns) {
1450
- if (!backgroundRun(known)) startedBackgroundRuns.delete(known)
1451
- }
1452
- startedBackgroundRuns.add(id)
1453
- }
1454
-
1455
1442
  const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
1456
1443
  // Runs through driveRun's guard, same as the background-mode callback above.
1457
1444
  // The stop event fires here too, so SubagentStop hooks see resumed runs end.
@@ -1585,7 +1572,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
1585
1572
 
1586
1573
  if (params.resume) {
1587
1574
  const onResumed = (run: { id: string; agent: string }): void => {
1588
- rememberBackgroundRun(run.id)
1589
1575
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })
1590
1576
  }
1591
1577
  return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, onResumed) }], details: makeDetails('single')([]) }
@@ -1629,7 +1615,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1629
1615
  // unavailable tier still falls back to the session model.
1630
1616
  const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
1631
1617
 
1632
- 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 })
1633
1619
 
1634
1620
  const mode: ModeContext = {
1635
1621
  agents,
@@ -1686,8 +1672,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1686
1672
  pi.registerCommand('tasks', {
1687
1673
  description: 'Show background subagent runs',
1688
1674
  handler: async (_args, ctx) => {
1689
- const runs = [...startedBackgroundRuns].map((id) => backgroundRun(id)).filter((run): run is BackgroundRun => run !== undefined)
1690
- ctx.ui.notify(tasksStatusText(runs), 'info')
1675
+ ctx.ui.notify(tasksStatusText(allBackgroundRuns()), 'info')
1691
1676
  },
1692
1677
  })
1693
1678
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.51",
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",