@fradser/pi-agent-teams 0.10.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
+ import { isTaskReplaced } from "@fradser/pi-tasks";
1
2
  /**
2
3
  * Team machine — resident teammate lifecycle, peer mail routing, task-intent
3
4
  * processing, verify gating, and the harness poll loop that wakes idle
4
- * teammates. The leader model never polls; this loop is the coordination
5
+ * agents. The leader model never polls; this loop is the coordination
5
6
  * engine described by the BDD contract.
6
7
  */
7
8
 
@@ -15,63 +16,72 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
16
  type LeaderThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
16
17
  const THINKING_LEVELS: readonly LeaderThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
17
18
  import { modelLabel, runPiWorker, type RunPiWorkerOptions } from "@fradser/pi-kit";
18
- import { MODEL_INHERIT_ALIAS, discoverAgents, persistAgentDefinition, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "./agents.ts";
19
+ import { MODEL_INHERIT_ALIAS, discoverAgents, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "@fradser/pi-subagents";
19
20
  import {
20
21
  activeAssignmentConflict,
22
+ acknowledgeSupersession,
21
23
  applyClaimIntent,
22
- assignTeammate,
23
- boardRevision,
24
+ assignAgent,
25
+ bindTask,
26
+ completeTaskWithOutcome,
27
+ createBoundWork,
28
+ tasksRevision,
24
29
  claimableTasks,
25
30
  clearWorkerRunEvents,
26
- completeTask,
27
31
  createTask,
28
- createDirectWork,
29
- discardDirectWork,
30
32
  deliverToLeader,
33
+ discardTask,
31
34
  getPeerInboxOffset,
32
35
  getState,
33
- getTeammate,
36
+ getAgent,
34
37
  getTeamDefaultModel,
35
- idleTeammates,
38
+ idleAgents,
36
39
  isPeerDelivered,
37
- isValidTeammateName,
38
- listTeammates,
39
- livingTeammates,
40
- loadBoard,
41
- markBoardChanged,
40
+ isValidAgentName,
41
+ listAgents,
42
+ livingAgents,
43
+ loadTasks,
44
+ markTasksChanged,
42
45
  markPeerDelivered,
43
46
  getTask,
44
- normalizeResources,
45
47
  progressRevision,
46
- registerTeammate,
47
- releaseTask,
48
- releaseTasksOf,
49
- reclaimDirectWork,
48
+ registerAgent,
49
+ failTasksHeldBy,
50
+ revertUndeliveredTake,
50
51
  taskDependenciesMet,
51
52
  receiveWorkerMessage,
52
- reopenCompletedWork,
53
+ reopenTask,
54
+ setTaskContext,
53
55
  setPeerInboxOffset,
54
56
  rosterRevision,
55
57
  setPeerDeliveryState,
56
58
  stateRevision,
57
- updateTeammate,
58
- updateTeammateProgress,
59
+ updateAgent,
60
+ updateAgentProgress,
59
61
  } from "./state.ts";
60
62
  import {
61
- appendInboxMessage,
62
- boardFilePath,
63
+ tasksFilePath,
64
+ buildTaskHandoff,
63
65
  claimsDir,
64
66
  createTaskIntent,
67
+ formatTaskHandoff,
68
+ normalizeResources,
69
+ readTasksFile,
70
+ submissionsDir,
71
+ takeTaskIntent,
72
+ writeTasksFile,
73
+ TASK_HOLDER_ENV,
74
+ type Task,
75
+ type TaskRefusalResult,
76
+ } from "@fradser/pi-tasks";
77
+ import {
78
+ appendInboxMessage,
65
79
  inboxPath,
66
- readBoardFile,
67
80
  readJsonlBatch,
68
81
  removeSessionStateDir,
69
82
  removeWorkerOutbox,
70
83
  rosterPath,
71
84
  stateFilePath,
72
- submissionsDir,
73
- takeTaskIntent,
74
- writeBoardFile,
75
85
  writeRoster,
76
86
  writeStateFile,
77
87
  workerOutboxPath,
@@ -80,23 +90,38 @@ import { isWorkerEvent } from "./types.ts";
80
90
  import {
81
91
  deliverFreshAssignment,
82
92
  deliverPrompt,
93
+ deliverResidentPrompt,
94
+ deliverResidentSteer,
83
95
  isCleanExit,
84
96
  isFreshAssignmentPending,
85
- resolveWorkerTools,
86
- sendWorkerSteer,
87
- sendWorkerFollowUp,
97
+ resolveAgentTools,
98
+ sendAgentSteer,
99
+ sendAgentFollowUp,
88
100
  spawnResident,
89
- terminateAllTeammates,
90
- terminateTeammate,
91
- unknownWorkerTools,
92
- WORKER_TOOL_UNIVERSE,
93
- type WorkerProcessResult,
101
+ terminateAllAgents,
102
+ terminateAgent,
103
+ unknownAgentTools,
104
+ type AgentProcessResult,
94
105
  type ResidentSpawnOptions,
95
- } from "./spawner.ts";
96
- import { captureWorktreeDiff, cleanupWorktree, createWorktree, discardWorktree } from "./worktree.ts";
97
- import { messageTitle, type InboxMessage, type Teammate, type WorkerAssignment, type WorkerUsage } from "./types.ts";
106
+ } from "@fradser/pi-subagents";
107
+ import { WORKER_CAPABILITY_TOOLS, WORKER_EXTENSION_PATH } from "./capability-tools.ts";
108
+ import {
109
+ assertPrivateSessionFiles,
110
+ ensureAgentWorkspace,
111
+ inlineDefinitionApplies,
112
+ AGENT_MEMORY_CAPABILITY_TOOLS,
113
+ SUBAGENT_ROLE_ENV,
114
+ SUBAGENT_TOOLS_ENV,
115
+ SUBAGENT_DEFINITION_ENV,
116
+ SUBAGENT_SCOPE_ENV,
117
+ SUBAGENT_EXTENSION_PATH,
118
+ STREAM_RECOVERY_EXTENSION_PATH,
119
+ unknownAgentToolsError,
120
+ type AgentWorkspace,
121
+ } from "@fradser/pi-subagents";
122
+ import { messageTitle, type InboxMessage, type Agent, type WorkerAssignment, type AgentUsage } from "./types.ts";
98
123
  import { exactSessionRoute, resolveExactSession } from "./recipient.ts";
99
- import type { LeaderReport } from "./leader-reports.ts";
124
+ import type { LeaderReport } from "@fradser/pi-subagents";
100
125
 
101
126
  /** Harness coordination cadence: outbox drain every tick, notices paced. */
102
127
  const LIVE_POLL_MS = 500;
@@ -109,9 +134,9 @@ export const NOTICE_PACE_MS = readDurationEnv("PI_TEAMMATE_NOTICE_PACE_MS", DEFA
109
134
  export const VERIFY_FAILURE_ESCALATE_AFTER = 2;
110
135
  /** Claimable task ids remembered per teammate. markTasksNoticed prunes stale
111
136
  * ids on every notice, so slots are occupied only by currently-claimable
112
- * work; the cap is far above any realistic concurrent board size. */
137
+ * work; the cap is far above any realistic concurrent tasks size. */
113
138
  const MAX_NOTICED_TASK_IDS = 256;
114
- /** Claimable tasks listed in one wake-prompt board notice. */
139
+ /** Claimable tasks listed in one wake-prompt tasks notice. */
115
140
  const WAKE_NOTICE_TASK_LIMIT = 10;
116
141
  /** Console-only silence marker: after this long without output the roster
117
142
  * shows "stalled". It never notifies the leader or terminates anything. */
@@ -127,7 +152,7 @@ function readDurationEnv(name: string, fallback: number): number {
127
152
  return Number.isFinite(value) && value >= 0 ? value : fallback;
128
153
  }
129
154
 
130
- export function stallSilenceMs(teammate: Pick<Teammate, "lastOutputAt">, now = Date.now()): number | undefined {
155
+ export function stallSilenceMs(teammate: Pick<Agent, "lastOutputAt">, now = Date.now()): number | undefined {
131
156
  if (teammate.lastOutputAt === undefined) return undefined;
132
157
  return Math.max(0, now - teammate.lastOutputAt);
133
158
  }
@@ -142,14 +167,14 @@ export function formatSilenceDuration(milliseconds: number): string {
142
167
  }
143
168
 
144
169
  /** Console-only silence threshold for the roster "stalled" marker. */
145
- export function stallThresholdMs(_teammate: Pick<Teammate, "modelOutputSeen" | "activeTool">): number {
170
+ export function stallThresholdMs(_teammate: Pick<Agent, "modelOutputSeen" | "activeTool">): number {
146
171
  return STALL_SILENCE_MS;
147
172
  }
148
173
 
149
174
  let livePollTimer: ReturnType<typeof setInterval> | undefined;
150
175
  let generation = 0;
151
176
  let runtimeStateFile = "";
152
- let boardFile = "";
177
+ let tasksFile = "";
153
178
  let leaderCwd = "";
154
179
  /** Live view of the leader session's current model, resolved at spawn time. */
155
180
  let leaderModelRef: () => string | undefined = () => undefined;
@@ -220,9 +245,9 @@ function runVerifyGate(
220
245
  detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
221
246
  }));
222
247
  }
223
- type PendingSubmission = import("./types").TaskIntent & { assignmentId: string; verify: string | undefined };
248
+ type PendingSubmission = import("./types").TaskIntent & { assignmentId: string; verify: string | undefined; rejected?: boolean };
224
249
  /** Submitted Work waits for its authoring Worker to settle before a gate can
225
- * inspect its files. This applies to direct automatic and board submissions. */
250
+ * inspect its files. This applies to direct automatic and tasks submissions. */
226
251
  const pendingSubmissions = new Map<string, PendingSubmission>();
227
252
  /** Idle nudges already fired per teammate incarnation (one per transition). */
228
253
  const idleNudgesSent = new Set<string>();
@@ -233,7 +258,12 @@ const verifyFailures = new Map<string, VerifyFailureRecord>();
233
258
  /** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
234
259
  const selfFinalizeAttempts = new Set<string>();
235
260
  const pendingDeliveries = new Map<string, InboxMessage[]>();
236
- const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
261
+ /** Role and first-input delivery are pinned per incarnation, never re-resolved
262
+ * when a later send or first idle wake begins. */
263
+ const residentSequences = new Map<string, { rolePrompt: string; roleDelivered: boolean }>();
264
+ /** A correlated control receipt may arrive after result callbacks. Keep the
265
+ * exact incarnation reserved until that receipt resolves. */
266
+ const pendingResidentInputs = new Set<string>();
237
267
 
238
268
  // ── Lifecycle ─────────────────────────────────────────────────────
239
269
 
@@ -252,31 +282,31 @@ export function initTeamMachine(
252
282
  leaderThinkingLevelRef = () => ctx.thinkingLevel;
253
283
  const sessionFile = ctx.sessionManager?.getSessionFile();
254
284
  runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
255
- boardFile = boardFilePath(sessionFile, leaderCwd);
285
+ tasksFile = tasksFilePath(sessionFile, leaderCwd);
256
286
  sendUpdate = hooks.sendUpdate;
257
287
  notifyChange = hooks.notifyChange;
258
- // The board and runtime dir are keyed per session, so two Leader processes
288
+ // The tasks and runtime dir are keyed per session, so two Leader processes
259
289
  // holding the same session would overwrite each other's single-writer state.
260
290
  // Detection is a report, not a block: the harness never takes ownership away
261
291
  // from a session the user may legitimately be resuming.
262
292
  claimLeaderLease();
263
- // Resume: reload a persisted board; claims die with their holders. A board
293
+ // Resume: reload a persisted tasks; claims die with their holders. A task set
264
294
  // that cannot be loaded is archived rather than silently discarded or allowed
265
295
  // to prevent extension reload.
266
296
  try {
267
- const persisted = readBoardFile(boardFile);
268
- if (persisted) loadBoard(persisted.tasks);
297
+ const persisted = readTasksFile(tasksFile);
298
+ if (persisted) loadTasks(persisted.tasks);
269
299
  } catch (error) {
270
300
  const detail = error instanceof Error ? error.message : String(error);
271
301
  let preservation: string;
272
302
  try {
273
- const archived = `${boardFile}.incompatible-${Date.now()}`;
274
- fs.renameSync(boardFile, archived);
303
+ const archived = `${tasksFile}.incompatible-${Date.now()}`;
304
+ fs.renameSync(tasksFile, archived);
275
305
  preservation = `Archived preserved snapshot: ${archived}`;
276
306
  } catch {
277
307
  // An unusable agents directory must still not turn preservation into a
278
308
  // fatal startup error: report where the snapshot stayed instead.
279
- preservation = `The preserved snapshot could not be archived and stays in place: ${boardFile}`;
309
+ preservation = `The preserved snapshot could not be archived and stays in place: ${tasksFile}`;
280
310
  }
281
311
  deliverToLeader({ from: "harness", subject: "Incompatible Work snapshot", body: `${detail}\n${preservation}` });
282
312
  }
@@ -297,7 +327,7 @@ export function shutdownTeamMachine(): void {
297
327
  if (livePollTimer) clearInterval(livePollTimer);
298
328
  livePollTimer = undefined;
299
329
  runtimeStateFile = "";
300
- boardFile = "";
330
+ tasksFile = "";
301
331
  leaderCwd = "";
302
332
  leaderModelRef = () => undefined;
303
333
  leaderThinkingLevelRef = () => undefined;
@@ -314,6 +344,8 @@ export function shutdownTeamMachine(): void {
314
344
  idleNudgesSent.clear();
315
345
  selfFinalizeAttempts.clear();
316
346
  pendingDeliveries.clear();
347
+ residentSequences.clear();
348
+ pendingResidentInputs.clear();
317
349
  verifyFailures.clear();
318
350
  inconclusiveParks.clear();
319
351
  verifyFailureParks.clear();
@@ -322,7 +354,7 @@ export function shutdownTeamMachine(): void {
322
354
  snapshotFailure = undefined;
323
355
  writtenStateRevision = -1;
324
356
  writtenRosterRevision = -1;
325
- writtenBoardRevision = -1;
357
+ writtenTasksRevision = -1;
326
358
  writtenProgressRevision = -1;
327
359
  lastStateWriteAt = 0;
328
360
  }
@@ -377,21 +409,21 @@ export function currentLeaderThinkingLevel(): LeaderThinkingLevel | undefined {
377
409
  }
378
410
 
379
411
  /** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
380
- export async function teardownTeammates(): Promise<string[]> {
412
+ export async function teardownAgents(): Promise<string[]> {
381
413
  // Session shutdown is an intentional lifecycle transition. Mark every
382
414
  // resident before signalling so close callbacks do not misclassify normal
383
415
  // session teardown as an unexpected crash and enqueue a follow-up turn.
384
- for (const teammate of livingTeammates()) pendingShutdowns.add(teammate.name);
385
- const results = await terminateAllTeammates();
416
+ for (const teammate of livingAgents()) pendingShutdowns.add(teammate.name);
417
+ const results = await terminateAllAgents();
386
418
  const diagnostics: string[] = [];
387
419
  for (const result of results) {
388
420
  if (!result.confirmedClosed) diagnostics.push(`Agent ${result.name} could not be confirmed closed.`);
389
- updateTeammate(result.name, { status: "stopped" });
421
+ updateAgent(result.name, { status: "stopped" });
390
422
  }
391
423
  return diagnostics;
392
424
  }
393
425
 
394
- /** Remove the current session's runtime dir; the task board persists. */
426
+ /** Remove the current session's runtime dir; the task set persists. */
395
427
  export function removeRuntimeDir(ctx: Pick<ExtensionContext, "sessionManager" | "cwd">): void {
396
428
  removeSessionStateDir(ctx.sessionManager?.getSessionFile(), ctx.cwd || process.cwd());
397
429
  }
@@ -417,7 +449,7 @@ let snapshotFailure: { count: number; reported: boolean; detail: string } | unde
417
449
  * every artifact. */
418
450
  let writtenStateRevision = -1;
419
451
  let writtenRosterRevision = -1;
420
- let writtenBoardRevision = -1;
452
+ let writtenTasksRevision = -1;
421
453
  let writtenProgressRevision = -1;
422
454
  let lastStateWriteAt = 0;
423
455
  /** Roughly 2 poll ticks: the debug snapshot follows progress, but not per token. */
@@ -466,23 +498,23 @@ function claimLeaderLease(): void {
466
498
  function flushSnapshots(): void {
467
499
  const stateFile = requireStateFile();
468
500
  const rosterRev = rosterRevision();
469
- const boardRev = boardRevision();
501
+ const tasksRev = tasksRevision();
470
502
  const stateRev = stateRevision();
471
503
  const progressRev = progressRevision();
472
504
  const needsRoster = rosterRev !== writtenRosterRevision;
473
- const needsBoard = boardRev !== writtenBoardRevision;
505
+ const needsTasks = tasksRev !== writtenTasksRevision;
474
506
  // The whole-state file exists for forensics; nothing in the runtime reads it
475
507
  // back. It still tracks streamed progress, but at a bounded rate instead of
476
508
  // rewriting the state on every token.
477
509
  const needsState = stateRev !== writtenStateRevision
478
510
  || (progressRev !== writtenProgressRevision && Date.now() - lastStateWriteAt >= STATE_SNAPSHOT_MIN_INTERVAL_MS);
479
- if (!needsRoster && !needsBoard && !needsState) return;
511
+ if (!needsRoster && !needsTasks && !needsState) return;
480
512
  try {
481
513
  if (needsState) writeStateFile(stateFile, getState());
482
514
  if (needsRoster) {
483
- writeRoster(rosterPath(stateFile), livingTeammates().map((t) => ({
515
+ writeRoster(rosterPath(stateFile), livingAgents().map((t) => ({
484
516
  name: t.name,
485
- agent: t.agent,
517
+ role: t.role,
486
518
  spawnId: t.spawnId,
487
519
  status: t.status,
488
520
  tools: t.tools,
@@ -490,7 +522,7 @@ function flushSnapshots(): void {
490
522
  assignment: t.assignment,
491
523
  })));
492
524
  }
493
- if (needsBoard && boardFile) writeBoardFile(boardFile, getState().tasks);
525
+ if (needsTasks && tasksFile) writeTasksFile(tasksFile, getState().tasks);
494
526
  // Only a fully successful flush advances the revisions, so a failed write
495
527
  // stays dirty and the next poll retries it.
496
528
  if (needsState) {
@@ -499,7 +531,7 @@ function flushSnapshots(): void {
499
531
  lastStateWriteAt = Date.now();
500
532
  }
501
533
  if (needsRoster) writtenRosterRevision = rosterRev;
502
- if (needsBoard) writtenBoardRevision = boardRev;
534
+ if (needsTasks) writtenTasksRevision = tasksRev;
503
535
  snapshotFailure = undefined;
504
536
  } catch (error) {
505
537
  // The next poll retries. A transient failure stays quiet, but a persistent
@@ -526,7 +558,7 @@ export function publishStateSnapshot(): void {
526
558
  }
527
559
 
528
560
  export function ensureLivePoll(): void {
529
- const busy = livingTeammates().length > 0 || verifyingTasks.size > 0 || pendingDeliveries.size > 0;
561
+ const busy = livingAgents().length > 0 || verifyingTasks.size > 0 || pendingDeliveries.size > 0;
530
562
  if (busy && !livePollTimer && runtimeStateFile) {
531
563
  livePollTimer = setInterval(() => {
532
564
  try {
@@ -553,9 +585,9 @@ function tick(): void {
553
585
 
554
586
  // ── Spawning ──────────────────────────────────────────────────────
555
587
 
556
- export function spawnTeammate(input: {
588
+ export function spawnAgent(input: {
557
589
  name: string;
558
- agent: string;
590
+ role: string;
559
591
  workId?: string;
560
592
  /** Internal binding of an existing Work Item, never exposed as a public field. */
561
593
  existingWorkId?: string;
@@ -565,17 +597,17 @@ export function spawnTeammate(input: {
565
597
  resources?: string[];
566
598
  verify?: string;
567
599
  handoffFrom?: string;
600
+ /** Explicit invocation tools may narrow an existing role, never widen it. */
601
+ tools?: string[];
568
602
  definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
569
603
  tools?: string[];
570
- persist?: boolean;
571
- persistScope?: "project" | "project-local";
572
604
  };
573
- }): { ok: true; teammate: Teammate; readiness?: Promise<string | undefined> } | { ok: false; error: string } {
605
+ }): { ok: true; agent: Agent; readiness?: Promise<string | undefined> } | { ok: false; error: string } {
574
606
  const stateFile = requireStateFile();
575
607
  const invalid = validateSpawnInput(input);
576
608
  if (invalid) return { ok: false, error: invalid };
577
609
  if (input.handoffFrom) {
578
- const predecessor = getTeammate(input.handoffFrom);
610
+ const predecessor = getAgent(input.handoffFrom);
579
611
  if (!predecessor) return { ok: false, error: `Cannot hand off from @${input.handoffFrom}: teammate not found.` };
580
612
  if (predecessor.status !== "stopped") {
581
613
  return { ok: false, error: `Cannot hand off from living @${input.handoffFrom}. Shut it down or wait for it to stop before spawning a successor.` };
@@ -585,43 +617,73 @@ export function spawnTeammate(input: {
585
617
  if (input.existingWorkId && (!existingWork || existingWork.status !== "pending" || !taskDependenciesMet(existingWork))) {
586
618
  return { ok: false, error: `Work Item "${input.existingWorkId}" is not pending and claimable.` };
587
619
  }
588
- const resolved = resolveAgent(input.agent, leaderCwd);
620
+ const resolved = resolveAgent(input.role, leaderCwd);
589
621
  // Reject tool ids the bare child could never grant before any side effect:
590
622
  // a silent --tools drop here is how reviewers end up blind mid-audit.
591
- const requestedTools = input.definition && inlineDefinitionApplies(resolved) ? input.definition.tools : resolved?.tools;
592
- const unknownTools = unknownWorkerTools(requestedTools);
593
- if (unknownTools.length > 0) return { ok: false, error: unknownWorkerToolsError(unknownTools) };
623
+ const requestedTools = input.tools ?? (input.definition && inlineDefinitionApplies(resolved) ? input.definition.tools : resolved?.tools);
624
+ if (resolved && input.tools !== undefined && !(input.definition && inlineDefinitionApplies(resolved))) {
625
+ const widened = input.tools.filter(tool => !resolved.tools.includes(tool));
626
+ if (widened.length > 0) return { ok: false, error: `Invocation cannot widen @${resolved.name}'s tool grant: ${widened.join(", ")}.` };
627
+ }
628
+ const unknownTools = unknownAgentTools(requestedTools, [
629
+ ...AGENT_MEMORY_CAPABILITY_TOOLS,
630
+ ...WORKER_CAPABILITY_TOOLS,
631
+ ]);
632
+ if (unknownTools.length > 0) return { ok: false, error: unknownAgentToolsError(unknownTools, WORKER_CAPABILITY_TOOLS) };
594
633
  let agent: AgentDefinition | undefined = resolved;
595
634
  if (input.definition && inlineDefinitionApplies(resolved)) {
596
635
  try {
597
636
  const generated = input.definition;
598
637
  const roleInput: AgentDefinitionInput = {
599
- name: input.agent,
638
+ name: input.role,
600
639
  description: generated.description,
601
- tools: generated.tools ?? [],
640
+ tools: requestedTools ?? [],
602
641
  model: generated.model,
603
642
  verify: generated.verify,
604
- worktree: generated.worktree,
605
643
  prompt: generated.prompt,
606
644
  };
607
- agent = generated.persist
608
- ? persistAgentDefinition(roleInput, generated.persistScope ?? "project-local", leaderCwd)
609
- : registerSessionAgent(roleInput);
645
+ agent = registerSessionAgent(roleInput);
610
646
  } catch (error) {
611
647
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
612
648
  }
613
649
  }
614
- if (!agent) return { ok: false, error: unknownAgentError(input.agent, leaderCwd) };
615
-
616
- let isolation: Teammate["isolation"] = "none";
617
- let workerCwd = leaderCwd;
618
- if (agent.worktree) {
619
- const worktree = createWorktree(workerCwd, `${input.name}-${Date.now()}`);
620
- if ("error" in worktree) return { ok: false, error: `Cannot isolate teammate: ${worktree.error}` };
621
- liveWorktrees.set(input.name, worktree);
622
- isolation = "worktree";
623
- workerCwd = worktree.cwd;
650
+ if (!agent) return { ok: false, error: unknownAgentError(input.role, leaderCwd) };
651
+
652
+ // Disk scope is the only durable identity declaration. Every disk role has
653
+ // Memory; a session role cannot acquire it through an invocation flag.
654
+ const durable = agent.scope !== "session";
655
+ if (requestedTools?.some((tool) => AGENT_MEMORY_CAPABILITY_TOOLS.includes(tool)) && !durable) {
656
+ return { ok: false, error: agentMemoryUnavailableError(agent.name) };
657
+ }
658
+
659
+ // Both packages contribute a worker extension to one child. `--extension` is
660
+ // repeatable and `--no-extensions` still loads explicit paths, so neither
661
+ // package has to know about the other; each declares its own capability tools.
662
+ // The subagents contribution is conditional: loading its extension into a
663
+ // child that has no memory folder would register nothing and advertise a tool
664
+ // the child cannot use.
665
+ const memoryEnabled = durable;
666
+ const capabilityTools = [
667
+ ...(memoryEnabled ? AGENT_MEMORY_CAPABILITY_TOOLS : []),
668
+ ...WORKER_CAPABILITY_TOOLS,
669
+ ];
670
+ const workerExtensions = [
671
+ STREAM_RECOVERY_EXTENSION_PATH,
672
+ ...(memoryEnabled ? [SUBAGENT_EXTENSION_PATH] : []),
673
+ WORKER_EXTENSION_PATH,
674
+ ];
675
+ const executionTools = requestedTools ?? agent.tools;
676
+ const effectiveTools = resolveAgentTools(executionTools, capabilityTools);
677
+ let workspace: AgentWorkspace | undefined;
678
+ try {
679
+ // Provision only data for an existing disk identity, never an agent.md.
680
+ if (durable) workspace = ensureAgentWorkspace({ agentName: agent.name, cwd: leaderCwd });
681
+ } catch (error) {
682
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
624
683
  }
684
+ const workerCwd = workspace?.cwd ?? leaderCwd;
685
+ const sessionDir = workspace?.sessionDir;
686
+ const hasSavedSession = sessionDir && assertPrivateSessionFiles(sessionDir);
625
687
 
626
688
  const spawnId = randomUUID();
627
689
  const effectiveKickoff = buildSuccessorHandoff(input.prompt, input.handoffFrom);
@@ -632,14 +694,10 @@ export function spawnTeammate(input: {
632
694
  : [];
633
695
  const conflict = activeAssignmentConflict(directResources);
634
696
  if (conflict) {
635
- discardWorktreeQuietly(input.name);
636
697
  return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
637
698
  }
638
- const registered = registerTeammate(newTeammate(input, spawnId, isolation, workerCwd));
639
- if (!registered.ok) {
640
- discardWorktreeQuietly(input.name);
641
- return { ok: false, error: registered.error };
642
- }
699
+ const registered = registerAgent(newTeammate(input, spawnId, workerCwd));
700
+ if (!registered.ok) return { ok: false, error: registered.error };
643
701
 
644
702
  const spawnModel = resolveSpawnModel(input.model ?? agent.model, getTeamDefaultModel(), currentLeaderModelRef());
645
703
  // Record the grant before the first wake: a role derived without tools shows
@@ -647,26 +705,30 @@ export function spawnTeammate(input: {
647
705
  const assignment = directAssignment(effectiveKickoff, directResources, spawnId);
648
706
  if (assignment) {
649
707
  const directWork = input.existingWorkId
650
- ? reclaimDirectWork(input.existingWorkId, input.name, assignment, "pending")
651
- : createDirectWork({
652
- id: getTeammate(input.name)?.workId ?? `work:${spawnId}`,
708
+ ? bindTask(input.existingWorkId, input.name, assignment, { from: ["pending"] })
709
+ : createBoundWork({
710
+ id: getAgent(input.name)?.workId ?? `work:${spawnId}`,
653
711
  subject: effectiveKickoff ? messageTitle(effectiveKickoff) : "Direct work",
654
712
  description: effectiveKickoff,
655
713
  resources: directResources,
656
714
  verify: input.verify,
657
- workerName: input.name,
715
+ holder: input.name,
658
716
  assignment,
659
717
  });
660
718
  if (!directWork.ok) {
661
- updateTeammate(input.name, { status: "stopped" });
662
- discardWorktreeQuietly(input.name);
663
- return { ok: false, error: directWork.error };
719
+ updateAgent(input.name, { status: "stopped" });
720
+ return { ok: false, error: directWork.reason };
664
721
  }
665
722
  }
666
- updateTeammate(input.name, {
723
+ residentSequences.set(spawnId, { rolePrompt: agent.prompt, roleDelivered: false });
724
+ updateAgent(input.name, {
725
+ scope: agent.scope,
726
+ source: agent.source,
727
+ projectCwd: leaderCwd,
728
+ ...(workspace ? { workspacePath: workspace.workspacePath, sessionDir } : {}),
667
729
  model: spawnModel.model,
668
730
  context: input.context === undefined ? "fresh" : "fork",
669
- tools: resolveWorkerTools(agent.tools),
731
+ tools: effectiveTools,
670
732
  ...(assignment ? {} : { assignment }),
671
733
  });
672
734
  // Flush before the kickoff is written: a fast child must not read a stale
@@ -678,30 +740,39 @@ export function spawnTeammate(input: {
678
740
  // produce an unhandled rejection when they do not await startup.
679
741
  let settleReadiness!: (error: string | undefined) => void;
680
742
  const readiness = new Promise<string | undefined>((resolve) => { settleReadiness = resolve; });
743
+ pendingResidentInputs.add(spawnId);
744
+ void readiness.then(() => { pendingResidentInputs.delete(spawnId); });
681
745
  const started = spawnResident({
682
- workerName: input.name,
746
+ agentName: input.name,
683
747
  description: assignment ? `[agent-teams-assignment:${assignment.id}]\n` + buildKickoffPrompt(
684
748
  input.name,
685
- input.agent,
749
+ input.role,
686
750
  agent.prompt,
687
751
  effectiveKickoff,
688
- isolation,
689
752
  ) : undefined,
690
753
  model: spawnModel.model,
691
754
  thinking: currentLeaderThinkingLevel(),
692
755
  context: input.context,
693
- tools: agent.tools,
756
+ tools: executionTools,
757
+ extensions: workerExtensions,
758
+ capabilityTools,
694
759
  cwd: workerCwd,
695
- env: teammateEnv(stateFile, input, spawnId, agent.verify),
760
+ session: durable ? (hasSavedSession ? "resume" : "persist") : "none",
761
+ ...(sessionDir ? { sessionDir } : {}),
762
+ // Both kinds launch in the original project. Scratch is data, not a new
763
+ // trusted checkout; do not force project approval merely for durability.
764
+ env: teammateEnv(stateFile, input, spawnId, agent, effectiveTools),
696
765
  onUpdate: (progress) => {
697
766
  applyProgress(input.name, spawnId, progress);
698
- if (progress.controlError) settleReadiness(progress.controlError);
699
- else if (progress.ready) settleReadiness(undefined);
767
+ if (!assignment) {
768
+ if (progress.controlError) settleReadiness(progress.controlError);
769
+ else if (progress.ready) settleReadiness(undefined);
770
+ }
700
771
  },
701
772
  onError: (error) => {
702
773
  settleReadiness(`Resident startup readiness failed: ${error.message}`);
703
- if (getTeammate(input.name)?.spawnId !== spawnId) return;
704
- if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${error.message}`, false);
774
+ if (getAgent(input.name)?.spawnId !== spawnId) return;
775
+ if (input.existingWorkId) revertUndeliveredTake(input.existingWorkId, input.name, `Agent failed to start: ${error.message}`);
705
776
  failSpawn(input.name, error.message, input.existingWorkId);
706
777
  },
707
778
  onExit: (result) => {
@@ -710,37 +781,66 @@ export function spawnTeammate(input: {
710
781
  },
711
782
  });
712
783
  if ("error" in started) {
713
- if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${started.error}`, false);
714
- else if (assignment) discardDirectWork(getTeammate(input.name)?.workId ?? `work:${spawnId}`, input.name);
784
+ settleReadiness(started.error);
785
+ if (input.existingWorkId) revertUndeliveredTake(input.existingWorkId, input.name, `Agent failed to start: ${started.error}`);
786
+ else if (assignment) discardTask(getAgent(input.name)?.workId ?? `work:${spawnId}`, input.name);
715
787
  failSpawn(input.name, started.error, input.existingWorkId);
716
788
  return { ok: false, error: started.error };
717
789
  }
718
- updateTeammate(input.name, { pid: started.pid });
719
- // Keep starting until model activity or the unassigned child's native
720
- // readiness acknowledgement arrives; spawning alone does not prove idle.
790
+ updateAgent(input.name, { pid: started.pid, envPolicy: started.envPolicy });
791
+ if (assignment) {
792
+ // Attach both outcomes immediately, even for synchronous internal callers.
793
+ // A writable pipe proves neither accepted input nor completed execution.
794
+ const rejectInput = (error: string) => {
795
+ if (getAgent(input.name)?.spawnId === spawnId) updateAgent(input.name, { error });
796
+ settleReadiness(error);
797
+ };
798
+ if (!started.accepted) rejectInput("Resident kickoff input acknowledgement is unavailable.");
799
+ else void started.accepted.then((accepted) => {
800
+ const current = getAgent(input.name);
801
+ if (!accepted) rejectInput("Resident kickoff input was not accepted. Inspect or stop before retrying.");
802
+ else if (!current || current.spawnId !== spawnId || current.status === "stopped") {
803
+ rejectInput("Resident exited or was replaced before kickoff input was acknowledged.");
804
+ } else {
805
+ // A fast result may settle before the RPC response. Do not revive it.
806
+ if (current.status === "starting") updateAgent(input.name, { status: "working" });
807
+ const sequence = residentSequences.get(spawnId);
808
+ if (sequence) sequence.roleDelivered = true;
809
+ settleReadiness(undefined);
810
+ }
811
+ }).catch((error) => rejectInput(error instanceof Error ? error.message : String(error)));
812
+ }
813
+ const contextWorkId = getAgent(input.name)?.workId;
814
+ if (contextWorkId && workspace) setTaskContext(contextWorkId, { workspacePath: workspace.workspacePath });
815
+ // Keep starting until native readiness or accepted kickoff input; settlement
816
+ // leaves both temporary and durable incarnations idle, never closed.
721
817
  publishStateSnapshot();
722
818
  ensureLivePoll();
723
819
  notifyChange();
724
- return { ok: true, teammate: getTeammate(input.name)!, ...(assignment ? {} : { readiness }) };
820
+ return { ok: true, agent: getAgent(input.name)!, readiness };
725
821
  }
726
822
 
727
823
  /** True when a spawn's inline definition should create or replace the role:
728
824
  * nothing resolved, or only a session-scoped generated role did. Filesystem
729
- * scopes are user-owned and always win over inline input. */
730
- export function inlineDefinitionApplies(resolved: AgentDefinition | undefined): boolean {
731
- return !resolved || resolved.scope === "session";
732
- }
825
+ * scopes are user-owned and always win over inline input.
826
+ *
827
+ * Owned by `@fradser/pi-subagents` and re-exported here, because the standalone
828
+ * spawner has to answer the same question. Two copies of this rule is how the
829
+ * two spawn paths came to disagree about which role a child received. */
830
+ export { inlineDefinitionApplies };
733
831
 
734
- /** Spawn failure for execution-tool ids outside the teammate universe. */
735
- export function unknownWorkerToolsError(unknown: readonly string[]): string {
736
- return [
737
- `Unknown tool id${unknown.length === 1 ? "" : "s"} for a teammate: ${unknown.join(", ")}.`,
738
- "A teammate child runs a bare pi process (--no-extensions), so only pi built-in tools plus the teammate capability set can be granted.",
739
- `Valid ids: ${WORKER_TOOL_UNIVERSE.join(", ")}.`,
740
- "MCP or project-extension tools cannot reach a teammate; perform that work in the leader session instead.",
741
- ].join(" ");
832
+ /** A session role has no durable Memory capability to contribute. */
833
+ export function agentMemoryUnavailableError(name: string): string {
834
+ return `Temporary Agent "${name}" has no Agent Memory. Remove ${AGENT_MEMORY_CAPABILITY_TOOLS.join(" and ")} from its tools; durable Memory belongs only to a user-authored disk agent.md.`;
742
835
  }
743
836
 
837
+ /** Spawn failure for execution-tool ids outside the teammate universe.
838
+ *
839
+ * Re-exported from `@fradser/pi-subagents`, which owns the universe and the
840
+ * message beside it: the standalone spawner refuses the same ids for the same
841
+ * reason, and a second copy of this text is a second copy to drift. */
842
+ export { unknownAgentToolsError };
843
+
744
844
  /** Spawn failure for an unresolvable agent name. Definitions resolve live at
745
845
  * spawn time, so the available-agents list in the cached session guidance can
746
846
  * go stale (e.g. another session removed the file); name every checked scope
@@ -749,39 +849,37 @@ export function unknownAgentError(name: string, cwd: string): string {
749
849
  const available = [...discoverAgents(cwd).keys()];
750
850
  return [
751
851
  `Agent "${name}" not found in any scope.`,
752
- `Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
852
+ `Checked: ${cwd}/.pi/agents/<name>.local/agent.md, ${cwd}/.pi/agents/<name>/agent.md, ${path.join(getAgentDir(), "agents", "<name>", "agent.md")}, and in-memory session roles.`,
753
853
  'The available-agents list in your guidance may be stale: definition files can change mid-session (for example removed by a parallel session).',
754
- 'Recover by retrying agent action=delegate or start with name and an existing agent role id, or with name, a new agent role id, and an inline definition derived from references/agent-roles.md that includes description and prompt.',
854
+ 'Recover with agent action=start using an existing name, or supply description and role_prompt derived from references/agent-roles.md for a temporary role.',
755
855
  `Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
756
856
  ].join(' ');
757
857
  }
758
858
 
759
859
  function validateSpawnInput(input: { name: string }): string | undefined {
760
- if (!isValidTeammateName(input.name)) {
860
+ if (!isValidAgentName(input.name)) {
761
861
  return `Invalid teammate name "${input.name}". Use letters, digits, dots, dashes, underscores.`;
762
862
  }
763
- if (livingTeammates().some((t) => t.name === input.name)) {
863
+ if (livingAgents().some((t) => t.name === input.name)) {
764
864
  return `A living teammate named "${input.name}" already exists.`;
765
865
  }
766
866
  return undefined;
767
867
  }
768
868
 
769
869
  function newTeammate(
770
- input: { name: string; agent: string; workId?: string },
870
+ input: { name: string; role: string; workId?: string },
771
871
 
772
872
  spawnId: string,
773
- isolation: Teammate["isolation"],
774
873
  workerCwd: string,
775
- ): Teammate {
874
+ ): Agent {
776
875
  return {
777
876
  name: input.name,
778
- agent: input.agent,
877
+ role: input.role,
779
878
  spawnId,
780
879
  workId: input.workId,
781
880
  pid: 0,
782
881
  status: "starting",
783
882
  cwd: workerCwd,
784
- isolation,
785
883
  turns: 0,
786
884
  createdAt: Date.now(),
787
885
  updatedAt: Date.now(),
@@ -805,35 +903,46 @@ export function directAssignment(
805
903
  export function resolveDirectResources(resources: string[] | undefined, handoffFrom: string | undefined): string[] {
806
904
  const explicit = normalizeResources(resources);
807
905
  if (explicit.length > 0) return explicit;
808
- const prior = handoffFrom ? getTeammate(handoffFrom) : undefined;
906
+ const prior = handoffFrom ? getAgent(handoffFrom) : undefined;
809
907
  return normalizeResources(prior?.assignment?.resources ?? prior?.lastAssignment?.resources);
810
908
  }
811
909
 
812
910
  export function buildSuccessorHandoff(kickoff: string | undefined, handoffFrom: string | undefined): string | undefined {
813
911
  if (!handoffFrom?.trim()) return kickoff;
814
- const prior = getTeammate(handoffFrom);
912
+ const prior = getAgent(handoffFrom);
815
913
  const reports = getState().leaderMailbox
816
914
  .filter((message) => message.from === handoffFrom)
817
915
  .slice(-3)
818
- .map((message) => `- ${message.subject}: ${truncated(message.body, 1000)}`)
819
- .join("\n");
916
+ .map((message) => `${message.subject}: ${truncated(message.body, 1000)}`);
820
917
  const assignment = prior?.assignment ?? prior?.lastAssignment;
821
918
  const taskId = prior?.currentTaskId ?? prior?.lastTaskId;
822
- const handoff = [
919
+ // A structured brief rather than concatenated prose. @fradser/pi-tasks owns the shape, so
920
+ // a successor handoff, a recheck, and a standalone tasks all produce the same
921
+ // sections, and an oversized brief is clipped and marked instead of silently
922
+ // truncated. Prior reports go in `priorFindings` deliberately: that is the field
923
+ // a recheck must not lose when a reopen clears the result.
924
+ const { handoff } = buildTaskHandoff({
925
+ candidate: prior?.cwd ? `workspace ${prior.cwd}` : undefined,
926
+ delta: assignment ? `${assignment.kind} ${assignment.id}` : "assignment unavailable",
927
+ verification: taskId ? `prior tasks claim ${taskId}` : "no prior tasks claim",
928
+ priorFindings: reports.length > 0 ? reports : undefined,
929
+ outstanding: [
930
+ "Verify current files yourself; do not claim the predecessor's tasks Work unless the leader explicitly assigns it.",
931
+ ],
932
+ });
933
+ const brief = [
823
934
  `=== SUCCESSOR HANDOFF FROM @${handoffFrom} ===`,
824
- assignment ? `Prior assignment: ${assignment.kind} ${assignment.id}` : "Prior assignment: unavailable",
825
- taskId ? `Prior board claim: ${taskId}` : "Prior board claim: none",
826
- reports ? `Recent leader reports:\n${reports}` : "Recent leader reports: none",
827
- "Verify current files yourself; do not claim the predecessor's board task unless the leader explicitly assigns it.",
828
- ].join("\n");
829
- return [kickoff?.trim(), handoff].filter(Boolean).join("\n\n");
935
+ formatTaskHandoff(handoff),
936
+ ].filter(Boolean).join("\n");
937
+ return [kickoff?.trim(), brief].filter(Boolean).join("\n\n");
830
938
  }
831
939
 
832
940
  function teammateEnv(
833
941
  stateFile: string,
834
- input: { name: string; agent: string },
942
+ input: { name: string; role: string },
835
943
  spawnId: string,
836
- verify: string | undefined,
944
+ definition: AgentDefinition,
945
+ tools: readonly string[],
837
946
  ): Record<string, string | undefined> {
838
947
  return {
839
948
  PI_TEAMMATE_WORKER_NAME: input.name,
@@ -841,11 +950,25 @@ function teammateEnv(
841
950
  PI_TEAMMATE_OUTBOX_FILE: workerOutboxPath(stateFile, input.name, spawnId),
842
951
  PI_TEAMMATE_INBOX_FILE: inboxPath(stateFile, input.name),
843
952
  PI_TEAMMATE_ROSTER_FILE: rosterPath(stateFile),
844
- PI_TEAMMATE_BOARD_FILE: boardFile,
845
- PI_TEAMMATE_CLAIMS_DIR: claimsDir(boardDirectory()),
846
- PI_TEAMMATE_SUBMISSIONS_DIR: submissionsDir(boardDirectory()),
847
- PI_TEAMMATE_ROLE_AGENT: input.agent,
848
- PI_TEAMMATE_VERIFY_DEFAULT: verify ?? "",
953
+ PI_TEAMMATE_TASKS_FILE: tasksFile,
954
+ PI_TEAMMATE_CLAIMS_DIR: claimsDir(taskDirectory()),
955
+ PI_TEAMMATE_SUBMISSIONS_DIR: submissionsDir(taskDirectory()),
956
+ PI_TEAMMATE_ROLE_AGENT: input.role,
957
+ // The tasks's own binding, named by the tasks. `PI_TEAMMATE_WORKER_NAME` is
958
+ // this package's private vocabulary; a child that read it to decide who it is
959
+ // on the tasks would couple the tasks to this package's naming.
960
+ [TASK_HOLDER_ENV]: input.name,
961
+ PI_TEAMMATE_VERIFY_DEFAULT: definition.verify ?? "",
962
+ // The subagent worker extension reads these. Named for that package rather
963
+ // than reusing PI_TEAMMATE_*, so the execution layer never has to learn this
964
+ // package's vocabulary.
965
+ ...(definition.scope !== "session" ? {
966
+ [SUBAGENT_ROLE_ENV]: definition.name,
967
+ [SUBAGENT_TOOLS_ENV]: tools.join(","),
968
+ [SUBAGENT_DEFINITION_ENV]: definition.source,
969
+ [SUBAGENT_SCOPE_ENV]: definition.scope,
970
+ } : {}),
971
+ PI_SUBAGENT_PROJECT_CWD: leaderCwd,
849
972
  };
850
973
  }
851
974
 
@@ -854,41 +977,36 @@ export function buildKickoffPrompt(
854
977
  agentName: string,
855
978
  rolePrompt: string,
856
979
  kickoff: string | undefined,
857
- isolation: Teammate["isolation"],
858
980
  ): string {
859
981
  const header = [
860
- `You are a FULLY AUTONOMOUS resident teammate named "${name}" (agent: ${agentName}) in a pi multi-agent team.`,
982
+ `You are a FULLY AUTONOMOUS resident agent named "${name}" (role: ${agentName}) in a pi multi-agent team.`,
861
983
  "You stay alive between tasks. The harness wakes you with new prompts when",
862
- "messages arrive for you or when the task board has unclaimed work.",
863
- isolation === "worktree" ? "You are working inside your own dedicated git worktree." : "",
984
+ "messages arrive for you or when the task set has unclaimed work.",
985
+ `Original project: ${leaderCwd}. A private scratch cwd is not a checkout; project edits change the original working tree directly.`,
864
986
  ].filter(Boolean).join(" ");
865
- const roleSection = `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt}`;
987
+ // A role may state a purpose without instructions, so an empty body is said
988
+ // rather than printed as a heading with nothing under it — a child told "ROLE
989
+ // PROMPT (x) ===" and nothing else cannot tell whether it was given a role.
990
+ const roleSection = rolePrompt.trim()
991
+ ? `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt.trim()}`
992
+ : `=== ROLE (${agentName}) ===\nThis role states what you are for and carries no standing instructions. Take the task below as it comes.`;
866
993
  const taskSection = kickoff?.trim()
867
- ? `=== KICKOFF TASK ===\n${kickoff.trim()}\n\nExecute this assigned task directly. Do not call work action=list or check the task board unless this task explicitly instructs you to do so.`
868
- : "=== KICKOFF TASK ===\n(none yet — inspect Work with action=list and claim suitable Work with action=claim)";
994
+ ? `=== KICKOFF TASK ===\n${kickoff.trim()}\n\nExecute this assigned task directly; this task text is the whole assignment, so use task tools only when the assignment requires them.`
995
+ : "=== KICKOFF TASK ===\n(none yet — list the tasks with task action=list and take one with task update status=in_progress)";
869
996
  return `${header}\n\n${roleSection}\n\n${taskSection}`;
870
997
  }
871
998
 
872
- function buildFreshAssignmentPrompt(teammate: Teammate, assignmentId: string, task: string): string {
999
+ function buildFreshAssignmentPrompt(teammate: Agent, assignmentId: string, task: string): string {
873
1000
  return `[agent-teams-assignment:${assignmentId}]\n${buildKickoffPrompt(
874
1001
  teammate.name,
875
- teammate.agent,
876
- resolveAgent(teammate.agent, leaderCwd)?.prompt ?? "",
1002
+ teammate.role,
1003
+ residentSequences.get(teammate.spawnId)?.rolePrompt ?? resolveAgent(teammate.role, leaderCwd)?.prompt ?? "",
877
1004
  task,
878
- teammate.isolation,
879
1005
  )}`;
880
1006
  }
881
1007
 
882
- function discardWorktreeQuietly(name: string): void {
883
- const handle = liveWorktrees.get(name);
884
- if (!handle || "error" in handle) return;
885
- liveWorktrees.delete(name);
886
- // A failed spawn never produced work; the empty branch goes too.
887
- discardWorktree(handle);
888
- }
889
-
890
- function boardDirectory(): string {
891
- return path.dirname(boardFile);
1008
+ function taskDirectory(): string {
1009
+ return path.dirname(tasksFile);
892
1010
  }
893
1011
 
894
1012
  export function applyProgress(name: string, spawnId: string, progress: {
@@ -899,18 +1017,18 @@ export function applyProgress(name: string, spawnId: string, progress: {
899
1017
  finalResponse?: boolean;
900
1018
  modelOutputSeen?: boolean;
901
1019
  ready?: boolean;
902
- usage?: WorkerUsage;
1020
+ usage?: AgentUsage;
903
1021
  controlError?: string;
904
1022
  }): void {
905
- const teammate = getTeammate(name);
1023
+ const teammate = getAgent(name);
906
1024
  // A stale callback from an older incarnation must not touch the current one.
907
1025
  if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
908
1026
  if (progress.controlError) {
909
- updateTeammate(name, { error: progress.controlError });
1027
+ updateAgent(name, { error: progress.controlError });
910
1028
  deliverDiagnostic(name, progress.controlError);
911
- sendUpdate({ teammate: name, spawnId, body: progress.controlError, origin: "harness", harnessEvent: { type: "control-rejected", subject: `@${name} control rejected` }, finished: false });
1029
+ sendUpdate({ agent: name, spawnId, body: progress.controlError, origin: "harness", harnessEvent: { type: "control-rejected", subject: `@${name} control rejected` }, finished: false });
912
1030
  }
913
- updateTeammateProgress(name, teammate.spawnId, {
1031
+ updateAgentProgress(name, teammate.spawnId, {
914
1032
  liveText: progress.text,
915
1033
  activeTool: progress.activeTool,
916
1034
  liveThinking: progress.liveThinking,
@@ -919,13 +1037,13 @@ export function applyProgress(name: string, spawnId: string, progress: {
919
1037
  modelOutputSeen: progress.modelOutputSeen,
920
1038
  usage: progress.usage,
921
1039
  });
922
- updateTeammate(name, { lastOutputAt: Date.now(), ...(progress.finalResponse === false ? { status: "working" } : {}) });
1040
+ updateAgent(name, { lastOutputAt: Date.now(), ...(progress.finalResponse === false ? { status: "working" } : {}) });
923
1041
  if (progress.finalResponse === false && teammate.currentTaskId && verifyingTasks.has(teammate.currentTaskId)) {
924
1042
  const holding = `${teammate.currentTaskId}:${spawnId}`;
925
1043
  invalidateVerifyGate(teammate.currentTaskId);
926
1044
  unexpectedExecutionParks.add(holding);
927
1045
  sendUpdate({
928
- teammate: name,
1046
+ agent: name,
929
1047
  spawnId,
930
1048
  origin: "harness",
931
1049
  harnessEvent: { type: "review-invalidated", subject: `Verification invalidated · ${getTask(teammate.currentTaskId)?.subject ?? "Work"}` },
@@ -934,13 +1052,14 @@ export function applyProgress(name: string, spawnId: string, progress: {
934
1052
  });
935
1053
  }
936
1054
  if (progress.finalResponse && teammate.status !== "idle") {
937
- updateTeammate(name, { status: "idle", activeTool: undefined });
1055
+ updateAgent(name, { status: "idle", activeTool: undefined });
938
1056
  drainTeammateOutboxes(name);
939
1057
  processTaskIntents();
940
1058
  const pending = pendingSubmissions.get(name);
941
- if (pending?.spawnId === spawnId && getTeammate(name)?.assignment?.id === pending.assignmentId) {
942
- pendingSubmissions.delete(name);
943
- archiveDeferredDeliveries(getTeammate(name)!);
1059
+ if (pending?.spawnId === spawnId && getAgent(name)?.assignment?.id === pending.assignmentId && !pending.rejected) {
1060
+ // A review owns its binding; an ungated submission stays pending until accepted.
1061
+ if (pending.verify?.trim()) pendingSubmissions.delete(name);
1062
+ archiveDeferredDeliveries(getAgent(name)!);
944
1063
  beginVerifyOrComplete(pending, pending.verify);
945
1064
  }
946
1065
  nudgeIfUnfinalized(name, spawnId);
@@ -966,10 +1085,10 @@ const verifyFailureParks = new Map<string, { worker: string; spawnId: string }>(
966
1085
  /** Owner execution observed during review; explicit Leader recovery is required. */
967
1086
  const unexpectedExecutionParks = new Set<string>();
968
1087
 
969
- type FinishIdentity = Pick<LeaderReport, "teammate" | "agent" | "spawnId" | "assignmentId" | "finished">;
1088
+ type FinishIdentity = Pick<LeaderReport, "agent" | "spawnId" | "assignmentId" | "finished">;
970
1089
 
971
1090
  function finishKey(report: FinishIdentity): string {
972
- return `${report.teammate ?? report.agent ?? "teammate"}:${report.spawnId ?? "session"}:${report.assignmentId ?? "unassigned"}`;
1091
+ return `${report.agent ?? "agent"}:${report.spawnId ?? "session"}:${report.assignmentId ?? "unassigned"}`;
973
1092
  }
974
1093
 
975
1094
  /** Announce each assignment attempt once, including later work in the same process. */
@@ -983,15 +1102,17 @@ export function markTeammateFinished(report: FinishIdentity): boolean {
983
1102
 
984
1103
  /** Only an open current assignment can owe a final result. */
985
1104
  export function hasUnfinalizedReport(name: string): boolean {
986
- const teammate = getTeammate(name);
1105
+ const teammate = getAgent(name);
1106
+ const pending = pendingSubmissions.get(name);
1107
+ const submitted = pending?.spawnId === teammate?.spawnId && pending?.assignmentId === teammate?.assignment?.id;
987
1108
  return Boolean(teammate?.assignment && !teammate.assignment.closed
988
- && !teammate.reportSequenceEnded && !verificationFrozen(teammate));
1109
+ && !teammate.reportSequenceEnded && !submitted && !verificationFrozen(teammate));
989
1110
  }
990
1111
 
991
1112
  /** One light reminder per idle transition when work looks unfinished. */
992
1113
  function nudgeIfUnfinalized(name: string, spawnId: string): void {
993
1114
  if (!hasUnfinalizedReport(name)) return;
994
- const key = `${name}:${spawnId}:${getTeammate(name)?.assignment?.id}`;
1115
+ const key = `${name}:${spawnId}:${getAgent(name)?.assignment?.id}`;
995
1116
  if (!selfFinalizeAttempts.has(key)) {
996
1117
  // First miss: give the worker one chance to fix its own bookkeeping
997
1118
  // before bothering the leader. The inbox message wakes it on the next tick.
@@ -999,16 +1120,16 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
999
1120
  deliverFeedback(
1000
1121
  name,
1001
1122
  "Assignment not finalized",
1002
- `Current assignment "${getTeammate(name)?.assignment?.id ?? "unassigned"}" has no automatic final result yet. Return a final answer or submit the current Work outcome.`,
1123
+ `Current assignment "${getAgent(name)?.assignment?.id ?? "unassigned"}" has no automatic final result yet. Return a final answer or submit the current Work outcome.`,
1003
1124
  );
1004
1125
  return;
1005
1126
  }
1006
1127
  if (idleNudgesSent.has(key)) return;
1007
1128
  idleNudgesSent.add(key);
1008
- const reminder = `@${name} is idle but current assignment "${getTeammate(name)?.assignment?.id ?? "unassigned"}" has no terminal report after one finalization request. Decide whether to resume or release the assignment.`;
1129
+ const reminder = `@${name} is idle but current assignment "${getAgent(name)?.assignment?.id ?? "unassigned"}" has no terminal report after one finalization request. Decide whether to resume or release the assignment.`;
1009
1130
  deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
1010
1131
  sendUpdate({
1011
- teammate: name,
1132
+ agent: name,
1012
1133
  body: reminder,
1013
1134
  origin: "harness",
1014
1135
  harnessEvent: { type: "unfinalized-report", subject: `@${name} idle without terminal report` },
@@ -1017,15 +1138,15 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
1017
1138
  }
1018
1139
 
1019
1140
  function failSpawn(name: string, error: string, existingWorkId?: string): void {
1020
- const teammate = getTeammate(name);
1141
+ const teammate = getAgent(name);
1021
1142
  if (!existingWorkId && teammate?.assignment?.kind === "direct" && teammate.currentTaskId) {
1022
- discardDirectWork(teammate.currentTaskId, name);
1143
+ discardTask(teammate.currentTaskId, name);
1023
1144
  } else {
1024
- releaseTasksOf(name, `Agent failed to start: ${error}`);
1145
+ failTasksHeldBy(name, `Agent failed to start: ${error}`);
1025
1146
  }
1026
- discardWorktreeQuietly(name);
1027
- updateTeammate(name, { status: "stopped", error });
1147
+ updateAgent(name, { status: "stopped", error });
1028
1148
  if (teammate) {
1149
+ residentSequences.delete(teammate.spawnId);
1029
1150
  clearWorkerRunEvents(name, teammate.spawnId);
1030
1151
  removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
1031
1152
  }
@@ -1040,19 +1161,19 @@ function failSpawn(name: string, error: string, existingWorkId?: string): void {
1040
1161
 
1041
1162
  // ── Shutdown and close ────────────────────────────────────────────
1042
1163
 
1043
- export async function shutdownTeammateExact(name: string, spawnId: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
1044
- const existing = getTeammate(name);
1164
+ export async function shutdownAgentExact(name: string, spawnId: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
1165
+ const existing = getAgent(name);
1045
1166
  if (!existing || existing.spawnId !== spawnId || existing.status === "stopped") {
1046
1167
  return { ok: false, error: `No living session named "${exactSessionRoute(name, spawnId)}".` };
1047
1168
  }
1048
- return shutdownTeammate(name);
1169
+ return shutdownAgent(name);
1049
1170
  }
1050
1171
 
1051
- export async function shutdownTeammate(name: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
1052
- const teammate = getTeammate(name);
1172
+ export async function shutdownAgent(name: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
1173
+ const teammate = getAgent(name);
1053
1174
  if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${name}".` };
1054
1175
  pendingShutdowns.add(name);
1055
- const terminated = await terminateTeammate(name);
1176
+ const terminated = await terminateAgent(name);
1056
1177
  if (terminated.outcome === "unconfirmed") {
1057
1178
  return { ok: false, error: `Agent @${name} could not be confirmed closed; shutdown remains pending.` };
1058
1179
  }
@@ -1060,14 +1181,15 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
1060
1181
  rememberBounded(confirmedStopTimes, teammate.spawnId, Date.now());
1061
1182
  // The child was already gone; synthesize the close bookkeeping.
1062
1183
  pendingShutdowns.delete(name);
1063
- const released = releaseTasksOf(name, "Agent was stopped.");
1184
+ const released = failTasksHeldBy(name, "Agent was stopped.");
1064
1185
  for (const task of released) {
1065
1186
  clearInconclusiveForHolding(task.id, teammate.spawnId);
1066
1187
  invalidateVerifyGate(task.id);
1067
1188
  rearmTaskNotice(task.id);
1068
1189
  }
1069
- updateTeammate(name, { status: "stopped", activeTool: undefined });
1190
+ updateAgent(name, { status: "stopped", activeTool: undefined });
1070
1191
  pendingDeliveries.delete(name);
1192
+ residentSequences.delete(teammate.spawnId);
1071
1193
  idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
1072
1194
  selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
1073
1195
  // No close event will fire for an already-gone child, so this branch is
@@ -1082,21 +1204,25 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
1082
1204
  return { ok: true, body: `Agent @${name} stopped.` };
1083
1205
  }
1084
1206
 
1085
- async function handleTeammateClose(name: string, spawnId: string, result: WorkerProcessResult): Promise<void> {
1207
+ async function handleTeammateClose(name: string, spawnId: string, result: AgentProcessResult): Promise<void> {
1086
1208
  if (!runtimeStateFile) return;
1087
- const teammate = getTeammate(name);
1209
+ const teammate = getAgent(name);
1088
1210
  if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
1089
1211
  rememberBounded(confirmedStopTimes, spawnId, Date.now());
1090
1212
  const requested = pendingShutdowns.has(name);
1091
1213
  const crashed = !requested && !isCleanExit(result);
1092
1214
 
1093
- await finalizeWorktree(name);
1094
-
1095
1215
  // Drain final reports before tearing down replay metadata so nothing
1096
1216
  // written before close is lost.
1097
1217
  drainTeammateOutboxes();
1098
1218
 
1099
- const released = releaseTasksOf(name, requested ? "Agent was stopped." : "Agent stopped unexpectedly.");
1219
+ // A clean idle close with no outstanding result is a lifecycle fact, not a
1220
+ // failed assignment. Keep it visible without starting another leader turn.
1221
+ const cleanIdleClose = !crashed && teammate.status === "idle"
1222
+ && !hasUnfinalizedReport(name) && !verificationFrozen(teammate)
1223
+ && !pendingSubmissions.has(name) && !pendingResidentInputs.has(spawnId)
1224
+ && !pendingDeliveries.has(name) && !hasUnreadPeerMail(teammate);
1225
+ const released = failTasksHeldBy(name, requested ? "Agent was stopped." : "Agent stopped unexpectedly.");
1100
1226
  for (const task of released) {
1101
1227
  clearInconclusiveForHolding(task.id, teammate.spawnId);
1102
1228
  invalidateVerifyGate(task.id);
@@ -1106,9 +1232,10 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
1106
1232
  // A stopped teammate must not keep the poll loop or its queue alive.
1107
1233
  pendingSubmissions.delete(name);
1108
1234
  pendingDeliveries.delete(name);
1235
+ residentSequences.delete(spawnId);
1109
1236
  idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
1110
1237
  selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
1111
- updateTeammate(name, {
1238
+ updateAgent(name, {
1112
1239
  status: "stopped",
1113
1240
  activeTool: undefined,
1114
1241
  error: requested ? undefined : closeErrorText(result, crashed),
@@ -1116,17 +1243,16 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
1116
1243
  clearWorkerRunEvents(name, teammate.spawnId);
1117
1244
  removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
1118
1245
 
1119
- if (requested) {
1246
+ if (requested || (cleanIdleClose && released.length === 0)) {
1120
1247
  const summary = summarizeShutdown(name, released.length, result.exitCode, result.usage);
1121
1248
  deliverToLeader({ from: name, subject: "Agent stopped", body: summary });
1122
- // A requested shutdown is already represented by the tool lifecycle row;
1123
- // keep its summary in the console mailbox without starting a leader turn.
1249
+ // Requested stops and clean idle closure need no additional leader turn.
1124
1250
  } else {
1125
1251
  deliverToLeader({ from: name, subject: "Agent stopped unexpectedly", body: crashDiagnostic(name, result, released) });
1126
1252
  const closeReport = {
1127
- teammate: name,
1253
+ agent: name,
1128
1254
  spawnId: teammate.spawnId,
1129
- body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
1255
+ body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the tasks` : ""}.`,
1130
1256
  origin: "harness" as const,
1131
1257
  harnessEvent: { type: "unexpected-stop", subject: `@${name} stopped unexpectedly` },
1132
1258
  finished: false,
@@ -1138,12 +1264,12 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
1138
1264
  notifyChange();
1139
1265
  }
1140
1266
 
1141
- function closeErrorText(result: WorkerProcessResult, crashed: boolean): string | undefined {
1267
+ function closeErrorText(result: AgentProcessResult, crashed: boolean): string | undefined {
1142
1268
  if (!crashed) return undefined;
1143
1269
  return `Closed unexpectedly (code ${result.exitCode ?? "unknown"}${result.signal ? `, signal ${result.signal}` : ""}).`;
1144
1270
  }
1145
1271
 
1146
- function crashDiagnostic(name: string, result: WorkerProcessResult, released: Array<{ id: string }>): string {
1272
+ function crashDiagnostic(name: string, result: AgentProcessResult, released: Array<{ id: string }>): string {
1147
1273
  return [
1148
1274
  `Agent @${name} closed without a shutdown request.`,
1149
1275
  result.stderr?.trim() ? `stderr: ${result.stderr.trim()}` : undefined,
@@ -1156,7 +1282,7 @@ function summarizeShutdown(
1156
1282
  name: string,
1157
1283
  releasedCount: number,
1158
1284
  exitCode: number | null | undefined,
1159
- usage?: import("./types").WorkerUsage,
1285
+ usage?: import("./types").AgentUsage,
1160
1286
  ): string {
1161
1287
  const lines = [`Agent @${name} stopped (exit code ${exitCode ?? "unknown"}).`];
1162
1288
  if (releasedCount > 0) lines.push(`Released claimed task(s): ${releasedCount}.`);
@@ -1164,78 +1290,12 @@ function summarizeShutdown(
1164
1290
  return lines.join("\n");
1165
1291
  }
1166
1292
 
1167
- async function finalizeWorktree(name: string): Promise<void> {
1168
- const handle = liveWorktrees.get(name);
1169
- if (!handle || "error" in handle) {
1170
- liveWorktrees.delete(name);
1171
- return;
1172
- }
1173
- liveWorktrees.delete(name);
1174
- const captured = captureWorktreeDiff(handle);
1175
- // Cleanup commits any remaining work onto the kept branch before removing
1176
- // the directory: staging alone would die with the worktree.
1177
- const cleaned = cleanupWorktree(handle);
1178
- if (!captured.ok) {
1179
- // The branch survives cleanup, so nothing is lost; wake the leader with
1180
- // the recovery path because this requires a decision.
1181
- const body = `Capturing @${name}'s worktree diff failed (${captured.error}). The branch ${handle.branch} was kept; inspect it manually.`;
1182
- deliverToLeader({ from: name, subject: "Worktree diff capture failed", body });
1183
- sendUpdate({
1184
- teammate: name,
1185
- origin: "harness",
1186
- harnessEvent: { type: "worktree-capture-failed", subject: `@${name} worktree diff capture failed` },
1187
- body,
1188
- finished: false,
1189
- });
1190
- return;
1191
- }
1192
- const changed = captured.diff.patch.trim().length > 0;
1193
- deliverToLeader({
1194
- from: name,
1195
- subject: "Worktree diff captured",
1196
- body: changed
1197
- ? `Agent @${name}'s worktree diff:\n\n=== Worktree changes ===\n${captured.diff.diffStat}\n\n${captured.diff.patch}`
1198
- : `Agent @${name}'s worktree diff:\n(no worktree changes)`,
1199
- });
1200
- // Changed work must reach the leader even though the worktree directory is
1201
- // gone: dispatch a bounded preview plus the branch retrieval command. A
1202
- // clean worktree carries no information and stays log-only.
1203
- if (changed) {
1204
- sendUpdate({
1205
- teammate: name,
1206
- spawnId: getTeammate(name)?.spawnId,
1207
- origin: "harness",
1208
- harnessEvent: { type: "worktree-changes", subject: `@${name} worktree changes captured` },
1209
- body: [
1210
- `Worktree changes captured for @${name} (${captured.diff.diffStat.trim() || "diff"}).`,
1211
- "",
1212
- truncated(captured.diff.patch),
1213
- "",
1214
- `Full diff: git diff ${handle.baseCommit}..${handle.branch}`,
1215
- ].join("\n"),
1216
- finished: false,
1217
- });
1218
- }
1219
- if (!cleaned.ok) {
1220
- const subject = cleaned.error?.includes("worktree left in place") ? "Worktree cleanup aborted" : "Worktree cleanup issue";
1221
- const body = `Cleaning up @${name}'s worktree reported problems (${cleaned.error ?? "unknown cleanup failure"}).`;
1222
- deliverToLeader({ from: name, subject, body });
1223
- sendUpdate({
1224
- teammate: name,
1225
- origin: "harness",
1226
- harnessEvent: { type: "worktree-cleanup-failed", subject: `@${name} worktree cleanup issue` },
1227
- body,
1228
- finished: false,
1229
- });
1230
- }
1231
- }
1232
-
1233
1293
  // ── Report outbox draining ────────────────────────────────────────
1234
1294
 
1235
1295
  /** Poll one batch per worker; explicit control drains its recipient's existing snapshot. */
1236
1296
  export function drainTeammateOutboxes(recipient?: string): void {
1237
1297
  const stateFile = requireStateFile();
1238
- for (const teammate of livingTeammates()) {
1298
+ for (const teammate of livingAgents()) {
1239
1299
  const key = `${teammate.name}:${teammate.spawnId}`;
1240
1300
  const file = workerOutboxPath(stateFile, teammate.name, teammate.spawnId);
1241
1301
  const endOffset = teammate.name === recipient ? fs.statSync(file, { throwIfNoEntry: false })?.size ?? 0 : undefined;
@@ -1259,7 +1319,7 @@ function deliverDiagnostic(from: string, detail: string): void {
1259
1319
  deliverToLeader({ from, subject: "Agent channel diagnostic", body: detail });
1260
1320
  }
1261
1321
 
1262
- function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
1322
+ function applyOutboxRecord(teammate: Agent, record: unknown): boolean {
1263
1323
  if (!isWorkerEvent(record)) return false;
1264
1324
  if (record.worker !== teammate.name || record.spawnId !== teammate.spawnId) return false;
1265
1325
  const ids = getState().workerEventIds;
@@ -1275,9 +1335,9 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
1275
1335
  const taskId = teammate.currentTaskId;
1276
1336
  const task = taskId ? getTask(taskId) : undefined;
1277
1337
  if (teammate.assignment && taskId && task?.claimedBy === teammate.name
1278
- && (task.status === "claimed" || task.status === "superseded")) {
1338
+ && (task.status === "in_progress" || isTaskReplaced(task))) {
1279
1339
  applySubmissionMarker({
1280
- taskId, worker: teammate.name, spawnId: teammate.spawnId, assignmentId: teammate.assignment.id,
1340
+ taskId, participant: teammate.name, spawnId: teammate.spawnId, assignmentId: teammate.assignment.id,
1281
1341
  status, result: record.body, timestamp: record.timestamp ?? Date.now(),
1282
1342
  });
1283
1343
  } else {
@@ -1297,13 +1357,14 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
1297
1357
  }, { archived });
1298
1358
  // Informational events carry no Work acceptance authority.
1299
1359
  const report = {
1300
- teammate: teammate.name,
1301
- agent: teammate.agent,
1360
+ agent: teammate.name,
1361
+ role: teammate.role,
1302
1362
  spawnId: teammate.spawnId,
1303
1363
  body: record.body,
1304
- origin: "teammate" as const,
1364
+ origin: "agent" as const,
1305
1365
  eventId: record.id,
1306
1366
  assignmentId: record.assignmentId,
1367
+ ...(teammate.inputId ? { inputId: teammate.inputId } : {}),
1307
1368
  workId: teammate.workId,
1308
1369
  status: record.status,
1309
1370
  timestamp: record.timestamp ?? Date.now(),
@@ -1317,13 +1378,13 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
1317
1378
  // ── Peer inbox routing ────────────────────────────────────────────
1318
1379
 
1319
1380
  /**
1320
- * Route new inbox messages. Idle teammates get the content queued for their
1321
- * next wake-up; working teammates receive it immediately through their
1381
+ * Route new inbox messages. Idle agents get the content queued for their
1382
+ * next wake-up; working agents receive it immediately through their
1322
1383
  * control stream (falling back to queueing when the stream is unavailable).
1323
1384
  */
1324
1385
  export function routePeerInboxes(): void {
1325
1386
  const stateFile = requireStateFile();
1326
- for (const teammate of livingTeammates()) {
1387
+ for (const teammate of livingAgents()) {
1327
1388
  if (pendingShutdowns.has(teammate.name)) continue;
1328
1389
  const inboxName = teammate.name;
1329
1390
  const inbox = inboxPath(stateFile, inboxName);
@@ -1355,6 +1416,32 @@ export function routePeerInboxes(): void {
1355
1416
  }
1356
1417
  }
1357
1418
 
1419
+ /** Inspect unread mail without routing it or changing delivery evidence. */
1420
+ function hasUnreadPeerMail(teammate: Agent): boolean {
1421
+ const inbox = inboxPath(requireStateFile(), teammate.name);
1422
+ let endOffset: number;
1423
+ try {
1424
+ endOffset = fs.statSync(inbox, { throwIfNoEntry: false })?.size ?? 0;
1425
+ } catch {
1426
+ // An unreadable inbox cannot establish that this child owes no input.
1427
+ return true;
1428
+ }
1429
+ let offset = getPeerInboxOffset(teammate.name);
1430
+ if (offset > endOffset) offset = 0;
1431
+ while (offset < endOffset) {
1432
+ const batch = readJsonlBatch(inbox, offset, endOffset);
1433
+ if (batch.diagnostics.length || batch.nextOffset <= offset) return true;
1434
+ for (const record of batch.records) {
1435
+ const message = parseInboxMessage(record);
1436
+ if (!message) return true;
1437
+ if (!isPeerDelivered(teammate.name, message.id)
1438
+ && (!message.toSpawnId || message.toSpawnId === teammate.spawnId)) return true;
1439
+ }
1440
+ offset = batch.nextOffset;
1441
+ }
1442
+ return false;
1443
+ }
1444
+
1358
1445
  function parseInboxMessage(record: unknown): InboxMessage | undefined {
1359
1446
  if (!record || typeof record !== "object") return undefined;
1360
1447
  const candidate = record as Partial<InboxMessage>;
@@ -1364,42 +1451,47 @@ function parseInboxMessage(record: unknown): InboxMessage | undefined {
1364
1451
  return { ...candidate, timestamp: candidate.timestamp ?? Date.now() } as InboxMessage;
1365
1452
  }
1366
1453
 
1367
- function submissionPending(teammate: Teammate): boolean {
1454
+ function submissionPending(teammate: Agent): boolean {
1368
1455
  const pending = pendingSubmissions.get(teammate.name);
1369
- if (pending?.spawnId === teammate.spawnId && pending.assignmentId === teammate.assignment?.id) return true;
1456
+ if (pending?.spawnId === teammate.spawnId && pending.assignmentId === teammate.assignment?.id) return !pending.rejected;
1370
1457
  const task = teammate.currentTaskId ? getState().tasks[teammate.currentTaskId] : undefined;
1371
1458
  return teammate.assignment?.kind === "direct" && teammate.assignment.closed === true
1372
- && task?.status === "claimed" && task.claimedBy === teammate.name;
1459
+ && task?.status === "in_progress" && task.claimedBy === teammate.name;
1373
1460
  }
1374
1461
 
1375
- function gateInFlight(teammate: Teammate): boolean {
1462
+ function gateInFlight(teammate: Agent): boolean {
1376
1463
  return teammate.currentTaskId !== undefined && verifyingTasks.has(teammate.currentTaskId);
1377
1464
  }
1378
1465
 
1379
- function verificationFrozen(teammate: Teammate): boolean {
1466
+ function verificationFrozen(teammate: Agent): boolean {
1380
1467
  const taskId = teammate.currentTaskId;
1381
1468
  if (!taskId) return false;
1382
1469
  const holding = `${taskId}:${teammate.spawnId}`;
1383
1470
  return submissionPending(teammate) || gateInFlight(teammate) || verifyFailureParks.has(holding) || unexpectedExecutionParks.has(holding) || inconclusiveParks.has(holding);
1384
1471
  }
1385
1472
 
1386
- function archiveDeferredDeliveries(teammate: Teammate): void {
1473
+ /** Deferred deliveries archived when an accepted result closed the Task they
1474
+ * were waiting behind. Leader-side, not on the task: a peer message is a
1475
+ * coordination record, and the task store states what must be done rather than what
1476
+ * was said about it. Bounded per Task because nothing ever reads the whole
1477
+ * archive. */
1478
+ const archivedDeliveries = new Map<string, Array<{ from: string; subject: string; body: string; timestamp: number }>>();
1479
+
1480
+ function archiveDeferredDeliveries(teammate: Agent): void {
1387
1481
  const deliveries = pendingDeliveries.get(teammate.name) ?? [];
1388
1482
  if (deliveries.length === 0) return;
1389
1483
  pendingDeliveries.delete(teammate.name);
1390
- const task = teammate.currentTaskId ? getState().tasks[teammate.currentTaskId] : undefined;
1391
- if (task) {
1392
- task.deferredMessages = [
1393
- ...(task.deferredMessages ?? []),
1394
- ...deliveries.map(({ from, subject, body, timestamp }) => ({ from, subject, body, timestamp })),
1395
- ].slice(-20);
1396
- task.updatedAt = Date.now();
1484
+ const taskId = teammate.currentTaskId;
1485
+ if (taskId) {
1486
+ const archived = archivedDeliveries.get(taskId) ?? [];
1487
+ archived.push(...deliveries.map(({ from, subject, body, timestamp }) => ({ from, subject, body, timestamp })));
1488
+ archivedDeliveries.set(taskId, archived.slice(-20));
1489
+ markTasksChanged();
1397
1490
  }
1398
1491
  for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
1399
- markBoardChanged();
1400
1492
  }
1401
1493
 
1402
- function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
1494
+ function dispatchInboxMessage(teammate: Agent, message: InboxMessage): void {
1403
1495
  if (verificationFrozen(teammate)) {
1404
1496
  setPeerDeliveryState(message.id, "queued");
1405
1497
  const queued = pendingDeliveries.get(teammate.name) ?? [];
@@ -1414,7 +1506,7 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
1414
1506
  || teammate.status === "starting"
1415
1507
  || isFreshAssignmentPending(teammate.name)
1416
1508
  )
1417
- ? sendWorkerFollowUp(teammate.name, `${marker}${formatDelivery([message])}`)
1509
+ ? sendAgentFollowUp(teammate.name, `${marker}${formatDelivery([message])}`)
1418
1510
  : false;
1419
1511
  if (routed) {
1420
1512
  setPeerDeliveryState(message.id, "routed");
@@ -1427,6 +1519,115 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
1427
1519
  ensureLivePoll();
1428
1520
  }
1429
1521
 
1522
+ export type ResidentControlResult = { ok: true } | { ok: false; error: string };
1523
+
1524
+ /** Policy check for a bounded input reservation. No task or sequence is created. */
1525
+ export function authorizeResidentInput(name: string, spawnId: string): ResidentControlResult {
1526
+ let agent = currentHostedResident(name, spawnId);
1527
+ if (!agent) return { ok: false, error: "The exact hosted child is unavailable." };
1528
+ if (pendingShutdowns.has(name) || agent.leaderRequestedEnd) return { ok: false, error: "The hosted child is stopping." };
1529
+ // Consume the predecessor while its settlement flag and input ID still belong
1530
+ // to that sequence. The queue changes them only after this policy succeeds.
1531
+ drainTeammateOutboxes(name);
1532
+ agent = currentHostedResident(name, spawnId);
1533
+ if (!agent) return { ok: false, error: "The exact hosted child changed during report delivery." };
1534
+ if (pendingShutdowns.has(name) || agent.leaderRequestedEnd) return { ok: false, error: "The hosted child is stopping." };
1535
+ if (isFreshAssignmentPending(name) || verificationFrozen(agent)) return { ok: false, error: "Task verification or assignment reset prevents input reservation." };
1536
+ return { ok: true };
1537
+ }
1538
+
1539
+ function currentHostedResident(name: string, spawnId: string): Agent | undefined {
1540
+ const agent = getAgent(name);
1541
+ return agent && agent.spawnId === spawnId && agent.status !== "stopped" && !agent.standalone ? agent : undefined;
1542
+ }
1543
+
1544
+ /** Continue one idle incarnation without resetting its Pi session or spawning.
1545
+ * A later independent input gets attempt-bound result authority before delivery;
1546
+ * an unfinished current holding continues under its existing authority. */
1547
+ export async function sendResidentSequence(name: string, spawnId: string, input: string): Promise<ResidentControlResult> {
1548
+ const agent = currentHostedResident(name, spawnId);
1549
+ if (!agent) return { ok: false, error: `No living hosted session "${exactSessionRoute(name, spawnId)}".` };
1550
+ if (!input.trim()) return { ok: false, error: "Send requires non-empty input." };
1551
+ if (pendingShutdowns.has(name) || agent.leaderRequestedEnd) return { ok: false, error: `@${name} is stopping.` };
1552
+ if (agent.status !== "idle" || pendingResidentInputs.has(spawnId) || isFreshAssignmentPending(name)) {
1553
+ return { ok: false, error: `@${name} is not idle for new input; inspect or interrupt its active sequence.` };
1554
+ }
1555
+ drainTeammateOutboxes(name);
1556
+ // Draining may synchronously start the final report's verification gate.
1557
+ if (verificationFrozen(agent)) return { ok: false, error: `@${name}'s Task is awaiting verification or explicit recovery; input cannot bypass that authority.` };
1558
+ const currentTask = agent.currentTaskId ? getTask(agent.currentTaskId) : undefined;
1559
+ const continuing = agent.assignment && !agent.assignment.closed && currentTask?.status === "in_progress" && !isTaskReplaced(currentTask) && currentTask.claimedBy === name;
1560
+ if (!continuing && currentTask && (currentTask.status === "in_progress" || isTaskReplaced(currentTask))) {
1561
+ return { ok: false, error: `@${name} still retains Task authority; settle or recover it before independent input.` };
1562
+ }
1563
+ const previousReportEnded = agent.reportSequenceEnded;
1564
+ const previousWorkId = agent.workId;
1565
+ let newTaskId: string | undefined;
1566
+ if (!continuing) {
1567
+ const assignment: WorkerAssignment = { id: `direct:${randomUUID()}`, kind: "direct", resources: [] };
1568
+ newTaskId = `work:${randomUUID()}`;
1569
+ const bound = createBoundWork({ id: newTaskId, subject: messageTitle(input), description: input, holder: name, assignment });
1570
+ if (!bound.ok) return { ok: false, error: bound.reason };
1571
+ updateAgent(name, { workId: newTaskId });
1572
+ }
1573
+ const assignmentId = agent.assignment!.id;
1574
+ const sequence = residentSequences.get(spawnId);
1575
+ const deliverRole = sequence !== undefined && !sequence.roleDelivered;
1576
+ const payload = `[agent-teams-assignment:${assignmentId}]\n` + (deliverRole
1577
+ ? buildKickoffPrompt(name, agent.role, sequence.rolePrompt, input)
1578
+ : input);
1579
+ pendingResidentInputs.add(spawnId);
1580
+ // Reserve synchronously so sibling sends cannot race the acknowledgement.
1581
+ updateAgent(name, { status: "working", sequenceEnded: false, reportSequenceEnded: false, error: undefined, lastOutputAt: Date.now() });
1582
+ publishStateSnapshot();
1583
+ try {
1584
+ const accepted = await deliverResidentPrompt(name, payload);
1585
+ const current = currentHostedResident(name, spawnId);
1586
+ if (!current) return { ok: false, error: "Resident exited or was replaced before input acknowledgement." };
1587
+ if (!accepted) {
1588
+ // A confirmed negative ack did not authorize a new turn. Do not undo
1589
+ // evidence if this attempt already settled before the response arrived.
1590
+ if (current.sequenceEnded !== true && current.assignment?.id === assignmentId) {
1591
+ if (newTaskId && getTask(newTaskId)?.status === "in_progress") {
1592
+ discardTask(newTaskId, name);
1593
+ updateAgent(name, { workId: previousWorkId });
1594
+ }
1595
+ updateAgent(name, { status: "idle", sequenceEnded: true, reportSequenceEnded: previousReportEnded });
1596
+ }
1597
+ return { ok: false, error: `@${name} did not accept input; no new turn was confirmed.` };
1598
+ }
1599
+ if (sequence) sequence.roleDelivered = true;
1600
+ return { ok: true };
1601
+ } catch (error) {
1602
+ // RPC timeout is uncertain: input may have begun. Retain the attempt and
1603
+ // working reservation; retrying must not create a competing input turn.
1604
+ const detail = error instanceof Error ? error.message : String(error);
1605
+ if (currentHostedResident(name, spawnId)) updateAgent(name, { error: detail });
1606
+ return { ok: false, error: detail };
1607
+ } finally {
1608
+ pendingResidentInputs.delete(spawnId);
1609
+ if (runtimeStateFile) publishStateSnapshot();
1610
+ notifyChange();
1611
+ }
1612
+ }
1613
+
1614
+ /** Native acknowledged steering stays in the current sequence and attempt. */
1615
+ export async function interruptResidentSequence(name: string, spawnId: string, input: string): Promise<ResidentControlResult> {
1616
+ const agent = currentHostedResident(name, spawnId);
1617
+ if (!agent) return { ok: false, error: `No living hosted session "${exactSessionRoute(name, spawnId)}".` };
1618
+ if (!input.trim()) return { ok: false, error: "Interrupt requires non-empty input." };
1619
+ if (agent.status === "idle") return { ok: false, error: `@${name} is idle; use send.` };
1620
+ if (pendingShutdowns.has(name) || agent.leaderRequestedEnd) return { ok: false, error: `@${name} is stopping.` };
1621
+ if (isFreshAssignmentPending(name) || verificationFrozen(agent)) return { ok: false, error: `@${name}'s attempt is resetting or awaiting verification; steering cannot bypass it.` };
1622
+ try {
1623
+ const accepted = await deliverResidentSteer(name, input);
1624
+ if (!currentHostedResident(name, spawnId)) return { ok: false, error: "Resident exited or was replaced before steer acknowledgement." };
1625
+ return accepted ? { ok: true } : { ok: false, error: `@${name} did not accept the steer; its sequence is unchanged.` };
1626
+ } catch (error) {
1627
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1628
+ }
1629
+ }
1630
+
1430
1631
  /** Route the leader's addressed agent_event through the same delivery path. */
1431
1632
  export type MessageRoutingOutcome = "steered" | "queued";
1432
1633
 
@@ -1439,7 +1640,7 @@ export type SendLeaderMessageResult =
1439
1640
  * mailbox entry carries terminal status. Lets the leader read the report
1440
1641
  * directly instead of steering the teammate into a duplicate resend. */
1441
1642
  function recordedTerminalReportBody(name: string): string | undefined {
1442
- const teammate = getTeammate(name);
1643
+ const teammate = getAgent(name);
1443
1644
  const assignmentId = teammate?.assignment?.id ?? teammate?.lastAssignment?.id;
1444
1645
  const mailbox = getState().leaderMailbox;
1445
1646
  for (let i = mailbox.length - 1; i >= 0; i--) {
@@ -1459,12 +1660,12 @@ export type AssignExistingWorkResult =
1459
1660
  /** Assign one pending Work Item to an exact idle resident through a fresh Pi
1460
1661
  * session. The record is claimed before reset; reset failure releases it. */
1461
1662
  export function assignExistingWork(workId: string, session: string): AssignExistingWorkResult {
1462
- const teammate = resolveExactSession(session, listTeammates());
1663
+ const teammate = resolveExactSession(session, listAgents());
1463
1664
  if (!teammate) return { ok: false, error: "Work assignment requires a current exact session route." };
1464
1665
  const owner = teammate.name;
1465
1666
  const task = getState().tasks[workId];
1466
1667
  if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${owner}".` };
1467
- if (task?.status === "claimed" && task.claimedBy === owner
1668
+ if (task?.status === "in_progress" && task.claimedBy === owner
1468
1669
  && teammate.currentTaskId === workId && teammate.assignment && !teammate.assignment.closed) {
1469
1670
  return { ok: true, workId, owner, assignmentId: teammate.assignment.id };
1470
1671
  }
@@ -1475,9 +1676,9 @@ export function assignExistingWork(workId: string, session: string): AssignExist
1475
1676
  if (!taskDependenciesMet(task)) return { ok: false, error: `Work Item "${workId}" has unmet dependencies.` };
1476
1677
  if (activeAssignmentConflict(task.resources, owner)) return { ok: false, error: `Work Item "${workId}" conflicts with active Work resources.` };
1477
1678
  const assignment = { id: `direct:${randomUUID()}`, kind: "direct" as const, resources: task.resources };
1478
- const claimed = reclaimDirectWork(workId, owner, assignment, "pending");
1479
- if (!claimed.ok) return claimed;
1480
- updateTeammate(owner, { reportSequenceEnded: false });
1679
+ const claimed = bindTask(workId, owner, assignment, { from: ["pending"] });
1680
+ if (!claimed.ok) return { ok: false, error: claimed.reason };
1681
+ updateAgent(owner, { reportSequenceEnded: false });
1481
1682
  flushSnapshots();
1482
1683
  const prompt = buildFreshAssignmentPrompt(teammate, assignment.id, [
1483
1684
  `Work Item ${task.id}: ${task.subject}`,
@@ -1486,11 +1687,11 @@ export function assignExistingWork(workId: string, session: string): AssignExist
1486
1687
  ].filter(Boolean).join("\n\n"));
1487
1688
  void deliverFreshAssignment(owner, prompt).then((sent) => {
1488
1689
  if (sent) {
1489
- updateTeammate(owner, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
1690
+ updateAgent(owner, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
1490
1691
  return;
1491
1692
  }
1492
- if (getTeammate(owner)?.assignment?.id === assignment.id) {
1493
- releaseTask(workId, "Pi session reset failed before the assigned Work started.", false);
1693
+ if (getAgent(owner)?.assignment?.id === assignment.id) {
1694
+ revertUndeliveredTake(workId, owner, "Pi session reset failed before the assigned Work started.");
1494
1695
  }
1495
1696
  deliverDiagnostic(owner, "Pi session reset failed; the assigned Work was not delivered.");
1496
1697
  });
@@ -1504,45 +1705,14 @@ export type ReopenExistingWorkResult =
1504
1705
  /** Reopen only a completed Work Item. Active, parked, superseded, and released
1505
1706
  * states remain governed by their explicit lifecycle transitions. */
1506
1707
  export function reopenExistingWork(workId: string): ReopenExistingWorkResult {
1507
- const reopened = reopenCompletedWork(workId);
1508
- if (!reopened.ok) return reopened;
1708
+ const reopened = reopenTask(workId);
1709
+ if (!reopened.ok) return { ok: false, error: reopened.reason };
1509
1710
  publishStateSnapshot();
1510
1711
  ensureLivePoll();
1511
1712
  notifyChange();
1512
1713
  return { ok: true, workId: reopened.task.id, subject: reopened.task.subject, resources: reopened.task.resources };
1513
1714
  }
1514
1715
 
1515
- export type ReleaseExistingWorkResult =
1516
- | { ok: true; workId: string; subject: string; resources: string[]; holderStillRunning?: string }
1517
- | { ok: false; error: string };
1518
-
1519
- /** Return one claimed Work Item to pending. This invalidates all in-memory
1520
- * submission/review authority before the single-writer state release. */
1521
- export function releaseExistingWork(workId: string, reason: string): ReleaseExistingWorkResult {
1522
- const task = getState().tasks[workId];
1523
- if (!task || task.status !== "claimed") return { ok: false, error: `Work Item "${workId}" is not claimed.` };
1524
- const holder = task.claimedBy ? getTeammate(task.claimedBy) : undefined;
1525
- let holderStillRunning: string | undefined;
1526
- if (holder) {
1527
- pendingSubmissions.delete(holder.name);
1528
- invalidateVerifyGate(workId);
1529
- clearInconclusiveForHolding(workId, holder.spawnId);
1530
- archiveDeferredDeliveries(holder);
1531
- sendWorkerSteer(holder.name, `Work Item "${workId}" was released by the leader: ${reason}. Stop work on this assignment.`);
1532
- deliverFeedback(holder.name, `Work Item "${workId}" released`, `Work Item "${workId}" was released by the leader: ${reason}.`);
1533
- // Release frees the resource immediately by contract. When the holder is
1534
- // still working, its in-flight tool batch may still be writing, so the
1535
- // residual window is reported instead of being silently implied safe.
1536
- holderStillRunning = holder.status === "working" ? holder.name : undefined;
1537
- }
1538
- const released = releaseTask(workId, reason, false);
1539
- if (!released) return { ok: false, error: `Work Item "${workId}" could not be released.` };
1540
- rearmTaskNotice(workId);
1541
- publishStateSnapshot();
1542
- ensureLivePoll();
1543
- notifyChange();
1544
- return { ok: true, workId: released.id, subject: released.subject, resources: released.resources, ...(holderStillRunning ? { holderStillRunning } : {}) };
1545
- }
1546
1716
 
1547
1717
  export function sendLeaderMessage(
1548
1718
  to: string,
@@ -1550,7 +1720,7 @@ export function sendLeaderMessage(
1550
1720
  options?: { reopen?: boolean | "if-closed"; resources?: string[]; workId?: string },
1551
1721
  ): SendLeaderMessageResult {
1552
1722
  drainTeammateOutboxes(to);
1553
- const teammate = getTeammate(to);
1723
+ const teammate = getAgent(to);
1554
1724
  if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${to}".` };
1555
1725
  if (pendingShutdowns.has(to)) return { ok: false, error: `Agent @${to} is stopping; new work is not accepted.` };
1556
1726
  // Explicit work control chooses after draining, not from a stale caller snapshot.
@@ -1575,7 +1745,7 @@ export function sendLeaderMessage(
1575
1745
  if (options?.reopen === undefined && (!teammate.assignment || teammate.assignment.closed)) {
1576
1746
  const prior = recordedTerminalReportBody(to);
1577
1747
  if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
1578
- return { ok: false, error: `@${to} has no open assignment. Use work action=assign for pending Work, or work action=reopen then assign for completed Work.` };
1748
+ return { ok: false, error: `@${to} has no open assignment. Take pending work with task update status=in_progress, or move a completed task back with task update status=pending first.` };
1579
1749
  }
1580
1750
  const retryReleasedWork = selectedExistingWork?.status === "pending" && teammate.assignment === undefined;
1581
1751
  if (teammate.assignment?.kind === "direct" && teammate.assignment.closed && !reopen && !retryReleasedWork) {
@@ -1583,23 +1753,23 @@ export function sendLeaderMessage(
1583
1753
  if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
1584
1754
  return { ok: false, error: `@${to} has a closed direct assignment. Use reopen=true for an explicit next assignment.` };
1585
1755
  }
1586
- // A board holder keeps its task after a terminal report. The leader must be
1756
+ // A task set holder keeps its task after a terminal report. The leader must be
1587
1757
  // able to steer it for verify feedback or an inconclusive decision without
1588
1758
  // replacing its assignment.
1589
- const activeBoardHolder = teammate.assignment?.kind === "board" && !teammate.assignment.closed;
1590
- if (teammate.reportSequenceEnded && !reopen && !retryReleasedWork && !activeBoardHolder) {
1759
+ const activeTaskHolder = teammate.assignment?.kind === "task" && !teammate.assignment.closed;
1760
+ if (teammate.reportSequenceEnded && !reopen && !retryReleasedWork && !activeTaskHolder) {
1591
1761
  const prior = recordedTerminalReportBody(to);
1592
1762
  if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
1593
- return { ok: false, error: `@${to} already sent a terminal report. Use work action=reopen followed by work action=assign for another Work attempt.` };
1763
+ return { ok: false, error: `@${to} already sent a terminal report. Move the task back with task update status=pending before another attempt.` };
1594
1764
  }
1595
1765
  const priorTerminalReport = teammate.reportSequenceEnded ? recordedTerminalReportBody(to) : undefined;
1596
- const boardParkKey = teammate.currentTaskId ? `${teammate.currentTaskId}:${teammate.spawnId}` : undefined;
1766
+ const taskParkKey = teammate.currentTaskId ? `${teammate.currentTaskId}:${teammate.spawnId}` : undefined;
1597
1767
  if ((reopen || retryReleasedWork) && selectedWorkId && verifyingTasks.has(selectedWorkId)) {
1598
1768
  return { ok: false, error: `Work Item "${selectedWorkId}" is awaiting verification; wait for the gate outcome before reopening.` };
1599
1769
  }
1600
1770
  if (reopen && teammate.assignment && !teammate.assignment.closed && !recoveringUnexpectedExecution) {
1601
- const action = teammate.assignment.kind === "board"
1602
- ? `work action=submit or be released/superseded`
1771
+ const action = teammate.assignment.kind === "task"
1772
+ ? `task complete, or be superseded`
1603
1773
  : "send a terminal report or be explicitly released";
1604
1774
  return { ok: false, error: `@${to} still owns active ${teammate.assignment.kind} assignment "${teammate.assignment.id}". It must ${action} before a direct assignment can open.` };
1605
1775
  }
@@ -1616,49 +1786,54 @@ export function sendLeaderMessage(
1616
1786
  resources,
1617
1787
  };
1618
1788
  if (reclaimingExistingWork) {
1619
- const reclaimed = reclaimDirectWork(
1789
+ const reclaimed = bindTask(
1620
1790
  selectedWorkId ?? "",
1621
1791
  to,
1622
1792
  assignment,
1623
- retryReleasedWork ? "pending" : recoveringUnexpectedExecution ? "claimed" : "completed",
1624
- recoveringUnexpectedExecution,
1793
+ {
1794
+ from: [retryReleasedWork ? "pending" : recoveringUnexpectedExecution ? "in_progress" : "completed"],
1795
+ // An unexpected execution left the previous holder's lease unreturned.
1796
+ // Binding anyway is the point: the holder is gone, so waiting for a
1797
+ // lease nobody will give back would strand the task.
1798
+ allowHeldLease: recoveringUnexpectedExecution,
1799
+ },
1625
1800
  );
1626
- if (!reclaimed.ok) return reclaimed;
1801
+ if (!reclaimed.ok) return { ok: false, error: reclaimed.reason };
1627
1802
  } else {
1628
1803
  const conflict = activeAssignmentConflict(resources, to);
1629
1804
  if (conflict) {
1630
1805
  return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
1631
1806
  }
1632
1807
  if (selectedWorkId) {
1633
- const created = createDirectWork({
1808
+ const bound = createBoundWork({
1634
1809
  id: selectedWorkId,
1635
1810
  subject: messageTitle(message),
1636
1811
  description: message,
1637
1812
  resources,
1638
- workerName: to,
1813
+ holder: to,
1639
1814
  assignment,
1640
1815
  });
1641
- if (!created.ok) return created;
1642
- updateTeammate(to, { workId: selectedWorkId });
1816
+ if (!bound.ok) return { ok: false, error: bound.reason };
1817
+ updateAgent(to, { workId: selectedWorkId });
1643
1818
  } else {
1644
- assignTeammate(to, assignment, undefined);
1819
+ assignAgent(to, assignment, undefined);
1645
1820
  }
1646
1821
  }
1647
- updateTeammate(to, { reportSequenceEnded: false });
1822
+ updateAgent(to, { reportSequenceEnded: false });
1648
1823
  }
1649
1824
  // A successfully accepted leader message is the explicit direction that
1650
- // unblocks a twice-inconclusive board holding; rejected reopen does nothing.
1651
- if (boardParkKey) {
1652
- inconclusiveParks.delete(boardParkKey);
1653
- verifyFailureParks.delete(boardParkKey);
1654
- unexpectedExecutionParks.delete(boardParkKey);
1825
+ // unblocks a twice-inconclusive tasks holding; rejected reopen does nothing.
1826
+ if (taskParkKey) {
1827
+ inconclusiveParks.delete(taskParkKey);
1828
+ verifyFailureParks.delete(taskParkKey);
1829
+ unexpectedExecutionParks.delete(taskParkKey);
1655
1830
  }
1656
- if (activeBoardHolder) updateTeammate(to, { reportSequenceEnded: false });
1831
+ if (activeTaskHolder) updateAgent(to, { reportSequenceEnded: false });
1657
1832
  const envelope: InboxMessage = {
1658
1833
  id: randomUUID(),
1659
1834
  from: "leader",
1660
1835
  subject: messageTitle(message),
1661
- body: `${opensDirectAssignment || (activeBoardHolder && priorTerminalReport !== undefined) ? `[agent-teams-assignment:${teammate.assignment?.id ?? "none"}]\n` : ""}${message}`,
1836
+ body: `${opensDirectAssignment || (activeTaskHolder && priorTerminalReport !== undefined) ? `[agent-teams-assignment:${teammate.assignment?.id ?? "none"}]\n` : ""}${message}`,
1662
1837
  timestamp: Date.now(),
1663
1838
  toSpawnId: teammate.spawnId,
1664
1839
  };
@@ -1666,22 +1841,22 @@ export function sendLeaderMessage(
1666
1841
  if (opensDirectAssignment) {
1667
1842
  void deliverFreshAssignment(teammate.name, formatDelivery([envelope])).then((sent) => {
1668
1843
  if (sent) {
1669
- updateTeammate(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
1844
+ updateAgent(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
1670
1845
  return;
1671
1846
  }
1672
- const current = getTeammate(to);
1847
+ const current = getAgent(to);
1673
1848
  const currentTaskId = current?.currentTaskId;
1674
1849
  if (current?.assignment?.id === teammate.assignment?.id && currentTaskId) {
1675
- releaseTask(currentTaskId, "Pi session reset failed before the new Assignment Attempt started.", false);
1850
+ revertUndeliveredTake(currentTaskId, to, "Pi session reset failed before the new Assignment Attempt started.");
1676
1851
  } else if (current?.assignment?.id === teammate.assignment?.id) {
1677
- assignTeammate(to, undefined, undefined);
1852
+ assignAgent(to, undefined, undefined);
1678
1853
  }
1679
1854
  deliverDiagnostic(to, "Pi session reset failed; the new Assignment Attempt was not delivered.");
1680
1855
  });
1681
1856
  return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
1682
1857
  }
1683
- const steered = sendWorkerSteer(teammate.name, formatDelivery([envelope]));
1684
- if (steered) updateTeammate(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
1858
+ const steered = sendAgentSteer(teammate.name, formatDelivery([envelope]));
1859
+ if (steered) updateAgent(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
1685
1860
  if (steered) return { ok: true, outcome: "steered", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
1686
1861
  const queued = pendingDeliveries.get(teammate.name) ?? [];
1687
1862
  queued.unshift(envelope);
@@ -1722,13 +1897,13 @@ export function deliverFeedback(to: string, subject: string, body: string): void
1722
1897
 
1723
1898
  /** Apply claim markers, then submission markers (with verify gating). */
1724
1899
  export function processTaskIntents(): void {
1725
- const claims = claimsDir(boardDirectory());
1726
- const submissions = submissionsDir(boardDirectory());
1900
+ const claims = claimsDir(taskDirectory());
1901
+ const submissions = submissionsDir(taskDirectory());
1727
1902
  let guard = 0;
1728
1903
  while (guard++ < 64) {
1729
1904
  const { intent, diagnostic } = takeTaskIntent(claims);
1730
1905
  if (diagnostic) {
1731
- deliverToLeader({ from: "task-board", subject: "Task intent diagnostic", body: diagnostic });
1906
+ deliverToLeader({ from: "tasks", subject: "Task intent diagnostic", body: diagnostic });
1732
1907
  continue;
1733
1908
  }
1734
1909
  if (!intent) break;
@@ -1738,7 +1913,7 @@ export function processTaskIntents(): void {
1738
1913
  while (guard++ < 64) {
1739
1914
  const { intent, diagnostic } = takeTaskIntent(submissions);
1740
1915
  if (diagnostic) {
1741
- deliverToLeader({ from: "task-board", subject: "Task intent diagnostic", body: diagnostic });
1916
+ deliverToLeader({ from: "tasks", subject: "Task intent diagnostic", body: diagnostic });
1742
1917
  continue;
1743
1918
  }
1744
1919
  if (!intent) break;
@@ -1754,64 +1929,62 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
1754
1929
  clearInconclusiveForHolding(intent.taskId, intent.spawnId);
1755
1930
  // A new holding must not inherit an in-flight gate from a previous one.
1756
1931
  invalidateVerifyGate(intent.taskId);
1757
- updateTeammate(intent.worker, { currentTaskId: intent.taskId });
1758
- const teammate = getTeammate(intent.worker);
1932
+ updateAgent(intent.participant, { currentTaskId: intent.taskId });
1933
+ const teammate = getAgent(intent.participant);
1759
1934
  const task = getState().tasks[intent.taskId];
1760
1935
  if (!teammate?.assignment || !task) return;
1761
1936
  const kickoff = [
1762
1937
  `Claim accepted for Work Item ${task.id}: ${task.subject}`,
1763
1938
  task.description,
1764
- "Complete this assignment with work action=submit.",
1939
+ "Complete this task with task action=complete outcome=success.",
1765
1940
  ].filter(Boolean).join("\n\n");
1766
1941
  const assignmentId = teammate.assignment.id;
1767
1942
  flushSnapshots();
1768
1943
  void deliverFreshAssignment(teammate.name, buildFreshAssignmentPrompt(teammate, assignmentId, kickoff)).then((sent) => {
1769
1944
  if (sent) return;
1770
- if (getTeammate(teammate.name)?.assignment?.id === assignmentId) {
1771
- releaseTask(task.id, "Pi session reset failed before the claimed Assignment Attempt started.", false);
1945
+ if (getAgent(teammate.name)?.assignment?.id === assignmentId) {
1946
+ revertUndeliveredTake(task.id, teammate.name, "Pi session reset failed before the claimed Assignment Attempt started.");
1772
1947
  }
1773
1948
  deliverDiagnostic(teammate.name, "Pi session reset failed; the claimed Assignment Attempt was not delivered.");
1774
1949
  });
1775
1950
  return;
1776
1951
  }
1777
- deliverFeedback(intent.worker, "Claim rejected", outcome.reason ?? "The task is no longer available.");
1952
+ deliverFeedback(intent.participant, "Claim rejected", outcome.reason ?? "The task is no longer available.");
1778
1953
  }
1779
1954
 
1780
1955
  function applySubmissionMarker(intent: import("./types").TaskIntent): void {
1781
1956
  if (intent.status !== "completed" && intent.status !== "failed") {
1782
- deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" has an invalid submission status.`);
1957
+ deliverFeedback(intent.participant, "Submission rejected", `Task "${intent.taskId}" has an invalid submission status.`);
1783
1958
  return;
1784
1959
  }
1785
1960
  const sender = findLivingTeammate(intent);
1786
1961
  if (!sender || !sender.assignment || intent.assignmentId !== sender.assignment.id) return;
1787
1962
  const task = getState().tasks[intent.taskId];
1788
- const pending = pendingSubmissions.get(intent.worker);
1789
- if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId) return;
1790
- if (!task || (task.status !== "claimed" && task.status !== "superseded") || task.claimedBy !== intent.worker) {
1791
- deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
1963
+ const pending = pendingSubmissions.get(intent.participant);
1964
+ if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId && !pending.rejected) return;
1965
+ if (!task || (task.status !== "in_progress" && !isTaskReplaced(task)) || task.claimedBy !== intent.participant) {
1966
+ deliverFeedback(intent.participant, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
1792
1967
  return;
1793
1968
  }
1794
- if (task.status === "superseded") {
1969
+ if (isTaskReplaced(task)) {
1795
1970
  if (intent.status !== "failed") {
1796
- deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}". Stop work and submit failed to acknowledge cancellation.`);
1971
+ deliverFeedback(intent.participant, "Submission rejected", `Task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}". Stop work and submit failed to acknowledge cancellation.`);
1797
1972
  return;
1798
1973
  }
1799
1974
  if (sender.sequenceEnded === false) {
1800
- pendingSubmissions.set(intent.worker, { ...intent, assignmentId: sender.assignment.id, verify: undefined });
1975
+ pendingSubmissions.set(intent.participant, { ...intent, assignmentId: sender.assignment.id, verify: undefined });
1801
1976
  } else {
1802
1977
  releaseSupersededHolding(intent);
1803
1978
  }
1804
1979
  return;
1805
1980
  }
1806
1981
  const parkKey = `${intent.taskId}:${intent.spawnId}`;
1807
- if (intent.status === "completed" && (inconclusiveParks.has(parkKey) || verifyFailureParks.has(parkKey) || unexpectedExecutionParks.has(parkKey))) {
1982
+ if (intent.status === "completed" && (verifyFailureParks.has(parkKey) || unexpectedExecutionParks.has(parkKey))) {
1808
1983
  const reason = unexpectedExecutionParks.has(parkKey)
1809
1984
  ? "unexpected execution during verification"
1810
- : inconclusiveParks.has(parkKey)
1811
- ? "two inconclusive reviews"
1812
- : "two explicit verification failures";
1985
+ : "two explicit verification failures";
1813
1986
  deliverFeedback(
1814
- intent.worker,
1987
+ intent.participant,
1815
1988
  "Submission rejected while verification is parked",
1816
1989
  `Task "${intent.taskId}" has ${reason}. Wait for explicit Work release and reassignment before submitting another completed outcome.`,
1817
1990
  );
@@ -1819,16 +1992,16 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
1819
1992
  }
1820
1993
  if (intent.status === "completed" && verifyingTasks.has(intent.taskId)) {
1821
1994
  deliverFeedback(
1822
- intent.worker,
1995
+ intent.participant,
1823
1996
  "Submission rejected while verification is running",
1824
1997
  `Task "${intent.taskId}" already has a completion review or verdict clarification in flight. Wait for it to pass, fail, or become inconclusive before submitting another completed outcome.`,
1825
1998
  );
1826
1999
  return;
1827
2000
  }
1828
- const verify = intent.status === "completed" ? task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify : undefined;
2001
+ const verify = intent.status === "completed" ? task.verify ?? resolveAgent(sender.role, leaderCwd)?.verify : undefined;
1829
2002
  const assignmentId = sender.assignment?.id;
1830
2003
  if (!assignmentId) return;
1831
- if (sender.sequenceEnded === false) pendingSubmissions.set(intent.worker, { ...intent, assignmentId, verify });
2004
+ if (sender.sequenceEnded === false) pendingSubmissions.set(intent.participant, { ...intent, assignmentId, verify });
1832
2005
  else beginVerifyOrComplete(intent, verify);
1833
2006
  }
1834
2007
 
@@ -1844,7 +2017,7 @@ export function reactToVerifyFailure(
1844
2017
  }
1845
2018
 
1846
2019
  function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: string): boolean {
1847
- const teammate = getTeammate(intent.worker);
2020
+ const teammate = getAgent(intent.participant);
1848
2021
  const task = getState().tasks[intent.taskId];
1849
2022
  if (!teammate || teammate.spawnId !== intent.spawnId || teammate.assignment?.kind !== "direct" || !task) return false;
1850
2023
  const assignment = {
@@ -1852,8 +2025,8 @@ function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: s
1852
2025
  kind: "direct" as const,
1853
2026
  resources: task.resources,
1854
2027
  };
1855
- assignTeammate(intent.worker, assignment, intent.taskId);
1856
- updateTeammate(intent.worker, { reportSequenceEnded: false });
2028
+ assignAgent(intent.participant, assignment, intent.taskId);
2029
+ updateAgent(intent.participant, { reportSequenceEnded: false });
1857
2030
  flushSnapshots();
1858
2031
  const revisionBrief = [
1859
2032
  `The completion gate failed for Work Item ${intent.taskId}: ${task.subject}. Revise the result and return a new final answer.`,
@@ -1868,8 +2041,8 @@ function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: s
1868
2041
  );
1869
2042
  void deliverFreshAssignment(teammate.name, prompt).then((sent) => {
1870
2043
  if (sent) return;
1871
- if (getTeammate(teammate.name)?.assignment?.id === assignment.id) {
1872
- releaseTask(intent.taskId, "Pi session reset failed before the verification revision started.", false);
2044
+ if (getAgent(teammate.name)?.assignment?.id === assignment.id) {
2045
+ revertUndeliveredTake(intent.taskId, teammate.name, "Pi session reset failed before the verification revision started.");
1873
2046
  }
1874
2047
  deliverDiagnostic(teammate.name, "Pi session reset failed; the verification revision was not delivered.");
1875
2048
  });
@@ -1879,7 +2052,7 @@ function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: s
1879
2052
  function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
1880
2053
  const task = getState().tasks[intent.taskId];
1881
2054
  if (!task) return;
1882
- if (task.status === "superseded") {
2055
+ if (isTaskReplaced(task)) {
1883
2056
  if (intent.status === "failed") releaseSupersededHolding(intent);
1884
2057
  return;
1885
2058
  }
@@ -1896,14 +2069,14 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
1896
2069
  // must not let the stale result complete the new holding.
1897
2070
  const submissionId = `${intent.taskId}:${intent.spawnId}:${randomUUID()}`;
1898
2071
  const token = randomUUID();
1899
- const assignmentId = getTeammate(intent.worker)?.assignment?.id;
2072
+ const assignmentId = getAgent(intent.participant)?.assignment?.id;
1900
2073
  if (!assignmentId) return;
1901
- verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, assignmentId, submissionId, token });
2074
+ verifyingTasks.set(intent.taskId, { worker: intent.participant, spawnId: intent.spawnId, assignmentId, submissionId, token });
1902
2075
  const input: VerifyReviewInput = {
1903
2076
  verify,
1904
2077
  taskSubject: task.subject,
1905
2078
  workerResult: intent.result ?? "",
1906
- cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
2079
+ cwd: leaderCwd,
1907
2080
  };
1908
2081
  runVerifyGate(intent, task.subject, submissionId, token, input);
1909
2082
  }
@@ -1922,17 +2095,17 @@ function resolveGateOutcome(
1922
2095
  const registered = verifyAborts.get(intent.taskId);
1923
2096
  if (registered?.submissionId === submissionId) verifyAborts.delete(intent.taskId);
1924
2097
  const current = getState().tasks[intent.taskId];
1925
- const holder = getTeammate(intent.worker);
1926
- const stillHolds = current?.status === "claimed"
1927
- && current.claimedBy === intent.worker
2098
+ const holder = getAgent(intent.participant);
2099
+ const stillHolds = current?.status === "in_progress"
2100
+ && current.claimedBy === intent.participant
1928
2101
  && holder?.spawnId === intent.spawnId
1929
2102
  && holder.assignment?.id === active.assignmentId
1930
- && !pendingShutdowns.has(intent.worker);
2103
+ && !pendingShutdowns.has(intent.participant);
1931
2104
  if (!stillHolds) return;
1932
2105
  if (outcome.kind === "pass") {
1933
2106
  inconclusiveVerifications.delete(submissionId);
1934
2107
  archiveDeferredDeliveries(holder);
1935
- finishCompletion(intent);
2108
+ finishCompletion(intent, "pass");
1936
2109
  } else if (outcome.kind === "inconclusive") {
1937
2110
  const detail = outcome.detail ?? "(reviewer omitted a machine-readable verdict)";
1938
2111
  requestVerifyVerdict(intent, subject, submissionId, detail);
@@ -1943,36 +2116,54 @@ function resolveGateOutcome(
1943
2116
  verifyFailures.set(key, reaction);
1944
2117
  const detail = outcome.detail ?? "(no review output)";
1945
2118
  if (reaction.count >= VERIFY_FAILURE_ESCALATE_AFTER) {
1946
- // An unfixable gate parks the task with its holder instead of
1947
- // looping: no further resubmit invitations, one leader escalation.
1948
- verifyFailureParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
2119
+ // An unfixable gate stops the loop. It used to park the task with its
2120
+ // holder, which left a runtime state only a leader-side release action could
2121
+ // clear, and that action no longer exists.
2122
+ //
2123
+ // So it becomes what it factually is: the attempt ran and did not deliver
2124
+ // an accepted result. The task returns to pending under a recovery hold, and
2125
+ // whoever retries has to say what changed — which is the only thing that
2126
+ // could possibly make a third attempt worth running.
2127
+ const parked = completeTaskWithOutcome(
2128
+ intent.taskId,
2129
+ intent.participant,
2130
+ "failed",
2131
+ `The completion gate failed ${reaction.count} consecutive times: ${detail}`,
2132
+ );
2133
+ if (parked.ok) {
2134
+ verifyFailureParks.delete(`${intent.taskId}:${intent.spawnId}`);
2135
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
2136
+ freeAgentFromTask(intent.participant, intent.taskId);
2137
+ rearmTaskNotice(intent.taskId);
2138
+ publishStateSnapshot();
2139
+ }
1949
2140
  if (reaction.escalateToLeader) {
1950
2141
  notifyTaskOutcome(subject, `verify failed ${reaction.count} times for ${intent.taskId}: manual attention needed`, detail);
1951
- // The holder is parked and will not narrate further; the parked task
1952
- // reaches the leader through the delivery channel itself.
2142
+ // The holder is out of the task, so the leader is the only route left; the
2143
+ // task itself is now pending and will not be retried unattended.
1953
2144
  sendUpdate({
1954
- teammate: "task-board",
2145
+ agent: "tasks",
1955
2146
  origin: "harness",
1956
2147
  harnessEvent: { type: "verify-escalation", subject: `Verify gate failed · ${subject}` },
1957
- body: `Verify gate for "${subject}" (${intent.taskId}) failed ${reaction.count} consecutive times.\n\n${detail}\n\nThe task stays claimed by @${intent.worker}; decide how to proceed.`,
2148
+ body: `Verify gate for "${subject}" (${intent.taskId}) failed ${reaction.count} consecutive times.\n\n${detail}\n\nThe attempt is recorded as not delivered and the task is pending under a recovery hold. It will not be retried until somebody takes it with a stated reason.`,
1958
2149
  finished: false,
1959
2150
  });
1960
2151
  }
1961
2152
  deliverFeedback(
1962
- intent.worker,
2153
+ intent.participant,
1963
2154
  `Verify still failing for ${intent.taskId}`,
1964
- [`The completion gate for "${subject}" failed again (${reaction.count} consecutive failures).`, detail, "The task stays claimed by you. Do not resubmit or reclaim it; the leader has been notified and will decide next steps."].join("\n"),
2155
+ [`The completion gate for "${subject}" failed again (${reaction.count} consecutive failures).`, detail, "This attempt is recorded as not delivered, so the task is pending under a recovery hold. Retrying requires a stated reason describing what changed; ordinary messages do not authorize another submission."].join("\n"),
1965
2156
  );
1966
2157
  } else {
1967
2158
  const directRevision = authorizeDirectRevision(intent, detail);
1968
2159
  if (!directRevision) {
1969
2160
  deliverFeedback(
1970
- intent.worker,
2161
+ intent.participant,
1971
2162
  `Verify failed for ${intent.taskId}`,
1972
2163
  [
1973
2164
  `The completion gate for "${subject}" failed.`,
1974
2165
  detail,
1975
- "Fix the issues and resubmit with work action=submit.",
2166
+ "Fix the issues and redeliver with task action=complete outcome=success.",
1976
2167
  ].join("\n"),
1977
2168
  );
1978
2169
  }
@@ -1995,30 +2186,52 @@ function requestVerifyVerdict(
1995
2186
  const task = getState().tasks[intent.taskId];
1996
2187
  if (!task) return;
1997
2188
  const token = randomUUID();
1998
- const assignmentId = getTeammate(intent.worker)?.assignment?.id;
2189
+ const assignmentId = getAgent(intent.participant)?.assignment?.id;
1999
2190
  if (!assignmentId) return;
2000
- verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, assignmentId, submissionId, token });
2191
+ verifyingTasks.set(intent.taskId, { worker: intent.participant, spawnId: intent.spawnId, assignmentId, submissionId, token });
2001
2192
  const input: VerifyReviewInput = {
2002
- verify: `${task.verify ?? resolveAgent(getTeammate(intent.worker)?.agent ?? "", leaderCwd)?.verify ?? ""}\n\nThe previous review was inconclusive:\n${detail}\n\nReply with exactly one line: ${VERIFY_VERDICT_PASS} or ${VERIFY_VERDICT_FAIL} - <reasons>.`,
2193
+ verify: `${task.verify ?? resolveAgent(getAgent(intent.participant)?.role ?? "", leaderCwd)?.verify ?? ""}\n\nThe previous review was inconclusive:\n${detail}\n\nReply with exactly one line: ${VERIFY_VERDICT_PASS} or ${VERIFY_VERDICT_FAIL} - <reasons>.`,
2003
2194
  taskSubject: task.subject,
2004
2195
  workerResult: intent.result ?? "",
2005
- cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
2196
+ cwd: leaderCwd,
2006
2197
  };
2007
2198
  runVerifyGate(intent, subject, submissionId, token, input);
2008
2199
  return;
2009
2200
  }
2010
- inconclusiveParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
2011
- notifyTaskOutcome(subject, `verify inconclusive for ${intent.taskId}: manual verdict needed`, detail);
2201
+ // The park used to be a distinct state: the task stayed in progress, held, with
2202
+ // the runtime refusing further submissions, and a leader-side action was the only
2203
+ // way out. That action no longer exists, and inventing a runtime state nothing
2204
+ // can clear is worse than using the one that can.
2205
+ //
2206
+ // So an inconclusive verdict is recorded as what it is — the attempt did not
2207
+ // deliver an accepted result — and the task returns to pending under a recovery
2208
+ // hold. Any participant may then take it with a stated reason, and the hold's
2209
+ // purpose is exactly this: one inconclusive reviewer must not be retried
2210
+ // unattended by whichever participant happens to be idle.
2211
+ inconclusiveParks.delete(`${intent.taskId}:${intent.spawnId}`);
2212
+ const parked = completeTaskWithOutcome(
2213
+ intent.taskId,
2214
+ intent.participant,
2215
+ "failed",
2216
+ `Verification was inconclusive twice for ${intent.taskId}: ${detail}`,
2217
+ );
2218
+ if (!parked.ok) return;
2219
+ clearInconclusiveForHolding(intent.taskId, intent.spawnId);
2220
+ invalidateVerifyGate(intent.taskId);
2221
+ freeAgentFromTask(intent.participant, intent.taskId);
2222
+ rearmTaskNotice(intent.taskId);
2223
+ publishStateSnapshot();
2224
+ notifyTaskOutcome(subject, `verify inconclusive for ${intent.taskId}: a stated reason is needed to retry`, detail);
2012
2225
  deliverFeedback(
2013
- intent.worker,
2226
+ intent.participant,
2014
2227
  `Verification inconclusive for ${intent.taskId}`,
2015
- `The reviewer twice omitted a machine-readable verdict. Your task remains claimed without a verify failure. Wait for explicit Work release and reassignment; ordinary messages do not authorize another submission.\n\n${detail}`,
2228
+ `The reviewer twice omitted a machine-readable verdict, so this attempt did not deliver an accepted result. The task is pending under a recovery hold. Take it again with a stated reason, or change the task's verification.\n\n${detail}`,
2016
2229
  );
2017
2230
  sendUpdate({
2018
- teammate: "task-board",
2231
+ agent: "tasks",
2019
2232
  origin: "harness",
2020
2233
  harnessEvent: { type: "verify-inconclusive", subject: `Verify verdict missing · ${subject}` },
2021
- body: `Verification for "${subject}" (${intent.taskId}) was inconclusive twice. It remains claimed by @${intent.worker} without counting as a verify failure.\n\n${detail}`,
2234
+ body: `Verification for "${subject}" (${intent.taskId}) was inconclusive twice, so the attempt is recorded as not delivered and the task is pending under a recovery hold.\n\n${detail}`,
2022
2235
  finished: false,
2023
2236
  });
2024
2237
  }
@@ -2038,41 +2251,58 @@ function clearInconclusiveForHolding(taskId: string, spawnId: string): void {
2038
2251
  verifyFailures.delete(holding);
2039
2252
  }
2040
2253
 
2041
- function finishCompletion(intent: import("./types").TaskIntent): void {
2254
+ function finishCompletion(intent: import("./types").TaskIntent, reviewVerdict?: "pass"): void {
2042
2255
  const task = getState().tasks[intent.taskId];
2043
2256
  if (!task) return;
2044
- const teammate = getTeammate(intent.worker);
2257
+ const teammate = getAgent(intent.participant);
2045
2258
  const assignmentId = teammate?.assignment?.id;
2046
2259
  if (!teammate || !assignmentId) return;
2260
+ // Only the bound independent review supplies a verdict; worker fields are not authority.
2261
+ const completed = completeTaskWithOutcome(intent.taskId, intent.participant, "success", intent.result,
2262
+ reviewVerdict ? { verdict: reviewVerdict } : {});
2263
+ if (!completed.ok) {
2264
+ rejectCompletion(intent, assignmentId, completed.reason);
2265
+ return;
2266
+ }
2047
2267
  retirePendingSubmission(intent);
2048
- const completed = completeTask(intent.taskId, intent.result);
2049
- if (!completed) return;
2050
2268
  clearInconclusiveForHolding(intent.taskId, intent.spawnId);
2051
- freeTeammateFromTask(intent.worker, intent.taskId);
2269
+ freeAgentFromTask(intent.participant, intent.taskId);
2052
2270
  announceWorkOutcome(intent, teammate, assignmentId);
2053
2271
  }
2054
2272
 
2273
+ function rejectCompletion(intent: import("./types").TaskIntent, assignmentId: string, reason: string): void {
2274
+ // Retain the submission freeze, but never retry refused evidence automatically.
2275
+ // A new explicit submission may replace it without releasing the holding.
2276
+ const pending = pendingSubmissions.get(intent.participant);
2277
+ if (pending?.spawnId === intent.spawnId && pending.assignmentId === assignmentId) pending.rejected = true;
2278
+ else pendingSubmissions.set(intent.participant, { ...intent, assignmentId, verify: undefined, rejected: true });
2279
+ deliverFeedback(intent.participant, "Submission rejected", reason);
2280
+ }
2281
+
2055
2282
  function retirePendingSubmission(intent: import("./types").TaskIntent): void {
2056
- const pending = pendingSubmissions.get(intent.worker);
2283
+ const pending = pendingSubmissions.get(intent.participant);
2057
2284
  if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId) {
2058
- pendingSubmissions.delete(intent.worker);
2285
+ pendingSubmissions.delete(intent.participant);
2059
2286
  }
2060
2287
  }
2061
2288
 
2062
2289
  function releaseSupersededHolding(intent: import("./types").TaskIntent): void {
2063
2290
  retirePendingSubmission(intent);
2064
- releaseTask(intent.taskId, intent.result?.trim() || "Superseded task cancellation acknowledged.", false);
2291
+ acknowledgeSupersession(intent.taskId, intent.participant, intent.result?.trim() || "Superseded task cancellation acknowledged.");
2065
2292
  clearInconclusiveForHolding(intent.taskId, intent.spawnId);
2066
2293
  invalidateVerifyGate(intent.taskId);
2067
2294
  }
2068
2295
 
2069
2296
  function finishFailure(intent: import("./types").TaskIntent): void {
2070
- const teammate = getTeammate(intent.worker);
2297
+ const teammate = getAgent(intent.participant);
2071
2298
  const assignmentId = teammate?.assignment?.id;
2072
2299
  if (!teammate || !assignmentId) return;
2300
+ const released = completeTaskWithOutcome(intent.taskId, intent.participant, "failed", intent.result?.trim() || "Agent reported failure.");
2301
+ if (!released.ok) {
2302
+ rejectCompletion(intent, assignmentId, released.reason);
2303
+ return;
2304
+ }
2073
2305
  retirePendingSubmission(intent);
2074
- const released = releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.", true);
2075
- if (!released) return;
2076
2306
  clearInconclusiveForHolding(intent.taskId, intent.spawnId);
2077
2307
  invalidateVerifyGate(intent.taskId);
2078
2308
  // Failure is evidence for deliberate recovery, not a fresh work offer to
@@ -2080,18 +2310,19 @@ function finishFailure(intent: import("./types").TaskIntent): void {
2080
2310
  announceWorkOutcome(intent, teammate, assignmentId);
2081
2311
  }
2082
2312
 
2083
- function announceWorkOutcome(intent: import("./types").TaskIntent, teammate: Teammate, assignmentId: string): void {
2084
- updateTeammate(intent.worker, { reportSequenceEnded: true });
2313
+ function announceWorkOutcome(intent: import("./types").TaskIntent, agent: Agent, assignmentId: string): void {
2314
+ updateAgent(intent.participant, { reportSequenceEnded: true });
2085
2315
  const eventId = randomUUID();
2086
2316
  const status = intent.status === "failed" ? "failed" : "completed";
2087
2317
  const report: LeaderReport = {
2088
- teammate: intent.worker, agent: teammate.agent, spawnId: intent.spawnId,
2318
+ agent: intent.participant, role: agent.role, spawnId: intent.spawnId,
2089
2319
  workId: intent.taskId, assignmentId, status, finished: true,
2090
- origin: "teammate", eventId, timestamp: intent.timestamp,
2320
+ ...(agent.inputId ? { inputId: agent.inputId } : {}),
2321
+ origin: "agent", eventId, timestamp: intent.timestamp,
2091
2322
  body: intent.result || "(no result summary submitted)",
2092
2323
  };
2093
2324
  receiveWorkerMessage({
2094
- id: eventId, type: "message", worker: intent.worker, spawnId: intent.spawnId,
2325
+ id: eventId, type: "message", worker: intent.participant, spawnId: intent.spawnId,
2095
2326
  assignmentId, status, body: report.body, timestamp: report.timestamp,
2096
2327
  });
2097
2328
  sendUpdate(report);
@@ -2106,12 +2337,11 @@ export const VERIFY_VERDICT_FAIL = "VERDICT: FAIL";
2106
2337
  export interface VerifyReviewInput {
2107
2338
  /** The acceptance-gate prompt (task-level or agent-role default). */
2108
2339
  verify: string;
2109
- /** Board subject of the gated task. */
2340
+ /** Tasks subject of the gated task. */
2110
2341
  taskSubject: string;
2111
2342
  /** The claimer's own result summary; evidence, never trusted alone. */
2112
2343
  workerResult: string;
2113
- /** Working directory to review: the holder's worktree root when isolated,
2114
- * else the leader cwd — the reviewer must inspect the claimed work's tree. */
2344
+ /** Original project working tree to review, never the private scratch cwd. */
2115
2345
  cwd: string;
2116
2346
  /** Aborted when the holding's authority is invalidated (release, stop, new
2117
2347
  * claim, supersession, or resumed owner execution), so an orphaned reviewer
@@ -2128,7 +2358,7 @@ export type VerifyReviewOutcome =
2128
2358
  export function buildVerifyReviewPrompt(input: VerifyReviewInput): string {
2129
2359
  const result = input.workerResult.trim().slice(0, 4000);
2130
2360
  return [
2131
- "You are a fresh completion-gate reviewer for one teammate task on a shared board.",
2361
+ "You are a fresh completion-gate reviewer for one participant task in the shared task set.",
2132
2362
  `Task subject: ${input.taskSubject}`,
2133
2363
  result ? `The claimer's result summary:\n${result}` : "The claimer provided no result summary.",
2134
2364
  "Independently verify the work against the acceptance gate below using your tools; do not trust the summary alone. Do not modify any files.",
@@ -2207,21 +2437,21 @@ export function setVerifyGateRunner(runner: ((input: VerifyReviewInput) => Promi
2207
2437
  verifyGateRunner = runner ?? runVerifyReview;
2208
2438
  }
2209
2439
 
2210
- function freeTeammateFromTask(workerName: string, taskId: string): void {
2211
- const teammate = getTeammate(workerName);
2212
- if (teammate?.currentTaskId === taskId) updateTeammate(workerName, { currentTaskId: undefined });
2440
+ function freeAgentFromTask(agentName: string, taskId: string): void {
2441
+ const entry = getAgent(agentName);
2442
+ if (entry?.currentTaskId === taskId) updateAgent(agentName, { currentTaskId: undefined });
2213
2443
  }
2214
2444
 
2215
2445
  function notifyTaskOutcome(subject: string, headline: string, body: string): void {
2216
2446
  deliverToLeader({
2217
- from: "task-board",
2447
+ from: "tasks",
2218
2448
  subject: `Task ${headline}: ${subject}`,
2219
2449
  body: body || "(no result summary submitted)",
2220
2450
  });
2221
2451
  }
2222
2452
 
2223
- function findLivingTeammate(intent: import("./types").TaskIntent): Teammate | undefined {
2224
- const teammate = getTeammate(intent.worker);
2453
+ function findLivingTeammate(intent: import("./types").TaskIntent): Agent | undefined {
2454
+ const teammate = getAgent(intent.participant);
2225
2455
  return teammate && teammate.status !== "stopped" && teammate.spawnId === intent.spawnId ? teammate : undefined;
2226
2456
  }
2227
2457
 
@@ -2236,7 +2466,7 @@ export function freshClaimableTasks<T extends { id: string }>(
2236
2466
  return tasks.filter((task) => !noticed.has(task.id));
2237
2467
  }
2238
2468
 
2239
- /** Retain only noticed ids that are still claimable, so a long board's history
2469
+ /** Retain only noticed ids that are still claimable, so a long tasks's history
2240
2470
  * can never evict a live one and resurrect an old wake-up. */
2241
2471
  export function retainLiveNoticedIds(noticedIds: readonly string[], claimableIds: ReadonlySet<string>): string[] {
2242
2472
  return noticedIds.filter((id) => claimableIds.has(id));
@@ -2244,37 +2474,37 @@ export function retainLiveNoticedIds(noticedIds: readonly string[], claimableIds
2244
2474
 
2245
2475
  function markTasksNoticed(name: string, taskIds: string[]): void {
2246
2476
  if (taskIds.length === 0) return;
2247
- const teammate = getTeammate(name);
2477
+ const teammate = getAgent(name);
2248
2478
  if (!teammate) return;
2249
2479
  const live = retainLiveNoticedIds(teammate.noticedTaskIds ?? [], new Set(claimableTasks().map((task) => task.id)));
2250
2480
  const merged = [...live, ...taskIds];
2251
2481
  while (merged.length > MAX_NOTICED_TASK_IDS) merged.shift();
2252
- updateTeammate(name, { noticedTaskIds: merged });
2482
+ updateAgent(name, { noticedTaskIds: merged });
2253
2483
  }
2254
2484
 
2255
2485
  /** A released task becomes noticeable again for every living teammate. */
2256
2486
  function rearmTaskNotice(taskId: string): void {
2257
- for (const teammate of livingTeammates()) {
2487
+ for (const teammate of livingAgents()) {
2258
2488
  if (teammate.noticedTaskIds?.includes(taskId)) {
2259
- updateTeammate(teammate.name, { noticedTaskIds: teammate.noticedTaskIds.filter((id) => id !== taskId) });
2489
+ updateAgent(teammate.name, { noticedTaskIds: teammate.noticedTaskIds.filter((id) => id !== taskId) });
2260
2490
  }
2261
2491
  }
2262
2492
  }
2263
2493
 
2264
2494
  /**
2265
2495
  * Compose one wake-up prompt per idle teammate from queued inbox deliveries
2266
- * plus a board notice covering only claimable work that teammate has never
2496
+ * plus a task set notice covering only claimable work that teammate has never
2267
2497
  * been shown. Teammates with nothing new are never woken.
2268
2498
  */
2269
2499
  export function wakeIdleTeammates(immediateTaskId?: string): string[] {
2270
2500
  const notified: string[] = [];
2271
- for (const teammate of idleTeammates()) {
2272
- if (pendingShutdowns.has(teammate.name) || verificationFrozen(teammate)) continue;
2501
+ for (const teammate of idleAgents()) {
2502
+ if (pendingShutdowns.has(teammate.name) || pendingResidentInputs.has(teammate.spawnId) || verificationFrozen(teammate)) continue;
2273
2503
  const deliveries = pendingDeliveries.get(teammate.name) ?? [];
2274
- // A terminal direct assignment stays closed until explicit reopen; a board
2275
- // holder must submit rather than drifting into more board work.
2276
- const boardEligible = teammate.assignment === undefined;
2277
- const fresh = boardEligible
2504
+ // A terminal direct assignment stays closed until explicit reopen; a task set
2505
+ // holder must submit rather than drifting into more task work.
2506
+ const taskEligible = teammate.assignment === undefined;
2507
+ const fresh = taskEligible
2278
2508
  ? freshClaimableTasks(teammate.noticedTaskIds, claimableTasks().filter((task) => !activeAssignmentConflict(task.resources, teammate.name)))
2279
2509
  : [];
2280
2510
  const immediateNotice = immediateTaskId !== undefined && fresh.some((task) => task.id === immediateTaskId);
@@ -2287,21 +2517,23 @@ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
2287
2517
  ...fresh.filter((task) => task.id !== immediateTaskId),
2288
2518
  ];
2289
2519
  const noticed = prioritized.slice(0, WAKE_NOTICE_TASK_LIMIT);
2290
- const role = !teammate.assignment && teammate.turns === 0
2291
- ? `=== ROLE PROMPT (${teammate.agent}) ===\n${resolveAgent(teammate.agent, leaderCwd)?.prompt ?? ""}\n\n`
2520
+ const sequence = residentSequences.get(teammate.spawnId);
2521
+ const role = !teammate.assignment && (sequence ? !sequence.roleDelivered : teammate.turns === 0)
2522
+ ? `=== ROLE PROMPT (${teammate.role}) ===\n${sequence?.rolePrompt ?? resolveAgent(teammate.role, leaderCwd)?.prompt ?? ""}\n\n`
2292
2523
  : "";
2293
2524
  const prompt = `${teammate.assignment ? `[agent-teams-assignment:${teammate.assignment.id}]\n` : ""}${role}${buildWakePrompt(deliveries, noticed, dueNotice)}`;
2294
2525
  if (!deliverPrompt(teammate.name, prompt)) continue;
2526
+ if (sequence) sequence.roleDelivered = true;
2295
2527
  for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
2296
2528
  notified.push(teammate.name);
2297
2529
  pendingDeliveries.delete(teammate.name);
2298
2530
  if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
2299
2531
  // A delivered prompt is fresh activity: restart the silence clock so a
2300
2532
  // long-idle teammate is never insta-flagged as stalled on wake.
2301
- updateTeammate(teammate.name, {
2533
+ updateAgent(teammate.name, {
2302
2534
  status: "working",
2303
2535
  sequenceEnded: false,
2304
- // A generic peer/harness/board wake-up never opens a terminal direct
2536
+ // A generic peer/harness/tasks wake-up never opens a terminal direct
2305
2537
  // assignment. Only an explicit Work assignment owns that transition.
2306
2538
  ...(teammate.assignment?.kind === "direct" && teammate.assignment.closed
2307
2539
  ? {}
@@ -2313,7 +2545,7 @@ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
2313
2545
  return notified;
2314
2546
  }
2315
2547
 
2316
- function noticeDue(teammate: Teammate): boolean {
2548
+ function noticeDue(teammate: Agent): boolean {
2317
2549
  return teammate.lastNoticeAt === undefined || Date.now() - teammate.lastNoticeAt >= NOTICE_PACE_MS;
2318
2550
  }
2319
2551
 
@@ -2328,7 +2560,7 @@ export function buildWakePrompt(
2328
2560
  }
2329
2561
  if (includeNotice && claimable.length > 0) {
2330
2562
  const listed = claimable.slice(0, 10).map((task) => `${task.id} (${task.subject})`).join(", ");
2331
- sections.push(`=== BOARD NOTICE ===\nUnclaimed tasks: ${listed}\nUse work action=list for details and work action=claim to take one if appropriate for your role.`);
2563
+ sections.push(`=== TASK NOTICE ===\nUnclaimed tasks: ${listed}\nUse task action=list for details and task update status=in_progress to take one if it suits your role.`);
2332
2564
  }
2333
2565
  if (sections.length === 0) return "";
2334
2566
  const leader = deliveries.find((message) => message.from === "leader" && message.body.startsWith("[agent-teams-assignment:"));
@@ -2342,39 +2574,37 @@ function formatDelivery(messages: InboxMessage[]): string {
2342
2574
  return `${marker ? `${marker}\n` : ""}${messages.map((message) => `From ${message.from} · ${message.subject}\n${message.body}`).join("\n\n---\n\n")}`;
2343
2575
  }
2344
2576
 
2345
- // ── Board helpers shared with tools.ts ────────────────────────────
2577
+ // ── Tasks helpers shared with tools.ts ────────────────────────────
2578
+
2579
+ /** A refusal that reached the coordinator's own task surface keeps the store's
2580
+ * shape — code, detail, reason — rather than being flattened to a sentence, so
2581
+ * a coordinator can still branch on why a creation was refused. It is re-exported
2582
+ * because `createCoordinatedTask` is the coordinator's task entry point. */
2583
+ export type { TaskRefusalResult };
2346
2584
 
2347
- export interface BoardTaskCreationResult {
2585
+ export interface TaskCreationResult {
2348
2586
  ok: true;
2349
2587
  id: string;
2350
2588
  notifiedTeammates: string[];
2351
- livingTeammates: number;
2589
+ livingAgents: number;
2352
2590
  claimable: boolean;
2353
2591
  resourceBlocked: boolean;
2354
2592
  supersededTaskIds: string[];
2355
2593
  }
2356
2594
 
2357
- /** Create a task and synchronously offer it to currently-idle teammates.
2595
+ /** Run the coordination side effects of superseding work.
2358
2596
  *
2359
- * The normal poll loop still handles later dependency unlocks and queued mail,
2360
- * but task creation is a user-visible boundary: an idle teammate should not
2361
- * have to wait for a future timer tick before it can see newly-created work.
2597
+ * Split out because the tasks transition itself belongs to @fradser/pi-tasks
2598
+ * now, and this package hears about it on `pi-tasks:task-created` rather than
2599
+ * performing it. A superseded holder keeps its assignment and resource lease
2600
+ * until it acknowledges cancellation, so its feedback has to be routable before
2601
+ * the replacement is announced.
2602
+ *
2603
+ * @returns the ids that were superseded, for the caller's report.
2362
2604
  */
2363
- export function createBoardTask(input: {
2364
- subject: string;
2365
- description?: string;
2366
- dependsOn?: string[];
2367
- verify?: string;
2368
- resources?: string[];
2369
- supersedes?: string[];
2370
- }): BoardTaskCreationResult | { ok: false; error: string } {
2371
- const created = createTask(input);
2372
- if (!created.ok) return created;
2373
- for (const task of created.superseded) {
2374
- // A superseded holder intentionally retains its assignment/resource until
2375
- // it submits failed or stops; cancellation is lifecycle control, so it
2376
- // must unpark the holder before its feedback can be routed.
2377
- const holder = task.claimedBy ? getTeammate(task.claimedBy) : undefined;
2605
+ export function settleSupersededWork(replaced: readonly Task[], replacementId: string): string[] {
2606
+ for (const task of replaced) {
2607
+ const holder = task.claimedBy ? getAgent(task.claimedBy) : undefined;
2378
2608
  invalidateVerifyGate(task.id);
2379
2609
  if (holder) unexpectedExecutionParks.delete(`${task.id}:${holder.spawnId}`);
2380
2610
  for (const [name, pending] of pendingSubmissions) {
@@ -2388,7 +2618,7 @@ export function createBoardTask(input: {
2388
2618
  deliverFeedback(
2389
2619
  holder.name,
2390
2620
  `Task superseded: ${task.id}`,
2391
- `Work Item "${task.id}" was superseded by "${created.task.id}". Stop work immediately and use work action=submit with outcome="failed" to acknowledge cancellation and release its resources.`,
2621
+ `Task "${task.id}" was superseded by "${replacementId}". Stop work immediately and acknowledge the cancellation with task complete outcome="failed" so its resources are released.`,
2392
2622
  );
2393
2623
  }
2394
2624
  for (const key of [...verifyFailures.keys()]) {
@@ -2399,6 +2629,67 @@ export function createBoardTask(input: {
2399
2629
  }
2400
2630
  rearmTaskNotice(task.id);
2401
2631
  }
2632
+ return replaced.map((task) => task.id);
2633
+ }
2634
+
2635
+ /** A task was created by @fradser/pi-tasks' own `task` tool.
2636
+ *
2637
+ * This is the whole of the leader's response to a task set transition: settle what
2638
+ * the supersession displaced, offer the new task to idle residents, and refresh
2639
+ * what the console shows. It arrives as an event rather than a call so
2640
+ * `@fradser/pi-tasks` never has to know a resident exists, and so a standalone
2641
+ * tasks with nobody listening still works.
2642
+ */
2643
+ export function onTaskCreated(input: { id: string; replaced?: string[] }): void {
2644
+ const replaced = (input.replaced ?? []).map((id) => getState().tasks[id]).filter(Boolean) as Task[];
2645
+ if (replaced.length > 0) settleSupersededWork(replaced, input.id);
2646
+ publishStateSnapshot();
2647
+ // Creation is a user-visible boundary: an idle resident should not have to
2648
+ // wait for a later poll tick to see new work.
2649
+ wakeIdleTeammates(input.id);
2650
+ publishStateSnapshot();
2651
+ notifyChange();
2652
+ }
2653
+
2654
+ /** A task was taken.
2655
+ *
2656
+ * This is where a re-take invalidates a verification gate left over from the
2657
+ * previous attempt. That work used to sit in the leader-side release action, which
2658
+ * no longer exists: a task returns to pending because a participant failed it,
2659
+ * because the runtime reverted a half-finished start, or because its holder
2660
+ * exited. Whichever route brought it back, a reviewer still judging the old
2661
+ * candidate must not be able to accept the new one.
2662
+ */
2663
+ export function onTaskTaken(input: { id: string }): void {
2664
+ invalidateVerifyGate(input.id);
2665
+ rearmTaskNotice(input.id);
2666
+ onTaskChanged();
2667
+ }
2668
+
2669
+ /** Any other tasks transition: no resident needs waking, but the snapshot and the
2670
+ * console both show the tasks, so both are refreshed. */
2671
+ export function onTaskChanged(): void {
2672
+ publishStateSnapshot();
2673
+ notifyChange();
2674
+ }
2675
+
2676
+ /** Create a task and synchronously offer it to currently-idle agents.
2677
+ *
2678
+ * The normal poll loop still handles later dependency unlocks and queued mail,
2679
+ * but task creation is a user-visible boundary: an idle teammate should not
2680
+ * have to wait for a future timer tick before it can see newly-created work.
2681
+ */
2682
+ export function createCoordinatedTask(input: {
2683
+ subject: string;
2684
+ description?: string;
2685
+ dependsOn?: string[];
2686
+ verify?: string;
2687
+ resources?: string[];
2688
+ supersedes?: string[];
2689
+ }): TaskCreationResult | TaskRefusalResult {
2690
+ const created = createTask(input);
2691
+ if (!created.ok) return created;
2692
+ const supersededTaskIds = settleSupersededWork(created.superseded, created.task.id);
2402
2693
  publishStateSnapshot();
2403
2694
  const resourceBlocked = activeAssignmentConflict(created.task.resources) !== undefined;
2404
2695
  const notifiedTeammates = wakeIdleTeammates(created.task.id);
@@ -2408,26 +2699,26 @@ export function createBoardTask(input: {
2408
2699
  ok: true,
2409
2700
  id: created.task.id,
2410
2701
  notifiedTeammates,
2411
- livingTeammates: livingTeammates().length,
2702
+ livingAgents: livingAgents().length,
2412
2703
  claimable: claimableTasks().some((task) => task.id === created.task.id) && !resourceBlocked,
2413
2704
  resourceBlocked,
2414
- supersededTaskIds: created.superseded.map((task) => task.id),
2705
+ supersededTaskIds,
2415
2706
  };
2416
2707
  }
2417
2708
 
2418
2709
  /** Worker-side submission through an exclusive-create marker file. */
2419
2710
  export function attemptSubmission(
2420
- workerName: string,
2711
+ agentName: string,
2421
2712
  spawnId: string,
2422
2713
  taskId: string,
2423
2714
  status: "completed" | "failed",
2424
2715
  result?: string,
2425
2716
  ): boolean {
2426
- return createTaskIntent(submissionsDir(boardDirectory()), taskId, {
2717
+ return createTaskIntent(submissionsDir(taskDirectory()), taskId, {
2427
2718
  taskId,
2428
- worker: workerName,
2719
+ participant: agentName,
2429
2720
  spawnId,
2430
- assignmentId: getTeammate(workerName)?.assignment?.id,
2721
+ assignmentId: getAgent(agentName)?.assignment?.id,
2431
2722
  status,
2432
2723
  result,
2433
2724
  timestamp: Date.now(),