@nanmicoder/dsh-agent-teams 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.js CHANGED
@@ -12,10 +12,11 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
12
12
  import { defineTool } from '@deepseek-ai/dsh-tools';
13
13
  import { join } from 'node:path';
14
14
  import { appendTeamEvent, captainSessionOf } from "./events.js";
15
- import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, removeTeamDir, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, normalizeBlankOptionalTaskFields, taskKindOf, } from "./state.js";
16
- import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, steerCaptainReport, validateMemberLlmSelections, } from "./members.js";
15
+ import { amendTaskContract, acknowledgeMailbox, markMailboxDelivered, discardMailboxMessages, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, normalizeBlankOptionalTaskFields, taskKindOf, } from "./state.js";
16
+ import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, installMemberDelegationGuard, memberActivity, resolveMemberLlmSelection, spawnMember, steerCaptainReport, validateMemberLlmSelections, } from "./members.js";
17
17
  import { TERMINAL_TASK_STATUSES } from "./types.js";
18
- import { installTeamScheduler } from "./scheduler.js";
18
+ import { collectCompletedDependencyOutputs, formatDependencyOutputs, installTeamScheduler } from "./scheduler.js";
19
+ import { installMailboxAdmission, mailboxPrompt } from "./mailbox.js";
19
20
  import { resolveTeamProfile } from "./profiles.js";
20
21
  export { steerCaptainReport } from "./members.js";
21
22
  /** The caller agent, or a loud failure for non-agent callers. */
@@ -158,32 +159,22 @@ function memberOpenTask(team, memberName, exceptTaskId) {
158
159
  && task.assignee === memberName
159
160
  && (task.status === 'claimed' || task.status === 'in_progress'));
160
161
  }
162
+ function taskDetails(team, task) {
163
+ return [task.subject, task.description ?? '',
164
+ `Kind: ${task.kind ?? 'work'}`,
165
+ `Objective: ${task.objective ?? ''}`,
166
+ `In scope: ${(task.inScope ?? []).join(', ')}; Out of scope: ${(task.outOfScope ?? []).join(', ')}`,
167
+ `Acceptance: ${(task.acceptance ?? []).join('; ')}`,
168
+ `Verify: ${(task.verify ?? []).join('; ')}`,
169
+ `Dependency results:\n${formatDependencyOutputs(collectCompletedDependencyOutputs(team.tasks, task.id))}`,
170
+ ].join('\n');
171
+ }
161
172
  /** Captain work is immediate, not a durable scheduler lane: allow one unfinished takeover at a time. */
162
173
  function captainOpenTask(team, exceptTaskId) {
163
174
  return team.tasks.find(task => task.id !== exceptTaskId
164
175
  && task.assignee === CAPTAIN_KEY
165
176
  && !TERMINAL_TASK_STATUSES.includes(task.status));
166
177
  }
167
- async function waitForMemberIdle(ctx, member, signal) {
168
- if (member.id === '')
169
- return;
170
- const live = ctx.agents.get(member.id);
171
- if (live === undefined)
172
- return;
173
- if (signal.aborted)
174
- throw signal.reason;
175
- let onAbort;
176
- const aborted = new Promise((_resolve, reject) => {
177
- onAbort = () => reject(signal.reason ?? new Error('task reassignment was cancelled'));
178
- signal.addEventListener('abort', onAbort, { once: true });
179
- });
180
- try {
181
- await Promise.race([live.whenIdle(), aborted]);
182
- }
183
- finally {
184
- signal.removeEventListener('abort', onAbort);
185
- }
186
- }
187
178
  /** Stop every currently-resident member activation for one halted team.
188
179
  *
189
180
  * Interrupt requests only cancel the member's current model turn and retain its
@@ -192,34 +183,13 @@ async function waitForMemberIdle(ctx, member, signal) {
192
183
  * keep executing after the captain-chat Stop control has reported success.
193
184
  */
194
185
  async function stopTeamMemberActivations(ctx, captain, members, signal) {
195
- const activeMembers = members.filter((member) => member.id !== '' && member.status !== 'removed');
196
- const memberIds = activeMembers.map((member) => member.id);
186
+ const memberIds = members.filter(member => member.id !== '').map(member => member.id);
197
187
  if (memberIds.length === 0)
198
188
  return;
199
- for (const memberId of memberIds)
200
- interruptMember(ctx, captain, memberId);
201
- // `drainContinuableChildren` is available in the current runtime and releases
202
- // the selected activation handles. Keep the quiescence fallback for pre-rc.8
203
- // hosts, where interrupt is the strongest available lifecycle operation.
204
- const runtime = ctx.subagents;
205
- if (runtime.drainContinuableChildren !== undefined) {
206
- try {
207
- await runtime.drainContinuableChildren(captain, memberIds);
208
- return;
209
- }
210
- catch (error) {
211
- // Do not claim the browser action stopped work when the runtime could not
212
- // release all selected child activations. The HTTP route surfaces this
213
- // failure instead of returning a false successful stop.
214
- ctx.logger.warn(`agent-teams: failed to drain halted members: ${String(error)}`);
215
- throw error;
216
- }
217
- }
218
- const fallbackSignal = signal ?? new AbortController().signal;
219
- const results = await Promise.allSettled(activeMembers.map((member) => waitForMemberIdle(ctx, member, fallbackSignal)));
220
- const failed = results.find((result) => result.status === 'rejected');
221
- if (failed?.status === 'rejected')
222
- throw failed.reason;
189
+ // Every supported exact host has targeted recursive drain. Unlike interrupt,
190
+ // it closes admission and clears queued work before awaiting descendants.
191
+ signal?.throwIfAborted();
192
+ await ctx.subagents.drainContinuableChildren(captain, memberIds);
223
193
  }
224
194
  export async function haltTeamWork(input) {
225
195
  const halted = await withTeamLock(teamLockKey(input.stateRoot, input.teamId), async () => {
@@ -310,16 +280,83 @@ export function stagedPlanFeedbackContext(teamName) {
310
280
  */
311
281
  export function registerAgentTeamsTools(ctx, config) {
312
282
  installRetiredMemberGuard(ctx, config.stateDir);
313
- const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir, executionPrompt: config.executionPrompt });
283
+ installMemberDelegationGuard(ctx, config.stateDir, config.memberMaxDepth ?? 0);
284
+ installMailboxAdmission(ctx, config.stateDir);
285
+ const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir, executionPrompt: config.executionPrompt, dispatch: dispatchMember });
314
286
  const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir, (workspace, teamId, memberName) => (scheduler.kickMember(workspace, teamId, memberName)));
315
- const updateStagedPlanBatch = async (captain, teamId, mutations, signal) => {
287
+ async function dispatchMember(captain, teamId, memberName, text, signal, mode, attemptId) {
288
+ const root = stateRootOf(workspaceOf(captain), config);
289
+ // Record why a member never started. The scheduler treats a failed dispatch as
290
+ // "not now" and retries, so without this the captain only ever sees an
291
+ // unexplained `unspawned` member and no diagnostic reaches any surface.
292
+ const recordSpawnError = async (reason) => {
293
+ try {
294
+ await withTeamLock(teamLockKey(root, teamId), async () => {
295
+ const fresh = await requireFreshCaptainTeam(root, teamId, captain.id);
296
+ const failed = fresh.members.find(item => item.name === memberName && item.status !== 'removed');
297
+ if (failed === undefined || failed.id !== '')
298
+ return;
299
+ failed.spawnError = reason;
300
+ await writeTeam(root, fresh);
301
+ });
302
+ }
303
+ catch (error) {
304
+ ctx.logger.warn(`agent-teams: could not record the member start failure for ${memberName}: ${String(error)}`);
305
+ }
306
+ };
307
+ let orphan;
308
+ try {
309
+ return await withTeamLock(teamLockKey(root, teamId), async () => {
310
+ const team = await readTeam(root, teamId);
311
+ if (team?.captainSessionId !== captain.id || team.halted === true || team.phase === 'staged')
312
+ return false;
313
+ const member = team.members.find(item => item.name === memberName && item.status !== 'removed');
314
+ if (member === undefined || member.stopping === true || team.tasks.some(task => task.reassigning === true && task.assignee === memberName))
315
+ return false;
316
+ if (attemptId !== undefined && !team.tasks.some(task => task.attemptId === attemptId && task.assignee === memberName && (task.status === 'claimed' || task.status === 'in_progress')))
317
+ return false;
318
+ if (member.id !== '')
319
+ return deliverToMember(ctx, captain, member.id, text, signal, mode);
320
+ const selection = await resolveMemberLlmSelection(ctx, captain, {
321
+ provider: member.provider, model: member.model, reasoningEffort: member.reasoningEffort, fallback: member.fallback,
322
+ }, signal);
323
+ await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, team, member, config.stateDir, signal, text);
324
+ orphan = { ...member };
325
+ delete member.spawnError;
326
+ await writeTeam(root, team);
327
+ orphan = undefined;
328
+ return true;
329
+ });
330
+ }
331
+ catch (error) {
332
+ if (orphan !== undefined) {
333
+ await recordRetiredMemberIds(root, [orphan.id]);
334
+ await stopTeamMemberActivations(ctx, captain, [orphan]);
335
+ }
336
+ // The stack carries the failing frame; the message alone rarely does.
337
+ let reason = String(error);
338
+ if (error instanceof Error && typeof error.stack === 'string' && error.stack !== '')
339
+ reason = error.stack;
340
+ ctx.logger.warn(`agent-teams: member dispatch failed for ${memberName}: ${String(error)}`);
341
+ await recordSpawnError(reason);
342
+ return false;
343
+ }
344
+ }
345
+ const updatePlanBatch = async (captain, teamId, mutations, signal, allowPendingEdits = false) => {
316
346
  if (mutations.length === 0)
317
347
  throw new Error('at least one staged plan operation is required');
318
348
  const workspace = workspaceOf(captain);
319
349
  const stateRoot = stateRootOf(workspace, config);
320
350
  return withTeamLock(teamLockKey(stateRoot, teamId), async () => {
321
351
  const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
322
- requireStagedTeam(fresh);
352
+ const staged = fresh.phase === 'staged';
353
+ if (!staged && !allowPendingEdits)
354
+ requireStagedTeam(fresh);
355
+ if (fresh.halted === true)
356
+ throw new Error('team is halted; resume before editing tasks');
357
+ if (!staged && mutations.some(mutation => mutation.action !== 'update_task')) {
358
+ throw new Error('a running team only permits update_task edits to pending, never-started tasks; roster and removal edits require a staged plan');
359
+ }
323
360
  for (const mutation of mutations) {
324
361
  if (mutation.action === 'update_member') {
325
362
  const member = requireMember(fresh, mutation.memberName);
@@ -339,9 +376,11 @@ export function registerAgentTeamsTools(ctx, config) {
339
376
  }
340
377
  else if (mutation.action === 'update_task') {
341
378
  const task = requireTask(fresh, mutation.taskId);
342
- if (task.status !== 'pending' || (task.attempt ?? 0) !== 0) {
379
+ if (task.status !== 'pending' || (task.attempt ?? 0) !== 0 || task.reassigning === true) {
343
380
  throw new Error(`task "${task.id}" has already started and cannot be edited`);
344
381
  }
382
+ if (!staged && mutation.assignee === CAPTAIN_KEY)
383
+ throw new Error('use reassign_task for captain takeover');
345
384
  const subject = mutation.subject.trim();
346
385
  if (subject === '')
347
386
  throw new Error('task subject must not be empty');
@@ -390,11 +429,25 @@ export function registerAgentTeamsTools(ctx, config) {
390
429
  }
391
430
  }
392
431
  validateStagedGraph(fresh, false);
393
- fresh.planReviewState = 'awaiting_review';
432
+ if (!staged) {
433
+ for (const mutation of mutations) {
434
+ if (mutation.action !== 'update_task')
435
+ continue;
436
+ const task = requireTask(fresh, mutation.taskId);
437
+ const validation = validateCreateTask({ ...fresh, tasks: fresh.tasks.filter(item => item.id !== task.id) }, task);
438
+ if (!validation.ok)
439
+ throw new Error(validation.error ?? 'edited task violates the quality contract');
440
+ }
441
+ }
442
+ else
443
+ fresh.planReviewState = 'awaiting_review';
444
+ signal?.throwIfAborted();
394
445
  await writeTeam(stateRoot, fresh);
395
446
  return fresh;
396
447
  });
397
448
  };
449
+ // Browser review controls retain their staged-only contract.
450
+ const updateStagedPlanBatch = (captain, teamId, mutations, signal) => (updatePlanBatch(captain, teamId, mutations, signal));
398
451
  const updateStagedPlan = async (captain, teamId, mutation, signal) => (updateStagedPlanBatch(captain, teamId, [mutation], signal));
399
452
  const approveStagedTeam = async (captain, teamId, signal) => {
400
453
  const workspace = workspaceOf(captain);
@@ -407,47 +460,22 @@ export function registerAgentTeamsTools(ctx, config) {
407
460
  // placeholders before transitioning to the stricter running shape.
408
461
  fresh.members = fresh.members.filter((member) => member.status !== 'removed');
409
462
  validateStagedGraph(fresh, true);
410
- const spawned = [];
411
- try {
412
- const selections = new Map();
413
- for (const member of fresh.members) {
414
- if (member.id !== '')
415
- continue;
416
- const selection = await resolveMemberLlmSelection(ctx, captain, {
417
- provider: member.provider,
418
- model: member.model,
419
- reasoningEffort: member.reasoningEffort,
420
- fallback: member.fallback,
421
- }, runSignal);
422
- selections.set(member, selection);
423
- member.provider = selection.provider;
424
- member.model = selection.model;
425
- member.reasoningEffort = selection.reasoningEffort;
426
- }
427
- // This is the approval commit barrier: resolve and validate the whole
428
- // final roster before spawning even the first durable child.
429
- await validateMemberLlmSelections(ctx, [...selections.values()], runSignal);
430
- for (const [member, selection] of selections) {
431
- await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, runSignal);
432
- spawned.push(member);
433
- }
434
- if (fresh.members.some((member) => member.id === '')) {
435
- throw new Error('one or more staged members could not be spawned');
436
- }
437
- fresh.phase = 'running';
438
- delete fresh.planReviewState;
439
- fresh.approvedAt = Date.now();
440
- await writeTeam(stateRoot, fresh);
441
- return { teamId: fresh.id, members: fresh.members.length, tasks: fresh.tasks.length };
442
- }
443
- catch (error) {
444
- await recordRetiredMemberIds(stateRoot, spawned.map((member) => member.id)).catch(() => undefined);
445
- for (const member of spawned) {
446
- if (member.id !== '')
447
- interruptMember(ctx, captain, member.id);
448
- }
449
- throw error;
463
+ const selections = [];
464
+ for (const member of fresh.members) {
465
+ const selection = await resolveMemberLlmSelection(ctx, captain, {
466
+ provider: member.provider, model: member.model, reasoningEffort: member.reasoningEffort, fallback: member.fallback,
467
+ }, runSignal);
468
+ selections.push(selection);
469
+ member.provider = selection.provider;
470
+ member.model = selection.model;
471
+ member.reasoningEffort = selection.reasoningEffort;
450
472
  }
473
+ await validateMemberLlmSelections(ctx, selections, runSignal);
474
+ fresh.phase = 'running';
475
+ delete fresh.planReviewState;
476
+ fresh.approvedAt = Date.now();
477
+ await writeTeam(stateRoot, fresh);
478
+ return { teamId: fresh.id, members: fresh.members.length, tasks: fresh.tasks.length };
451
479
  });
452
480
  try {
453
481
  await scheduler.kickTeam(workspace, teamId, captain);
@@ -547,6 +575,21 @@ export function registerAgentTeamsTools(ctx, config) {
547
575
  name: { type: 'string', required: true, description: 'Name for the new team (used as its stable id).' },
548
576
  description: { type: 'string', description: 'Team purpose / the goal the team will work on.' },
549
577
  profile: { type: 'string', description: 'Optional configured profile name.' },
578
+ plan: {
579
+ type: 'object', additionalProperties: false,
580
+ description: 'Optional complete ordinary-work roster and DAG in one atomic call, instead of separate add_member/create_task rounds. Mutually exclusive with profile. For quality gates use create_task with the explicit quality contract.',
581
+ properties: {
582
+ members: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
583
+ name: { type: 'string', required: true }, role: { type: 'string' },
584
+ provider: { type: 'string' }, model: { type: 'string' }, reasoning_effort: { type: 'string' },
585
+ } } },
586
+ tasks: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
587
+ id: { type: 'string', required: true, description: 'Local reference used by dependencies in this plan; the result maps it to a durable task id.' },
588
+ subject: { type: 'string', required: true }, description: { type: 'string' }, assignee: { type: 'string' },
589
+ dependencies: { type: 'array', items: { type: 'string' } },
590
+ } } },
591
+ },
592
+ },
550
593
  approval: {
551
594
  type: 'string',
552
595
  enum: ['required', 'automatic'],
@@ -570,9 +613,10 @@ export function registerAgentTeamsTools(ctx, config) {
570
613
  },
571
614
  render: (args, value) => [{
572
615
  type: 'text',
573
- text: value.phase === 'staged'
616
+ text: (value.phase === 'staged'
574
617
  ? `Team "${value.team_name}" plan created under ${value.state_dir}. It is staged: finish the roster and DAG, then wait for the user to edit and approve it. Do not start or approve it yourself.`
575
- : `Team "${value.team_name}" created (id ${value.team_id}) under ${value.state_dir}. You are the captain.`,
618
+ : `Team "${value.team_name}" created (id ${value.team_id}) under ${value.state_dir}. You are the captain.`)
619
+ + (value.tasks === undefined ? '' : '\n' + value.tasks.map(task => `${task.task_id} [${task.seed_id}]: ${task.subject}; assignee=${task.assignee ?? 'unassigned'}; dependencies=${task.dependencies.join(',')}`).join('\n')),
576
620
  }],
577
621
  },
578
622
  async execute(args, exec) {
@@ -590,6 +634,8 @@ export function registerAgentTeamsTools(ctx, config) {
590
634
  const profileName = args.profile !== undefined && args.profile.trim() !== ''
591
635
  ? args.profile.trim()
592
636
  : undefined;
637
+ if (profileName !== undefined && args.plan !== undefined)
638
+ throw new Error('choose either a configured profile or an inline plan');
593
639
  const created = await withTeamLock(captainLockKey(stateRoot, captain.id), async () => {
594
640
  const current = await findTeamByParticipant(stateRoot, captain.id);
595
641
  if (current !== undefined) {
@@ -604,7 +650,7 @@ export function registerAgentTeamsTools(ctx, config) {
604
650
  if (existing !== undefined) {
605
651
  throw new Error(`team id "${teamId}" is taken by another captain — pick a different team name`);
606
652
  }
607
- if (profileName === undefined) {
653
+ if (profileName === undefined && args.plan === undefined) {
608
654
  const state = {
609
655
  name: teamName,
610
656
  id: teamId,
@@ -628,7 +674,8 @@ export function registerAgentTeamsTools(ctx, config) {
628
674
  stateRoot,
629
675
  teamName,
630
676
  teamId,
631
- profileName,
677
+ profileName: profileName ?? 'inline-plan',
678
+ inlinePlan: args.plan,
632
679
  description: args.description,
633
680
  staged,
634
681
  });
@@ -673,7 +720,7 @@ export function registerAgentTeamsTools(ctx, config) {
673
720
  }
674
721
  const persisted = await readTeam(stateRoot, created.state.id).catch(() => undefined);
675
722
  const snapshot = persisted ?? created.state;
676
- if (snapshot.profile === undefined) {
723
+ if (snapshot.profile === undefined && args.plan === undefined) {
677
724
  return {
678
725
  team_id: snapshot.id,
679
726
  team_name: snapshot.name,
@@ -686,8 +733,8 @@ export function registerAgentTeamsTools(ctx, config) {
686
733
  team_name: snapshot.name,
687
734
  state_dir: join(stateRoot, snapshot.id),
688
735
  phase: snapshot.phase ?? 'running',
689
- profile: snapshot.profile.name,
690
- task_planning: snapshot.profile.taskPlanning ?? 'seed',
736
+ ...snapshot.profile === undefined ? {} : { profile: snapshot.profile.name },
737
+ task_planning: snapshot.profile?.taskPlanning ?? 'seed',
691
738
  members: snapshot.members.map((member) => ({
692
739
  member_name: member.name,
693
740
  member_id: member.id,
@@ -710,12 +757,12 @@ export function registerAgentTeamsTools(ctx, config) {
710
757
  }));
711
758
  ctx.tools.register(defineTool({
712
759
  name: 'agent_teams_edit_plan',
713
- description: 'Atomically revise the current staged AgentTeams plan without spawning members or scheduling tasks. Use this when the user continues chatting to change a plan that is waiting for approval. Submit dependent edits in order (update downstream dependencies or assignees, then remove tasks, then remove unused members). Never inspect or edit .agent-teams state files or plugin source code to revise a plan.',
760
+ description: 'Atomically revise an AgentTeams plan. While staged, edit tasks and roster without starting work. While running, only update_task is allowed, and only for pending tasks with no prior attempt: correct dependencies, assignees or descriptions before they start; newly ready work is scheduled after commit. Never edit active/finished attempts. Submit dependent edits in order. Never modify .agent-teams files directly.',
714
761
  parameters: {
715
762
  operations: {
716
763
  type: 'array',
717
764
  required: true,
718
- description: 'One atomic, ordered batch of staged-plan edits. If any operation is invalid, none of the edits are saved.',
765
+ description: 'One atomic, ordered batch. Running teams allow only update_task for never-started pending tasks. If any operation is invalid, none of the edits are saved.',
719
766
  items: {
720
767
  type: 'object',
721
768
  additionalProperties: false,
@@ -756,14 +803,13 @@ export function registerAgentTeamsTools(ctx, config) {
756
803
  },
757
804
  render: (_args, value) => [{
758
805
  type: 'text',
759
- text: `Staged plan updated atomically (${value.members} members, ${value.tasks} tasks, ${value.dependencies} dependencies). No members were spawned and no tasks were scheduled.\n${value.graph.join('\n')}`,
806
+ text: `${value.status === 'staged' ? 'Staged plan' : 'Pending task graph'} updated atomically (${value.members} members, ${value.tasks} tasks, ${value.dependencies} dependencies). ${value.status === 'staged' ? 'No members were spawned and no tasks were scheduled.' : 'The scheduler will dispatch any newly ready work.'}\n${value.graph.join('\n')}`,
760
807
  }],
761
808
  },
762
809
  async execute(args, exec) {
763
810
  const captain = requireCaptain(exec);
764
811
  const workspace = workspaceOf(captain);
765
812
  const team = await requireCaptainTeam(workspace, config, captain);
766
- requireStagedTeam(team);
767
813
  if (args.operations.length === 0)
768
814
  throw new Error('at least one staged plan operation is required');
769
815
  const mutations = args.operations.map((operation, index) => {
@@ -820,9 +866,11 @@ export function registerAgentTeamsTools(ctx, config) {
820
866
  throw new Error(`${label} requires member_name`);
821
867
  return { action: 'remove_member', memberName };
822
868
  });
823
- const updated = await updateStagedPlanBatch(captain, team.id, mutations, exec.signal);
869
+ const updated = await updatePlanBatch(captain, team.id, mutations, exec.signal, true);
870
+ if (updated.phase !== 'staged')
871
+ await scheduler.kickTeam(workspace, team.id, captain);
824
872
  return {
825
- status: 'staged',
873
+ status: updated.phase ?? 'running',
826
874
  team_id: updated.id,
827
875
  members: updated.members.length,
828
876
  tasks: updated.tasks.length,
@@ -866,7 +914,7 @@ export function registerAgentTeamsTools(ctx, config) {
866
914
  }));
867
915
  ctx.tools.register(defineTool({
868
916
  name: 'agent_teams_add_member',
869
- description: 'Add a member to the team roster. In a staged team this only adds an editable plan row and does not spawn a child; approval spawns the final configuration. In a running team it creates the durable continuable member immediately.',
917
+ description: 'Add a member to the team roster. Planning and idle roster rows do not call a model. After approval, the member session starts with its first ready task or explicit message and remains durable for later work.',
870
918
  parameters: {
871
919
  name: { type: 'string', required: true, description: 'Unique member name inside the team.' },
872
920
  role: { type: 'string', description: 'Role of the member (e.g. researcher, engineer, reviewer).' },
@@ -893,7 +941,7 @@ export function registerAgentTeamsTools(ctx, config) {
893
941
  type: 'text',
894
942
  text: value.phase === 'staged'
895
943
  ? `Member "${value.member_name}" added to the staged roster (${value.provider}/${value.model}); no child was spawned.`
896
- : `Member "${value.member_name}" added (subagent id ${value.member_id}, ${value.provider}/${value.model}${value.reasoning_effort === undefined ? '' : `, reasoning ${value.reasoning_effort}`}, status ${value.status}).`,
944
+ : `Member "${value.member_name}" added (session ${value.member_id || 'starts with first ready task'}, ${value.provider}/${value.model}${value.reasoning_effort === undefined ? '' : `, reasoning ${value.reasoning_effort}`}, status ${value.status}).`,
897
945
  }],
898
946
  },
899
947
  async execute(args, exec) {
@@ -935,23 +983,9 @@ export function registerAgentTeamsTools(ctx, config) {
935
983
  joinedAt: Date.now(),
936
984
  status: 'idle',
937
985
  };
938
- if (fresh.phase !== 'staged') {
939
- await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, exec.signal);
940
- }
986
+ await validateMemberLlmSelections(ctx, [selection], exec.signal);
941
987
  fresh.members.push(member);
942
- try {
943
- await writeTeam(stateRoot, fresh);
944
- }
945
- catch (error) {
946
- // The continuable child is already live, but the durable team record
947
- // never saw it. Retire the orphan so it disappears from subagent
948
- // listings and cannot be resumed, then surface the write failure.
949
- if (member.id !== '') {
950
- await recordRetiredMemberIds(stateRoot, [member.id]).catch(() => undefined);
951
- interruptMember(ctx, captain, member.id);
952
- }
953
- throw error;
954
- }
988
+ await writeTeam(stateRoot, fresh);
955
989
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-added', {
956
990
  teamId: fresh.id,
957
991
  memberId: member.id,
@@ -971,7 +1005,8 @@ export function registerAgentTeamsTools(ctx, config) {
971
1005
  };
972
1006
  });
973
1007
  await scheduler.kickMember(workspace, team.id, created.member_name, captain);
974
- return created;
1008
+ const latest = (await readTeam(stateRoot, team.id))?.members.find(member => member.name === created.member_name);
1009
+ return latest === undefined ? created : { ...created, member_id: latest.id, status: latest.status };
975
1010
  },
976
1011
  }));
977
1012
  ctx.tools.register(defineTool({
@@ -1002,7 +1037,9 @@ export function registerAgentTeamsTools(ctx, config) {
1002
1037
  const team = await requireCaptainTeam(workspace, config, captain);
1003
1038
  const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1004
1039
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1005
- const member = requireMember(fresh, args.name);
1040
+ const member = fresh.members.find(item => item.name === args.name);
1041
+ if (member === undefined)
1042
+ throw new Error(`no member \"${args.name}\" in team \"${fresh.name}\"`);
1006
1043
  const requeued = [];
1007
1044
  for (const task of fresh.tasks) {
1008
1045
  if (task.assignee !== member.name || task.status === 'completed')
@@ -1012,6 +1049,7 @@ export function registerAgentTeamsTools(ctx, config) {
1012
1049
  requeued.push(task.id);
1013
1050
  }
1014
1051
  member.status = 'removed';
1052
+ await discardMailboxMessages(stateRoot, fresh.id, member.name, (await readUnreadMailbox(stateRoot, fresh.id, member.name)).map(message => message.id));
1015
1053
  await writeTeam(stateRoot, fresh);
1016
1054
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-removed', {
1017
1055
  teamId: fresh.id,
@@ -1021,8 +1059,7 @@ export function registerAgentTeamsTools(ctx, config) {
1021
1059
  });
1022
1060
  if (revoked.member.id !== '') {
1023
1061
  await recordRetiredMemberIds(stateRoot, [revoked.member.id]);
1024
- interruptMember(ctx, captain, revoked.member.id);
1025
- await waitForMemberIdle(ctx, revoked.member, exec.signal);
1062
+ await stopTeamMemberActivations(ctx, captain, [revoked.member], exec.signal);
1026
1063
  }
1027
1064
  await scheduler.kickTeam(workspace, team.id, captain);
1028
1065
  return {
@@ -1034,7 +1071,7 @@ export function registerAgentTeamsTools(ctx, config) {
1034
1071
  }));
1035
1072
  ctx.tools.register(defineTool({
1036
1073
  name: 'agent_teams_create_task',
1037
- description: 'Create a task in your team\'s task list. Every call must include a non-empty subject, including verification and review tasks. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working.',
1074
+ description: 'Create a task in your team\'s task list. Use kind=work (default) for research, repository audits and general tasks. kind=review is only a quality gate for an existing implementation/repair task via reviewedTaskId. Every call must include a non-empty subject, including verification and review tasks. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working.',
1038
1075
  parameters: {
1039
1076
  subject: { type: 'string', required: true, description: 'Required non-empty title for this task. Never omit it, including for verification or review tasks.' },
1040
1077
  description: { type: 'string', description: 'What needs to be done, in detail.' },
@@ -1043,11 +1080,11 @@ export function registerAgentTeamsTools(ctx, config) {
1043
1080
  items: { type: 'string' },
1044
1081
  description: 'Task ids this task depends on (must be completed before this task can be claimed).',
1045
1082
  },
1046
- assignee: { type: 'string', description: 'Optional member name this task is intended for.' },
1083
+ assignee: { type: 'string', description: 'Member name when an owner is specified. Omission puts this task in the shared pool; roles, subjects and descriptions do not assign an owner.' },
1047
1084
  kind: {
1048
1085
  type: 'string',
1049
1086
  enum: ['work', 'requirements', 'implementation', 'verification', 'review', 'repair', 'integration'],
1050
- description: 'Task kind. Defaults to work (legacy, no quality gates). Quality kinds require a contract.',
1087
+ description: 'Explicit task kind. Omission means work with no quality gates, even if the subject says implementation/review. Quality kinds require a contract. An implementation may be planned before requirements passes when its dependency chain includes that requirements task.',
1051
1088
  },
1052
1089
  round: { type: 'number', description: '1-based review / requirements / repair round.' },
1053
1090
  objective: { type: 'string', description: 'Required non-empty objective for quality kinds.' },
@@ -1057,7 +1094,7 @@ export function registerAgentTeamsTools(ctx, config) {
1057
1094
  verify: { type: 'array', items: { type: 'string' }, description: 'Verification commands. Required for implementation/repair.' },
1058
1095
  deliverables: { type: 'array', items: { type: 'string' }, description: 'Expected deliverable paths or names.' },
1059
1096
  nonGoals: { type: 'array', items: { type: 'string' }, description: 'Explicit non-goals.' },
1060
- reviewedTaskId: { type: 'string', description: 'Task being reviewed. Required for kind=review.' },
1097
+ reviewedTaskId: { type: 'string', description: 'Existing AgentTeams implementation/repair task id. Required for kind=review; an external repository audit is kind=work.' },
1061
1098
  sourceTaskId: { type: 'string', description: 'Source implementation/artifact. Required for kind=repair.' },
1062
1099
  sourceFindingIds: { type: 'array', items: { type: 'string' }, description: 'Finding ids this repair must close.' },
1063
1100
  coverageOf: { type: 'array', items: { type: 'string' }, description: 'User-constraint / goal items this task covers.' },
@@ -1072,12 +1109,13 @@ export function registerAgentTeamsTools(ctx, config) {
1072
1109
  task_id: { type: 'string', required: true },
1073
1110
  subject: { type: 'string', required: true },
1074
1111
  status: { type: 'string', required: true },
1112
+ kind: { type: 'string', required: true },
1075
1113
  assignee: { type: 'string' },
1076
1114
  },
1077
1115
  },
1078
1116
  render: (args, value) => [{
1079
1117
  type: 'text',
1080
- text: `Task "${value.subject}" created as ${value.task_id} (status ${value.status}${value.assignee ? `, assigned to ${value.assignee}` : ''}).`,
1118
+ text: `Task "${value.subject}" created as ${value.task_id} (status ${value.status}, kind ${value.kind}, ${value.assignee ? `assigned to ${value.assignee}` : 'unassigned shared pool'}).`,
1081
1119
  }],
1082
1120
  },
1083
1121
  async execute(args, exec) {
@@ -1182,6 +1220,7 @@ export function registerAgentTeamsTools(ctx, config) {
1182
1220
  task_id: task.id,
1183
1221
  subject: task.subject,
1184
1222
  status: task.status,
1223
+ kind: taskKindOf(task),
1185
1224
  ...task.assignee !== undefined ? { assignee: task.assignee } : {},
1186
1225
  };
1187
1226
  });
@@ -1228,8 +1267,12 @@ export function registerAgentTeamsTools(ctx, config) {
1228
1267
  const task = requireTask(fresh, args.task_id);
1229
1268
  if (task.status === 'completed')
1230
1269
  throw new Error(`completed task ${task.id} is immutable and cannot be reassigned`);
1231
- if (task.reassigning === true)
1232
- throw new Error(`task ${task.id} is already being reassigned`);
1270
+ if (task.reassigning === true) {
1271
+ const previousMember = fresh.members.find(member => member.id === task.handoffFromMemberId && member.stopping === true);
1272
+ if (task.assignee !== target || previousMember === undefined)
1273
+ throw new Error(`task ${task.id} is already being reassigned`);
1274
+ return { previousAssignee: previousMember.name, previousMember: { ...previousMember }, handoffId: task.handoffId };
1275
+ }
1233
1276
  const targetMember = target === CAPTAIN_KEY ? undefined : requireMember(fresh, target);
1234
1277
  if (target === CAPTAIN_KEY) {
1235
1278
  const busy = captainOpenTask(fresh, task.id);
@@ -1253,6 +1296,11 @@ export function registerAgentTeamsTools(ctx, config) {
1253
1296
  ? undefined
1254
1297
  : fresh.members.find(member => member.name === task.assignee && member.status !== 'removed');
1255
1298
  invalidateTaskAttempt(task, target, true);
1299
+ if (previousMember !== undefined) {
1300
+ previousMember.stopping = true;
1301
+ task.handoffFromMemberId = previousMember.id;
1302
+ await discardMailboxMessages(stateRoot, fresh.id, previousMember.name, (await readUnreadMailbox(stateRoot, fresh.id, previousMember.name)).map(message => message.id));
1303
+ }
1256
1304
  await writeTeam(stateRoot, fresh);
1257
1305
  return {
1258
1306
  previousAssignee,
@@ -1262,9 +1310,8 @@ export function registerAgentTeamsTools(ctx, config) {
1262
1310
  });
1263
1311
  let quiescenceError;
1264
1312
  if (revoked.previousMember !== undefined) {
1265
- interruptMember(ctx, captain, revoked.previousMember.id);
1266
1313
  try {
1267
- await waitForMemberIdle(ctx, revoked.previousMember, exec.signal);
1314
+ await stopTeamMemberActivations(ctx, captain, [revoked.previousMember], exec.signal);
1268
1315
  }
1269
1316
  catch (error) {
1270
1317
  quiescenceError = error;
@@ -1276,7 +1323,13 @@ export function registerAgentTeamsTools(ctx, config) {
1276
1323
  if (task.handoffId !== revoked.handoffId || task.assignee !== target || task.reassigning !== true) {
1277
1324
  throw new Error(`task ${task.id} changed during reassignment; refusing to overwrite the newer state`);
1278
1325
  }
1279
- task.reassigning = false;
1326
+ task.reassigning = quiescenceError !== undefined;
1327
+ if (quiescenceError === undefined) {
1328
+ const previous = fresh.members.find(member => member.id === revoked.previousMember?.id);
1329
+ if (previous !== undefined)
1330
+ delete previous.stopping;
1331
+ delete task.handoffFromMemberId;
1332
+ }
1280
1333
  if (quiescenceError === undefined && target === CAPTAIN_KEY) {
1281
1334
  beginTaskAttempt(task, CAPTAIN_KEY);
1282
1335
  // The captain is already in the turn that requested takeover; there
@@ -1328,11 +1381,12 @@ export function registerAgentTeamsTools(ctx, config) {
1328
1381
  assignee: { type: 'string', required: true },
1329
1382
  attempt: { type: 'number', required: true },
1330
1383
  attempt_id: { type: 'string' },
1384
+ task_details: { type: 'string', required: true },
1331
1385
  },
1332
1386
  },
1333
1387
  render: (args, value) => [{
1334
1388
  type: 'text',
1335
- text: `Task ${value.task_id} claimed by ${value.assignee} (attempt ${value.attempt}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}, status ${value.status}).`,
1389
+ text: `Task ${value.task_id} claimed by ${value.assignee} (attempt ${value.attempt}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}, status ${value.status}).\n${value.task_details}`,
1336
1390
  }],
1337
1391
  },
1338
1392
  async execute(args, exec) {
@@ -1371,6 +1425,7 @@ export function registerAgentTeamsTools(ctx, config) {
1371
1425
  throw new Error(`task ${task.id} is already claimed by "${task.assignee ?? 'nobody'}"`);
1372
1426
  }
1373
1427
  return {
1428
+ task_details: taskDetails(fresh, task),
1374
1429
  task_id: task.id,
1375
1430
  status: task.status,
1376
1431
  assignee,
@@ -1401,6 +1456,7 @@ export function registerAgentTeamsTools(ctx, config) {
1401
1456
  assignee: task.assignee,
1402
1457
  });
1403
1458
  return {
1459
+ task_details: taskDetails(fresh, task),
1404
1460
  task_id: task.id,
1405
1461
  status: task.status,
1406
1462
  assignee: task.assignee ?? '',
@@ -1415,13 +1471,13 @@ export function registerAgentTeamsTools(ctx, config) {
1415
1471
  description: 'Update a task status/output. Members must supply the current attempt_id returned by claim_task; stale attempts are rejected after takeover/reassignment. Terminal results are immutable. A captain must use reassign_task(assignee="captain") before updating member-owned work.',
1416
1472
  parameters: {
1417
1473
  task_id: { type: 'string', required: true, description: 'The task id to update.' },
1474
+ attempt_id: { type: 'string', description: 'Members must explicitly include the current attempt_id from their assignment/claim in EVERY update, including failed reviews with findings. If omitted, retry with the same current id; omission does not revoke the attempt.' },
1418
1475
  status: {
1419
1476
  type: 'string',
1420
1477
  enum: ['in_progress', 'completed', 'failed', 'cancelled'],
1421
1478
  description: 'New status (in_progress, completed, failed, cancelled).',
1422
1479
  },
1423
1480
  output: { type: 'string', description: 'Result summary; set when completing or failing.' },
1424
- attempt_id: { type: 'string', description: 'Current execution capability returned by claim_task (required for members when present on the task).' },
1425
1481
  verdict: {
1426
1482
  type: 'string',
1427
1483
  enum: ['pass', 'needs_revision', 'reject'],
@@ -1504,13 +1560,17 @@ export function registerAgentTeamsTools(ctx, config) {
1504
1560
  const task = requireTask(fresh, args.task_id);
1505
1561
  if (identity.kind === 'captain'
1506
1562
  && task.assignee !== undefined
1507
- && task.assignee !== CAPTAIN_KEY) {
1563
+ && task.assignee !== CAPTAIN_KEY
1564
+ && !(args.status === 'cancelled' && task.status === 'pending' && (task.attempt ?? 0) === 0 && task.reassigning !== true)) {
1508
1565
  throw new Error(`task ${task.id} is owned by member "${task.assignee}"; call agent_teams_reassign_task with assignee="captain" before takeover`);
1509
1566
  }
1510
1567
  if (identity.kind === 'member') {
1511
1568
  if (task.assignee !== identity.name) {
1512
1569
  throw new Error(`task ${task.id} is assigned to "${task.assignee ?? 'nobody'}", not you`);
1513
1570
  }
1571
+ if (task.attemptId !== undefined && (args.attempt_id === undefined || args.attempt_id.trim() === '')) {
1572
+ throw new Error(`missing attempt_id for task ${task.id}. Retry this update with attempt_id="${task.attemptId}" from your current assignment. This is a missing parameter, not a revoked attempt; do not restart the work or request reassignment.`);
1573
+ }
1514
1574
  if (task.attemptId !== undefined && args.attempt_id !== task.attemptId) {
1515
1575
  throw new Error(`stale attempt for task ${task.id}: expected the current attempt_id; stop work and request fresh assignment`);
1516
1576
  }
@@ -1605,9 +1665,89 @@ export function registerAgentTeamsTools(ctx, config) {
1605
1665
  return updated;
1606
1666
  },
1607
1667
  }));
1668
+ ctx.tools.register(defineTool({
1669
+ name: 'agent_teams_amend_task',
1670
+ description: 'Captain-only controlled contract amendment for one non-terminal quality task: replace a wrong objective/acceptance/verify/inScope/outOfScope when the original contract makes honest completion impossible (for example a verify command that cannot pass, or an inScope that forbids the file the objective names). The amendment is appended to the task\'s revisions ledger with previous values and the reason, and is rejected once a review/requirements task has passed judgment on this task. Members cannot amend contracts; the implementer re-reads the amended contract before its next quality gate. Lists are full replacements, not deltas.',
1671
+ parameters: {
1672
+ task_id: { type: 'string', required: true, description: 'Task whose contract is being amended.' },
1673
+ reason: { type: 'string', required: true, description: 'Why the current contract is wrong; recorded in the revisions ledger.' },
1674
+ objective: { type: 'string', description: 'Replacement objective.' },
1675
+ acceptance: { type: 'array', items: { type: 'string' }, description: 'Replacement acceptance criteria (full list, not a delta).' },
1676
+ verify: { type: 'array', items: { type: 'string' }, description: 'Replacement verification commands (full list, not a delta).' },
1677
+ inScope: { type: 'array', items: { type: 'string' }, description: 'Replacement workspace-relative inScope paths (full list).' },
1678
+ outOfScope: { type: 'array', items: { type: 'string' }, description: 'Replacement workspace-relative outOfScope paths (full list).' },
1679
+ },
1680
+ output: {
1681
+ schema: {
1682
+ type: 'object',
1683
+ additionalProperties: false,
1684
+ properties: {
1685
+ task_id: { type: 'string', required: true },
1686
+ status: { type: 'string', required: true },
1687
+ revised_fields: { type: 'string', required: true },
1688
+ revision_count: { type: 'number', required: true },
1689
+ contract: { type: 'string', required: true },
1690
+ },
1691
+ },
1692
+ render: (_args, value) => [{
1693
+ type: 'text',
1694
+ text: `Task ${value.task_id} contract amended (${value.revised_fields}); ${value.revision_count} revision(s) on record, status ${value.status}. New contract: ${value.contract}`,
1695
+ }],
1696
+ },
1697
+ async execute(args, exec) {
1698
+ const captain = requireCaptain(exec);
1699
+ const workspace = workspaceOf(captain);
1700
+ const stateRoot = stateRootOf(workspace, config);
1701
+ const team = await requireCaptainTeam(workspace, config, captain);
1702
+ const amended = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1703
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1704
+ const task = requireTask(fresh, args.task_id);
1705
+ const input = {
1706
+ ...args.objective === undefined ? {} : { objective: args.objective },
1707
+ ...args.acceptance === undefined ? {} : { acceptance: args.acceptance },
1708
+ ...args.verify === undefined ? {} : { verify: args.verify },
1709
+ ...args.inScope === undefined ? {} : { inScope: args.inScope },
1710
+ ...args.outOfScope === undefined ? {} : { outOfScope: args.outOfScope },
1711
+ };
1712
+ const result = amendTaskContract(fresh, task, normalizeBlankOptionalTaskFields(input), CAPTAIN_KEY, args.reason);
1713
+ if (!result.ok || result.task === undefined) {
1714
+ throw new Error(result.error ?? 'amend_task rejected by quality gates');
1715
+ }
1716
+ Object.assign(task, result.task);
1717
+ task.updatedAt = Date.now();
1718
+ await writeTeam(stateRoot, fresh);
1719
+ return {
1720
+ taskId: task.id,
1721
+ status: task.status,
1722
+ fields: result.revision?.fields ?? [],
1723
+ revisionCount: task.revisions?.length ?? 0,
1724
+ contract: {
1725
+ ...task.objective === undefined ? {} : { objective: task.objective },
1726
+ ...task.acceptance === undefined ? {} : { acceptance: task.acceptance },
1727
+ ...task.verify === undefined ? {} : { verify: task.verify },
1728
+ ...task.inScope === undefined ? {} : { inScope: task.inScope },
1729
+ ...task.outOfScope === undefined ? {} : { outOfScope: task.outOfScope },
1730
+ },
1731
+ };
1732
+ });
1733
+ appendTeamEvent(ctx, captainSessionOf(ctx, team.captainSessionId, captain.session), 'agent-teams/task-amended', {
1734
+ teamId: team.id,
1735
+ taskId: amended.taskId,
1736
+ fields: amended.fields,
1737
+ reason: args.reason,
1738
+ });
1739
+ return {
1740
+ task_id: amended.taskId,
1741
+ status: amended.status,
1742
+ revised_fields: amended.fields.join(', '),
1743
+ revision_count: amended.revisionCount,
1744
+ contract: JSON.stringify(amended.contract),
1745
+ };
1746
+ },
1747
+ }));
1608
1748
  ctx.tools.register(defineTool({
1609
1749
  name: 'agent_teams_send_message',
1610
- description: 'Send a message to the captain or to a teammate. Messages go straight into the recipient\'s mailbox; when the captain agent is online the plugin also schedules live delivery (member recipients get the message as their next turn; a running captain sees it at the nearest model step). No relay is involved: teammates talk to each other directly, exactly like the Claude Code AgentTeams mailbox model.',
1750
+ description: 'Send coordination or current-task guidance directly to the captain or a teammate. A running recipient receives it at the next model step; an idle recipient wakes. Messages are durably retained until read. Use task creation/reassignment for a new unit of work, not repeated status nudges.',
1611
1751
  parameters: {
1612
1752
  to: { type: 'string', required: true, description: 'Recipient: "captain" or a member name.' },
1613
1753
  content: { type: 'string', required: true, description: 'The message text.' },
@@ -1660,7 +1800,10 @@ export function registerAgentTeamsTools(ctx, config) {
1660
1800
  throw new Error(`team "${fresh.name}" is halted; call agent_teams_resume before waking a member`);
1661
1801
  }
1662
1802
  const recipient = requireMember(fresh, to);
1663
- const message = { ...createMessage(from, recipient.name, args.content), deliveryClaimedAt: Date.now() };
1803
+ const owned = memberOpenTask(fresh, recipient.name);
1804
+ const message = { ...createMessage(from, recipient.name, args.content), deliveryClaimedAt: Date.now(),
1805
+ ...owned?.attemptId === undefined ? {} : { taskId: owned.id, attemptId: owned.attemptId },
1806
+ };
1664
1807
  await appendMailbox(stateRoot, fresh.id, recipient.name, message);
1665
1808
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
1666
1809
  teamId: fresh.id,
@@ -1678,10 +1821,10 @@ export function registerAgentTeamsTools(ctx, config) {
1678
1821
  if (prepared.kind === 'captain') {
1679
1822
  let delivered = 'mailbox';
1680
1823
  if (captain !== undefined && prepared.identity.kind === 'member') {
1681
- delivered = steerCaptainReport(captain, prepared.from, args.content) ? 'live' : 'mailbox';
1824
+ delivered = steerCaptainReport(captain, prepared.from, args.content, mailboxPrompt(prepared.fresh.id, CAPTAIN_KEY, [prepared.message])) ? 'live' : 'mailbox';
1682
1825
  }
1683
1826
  if (delivered === 'live') {
1684
- await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
1827
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (markMailboxDelivered(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
1685
1828
  }
1686
1829
  else {
1687
1830
  await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (releaseMailboxDelivery(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
@@ -1689,15 +1832,12 @@ export function registerAgentTeamsTools(ctx, config) {
1689
1832
  return { message_id: prepared.message.id, from: prepared.from, to: CAPTAIN_KEY, delivered };
1690
1833
  }
1691
1834
  let delivered = 'mailbox';
1692
- if (captain !== undefined && prepared.recipient.id !== '') {
1693
- const senderText = prepared.from === CAPTAIN_KEY
1694
- ? args.content
1695
- : `Message from team member ${prepared.from}:\n\n${args.content}`;
1696
- const text = `AgentTeams state policy: inspect ${config.stateDir}/${prepared.fresh.id}/ read-only; never edit team.json or inbox files directly. Use agent_teams_* tools for team state.\n\n${senderText}`;
1697
- const accepted = await deliverToMember(ctx, captain, prepared.recipient.id, text, exec.signal);
1835
+ if (captain !== undefined) {
1836
+ const text = mailboxPrompt(prepared.fresh.id, prepared.recipient.name, [prepared.message]);
1837
+ const accepted = await dispatchMember(captain, prepared.fresh.id, prepared.recipient.name, text, exec.signal, 'steer', prepared.message.attemptId);
1698
1838
  delivered = accepted ? 'wake' : 'mailbox';
1699
1839
  if (accepted) {
1700
- await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, prepared.recipient.name, [prepared.message.id])));
1840
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (markMailboxDelivered(stateRoot, prepared.fresh.id, prepared.recipient.name, [prepared.message.id])));
1701
1841
  }
1702
1842
  }
1703
1843
  if (delivered === 'mailbox') {
@@ -1739,6 +1879,7 @@ export function registerAgentTeamsTools(ctx, config) {
1739
1879
  reasoning_effort: member.reasoningEffort ?? '',
1740
1880
  status: member.status,
1741
1881
  activity: member.id !== '' ? (activity.get(member.id) ?? 'unknown') : 'unspawned',
1882
+ ...member.spawnError === undefined ? {} : { spawn_error: member.spawnError },
1742
1883
  }));
1743
1884
  const tasks = team.tasks.map((task) => ({
1744
1885
  id: task.id,
@@ -1767,6 +1908,7 @@ export function registerAgentTeamsTools(ctx, config) {
1767
1908
  const captainInbox = identity.kind === 'captain'
1768
1909
  ? await readUnreadMailbox(stateRoot, team.id, CAPTAIN_KEY, reportMalformed(CAPTAIN_KEY))
1769
1910
  : [];
1911
+ const ownInbox = identity.kind === 'member' ? (await readUnreadMailbox(stateRoot, team.id, identity.name)).slice(0, 10) : [];
1770
1912
  const memberInboxes = {};
1771
1913
  const visibleMembers = identity.kind === 'captain'
1772
1914
  ? members
@@ -1813,18 +1955,19 @@ export function registerAgentTeamsTools(ctx, config) {
1813
1955
  viewer: identity.name,
1814
1956
  members,
1815
1957
  tasks,
1816
- captain_inbox: captainInbox.slice(-10).map((message) => ({
1958
+ captain_inbox: captainInbox.slice(0, 10).map((message) => ({
1817
1959
  from: message.from,
1818
1960
  content: message.content,
1819
1961
  ts: message.ts,
1820
1962
  })),
1963
+ member_inbox: ownInbox.map(message => ({ from: message.from, content: message.content, ts: message.ts })),
1821
1964
  member_inboxes: memberInboxes,
1822
1965
  mailbox_warnings: mailboxWarnings,
1823
1966
  mailbox_warning_count: mailboxWarningCount,
1824
1967
  };
1825
1968
  const acknowledged = identity.kind === 'captain'
1826
- ? captainInbox.map(message => message.id)
1827
- : await readUnreadMailbox(stateRoot, team.id, identity.name).then(messages => messages.map(message => message.id));
1969
+ ? captainInbox.slice(0, 10).map(message => message.id)
1970
+ : ownInbox.map(message => message.id);
1828
1971
  if (acknowledged.length > 0) {
1829
1972
  await withTeamLock(teamLockKey(stateRoot, team.id), () => (acknowledgeMailbox(stateRoot, team.id, identity.kind === 'captain' ? CAPTAIN_KEY : identity.name, acknowledged)));
1830
1973
  }
@@ -1913,6 +2056,7 @@ export function registerAgentTeamsTools(ctx, config) {
1913
2056
  // retires durable catalog entries left behind by remove_member.
1914
2057
  const roster = fresh.members.map(member => ({ ...member }));
1915
2058
  for (const member of fresh.members) {
2059
+ await discardMailboxMessages(stateRoot, fresh.id, member.name, (await readUnreadMailbox(stateRoot, fresh.id, member.name)).map(message => message.id));
1916
2060
  if (member.status === 'removed')
1917
2061
  continue;
1918
2062
  member.status = 'removed';
@@ -1925,17 +2069,7 @@ export function registerAgentTeamsTools(ctx, config) {
1925
2069
  return roster;
1926
2070
  });
1927
2071
  await recordRetiredMemberIds(stateRoot, members.map(member => member.id));
1928
- for (const member of members) {
1929
- if (member.id === '')
1930
- continue;
1931
- interruptMember(ctx, captain, member.id);
1932
- }
1933
- const quiescence = await Promise.allSettled(members.map(member => waitForMemberIdle(ctx, member, exec.signal)));
1934
- for (const result of quiescence) {
1935
- if (result.status === 'rejected') {
1936
- ctx.logger.warn(`agent-teams: member did not quiesce cleanly before team archive: ${String(result.reason)}`);
1937
- }
1938
- }
2072
+ await stopTeamMemberActivations(ctx, captain, members, exec.signal);
1939
2073
  await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1940
2074
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1941
2075
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-deleted', {
@@ -1951,7 +2085,7 @@ export function registerAgentTeamsTools(ctx, config) {
1951
2085
  return runtime;
1952
2086
  }
1953
2087
  async function initializeProfileTeam(input) {
1954
- const profile = resolveTeamProfile(input.config.profiles, input.profileName, input.config.maxMembers);
2088
+ const profile = resolveTeamProfile(input.inlinePlan === undefined ? input.config.profiles : { [input.profileName]: input.inlinePlan }, input.profileName, input.config.maxMembers);
1955
2089
  const selections = [];
1956
2090
  for (const template of profile.members) {
1957
2091
  selections.push(await resolveMemberLlmSelection(input.ctx, input.captain, {
@@ -2011,50 +2145,12 @@ async function initializeProfileTeam(input) {
2011
2145
  })),
2012
2146
  taskSeq: profile.tasks.length,
2013
2147
  };
2014
- if (input.staged) {
2015
- await createTeamDir(input.stateRoot, draft);
2016
- return { committed: true, state: draft };
2017
- }
2018
- const spawned = [];
2019
- try {
2020
- for (const member of draft.members) {
2021
- const selection = selections[spawned.length];
2022
- await spawnMember(input.ctx, memberRuntime(input.config), input.memberSelections, selection, input.captain, draft, member, input.config.stateDir, input.exec.signal);
2023
- spawned.push(member);
2024
- }
2025
- if (draft.members.some((member) => member.id === '')) {
2026
- throw new Error(`failed to initialize profile "${profile.name}": a spawned member is missing its child id`);
2027
- }
2028
- await createTeamDir(input.stateRoot, draft);
2029
- return { committed: true, state: draft };
2030
- }
2031
- catch (error) {
2032
- const cleanupErrors = [];
2033
- try {
2034
- await removeTeamDir(input.stateRoot, draft.id);
2035
- }
2036
- catch (cleanupError) {
2037
- cleanupErrors.push(cleanupError);
2038
- }
2039
- try {
2040
- await recordRetiredMemberIds(input.stateRoot, spawned.map((member) => member.id));
2041
- }
2042
- catch (cleanupError) {
2043
- cleanupErrors.push(cleanupError);
2044
- }
2045
- for (const member of spawned) {
2046
- try {
2047
- interruptMember(input.ctx, input.captain, member.id);
2048
- }
2049
- catch (cleanupError) {
2050
- cleanupErrors.push(cleanupError);
2051
- }
2052
- }
2053
- if (cleanupErrors.length > 0) {
2054
- throw new AggregateError([error, ...cleanupErrors], `failed to initialize profile "${profile.name}"`);
2055
- }
2056
- throw error;
2057
- }
2148
+ // Roster creation is durable planning only. The scheduler starts each
2149
+ // member with its first actual task once its dependencies are satisfied.
2150
+ if (input.inlinePlan !== undefined)
2151
+ delete draft.profile;
2152
+ await createTeamDir(input.stateRoot, draft);
2153
+ return { committed: true, state: draft };
2058
2154
  }
2059
2155
  function parseFindings(value) {
2060
2156
  if (value === undefined)
@@ -2223,7 +2319,8 @@ function renderStatus(value) {
2223
2319
  ...team.members.map((member) => {
2224
2320
  const route = member.provider && member.model ? ` · ${member.provider}/${member.model}` : '';
2225
2321
  const effort = member.reasoning_effort ? ` · reasoning ${member.reasoning_effort}` : '';
2226
- return ` - ${member.name} [${member.role}] ${member.status}/${member.activity}${route}${effort}`;
2322
+ const failure = member.spawn_error === undefined ? '' : `\n start failed: ${member.spawn_error.slice(0, 400)}`;
2323
+ return ` - ${member.name} [${member.role}] ${member.status}/${member.activity}${route}${effort}${failure}`;
2227
2324
  }),
2228
2325
  `Tasks (${team.tasks.length}):`,
2229
2326
  ...team.tasks.map((task) => {
@@ -2244,7 +2341,8 @@ function renderStatus(value) {
2244
2341
  `Delivery: ${team.delivery.ok ? 'ok' : `blocked (${team.delivery.blockers.join('; ')})`}`,
2245
2342
  ],
2246
2343
  `Captain inbox (${team.captain_inbox.length}):`,
2247
- ...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content.slice(0, 200)}`),
2344
+ ...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content}`),
2345
+ ...(team.member_inbox ?? []).map(message => ` - [${message.from}] ${message.content}`),
2248
2346
  ];
2249
2347
  for (const [name, inbox] of Object.entries(team.member_inboxes)) {
2250
2348
  lines.push(`Member inbox ${name} (${inbox.count}): latest — ${inbox.latest.slice(0, 120)}`);