@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.
@@ -1,9 +1,10 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
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,
@@ -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: 'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. Use when a search or task needs several steps of its own.',
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 head = `${title.trim() === '' ? 'subagent' : title.trim()} · ${status} · agent ${agentId}`;
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
 
@@ -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} (pass it as \`resume\` to send a follow-up after it completes)`;
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
- 'Subagent is running in the background. Its final response arrives automatically after your turn ends, so do not wait or poll for it. End your turn or work on something else.',
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
- 'The following task has finished. If you already handled it, ignore this notification and do not restate earlier responses.',
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
  }
@@ -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 dead holder's lock is removed only if it still names that holder, then created again exclusively; two
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
- for (let attempt = 0; attempt < 2; attempt += 1) {
208
- if (createExclusive(path)) {
209
- let owner = process.pid;
210
- return {
211
- holdFor: (pid) => {
212
- writeAtomically(path, String(pid));
213
- owner = pid;
214
- },
215
- // Best effort: a lock left behind names a pid that will be dead, so the next acquire takes it over.
216
- release: () => {
217
- try {
218
- if (holderOf(path) === owner) rmSync(path, { force: true });
219
- } catch (error) {
220
- if (!['EACCES', 'EPERM', 'EROFS', 'EBUSY', 'EIO'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
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
- const holder = holderOf(path);
226
- if (holder !== undefined && (!isPid(holder) || alive(holder))) throw stillRunning(agentId, holder, path);
227
- if (holder !== undefined && holderOf(path) === holder) rmSync(path, { force: true });
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
  }
@@ -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
  };
@@ -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 && (options.restoreTools ?? []).length > 0 ? ['--tools', (options.restoreTools ?? []).map((name) => `+${name}`).join(',')] : []),
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
- depth: number;
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
- // Nesting stops at the subagent of a subagent, which has no subagents to stop.
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({ description: 'A short (3-5 word) title for the subagent, shown in the UI.' }),
26
- prompt: Type.String({ description: 'The task for the agent to perform. The subagent sees nothing of this conversation, so include every detail and say what to report back.' }),
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 = { ...process.env, ...childVariables(plan.agent.type, plan.depth, readonly, plan.rootStore, workspaceStateDir(plan.workspace), plan.rootWorkspace) };
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({ sessionDir: subagentDir(plan.workspace), agentId: plan.agentId, readonly, restoreTools, trusted: plan.trusted, ...(plan.model === undefined ? {} : { model: plan.model }) });
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 shows the Task description as the subagent's name, so the completion notice carries it too. */
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: flat.length > TITLE_LIMIT ? `${flat.slice(0, TITLE_LIMIT)}…` : flat,
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 subagent: a separate Pi process with its own context that works autonomously and returns one final message. It sees nothing of this conversation, so give it a self-contained prompt and say what to report back. Put several Task calls in one message to run them in parallel. Subagent types and model values are listed in the pstack_subagents section of the system prompt.';
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.depth >= 2) dropTask(pi);
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.depth < 2,
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, depth: options.depth });
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 = { description, prompt, run_in_background: true, ...(startOptions?.model === undefined ? {} : { model: startOptions.model }) };
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