pi-code 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,7 +38,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
38
38
  | Feature | Reads / provides | Extension |
39
39
  |---|---|---|
40
40
  | Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
41
- | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`model`/`argument-hint` frontmatter; project commands gated on approval | `commands.ts` |
41
+ | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`argument-hint` frontmatter (`model` is parsed but not yet applied); project commands gated on approval | `commands.ts` |
42
42
  | Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
43
43
  | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `hooks.ts` |
44
44
  | Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
@@ -57,7 +57,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
57
57
 
58
58
  `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
59
59
 
60
- `extensions/internal/` holds shared modules pi's loader must not treat as extensions: `output-guard.ts` (context-budget truncation), `web-transport.ts` (DNS-pinned fetch), and `project-approval.ts` (the trust decision above). The extensions use them; only `internal/` keeps them out of pi's extension scan.
60
+ `extensions/internal/` holds shared modules pi's loader must not treat as extensions: `output-guard.ts` (context-budget truncation), `web-transport.ts` (DNS-pinned fetch), `project-approval.ts` (the trust decision above), `command-file.ts` (slash-command parsing and dynamic content), and the shared-bus contracts `mcp-alias.ts`, `plan-mode-state.ts` and `subagent-events.ts`. The extensions use them; only `internal/` keeps them out of pi's extension scan.
61
61
 
62
62
  Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
63
63
 
@@ -6,8 +6,10 @@
6
6
  * is what makes the rest of Claude's command contract reachable: namespaced
7
7
  * subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
8
8
  * positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
9
- * `allowed-tools` / `model` / `argument-hint` / `disable-model-invocation`
10
- * frontmatter.
9
+ * `allowed-tools` and `argument-hint` frontmatter. `model` and
10
+ * `disable-model-invocation` are parsed but not applied yet: pi has seams for both
11
+ * (`pi.setModel`, and commands are user-invoked anyway), so they are a gap rather
12
+ * than an impossibility.
11
13
  *
12
14
  * A project command body is repository-controlled text that can now run shell
13
15
  * commands and read files, so project commands load only once the project is
@@ -59,6 +61,14 @@ export function collectCommands(dirs: string[]): DiscoveredCommand[] {
59
61
 
60
62
  export default function commandsExtension(pi: ExtensionAPI) {
61
63
  const registered = new Set<string>()
64
+ /** Tool set to put back once the turn a restricted command drove has ended. */
65
+ let pendingRestore: string[] | undefined
66
+
67
+ pi.on('turn_end', async () => {
68
+ if (!pendingRestore) return
69
+ pi.setActiveTools(pendingRestore)
70
+ pendingRestore = undefined
71
+ })
62
72
 
63
73
  async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext): Promise<void> {
64
74
  const withArgs = substituteArgs(parsed.body, args)
@@ -71,17 +81,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
71
81
  return { stdout: result.stdout, stderr: result.stderr, code: result.code }
72
82
  })
73
83
 
74
- // allowed-tools restricts the turn the command drives, then the previous set is
75
- // restored: the restriction belongs to the command, not to the rest of the session.
76
- const saved = parsed.allowedTools ? pi.getActiveTools() : undefined
77
- if (parsed.allowedTools && saved) {
84
+ // allowed-tools restricts the turn the command drives, and the previous set is
85
+ // restored when that turn ends. Restoring inline does not work: sendUserMessage is
86
+ // fire-and-forget, so the restore would land before the agent ever read the tool
87
+ // list, leaving the command running with everything enabled.
88
+ if (parsed.allowedTools) {
89
+ const saved = pi.getActiveTools()
90
+ pendingRestore = saved
78
91
  pi.setActiveTools(parsed.allowedTools.filter((tool) => saved.includes(tool)))
79
92
  }
80
- try {
81
- pi.sendUserMessage(expanded)
82
- } finally {
83
- if (saved) pi.setActiveTools(saved)
84
- }
93
+ pi.sendUserMessage(expanded)
85
94
  }
86
95
 
87
96
  pi.on('session_start', async (_event, ctx) => {
@@ -27,9 +27,23 @@ export interface DiscoveredCommand {
27
27
  filePath: string
28
28
  }
29
29
 
30
- /** Claude tool names are PascalCase; pi's are lowercase. */
31
- function normalizeToolName(name: string): string {
32
- return name.trim().toLowerCase()
30
+ /** Claude tool names are PascalCase and do not all exist in pi: `Glob` is pi's
31
+ * `find`. Lowercasing alone left `glob` in the list, and since pi has no tool by
32
+ * that name the grant was silently dropped when the list was intersected with the
33
+ * active tools. Shared with the subagent's own frontmatter parsing. */
34
+ const CLAUDE_TOOL_MAP: Record<string, string> = {
35
+ read: 'read',
36
+ write: 'write',
37
+ edit: 'edit',
38
+ bash: 'bash',
39
+ grep: 'grep',
40
+ glob: 'find',
41
+ ls: 'ls',
42
+ }
43
+
44
+ export function normalizeToolName(name: string): string {
45
+ const lower = name.trim().toLowerCase()
46
+ return CLAUDE_TOOL_MAP[lower] ?? lower
33
47
  }
34
48
 
35
49
  function field(frontmatter: string, key: string): string {
@@ -128,12 +142,16 @@ const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ra
128
142
  /** Read a `@path` reference, confined to the working directory. Returns undefined
129
143
  * when the path escapes it or cannot be read, so the reference stays literal. */
130
144
  function readReference(cwd: string, reference: string): string | undefined {
131
- const resolved = path.resolve(cwd, reference)
132
- const root = path.resolve(cwd)
133
- if (resolved !== root && !resolved.startsWith(root + path.sep)) return undefined
134
145
  try {
135
- if (!fs.statSync(resolved).isFile()) return undefined
136
- return fs.readFileSync(resolved, 'utf-8')
146
+ // Both sides canonicalised: on macOS /var is itself a symlink, so comparing a
147
+ // resolved path against an unresolved root rejects every legitimate read.
148
+ const root = fs.realpathSync(cwd)
149
+ // Confinement is checked after symlinks resolve: a lexical check passes a link
150
+ // that points outside the project, and the read would follow it.
151
+ const real = fs.realpathSync(path.resolve(cwd, reference))
152
+ if (real !== root && !real.startsWith(root + path.sep)) return undefined
153
+ if (!fs.statSync(real).isFile()) return undefined
154
+ return fs.readFileSync(real, 'utf-8')
137
155
  } catch {
138
156
  return undefined
139
157
  }
@@ -29,6 +29,8 @@ const CLAUDE_SHAPED = [
29
29
  path.join('.claude', 'hooks'),
30
30
  path.join('.claude', 'output-styles'),
31
31
  path.join('.claude', 'rules'),
32
+ path.join('.claude', 'skills'),
33
+ path.join('.claude', 'commands'),
32
34
  'CLAUDE.local.md',
33
35
  '.mcp.json',
34
36
  path.join('.pi', 'mcp.json'),
package/extensions/mcp.ts CHANGED
@@ -32,7 +32,7 @@ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/typ
32
32
  import { Type } from 'typebox'
33
33
  import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
34
34
  import { capForContext } from './internal/output-guard.js'
35
- import { isProjectApproved } from './internal/project-approval.js'
35
+ import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
36
36
 
37
37
  const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
38
38
  const DEFAULT_CALL_TIMEOUT_MS = 120_000
@@ -103,13 +103,18 @@ export interface ProjectServerPolicy {
103
103
  consentAll: boolean
104
104
  }
105
105
 
106
- /** Claude's per-server approvals for project .mcp.json servers. Consent-granting keys
107
- * (enabledMcpjsonServers, enableAllProjectMcpServers) count only from files the repo
108
- * does not control (user settings and settings.local.json), so a checked-in
109
- * settings.json cannot approve its own servers. disabledMcpjsonServers counts from
110
- * every file and wins over consent. Lists union across files: for denies the union is
111
- * the restrictive reading, and consent is the union of the user's own two files. */
112
- export function projectServerPolicy(cwd: string, home: string): ProjectServerPolicy {
106
+ /** Claude's per-server approvals for project .mcp.json servers.
107
+ *
108
+ * Consent-granting keys (enabledMcpjsonServers, enableAllProjectMcpServers) count
109
+ * from the user's own settings always, and from the project's settings.local.json
110
+ * only once the project itself is approved. That file is gitignored by convention,
111
+ * not by enforcement: a repository can commit one, and honoring it unconditionally
112
+ * let a hostile repo self-approve a server whose `command` runs on connect, even
113
+ * after the user declined the trust prompt.
114
+ *
115
+ * disabledMcpjsonServers counts from every file, including the repo's own, and wins
116
+ * over consent: a repo may always restrict itself further, never less. */
117
+ export function projectServerPolicy(cwd: string, home: string, projectApproved: boolean): ProjectServerPolicy {
113
118
  const read = (file: string): Record<string, unknown> => {
114
119
  try {
115
120
  return JSON.parse(fs.readFileSync(file, 'utf-8'))
@@ -122,7 +127,7 @@ export function projectServerPolicy(cwd: string, home: string): ProjectServerPol
122
127
  const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
123
128
  const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
124
129
  const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
125
- const consentSources = [userSettings, localSettings]
130
+ const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
126
131
  const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
127
132
  const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
128
133
  return { disabled, consented, consentAll }
@@ -431,7 +436,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
431
436
  /** Connect the project scope under the per-server policy. Returns whether the scope
432
437
  * is settled, so a refused confirm can be retried on a later session start. */
433
438
  async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
434
- const policy = projectServerPolicy(ctx.cwd, os.homedir())
439
+ // The stored decision, read without prompting: consent recorded inside the
440
+ // project only counts once the project itself has been approved.
441
+ const policy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
435
442
  const { consented, gated } = splitByPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy)
436
443
  if (Object.keys(consented).length > 0) await connectServers(consented)
437
444
  if (Object.keys(gated).length === 0) return true
@@ -4,9 +4,9 @@ Read-only exploration mode for safe code analysis.
4
4
 
5
5
  ## Features
6
6
 
7
- - **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question
7
+ - **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question, and `plan_mode_complete`
8
8
  - **Bash allowlist**: Only read-only bash commands are allowed
9
- - **Plan extraction**: Extracts numbered steps from `Plan:` sections
9
+ - **Plan extraction**: Takes the plan from the `plan_mode_complete` tool call, falling back to numbered steps under a `Plan:` header when the model writes prose instead
10
10
  - **Progress tracking**: Widget shows completion status during execution
11
11
  - **[DONE:n] markers**: Explicit step completion tracking
12
12
  - **Session persistence**: State survives session resume
@@ -21,7 +21,7 @@ Read-only exploration mode for safe code analysis.
21
21
 
22
22
  1. Enable plan mode with `/plan` or `--plan` flag
23
23
  2. Ask the agent to analyze code and create a plan
24
- 3. The agent should output a numbered plan under a `Plan:` header:
24
+ 3. The agent calls `plan_mode_complete` with the finished plan. If it writes prose instead, a numbered plan under a `Plan:` header is still picked up:
25
25
 
26
26
  ```
27
27
  Plan:
@@ -83,6 +83,7 @@ export default function statusLine(pi: ExtensionAPI) {
83
83
  let sessionCtx: ExtensionContext | undefined
84
84
  let commandLine: string | undefined
85
85
  let permissionMode = 'default'
86
+ let projectApproved = false
86
87
  let refreshTimer: ReturnType<typeof setInterval> | undefined
87
88
  let debounceTimer: ReturnType<typeof setTimeout> | undefined
88
89
  let running = false
@@ -103,7 +104,9 @@ export default function statusLine(pi: ExtensionAPI) {
103
104
  /** The stdin payload per Claude's documented statusline contract. */
104
105
  function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
105
106
  const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
106
- const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), true))
107
+ // Same gate as the config read above: an unapproved project's style is not applied,
108
+ // so reporting it here would describe a style the session is not using.
109
+ const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
107
110
  const payload: Record<string, unknown> = {
108
111
  session_id: ctx.sessionManager.getSessionId(),
109
112
  cwd: ctx.cwd,
@@ -128,11 +131,17 @@ export default function statusLine(pi: ExtensionAPI) {
128
131
  }
129
132
  running = true
130
133
  try {
134
+ // Everything below can touch ctx after an await, and every ctx getter throws
135
+ // once the session is disposed. This promise is started from a timer with no
136
+ // awaiter, so an escaping rejection becomes an uncaughtException and exits pi.
131
137
  const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS)
132
138
  const first = result.stdout.split('\n')[0].trimEnd()
133
139
  const pad = ' '.repeat(config.padding)
134
140
  commandLine = first ? `${pad}${first}${pad}` : undefined
135
141
  show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
142
+ } catch {
143
+ // A replaced or reloaded session invalidates ctx while the command is in
144
+ // flight; there is nothing left to update, and the next session starts fresh.
136
145
  } finally {
137
146
  running = false
138
147
  if (rerunQueued) {
@@ -168,6 +177,7 @@ export default function statusLine(pi: ExtensionAPI) {
168
177
  // approval at session start, and a second prompt stacks over the first and eats
169
178
  // the keys meant for it. An undecided project simply skips project settings.
170
179
  const trusted = isProjectApprovedSilently(ctx)
180
+ projectApproved = trusted
171
181
  config = readStatusLineConfig(hookFiles(ctx.cwd, os.homedir(), trusted))
172
182
  if (config?.refreshInterval) {
173
183
  refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
@@ -41,7 +41,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
41
41
 
42
42
  To enable project-local agents (`.claude/agents`, `.pi/agents`), pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
43
43
 
44
- When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable.
44
+ 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
45
 
46
46
  ## Usage
47
47
 
@@ -5,7 +5,7 @@
5
5
  import * as fs from 'node:fs'
6
6
  import * as os from 'node:os'
7
7
  import * as path from 'node:path'
8
- import { getAgentDir, parseFrontmatter } from '@earendil-works/pi-coding-agent'
8
+ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
9
9
 
10
10
  // Claude Code tool names -> pi tool names; unmapped names pass through lowercased
11
11
  const CLAUDE_TOOL_MAP: Record<string, string> = {
@@ -110,21 +110,31 @@ export function withPreloadedSkills(prompt: string, skills: string[] | undefined
110
110
  * prompt sent to the model, so a traversal would be an arbitrary-file read. */
111
111
  const SKILL_NAME = /^[A-Za-z0-9_.-]+$/
112
112
 
113
+ /** Read one candidate file, but only if it really sits under `root` once symlinks
114
+ * are resolved. Both sides are canonicalised: on macOS /var is itself a symlink, so
115
+ * comparing a resolved path against an unresolved root rejects every valid read. */
116
+ function readConfined(candidate: string, root: string): string | undefined {
117
+ try {
118
+ const real = fs.realpathSync(candidate)
119
+ if (real !== root && !real.startsWith(root + path.sep)) return undefined
120
+ return stripFrontmatter(fs.readFileSync(real, 'utf-8'))
121
+ } catch {
122
+ return undefined
123
+ }
124
+ }
125
+
113
126
  function readSkillBody(name: string, skillDirs: string[]): string | undefined {
114
127
  if (!SKILL_NAME.test(name) || name === '.' || name === '..') return undefined
115
128
  for (const dir of skillDirs) {
116
- const root = path.resolve(dir)
129
+ let root: string
130
+ try {
131
+ root = fs.realpathSync(dir)
132
+ } catch {
133
+ continue // a skills directory that does not exist simply contributes nothing
134
+ }
117
135
  for (const candidate of [path.join(dir, name, 'SKILL.md'), path.join(dir, `${name}.md`)]) {
118
- // Belt and braces against symlinks and platform path quirks: the file actually
119
- // read must still sit under the skills directory it was resolved from.
120
- if (!path.resolve(candidate).startsWith(root + path.sep)) continue
121
- try {
122
- const content = fs.readFileSync(candidate, 'utf-8')
123
- const match = /^---\r?\n[\s\S]*?\r?\n---/.exec(content)
124
- return match ? content.slice(match[0].length) : content
125
- } catch {
126
- // try the next shape
127
- }
136
+ const body = readConfined(candidate, root)
137
+ if (body !== undefined) return body
128
138
  }
129
139
  }
130
140
  return undefined
@@ -7,6 +7,9 @@
7
7
 
8
8
  import { spawn } from 'node:child_process'
9
9
  import { randomUUID } from 'node:crypto'
10
+ import * as fs from 'node:fs'
11
+ import * as os from 'node:os'
12
+ import * as path from 'node:path'
10
13
 
11
14
  export interface BackgroundRun {
12
15
  id: string
@@ -28,6 +31,10 @@ export interface BackgroundSpawn {
28
31
  command: string
29
32
  args: string[]
30
33
  cwd: string
34
+ /** The --append-system-prompt body, kept so a resume can rebuild the file the
35
+ * completing run deleted. Without it the resumed child is handed a path that no
36
+ * longer exists, and pi falls back to using that path as the prompt text. */
37
+ promptBody?: string
31
38
  }
32
39
 
33
40
  const runs = new Map<string, BackgroundRun>()
@@ -97,7 +104,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
97
104
  const run = runs.get(id)
98
105
  if (!run) return 'unknown'
99
106
  if (run.state === 'running') return 'still-running'
100
- const args = run.spawn.args.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
107
+ const args = withRebuiltPrompt(run.spawn).map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
101
108
  run.state = 'running'
102
109
  run.task = task
103
110
  run.output = undefined
@@ -106,6 +113,26 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
106
113
  return 'resumed'
107
114
  }
108
115
 
116
+ /** Re-point --append-system-prompt at a fresh file when the original is gone. */
117
+ function withRebuiltPrompt(spawnSpec: BackgroundSpawn): string[] {
118
+ const flag = spawnSpec.args.indexOf('--append-system-prompt')
119
+ if (flag === -1 || !spawnSpec.promptBody) return spawnSpec.args
120
+ const current = spawnSpec.args[flag + 1]
121
+ if (current && fs.existsSync(current)) return spawnSpec.args
122
+ try {
123
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-subagent-'))
124
+ const file = path.join(dir, 'prompt.md')
125
+ fs.writeFileSync(file, spawnSpec.promptBody, { mode: 0o600 })
126
+ const rebuilt = [...spawnSpec.args]
127
+ rebuilt[flag + 1] = file
128
+ return rebuilt
129
+ } catch {
130
+ // Cannot rewrite it: drop the pair rather than hand pi a path it will treat as
131
+ // prompt text, which would replace the agent persona with a temp path.
132
+ return spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1)
133
+ }
134
+ }
135
+
109
136
  export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string | null {
110
137
  // Checked here, synchronously with registration: callers await temp-file writes
111
138
  // between any check of their own and this call, so a parallel tool-call batch
@@ -626,10 +626,17 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
626
626
  }
627
627
  args.push(`Task: ${task}`)
628
628
  const invocation = getPiInvocation(args)
629
- const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
629
+ const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd, promptBody: tmpPrompt ? promptWithSkills : undefined }, (run) => {
630
630
  removeTmpPrompt(tmpPrompt)
631
631
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
632
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
632
+ // The run outlives the session that started it, and every pi call throws once
633
+ // that session is disposed; an escaping error here would reach Node as an
634
+ // uncaughtException and take the process down with it.
635
+ try {
636
+ pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
637
+ } catch {
638
+ // the session that asked for this run is gone; nothing left to notify
639
+ }
633
640
  })
634
641
  if (id === null) {
635
642
  // Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
@@ -749,6 +756,10 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
749
756
  cwd: t.cwd,
750
757
  signal,
751
758
  onPhase: mode.onPhase,
759
+ // Same context single and chain mode pass: without these, an agent's skills
760
+ // preload and its model tier alias silently do nothing in parallel mode only.
761
+ skillRoots: mode.skillRoots,
762
+ availableModels: mode.availableModels,
752
763
  // Per-task update callback
753
764
  onUpdate: (partial) => {
754
765
  const live = partial.details?.results[0]
@@ -1102,7 +1113,11 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1102
1113
 
1103
1114
  export default function subagentExtension(pi: ExtensionAPI) {
1104
1115
  const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
1105
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1116
+ try {
1117
+ pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1118
+ } catch {
1119
+ // same as above: the session that started the run may already be gone
1120
+ }
1106
1121
  }
1107
1122
 
1108
1123
  // Claude surfaces each agent's description so the model can pick one autonomously.
@@ -335,8 +335,11 @@ export default function todoExtension(pi: ExtensionAPI) {
335
335
  const msg = entry.message
336
336
  if (msg.role !== 'toolResult' || msg.toolName !== TOOL_NAME) continue
337
337
 
338
- const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos: LegacyTodo[] }) | undefined
339
- if (details) {
338
+ const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos?: LegacyTodo[] }) | undefined
339
+ // pi persists a failed tool call as `details: {}`, which is truthy: a rejected
340
+ // or blocked todo call would otherwise throw here and break replay for the rest
341
+ // of the session, losing the list on every resume, fork and compaction.
342
+ if (Array.isArray(details?.todos)) {
340
343
  replayTodos = details.todos.map(normalizeTodo)
341
344
  replayNextId = details.nextId
342
345
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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, and subagents",
5
5
  "keywords": [
6
6
  "pi",