@justjoshn/pi-pstack 0.15.36-pi.11 → 0.15.36-pi.13
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/CHANGELOG.md +28 -0
- package/README.md +14 -14
- package/agents/thermo-nuclear-code-quality-review.md +1 -1
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/skills/reproduce-and-fix-issues/SKILL.md +3 -3
- package/automations/benny/skills/setup-benny/SKILL.md +3 -3
- package/automations/benny/skills/triage-issue-reports/SKILL.md +2 -2
- package/docs/native-gaps.md +1 -1
- package/docs/parity-status.md +55 -11
- package/package.json +4 -3
- package/skills/make-bot-ui/SKILL.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +1 -1
- package/skills/statechart/scripts/test/cli.test.ts +10 -1
- package/skills/teach/SKILL.md +1 -1
- package/src/ask.ts +24 -10
- package/src/child-env.ts +4 -0
- package/src/generate-image.ts +98 -0
- package/src/index.ts +19 -11
- package/src/loop.ts +113 -60
- package/src/mode.ts +84 -46
- package/src/plan.ts +338 -46
- package/src/process-lock.ts +76 -0
- package/src/readonly-shell.ts +6 -8
- package/src/sections.ts +6 -1
- package/src/subscriptions/durable-store.ts +2 -75
- package/src/subscriptions/index.ts +2 -17
- package/src/subscriptions/supervisor.ts +1 -1
- package/src/task/agents.ts +39 -3
- package/src/task/card.ts +4 -2
- package/src/task/format.ts +17 -3
- package/src/task/records.ts +37 -32
- package/src/task/registry.ts +2 -1
- package/src/task/runner.ts +4 -1
- package/src/task/stop.ts +3 -3
- package/src/task/tool.ts +62 -22
- package/src/todo.ts +50 -7
- package/src/path-skills.ts +0 -104
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync,
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { performance } from 'node:perf_hooks';
|
|
5
5
|
|
|
6
6
|
import { durableRegistryPath, durableRuntimeDir } from '../paths.ts';
|
|
7
|
+
import { acquireLease, alive } from '../process-lock.ts';
|
|
7
8
|
import { parseCron } from './cron.ts';
|
|
8
9
|
import type { ArmStatus, DurableRegistry, DurableSubscription, EventAdmission, PendingEvent, StoredSubscription, SupervisorLock } from './durable-types.ts';
|
|
9
10
|
import { routineSlug } from './webhook.ts';
|
|
@@ -18,28 +19,6 @@ export function canonicalPath(path: string): string {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
function zombie(pid: number): boolean {
|
|
22
|
-
if (process.platform !== 'linux') return false;
|
|
23
|
-
try {
|
|
24
|
-
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
25
|
-
const close = stat.lastIndexOf(')');
|
|
26
|
-
return close !== -1 && stat.slice(close + 2, close + 3) === 'Z';
|
|
27
|
-
} catch {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function alive(pid: number): boolean {
|
|
33
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
34
|
-
try {
|
|
35
|
-
process.kill(pid, 0);
|
|
36
|
-
// A zombie still answers kill(pid, 0) until its parent reaps it, but it holds no lock and runs nothing.
|
|
37
|
-
return !zombie(pid);
|
|
38
|
-
} catch (error) {
|
|
39
|
-
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
22
|
function writeAtomic(path: string, value: unknown): void {
|
|
44
23
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
45
24
|
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -77,58 +56,6 @@ export function writeRoutinesPort(cwd: string, port: number): void {
|
|
|
77
56
|
}
|
|
78
57
|
}
|
|
79
58
|
|
|
80
|
-
export type Lease = { token: string; release: () => void };
|
|
81
|
-
|
|
82
|
-
/** Publish a nonempty directory atomically. Recovery removes only the dead holder's token. */
|
|
83
|
-
export function acquireLease(path: string): Lease | undefined {
|
|
84
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
85
|
-
const token = `${process.pid}-${randomUUID()}`;
|
|
86
|
-
const temporary = `${path}.${token}.tmp`;
|
|
87
|
-
mkdirSync(temporary, { mode: 0o700 });
|
|
88
|
-
try {
|
|
89
|
-
writeFileSync(join(temporary, token), '', { mode: 0o600 });
|
|
90
|
-
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
91
|
-
try {
|
|
92
|
-
renameSync(temporary, path);
|
|
93
|
-
return {
|
|
94
|
-
token,
|
|
95
|
-
release: () => {
|
|
96
|
-
rmSync(join(path, token), { force: true });
|
|
97
|
-
try {
|
|
98
|
-
rmdirSync(path);
|
|
99
|
-
} catch (error) {
|
|
100
|
-
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
101
|
-
}
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
} catch (error) {
|
|
105
|
-
if (!['ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
106
|
-
}
|
|
107
|
-
let holders: string[];
|
|
108
|
-
try {
|
|
109
|
-
holders = readdirSync(path);
|
|
110
|
-
} catch (error) {
|
|
111
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
112
|
-
throw error;
|
|
113
|
-
}
|
|
114
|
-
for (const holder of holders) {
|
|
115
|
-
const pid = Number(/^(\d+)-[a-f0-9-]+$/.exec(holder)?.[1]);
|
|
116
|
-
if (!Number.isInteger(pid)) throw new Error(`Invalid subscription lock ${path}. Retain it for diagnosis.`);
|
|
117
|
-
if (alive(pid)) return undefined;
|
|
118
|
-
rmSync(join(path, holder), { force: true });
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
rmdirSync(path);
|
|
122
|
-
} catch (error) {
|
|
123
|
-
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return undefined;
|
|
127
|
-
} finally {
|
|
128
|
-
rmSync(temporary, { recursive: true, force: true });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
59
|
export function ownerIsLive(cwd: string, sessionId: string): boolean {
|
|
133
60
|
const path = ownerLockPath(cwd, sessionId);
|
|
134
61
|
try {
|
|
@@ -6,24 +6,9 @@ import { type Static, Type } from 'typebox';
|
|
|
6
6
|
import { Value } from 'typebox/value';
|
|
7
7
|
import { isOneShot } from '../modes.ts';
|
|
8
8
|
import { routinesDir } from '../paths.ts';
|
|
9
|
+
import { acquireLease, type Lease } from '../process-lock.ts';
|
|
9
10
|
import { parseCron } from './cron.ts';
|
|
10
|
-
import {
|
|
11
|
-
acquireLease,
|
|
12
|
-
cancelDesired,
|
|
13
|
-
canonicalPath,
|
|
14
|
-
createDesired,
|
|
15
|
-
deliverOwner,
|
|
16
|
-
finishFresh,
|
|
17
|
-
isDesired,
|
|
18
|
-
isSupervisorLive,
|
|
19
|
-
type Lease,
|
|
20
|
-
ownerLockPath,
|
|
21
|
-
readArm,
|
|
22
|
-
readRegistry,
|
|
23
|
-
runsDetached,
|
|
24
|
-
startFresh,
|
|
25
|
-
visibleSubscriptions,
|
|
26
|
-
} from './durable-store.ts';
|
|
11
|
+
import { cancelDesired, canonicalPath, createDesired, deliverOwner, finishFresh, isDesired, isSupervisorLive, ownerLockPath, readArm, readRegistry, runsDetached, startFresh, visibleSubscriptions } from './durable-store.ts';
|
|
27
12
|
import type { StoredSubscription } from './durable-types.ts';
|
|
28
13
|
import { defaultEnsureSupervisor, waitForArm, waitForSupervisor } from './ensure-supervisor.ts';
|
|
29
14
|
import { type GhRunner, targetText } from './github.ts';
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { routinesDir } from '../paths.ts';
|
|
4
|
+
import { alive } from '../process-lock.ts';
|
|
4
5
|
import { cronMatches, parseCron } from './cron.ts';
|
|
5
6
|
import {
|
|
6
7
|
acceptEvent,
|
|
7
8
|
acquireSupervisorLock,
|
|
8
|
-
alive,
|
|
9
9
|
canonicalPath,
|
|
10
10
|
clearError,
|
|
11
11
|
expireDesired,
|
package/src/task/agents.ts
CHANGED
|
@@ -11,18 +11,54 @@ export interface AgentDefinition {
|
|
|
11
11
|
model?: string;
|
|
12
12
|
readonly: boolean;
|
|
13
13
|
background: boolean;
|
|
14
|
+
/** False for a type that never launches subagents of its own, such as Cursor's built-in explore. */
|
|
15
|
+
spawns?: boolean;
|
|
16
|
+
/** Pi tools this type turns on beyond Pi's defaults, such as explore's search tools. */
|
|
17
|
+
tools?: readonly string[];
|
|
14
18
|
prompt: string;
|
|
15
19
|
file?: string;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export const GENERAL_PURPOSE: AgentDefinition = {
|
|
19
23
|
type: 'generalPurpose',
|
|
20
|
-
description:
|
|
24
|
+
description: "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. Use when searching for a keyword or file and not confident you'll find the match quickly.",
|
|
21
25
|
readonly: false,
|
|
22
26
|
background: false,
|
|
23
27
|
prompt: '',
|
|
24
28
|
};
|
|
25
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Cursor's built-in codebase explorer: a file-search specialist that the caller tells how thorough to be, and that has
|
|
32
|
+
* no Task tool of its own.
|
|
33
|
+
*/
|
|
34
|
+
export const EXPLORE: AgentDefinition = {
|
|
35
|
+
type: 'explore',
|
|
36
|
+
description:
|
|
37
|
+
'Fast agent specialized for exploring codebases. Use it to find files by pattern (such as "src/components/**/*.tsx"), search code for keywords (such as "API endpoints"), or answer questions about the codebase (such as "how do API endpoints work?"). When you call it, name the thoroughness you want: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for a comprehensive search across many locations and naming conventions.',
|
|
38
|
+
readonly: false,
|
|
39
|
+
background: false,
|
|
40
|
+
spawns: false,
|
|
41
|
+
tools: ['grep', 'find', 'ls'],
|
|
42
|
+
prompt: [
|
|
43
|
+
'You are a file search specialist. You navigate and explore codebases thoroughly.',
|
|
44
|
+
'',
|
|
45
|
+
'You are good at:',
|
|
46
|
+
'- finding files fast with glob patterns (find)',
|
|
47
|
+
'- searching code and text with regular expressions (grep)',
|
|
48
|
+
'- reading and analyzing file contents (read)',
|
|
49
|
+
'',
|
|
50
|
+
'Guidelines:',
|
|
51
|
+
'- Match how hard you search to the thoroughness the caller asked for.',
|
|
52
|
+
'- Give file paths as absolute paths in your final response.',
|
|
53
|
+
'- Do not use emojis.',
|
|
54
|
+
'- Send your final report directly as a regular message.',
|
|
55
|
+
'',
|
|
56
|
+
'You are meant to be fast and to return as soon as you can. Use your tools efficiently: think about where the files and implementations are likely to be, and run several grep and read calls in parallel wherever you can.',
|
|
57
|
+
'',
|
|
58
|
+
"Complete the caller's search request efficiently and report your findings clearly.",
|
|
59
|
+
].join('\n'),
|
|
60
|
+
};
|
|
61
|
+
|
|
26
62
|
function parseAgentFile(file: string): AgentDefinition {
|
|
27
63
|
const { frontmatter, body } = parseFrontmatter(readFileSync(file, 'utf8'));
|
|
28
64
|
const name = frontmatter['name'];
|
|
@@ -39,12 +75,12 @@ function parseAgentFile(file: string): AgentDefinition {
|
|
|
39
75
|
};
|
|
40
76
|
}
|
|
41
77
|
|
|
42
|
-
/** generalPurpose first, then every `*.md` agent file in `dir` sorted by file name. */
|
|
78
|
+
/** The built-in types (generalPurpose, explore) first, as Cursor lists them, then every `*.md` agent file in `dir` sorted by file name. */
|
|
43
79
|
export function loadAgentDefinitions(dir: string = AGENTS_DIR): AgentDefinition[] {
|
|
44
80
|
const files = readdirSync(dir)
|
|
45
81
|
.filter((name) => name.endsWith('.md'))
|
|
46
82
|
.toSorted();
|
|
47
|
-
return [GENERAL_PURPOSE, ...files.map((name) => parseAgentFile(join(dir, name)))];
|
|
83
|
+
return [GENERAL_PURPOSE, EXPLORE, ...files.map((name) => parseAgentFile(join(dir, name)))];
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
export function findAgent(agents: readonly AgentDefinition[], type: string | undefined): AgentDefinition {
|
package/src/task/card.ts
CHANGED
|
@@ -35,6 +35,7 @@ interface NotificationCardDetails {
|
|
|
35
35
|
status: 'success' | 'error';
|
|
36
36
|
agentId: string;
|
|
37
37
|
title: string;
|
|
38
|
+
description?: string;
|
|
38
39
|
detail: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -49,10 +50,11 @@ export function taskNotificationCardText(message: { content: string | { type: st
|
|
|
49
50
|
const lines = raw.split('\n');
|
|
50
51
|
return (expanded || lines.length <= COLLAPSED_LINES ? lines : [...lines.slice(0, COLLAPSED_LINES), `… ${lines.length - COLLAPSED_LINES} more lines`]).join('\n');
|
|
51
52
|
}
|
|
52
|
-
const { title, status, agentId, detail } = message.details;
|
|
53
|
+
const { title, description, status, agentId, detail } = message.details;
|
|
53
54
|
const lines = detail.split('\n');
|
|
54
55
|
const body = expanded || lines.length <= COLLAPSED_LINES ? lines : [...lines.slice(0, COLLAPSED_LINES), `… ${lines.length - COLLAPSED_LINES} more lines`];
|
|
55
|
-
const
|
|
56
|
+
const name = [description, title].map((value) => value?.trim() ?? '').find((value) => value !== '') ?? 'subagent';
|
|
57
|
+
const head = `${name} · ${status} · agent ${agentId}`;
|
|
56
58
|
return [head, ...body].join('\n');
|
|
57
59
|
}
|
|
58
60
|
|
package/src/task/format.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function bounded(text: string, transcript: string | undefined): string {
|
|
|
28
28
|
return `${result.content}\n[truncated: showing ${result.outputLines} of ${result.totalLines} lines${size}; ${where}]`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const agentIdLine = (agentId: string) => `Agent ID: ${agentId} (
|
|
31
|
+
const agentIdLine = (agentId: string) => `Agent ID: ${agentId} (can be used with the \`resume\` parameter to send a follow-up after it completes)`;
|
|
32
32
|
const transcriptLine = (transcript: string | undefined) => (transcript === undefined ? [] : [`Transcript: ${transcript}`]);
|
|
33
33
|
|
|
34
34
|
export function foregroundText(agentId: string, response: string, transcript: string | undefined, notes: readonly string[] = []): string {
|
|
@@ -37,7 +37,7 @@ export function foregroundText(agentId: string, response: string, transcript: st
|
|
|
37
37
|
|
|
38
38
|
export function backgroundText(agentId: string, transcript: string | undefined, notes: readonly string[] = []): string {
|
|
39
39
|
return [
|
|
40
|
-
|
|
40
|
+
"Subagent is running in the background. When you end your turn, you will be automatically sent the subagent's final response upon its completion, so do not wait for it - either end your turn or work on something else.",
|
|
41
41
|
'',
|
|
42
42
|
agentIdLine(agentId),
|
|
43
43
|
...transcriptLine(transcript),
|
|
@@ -65,16 +65,28 @@ export function worktreePreface(worktree: Worktree, prompt: string): string {
|
|
|
65
65
|
export interface NotificationFields {
|
|
66
66
|
status: 'success' | 'error';
|
|
67
67
|
agentId: string;
|
|
68
|
+
/** The model-facing title, from the prompt as Cursor writes it. */
|
|
68
69
|
title: string;
|
|
70
|
+
/** The Task description, which names the subagent in the UI. */
|
|
71
|
+
description?: string;
|
|
69
72
|
toolCallId: string;
|
|
70
73
|
detail: string;
|
|
71
74
|
/** What the background child spent. It stays in the notification details, because Pi gives extensions no way to add it to session totals. */
|
|
72
75
|
usage?: Usage;
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
const TITLE_LIMIT = 80;
|
|
79
|
+
|
|
80
|
+
/** Cursor titles a completion by its prompt: the first non-empty line, cut at 80 characters. */
|
|
81
|
+
export function notificationTitle(prompt: string): string {
|
|
82
|
+
const line = prompt.split('\n').find((candidate) => candidate.trim() !== '') ?? '';
|
|
83
|
+
return line.trim().slice(0, TITLE_LIMIT);
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
export function notificationText(fields: NotificationFields): string {
|
|
76
87
|
return [
|
|
77
|
-
'
|
|
88
|
+
'<system_notification>',
|
|
89
|
+
'The following task has finished. If you were already aware, ignore this notification and do not restate prior responses.',
|
|
78
90
|
'',
|
|
79
91
|
'<task>',
|
|
80
92
|
'kind: subagent',
|
|
@@ -84,5 +96,7 @@ export function notificationText(fields: NotificationFields): string {
|
|
|
84
96
|
`tool_call_id: ${fields.toolCallId}`,
|
|
85
97
|
`detail: ${fields.detail}`,
|
|
86
98
|
'</task>',
|
|
99
|
+
'</system_notification>',
|
|
100
|
+
"Perform any necessary follow-up actions in response to the subagent completion above. If no follow-up work is needed, no further action is required. If you mention an agent or subagent in your response, link it with the `[Name](id)` Don't use generic label such as `[agent]`, `[worker]`, or `[subagent]`. Don't repeat the same confirmation every time.",
|
|
87
101
|
].join('\n');
|
|
88
102
|
}
|
package/src/task/records.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, w
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { parseSessionEntries, SessionManager } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { acquireLease, alive } from '../process-lock.ts';
|
|
5
6
|
import type { ModelSpec } from './models.ts';
|
|
6
7
|
import type { Worktree } from './worktree.ts';
|
|
7
8
|
|
|
@@ -142,15 +143,6 @@ export function recordIds(dir: string): string[] {
|
|
|
142
143
|
.map((name) => name.slice(0, -'.json'.length));
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
function alive(pid: number): boolean {
|
|
146
|
-
try {
|
|
147
|
-
process.kill(pid, 0);
|
|
148
|
-
return true;
|
|
149
|
-
} catch (error) {
|
|
150
|
-
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
146
|
const isPid = (value: number) => Number.isInteger(value) && value > 0;
|
|
155
147
|
|
|
156
148
|
/** The live pid the agent's run lock names, or undefined when no live process holds it. */
|
|
@@ -198,33 +190,46 @@ export interface RunLock {
|
|
|
198
190
|
/**
|
|
199
191
|
* Pi appends to a session file without a lock, so two processes on one agent ID would interleave its transcript.
|
|
200
192
|
* The lock file holds the pid of the process writing the transcript. A lock without a readable pid counts as held.
|
|
201
|
-
* A
|
|
202
|
-
* processes taking over the same stale lock in the narrow window between that re-read and the removal can both succeed.
|
|
193
|
+
* A token-directory lease serializes acquisition and stale recovery without changing the PID file read by stop/status.
|
|
203
194
|
*/
|
|
204
195
|
export function acquireRunLock(dir: string, agentId: string): RunLock {
|
|
205
196
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
206
197
|
const path = join(dir, `${agentId}.lock`);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
198
|
+
const lease = acquireLease(`${path}.owner`);
|
|
199
|
+
if (lease === undefined) throw stillRunning(agentId, runningPid(dir, agentId), path);
|
|
200
|
+
let acquired = false;
|
|
201
|
+
try {
|
|
202
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
203
|
+
if (createExclusive(path)) {
|
|
204
|
+
acquired = true;
|
|
205
|
+
let owner = process.pid;
|
|
206
|
+
return {
|
|
207
|
+
holdFor: (pid) => {
|
|
208
|
+
writeAtomically(path, String(pid));
|
|
209
|
+
owner = pid;
|
|
210
|
+
},
|
|
211
|
+
release: () => {
|
|
212
|
+
for (const release of [
|
|
213
|
+
() => {
|
|
214
|
+
if (holderOf(path) === owner) rmSync(path, { force: true });
|
|
215
|
+
},
|
|
216
|
+
lease.release,
|
|
217
|
+
]) {
|
|
218
|
+
try {
|
|
219
|
+
release();
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (!['EACCES', 'EPERM', 'EROFS', 'EBUSY', 'EIO'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const holder = holderOf(path);
|
|
228
|
+
if (holder !== undefined && (!isPid(holder) || alive(holder))) throw stillRunning(agentId, holder, path);
|
|
229
|
+
if (holder !== undefined && holderOf(path) === holder) rmSync(path, { force: true });
|
|
224
230
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
231
|
+
throw stillRunning(agentId, holderOf(path), path);
|
|
232
|
+
} finally {
|
|
233
|
+
if (!acquired) lease.release();
|
|
228
234
|
}
|
|
229
|
-
throw stillRunning(agentId, holderOf(path), path);
|
|
230
235
|
}
|
package/src/task/registry.ts
CHANGED
|
@@ -15,6 +15,7 @@ interface Running {
|
|
|
15
15
|
|
|
16
16
|
export interface BackgroundNotice {
|
|
17
17
|
title: string;
|
|
18
|
+
description: string;
|
|
18
19
|
toolCallId: string;
|
|
19
20
|
mode: ExtensionContext['mode'];
|
|
20
21
|
detailFor: (outcome: ChildOutcome) => Pick<NotificationFields, 'status' | 'detail' | 'usage'>;
|
|
@@ -42,7 +43,7 @@ export function createSubagentRegistry(pi: ExtensionAPI) {
|
|
|
42
43
|
release();
|
|
43
44
|
// Shutdown killed this child; the retired runtime's pi must not be touched.
|
|
44
45
|
if (retired || notice === undefined) return;
|
|
45
|
-
deliver({ ...notice.detailFor(outcome), agentId, title: notice.title, toolCallId: notice.toolCallId }, notice.mode);
|
|
46
|
+
deliver({ ...notice.detailFor(outcome), agentId, title: notice.title, description: notice.description, toolCallId: notice.toolCallId }, notice.mode);
|
|
46
47
|
});
|
|
47
48
|
running.set(agentId, { settled, handle, readonly, background: notice !== undefined });
|
|
48
49
|
};
|
package/src/task/runner.ts
CHANGED
|
@@ -31,10 +31,13 @@ export interface ChildArgsOptions {
|
|
|
31
31
|
restoreTools?: readonly string[];
|
|
32
32
|
/** The parent's project trust, so the child loads the same project resources (`ctx.isProjectTrusted()`). */
|
|
33
33
|
trusted?: boolean;
|
|
34
|
+
/** Tools the agent type adds to Pi's default loadout (explore's search tools). */
|
|
35
|
+
addTools?: readonly string[];
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/** Argv for one `pi --mode rpc` subagent process. Its prompts travel as RPC commands on stdin, never in argv. */
|
|
37
39
|
export function childArgs(options: ChildArgsOptions): string[] {
|
|
40
|
+
const extraTools = [...new Set([...(options.restoreTools ?? []), ...(options.addTools ?? [])])];
|
|
38
41
|
const model = options.model === undefined ? [] : ['--model', `${options.model.provider}/${options.model.id}`, ...(options.model.thinking === undefined ? [] : ['--thinking', options.model.thinking])];
|
|
39
42
|
return [
|
|
40
43
|
'--mode',
|
|
@@ -48,7 +51,7 @@ export function childArgs(options: ChildArgsOptions): string[] {
|
|
|
48
51
|
...(options.trusted === undefined ? [] : [options.trusted ? '--approve' : '--no-approve']),
|
|
49
52
|
...model,
|
|
50
53
|
...(options.readonly === true ? ['--tools', READONLY_TOOLS, '--no-mcp'] : []),
|
|
51
|
-
...(options.readonly !== true &&
|
|
54
|
+
...(options.readonly !== true && extraTools.length > 0 ? ['--tools', extraTools.map((name) => `+${name}`).join(',')] : []),
|
|
52
55
|
];
|
|
53
56
|
}
|
|
54
57
|
|
package/src/task/stop.ts
CHANGED
|
@@ -65,7 +65,8 @@ async function stopElsewhere(agentId: string, lockPath: string, pid: number): Pr
|
|
|
65
65
|
export interface StopAgentOptions {
|
|
66
66
|
/** A child this process runs, if any. */
|
|
67
67
|
running: (agentId: string) => { handle: ChildHandle } | undefined;
|
|
68
|
-
|
|
68
|
+
/** An agent that cannot launch subagents has none to stop. */
|
|
69
|
+
canSpawn: boolean;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
const DESCRIPTION =
|
|
@@ -79,8 +80,7 @@ export function registerStopAgent(pi: ExtensionAPI, options: StopAgentOptions):
|
|
|
79
80
|
promptSnippet: 'Stop a running subagent by agent ID',
|
|
80
81
|
parameters: Type.Object({ agent_id: Type.String({ description: 'Agent ID from an earlier Task call in this workspace.' }) }),
|
|
81
82
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
82
|
-
|
|
83
|
-
defaultActive: options.depth < 2,
|
|
83
|
+
defaultActive: options.canSpawn,
|
|
84
84
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
85
85
|
const agentId = validateAgentId(params.agent_id);
|
|
86
86
|
const owned = options.running(agentId);
|
package/src/task/tool.ts
CHANGED
|
@@ -5,12 +5,12 @@ import { isAbsolute, join, relative } from 'node:path';
|
|
|
5
5
|
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
|
|
6
6
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
7
7
|
import { type Static, Type } from 'typebox';
|
|
8
|
-
import { childIdentity, childVariables } from '../child-env.ts';
|
|
8
|
+
import { BUILD_PLAN_ENV, childIdentity, childVariables } from '../child-env.ts';
|
|
9
9
|
import { rootStoreDir, subagentDir, workspaceStateDir, worktreesDir } from '../paths.ts';
|
|
10
10
|
import { piCommand } from '../pi-command.ts';
|
|
11
11
|
import { type AgentDefinition, findAgent } from './agents.ts';
|
|
12
12
|
import { renderTaskCard, renderTaskNotification } from './card.ts';
|
|
13
|
-
import { backgroundText, bounded, foregroundText, steerText, worktreeLine, worktreePreface } from './format.ts';
|
|
13
|
+
import { backgroundText, bounded, foregroundText, notificationTitle, steerText, worktreeLine, worktreePreface } from './format.ts';
|
|
14
14
|
import { type AvailableModel, type ModelSpec, resolveSubagentModel } from './models.ts';
|
|
15
15
|
import { createProgress } from './progress.ts';
|
|
16
16
|
import { type AgentRecord, acquireRunLock, forkTranscript, type RunLock, readonlyLaunchRestores, readRecord, transcriptFor, writeRecord } from './records.ts';
|
|
@@ -19,16 +19,17 @@ import { type ChildHandle, type ChildInput, type ChildOutcome, childArgs, resolv
|
|
|
19
19
|
import { registerStopAgent } from './stop.ts';
|
|
20
20
|
import { addWorktree, planWorktree, type Worktree } from './worktree.ts';
|
|
21
21
|
|
|
22
|
-
const TITLE_LIMIT = 200;
|
|
23
|
-
|
|
24
22
|
export const TaskParams = Type.Object({
|
|
25
|
-
description: Type.String({
|
|
26
|
-
|
|
23
|
+
description: Type.String({
|
|
24
|
+
description:
|
|
25
|
+
"A short, user-friendly title for the subagent, shown in the UI as the subagent's name. Make it concrete and distinct from recent titles. When you resume an agent for a separate task, give it an updated title; keep the title when it continues the same work.",
|
|
26
|
+
}),
|
|
27
|
+
prompt: Type.String({ description: 'The task for the agent to perform.' }),
|
|
27
28
|
subagent_type: Type.Optional(Type.String({ description: 'Subagent type from the pstack_subagents section. Defaults to generalPurpose.' })),
|
|
28
29
|
model: Type.Optional(
|
|
29
30
|
Type.String({
|
|
30
31
|
description:
|
|
31
|
-
'Optional model for this agent, from the pstack_subagents section: inherit (default), a Pi model such as claude-opus-5-5:xhigh or provider/model:high, or claude-opus-5-5[effort=high]. An invalid value fails with the allowed list; pick a listed value or omit it. Ignored with resume; with fork, defaults to the source agent model.',
|
|
32
|
+
'Optional model for this agent, from the pstack_subagents section: inherit (default), a Pi model such as claude-opus-5-5:xhigh or provider/model:high, or claude-opus-5-5[effort=high]. An invalid value fails with the allowed list; pick a listed value or omit it, and never retry a rejected value unchanged. Omitted, the agent runs on the parent model. Use inherit unless the user or a skill asks for another listed model. Ignored with resume, which keeps the earlier model; with fork, defaults to the source agent model.',
|
|
32
33
|
}),
|
|
33
34
|
),
|
|
34
35
|
resume: Type.Optional(
|
|
@@ -110,6 +111,7 @@ export interface Launch {
|
|
|
110
111
|
attachments: string[];
|
|
111
112
|
notes: string[];
|
|
112
113
|
description: string;
|
|
114
|
+
buildPlan?: string;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
const runsReadonly = (plan: Launch) => plan.readonly || plan.planMode;
|
|
@@ -268,10 +270,22 @@ export function planFork(params: TaskInput, ctx: ExtensionContext, agents: reado
|
|
|
268
270
|
|
|
269
271
|
function launchChild(plan: Launch, input: ChildInput, signal: AbortSignal | undefined, onEvent?: (event: { type: string } & Record<string, unknown>) => void) {
|
|
270
272
|
const readonly = runsReadonly(plan);
|
|
271
|
-
const env = {
|
|
273
|
+
const env = {
|
|
274
|
+
...process.env,
|
|
275
|
+
...childVariables(plan.agent.type, plan.depth, readonly, plan.rootStore, workspaceStateDir(plan.workspace), plan.rootWorkspace),
|
|
276
|
+
...(plan.buildPlan === undefined ? {} : { [BUILD_PLAN_ENV]: plan.buildPlan }),
|
|
277
|
+
};
|
|
272
278
|
const transcript = plan.kind === 'fresh' || readonly ? undefined : transcriptFor(subagentDir(plan.workspace), plan.agentId);
|
|
273
279
|
const restoreTools = transcript === undefined ? [] : readonlyLaunchRestores(transcript);
|
|
274
|
-
const args = childArgs({
|
|
280
|
+
const args = childArgs({
|
|
281
|
+
sessionDir: subagentDir(plan.workspace),
|
|
282
|
+
agentId: plan.agentId,
|
|
283
|
+
readonly,
|
|
284
|
+
restoreTools,
|
|
285
|
+
trusted: plan.trusted,
|
|
286
|
+
...(plan.agent.tools === undefined ? {} : { addTools: plan.agent.tools }),
|
|
287
|
+
...(plan.model === undefined ? {} : { model: plan.model }),
|
|
288
|
+
});
|
|
275
289
|
return startChild({ command: piCommand(), args, prompt: input.text, images: input.images, cwd: plan.executionCwd, env, ...(signal === undefined ? {} : { signal }), ...(onEvent === undefined ? {} : { onEvent }) });
|
|
276
290
|
}
|
|
277
291
|
|
|
@@ -359,11 +373,11 @@ function foregroundResult(plan: Launch, outcome: ChildOutcome) {
|
|
|
359
373
|
return { content: [{ type: 'text' as const, text }], details: details(plan, false, 'error'), usage: outcome.usage, isError: true };
|
|
360
374
|
}
|
|
361
375
|
|
|
362
|
-
/** Cursor
|
|
363
|
-
export function backgroundNotice(plan: Launch, description: string, ctx: ExtensionContext, toolCallId: string): BackgroundNotice {
|
|
364
|
-
const flat = description.replace(/\s+/g, ' ').trim();
|
|
376
|
+
/** Cursor titles the completion by the prompt's first line and names the subagent in the UI by its description. */
|
|
377
|
+
export function backgroundNotice(plan: Launch, prompt: string, description: string, ctx: ExtensionContext, toolCallId: string): BackgroundNotice {
|
|
365
378
|
return {
|
|
366
|
-
title:
|
|
379
|
+
title: notificationTitle(prompt),
|
|
380
|
+
description: description.replace(/\s+/g, ' ').trim(),
|
|
367
381
|
toolCallId,
|
|
368
382
|
mode: ctx.mode,
|
|
369
383
|
detailFor: (outcome) => {
|
|
@@ -449,8 +463,21 @@ async function steerRunning(params: TaskInput, running: { handle: ChildHandle; r
|
|
|
449
463
|
return { content: [{ type: 'text' as const, text: steerText(agentId, notes) }], details: { agentId, status: 'steered', pid: running.handle.pid ?? null, description: params.description } };
|
|
450
464
|
}
|
|
451
465
|
|
|
452
|
-
const TASK_DESCRIPTION =
|
|
453
|
-
'Launch a
|
|
466
|
+
const TASK_DESCRIPTION = [
|
|
467
|
+
'Launch a new agent to handle a complex, multi-step task on its own. Each agent is a separate Pi process with its own context, and each subagent_type has its own instructions and tools. Pick the type with subagent_type; the types, and the model values the model parameter takes, are listed in the pstack_subagents section of the system prompt.',
|
|
468
|
+
'',
|
|
469
|
+
'Do not use Task for a narrow or specific question. To find where one class is defined, read a known file, or search two or three files, use read, grep, or find directly. When separate areas can be explored in parallel, launch several agents at once. Do not hand the whole request to one agent and relay its answer.',
|
|
470
|
+
'',
|
|
471
|
+
'Usage notes:',
|
|
472
|
+
'- Give every call a short (3-5 word) description.',
|
|
473
|
+
'- To run agents in parallel, put several Task calls in one message. Do this whenever the work splits, and always when the user asks for agents in parallel.',
|
|
474
|
+
'- The agent returns one final message. Say exactly what it should report back.',
|
|
475
|
+
"- The agent cannot see the user's messages or your earlier steps, so the prompt must carry every detail and all the context it needs.",
|
|
476
|
+
'- Trust the agent output in general.',
|
|
477
|
+
'- When a subagent type says to use it proactively, use it without being asked.',
|
|
478
|
+
'- resume continues an earlier agent with its context preserved. Without resume, each call starts fresh.',
|
|
479
|
+
'- A background agent notifies you automatically when it finishes, after you end your turn. Do not poll it, sleep, or check on its progress. Work on something else or end your turn, and do not mention this to the user.',
|
|
480
|
+
].join('\n');
|
|
454
481
|
|
|
455
482
|
// Interactive Pi redraws the chat between a replaced session's shutdown and the next session's start, which erases a
|
|
456
483
|
// warning sent at shutdown. The next runtime shows it instead, so it rides on globalThis across the reload.
|
|
@@ -466,6 +493,8 @@ function dropTask(pi: ExtensionAPI): void {
|
|
|
466
493
|
export interface TaskToolOptions {
|
|
467
494
|
agents: readonly AgentDefinition[];
|
|
468
495
|
depth: number;
|
|
496
|
+
/** False in a subagent of a subagent, and in a type that never launches subagents (explore). */
|
|
497
|
+
canSpawn: boolean;
|
|
469
498
|
/** Whether plan mode is on: its Task launches run readonly and here, and record nothing of that. */
|
|
470
499
|
planMode?: () => boolean;
|
|
471
500
|
}
|
|
@@ -473,8 +502,12 @@ export interface TaskToolOptions {
|
|
|
473
502
|
export interface StartBackgroundOptions {
|
|
474
503
|
/** A Pi model spec, used by subscription timers so a live and a detached fire run the same model. */
|
|
475
504
|
model?: string;
|
|
505
|
+
/** cloud runs the agent in its own git worktree, as plan mode's "build in cloud" does. */
|
|
506
|
+
environment?: 'cloud';
|
|
476
507
|
/** The owner's project-trust decision, used when this subscription fire starts away from its chat. */
|
|
477
508
|
trusted?: boolean;
|
|
509
|
+
/** Imports the reviewed plan's todos into the worker before its first model request. */
|
|
510
|
+
buildPlan?: string;
|
|
478
511
|
}
|
|
479
512
|
|
|
480
513
|
export interface TaskHandle {
|
|
@@ -498,7 +531,7 @@ export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHa
|
|
|
498
531
|
});
|
|
499
532
|
pi.on('session_start', (_event, ctx) => {
|
|
500
533
|
sessionCtx = ctx;
|
|
501
|
-
if (options.
|
|
534
|
+
if (!options.canSpawn) dropTask(pi);
|
|
502
535
|
const message = carrier[STOPPED_NOTICE];
|
|
503
536
|
delete carrier[STOPPED_NOTICE];
|
|
504
537
|
if (message !== undefined && ctx.hasUI) ctx.ui.notify(message, 'warning');
|
|
@@ -510,8 +543,8 @@ export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHa
|
|
|
510
543
|
description: TASK_DESCRIPTION,
|
|
511
544
|
promptSnippet: 'Launch subagents (foreground, background with completion notifications, resume by agent ID)',
|
|
512
545
|
parameters: TaskParams,
|
|
513
|
-
// Nesting stops at the subagent of a subagent, so that depth never sees the tool.
|
|
514
|
-
defaultActive: options.
|
|
546
|
+
// Nesting stops at the subagent of a subagent, so that depth never sees the tool, and so does explore.
|
|
547
|
+
defaultActive: options.canSpawn,
|
|
515
548
|
renderResult: (result, renderOptions) => renderTaskCard(result, renderOptions),
|
|
516
549
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
517
550
|
taskPreflight(params, options.depth);
|
|
@@ -527,7 +560,7 @@ export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHa
|
|
|
527
560
|
const handle = start(plan, input, background ? undefined : signal, lock, progress?.onEvent);
|
|
528
561
|
pid = handle.pid;
|
|
529
562
|
if (background) {
|
|
530
|
-
registry.track(plan.agentId, handle, lock.release, runsReadonly(plan), backgroundNotice(plan, plan.description, ctx, toolCallId));
|
|
563
|
+
registry.track(plan.agentId, handle, lock.release, runsReadonly(plan), backgroundNotice(plan, params.prompt, plan.description, ctx, toolCallId));
|
|
531
564
|
return { content: [{ type: 'text', text: backgroundText(plan.agentId, transcriptOf(plan), plan.notes) }], details: details(plan, true, 'running', handle.pid) };
|
|
532
565
|
}
|
|
533
566
|
registry.track(plan.agentId, handle, lock.release, runsReadonly(plan));
|
|
@@ -541,18 +574,25 @@ export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHa
|
|
|
541
574
|
},
|
|
542
575
|
});
|
|
543
576
|
|
|
544
|
-
registerStopAgent(pi, { running: registry.get,
|
|
577
|
+
registerStopAgent(pi, { running: registry.get, canSpawn: options.canSpawn });
|
|
545
578
|
|
|
546
579
|
async function startBackground(description: string, prompt: string, startOptions?: StartBackgroundOptions): Promise<string> {
|
|
547
580
|
const ctx = sessionCtx;
|
|
548
581
|
if (ctx === undefined) throw new Error('No session is active, so no subagent can start.');
|
|
549
|
-
const params: TaskInput = {
|
|
582
|
+
const params: TaskInput = {
|
|
583
|
+
description,
|
|
584
|
+
prompt,
|
|
585
|
+
run_in_background: true,
|
|
586
|
+
...(startOptions?.model === undefined ? {} : { model: startOptions.model }),
|
|
587
|
+
...(startOptions?.environment === undefined ? {} : { environment: startOptions.environment }),
|
|
588
|
+
};
|
|
550
589
|
taskPreflight(params, options.depth);
|
|
551
590
|
const { plan, lock } = prepare(params, ctx, options, registry.isRunning);
|
|
552
591
|
if (startOptions?.trusted !== undefined) plan.trusted = startOptions.trusted;
|
|
592
|
+
if (startOptions?.buildPlan !== undefined) plan.buildPlan = startOptions.buildPlan;
|
|
553
593
|
await createWorktree(plan, lock, undefined);
|
|
554
594
|
const handle = start(plan, { text: prompt.trim(), images: [] }, undefined, lock);
|
|
555
|
-
registry.track(plan.agentId, handle, lock.release, runsReadonly(plan), backgroundNotice(plan, description, ctx, `pstack-${plan.agentId}`));
|
|
595
|
+
registry.track(plan.agentId, handle, lock.release, runsReadonly(plan), backgroundNotice(plan, prompt, description, ctx, `pstack-${plan.agentId}`));
|
|
556
596
|
return plan.agentId;
|
|
557
597
|
}
|
|
558
598
|
|