@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
@@ -11,13 +11,14 @@ import { TeammateRpc } from "./teammate-rpc.js";
11
11
  import { ensureTeamConfig, loadTeamConfig, setMemberStatus, upsertMember, type TeamConfig } from "./team-config.js";
12
12
  import { getTeamDir } from "./paths.js";
13
13
  import { heartbeatTeamAttachClaim, releaseTeamAttachClaim } from "./team-attach-claim.js";
14
- import { ensureWorktreeCwd } from "./worktree.js";
14
+ import { ensureWorktreeCwd, cleanupWorktrees } from "./worktree.js";
15
15
  import { ActivityTracker, TranscriptTracker } from "./activity-tracker.js";
16
16
  import { openInteractiveWidget } from "./teams-panel.js";
17
+ import { isTeamDone } from "./teams-ui-shared.js";
17
18
  import { createTeamsWidget } from "./teams-widget.js";
18
- import { isDeprecatedTeammateModelId } from "./model-policy.js";
19
+ import { resolveTeammateModelSelection, formatProviderModel } from "./model-policy.js";
19
20
  import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
20
- import { pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
21
+ import { DelegationTracker, pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
21
22
  import {
22
23
  getHookBaseName,
23
24
  getTeamsHookFailureAction,
@@ -116,6 +117,7 @@ export function runLeader(pi: ExtensionAPI): void {
116
117
  let tasks: TeamTask[] = [];
117
118
  let teamConfig: TeamConfig | null = null;
118
119
  const pendingPlanApprovals = new Map<string, { requestId: string; name: string; taskId?: string }>();
120
+ const delegationTracker = new DelegationTracker();
119
121
  // Task list namespace. By default we keep it aligned with the current session id.
120
122
  // (Do NOT read PI_TEAMS_TASK_LIST_ID for the leader; that env var is intended for workers
121
123
  // and can easily be set globally, which makes the leader "lose" its tasks.)
@@ -160,6 +162,7 @@ export function runLeader(pi: ExtensionAPI): void {
160
162
  );
161
163
  currentTeamId = sessionTeamId;
162
164
  taskListId = sessionTeamId;
165
+ delegationTracker.clear();
163
166
  await refreshTasks();
164
167
  renderWidget();
165
168
  };
@@ -411,8 +414,16 @@ export function runLeader(pi: ExtensionAPI): void {
411
414
  isDelegateMode: () => delegateMode,
412
415
  getActiveTeamId: () => currentTeamId,
413
416
  getSessionTeamId: () => currentCtx?.sessionManager.getSessionId() ?? null,
417
+ getLeaderModel: () => {
418
+ const model = currentCtx?.model;
419
+ if (!model) return null;
420
+ return { provider: model.provider, modelId: model.id };
421
+ },
414
422
  });
415
423
 
424
+ // Auto-done detection: notify once when all tasks complete and teammates idle.
425
+ let autoDoneNotified = false;
426
+
416
427
  const refreshTasks = async () => {
417
428
  if (!currentCtx || !currentTeamId) return;
418
429
  const teamDir = getTeamDir(currentTeamId);
@@ -429,6 +440,17 @@ export function runLeader(pi: ExtensionAPI): void {
429
440
  style,
430
441
  }));
431
442
  style = teamConfig.style ?? style;
443
+
444
+ // Auto-done hint (fire once per "all done" state transition)
445
+ if (isTeamDone(tasks, teammates)) {
446
+ if (!autoDoneNotified) {
447
+ autoDoneNotified = true;
448
+ currentCtx.ui.notify("All tasks completed. Use /team done to end the team session.", "info");
449
+ }
450
+ } else {
451
+ // Reset when work resumes (new tasks added, etc.)
452
+ autoDoneNotified = false;
453
+ }
432
454
  };
433
455
 
434
456
  let widgetSuppressed = false;
@@ -439,6 +461,16 @@ export function runLeader(pi: ExtensionAPI): void {
439
461
  currentCtx.ui.setWidget("pi-teams", widgetFactory);
440
462
  };
441
463
 
464
+ const hideWidget = () => {
465
+ widgetSuppressed = true;
466
+ if (currentCtx) currentCtx.ui.setWidget("pi-teams", undefined);
467
+ };
468
+
469
+ const restoreWidget = () => {
470
+ widgetSuppressed = false;
471
+ renderWidget();
472
+ };
473
+
442
474
  const spawnTeammate: SpawnTeammateFn = async (ctx, opts): Promise<SpawnTeammateResult> => {
443
475
  const warnings: string[] = [];
444
476
  const mode: ContextMode = opts.mode ?? "fresh";
@@ -453,49 +485,15 @@ export function runLeader(pi: ExtensionAPI): void {
453
485
 
454
486
  // Spawn-time model / thinking overrides (optional).
455
487
  const thinkingLevel = opts.thinking ?? pi.getThinkingLevel();
456
- let childProvider: string | undefined;
457
- let childModelId: string | undefined;
458
-
459
- const modelOverrideRaw = opts.model?.trim();
460
- if (modelOverrideRaw) {
461
- const slashIdx = modelOverrideRaw.indexOf("/");
462
- if (slashIdx >= 0) {
463
- const provider = modelOverrideRaw.slice(0, slashIdx).trim();
464
- const id = modelOverrideRaw.slice(slashIdx + 1).trim();
465
- if (!provider || !id) {
466
- return {
467
- ok: false,
468
- error: `Invalid model override '${modelOverrideRaw}'. Expected <provider>/<modelId>.`,
469
- };
470
- }
471
- if (isDeprecatedTeammateModelId(id)) {
472
- return {
473
- ok: false,
474
- error: `Model override '${modelOverrideRaw}' is deprecated. Choose a current model id.`,
475
- };
476
- }
477
- childProvider = provider;
478
- childModelId = id;
479
- } else {
480
- if (isDeprecatedTeammateModelId(modelOverrideRaw)) {
481
- return {
482
- ok: false,
483
- error: `Model override '${modelOverrideRaw}' is deprecated. Choose a current model id.`,
484
- };
485
- }
486
- childModelId = modelOverrideRaw;
487
- childProvider = ctx.model?.provider;
488
- if (!childProvider) {
489
- warnings.push(
490
- `Model override '${modelOverrideRaw}' provided without a provider. ` +
491
- `Teammate will use its default provider; use <provider>/<modelId> to force one.`,
492
- );
493
- }
494
- }
495
- } else if (ctx.model && !isDeprecatedTeammateModelId(ctx.model.id)) {
496
- childProvider = ctx.model.provider;
497
- childModelId = ctx.model.id;
498
- }
488
+
489
+ const modelResolution = resolveTeammateModelSelection({
490
+ modelOverride: opts.model,
491
+ leaderProvider: ctx.model?.provider,
492
+ leaderModelId: ctx.model?.id,
493
+ });
494
+ if (!modelResolution.ok) return { ok: false, error: modelResolution.error };
495
+ const { provider: childProvider, modelId: childModelId, warnings: modelWarnings } = modelResolution.value;
496
+ warnings.push(...modelWarnings);
499
497
 
500
498
  const teamId = currentTeamId ?? ctx.sessionManager.getSessionId();
501
499
  const teamDir = getTeamDir(teamId);
@@ -506,10 +504,21 @@ export function runLeader(pi: ExtensionAPI): void {
506
504
 
507
505
  const t = new TeammateRpc(name, sessionFile);
508
506
  teammates.set(name, t);
507
+ // Restore the widget if it was hidden by /team done — new work is starting.
508
+ restoreWidget();
509
509
  // Track teammate activity for the widget/panel.
510
+ // Render on status-changing events for a more "live" feel.
510
511
  const unsub = t.onEvent((ev) => {
511
512
  tracker.handleEvent(name, ev);
512
513
  transcriptTracker.handleEvent(name, ev);
514
+ // Refresh widget on events that change visible state (tool start/end, turn end).
515
+ if (
516
+ ev.type === "tool_execution_start" ||
517
+ ev.type === "tool_execution_end" ||
518
+ ev.type === "agent_end"
519
+ ) {
520
+ renderWidget();
521
+ }
513
522
  });
514
523
  teammateEventUnsubs.set(name, unsub);
515
524
  renderWidget();
@@ -614,6 +623,7 @@ export function runLeader(pi: ExtensionAPI): void {
614
623
  }
615
624
 
616
625
  await ensureTeamConfig(teamDir, { teamId, taskListId: taskListId ?? teamId, leadName: "team-lead", style });
626
+ const childModel = formatProviderModel(childProvider, childModelId);
617
627
  await upsertMember(teamDir, {
618
628
  name,
619
629
  role: "worker",
@@ -624,14 +634,24 @@ export function runLeader(pi: ExtensionAPI): void {
624
634
  workspaceMode,
625
635
  sessionName,
626
636
  thinkingLevel,
627
- ...(childModelId ? { model: childProvider ? `${childProvider}/${childModelId}` : childModelId } : {}),
637
+ ...(childModel ? { model: childModel } : {}),
628
638
  },
629
639
  });
630
640
 
631
641
  await refreshTasks();
632
642
  renderWidget();
633
643
 
634
- return { ok: true, name, mode, workspaceMode, childCwd, note, warnings };
644
+ return {
645
+ ok: true,
646
+ name,
647
+ mode,
648
+ workspaceMode,
649
+ childCwd,
650
+ note,
651
+ model: childModel ?? undefined,
652
+ thinking: thinkingLevel,
653
+ warnings,
654
+ };
635
655
  };
636
656
 
637
657
  const pollLeaderInbox = async () => {
@@ -647,6 +667,10 @@ export function runLeader(pi: ExtensionAPI): void {
647
667
  style,
648
668
  pendingPlanApprovals,
649
669
  enqueueHook,
670
+ sendLeaderLlmMessage: (content, options) => {
671
+ pi.sendUserMessage(content, options);
672
+ },
673
+ delegationTracker,
650
674
  });
651
675
  };
652
676
 
@@ -665,6 +689,9 @@ export function runLeader(pi: ExtensionAPI): void {
665
689
  // use `/team task use <taskListId>` after switching.
666
690
  taskListId = currentTeamId;
667
691
  lastAttachClaimHeartbeatMs = 0;
692
+ // Clear any /team done suppression from a previous session.
693
+ widgetSuppressed = false;
694
+ autoDoneNotified = false;
668
695
 
669
696
  // Claude-style: a persisted team config file.
670
697
  await ensureTeamConfig(getTeamDir(currentTeamId), {
@@ -704,12 +731,28 @@ export function runLeader(pi: ExtensionAPI): void {
704
731
  });
705
732
 
706
733
  pi.on("session_switch", async (_event, ctx) => {
734
+ const prevTeamId = currentTeamId;
735
+ const prevCwd = currentCtx?.cwd;
736
+
707
737
  if (currentCtx) {
708
738
  await releaseActiveAttachClaim(currentCtx);
709
739
  const strings = getTeamsStrings(style);
710
740
  await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is dissolved — leader moved on`);
711
741
  }
712
742
  stopLoops();
743
+ delegationTracker.clear();
744
+
745
+ // Clean up worktrees from the old session before switching.
746
+ // Only clean up teams this session owns — never attached teams.
747
+ const prevSessionId = currentCtx?.sessionManager.getSessionId();
748
+ if (prevTeamId && prevTeamId === prevSessionId) {
749
+ const teamDir = getTeamDir(prevTeamId);
750
+ try {
751
+ await cleanupWorktrees({ teamDir, teamId: prevTeamId, repoCwd: prevCwd });
752
+ } catch {
753
+ // Best-effort — don't block session switch.
754
+ }
755
+ }
713
756
 
714
757
  currentCtx = ctx;
715
758
  currentTeamId = currentCtx.sessionManager.getSessionId();
@@ -717,6 +760,9 @@ export function runLeader(pi: ExtensionAPI): void {
717
760
  // use `/team task use <taskListId>` after switching.
718
761
  taskListId = currentTeamId;
719
762
  lastAttachClaimHeartbeatMs = 0;
763
+ // Clear any /team done suppression — new session context.
764
+ widgetSuppressed = false;
765
+ autoDoneNotified = false;
720
766
 
721
767
  await ensureTeamConfig(getTeamDir(currentTeamId), {
722
768
  teamId: currentTeamId,
@@ -760,6 +806,18 @@ export function runLeader(pi: ExtensionAPI): void {
760
806
  stopLoops();
761
807
  const strings = getTeamsStrings(style);
762
808
  await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is over`);
809
+
810
+ // Clean up worktrees + branches for this team session so they don't accumulate on disk.
811
+ // Only clean up teams this session owns — never attached teams.
812
+ const sessionId = currentCtx.sessionManager.getSessionId();
813
+ if (currentTeamId && currentTeamId === sessionId) {
814
+ const teamDir = getTeamDir(currentTeamId);
815
+ try {
816
+ await cleanupWorktrees({ teamDir, teamId: currentTeamId, repoCwd: currentCtx.cwd });
817
+ } catch {
818
+ // Best-effort — don't block shutdown.
819
+ }
820
+ }
763
821
  });
764
822
 
765
823
  registerTeamsTool({
@@ -768,9 +826,17 @@ export function runLeader(pi: ExtensionAPI): void {
768
826
  spawnTeammate,
769
827
  getTeamId: (ctx) => currentTeamId ?? ctx.sessionManager.getSessionId(),
770
828
  getTaskListId: () => taskListId,
829
+ getTracker: () => tracker,
830
+ getTeamConfig: () => teamConfig,
771
831
  refreshTasks,
772
832
  renderWidget,
833
+ hideWidget,
834
+ stopAllTeammates: async (reason: string) => {
835
+ if (!currentCtx) return;
836
+ await stopAllTeammates(currentCtx, reason);
837
+ },
773
838
  pendingPlanApprovals,
839
+ delegationTracker,
774
840
  });
775
841
 
776
842
  const openWidget = async (ctx: ExtensionCommandContext) => {
@@ -874,13 +940,16 @@ export function runLeader(pi: ExtensionAPI): void {
874
940
  getSessionTeamId() {
875
941
  return ctx.sessionManager.getSessionId();
876
942
  },
943
+ getLeaderModel() {
944
+ const model = currentCtx?.model;
945
+ if (!model) return null;
946
+ return { provider: model.provider, modelId: model.id };
947
+ },
877
948
  suppressWidget() {
878
- widgetSuppressed = true;
879
- ctx.ui.setWidget("pi-teams", undefined);
949
+ hideWidget();
880
950
  },
881
951
  restoreWidget() {
882
- widgetSuppressed = false;
883
- renderWidget();
952
+ restoreWidget();
884
953
  },
885
954
  });
886
955
  };
@@ -926,16 +995,21 @@ export function runLeader(pi: ExtensionAPI): void {
926
995
  ctx,
927
996
  teammates,
928
997
  getTeamConfig: () => teamConfig,
998
+ getTracker: () => tracker,
929
999
  getTasks: () => tasks,
930
1000
  refreshTasks,
931
1001
  renderWidget,
1002
+ hideWidget,
1003
+ restoreWidget,
932
1004
  getTaskListId: () => taskListId,
933
1005
  setTaskListId: (id) => {
934
1006
  taskListId = id;
1007
+ delegationTracker.clear();
935
1008
  },
936
1009
  getActiveTeamId: () => currentTeamId ?? ctx.sessionManager.getSessionId(),
937
1010
  setActiveTeamId: (teamId) => {
938
1011
  currentTeamId = teamId;
1012
+ delegationTracker.clear();
939
1013
  },
940
1014
  pendingPlanApprovals,
941
1015
  getDelegateMode: () => delegateMode,
@@ -9,6 +9,9 @@ export interface MailboxMessage {
9
9
  timestamp: string;
10
10
  read: boolean;
11
11
  color?: string;
12
+ /** When true, the recipient should deliver this message as a steering interrupt
13
+ * even if the agent is mid-turn, rather than queueing for the next idle window. */
14
+ urgent?: boolean;
12
15
  }
13
16
 
14
17
  function inboxDir(teamDir: string, namespace: string): string {
@@ -38,7 +41,8 @@ function coerceMailboxMessage(v: unknown): MailboxMessage | null {
38
41
  if (typeof v.timestamp !== "string") return null;
39
42
  const read = typeof v.read === "boolean" ? v.read : false;
40
43
  const color = typeof v.color === "string" ? v.color : undefined;
41
- return { from: v.from, text: v.text, timestamp: v.timestamp, read, color };
44
+ const urgent = typeof v.urgent === "boolean" ? v.urgent : undefined;
45
+ return { from: v.from, text: v.text, timestamp: v.timestamp, read, color, urgent };
42
46
  }
43
47
 
44
48
  async function readJsonArray(file: string): Promise<unknown[]> {
@@ -80,6 +84,7 @@ export async function writeToMailbox(
80
84
  timestamp: msg.timestamp,
81
85
  read: msg.read ?? false,
82
86
  color: msg.color,
87
+ ...(msg.urgent === true ? { urgent: true } : {}),
83
88
  };
84
89
  arr.push(m);
85
90
  await writeJsonAtomic(inboxPath, arr);
@@ -12,6 +12,12 @@ function hasAnyMarker(value: string, markers: readonly string[]): boolean {
12
12
  return false;
13
13
  }
14
14
 
15
+ function trimOrUndefined(value: string | undefined): string | undefined {
16
+ if (value === undefined) return undefined;
17
+ const trimmed = value.trim();
18
+ return trimmed.length > 0 ? trimmed : undefined;
19
+ }
20
+
15
21
  export function isDeprecatedTeammateModelId(modelId: string): boolean {
16
22
  const normalized = normalizeModelId(modelId);
17
23
  if (!normalized) return false;
@@ -23,3 +29,114 @@ export function isDeprecatedTeammateModelId(modelId: string): boolean {
23
29
  if (!next) return true;
24
30
  return next === "-" || next === "_" || next === "." || next === ":";
25
31
  }
32
+
33
+ export type TeammateModelSource = "override" | "inherit_leader" | "default";
34
+
35
+ export interface ResolvedTeammateModel {
36
+ source: TeammateModelSource;
37
+ provider?: string;
38
+ modelId?: string;
39
+ warnings: string[];
40
+ }
41
+
42
+ export type ResolveTeammateModelResult =
43
+ | {
44
+ ok: true;
45
+ value: ResolvedTeammateModel;
46
+ }
47
+ | {
48
+ ok: false;
49
+ error: string;
50
+ reason: "invalid_override" | "deprecated_override";
51
+ };
52
+
53
+ export function formatProviderModel(provider: string | undefined, modelId: string | undefined): string | null {
54
+ if (!modelId) return null;
55
+ return provider ? `${provider}/${modelId}` : modelId;
56
+ }
57
+
58
+ export function resolveTeammateModelSelection(input: {
59
+ modelOverride?: string;
60
+ leaderProvider?: string;
61
+ leaderModelId?: string;
62
+ }): ResolveTeammateModelResult {
63
+ const override = trimOrUndefined(input.modelOverride);
64
+ if (override) {
65
+ const slashIdx = override.indexOf("/");
66
+ if (slashIdx >= 0) {
67
+ const provider = override.slice(0, slashIdx).trim();
68
+ const id = override.slice(slashIdx + 1).trim();
69
+ if (!provider || !id) {
70
+ return {
71
+ ok: false,
72
+ reason: "invalid_override",
73
+ error: `Invalid model override '${override}'. Expected <provider>/<modelId>.`,
74
+ };
75
+ }
76
+ if (isDeprecatedTeammateModelId(id)) {
77
+ return {
78
+ ok: false,
79
+ reason: "deprecated_override",
80
+ error: `Model override '${override}' is deprecated. Choose a current model id.`,
81
+ };
82
+ }
83
+ return {
84
+ ok: true,
85
+ value: {
86
+ source: "override",
87
+ provider,
88
+ modelId: id,
89
+ warnings: [],
90
+ },
91
+ };
92
+ }
93
+
94
+ if (isDeprecatedTeammateModelId(override)) {
95
+ return {
96
+ ok: false,
97
+ reason: "deprecated_override",
98
+ error: `Model override '${override}' is deprecated. Choose a current model id.`,
99
+ };
100
+ }
101
+
102
+ const leaderProvider = trimOrUndefined(input.leaderProvider);
103
+ const warnings: string[] = [];
104
+ if (!leaderProvider) {
105
+ warnings.push(
106
+ `Model override '${override}' provided without a provider. ` +
107
+ `Teammate will use its default provider; use <provider>/<modelId> to force one.`,
108
+ );
109
+ }
110
+ return {
111
+ ok: true,
112
+ value: {
113
+ source: "override",
114
+ provider: leaderProvider,
115
+ modelId: override,
116
+ warnings,
117
+ },
118
+ };
119
+ }
120
+
121
+ const leaderModelId = trimOrUndefined(input.leaderModelId);
122
+ const leaderProvider = trimOrUndefined(input.leaderProvider);
123
+ if (leaderModelId && !isDeprecatedTeammateModelId(leaderModelId)) {
124
+ return {
125
+ ok: true,
126
+ value: {
127
+ source: "inherit_leader",
128
+ provider: leaderProvider,
129
+ modelId: leaderModelId,
130
+ warnings: [],
131
+ },
132
+ };
133
+ }
134
+
135
+ return {
136
+ ok: true,
137
+ value: {
138
+ source: "default",
139
+ warnings: [],
140
+ },
141
+ };
142
+ }
@@ -29,6 +29,10 @@ export type SpawnTeammateResult =
29
29
  workspaceMode: WorkspaceMode;
30
30
  childCwd?: string;
31
31
  note?: string;
32
+ /** The resolved model string (provider/modelId or modelId), if any. */
33
+ model?: string;
34
+ /** The effective thinking level for this teammate. */
35
+ thinking?: ThinkingLevel;
32
36
  warnings: string[];
33
37
  }
34
38
  | { ok: false; error: string };
@@ -86,6 +86,12 @@ export class TeammateRpc {
86
86
  /** Task currently assigned by the team lead (if any). */
87
87
  currentTaskId: string | null = null;
88
88
 
89
+ /** Epoch ms when the current `status` was entered. */
90
+ lastStatusChangeAt: number = Date.now();
91
+
92
+ /** Epoch ms of the most recent agent event received from the child process. */
93
+ lastEventAt: number = Date.now();
94
+
89
95
  private proc: ReturnType<typeof spawn> | null = null;
90
96
  private pending = new Map<string, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
91
97
  private nextId = 0;
@@ -164,6 +170,9 @@ export class TeammateRpc {
164
170
  // Give the child a moment to boot.
165
171
  await new Promise((r) => setTimeout(r, 120));
166
172
  this.status = "idle";
173
+ const bootNow = Date.now();
174
+ this.lastStatusChangeAt = bootNow;
175
+ this.lastEventAt = bootNow;
167
176
  }
168
177
 
169
178
  async stop(): Promise<void> {
@@ -224,12 +233,17 @@ export class TeammateRpc {
224
233
  // Agent event
225
234
  if (!isAgentEvent(obj)) return;
226
235
  const ev = obj;
236
+ const now = Date.now();
237
+ this.lastEventAt = now;
238
+
227
239
  if (ev.type === "agent_start") {
228
240
  this.status = "streaming";
241
+ this.lastStatusChangeAt = now;
229
242
  this.lastAssistantText = "";
230
243
  }
231
244
  if (ev.type === "agent_end") {
232
245
  this.status = "idle";
246
+ this.lastStatusChangeAt = now;
233
247
  }
234
248
  if (ev.type === "message_update") {
235
249
  const ame = ev.assistantMessageEvent;