@nanmicoder/dsh-agent-teams 0.1.5 → 0.1.6

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,8 +12,10 @@ 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 { appendMailbox, archiveTeamDir, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, readMailbox, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
16
- import { deliverToMember, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, } from "./members.js";
15
+ import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
16
+ import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, } from "./members.js";
17
+ import { TERMINAL_TASK_STATUSES } from "./types.js";
18
+ import { installTeamScheduler } from "./scheduler.js";
17
19
  /** The caller agent, or a loud failure for non-agent callers. */
18
20
  function requireCaptain(exec) {
19
21
  if (!exec.agent) {
@@ -99,6 +101,31 @@ function requireTask(team, taskId) {
99
101
  }
100
102
  return task;
101
103
  }
104
+ function memberOpenTask(team, memberName, exceptTaskId) {
105
+ return team.tasks.find(task => task.id !== exceptTaskId
106
+ && task.assignee === memberName
107
+ && (task.status === 'claimed' || task.status === 'in_progress'));
108
+ }
109
+ async function waitForMemberIdle(ctx, member, signal) {
110
+ if (member.id === '')
111
+ return;
112
+ const live = ctx.agents.get(member.id);
113
+ if (live === undefined)
114
+ return;
115
+ if (signal.aborted)
116
+ throw signal.reason;
117
+ let onAbort;
118
+ const aborted = new Promise((_resolve, reject) => {
119
+ onAbort = () => reject(signal.reason ?? new Error('task reassignment was cancelled'));
120
+ signal.addEventListener('abort', onAbort, { once: true });
121
+ });
122
+ try {
123
+ await Promise.race([live.whenIdle(), aborted]);
124
+ }
125
+ finally {
126
+ signal.removeEventListener('abort', onAbort);
127
+ }
128
+ }
102
129
  /**
103
130
  * Deliver a durable member report at the captain's nearest model boundary.
104
131
  *
@@ -126,7 +153,9 @@ export function steerCaptainReport(captain, from, content) {
126
153
  * @param config - resolved tool config.
127
154
  */
128
155
  export function registerAgentTeamsTools(ctx, config) {
156
+ installRetiredMemberGuard(ctx, config.stateDir);
129
157
  const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir);
158
+ const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir });
130
159
  ctx.tools.register(defineTool({
131
160
  name: 'agent_teams_create',
132
161
  description: 'Create a new AgentTeams team: you (the calling agent) become the captain. A captain leads one team at a time; create tasks and members afterwards with agent_teams_add_member and agent_teams_create_task.',
@@ -222,7 +251,7 @@ export function registerAgentTeamsTools(ctx, config) {
222
251
  const workspace = workspaceOf(captain);
223
252
  const stateRoot = stateRootOf(workspace, config);
224
253
  const team = await requireCaptainTeam(workspace, config, captain);
225
- return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
254
+ const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
226
255
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
227
256
  const memberName = args.name.trim();
228
257
  if (memberName === '')
@@ -272,11 +301,13 @@ export function registerAgentTeamsTools(ctx, config) {
272
301
  status: member.status,
273
302
  };
274
303
  });
304
+ await scheduler.kickMember(workspace, team.id, created.member_name, captain);
305
+ return created;
275
306
  },
276
307
  }));
277
308
  ctx.tools.register(defineTool({
278
309
  name: 'agent_teams_remove_member',
279
- description: 'Remove a member from your team: interrupts its live turn (best effort) and marks it removed. Its mailbox and past task outputs stay on disk.',
310
+ description: 'Remove a member safely: revoke its current attempts, return all unfinished owned tasks to the shared pending pool, interrupt its live turn, and mark it removed.',
280
311
  parameters: {
281
312
  name: { type: 'string', required: true, description: 'Name of the member to remove.' },
282
313
  },
@@ -287,11 +318,12 @@ export function registerAgentTeamsTools(ctx, config) {
287
318
  properties: {
288
319
  member_name: { type: 'string', required: true },
289
320
  status: { type: 'string', required: true },
321
+ requeued_tasks: { type: 'array', items: { type: 'string' }, required: true },
290
322
  },
291
323
  },
292
324
  render: (args, value) => [{
293
325
  type: 'text',
294
- text: `Member "${value.member_name}" removed (status ${value.status}).`,
326
+ text: `Member "${value.member_name}" removed (status ${value.status}); requeued tasks: ${value.requeued_tasks.join(', ') || 'none'}.`,
295
327
  }],
296
328
  },
297
329
  async execute(args, exec) {
@@ -299,19 +331,36 @@ export function registerAgentTeamsTools(ctx, config) {
299
331
  const workspace = workspaceOf(captain);
300
332
  const stateRoot = stateRootOf(workspace, config);
301
333
  const team = await requireCaptainTeam(workspace, config, captain);
302
- return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
334
+ const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
303
335
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
304
336
  const member = requireMember(fresh, args.name);
305
- if (member.id !== '')
306
- interruptMember(ctx, captain, member.id);
337
+ const requeued = [];
338
+ for (const task of fresh.tasks) {
339
+ if (task.assignee !== member.name || task.status === 'completed')
340
+ continue;
341
+ invalidateTaskAttempt(task);
342
+ task.reassigning = false;
343
+ requeued.push(task.id);
344
+ }
307
345
  member.status = 'removed';
308
346
  await writeTeam(stateRoot, fresh);
309
347
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-removed', {
310
348
  teamId: fresh.id,
311
349
  memberId: member.id,
312
350
  });
313
- return { member_name: member.name, status: member.status };
351
+ return { member: { ...member }, requeued };
314
352
  });
353
+ if (revoked.member.id !== '') {
354
+ await recordRetiredMemberIds(stateRoot, [revoked.member.id]);
355
+ interruptMember(ctx, captain, revoked.member.id);
356
+ await waitForMemberIdle(ctx, revoked.member, exec.signal);
357
+ }
358
+ await scheduler.kickTeam(workspace, team.id, captain);
359
+ return {
360
+ member_name: revoked.member.name,
361
+ status: revoked.member.status,
362
+ requeued_tasks: revoked.requeued,
363
+ };
315
364
  },
316
365
  }));
317
366
  ctx.tools.register(defineTool({
@@ -348,7 +397,7 @@ export function registerAgentTeamsTools(ctx, config) {
348
397
  const workspace = workspaceOf(captain);
349
398
  const stateRoot = stateRootOf(workspace, config);
350
399
  const team = await requireCaptainTeam(workspace, config, captain);
351
- return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
400
+ const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
352
401
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
353
402
  const dependencies = args.dependencies ?? [];
354
403
  for (const dependency of dependencies) {
@@ -365,6 +414,7 @@ export function registerAgentTeamsTools(ctx, config) {
365
414
  status: 'pending',
366
415
  assignee: args.assignee,
367
416
  dependencies,
417
+ attempt: 0,
368
418
  createdAt: Date.now(),
369
419
  updatedAt: Date.now(),
370
420
  };
@@ -385,11 +435,120 @@ export function registerAgentTeamsTools(ctx, config) {
385
435
  ...task.assignee !== undefined ? { assignee: task.assignee } : {},
386
436
  };
387
437
  });
438
+ await scheduler.kickTeam(workspace, team.id, captain);
439
+ return created;
440
+ },
441
+ }));
442
+ ctx.tools.register(defineTool({
443
+ name: 'agent_teams_reassign_task',
444
+ description: 'Atomically retry, reassign, or let the captain take over any unfinished/failed task. The old attempt is revoked before its member is interrupted, so late updates cannot overwrite the new owner. Use assignee="captain" for captain takeover.',
445
+ parameters: {
446
+ task_id: { type: 'string', required: true, description: 'Task to retry/reassign.' },
447
+ assignee: { type: 'string', required: true, description: 'Active member name, or "captain" for captain takeover.' },
448
+ reason: { type: 'string', description: 'Why the task is being retried or reassigned.' },
449
+ },
450
+ output: {
451
+ schema: {
452
+ type: 'object',
453
+ additionalProperties: false,
454
+ properties: {
455
+ task_id: { type: 'string', required: true },
456
+ previous_assignee: { type: 'string', required: true },
457
+ assignee: { type: 'string', required: true },
458
+ status: { type: 'string', required: true },
459
+ attempt: { type: 'number', required: true },
460
+ attempt_id: { type: 'string' },
461
+ },
462
+ },
463
+ render: (_args, value) => [{
464
+ type: 'text',
465
+ text: `Task ${value.task_id} reassigned ${value.previous_assignee || 'unassigned'} → ${value.assignee} (attempt ${value.attempt}, status ${value.status}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}).`,
466
+ }],
467
+ },
468
+ async execute(args, exec) {
469
+ const captain = requireCaptain(exec);
470
+ const workspace = workspaceOf(captain);
471
+ const stateRoot = stateRootOf(workspace, config);
472
+ const team = await requireCaptainTeam(workspace, config, captain);
473
+ const target = args.assignee.trim();
474
+ if (target === '')
475
+ throw new Error('reassignment assignee must not be empty');
476
+ const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
477
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
478
+ const task = requireTask(fresh, args.task_id);
479
+ if (task.status === 'completed')
480
+ throw new Error(`completed task ${task.id} is immutable and cannot be reassigned`);
481
+ if (task.reassigning === true)
482
+ throw new Error(`task ${task.id} is already being reassigned`);
483
+ const targetMember = target === CAPTAIN_KEY ? undefined : requireMember(fresh, target);
484
+ if (targetMember !== undefined) {
485
+ const busy = memberOpenTask(fresh, targetMember.name, task.id);
486
+ if (busy !== undefined) {
487
+ throw new Error(`member "${targetMember.name}" is busy with ${busy.id}; finish or reassign it first`);
488
+ }
489
+ }
490
+ const previousAssignee = task.assignee ?? '';
491
+ const previousMember = (task.status !== 'claimed' && task.status !== 'in_progress')
492
+ || task.assignee === undefined || task.assignee === CAPTAIN_KEY
493
+ ? undefined
494
+ : fresh.members.find(member => member.name === task.assignee && member.status !== 'removed');
495
+ invalidateTaskAttempt(task, target, true);
496
+ await writeTeam(stateRoot, fresh);
497
+ return {
498
+ previousAssignee,
499
+ previousMember: previousMember === undefined ? undefined : { ...previousMember },
500
+ handoffId: task.handoffId,
501
+ };
502
+ });
503
+ let quiescenceError;
504
+ if (revoked.previousMember !== undefined) {
505
+ interruptMember(ctx, captain, revoked.previousMember.id);
506
+ try {
507
+ await waitForMemberIdle(ctx, revoked.previousMember, exec.signal);
508
+ }
509
+ catch (error) {
510
+ quiescenceError = error;
511
+ }
512
+ }
513
+ await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
514
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
515
+ const task = requireTask(fresh, args.task_id);
516
+ if (task.handoffId !== revoked.handoffId || task.assignee !== target || task.reassigning !== true) {
517
+ throw new Error(`task ${task.id} changed during reassignment; refusing to overwrite the newer state`);
518
+ }
519
+ task.reassigning = false;
520
+ if (quiescenceError === undefined && target === CAPTAIN_KEY)
521
+ beginTaskAttempt(task, CAPTAIN_KEY);
522
+ await writeTeam(stateRoot, fresh);
523
+ appendTeamEvent(ctx, captain.session, 'agent-teams/task-updated', {
524
+ teamId: fresh.id,
525
+ taskId: task.id,
526
+ status: task.status,
527
+ assignee: task.assignee,
528
+ ...args.reason === undefined ? {} : { output: `Reassigned: ${args.reason}` },
529
+ });
530
+ });
531
+ if (quiescenceError !== undefined)
532
+ throw quiescenceError;
533
+ if (target !== CAPTAIN_KEY)
534
+ await scheduler.kickMember(workspace, team.id, target, captain);
535
+ const current = await readTeam(stateRoot, team.id);
536
+ const task = current === undefined ? undefined : requireTask(current, args.task_id);
537
+ if (task === undefined)
538
+ throw new Error(`team "${team.name}" ended during reassignment`);
539
+ return {
540
+ task_id: task.id,
541
+ previous_assignee: revoked.previousAssignee,
542
+ assignee: task.assignee ?? '',
543
+ status: task.status,
544
+ attempt: task.attempt ?? 0,
545
+ ...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
546
+ };
388
547
  },
389
548
  }));
390
549
  ctx.tools.register(defineTool({
391
550
  name: 'agent_teams_claim_task',
392
- description: 'Claim a task for a member (or for yourself when you are the member). Blocked while any dependency is unfinished — the error lists the pending dependencies. The captain may claim on behalf of an assignee; a member may only claim tasks assigned to it (or unassigned).',
551
+ description: 'Claim one ready task for a member (or yourself). A member cannot own a second unfinished task. The returned attempt_id is required for that member\'s updates and becomes stale after retry/reassignment.',
393
552
  parameters: {
394
553
  task_id: { type: 'string', required: true, description: 'The task id to claim.' },
395
554
  assignee: { type: 'string', description: 'Member to claim for (captain only; defaults to the task\'s assignee).' },
@@ -402,11 +561,13 @@ export function registerAgentTeamsTools(ctx, config) {
402
561
  task_id: { type: 'string', required: true },
403
562
  status: { type: 'string', required: true },
404
563
  assignee: { type: 'string', required: true },
564
+ attempt: { type: 'number', required: true },
565
+ attempt_id: { type: 'string' },
405
566
  },
406
567
  },
407
568
  render: (args, value) => [{
408
569
  type: 'text',
409
- text: `Task ${value.task_id} claimed by ${value.assignee} (status ${value.status}).`,
570
+ text: `Task ${value.task_id} claimed by ${value.assignee} (attempt ${value.attempt}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}, status ${value.status}).`,
410
571
  }],
411
572
  },
412
573
  async execute(args, exec) {
@@ -417,6 +578,9 @@ export function registerAgentTeamsTools(ctx, config) {
417
578
  return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
418
579
  const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
419
580
  const task = requireTask(fresh, args.task_id);
581
+ if (task.reassigning === true) {
582
+ throw new Error(`task ${task.id} is being reassigned; wait for the handoff to finish`);
583
+ }
420
584
  let assignee = task.assignee;
421
585
  if (identity.kind === 'captain') {
422
586
  if (args.assignee !== undefined) {
@@ -439,7 +603,13 @@ export function registerAgentTeamsTools(ctx, config) {
439
603
  if (assignee === undefined || task.assignee !== assignee) {
440
604
  throw new Error(`task ${task.id} is already claimed by "${task.assignee ?? 'nobody'}"`);
441
605
  }
442
- return { task_id: task.id, status: task.status, assignee };
606
+ return {
607
+ task_id: task.id,
608
+ status: task.status,
609
+ assignee,
610
+ attempt: task.attempt ?? 0,
611
+ ...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
612
+ };
443
613
  }
444
614
  const pending = unsatisfiedDependencies(fresh.tasks, task.dependencies);
445
615
  if (pending.length > 0) {
@@ -451,9 +621,11 @@ export function registerAgentTeamsTools(ctx, config) {
451
621
  if (assignee === undefined) {
452
622
  throw new Error('claiming an unassigned task needs an assignee (claim on behalf of a member)');
453
623
  }
454
- task.status = 'claimed';
455
- task.assignee = assignee;
456
- task.updatedAt = Date.now();
624
+ const busy = memberOpenTask(fresh, assignee, task.id);
625
+ if (busy !== undefined) {
626
+ throw new Error(`member "${assignee}" is busy with ${busy.id}; finish or reassign it first`);
627
+ }
628
+ const attemptId = beginTaskAttempt(task, assignee);
457
629
  await writeTeam(stateRoot, fresh);
458
630
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/task-updated', {
459
631
  teamId: fresh.id,
@@ -461,13 +633,19 @@ export function registerAgentTeamsTools(ctx, config) {
461
633
  status: task.status,
462
634
  assignee: task.assignee,
463
635
  });
464
- return { task_id: task.id, status: task.status, assignee: task.assignee ?? '' };
636
+ return {
637
+ task_id: task.id,
638
+ status: task.status,
639
+ assignee: task.assignee ?? '',
640
+ attempt: task.attempt ?? 0,
641
+ attempt_id: attemptId,
642
+ };
465
643
  });
466
644
  },
467
645
  }));
468
646
  ctx.tools.register(defineTool({
469
647
  name: 'agent_teams_update_task',
470
- description: 'Update a task\'s status and/or write its output. Transitions: claimed in_progress completed|failed|cancelled (pending may also be cancelled). The captain may update any task; a member may only update tasks assigned to it. Set output when completing or failing a task.',
648
+ 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.',
471
649
  parameters: {
472
650
  task_id: { type: 'string', required: true, description: 'The task id to update.' },
473
651
  status: {
@@ -476,6 +654,7 @@ export function registerAgentTeamsTools(ctx, config) {
476
654
  description: 'New status (in_progress, completed, failed, cancelled).',
477
655
  },
478
656
  output: { type: 'string', description: 'Result summary; set when completing or failing.' },
657
+ attempt_id: { type: 'string', description: 'Current execution capability returned by claim_task (required for members when present on the task).' },
479
658
  },
480
659
  output: {
481
660
  schema: {
@@ -485,11 +664,13 @@ export function registerAgentTeamsTools(ctx, config) {
485
664
  task_id: { type: 'string', required: true },
486
665
  status: { type: 'string', required: true },
487
666
  output: { type: 'string' },
667
+ attempt: { type: 'number', required: true },
668
+ attempt_id: { type: 'string' },
488
669
  },
489
670
  },
490
671
  render: (args, value) => [{
491
672
  type: 'text',
492
- text: `Task ${value.task_id} → ${value.status}${value.output !== undefined ? `\nOutput: ${value.output}` : ''}`,
673
+ text: `Task ${value.task_id} attempt ${value.attempt} → ${value.status}${value.output !== undefined ? `\nOutput: ${value.output}` : ''}`,
493
674
  }],
494
675
  },
495
676
  async execute(args, exec) {
@@ -497,13 +678,35 @@ export function registerAgentTeamsTools(ctx, config) {
497
678
  const workspace = workspaceOf(caller);
498
679
  const stateRoot = stateRootOf(workspace, config);
499
680
  const team = await requireParticipantTeam(workspace, config, caller);
500
- return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
681
+ const updated = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
501
682
  const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
502
683
  const task = requireTask(fresh, args.task_id);
684
+ if (identity.kind === 'captain'
685
+ && task.assignee !== undefined
686
+ && task.assignee !== CAPTAIN_KEY) {
687
+ throw new Error(`task ${task.id} is owned by member "${task.assignee}"; call agent_teams_reassign_task with assignee="captain" before takeover`);
688
+ }
503
689
  if (identity.kind === 'member') {
504
690
  if (task.assignee !== identity.name) {
505
691
  throw new Error(`task ${task.id} is assigned to "${task.assignee ?? 'nobody'}", not you`);
506
692
  }
693
+ if (task.attemptId !== undefined && args.attempt_id !== task.attemptId) {
694
+ throw new Error(`stale attempt for task ${task.id}: expected the current attempt_id; stop work and request fresh assignment`);
695
+ }
696
+ }
697
+ if (TERMINAL_TASK_STATUSES.includes(task.status)) {
698
+ const sameStatus = args.status === undefined || args.status === task.status;
699
+ const sameOutput = args.output === undefined || args.output === task.output;
700
+ if (!sameStatus || !sameOutput) {
701
+ throw new Error(`terminal task ${task.id} is immutable; use agent_teams_reassign_task to retry failed/cancelled work`);
702
+ }
703
+ return {
704
+ task_id: task.id,
705
+ status: task.status,
706
+ attempt: task.attempt ?? 0,
707
+ ...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
708
+ ...task.output !== undefined ? { output: task.output } : {},
709
+ };
507
710
  }
508
711
  if (args.status !== undefined) {
509
712
  const transition = transitionError(task.status, args.status);
@@ -525,9 +728,13 @@ export function registerAgentTeamsTools(ctx, config) {
525
728
  return {
526
729
  task_id: task.id,
527
730
  status: task.status,
731
+ attempt: task.attempt ?? 0,
732
+ ...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
528
733
  ...task.output !== undefined ? { output: task.output } : {},
529
734
  };
530
735
  });
736
+ await scheduler.kickTeam(workspace, team.id, team.captainSessionId === caller.id ? caller : undefined);
737
+ return updated;
531
738
  },
532
739
  }));
533
740
  ctx.tools.register(defineTool({
@@ -569,7 +776,7 @@ export function registerAgentTeamsTools(ctx, config) {
569
776
  throw new Error(`agent_teams_send_message: "from" must be your own identity ("${from}"), not "${args.from}"`);
570
777
  }
571
778
  if (to === CAPTAIN_KEY) {
572
- const message = createMessage(from, CAPTAIN_KEY, args.content);
779
+ const message = { ...createMessage(from, CAPTAIN_KEY, args.content), deliveryClaimedAt: Date.now() };
573
780
  await appendMailbox(stateRoot, fresh.id, CAPTAIN_KEY, message);
574
781
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
575
782
  teamId: fresh.id,
@@ -582,7 +789,7 @@ export function registerAgentTeamsTools(ctx, config) {
582
789
  return { kind: 'captain', fresh, identity, message, from };
583
790
  }
584
791
  const recipient = requireMember(fresh, to);
585
- const message = createMessage(from, recipient.name, args.content);
792
+ const message = { ...createMessage(from, recipient.name, args.content), deliveryClaimedAt: Date.now() };
586
793
  await appendMailbox(stateRoot, fresh.id, recipient.name, message);
587
794
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
588
795
  teamId: fresh.id,
@@ -602,6 +809,12 @@ export function registerAgentTeamsTools(ctx, config) {
602
809
  if (captain !== undefined && prepared.identity.kind === 'member') {
603
810
  delivered = steerCaptainReport(captain, prepared.from, args.content) ? 'live' : 'mailbox';
604
811
  }
812
+ if (delivered === 'live') {
813
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
814
+ }
815
+ else {
816
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (releaseMailboxDelivery(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
817
+ }
605
818
  return { message_id: prepared.message.id, from: prepared.from, to: CAPTAIN_KEY, delivered };
606
819
  }
607
820
  let delivered = 'mailbox';
@@ -612,6 +825,12 @@ export function registerAgentTeamsTools(ctx, config) {
612
825
  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}`;
613
826
  const accepted = await deliverToMember(ctx, captain, prepared.recipient.id, text, exec.signal);
614
827
  delivered = accepted ? 'wake' : 'mailbox';
828
+ if (accepted) {
829
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, prepared.recipient.name, [prepared.message.id])));
830
+ }
831
+ }
832
+ if (delivered === 'mailbox') {
833
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (releaseMailboxDelivery(stateRoot, prepared.fresh.id, prepared.recipient.name, [prepared.message.id])));
615
834
  }
616
835
  return {
617
836
  message_id: prepared.message.id,
@@ -634,6 +853,9 @@ export function registerAgentTeamsTools(ctx, config) {
634
853
  const workspace = workspaceOf(caller);
635
854
  const stateRoot = stateRootOf(workspace, config);
636
855
  const located = await requireParticipantTeam(workspace, config, caller);
856
+ if (located.captainSessionId === caller.id) {
857
+ await scheduler.kickTeam(workspace, located.id, caller);
858
+ }
637
859
  const { team, identity } = await withTeamLock(teamLockKey(stateRoot, located.id), () => requireFreshParticipant(stateRoot, located.id, caller.id));
638
860
  const activity = await memberActivity(ctx, team.captainSessionId);
639
861
  const members = team.members
@@ -653,6 +875,9 @@ export function registerAgentTeamsTools(ctx, config) {
653
875
  status: task.status,
654
876
  assignee: task.assignee ?? '',
655
877
  dependencies: task.dependencies,
878
+ attempt: task.attempt ?? 0,
879
+ attempt_id: task.attemptId ?? '',
880
+ reassigning: task.reassigning === true,
656
881
  ...task.output !== undefined ? { output: task.output } : {},
657
882
  }));
658
883
  const mailboxWarnings = [];
@@ -664,14 +889,14 @@ export function registerAgentTeamsTools(ctx, config) {
664
889
  }
665
890
  };
666
891
  const captainInbox = identity.kind === 'captain'
667
- ? await readMailbox(stateRoot, team.id, CAPTAIN_KEY, reportMalformed(CAPTAIN_KEY))
892
+ ? await readUnreadMailbox(stateRoot, team.id, CAPTAIN_KEY, reportMalformed(CAPTAIN_KEY))
668
893
  : [];
669
894
  const memberInboxes = {};
670
895
  const visibleMembers = identity.kind === 'captain'
671
896
  ? members
672
897
  : members.filter((member) => member.name === identity.name);
673
898
  for (const member of visibleMembers) {
674
- const messages = await readMailbox(stateRoot, team.id, member.name, reportMalformed(member.name));
899
+ const messages = await readUnreadMailbox(stateRoot, team.id, member.name, reportMalformed(member.name));
675
900
  if (messages.length > 0) {
676
901
  memberInboxes[member.name] = {
677
902
  count: messages.length,
@@ -679,7 +904,7 @@ export function registerAgentTeamsTools(ctx, config) {
679
904
  };
680
905
  }
681
906
  }
682
- return {
907
+ const result = {
683
908
  team_id: team.id,
684
909
  team_name: team.name,
685
910
  description: team.description ?? '',
@@ -695,6 +920,13 @@ export function registerAgentTeamsTools(ctx, config) {
695
920
  mailbox_warnings: mailboxWarnings,
696
921
  mailbox_warning_count: mailboxWarningCount,
697
922
  };
923
+ const acknowledged = identity.kind === 'captain'
924
+ ? captainInbox.map(message => message.id)
925
+ : await readUnreadMailbox(stateRoot, team.id, identity.name).then(messages => messages.map(message => message.id));
926
+ if (acknowledged.length > 0) {
927
+ await withTeamLock(teamLockKey(stateRoot, team.id), () => (acknowledgeMailbox(stateRoot, team.id, identity.kind === 'captain' ? CAPTAIN_KEY : identity.name, acknowledged)));
928
+ }
929
+ return result;
698
930
  },
699
931
  }));
700
932
  ctx.tools.register(defineTool({
@@ -720,12 +952,37 @@ export function registerAgentTeamsTools(ctx, config) {
720
952
  const workspace = workspaceOf(captain);
721
953
  const stateRoot = stateRootOf(workspace, config);
722
954
  const team = await requireCaptainTeam(workspace, config, captain);
723
- await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
955
+ const members = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
724
956
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
957
+ // Include previously removed members so deleting a pre-fix team also
958
+ // retires durable catalog entries left behind by remove_member.
959
+ const roster = fresh.members.map(member => ({ ...member }));
725
960
  for (const member of fresh.members) {
726
- if (member.status !== 'removed' && member.id !== '')
727
- interruptMember(ctx, captain, member.id);
961
+ if (member.status === 'removed')
962
+ continue;
963
+ member.status = 'removed';
964
+ for (const task of fresh.tasks) {
965
+ if (task.assignee === member.name && task.status !== 'completed')
966
+ invalidateTaskAttempt(task);
967
+ }
728
968
  }
969
+ await writeTeam(stateRoot, fresh);
970
+ return roster;
971
+ });
972
+ await recordRetiredMemberIds(stateRoot, members.map(member => member.id));
973
+ for (const member of members) {
974
+ if (member.id === '')
975
+ continue;
976
+ interruptMember(ctx, captain, member.id);
977
+ }
978
+ const quiescence = await Promise.allSettled(members.map(member => waitForMemberIdle(ctx, member, exec.signal)));
979
+ for (const result of quiescence) {
980
+ if (result.status === 'rejected') {
981
+ ctx.logger.warn(`agent-teams: member did not quiesce cleanly before team archive: ${String(result.reason)}`);
982
+ }
983
+ }
984
+ await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
985
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
729
986
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-deleted', {
730
987
  teamId: fresh.id,
731
988
  });
@@ -760,7 +1017,8 @@ function renderStatus(value) {
760
1017
  ...team.tasks.map((task) => {
761
1018
  const deps = task.dependencies.length > 0 ? ` (deps: ${task.dependencies.join(',')})` : '';
762
1019
  const output = task.output !== undefined ? `\n output: ${task.output.slice(0, 300)}` : '';
763
- return ` - ${task.id} [${task.status}] ${task.subject} ${task.assignee || 'unassigned'}${deps}${output}`;
1020
+ const handoff = task.reassigning ? ' (reassigning)' : '';
1021
+ return ` - ${task.id} [${task.status}] attempt ${task.attempt}${handoff} ${task.subject} → ${task.assignee || 'unassigned'}${deps}${output}`;
764
1022
  }),
765
1023
  `Captain inbox (${team.captain_inbox.length}):`,
766
1024
  ...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content.slice(0, 200)}`),
@@ -21,6 +21,7 @@ export interface ActivityMember {
21
21
  readonly id: string;
22
22
  readonly name: string;
23
23
  readonly role: string;
24
+ readonly status?: 'idle' | 'working' | 'removed';
24
25
  readonly activity: 'working' | 'idle' | 'unknown';
25
26
  readonly progress: number;
26
27
  readonly done: number;
@@ -10,6 +10,32 @@ export interface RelationshipStage<T extends RelationshipTask> {
10
10
  readonly depth: number;
11
11
  readonly tasks: readonly T[];
12
12
  }
13
+ /** Geometry used by the compact task DAG in the activity panel. */
14
+ export interface CompactDagNode<T extends RelationshipTask> {
15
+ readonly task: T;
16
+ readonly x: number;
17
+ readonly y: number;
18
+ }
19
+ /** One dependency edge routed between two compact DAG nodes. */
20
+ export interface CompactDagEdge {
21
+ readonly from: string;
22
+ readonly to: string;
23
+ readonly path: string;
24
+ }
25
+ /** Complete, scrollable compact DAG projection. */
26
+ export interface CompactDagLayout<T extends RelationshipTask> {
27
+ readonly width: number;
28
+ readonly height: number;
29
+ readonly nodes: readonly CompactDagNode<T>[];
30
+ readonly edges: readonly CompactDagEdge[];
31
+ }
32
+ /** Reference-panel geometry: narrow nodes with enough room for curved edges. */
33
+ export declare const COMPACT_DAG_NODE_WIDTH = 92;
34
+ export declare const COMPACT_DAG_NODE_HEIGHT = 30;
35
+ export declare const COMPACT_DAG_COLUMN_GAP = 26;
36
+ export declare const COMPACT_DAG_ROW_GAP = 8;
37
+ /** Use a fill-width grid when the task graph has no real dependency edges. */
38
+ export declare function usesParallelTaskGrid<T extends RelationshipTask>(tasks: readonly T[]): boolean;
13
39
  /**
14
40
  * Whether an expanded activity panel still belongs to the current session.
15
41
  *
@@ -29,6 +55,14 @@ export declare function activityPanelExpandedForSession(open: boolean, owner: st
29
55
  export declare function dependencyFocusTaskId(pinnedTaskId: string | null, keyboardTaskId: string | null, hoverTaskId: string | null): string | null;
30
56
  /** Group tasks by their precomputed dependency depth. */
31
57
  export declare function taskStages<T extends RelationshipTask>(tasks: readonly T[]): readonly RelationshipStage<T>[];
58
+ /**
59
+ * Lay tasks out as the reference panel's compact left-to-right DAG.
60
+ *
61
+ * Columns are dependency-depth stages. Rows are stable task-id order within
62
+ * each stage. Edges use cubic curves so fan-in remains readable without
63
+ * turning every task into a large card.
64
+ */
65
+ export declare function compactDagLayout<T extends RelationshipTask>(tasks: readonly T[]): CompactDagLayout<T>;
32
66
  /**
33
67
  * Return the complete upstream/downstream chain around one task.
34
68
  *
@@ -44,6 +44,8 @@ export interface AgentTeamsTaskUpdatedData {
44
44
  readonly status: string;
45
45
  readonly assignee?: string;
46
46
  readonly output?: string;
47
+ readonly attempt?: number;
48
+ readonly attemptId?: string;
47
49
  }
48
50
  /** Closes one team record: the team was deleted. */
49
51
  export interface AgentTeamsTeamDeletedData {