pi-code 0.7.0 → 0.8.0

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.
@@ -13,6 +13,7 @@
13
13
  * the checkpoint (files created after the checkpoint are left in place).
14
14
  */
15
15
 
16
+ import * as fs from 'node:fs'
16
17
  import * as os from 'node:os'
17
18
  import * as path from 'node:path'
18
19
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
@@ -28,6 +29,35 @@ interface Checkpoint {
28
29
  createdAt: string
29
30
  }
30
31
 
32
+ /** Claude deletes checkpoints after 30 days (cleanupPeriodDays). Shadow repos hold
33
+ * full snapshots of every non-ignored file, so unbounded retention grows under $HOME
34
+ * for the life of the machine. */
35
+ export const CHECKPOINT_RETENTION_DAYS = 30
36
+
37
+ /** Remove shadow repos untouched for longer than the retention window. The live
38
+ * session's repo is always kept, whatever its age: a long session's directory mtime
39
+ * can predate the window. Failures are ignored; this is housekeeping, not a gate. */
40
+ export function pruneCheckpointRepos(root: string, retentionDays: number, keepDir?: string): void {
41
+ let entries: fs.Dirent[]
42
+ try {
43
+ entries = fs.readdirSync(root, { withFileTypes: true })
44
+ } catch {
45
+ return
46
+ }
47
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) continue
50
+ const dir = path.join(root, entry.name)
51
+ if (keepDir && path.resolve(dir) === path.resolve(keepDir)) continue
52
+ try {
53
+ if (fs.statSync(dir).mtimeMs >= cutoff) continue
54
+ fs.rmSync(dir, { recursive: true, force: true })
55
+ } catch {
56
+ // a repo we cannot stat or remove stays; housekeeping must not break startup
57
+ }
58
+ }
59
+ }
60
+
31
61
  export function sessionSlug(sessionFile: string | undefined): string {
32
62
  if (!sessionFile) return `ephemeral-${process.pid}`
33
63
  return path.basename(sessionFile).replace(/[^\w.-]+/g, '_')
@@ -93,7 +123,9 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
93
123
  async function ensureShadow(ctx: ExtensionContext): Promise<void> {
94
124
  workTree = ctx.cwd
95
125
  const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
96
- shadowDir = path.join(os.homedir(), '.pi', 'agent', 'checkpoints', sessionSlug(sessionFile))
126
+ const checkpointsRoot = path.join(os.homedir(), '.pi', 'agent', 'checkpoints')
127
+ shadowDir = path.join(checkpointsRoot, sessionSlug(sessionFile))
128
+ pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
97
129
  const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
98
130
  if (check.code !== 0) {
99
131
  await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
package/extensions/mcp.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  import * as fs from 'node:fs'
22
22
  import * as os from 'node:os'
23
23
  import * as path from 'node:path'
24
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
24
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
25
25
  import { Client } from '@modelcontextprotocol/sdk/client/index.js'
26
26
  // SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
27
27
  // the old spec exist, so this stays as a fallback for the migration period.
@@ -60,6 +60,8 @@ export interface StdioServerConfig {
60
60
  args?: string[]
61
61
  env?: Record<string, string>
62
62
  cwd?: string
63
+ /** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
64
+ timeout?: number
63
65
  }
64
66
 
65
67
  export interface HttpServerConfig {
@@ -68,6 +70,8 @@ export interface HttpServerConfig {
68
70
  headers?: Record<string, string>
69
71
  bearerToken?: string
70
72
  bearerTokenEnv?: string
73
+ /** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
74
+ timeout?: number
71
75
  }
72
76
 
73
77
  export type ServerConfig = StdioServerConfig | HttpServerConfig
@@ -123,6 +127,19 @@ export function projectServerPolicy(cwd: string, home: string): ProjectServerPol
123
127
  return { disabled, consented, consentAll }
124
128
  }
125
129
 
130
+ /** Split project servers by the per-server policy: never-connect, connect without the
131
+ * whole-project confirm, and still gated behind it. */
132
+ export function splitByPolicy(candidates: Record<string, ServerConfig>, policy: ProjectServerPolicy): { consented: Record<string, ServerConfig>; gated: Record<string, ServerConfig> } {
133
+ const consented: Record<string, ServerConfig> = {}
134
+ const gated: Record<string, ServerConfig> = {}
135
+ for (const [name, config] of Object.entries(candidates)) {
136
+ if (policy.disabled.has(name)) continue
137
+ if (policy.consentAll || policy.consented.has(name)) consented[name] = config
138
+ else gated[name] = config
139
+ }
140
+ return { consented, gated }
141
+ }
142
+
126
143
  export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
127
144
  const servers: Record<string, ServerConfig> = {}
128
145
  for (const file of files) {
@@ -153,6 +170,28 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
153
170
  return servers
154
171
  }
155
172
 
173
+ /** Claude reports a config entry that has a url but no type as an error; pi-code
174
+ * still connects (streamable HTTP with SSE fallback) but says the entry is wrong. */
175
+ /** An inline bearerToken (interpolated) wins over bearerTokenEnv, which names an
176
+ * environment variable read as-is. */
177
+ export function resolveBearerToken(config: { bearerToken?: string; bearerTokenEnv?: string }): string | undefined {
178
+ if (config.bearerToken) return interpolateEnv(config.bearerToken)
179
+ if (config.bearerTokenEnv) return process.env[config.bearerTokenEnv]
180
+ return undefined
181
+ }
182
+
183
+ /** A server cwd expands ${VAR} then a leading ~, or stays unset. */
184
+ export function expandCwd(cwd: string | undefined): string | undefined {
185
+ if (!cwd) return undefined
186
+ return interpolateEnv(cwd).replace(/^~(?=\/|$)/, os.homedir())
187
+ }
188
+
189
+ export function warnOnTypelessUrl(name: string, config: ServerConfig): void {
190
+ if ('url' in config && config.type === undefined) {
191
+ console.warn(`pi-code-mcp: server ${name} declares a url with no "type"; add "type": "http" or "sse"`)
192
+ }
193
+ }
194
+
156
195
  export function formatToolName(server: string, tool: string): string {
157
196
  return `${server}_${tool}`.replaceAll('-', '_')
158
197
  }
@@ -228,7 +267,7 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
228
267
  command: interpolateEnv(config.command),
229
268
  args: (config.args ?? []).map((arg) => interpolateEnv(arg)),
230
269
  env,
231
- cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
270
+ cwd: expandCwd(config.cwd),
232
271
  stderr: 'ignore',
233
272
  })
234
273
  await connectWithTimeout(client, transport, `connect ${name}`)
@@ -236,7 +275,7 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
236
275
  }
237
276
  const headers: Record<string, string> = {}
238
277
  for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
239
- const token = config.bearerToken ? interpolateEnv(config.bearerToken) : config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined
278
+ const token = resolveBearerToken(config)
240
279
  if (token) headers.Authorization = `Bearer ${token}`
241
280
  const url = new URL(interpolateEnv(config.url))
242
281
  if (config.type === 'sse') {
@@ -305,6 +344,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
305
344
  console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
306
345
  continue
307
346
  }
347
+ warnOnTypelessUrl(name, config)
308
348
  try {
309
349
  const client = await connect(name, config)
310
350
  clients.set(name, client)
@@ -327,7 +367,10 @@ export default async function mcpExtension(pi: ExtensionAPI) {
327
367
  async execute(_id, params) {
328
368
  // Pass the timeout to the SDK too: its own default request timeout is 60s and
329
369
  // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
330
- const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: callTimeoutMs() }), callTimeoutMs(), toolName)
370
+ // Claude's per-server timeout wins over MCP_TOOL_TIMEOUT, with a 1s floor.
371
+ const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
372
+ const budget = declared ?? callTimeoutMs()
373
+ const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
331
374
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
332
375
  const details: { error?: string } = {}
333
376
  if (result.isError) {
@@ -346,6 +389,18 @@ export default async function mcpExtension(pi: ExtensionAPI) {
346
389
  }
347
390
  }
348
391
 
392
+ /** Connect the project scope under the per-server policy. Returns whether the scope
393
+ * is settled, so a refused confirm can be retried on a later session start. */
394
+ async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
395
+ const policy = projectServerPolicy(ctx.cwd, os.homedir())
396
+ const { consented, gated } = splitByPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy)
397
+ if (Object.keys(consented).length > 0) await connectServers(consented)
398
+ if (Object.keys(gated).length === 0) return true
399
+ if (!(await isProjectApproved(ctx))) return false
400
+ await connectServers(gated)
401
+ return true
402
+ }
403
+
349
404
  let userConnected = false
350
405
  let projectConnected = false
351
406
 
@@ -361,23 +416,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
361
416
  // connect, servers the user consented to individually connect without the
362
417
  // whole-project confirm, and the rest stay behind it. Reconnect attempts after a
363
418
  // refusal are safe: connectServers skips names that already connected.
364
- if (!projectConnected) {
365
- const policy = projectServerPolicy(ctx.cwd, os.homedir())
366
- const candidates = loadConfigFrom(projectConfigPaths(ctx.cwd))
367
- const consented: Record<string, ServerConfig> = {}
368
- const gated: Record<string, ServerConfig> = {}
369
- for (const [name, config] of Object.entries(candidates)) {
370
- if (policy.disabled.has(name)) continue
371
- if (policy.consentAll || policy.consented.has(name)) consented[name] = config
372
- else gated[name] = config
373
- }
374
- if (Object.keys(consented).length > 0) await connectServers(consented)
375
- if (Object.keys(gated).length === 0) projectConnected = true
376
- else if (await isProjectApproved(ctx)) {
377
- projectConnected = true
378
- await connectServers(gated)
379
- }
380
- }
419
+ if (!projectConnected) projectConnected = await connectProjectScope(ctx)
381
420
 
382
421
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
383
422
 
@@ -7,6 +7,7 @@
7
7
  * memories through the memory tool (save / read / delete / list).
8
8
  */
9
9
 
10
+ import { createHash } from 'node:crypto'
10
11
  import * as fs from 'node:fs'
11
12
  import * as os from 'node:os'
12
13
  import * as path from 'node:path'
@@ -17,7 +18,27 @@ import { capForContext } from './internal/output-guard.js'
17
18
 
18
19
  const INDEX_FILE = 'MEMORY.md'
19
20
 
21
+ /** Claude loads the first 200 lines or 25KB of the memory index at startup. */
22
+ export const INDEX_MAX_LINES = 200
23
+ export const INDEX_MAX_BYTES = 25_000
24
+
25
+ /** Windows drive letters are case-insensitive, so C:\x and c:\x are one project. */
26
+ function normalizeCwd(cwd: string): string {
27
+ return cwd.replace(/^([A-Za-z]):(?=[/\\])/, (_match, letter: string) => letter.toUpperCase())
28
+ }
29
+
30
+ /** Readable dashed path plus a short digest of the real path. The digest is what makes
31
+ * the slug injective: every separator becomes a dash, so /a/b, /a-b and \a\b share a
32
+ * dashed form and would otherwise share one store. */
20
33
  export function projectSlug(cwd: string): string {
34
+ const normalized = normalizeCwd(cwd)
35
+ const readable = normalized.replace(/[/\\]/g, '-').replace(/^-+/, '-')
36
+ const digest = createHash('sha256').update(normalized).digest('hex').slice(0, 8)
37
+ return `${readable}-${digest}`
38
+ }
39
+
40
+ /** The pre-digest slug, kept only to migrate an existing store to the new name. */
41
+ function legacySlug(cwd: string): string {
21
42
  return cwd
22
43
  .replace(/^([A-Za-z]):(?=[/\\])/, '$1')
23
44
  .replace(/[/\\]/g, '-')
@@ -28,6 +49,34 @@ export function memoryDir(cwd: string): string {
28
49
  return path.join(os.homedir(), '.pi', 'agent', 'memory', projectSlug(cwd))
29
50
  }
30
51
 
52
+ /** Move a store written under the pre-digest slug to the current one, once. Without
53
+ * this the slug change would silently orphan every memory a user already has. */
54
+ export function migrateLegacyStore(cwd: string): void {
55
+ const current = memoryDir(cwd)
56
+ if (fs.existsSync(current)) return
57
+ const legacy = path.join(os.homedir(), '.pi', 'agent', 'memory', legacySlug(cwd))
58
+ if (!fs.existsSync(legacy)) return
59
+ try {
60
+ fs.renameSync(legacy, current)
61
+ } catch {
62
+ // A failed migration must not take down session start; the store stays legacy.
63
+ }
64
+ }
65
+
66
+ /** The index as injected into the prompt, bounded like Claude's startup load. */
67
+ export function capIndexForPrompt(index: string): string {
68
+ const withinLines = index.split('\n').slice(0, INDEX_MAX_LINES)
69
+ let dropped = index.split('\n').length - withinLines.length
70
+ let text = withinLines.join('\n')
71
+ while (Buffer.byteLength(text, 'utf-8') > INDEX_MAX_BYTES && withinLines.length > 1) {
72
+ withinLines.pop()
73
+ dropped++
74
+ text = withinLines.join('\n')
75
+ }
76
+ if (dropped <= 0) return index
77
+ return `${text}\n(${dropped} more memories not shown; use the memory tool with action "list")`
78
+ }
79
+
31
80
  export function slugifyName(name: string): string {
32
81
  return (
33
82
  name
@@ -76,6 +125,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
76
125
  let dir = memoryDir(process.cwd())
77
126
 
78
127
  pi.on('session_start', async (_event, ctx) => {
128
+ migrateLegacyStore(ctx.cwd)
79
129
  dir = memoryDir(ctx.cwd)
80
130
  const count = readIndex(dir)
81
131
  .split('\n')
@@ -87,7 +137,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
87
137
  const index = readIndex(dir)
88
138
  if (!index.trim()) return
89
139
  return {
90
- systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${index}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
140
+ systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${capIndexForPrompt(index)}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
91
141
  }
92
142
  })
93
143
 
@@ -194,8 +194,10 @@ export function cleanStepText(text: string): string {
194
194
  // Anchored to line start (m flag) so a prose line merely ending in "plan:" is not taken
195
195
  // for the header, which would slice the plan section mid-list and drop earlier steps.
196
196
  // Horizontal whitespace only ([^\S\n]): \s would include \n itself and overlap the
197
- // following \n, which is what backtracks super-linearly.
198
- const PLAN_HEADER = /^[^\S\n]*\*{0,2}Plan:\*{0,2}[^\S\n]*\n/im
197
+ // following \n. The runs are bounded rather than unbounded: an unbounded run retried
198
+ // from every position on a long whitespace-only line is what backtracks super-linearly,
199
+ // and a real header carries at most a few spaces of indentation.
200
+ const PLAN_HEADER = /^[^\S\n]{0,8}\*{0,2}Plan:\*{0,2}[^\S\n]{0,8}\n/im
199
201
 
200
202
  const isBlank = (ch: string | undefined): boolean => ch !== undefined && ch !== '\n' && ch.trim() === ''
201
203
 
@@ -32,10 +32,10 @@ const OptionSchema = Type.Object({
32
32
  description: Type.Optional(Type.String({ description: 'Optional description shown below label' })),
33
33
  })
34
34
 
35
- const QuestionParams = Type.Object({
35
+ export const QuestionParams = Type.Object({
36
36
  question: Type.String({ description: 'The question to ask the user' }),
37
- header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it' })),
38
- options: Type.Array(OptionSchema, { description: 'Options for the user to choose from' }),
37
+ header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it (max 12 characters)', maxLength: 12 })),
38
+ options: Type.Array(OptionSchema, { description: 'Options for the user to choose from (1-4)', minItems: 1, maxItems: 4 }),
39
39
  multiSelect: Type.Optional(Type.Boolean({ description: 'Allow selecting several options (space toggles, enter confirms)' })),
40
40
  })
41
41
 
@@ -7,7 +7,7 @@ Delegate tasks to specialized subagents with isolated context windows.
7
7
  - **Isolated context**: Each subagent runs in a separate `pi` process
8
8
  - **Streaming output**: See tool calls and progress as they happen
9
9
  - **Parallel streaming**: All parallel tasks stream updates simultaneously
10
- - **Background runs**: Fire-and-forget with a completion notification; max 8 running at once
10
+ - **Background runs**: `{background: true}` returns a run id and notifies on completion; `{status: true}` lists runs and `{cancel: "<id>"}` stops one (signalling its process group); max 8 running at once
11
11
  - **Bounded fan-out**: A subagent refuses to spawn subagents of its own (an env marker the tool honors: steering, not a sandbox)
12
12
  - **Markdown rendering**: Final output rendered with proper formatting (expanded view)
13
13
  - **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
@@ -12,10 +12,12 @@ export interface BackgroundRun {
12
12
  id: string
13
13
  agent: string
14
14
  task: string
15
- state: 'running' | 'done' | 'failed'
15
+ state: 'running' | 'done' | 'failed' | 'cancelled'
16
16
  exitCode?: number
17
17
  output?: string
18
18
  turns: number
19
+ /** Set while running so the run can be cancelled; cleared on completion. */
20
+ kill?: () => void
19
21
  }
20
22
 
21
23
  export interface BackgroundSpawn {
@@ -63,6 +65,18 @@ export function formatStatus(all: Iterable<BackgroundRun>): string {
63
65
  return lines.length > 0 ? lines.join('\n') : 'No background runs in this session.'
64
66
  }
65
67
 
68
+ /** Cancel a running background child. Returns what the caller should tell the model:
69
+ * unknown id, already finished, or cancelled. */
70
+ export function cancelBackgroundRun(id: string): 'cancelled' | 'not-running' | 'unknown' {
71
+ const run = runs.get(id)
72
+ if (!run) return 'unknown'
73
+ if (run.state !== 'running' || !run.kill) return 'not-running'
74
+ run.state = 'cancelled'
75
+ run.kill()
76
+ run.kill = undefined
77
+ return 'cancelled'
78
+ }
79
+
66
80
  export function backgroundStatusText(): string {
67
81
  return formatStatus(runs.values())
68
82
  }
@@ -80,9 +94,18 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
80
94
  cwd: invocation.cwd,
81
95
  shell: false,
82
96
  stdio: ['ignore', 'pipe', 'ignore'],
97
+ // Its own group, so cancelling reaches any grandchild the agent spawned.
98
+ detached: true,
83
99
  // The marker lets the child's subagent tool refuse to nest further.
84
100
  env: { ...process.env, PI_CODE_SUBAGENT: '1' },
85
101
  })
102
+ run.kill = () => {
103
+ try {
104
+ process.kill(-proc.pid!, 'SIGTERM')
105
+ } catch {
106
+ proc.kill('SIGTERM')
107
+ }
108
+ }
86
109
  let stdout = ''
87
110
  // Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
88
111
  let completed = false
@@ -96,13 +119,16 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
96
119
  })
97
120
  proc.on('close', (code) => {
98
121
  const { text, turns } = parseFinalOutputFromJsonl(stdout)
99
- run.state = code === 0 ? 'done' : 'failed'
122
+ run.kill = undefined
123
+ // A cancelled run keeps that state: its non-zero exit is the cancellation.
124
+ if (run.state !== 'cancelled') run.state = code === 0 ? 'done' : 'failed'
100
125
  run.exitCode = code ?? 0
101
126
  run.output = text
102
127
  run.turns = turns
103
128
  complete()
104
129
  })
105
130
  proc.on('error', () => {
131
+ run.kill = undefined
106
132
  run.state = 'failed'
107
133
  run.exitCode = 1
108
134
  complete()
@@ -27,7 +27,7 @@ import { capForContext } from '../internal/output-guard.js'
27
27
  import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
28
28
  import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
29
29
  import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
30
- import { activeBackgroundRuns, backgroundStatusText, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
30
+ import { activeBackgroundRuns, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
31
31
 
32
32
  const MAX_PARALLEL_TASKS = 8
33
33
  const MAX_CONCURRENCY = 4
@@ -460,6 +460,7 @@ const SubagentParams = Type.Object({
460
460
  cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process (single mode)' })),
461
461
  background: Type.Optional(Type.Boolean({ description: 'Run the single-mode task in the background: returns a run id immediately and a notification arrives when it completes.' })),
462
462
  status: Type.Optional(Type.Boolean({ description: 'Set true (alone, no other params) to list background runs instead of running anything.' })),
463
+ cancel: Type.Optional(Type.String({ description: 'Background run id to cancel (from the id returned when it started, or from status).' })),
463
464
  })
464
465
 
465
466
  /**
@@ -489,6 +490,14 @@ type SubagentParamsStatic = Static<typeof SubagentParams>
489
490
  type ChainStepParam = Static<typeof ChainItem>
490
491
  type TaskItemParam = Static<typeof TaskItem>
491
492
 
493
+ /** What to tell the model about a cancel request. */
494
+ export function cancelResultText(id: string): string {
495
+ const outcome = cancelBackgroundRun(id)
496
+ if (outcome === 'cancelled') return `Cancelled background run ${id}.`
497
+ if (outcome === 'not-running') return `Background run ${id} already finished; nothing to cancel.`
498
+ return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
499
+ }
500
+
492
501
  /** Everything a mode handler needs from the surrounding execute() call. */
493
502
  interface ModeContext {
494
503
  agents: AgentConfig[]
@@ -1120,6 +1129,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
1120
1129
  results,
1121
1130
  })
1122
1131
 
1132
+ if (params.cancel) {
1133
+ return { content: [{ type: 'text', text: cancelResultText(params.cancel) }], details: makeDetails('single')([]) }
1134
+ }
1135
+
1123
1136
  if (params.status) {
1124
1137
  return { content: [{ type: 'text', text: backgroundStatusText() }], details: makeDetails('single')([]) }
1125
1138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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",