pi-squad 0.16.3 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,8 +52,10 @@ Squad agents—including QA/reviewer agents—produce candidate work and evidenc
52
52
  - Persisted status becomes `review`, never directly `done`.
53
53
  - A persistent `<squad_review_required>` system reminder tells main Pi to re-read the original conversation contract, inspect the actual diff/source, rerun verification independently, and run integration/E2E where applicable.
54
54
  - Main Pi must call `squad_review` with requirement-by-requirement contract checks, diff review, actual command/result evidence, integration/E2E evidence, and issues.
55
- - Only `pass` or `pass_with_issues` changes the squad to `done`; `fail` leaves it review-blocked for fixes and re-review.
56
- - Pending review survives Pi restarts and is restored on the next session.
55
+ - Only `pass` or `pass_with_issues` changes the squad to `done`; `fail` leaves it review-blocked and cannot be overwritten by another verdict.
56
+ - Failed review is reworked in the **same authoritative squad**: use `squad_modify` with that `squadId` and `add_task` or `resume_task` (or `resume` when interrupted work exists). These operations reconstruct the scheduler after restart. `/squad resume <squad-id>` provides the same resume path.
57
+ - When rework begins, the failed attempt moves to `reviewHistory`, the squad returns to `running`, and its evidence remains auditable. After every rework task settles, a fresh pending review becomes the active gate and `squad_review` is required again.
58
+ - Pending and failed review gates survive Pi restarts and are restored on the next session. A separate squad never links to, remediates, or accepts the failed gate.
57
59
 
58
60
  The completion report is explicitly labeled **untrusted and not yet accepted**. Main Pi must never merely relay it or ask whether verification should be run.
59
61
 
@@ -139,7 +141,7 @@ Bundled agent definitions are copied to `~/.pi/squad/agents/` on first run. Edit
139
141
 
140
142
  **@mention routing**: Agents write `@frontend what token format?` in their output. The router delivers it in real-time via RPC `steer()`.
141
143
 
142
- **Main-session request/reply**: `squad_message` resolves an agent name to its live task, durably records the full request, and writes the documented JSONL RPC command `{"type":"steer","message":"..."}` to the child. By default (`expectReply: true`), the next substantive agent response is durably marked as the reply, pushed into the main Pi session, and wakes it via follow-up delivery; ordinary later activity remains panel-only. The pending reply marker survives scheduler reconstruction. Use `expectReply: false` for fire-and-forget steering. pi-squad waits for Pi's final `agent_settled` event before closing the child; low-level `agent_end` never kills queued continuations.
144
+ **Main-session request/reply**: `squad_message` addresses one exact task ID. An agent name is accepted only as shorthand for exactly one currently live task, so two tasks assigned to the same role never share or steal messages. Requests are written first to the task's durable mailbox, then delivered with correlated Pi RPC. A completed task is reopened and resumed with `pi --session <its-original-session-file>`; only a task without a session binding receives a new session. This also works after scheduler/main-process reconstruction. By default (`expectReply: true`), the next substantive response is durably marked as the reply, pushed into main Pi, and wakes it; use `expectReply: false` for fire-and-forget steering. pi-squad waits for Pi's final `agent_settled` event before marking the task done or closing the child; low-level `agent_end` never kills queued continuations.
143
145
 
144
146
  ### Smart Planner
145
147
 
@@ -171,7 +173,7 @@ Shows live squad progress. Truncated to terminal width — no wrapping, determin
171
173
 
172
174
  ### Panel (Ctrl+Q)
173
175
 
174
- Full overlay with task list, live activity preview, and scrollable message view.
176
+ Full overlay with task list, live activity preview, and scrollable message view. The view opens at the live tail but can scroll back through the complete durable history, including acknowledged and older orchestrator/human messages; multiline bodies are not shortened. Main-Pi requests are labeled `ORCHESTRATOR` and panel/user input is labeled `YOU`.
175
177
 
176
178
  | Key | Action |
177
179
  |---|---|
@@ -188,10 +190,11 @@ Full overlay with task list, live activity preview, and scrollable message view.
188
190
  | Command | Description |
189
191
  |---|---|
190
192
  | `/squad select` | Pick a squad to view |
193
+ | `/squad resume [squad-id]` | Reconstruct and resume an exact paused/failed/failed-review squad |
191
194
  | `/squad list` | List project squads |
192
195
  | `/squad all` | List all squads |
193
196
  | `/squad agents` | Manage agent definitions |
194
- | `/squad msg [agent] text` | Send message to agent |
197
+ | `/squad msg [task-id\|running-agent] text` | Message an exact task, or use an agent name only when it has one live task |
195
198
  | `/squad widget` | Toggle widget |
196
199
  | `/squad panel` | Toggle panel |
197
200
  | `/squad cancel` | Cancel running squad |
@@ -205,8 +208,8 @@ Full overlay with task list, live activity preview, and scrollable message view.
205
208
  |---|---|
206
209
  | `squad` | Start a squad with goal + optional tasks/config |
207
210
  | `squad_status` | Check progress, costs, task states |
208
- | `squad_message` | Send message to a running agent |
209
- | `squad_modify` | Add/cancel/complete/pause/resume tasks or squads |
211
+ | `squad_message` | Durably message an exact task; completed tasks reopen on their original session |
212
+ | `squad_modify` | Add/cancel/complete/pause/resume tasks or squads; accepts `squadId` for exact same-squad failed-review rework |
210
213
 
211
214
  The main agent sees available agents in its system prompt and squad state when a squad is active.
212
215
 
@@ -245,7 +248,8 @@ squad({
245
248
  - **Cost**: the agent pays the entire conversation history as input tokens on every turn — use sparingly
246
249
  - **Context-window guard**: the fork is skipped automatically when the estimated session size exceeds 50% of the agent model's context window (agents on smaller-context models silently degrade to standard squad context; the skip is recorded in the task's message log and `debug.log`)
247
250
  - Requires the main session to have a session file (skipped under `--no-session`)
248
- - Forked child sessions are stored under `~/.pi/squad/<squad-id>/sessions/`, not in your project's session list
251
+ - Each child session is stored under its task directory at `~/.pi/squad/<squad-id>/<task-id>/session/`, not in your project's session list
252
+ - Once created, that task-to-session binding is immutable; later resumes pass the original file through `--session`
249
253
  - Prefer restating the 3-5 key decisions in the task description — reach for `inheritContext` only when that's impractical
250
254
 
251
255
  ### Custom Agents
@@ -298,14 +302,17 @@ Configure with `/squad advisor` (on/off, model, max calls per task, reasoning ef
298
302
 
299
303
  ### Meaningful Work Check
300
304
 
301
- Agents must complete at least 1 LLM turn AND make at least 1 tool call to be marked as "done". Agents that exit cleanly but did no work (rate limit, API error, model not found) are retried once, then failed never silently marked successful.
305
+ Agents must complete at least one LLM turn and produce either a tool call or a substantive assistant artifact before they can be marked `done`. This permits legitimate report-only planning/review work while rejecting empty exits. A child that exits before final `agent_settled`, or settles without meaningful work, is resumed once on the same task session and then failed if the retry is exhausted.
302
306
 
303
307
  ### Session Resilience
304
308
 
305
- - In-progress tasks are **suspended** on session crash, **resumed** on next startup
306
- - Failure is never terminal: `resume` recovers failed squads (failed tasks reset to pending), `complete_task` marks recovered work done and schedules dependents, and a 60s reconcile loop re-derives scheduling from persisted state so out-of-band store edits can't strand ready tasks
307
- - Squads are fully reconstructable from JSON files on disk
308
- - Spawn failures are retried once with a 2-second delay
309
+ - Every task owns one durable Pi session. New tasks create it under their task directory; stale, suspended, failed, or explicitly reopened tasks resume that same session rather than starting over. A pre-prompt retry may refresh Pi's provisional session ID only while the bound file is the same and its JSONL has not materialized; afterward both file and ID are immutable.
310
+ - Legacy tasks without a session binding migrate on first reopen by creating a task-owned session and seeding its first prompt with the complete persisted multiline message history and prior task output—without truncation.
311
+ - In-progress tasks are **suspended** on orderly shutdown; ordinary orphaned work is paused for explicit resume. If a scheduler itself is reconstructed, stale `in_progress` state is resumed by reconciliation. Independently, extension startup always reconstructs any project squad with pending mailbox entries—including `review` or already accepted `done` squads—and resumes delivery without another user action.
312
+ - A durable message to a completed task clears its prior completion/review state, reopens the squad, keeps the task `in_progress` while its agent is live, and requires a fresh orchestrator review after the final `agent_settled`. Every transitive descendant is re-blocked and reruns in dependency order, so results derived from the reopened dependency cannot remain falsely complete.
313
+ - Failure is never terminal: `resume` recovers failed squads (failed tasks reset to pending), `complete_task` marks recovered work done and schedules dependents, and a 60s reconcile loop re-derives scheduling from persisted state so out-of-band store edits can't strand ready tasks.
314
+ - Mail is acknowledged only after Pi accepts the correlated RPC command. Pending and acknowledged entries remain task-addressed on disk, survive process restart, and remain visible in history. Queue and acknowledgement read/modify/write operations are serialized across processes so concurrent mutations cannot overwrite messages or delivery state.
315
+ - Squads are fully reconstructable from JSON files on disk. Unexpected child exits are retried once after 2 seconds on the same bound task session.
309
316
  - All errors logged to `~/.pi/squad/debug.log` (always for errors, `PI_SQUAD_DEBUG=1` for verbose)
310
317
 
311
318
  ### Health Monitoring
@@ -331,8 +338,10 @@ All state in `~/.pi/squad/`. No database, no daemon. Writes are atomic. JSONL re
331
338
  ├── squad.json — goal, status, config, cwd
332
339
  ├── context.json — live state snapshot
333
340
  └── {task-id}/
334
- ├── task.json — status, output, usage, retryOf, qaFeedback
335
- └── messages.jsonl — conversation log
341
+ ├── task.json — status, output, usage, immutable session binding, retry metadata
342
+ ├── messages.jsonl append-only conversation history
343
+ ├── mailbox.json — task-addressed inbound mail, including delivery acknowledgements
344
+ └── session/ — this task's durable Pi session JSONL
336
345
  ```
337
346
 
338
347
  ## Architecture
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/agent-pool.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  */
7
7
 
8
8
  import { spawn, type ChildProcess } from "node:child_process";
9
+ import { randomUUID } from "node:crypto";
9
10
  import * as fs from "node:fs";
10
11
  import * as os from "node:os";
11
12
  import * as path from "node:path";
12
- import type { AgentDef, AgentActivity, Task, TaskMessage } from "./types.js";
13
+ import type { AgentDef, AgentActivity, TaskSession } from "./types.js";
13
14
  import { buildAgentSystemPrompt, type ProtocolBuildOptions } from "./protocol.js";
14
15
  import { debug, logError } from "./logger.js";
15
16
 
@@ -22,8 +23,8 @@ export interface AgentProcess {
22
23
  agentName: string;
23
24
  process: ChildProcess;
24
25
  activity: AgentActivity;
25
- /** Queued messages for this agent (received while stopped, consumed on spawn) */
26
- pendingMessages: TaskMessage[];
26
+ /** Durable Pi session owned by this task. */
27
+ session: TaskSession;
27
28
  /** Abort controller for cleanup */
28
29
  aborted: boolean;
29
30
  }
@@ -34,6 +35,7 @@ export type AgentEventType =
34
35
  | "tool_execution_end"
35
36
  | "turn_end"
36
37
  | "agent_end"
38
+ | "agent_settled"
37
39
  | "error";
38
40
 
39
41
  export interface AgentEvent {
@@ -77,7 +79,12 @@ function attachLineReader(
77
79
  export class AgentPool {
78
80
  private agents = new Map<string, AgentProcess>();
79
81
  private listeners: AgentEventListener[] = [];
80
- private messageQueues = new Map<string, TaskMessage[]>();
82
+ private responseWaiters = new Map<string, {
83
+ process: ChildProcess;
84
+ resolve: (event: any) => void;
85
+ reject: (error: Error) => void;
86
+ timer: ReturnType<typeof setTimeout>;
87
+ }>();
81
88
 
82
89
  /** Subscribe to agent events */
83
90
  onEvent(listener: AgentEventListener): () => void {
@@ -124,20 +131,6 @@ export class AgentPool {
124
131
  .map((a) => a.agentName);
125
132
  }
126
133
 
127
- /** Queue a message for an agent (delivered on next spawn or via steer if running) */
128
- queueMessage(agentName: string, message: TaskMessage): void {
129
- const queue = this.messageQueues.get(agentName) || [];
130
- queue.push(message);
131
- this.messageQueues.set(agentName, queue);
132
- }
133
-
134
- /** Consume queued messages for an agent */
135
- consumeQueue(agentName: string): TaskMessage[] {
136
- const queue = this.messageQueues.get(agentName) || [];
137
- this.messageQueues.delete(agentName);
138
- return queue;
139
- }
140
-
141
134
  /**
142
135
  * Spawn a pi process in RPC mode for a task.
143
136
  */
@@ -147,10 +140,14 @@ export class AgentPool {
147
140
  protocolOptions: ProtocolBuildOptions;
148
141
  cwd: string;
149
142
  skillPaths: string[];
150
- /** Fork the given session file so the agent inherits its conversation context */
143
+ /** Resume this task's already-bound durable Pi session. */
144
+ resumeSession?: TaskSession;
145
+ /** Create a new durable session here (new tasks only). */
146
+ sessionDir?: string;
147
+ /** Fork the given session file so a new task inherits main-session context. */
151
148
  forkSession?: { file: string; sessionDir: string };
152
149
  }): Promise<AgentProcess> {
153
- const { taskId, agentDef, protocolOptions, cwd, skillPaths, forkSession } = options;
150
+ const { taskId, agentDef, protocolOptions, cwd, skillPaths, resumeSession, sessionDir, forkSession } = options;
154
151
 
155
152
  // Kill existing process for this task if any
156
153
  if (this.agents.has(taskId)) {
@@ -164,7 +161,7 @@ export class AgentPool {
164
161
  fs.writeFileSync(promptFile, systemPrompt, "utf-8");
165
162
 
166
163
  // Build pi CLI args
167
- const args = buildPiArgs(agentDef, promptFile, skillPaths, forkSession);
164
+ const args = buildPiArgs(agentDef, promptFile, skillPaths, { resumeSession, sessionDir, forkSession });
168
165
 
169
166
  // Spawn pi process — set env var to prevent recursive squad extension loading
170
167
  const invocation = getPiInvocation(["--mode", "rpc", ...args]);
@@ -190,7 +187,8 @@ export class AgentPool {
190
187
  agentName: agentDef.name,
191
188
  process: proc,
192
189
  activity,
193
- pendingMessages: this.consumeQueue(agentDef.name),
190
+ // Replaced with the authoritative get_state values before spawn returns.
191
+ session: resumeSession ?? { file: "" },
194
192
  aborted: false,
195
193
  };
196
194
 
@@ -212,26 +210,31 @@ export class AgentPool {
212
210
  }
213
211
  });
214
212
 
215
- let agentEndEmitted = false;
213
+ let terminalEventEmitted = false;
216
214
  let stdoutLines = 0;
217
215
  proc.on("exit", (code, signal) => {
216
+ this.rejectResponseWaiters(proc, new Error(`Agent ${agentDef.name} exited before RPC response`));
218
217
  // Log diagnostic info for debugging spawn failures
219
218
  if (code !== 0 && code !== null) {
220
219
  logError("squad-pool", `${agentDef.name} exited: code=${code} signal=${signal} pid=${proc.pid} stdoutLines=${stdoutLines} stderr=${stderr || "(empty)"}`);
221
220
  }
222
- // Capture activity stats BEFORE deleting the agent
221
+ // Capture activity stats before cleanup. A delayed exit callback from an
222
+ // old child must never delete or mutate a replacement registered for the
223
+ // same task ID.
223
224
  const finalActivity = agentProc.activity;
224
- // Clean up agents map so getRunningAgents() doesn't count dead processes
225
- this.agents.delete(taskId);
226
- // Only emit if we haven't already emitted via RPC agent_end event
227
- if (!agentEndEmitted) {
228
- agentEndEmitted = true;
225
+ const isCurrentChild = this.agents.get(taskId) === agentProc;
226
+ if (isCurrentChild) this.agents.delete(taskId);
227
+ // Only the currently registered child may report an unexpected exit.
228
+ // Intentional shutdown must not reopen a suspended or cancelled task.
229
+ if (isCurrentChild && !terminalEventEmitted && !agentProc.aborted) {
230
+ terminalEventEmitted = true;
229
231
  this.emit({
230
232
  type: "agent_end",
231
233
  taskId,
232
234
  agentName: agentDef.name,
233
235
  data: {
234
236
  exitCode: code,
237
+ unexpectedExit: true,
235
238
  // Preserve complete diagnostics for task failure reports and recovery.
236
239
  stderr,
237
240
  turnCount: finalActivity.turnCount,
@@ -247,8 +250,8 @@ export class AgentPool {
247
250
  }, 500);
248
251
  });
249
252
 
250
- // Expose the guard so handleRpcEvent can set it
251
- (agentProc as any)._agentEndEmitted = () => { agentEndEmitted = true; };
253
+ // Expose the guard so handleRpcEvent can mark final settlement.
254
+ (agentProc as any)._terminalEventEmitted = () => { terminalEventEmitted = true; };
252
255
 
253
256
  // Wait for process to initialize — pi needs time to load extensions, models, etc.
254
257
  await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -259,25 +262,43 @@ export class AgentPool {
259
262
  );
260
263
  }
261
264
 
262
- // Send initial prompt
263
- const taskPrompt = `Your task: ${protocolOptions.task.title}\n\n${protocolOptions.task.description || ""}`;
264
- this.sendRpcCommand(proc, { type: "prompt", message: taskPrompt });
265
+ // Persisted session identity is authoritative. Capture it before any prompt
266
+ // is sent so a crash/retry can only resume this task's original context.
267
+ const state = await this.requestRpc(proc, { type: "get_state" });
268
+ const rawSessionFile = state?.data?.sessionFile;
269
+ if (!state?.success || typeof rawSessionFile !== "string" || rawSessionFile.length === 0) {
270
+ await this.kill(taskId);
271
+ throw new Error(`Agent ${agentDef.name} did not expose a durable session file`);
272
+ }
273
+ agentProc.session = {
274
+ file: path.isAbsolute(rawSessionFile) ? path.normalize(rawSessionFile) : path.resolve(cwd, rawSessionFile),
275
+ ...(typeof state.data?.sessionId === "string" && state.data.sessionId
276
+ ? { sessionId: state.data.sessionId }
277
+ : {}),
278
+ };
265
279
 
266
280
  return agentProc;
267
281
  }
268
282
 
283
+ /** Start a turn in an initialized task session. */
284
+ async prompt(taskId: string, message: string): Promise<boolean> {
285
+ const agent = this.agents.get(taskId);
286
+ if (!agent || agent.aborted || agent.process.exitCode !== null) return false;
287
+ return this.requestAccepted(agent.process, { type: "prompt", message });
288
+ }
289
+
269
290
  /** Inject a steering message into a running agent */
270
291
  async steer(taskId: string, message: string): Promise<boolean> {
271
292
  const agent = this.agents.get(taskId);
272
293
  if (!agent || agent.aborted || agent.process.exitCode !== null) return false;
273
- return this.sendRpcCommand(agent.process, { type: "steer", message });
294
+ return this.requestAccepted(agent.process, { type: "steer", message });
274
295
  }
275
296
 
276
297
  /** Queue a follow-up message for after the current turn */
277
298
  async followUp(taskId: string, message: string): Promise<boolean> {
278
299
  const agent = this.agents.get(taskId);
279
300
  if (!agent || agent.aborted || agent.process.exitCode !== null) return false;
280
- return this.sendRpcCommand(agent.process, { type: "follow_up", message });
301
+ return this.requestAccepted(agent.process, { type: "follow_up", message });
281
302
  }
282
303
 
283
304
  /** Abort the current operation */
@@ -348,7 +369,55 @@ export class AgentPool {
348
369
  }
349
370
  }
350
371
 
372
+ private async requestAccepted(proc: ChildProcess, command: Record<string, unknown>): Promise<boolean> {
373
+ try {
374
+ const response = await this.requestRpc(proc, command);
375
+ return response?.success === true;
376
+ } catch {
377
+ return false;
378
+ }
379
+ }
380
+
381
+ private requestRpc(proc: ChildProcess, command: Record<string, unknown>): Promise<any> {
382
+ const id = randomUUID();
383
+ return new Promise((resolve, reject) => {
384
+ const timer = setTimeout(() => {
385
+ this.responseWaiters.delete(id);
386
+ reject(new Error(`Timed out waiting for RPC ${String(command.type)}`));
387
+ }, 10_000);
388
+ timer.unref();
389
+ this.responseWaiters.set(id, { process: proc, resolve, reject, timer });
390
+ if (!this.sendRpcCommand(proc, { ...command, id })) {
391
+ clearTimeout(timer);
392
+ this.responseWaiters.delete(id);
393
+ reject(new Error(`Failed to send RPC ${String(command.type)}`));
394
+ }
395
+ });
396
+ }
397
+
398
+ private rejectResponseWaiters(proc: ChildProcess, error: Error): void {
399
+ for (const [id, waiter] of this.responseWaiters) {
400
+ if (waiter.process !== proc) continue;
401
+ clearTimeout(waiter.timer);
402
+ this.responseWaiters.delete(id);
403
+ waiter.reject(error);
404
+ }
405
+ }
406
+
351
407
  private handleRpcEvent(agent: AgentProcess, event: any): void {
408
+ if (event.type === "response" && typeof event.id === "string") {
409
+ const waiter = this.responseWaiters.get(event.id);
410
+ if (waiter) {
411
+ clearTimeout(waiter.timer);
412
+ this.responseWaiters.delete(event.id);
413
+ waiter.resolve(event);
414
+ }
415
+ return;
416
+ }
417
+ // Process stdout can drain after a replacement has already been installed.
418
+ // Ignore every stale lifecycle/activity event so the old child cannot
419
+ // evict, settle, or complete the new task process.
420
+ if (this.agents.get(agent.taskId) !== agent) return;
352
421
  // Only genuine agent activity advances the idle clock. Command acks
353
422
  // (type=response) and echoes of injected user messages (steer messages
354
423
  // recorded as user-role message events) must NOT reset it — otherwise the
@@ -407,14 +476,14 @@ export class AgentPool {
407
476
  } else if (event.type === "agent_settled") {
408
477
  debug("squad-pool", `agent_settled from RPC: ${agent.agentName} (task: ${agent.taskId})`);
409
478
  // Mark the guard to prevent double-emit from proc.on("exit")
410
- const guardFn = (agent as any)._agentEndEmitted;
479
+ const guardFn = (agent as any)._terminalEventEmitted;
411
480
  if (guardFn) guardFn();
412
481
  // Capture activity stats BEFORE deleting
413
482
  const endActivity = agent.activity;
414
483
  // Remove from agents map BEFORE emitting so getRunningAgents() doesn't count it
415
484
  this.agents.delete(agent.taskId);
416
485
  this.emit({
417
- type: "agent_end",
486
+ type: "agent_settled",
418
487
  taskId: agent.taskId,
419
488
  agentName: agent.agentName,
420
489
  data: {
@@ -450,13 +519,23 @@ function buildPiArgs(
450
519
  agentDef: AgentDef,
451
520
  promptFile: string,
452
521
  skillPaths: string[],
453
- forkSession?: { file: string; sessionDir: string },
522
+ sessionOptions: {
523
+ resumeSession?: TaskSession;
524
+ sessionDir?: string;
525
+ forkSession?: { file: string; sessionDir: string };
526
+ },
454
527
  ): string[] {
455
- // --fork cannot combine with --no-session; forked child sessions are
456
- // stored under the squad's data dir to keep the user's session list clean.
457
- const sessionArgs = forkSession
458
- ? ["--fork", forkSession.file, "--session-dir", forkSession.sessionDir]
459
- : ["--no-session"];
528
+ const { resumeSession, sessionDir, forkSession } = sessionOptions;
529
+ if (resumeSession && forkSession) throw new Error("Cannot resume and fork a task session simultaneously");
530
+ // Existing tasks always reopen the exact bound file. Only tasks without a
531
+ // binding create a new session (optionally as a main-session fork).
532
+ const sessionArgs = resumeSession
533
+ ? ["--session", resumeSession.file]
534
+ : forkSession
535
+ ? ["--fork", forkSession.file, "--session-dir", forkSession.sessionDir]
536
+ : sessionDir
537
+ ? ["--session-dir", sessionDir]
538
+ : (() => { throw new Error("A durable task session is required"); })();
460
539
  const args: string[] = [...sessionArgs, "--append-system-prompt", promptFile];
461
540
 
462
541
  if (agentDef.model) {