@tmustier/pi-agent-teams 0.4.0-beta.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +72 -9
  3. package/WORKFLOW.md +110 -0
  4. package/docs/claude-parity.md +18 -13
  5. package/docs/hook-contract.md +183 -0
  6. package/docs/smoke-test-plan.md +26 -7
  7. package/extensions/teams/activity-tracker.ts +296 -8
  8. package/extensions/teams/cleanup.ts +216 -3
  9. package/extensions/teams/hooks.ts +57 -5
  10. package/extensions/teams/leader-attach-commands.ts +8 -4
  11. package/extensions/teams/leader-inbox.ts +162 -4
  12. package/extensions/teams/leader-info-commands.ts +105 -3
  13. package/extensions/teams/leader-lifecycle-commands.ts +205 -3
  14. package/extensions/teams/leader-messaging-commands.ts +19 -7
  15. package/extensions/teams/leader-spawn-command.ts +5 -1
  16. package/extensions/teams/leader-team-command.ts +51 -2
  17. package/extensions/teams/leader-teams-tool.ts +387 -11
  18. package/extensions/teams/leader.ts +126 -52
  19. package/extensions/teams/mailbox.ts +6 -1
  20. package/extensions/teams/model-policy.ts +117 -0
  21. package/extensions/teams/spawn-types.ts +4 -0
  22. package/extensions/teams/teammate-rpc.ts +14 -0
  23. package/extensions/teams/teams-panel.ts +117 -19
  24. package/extensions/teams/teams-ui-shared.ts +205 -2
  25. package/extensions/teams/teams-widget.ts +67 -14
  26. package/extensions/teams/worker.ts +18 -6
  27. package/extensions/teams/worktree.ts +143 -0
  28. package/package.json +4 -2
  29. package/scripts/integration-cleanup-test.mts +419 -0
  30. package/scripts/integration-hooks-remediation-test.mts +382 -0
  31. package/scripts/integration-spawn-overrides-test.mts +10 -0
  32. package/scripts/smoke-test.mts +701 -3
  33. package/skills/agent-teams/SKILL.md +28 -7
@@ -9,6 +9,12 @@ import { getTeamDir } from "./paths.js";
9
9
  import { TEAM_MAILBOX_NS, taskAssignmentPayload } from "./protocol.js";
10
10
  import { ensureTeamConfig, setMemberStatus, updateTeamHooksPolicy } from "./team-config.js";
11
11
  import { getTeamsNamingRules, getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
12
+ import {
13
+ formatProviderModel,
14
+ isDeprecatedTeammateModelId,
15
+ resolveTeammateModelSelection,
16
+ type TeammateModelSource,
17
+ } from "./model-policy.js";
12
18
  import {
13
19
  getTeamsHookFailureAction,
14
20
  getTeamsHookFollowupOwnerPolicy,
@@ -27,10 +33,25 @@ import {
27
33
  updateTask,
28
34
  } from "./task-store.js";
29
35
  import type { TeammateRpc } from "./teammate-rpc.js";
36
+ import type { ActivityTracker } from "./activity-tracker.js";
37
+ import {
38
+ resolveDisplayStatus,
39
+ formatElapsed,
40
+ lastMessageSummary,
41
+ formatTokens,
42
+ toolActivity,
43
+ } from "./teams-ui-shared.js";
30
44
  import type { ContextMode, WorkspaceMode, SpawnTeammateFn } from "./spawn-types.js";
45
+ import type { DelegationTracker } from "./leader-inbox.js";
31
46
 
32
47
  type TeamsToolDelegateTask = { text: string; assignee?: string };
33
48
 
49
+ function describeModelSource(source: TeammateModelSource): string {
50
+ if (source === "override") return "override";
51
+ if (source === "inherit_leader") return "leader";
52
+ return "teammate-default";
53
+ }
54
+
34
55
  const TeamsActionSchema = StringEnum(
35
56
  [
36
57
  "delegate",
@@ -44,13 +65,17 @@ const TeamsActionSchema = StringEnum(
44
65
  "message_broadcast",
45
66
  "message_steer",
46
67
  "member_spawn",
68
+ "member_status",
47
69
  "member_shutdown",
48
70
  "member_kill",
49
71
  "member_prune",
72
+ "team_done",
50
73
  "plan_approve",
51
74
  "plan_reject",
52
75
  "hooks_policy_get",
53
76
  "hooks_policy_set",
77
+ "model_policy_get",
78
+ "model_policy_check",
54
79
  ] as const,
55
80
  {
56
81
  description: "Teams tool action.",
@@ -101,7 +126,7 @@ const TeamsToolParamsSchema = Type.Object({
101
126
  message: Type.Optional(Type.String({ description: "Message body for messaging actions." })),
102
127
  reason: Type.Optional(Type.String({ description: "Optional reason for lifecycle actions." })),
103
128
  feedback: Type.Optional(Type.String({ description: "Feedback for action=plan_reject." })),
104
- all: Type.Optional(Type.Boolean({ description: "For member_shutdown/member_prune, apply to all workers." })),
129
+ all: Type.Optional(Type.Boolean({ description: "For member_shutdown/member_prune: apply to all workers. For team_done: force even with in-progress tasks." })),
105
130
  planRequired: Type.Optional(Type.Boolean({ description: "For member_spawn, start worker in plan-required mode." })),
106
131
  teammates: Type.Optional(
107
132
  Type.Array(Type.String(), {
@@ -131,6 +156,7 @@ const TeamsToolParamsSchema = Type.Object({
131
156
  ),
132
157
  hookFollowupOwner: Type.Optional(TeamsHookFollowupOwnerSchema),
133
158
  hooksPolicyReset: Type.Optional(Type.Boolean({ description: "For hooks_policy_set, clear team-level overrides before applying fields." })),
159
+ urgent: Type.Optional(Type.Boolean({ description: "For message_dm/message_broadcast: interrupt the recipient's active turn via steering instead of waiting for idle. Use sparingly." })),
134
160
  });
135
161
 
136
162
  type TeamsToolParamsType = Static<typeof TeamsToolParamsSchema>;
@@ -141,18 +167,25 @@ export function registerTeamsTool(opts: {
141
167
  spawnTeammate: SpawnTeammateFn;
142
168
  getTeamId: (ctx: Parameters<SpawnTeammateFn>[0]) => string;
143
169
  getTaskListId: () => string | null;
170
+ getTracker: () => ActivityTracker;
171
+ getTeamConfig: () => import("./team-config.js").TeamConfig | null;
144
172
  refreshTasks: () => Promise<void>;
145
173
  renderWidget: () => void;
174
+ hideWidget: () => void;
175
+ stopAllTeammates: (reason: string) => Promise<void>;
146
176
  pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
177
+ delegationTracker?: DelegationTracker;
147
178
  }): void {
148
- const { pi, teammates, spawnTeammate, getTeamId, getTaskListId, refreshTasks, renderWidget, pendingPlanApprovals } = opts;
179
+ const { pi, teammates, spawnTeammate, getTeamId, getTaskListId, getTracker, getTeamConfig: getTeamCfg, refreshTasks, renderWidget, hideWidget, stopAllTeammates, pendingPlanApprovals, delegationTracker } = opts;
149
180
 
150
181
  pi.registerTool({
151
182
  name: "teams",
152
183
  label: "Teams",
153
184
  description: [
154
185
  "Spawn comrade agents and delegate tasks. Each comrade is a child Pi process that executes work autonomously and reports back.",
155
- "You can also mutate existing tasks (assign, unassign, set status, dependencies), send team messages, run teammate lifecycle actions, and manage hooks remediation policy without user slash commands.",
186
+ "You can also mutate existing tasks (assign, unassign, set status, dependencies), send team messages, run teammate lifecycle actions, and manage hooks/model policy without user slash commands.",
187
+ "Use member_status (with optional name) to get real-time worker state: activity, time in state, stall detection, tool use, tokens, and last message summary.",
188
+ "Use team_done to end a team run when all tasks are complete (stops teammates, hides widget).",
156
189
  "Provide a list of tasks with optional assignees; comrades are spawned automatically and assigned round-robin if unspecified.",
157
190
  "Options: contextMode=branch (clone session context), workspaceMode=worktree (git worktree isolation).",
158
191
  "Optional overrides: model='<provider>/<modelId>' and thinking (off|minimal|low|medium|high|xhigh).",
@@ -369,14 +402,17 @@ export function registerTeamsTool(opts: {
369
402
  };
370
403
  }
371
404
  const name = sanitizeName(nameRaw);
405
+ const isUrgent = params.urgent === true;
372
406
  await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
373
407
  from: cfg.leadName,
374
408
  text: message,
375
409
  timestamp: new Date().toISOString(),
410
+ ...(isUrgent ? { urgent: true } : {}),
376
411
  });
412
+ const verb = isUrgent ? "Urgent DM" : "DM";
377
413
  return {
378
- content: [{ type: "text", text: `DM queued for ${formatMemberDisplayName(style, name)}` }],
379
- details: { action, teamId, name, mailboxNamespace: TEAM_MAILBOX_NS },
414
+ content: [{ type: "text", text: `${verb} queued for ${formatMemberDisplayName(style, name)}` }],
415
+ details: { action, teamId, name, urgent: isUrgent, mailboxNamespace: TEAM_MAILBOX_NS },
380
416
  };
381
417
  }
382
418
 
@@ -404,6 +440,7 @@ export function registerTeamsTool(opts: {
404
440
  details: { action, recipients: [] },
405
441
  };
406
442
  }
443
+ const isUrgent = params.urgent === true;
407
444
  const ts = new Date().toISOString();
408
445
  await Promise.all(
409
446
  names.map((name) =>
@@ -411,12 +448,14 @@ export function registerTeamsTool(opts: {
411
448
  from: cfg.leadName,
412
449
  text: message,
413
450
  timestamp: ts,
451
+ ...(isUrgent ? { urgent: true } : {}),
414
452
  }),
415
453
  ),
416
454
  );
455
+ const verb = isUrgent ? "Urgent broadcast" : "Broadcast";
417
456
  return {
418
- content: [{ type: "text", text: `Broadcast queued for ${names.length} ${strings.memberTitle.toLowerCase()}(s): ${names.map((n) => formatMemberDisplayName(style, n)).join(", ")}` }],
419
- details: { action, teamId, recipients: names, mailboxNamespace: TEAM_MAILBOX_NS },
457
+ content: [{ type: "text", text: `${verb} queued for ${names.length} ${strings.memberTitle.toLowerCase()}(s): ${names.map((n) => formatMemberDisplayName(style, n)).join(", ")}` }],
458
+ details: { action, teamId, recipients: names, urgent: isUrgent, mailboxNamespace: TEAM_MAILBOX_NS },
420
459
  };
421
460
  }
422
461
 
@@ -485,11 +524,152 @@ export function registerTeamsTool(opts: {
485
524
  const lines: string[] = [
486
525
  `Spawned ${formatMemberDisplayName(style, res.name)} (${res.mode}/${res.workspaceMode})`,
487
526
  ];
527
+ if (res.model) lines.push(`model: ${res.model}`);
528
+ if (res.thinking) lines.push(`thinking: ${res.thinking}`);
488
529
  if (res.note) lines.push(`note: ${res.note}`);
489
530
  for (const w of res.warnings) lines.push(`warning: ${w}`);
490
531
  return {
491
532
  content: [{ type: "text", text: lines.join("\n") }],
492
- details: { action, teamId, name: res.name, mode: res.mode, workspaceMode: res.workspaceMode, warnings: res.warnings },
533
+ details: {
534
+ action,
535
+ teamId,
536
+ name: res.name,
537
+ mode: res.mode,
538
+ workspaceMode: res.workspaceMode,
539
+ model: res.model,
540
+ thinking: res.thinking,
541
+ warnings: res.warnings,
542
+ },
543
+ };
544
+ }
545
+
546
+ if (action === "member_status") {
547
+ const nameRaw = params.name?.trim();
548
+ const name = sanitizeName(nameRaw ?? "");
549
+ const teamCfg = getTeamCfg();
550
+
551
+ // If no name given, return summary for all workers.
552
+ if (!name) {
553
+ const allTasks = await listTasks(teamDir, effectiveTlId);
554
+ const tracker = getTracker();
555
+ const cfgMembers = teamCfg?.members ?? [];
556
+ const cfgByName = new Map<string, (typeof cfgMembers)[number]>();
557
+ for (const m of cfgMembers) cfgByName.set(m.name, m);
558
+
559
+ const workerNames = new Set<string>();
560
+ for (const n of teammates.keys()) workerNames.add(n);
561
+ for (const m of cfgMembers) {
562
+ if (m.role === "worker" && m.status === "online") workerNames.add(m.name);
563
+ }
564
+ for (const t of allTasks) {
565
+ if (t.owner && t.owner !== (teamCfg?.leadName ?? "") && t.status === "in_progress") workerNames.add(t.owner);
566
+ }
567
+
568
+ if (workerNames.size === 0) {
569
+ return {
570
+ content: [{ type: "text", text: `No ${strings.memberTitle.toLowerCase()}s to report on` }],
571
+ details: { action, teamId, workers: [] },
572
+ };
573
+ }
574
+
575
+ const workers: Array<Record<string, unknown>> = [];
576
+ const lines: string[] = [];
577
+ for (const n of Array.from(workerNames).sort()) {
578
+ const rpc = teammates.get(n);
579
+ const memberCfg = cfgByName.get(n);
580
+ const displayStatus = resolveDisplayStatus(rpc, memberCfg);
581
+ const activity = tracker.get(n);
582
+ const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
583
+ const noEventFor = rpc ? formatElapsed(Date.now() - rpc.lastEventAt) : "";
584
+ const currentTool = toolActivity(activity.currentToolName);
585
+ const msgPreview = lastMessageSummary(rpc, 80);
586
+ const model = memberCfg?.meta?.["model"];
587
+
588
+ lines.push(`${formatMemberDisplayName(style, n)}: ${displayStatus} ${elapsed}${currentTool ? ` (${currentTool})` : ""} · ${formatTokens(activity.totalTokens)} tokens`);
589
+ if (msgPreview) lines.push(` last: ${msgPreview}`);
590
+
591
+ workers.push({
592
+ name: n,
593
+ status: displayStatus,
594
+ transportStatus: rpc?.status ?? "unknown",
595
+ timeInState: elapsed,
596
+ lastEventAgo: noEventFor,
597
+ currentTool: activity.currentToolName,
598
+ toolUseCount: activity.toolUseCount,
599
+ turnCount: activity.turnCount,
600
+ totalTokens: activity.totalTokens,
601
+ model: typeof model === "string" ? model : undefined,
602
+ });
603
+ }
604
+
605
+ return {
606
+ content: [{ type: "text", text: lines.join("\n") }],
607
+ details: { action, teamId, workers },
608
+ };
609
+ }
610
+
611
+ // Single worker status
612
+ const rpc = teammates.get(name);
613
+ const memberCfg = (teamCfg?.members ?? []).find((m) => m.name === name);
614
+ if (!rpc && !memberCfg) {
615
+ return {
616
+ content: [{ type: "text", text: `Unknown ${strings.memberTitle.toLowerCase()}: ${name}` }],
617
+ details: { action, name },
618
+ };
619
+ }
620
+
621
+ const displayStatus = resolveDisplayStatus(rpc, memberCfg);
622
+ const tracker = getTracker();
623
+ const activity = tracker.get(name);
624
+ const elapsed = rpc ? formatElapsed(Date.now() - rpc.lastStatusChangeAt) : "";
625
+ const noEventFor = rpc ? formatElapsed(Date.now() - rpc.lastEventAt) : "";
626
+ const currentTool = toolActivity(activity.currentToolName);
627
+ const msgPreview = lastMessageSummary(rpc, 120);
628
+ const allTasks = await listTasks(teamDir, effectiveTlId);
629
+ const owned = allTasks.filter((t) => t.owner === name);
630
+ const activeTask = owned.find((t) => t.status === "in_progress");
631
+ const model = memberCfg?.meta?.["model"];
632
+ const cwd = memberCfg?.cwd;
633
+
634
+ const lines: string[] = [
635
+ `${formatMemberDisplayName(style, name)}: ${displayStatus}`,
636
+ `time in state: ${elapsed || "(unknown)"}`,
637
+ `last event: ${noEventFor || "(unknown)"} ago`,
638
+ `current activity: ${currentTool || "(none)"}`,
639
+ `tool calls: ${activity.toolUseCount} · turns: ${activity.turnCount} · tokens: ${formatTokens(activity.totalTokens)}`,
640
+ ];
641
+ if (typeof model === "string" && model) lines.push(`model: ${model}`);
642
+ if (cwd) lines.push(`cwd: ${cwd}`);
643
+ if (activeTask) lines.push(`active task: #${activeTask.id} ${activeTask.subject}`);
644
+ lines.push(`tasks: ${owned.filter((t) => t.status === "pending").length} pending · ${owned.filter((t) => t.status === "in_progress").length} in-progress · ${owned.filter((t) => t.status === "completed").length} completed`);
645
+ if (msgPreview) lines.push(`last message: ${msgPreview}`);
646
+ if (displayStatus === "stalled") {
647
+ lines.push(`\u26a0 WARNING: no agent events for ${noEventFor} — worker may be stalled`);
648
+ }
649
+
650
+ return {
651
+ content: [{ type: "text", text: lines.join("\n") }],
652
+ details: {
653
+ action,
654
+ teamId,
655
+ name,
656
+ status: displayStatus,
657
+ transportStatus: rpc?.status ?? "unknown",
658
+ timeInState: elapsed,
659
+ lastEventAgo: noEventFor,
660
+ currentTool: activity.currentToolName,
661
+ toolUseCount: activity.toolUseCount,
662
+ turnCount: activity.turnCount,
663
+ totalTokens: activity.totalTokens,
664
+ model: typeof model === "string" ? model : undefined,
665
+ activeTaskId: activeTask?.id,
666
+ tasks: {
667
+ pending: owned.filter((t) => t.status === "pending").length,
668
+ inProgress: owned.filter((t) => t.status === "in_progress").length,
669
+ completed: owned.filter((t) => t.status === "completed").length,
670
+ },
671
+ stalled: displayStatus === "stalled",
672
+ },
493
673
  };
494
674
  }
495
675
 
@@ -626,6 +806,79 @@ export function registerTeamsTool(opts: {
626
806
  };
627
807
  }
628
808
 
809
+ if (action === "team_done") {
810
+ const tasks = await listTasks(teamDir, effectiveTlId);
811
+ const inProgress = tasks.filter((t) => t.status === "in_progress");
812
+ const force = params.all === true;
813
+
814
+ if (inProgress.length > 0 && !force) {
815
+ return {
816
+ content: [{
817
+ type: "text",
818
+ text: `${inProgress.length} task(s) still in progress. Set all=true to force.`,
819
+ }],
820
+ details: {
821
+ action,
822
+ teamId,
823
+ status: "blocked",
824
+ reason: "tasks_in_progress",
825
+ inProgress: inProgress.length,
826
+ hint: "Set all=true to force, or wait for tasks to complete.",
827
+ },
828
+ };
829
+ }
830
+
831
+ // Stop all RPC teammates (reuses leader's stopAllTeammates for proper
832
+ // event unsub + tracker/transcript cleanup — avoids stale state on reuse).
833
+ await stopAllTeammates("team done");
834
+
835
+ // Mark config workers offline + send shutdown mailbox messages
836
+ const cfgWorkers = cfg.members.filter((m) => m.role === "worker" && m.status === "online");
837
+ for (const m of cfgWorkers) {
838
+ if (teammates.has(m.name)) continue; // already stopped via RPC above
839
+ const ts = new Date().toISOString();
840
+ try {
841
+ await writeToMailbox(teamDir, TEAM_MAILBOX_NS, m.name, {
842
+ from: cfg.leadName,
843
+ text: JSON.stringify({
844
+ type: "shutdown_request",
845
+ requestId: randomUUID(),
846
+ from: cfg.leadName,
847
+ timestamp: ts,
848
+ reason: "Team done",
849
+ }),
850
+ timestamp: ts,
851
+ });
852
+ } catch {
853
+ // ignore mailbox errors
854
+ }
855
+ await setMemberStatus(teamDir, m.name, "offline", {
856
+ meta: { stoppedReason: "team-done", stoppedAt: ts },
857
+ });
858
+ }
859
+
860
+ await refreshTasks();
861
+ hideWidget();
862
+
863
+ const completed = tasks.filter((t) => t.status === "completed").length;
864
+ const pending = tasks.filter((t) => t.status === "pending").length;
865
+ return {
866
+ content: [{
867
+ type: "text",
868
+ text: `Team done. ${tasks.length} task(s): ${completed} completed, ${pending} pending${inProgress.length > 0 ? `, ${inProgress.length} were in-progress (unassigned)` : ""}. Widget hidden.`,
869
+ }],
870
+ details: {
871
+ action,
872
+ teamId,
873
+ status: "succeeded",
874
+ total: tasks.length,
875
+ completed,
876
+ pending,
877
+ unassigned: inProgress.length,
878
+ },
879
+ };
880
+ }
881
+
629
882
  if (action === "plan_approve") {
630
883
  const nameRaw = params.name?.trim();
631
884
  const name = sanitizeName(nameRaw ?? "");
@@ -696,6 +949,114 @@ export function registerTeamsTool(opts: {
696
949
  };
697
950
  }
698
951
 
952
+ if (action === "model_policy_get") {
953
+ const leaderProvider = ctx.model?.provider;
954
+ const leaderModelId = ctx.model?.id;
955
+ const leaderModel = formatProviderModel(leaderProvider, leaderModelId);
956
+ const leaderModelDeprecated = leaderModelId ? isDeprecatedTeammateModelId(leaderModelId) : false;
957
+ const resolved = resolveTeammateModelSelection({
958
+ leaderProvider,
959
+ leaderModelId,
960
+ });
961
+ if (!resolved.ok) {
962
+ return {
963
+ content: [{ type: "text", text: `Model policy resolution failed: ${resolved.error}` }],
964
+ details: {
965
+ action,
966
+ teamId,
967
+ error: resolved.error,
968
+ reason: resolved.reason,
969
+ },
970
+ };
971
+ }
972
+
973
+ const effectiveModel = formatProviderModel(resolved.value.provider, resolved.value.modelId);
974
+ const lines: string[] = [
975
+ "Model policy",
976
+ "deprecated model family: claude-sonnet-4* (except claude-sonnet-4-5 / claude-sonnet-4.5)",
977
+ `leader model: ${leaderModel ?? "(unknown)"}`,
978
+ `leader model deprecated: ${leaderModelDeprecated ? "yes" : "no"}`,
979
+ `default teammate selection: source=${describeModelSource(resolved.value.source)}, model=${effectiveModel ?? "(teammate default)"}`,
980
+ "override forms: '<provider>/<modelId>' or '<modelId>' (inherits leader provider when available)",
981
+ ];
982
+
983
+ return {
984
+ content: [{ type: "text", text: lines.join("\n") }],
985
+ details: {
986
+ action,
987
+ teamId,
988
+ deprecatedPolicy: {
989
+ family: "claude-sonnet-4",
990
+ allowedExceptions: ["claude-sonnet-4-5", "claude-sonnet-4.5"],
991
+ },
992
+ leader: {
993
+ provider: leaderProvider,
994
+ modelId: leaderModelId,
995
+ model: leaderModel,
996
+ deprecated: leaderModelDeprecated,
997
+ },
998
+ defaultSelection: {
999
+ source: resolved.value.source,
1000
+ provider: resolved.value.provider,
1001
+ modelId: resolved.value.modelId,
1002
+ model: effectiveModel,
1003
+ warnings: resolved.value.warnings,
1004
+ },
1005
+ },
1006
+ };
1007
+ }
1008
+
1009
+ if (action === "model_policy_check") {
1010
+ const modelInput = params.model?.trim();
1011
+ const resolved = resolveTeammateModelSelection({
1012
+ modelOverride: modelInput,
1013
+ leaderProvider: ctx.model?.provider,
1014
+ leaderModelId: ctx.model?.id,
1015
+ });
1016
+
1017
+ if (!resolved.ok) {
1018
+ const lines = [
1019
+ "Model policy check: rejected",
1020
+ `input: ${modelInput ?? "(none)"}`,
1021
+ `reason: ${resolved.error}`,
1022
+ ];
1023
+ return {
1024
+ content: [{ type: "text", text: lines.join("\n") }],
1025
+ details: {
1026
+ action,
1027
+ teamId,
1028
+ accepted: false,
1029
+ input: modelInput,
1030
+ error: resolved.error,
1031
+ reason: resolved.reason,
1032
+ },
1033
+ };
1034
+ }
1035
+
1036
+ const resolvedModel = formatProviderModel(resolved.value.provider, resolved.value.modelId);
1037
+ const lines = [
1038
+ "Model policy check: accepted",
1039
+ `input: ${modelInput ?? "(none)"}`,
1040
+ `source: ${describeModelSource(resolved.value.source)}`,
1041
+ `resolved model: ${resolvedModel ?? "(teammate default)"}`,
1042
+ ];
1043
+ for (const warning of resolved.value.warnings) lines.push(`warning: ${warning}`);
1044
+ return {
1045
+ content: [{ type: "text", text: lines.join("\n") }],
1046
+ details: {
1047
+ action,
1048
+ teamId,
1049
+ accepted: true,
1050
+ input: modelInput,
1051
+ source: resolved.value.source,
1052
+ provider: resolved.value.provider,
1053
+ modelId: resolved.value.modelId,
1054
+ model: resolvedModel,
1055
+ warnings: resolved.value.warnings,
1056
+ },
1057
+ };
1058
+ }
1059
+
699
1060
  if (action === "hooks_policy_get") {
700
1061
  const configuredFailureAction: TeamsHookFailureAction | undefined = cfg.hooks?.failureAction;
701
1062
  const configuredFollowupOwner: TeamsHookFollowupOwnerPolicy | undefined = cfg.hooks?.followupOwner;
@@ -869,6 +1230,9 @@ export function registerTeamsTool(opts: {
869
1230
  warnings.push(...res.warnings);
870
1231
  }
871
1232
 
1233
+ // Two-pass delegation: create all tasks first, then notify workers.
1234
+ // This ensures DelegationTracker has the batch registered before any
1235
+ // worker can complete a task and emit an idle_notification.
872
1236
  const assignments: Array<{ taskId: string; assignee: string; subject: string }> = [];
873
1237
  let rr = 0;
874
1238
  for (const t of inputTasks) {
@@ -909,13 +1273,23 @@ export function registerTeamsTool(opts: {
909
1273
  const subject = firstLine.slice(0, 120);
910
1274
  const task = await createTask(teamDir, effectiveTlId, { subject, description, owner: assignee });
911
1275
 
912
- await writeToMailbox(teamDir, effectiveTlId, assignee, {
1276
+ assignments.push({ taskId: task.id, assignee, subject });
1277
+ }
1278
+
1279
+ // Register batch BEFORE notifying workers so completions are never missed.
1280
+ if (delegationTracker && assignments.length > 0) {
1281
+ delegationTracker.addBatch(assignments.map((a) => a.taskId));
1282
+ }
1283
+
1284
+ // Now notify workers via mailbox.
1285
+ for (const a of assignments) {
1286
+ const task = await getTask(teamDir, effectiveTlId, a.taskId);
1287
+ if (!task) continue;
1288
+ await writeToMailbox(teamDir, effectiveTlId, a.assignee, {
913
1289
  from: cfg.leadName,
914
1290
  text: JSON.stringify(taskAssignmentPayload(task, cfg.leadName)),
915
1291
  timestamp: new Date().toISOString(),
916
1292
  });
917
-
918
- assignments.push({ taskId: task.id, assignee, subject });
919
1293
  }
920
1294
 
921
1295
  void refreshTasks().finally(renderWidget);
@@ -923,6 +1297,8 @@ export function registerTeamsTool(opts: {
923
1297
  const lines: string[] = [];
924
1298
  if (spawned.length) {
925
1299
  lines.push(`Spawned: ${spawned.map((n) => formatMemberDisplayName(style, n)).join(", ")}`);
1300
+ if (spawnModel) lines.push(`model: ${spawnModel}`);
1301
+ if (spawnThinking) lines.push(`thinking: ${spawnThinking}`);
926
1302
  }
927
1303
  lines.push(`Delegated ${assignments.length} task(s):`);
928
1304
  for (const a of assignments) {