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.
- package/extensions/git-checkpoint.ts +33 -1
- package/extensions/mcp.ts +60 -21
- package/extensions/memory.ts +51 -1
- package/extensions/plan-mode/utils.ts +4 -2
- package/extensions/question.ts +3 -3
- package/extensions/subagent/README.md +1 -1
- package/extensions/subagent/background.ts +28 -2
- package/extensions/subagent/index.ts +14 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
package/extensions/memory.ts
CHANGED
|
@@ -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
|
|
198
|
-
|
|
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
|
|
package/extensions/question.ts
CHANGED
|
@@ -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**:
|
|
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.
|
|
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.
|
|
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",
|