pi-code 1.0.55 → 1.0.57

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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/extensions/commands.ts +6 -12
  3. package/extensions/context-imports.ts +2 -10
  4. package/extensions/env-settings.ts +1 -5
  5. package/extensions/git-checkpoint.ts +4 -12
  6. package/extensions/goal.ts +2 -2
  7. package/extensions/hooks/config.ts +6 -21
  8. package/extensions/hooks/decisions.ts +3 -2
  9. package/extensions/hooks/index.ts +4 -16
  10. package/extensions/hooks/matcher.ts +2 -1
  11. package/extensions/hooks/runners.ts +10 -4
  12. package/extensions/internal/command-file.ts +7 -241
  13. package/extensions/internal/command-spans.ts +246 -0
  14. package/extensions/internal/managed-settings.ts +3 -5
  15. package/extensions/internal/plugins.ts +2 -2
  16. package/extensions/internal/settings-chain.ts +19 -0
  17. package/extensions/internal/values.ts +38 -0
  18. package/extensions/mcp/index.ts +6 -5
  19. package/extensions/mcp/listing.ts +2 -1
  20. package/extensions/mcp/oauth-flow.ts +2 -1
  21. package/extensions/mcp/policy.ts +9 -2
  22. package/extensions/memory.ts +10 -15
  23. package/extensions/output-styles.ts +4 -17
  24. package/extensions/plan-mode/index.ts +9 -9
  25. package/extensions/plan-mode/utils.ts +31 -0
  26. package/extensions/session-title.ts +2 -12
  27. package/extensions/skills.ts +6 -18
  28. package/extensions/status-line.ts +2 -8
  29. package/extensions/subagent/README.md +15 -5
  30. package/extensions/subagent/agents.ts +2 -2
  31. package/extensions/subagent/background.ts +2 -1
  32. package/extensions/subagent/child.ts +197 -0
  33. package/extensions/subagent/concurrency.ts +23 -0
  34. package/extensions/subagent/index.ts +36 -1426
  35. package/extensions/subagent/modes.ts +405 -0
  36. package/extensions/subagent/params.ts +56 -0
  37. package/extensions/subagent/registry-text.ts +105 -0
  38. package/extensions/subagent/render-result.ts +306 -0
  39. package/extensions/subagent/run.ts +375 -0
  40. package/extensions/subagent/types.ts +41 -0
  41. package/extensions/subagent/worktree.ts +2 -1
  42. package/package.json +1 -1
@@ -44,6 +44,7 @@ import { installedPlugins } from '../internal/plugins.js'
44
44
  import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
45
45
  import { repoRoot } from '../internal/project-root.js'
46
46
  import { claudeSettingsChain } from '../internal/settings-chain.js'
47
+ import { errorMessage } from '../internal/values.js'
47
48
  import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope, localScopeServerNames, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
48
49
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
49
50
  import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
@@ -272,7 +273,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
272
273
  // streaming, so mid-stream invocations queue as a follow-up turn.
273
274
  pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: 'followUp' })
274
275
  } catch (error) {
275
- ctx.ui.notify(`${commandName}: ${error instanceof Error ? error.message : String(error)}`, 'error')
276
+ ctx.ui.notify(`${commandName}: ${errorMessage(error)}`, 'error')
276
277
  }
277
278
  },
278
279
  })
@@ -286,7 +287,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
286
287
  try {
287
288
  registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
288
289
  } catch (error) {
289
- console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
290
+ console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${errorMessage(error)}`)
290
291
  }
291
292
  }
292
293
 
@@ -298,7 +299,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
298
299
  try {
299
300
  registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
300
301
  } catch (error) {
301
- console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
302
+ console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${errorMessage(error)}`)
302
303
  }
303
304
  })
304
305
  } catch {
@@ -386,7 +387,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
386
387
  status.set(name, { state: current?.state ?? 'connected', tools: serverToolCount(name) })
387
388
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
388
389
  } catch (error) {
389
- console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
390
+ console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${errorMessage(error)}`)
390
391
  }
391
392
  })
392
393
  } catch {
@@ -445,7 +446,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
445
446
  if (!shuttingDown && !serverCallTuning(config).stdio) void reconnectWithBackoff(name, config)
446
447
  }
447
448
  } catch (error) {
448
- status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
449
+ status.set(name, { state: `failed: ${errorMessage(error)}`, tools: 0 })
449
450
  // Connected but failed after (tool listing hung or errored): left in the
450
451
  // map, the client idles its process for the whole session and the
451
452
  // duplicate-name guard blocks the name for every later attempt.
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
7
+ import { errorMessage } from '../internal/values.js'
7
8
  import type { McpPromptInfo } from './mapping.js'
8
9
  import { callRequestOptions, withTimeout } from './transport.js'
9
10
 
@@ -76,7 +77,7 @@ export async function collectServerResourceEntries(entries: Array<Record<string,
76
77
  try {
77
78
  await collectResources(entries, name, client, budget)
78
79
  } catch (error) {
79
- entries.push({ server: name, error: error instanceof Error ? error.message : String(error) })
80
+ entries.push({ server: name, error: errorMessage(error) })
80
81
  }
81
82
  try {
82
83
  await collectResourceTemplates(entries, name, client, budget)
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
9
9
  import { FileOAuthProvider, type OAuthServerConfig, openBrowser, startCallbackServer, waitForAuthCode } from '../internal/mcp-oauth.js'
10
+ import { errorMessage } from '../internal/values.js'
10
11
  import { type AuthUi, connectWithTimeout, isUnauthorized, type MakeTransport, OAuthRequiredError } from './transport.js'
11
12
 
12
13
  /** Browser logins are human-paced; a connect-sized timeout would cut them off. */
@@ -16,7 +17,7 @@ const OAUTH_FLOW_TIMEOUT_MS = 180_000
16
17
  * unchanged so its message is not doubled. */
17
18
  function asOAuthRequiredError(name: string, error: unknown): OAuthRequiredError {
18
19
  if (error instanceof OAuthRequiredError) return error
19
- const detail = error instanceof Error ? error.message : String(error)
20
+ const detail = errorMessage(error)
20
21
  return new OAuthRequiredError(`login for ${name} failed: ${detail}`)
21
22
  }
22
23
 
@@ -9,6 +9,7 @@ import * as path from 'node:path'
9
9
  import { claudeConfigDir } from '../internal/config-dir.js'
10
10
  import { managedSettingsFile } from '../internal/managed-settings.js'
11
11
  import { findNearestFile } from '../internal/project-root.js'
12
+ import { errorMessage } from '../internal/values.js'
12
13
  import { interpolateEnv, type ServerConfig } from './config.js'
13
14
 
14
15
  export interface ProjectServerPolicy {
@@ -151,7 +152,13 @@ export function urlPatternMatches(pattern: string, url: string): boolean {
151
152
  return wildcardRegExp(patternParts.path).test(urlParts.path ?? '/')
152
153
  }
153
154
 
154
- const configUrl = (config: ServerConfig): string | undefined => (config as { url?: string }).url
155
+ /** A config file is user- or repo-written JSON, so any field can hold any value. Only a
156
+ * string `url` makes a server remote: a config that carries some other value there is
157
+ * still gated by its command, and no policy entry is evaluated against a non-string. */
158
+ const configUrl = (config: ServerConfig): string | undefined => {
159
+ const url = (config as { url?: unknown }).url
160
+ return typeof url === 'string' ? url : undefined
161
+ }
155
162
  const configArgv = (config: ServerConfig): string[] | undefined => {
156
163
  const command = (config as { command?: string }).command
157
164
  if (typeof command !== 'string') return undefined
@@ -227,7 +234,7 @@ export function loadManagedMcpServers(managedFile: string = managedSettingsFile(
227
234
  } catch (error) {
228
235
  // Present but corrupt: fail closed to an empty managed set, exactly like an empty map,
229
236
  // rather than reopening the user/project/plugin scopes.
230
- console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${error instanceof Error ? error.message : String(error)}`)
237
+ console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${errorMessage(error)}`)
231
238
  return {}
232
239
  }
233
240
  if (parsed === null || typeof parsed !== 'object') return {}
@@ -20,8 +20,9 @@ import { readManagedSettings } from './internal/managed-settings.js'
20
20
  import { capForContext } from './internal/output-guard.js'
21
21
  import { isProjectApprovedSilently } from './internal/project-approval.js'
22
22
  import { repoRoot } from './internal/project-root.js'
23
- import { claudeSettingsChain } from './internal/settings-chain.js'
23
+ import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
24
24
  import { statToken } from './internal/stat-token.js'
25
+ import { errorMessage } from './internal/values.js'
25
26
 
26
27
  export const INDEX_FILE = 'MEMORY.md'
27
28
 
@@ -152,7 +153,7 @@ export function migrateLegacyStore(cwd: string): void {
152
153
  } catch (error) {
153
154
  // A failed migration must not take down session start, but the session then has no
154
155
  // memories while they sit under the old slug, which reads as having lost them.
155
- console.warn(`pi-code-memory: could not move ${legacy} to ${current}: ${error instanceof Error ? error.message : String(error)}; this session starts without those memories`)
156
+ console.warn(`pi-code-memory: could not move ${legacy} to ${current}: ${errorMessage(error)}; this session starts without those memories`)
156
157
  }
157
158
  return
158
159
  }
@@ -236,7 +237,7 @@ function readMemory(dir: string, name: string): MemoryToolResult {
236
237
  // Only a missing file is "no such memory"; anything else (a directory in its place, a
237
238
  // permission problem) sends the model hunting for a name that is actually there.
238
239
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
239
- return { content: [{ type: 'text', text: `Memory ${name} could not be read: ${error instanceof Error ? error.message : String(error)}` }], details: {} }
240
+ return { content: [{ type: 'text', text: `Memory ${name} could not be read: ${errorMessage(error)}` }], details: {} }
240
241
  }
241
242
  return { content: [{ type: 'text', text: `No memory named ${name}.` }], details: {} }
242
243
  }
@@ -257,7 +258,7 @@ async function deleteMemory(dir: string, indexPath: string, name: string): Promi
257
258
  return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
258
259
  })
259
260
  } catch (error) {
260
- return { content: [{ type: 'text', text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
261
+ return { content: [{ type: 'text', text: `Memory delete failed: ${errorMessage(error)}. Nothing was deleted.` }], details: {} }
261
262
  }
262
263
  }
263
264
 
@@ -350,15 +351,9 @@ export function memorySettingsFiles(cwd: string, home: string, approved: boolean
350
351
  * managed policy settings win over every file, per Claude's settings precedence. */
351
352
  export function readMemorySettings(files: string[], managed: Record<string, unknown> = readManagedSettings()): { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } {
352
353
  const merged: { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } = {}
353
- for (const file of files) {
354
- try {
355
- const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
356
- if (settings === null || typeof settings !== 'object') continue
357
- if ('autoMemoryEnabled' in settings) merged.autoMemoryEnabled = settings.autoMemoryEnabled
358
- if ('autoMemoryDirectory' in settings) merged.autoMemoryDirectory = settings.autoMemoryDirectory
359
- } catch {
360
- // missing or invalid settings file: skip
361
- }
354
+ for (const settings of readSettingsChain(files)) {
355
+ if ('autoMemoryEnabled' in settings) merged.autoMemoryEnabled = settings.autoMemoryEnabled
356
+ if ('autoMemoryDirectory' in settings) merged.autoMemoryDirectory = settings.autoMemoryDirectory
362
357
  }
363
358
  if ('autoMemoryEnabled' in managed) merged.autoMemoryEnabled = managed.autoMemoryEnabled
364
359
  if ('autoMemoryDirectory' in managed) merged.autoMemoryDirectory = managed.autoMemoryDirectory
@@ -478,7 +473,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
478
473
  // Awaited here, not returned: the catch must see a queued write's rejection.
479
474
  return await saveMemory(dir, indexPath, name, params.description, params.content)
480
475
  } catch (error) {
481
- return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
476
+ return { content: [{ type: 'text' as const, text: `Memory save failed: ${errorMessage(error)}. The index was left untouched.` }], details: {} }
482
477
  } finally {
483
478
  indexCache = null
484
479
  }
@@ -520,7 +515,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
520
515
  try {
521
516
  result = setAutoMemoryEnabledSetting(home, next)
522
517
  } catch (error) {
523
- ctx.ui.notify(`Could not update auto memory: ${error instanceof Error ? error.message : String(error)}`, 'error')
518
+ ctx.ui.notify(`Could not update auto memory: ${errorMessage(error)}`, 'error')
524
519
  return
525
520
  }
526
521
  if (!result.ok) {
@@ -24,13 +24,13 @@ 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
26
  import { atomicWriteFile } from './internal/atomic-write.js'
27
-
28
27
  import { claudeConfigDir } from './internal/config-dir.js'
29
28
  import { readManagedSettings } from './internal/managed-settings.js'
30
29
  import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
31
30
  import { isProjectApproved } from './internal/project-approval.js'
32
31
  import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
33
- import { claudeSettingsChain } from './internal/settings-chain.js'
32
+ import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
33
+ import { isDirectory } from './internal/values.js'
34
34
 
35
35
  export interface OutputStyle {
36
36
  name: string
@@ -87,14 +87,6 @@ export function applyStyle(systemPrompt: string, style: OutputStyle): string {
87
87
  return `${systemPrompt}\n\n${styleSection}`
88
88
  }
89
89
 
90
- function isDirectory(target: string): boolean {
91
- try {
92
- return fs.statSync(target).isDirectory()
93
- } catch {
94
- return false
95
- }
96
- }
97
-
98
90
  /**
99
91
  * Existing `.claude/output-styles` directories, user first then project. The project
100
92
  * directory is included only for trusted projects, since its style body is injected
@@ -159,13 +151,8 @@ export function settingsFiles(cwd: string, home: string, trusted: boolean): stri
159
151
  export function readActiveStyleName(files: string[], managed: Record<string, unknown> = readManagedSettings()): string | undefined {
160
152
  if (typeof managed.outputStyle === 'string') return managed.outputStyle
161
153
  let name: string | undefined
162
- for (const file of files) {
163
- try {
164
- const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
165
- if (typeof settings.outputStyle === 'string') name = settings.outputStyle
166
- } catch {
167
- // missing or invalid file: skip
168
- }
154
+ for (const settings of readSettingsChain(files)) {
155
+ if (typeof settings.outputStyle === 'string') name = settings.outputStyle
169
156
  }
170
157
  return name
171
158
  }
@@ -19,7 +19,7 @@ import { Key } from '@earendil-works/pi-tui'
19
19
  import { Type } from 'typebox'
20
20
 
21
21
  import { PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
22
- import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
22
+ import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, restoredPlanState, type TodoItem } from './utils.js'
23
23
 
24
24
  // Tools
25
25
  const PLAN_MODE_TOOLS = ['read', 'bash', 'grep', 'find', 'ls', 'question', 'plan_mode_complete']
@@ -433,14 +433,14 @@ After completing a step, include a [DONE:n] tag in your response.`,
433
433
  // rewind past a plan it would resurrect the abandoned plan and its tool restriction.
434
434
  const entries = ctx.sessionManager.getBranch()
435
435
 
436
- // Restore persisted state
437
- const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean; savedTools?: string[] } } | undefined
436
+ // Restore persisted state. The entry is JSON on disk, so every field is checked
437
+ // before it is used: savedTools reaches pi.setActiveTools.
438
+ const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: unknown } | undefined
439
+ const restored = restoredPlanState(planModeEntry?.data)
438
440
 
439
- if (planModeEntry?.data) {
440
- planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled
441
- todoItems = planModeEntry.data.todos ?? todoItems
442
- executionMode = planModeEntry.data.executing ?? executionMode
443
- }
441
+ planModeEnabled = restored.enabled ?? planModeEnabled
442
+ todoItems = restored.todos ?? todoItems
443
+ executionMode = restored.executing ?? executionMode
444
444
  publishPlanState()
445
445
 
446
446
  // On resume: re-scan messages after the last "plan-mode-execute" to rebuild
@@ -456,7 +456,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
456
456
  // across /reload and cost the session edit and write for good; applying the
457
457
  // snapshot when plan mode is off would instead push a stale set over whatever
458
458
  // pi has registered since, so it stays scoped to this branch.
459
- savedTools = planModeEntry?.data?.savedTools ?? pi.getActiveTools()
459
+ savedTools = restored.savedTools ?? pi.getActiveTools()
460
460
  pi.setActiveTools(PLAN_MODE_TOOLS.filter((t) => savedTools.includes(t)))
461
461
  // --plan enters plan mode without ever toggling, so nothing has persisted yet
462
462
  // and a /reload would find no snapshot to restore from. Record it now, while
@@ -120,6 +120,37 @@ export interface TodoItem {
120
120
  completed: boolean
121
121
  }
122
122
 
123
+ /** The plan state persisted in a session entry, once each field has been checked.
124
+ * A field the restore cannot recognize is simply absent, so the caller keeps its
125
+ * current value. */
126
+ export interface RestoredPlanState {
127
+ enabled?: boolean
128
+ todos?: TodoItem[]
129
+ executing?: boolean
130
+ savedTools?: string[]
131
+ }
132
+
133
+ const isTodoItem = (value: unknown): value is TodoItem => {
134
+ if (value === null || typeof value !== 'object') return false
135
+ const item = value as Record<string, unknown>
136
+ return typeof item.step === 'number' && typeof item.text === 'string' && typeof item.completed === 'boolean'
137
+ }
138
+
139
+ /** Read a persisted plan-mode entry, keeping only fields of the expected shape.
140
+ * The session file is data on disk, and `savedTools` feeds the active tool set: a
141
+ * string there would be spread character by character into the tool gating, and a
142
+ * non-array `todos` throws on the first restore that iterates it. */
143
+ export function restoredPlanState(data: unknown): RestoredPlanState {
144
+ if (data === null || typeof data !== 'object') return {}
145
+ const raw = data as Record<string, unknown>
146
+ const state: RestoredPlanState = {}
147
+ if (typeof raw.enabled === 'boolean') state.enabled = raw.enabled
148
+ if (typeof raw.executing === 'boolean') state.executing = raw.executing
149
+ if (Array.isArray(raw.todos) && raw.todos.every(isTodoItem)) state.todos = raw.todos
150
+ if (Array.isArray(raw.savedTools) && raw.savedTools.every((tool) => typeof tool === 'string')) state.savedTools = raw.savedTools
151
+ return state
152
+ }
153
+
123
154
  function cleanStepText(text: string): string {
124
155
  let cleaned = text
125
156
  .replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1') // Remove bold/italic
@@ -21,6 +21,7 @@
21
21
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
22
22
 
23
23
  import { completeText } from './internal/model-complete.js'
24
+ import { contentText } from './internal/values.js'
24
25
 
25
26
  const TITLE_SYSTEM = 'You name a coding session from its first user message. Reply with a terse 3 to 6 word title in Title Case that captures the task. No quotes, no surrounding punctuation, no trailing period. Output the title only, nothing else.'
26
27
  /** A title is a few words; a tight cap keeps the extra call cheap and stops a runaway reply. */
@@ -29,23 +30,12 @@ const TITLE_MAX_TOKENS = 24
29
30
  * bounded prompt keeps the input cost of the extra call small. */
30
31
  const MAX_PROMPT_CHARS = 1000
31
32
 
32
- /** Join the text of a message's content, mirroring git-checkpoint's extraction: content is
33
- * either a plain string or an array of parts, of which only text parts carry a title's worth. */
34
- function extractText(content: unknown): string {
35
- if (typeof content === 'string') return content
36
- if (!Array.isArray(content)) return ''
37
- return content
38
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
39
- .map((part) => part.text)
40
- .join(' ')
41
- }
42
-
43
33
  /** Text of the first user message in the branch, or empty when the run carried no user text
44
34
  * (for example a slash-command-only turn), in which case there is nothing to title from. */
45
35
  export function firstUserText(ctx: ExtensionContext): string {
46
36
  for (const entry of ctx.sessionManager.getBranch()) {
47
37
  if (entry?.type === 'message' && entry.message.role === 'user') {
48
- return extractText(entry.message.content).trim()
38
+ return contentText(entry.message.content, ' ').trim()
49
39
  }
50
40
  }
51
41
  return ''
@@ -23,7 +23,6 @@ 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, type ExtensionContext, parseFrontmatter } from '@earendil-works/pi-coding-agent'
26
-
27
26
  import { expandCommand, shellExecutionDisabled } from './commands.js'
28
27
  import { runAgent } from './internal/agent-run.js'
29
28
  import { parseCommandFile } from './internal/command-file.js'
@@ -32,16 +31,9 @@ import { managedSettingsFile } from './internal/managed-settings.js'
32
31
  import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
33
32
  import { isProjectApprovedSilently } from './internal/project-approval.js'
34
33
  import { ancestorDirs } from './internal/project-root.js'
35
- import { claudeSettingsChain } from './internal/settings-chain.js'
34
+ import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
36
35
  import { SKILL_HOOKS_CHANNEL } from './internal/skill-hooks.js'
37
-
38
- function isDirectory(target: string): boolean {
39
- try {
40
- return fs.statSync(target).isDirectory()
41
- } catch {
42
- return false
43
- }
44
- }
36
+ import { errorMessage, isDirectory, isRecord } from './internal/values.js'
45
37
 
46
38
  /** Existing `.claude/skills` directories, user first then project. The project
47
39
  * directory is included only for approved projects: pi's loader surfaces every skill's
@@ -136,13 +128,9 @@ export default function skillsExtension(pi: ExtensionAPI) {
136
128
  * (a pi-loader surface, noted in docs). */
137
129
  function skillOverrideFor(name: string, cwd: string, trusted: boolean): string | undefined {
138
130
  let value: string | undefined
139
- for (const file of claudeSettingsChain(cwd, os.homedir(), trusted)) {
140
- try {
141
- const overrides = JSON.parse(fs.readFileSync(file, 'utf-8')).skillOverrides
142
- if (overrides !== null && typeof overrides === 'object' && typeof overrides[name] === 'string') value = overrides[name]
143
- } catch {
144
- // missing or invalid file: skip
145
- }
131
+ for (const settings of readSettingsChain(claudeSettingsChain(cwd, os.homedir(), trusted))) {
132
+ const overrides = settings.skillOverrides
133
+ if (isRecord(overrides) && typeof overrides[name] === 'string') value = overrides[name]
146
134
  }
147
135
  return value
148
136
  }
@@ -165,7 +153,7 @@ async function runForkedSkill(name: string, filePath: string, expanded: string,
165
153
  const output = await runAgent({ prompt: expanded, fullTools: true, ...(agentName ? { agent: agentName } : {}) })
166
154
  return { action: 'transform', text: `<skill name="${name}" location="${filePath}">\nThe skill ran in a forked subagent (no conversation history shared). Its result:\n\n${output}\n</skill>` }
167
155
  } catch (error) {
168
- return { action: 'transform', text: `<skill name="${name}">\nThe forked subagent run failed: ${error instanceof Error ? error.message : String(error)}\n</skill>` }
156
+ return { action: 'transform', text: `<skill name="${name}">\nThe forked subagent run failed: ${errorMessage(error)}\n</skill>` }
169
157
  }
170
158
  }
171
159
 
@@ -36,6 +36,7 @@ import { claudeEffortLevel } from './internal/effort.js'
36
36
  import { readManagedSettings } from './internal/managed-settings.js'
37
37
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
38
38
  import { isProjectApprovedSilently } from './internal/project-approval.js'
39
+ import { readSettingsChain } from './internal/settings-chain.js'
39
40
  import { watchSettingsFiles } from './internal/settings-watch.js'
40
41
  import { readActiveStyleName, settingsFiles } from './output-styles.js'
41
42
 
@@ -178,14 +179,7 @@ export function readStatusLineConfig(files: string[], managed: Record<string, un
178
179
  if (managedConfig) return managedConfig
179
180
  if (managed.allowManagedHooksOnly === true) return undefined
180
181
  let found: StatusLineConfig | undefined
181
- for (const file of files) {
182
- try {
183
- const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
184
- found = parseStatusLineEntry(settings.statusLine) ?? found
185
- } catch {
186
- // missing or invalid file: skip
187
- }
188
- }
182
+ for (const settings of readSettingsChain(files)) found = parseStatusLineEntry(settings.statusLine) ?? found
189
183
  return found
190
184
  }
191
185
 
@@ -18,9 +18,19 @@ Delegate tasks to specialized subagents with isolated context windows.
18
18
  ```
19
19
  subagent/
20
20
  ├── README.md # This file
21
- ├── index.ts # The extension (entry point)
22
- ├── agents.ts # Agent discovery logic
21
+ ├── index.ts # The extension: tool schema, dispatch, session hooks
22
+ ├── agents.ts # Agent discovery and frontmatter
23
+ ├── child.ts # How a child is configured before it is spawned
24
+ ├── run.ts # Spawning one child and parsing its event stream
25
+ ├── modes.ts # Single, parallel, chain, background, and the project-agent gate
23
26
  ├── background.ts # Background run registry and spawning
27
+ ├── worktree.ts # isolation: worktree setup and teardown
28
+ ├── params.ts # The tool schema and the types derived from it
29
+ ├── types.ts # Result shapes shared by the tool and its renderers
30
+ ├── concurrency.ts # Parallel-run caps and the bounded worker pool
31
+ ├── registry-text.ts # Text for /tasks, /agents and completion notices
32
+ ├── render.ts # Transcript formatting shared with the parent
33
+ ├── render-result.ts # How a call and its results are drawn
24
34
  ├── agents/ # Bundled builtin agents, always available (lowest precedence)
25
35
  │ ├── explore.md # Explore: fast read-only codebase exploration
26
36
  │ ├── plan.md # Plan: read-only implementation planning
@@ -39,7 +49,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
39
49
 
40
50
  **Default behavior:** Loads the bundled builtin agents (Explore, Plan, general-purpose) plus **user-level agents** from `~/.claude/agents` and `~/.pi/agent/agents`. A user or project agent with the same name overrides a builtin. Discovered agents and their descriptions are listed in the system prompt each turn, so the model can pick one itself; project agent descriptions appear only once the project is approved.
41
51
 
42
- To enable project-local agents (`.claude/agents`, `.pi/agents`), pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
52
+ Project-local agents (`.claude/agents`, `.pi/agents`) load once the project is approved: a default call resolves `agentScope` to `"both"` for an approved project and `"user"` otherwise, and an explicit `agentScope` still narrows or widens it. Every invocation passes the project-agent gate either way.
43
53
 
44
54
  When running interactively, the tool prompts for confirmation before running project-local agents. `confirmProjectAgents: false` skips that prompt for a project you have already approved; an unapproved project is still asked about.
45
55
 
@@ -134,9 +144,9 @@ enforces per call.
134
144
 
135
145
  **Locations:**
136
146
  - `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
137
- - `.claude/agents/*.md`, `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`; `.pi` wins a name conflict)
147
+ - `.claude/agents/*.md`, `.pi/agents/*.md` - Project-level (an approved project, or an explicit `agentScope` of `"project"`/`"both"`; `.pi` wins a name conflict)
138
148
 
139
- Project agents override user agents with the same name when `agentScope: "both"`.
149
+ Project agents override user agents with the same name when both scopes are in play.
140
150
 
141
151
  ## Builtin Agents
142
152
 
@@ -6,7 +6,6 @@ import * as fs from 'node:fs'
6
6
  import * as os from 'node:os'
7
7
  import * as path from 'node:path'
8
8
  import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
9
-
10
9
  // The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
11
10
  // Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
12
11
  // is not merely ignored, it narrows the child's registry.
@@ -14,6 +13,7 @@ import { parseToolGrants } from '../internal/command-file.js'
14
13
  import { claudeConfigDir } from '../internal/config-dir.js'
15
14
  import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
16
15
  import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
16
+ import { errorMessage } from '../internal/values.js'
17
17
 
18
18
  /**
19
19
  * `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
@@ -180,7 +180,7 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string,
180
180
  } catch (error) {
181
181
  // Malformed YAML must not abort discovery for the whole directory, but a silent drop
182
182
  // reads as "that agent does not exist", so it is named like the other rejections here.
183
- console.warn(`pi-code-subagent: ignoring agent ${filePath}: its frontmatter could not be parsed (${error instanceof Error ? error.message : String(error)})`)
183
+ console.warn(`pi-code-subagent: ignoring agent ${filePath}: its frontmatter could not be parsed (${errorMessage(error)})`)
184
184
  return null
185
185
  }
186
186
  const { frontmatter, body } = parsed
@@ -11,6 +11,7 @@ import { randomUUID } from 'node:crypto'
11
11
  import * as fs from 'node:fs'
12
12
  import * as os from 'node:os'
13
13
  import * as path from 'node:path'
14
+ import { errorMessage } from '../internal/values.js'
14
15
 
15
16
  export interface BackgroundRun {
16
17
  id: string
@@ -245,7 +246,7 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn, agent: string): { args: s
245
246
  // prompt text, which would replace the agent persona with a temp path. The child then
246
247
  // runs as a plain assistant instead of the agent asked for, and nothing in its output
247
248
  // says so, hence the notice.
248
- console.warn(`pi-code-subagent: resuming ${agent} without its agent prompt: ${error instanceof Error ? error.message : String(error)}`)
249
+ console.warn(`pi-code-subagent: resuming ${agent} without its agent prompt: ${errorMessage(error)}`)
249
250
  return { args: spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1) }
250
251
  }
251
252
  }