@tmustier/pi-agent-teams 0.4.0 → 0.5.1
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/CHANGELOG.md +37 -0
- package/README.md +55 -9
- package/WORKFLOW.md +110 -0
- package/docs/claude-parity.md +16 -13
- package/docs/hook-contract.md +183 -0
- package/docs/smoke-test-plan.md +26 -7
- package/extensions/teams/activity-tracker.ts +296 -8
- package/extensions/teams/cleanup.ts +222 -3
- package/extensions/teams/hooks.ts +57 -5
- package/extensions/teams/leader-attach-commands.ts +8 -4
- package/extensions/teams/leader-inbox.ts +162 -4
- package/extensions/teams/leader-info-commands.ts +105 -3
- package/extensions/teams/leader-lifecycle-commands.ts +205 -3
- package/extensions/teams/leader-messaging-commands.ts +19 -7
- package/extensions/teams/leader-spawn-command.ts +5 -1
- package/extensions/teams/leader-team-command.ts +51 -2
- package/extensions/teams/leader-teams-tool.ts +264 -10
- package/extensions/teams/leader.ts +174 -9
- package/extensions/teams/mailbox.ts +6 -1
- package/extensions/teams/spawn-types.ts +4 -0
- package/extensions/teams/teammate-rpc.ts +14 -0
- package/extensions/teams/teams-panel.ts +117 -19
- package/extensions/teams/teams-ui-shared.ts +205 -2
- package/extensions/teams/teams-widget.ts +67 -14
- package/extensions/teams/worker.ts +18 -6
- package/extensions/teams/worktree.ts +143 -0
- package/package.json +3 -2
- package/scripts/integration-cleanup-test.mts +419 -0
- package/scripts/smoke-test.mts +655 -2
- package/skills/agent-teams/SKILL.md +24 -7
|
@@ -9,15 +9,17 @@ import { TEAM_MAILBOX_NS, taskAssignmentPayload } from "./protocol.js";
|
|
|
9
9
|
import { createTask, listTasks, unassignTasksForAgent, updateTask, type TeamTask } from "./task-store.js";
|
|
10
10
|
import { TeammateRpc } from "./teammate-rpc.js";
|
|
11
11
|
import { ensureTeamConfig, loadTeamConfig, setMemberStatus, upsertMember, type TeamConfig } from "./team-config.js";
|
|
12
|
-
import { getTeamDir } from "./paths.js";
|
|
13
|
-
import { heartbeatTeamAttachClaim, releaseTeamAttachClaim } from "./team-attach-claim.js";
|
|
14
|
-
import {
|
|
12
|
+
import { getTeamDir, getTeamsRootDir } from "./paths.js";
|
|
13
|
+
import { assessAttachClaimFreshness, heartbeatTeamAttachClaim, readTeamAttachClaim, releaseTeamAttachClaim } from "./team-attach-claim.js";
|
|
14
|
+
import { cleanupTeamDir, gcStaleTeamDirs } from "./cleanup.js";
|
|
15
|
+
import { ensureWorktreeCwd, cleanupWorktrees } from "./worktree.js";
|
|
15
16
|
import { ActivityTracker, TranscriptTracker } from "./activity-tracker.js";
|
|
16
17
|
import { openInteractiveWidget } from "./teams-panel.js";
|
|
18
|
+
import { isTeamDone } from "./teams-ui-shared.js";
|
|
17
19
|
import { createTeamsWidget } from "./teams-widget.js";
|
|
18
20
|
import { resolveTeammateModelSelection, formatProviderModel } from "./model-policy.js";
|
|
19
21
|
import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
20
|
-
import { pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
|
|
22
|
+
import { DelegationTracker, pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
|
|
21
23
|
import {
|
|
22
24
|
getHookBaseName,
|
|
23
25
|
getTeamsHookFailureAction,
|
|
@@ -105,6 +107,29 @@ async function createSessionForTeammate(
|
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
/** Check if a team dir has any task files across all task-list namespaces. */
|
|
111
|
+
async function teamDirHasAnyTasks(teamDir: string): Promise<boolean> {
|
|
112
|
+
const tasksDir = path.join(teamDir, "tasks");
|
|
113
|
+
let taskListDirs: string[];
|
|
114
|
+
try {
|
|
115
|
+
taskListDirs = await fs.promises.readdir(tasksDir);
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
for (const listDir of taskListDirs) {
|
|
120
|
+
const listPath = path.join(tasksDir, listDir);
|
|
121
|
+
try {
|
|
122
|
+
const stat = await fs.promises.stat(listPath);
|
|
123
|
+
if (!stat.isDirectory()) continue;
|
|
124
|
+
const files = await fs.promises.readdir(listPath);
|
|
125
|
+
if (files.some((f) => f.endsWith(".json") && !f.startsWith("."))) return true;
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
108
133
|
// Message parsers are shared with the worker implementation.
|
|
109
134
|
export function runLeader(pi: ExtensionAPI): void {
|
|
110
135
|
const teammates = new Map<string, TeammateRpc>();
|
|
@@ -116,6 +141,7 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
116
141
|
let tasks: TeamTask[] = [];
|
|
117
142
|
let teamConfig: TeamConfig | null = null;
|
|
118
143
|
const pendingPlanApprovals = new Map<string, { requestId: string; name: string; taskId?: string }>();
|
|
144
|
+
const delegationTracker = new DelegationTracker();
|
|
119
145
|
// Task list namespace. By default we keep it aligned with the current session id.
|
|
120
146
|
// (Do NOT read PI_TEAMS_TASK_LIST_ID for the leader; that env var is intended for workers
|
|
121
147
|
// and can easily be set globally, which makes the leader "lose" its tasks.)
|
|
@@ -160,6 +186,7 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
160
186
|
);
|
|
161
187
|
currentTeamId = sessionTeamId;
|
|
162
188
|
taskListId = sessionTeamId;
|
|
189
|
+
delegationTracker.clear();
|
|
163
190
|
await refreshTasks();
|
|
164
191
|
renderWidget();
|
|
165
192
|
};
|
|
@@ -411,8 +438,16 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
411
438
|
isDelegateMode: () => delegateMode,
|
|
412
439
|
getActiveTeamId: () => currentTeamId,
|
|
413
440
|
getSessionTeamId: () => currentCtx?.sessionManager.getSessionId() ?? null,
|
|
441
|
+
getLeaderModel: () => {
|
|
442
|
+
const model = currentCtx?.model;
|
|
443
|
+
if (!model) return null;
|
|
444
|
+
return { provider: model.provider, modelId: model.id };
|
|
445
|
+
},
|
|
414
446
|
});
|
|
415
447
|
|
|
448
|
+
// Auto-done detection: notify once when all tasks complete and teammates idle.
|
|
449
|
+
let autoDoneNotified = false;
|
|
450
|
+
|
|
416
451
|
const refreshTasks = async () => {
|
|
417
452
|
if (!currentCtx || !currentTeamId) return;
|
|
418
453
|
const teamDir = getTeamDir(currentTeamId);
|
|
@@ -429,6 +464,17 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
429
464
|
style,
|
|
430
465
|
}));
|
|
431
466
|
style = teamConfig.style ?? style;
|
|
467
|
+
|
|
468
|
+
// Auto-done hint (fire once per "all done" state transition)
|
|
469
|
+
if (isTeamDone(tasks, teammates)) {
|
|
470
|
+
if (!autoDoneNotified) {
|
|
471
|
+
autoDoneNotified = true;
|
|
472
|
+
currentCtx.ui.notify("All tasks completed. Use /team done to end the team session.", "info");
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
// Reset when work resumes (new tasks added, etc.)
|
|
476
|
+
autoDoneNotified = false;
|
|
477
|
+
}
|
|
432
478
|
};
|
|
433
479
|
|
|
434
480
|
let widgetSuppressed = false;
|
|
@@ -439,6 +485,16 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
439
485
|
currentCtx.ui.setWidget("pi-teams", widgetFactory);
|
|
440
486
|
};
|
|
441
487
|
|
|
488
|
+
const hideWidget = () => {
|
|
489
|
+
widgetSuppressed = true;
|
|
490
|
+
if (currentCtx) currentCtx.ui.setWidget("pi-teams", undefined);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const restoreWidget = () => {
|
|
494
|
+
widgetSuppressed = false;
|
|
495
|
+
renderWidget();
|
|
496
|
+
};
|
|
497
|
+
|
|
442
498
|
const spawnTeammate: SpawnTeammateFn = async (ctx, opts): Promise<SpawnTeammateResult> => {
|
|
443
499
|
const warnings: string[] = [];
|
|
444
500
|
const mode: ContextMode = opts.mode ?? "fresh";
|
|
@@ -472,10 +528,21 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
472
528
|
|
|
473
529
|
const t = new TeammateRpc(name, sessionFile);
|
|
474
530
|
teammates.set(name, t);
|
|
531
|
+
// Restore the widget if it was hidden by /team done — new work is starting.
|
|
532
|
+
restoreWidget();
|
|
475
533
|
// Track teammate activity for the widget/panel.
|
|
534
|
+
// Render on status-changing events for a more "live" feel.
|
|
476
535
|
const unsub = t.onEvent((ev) => {
|
|
477
536
|
tracker.handleEvent(name, ev);
|
|
478
537
|
transcriptTracker.handleEvent(name, ev);
|
|
538
|
+
// Refresh widget on events that change visible state (tool start/end, turn end).
|
|
539
|
+
if (
|
|
540
|
+
ev.type === "tool_execution_start" ||
|
|
541
|
+
ev.type === "tool_execution_end" ||
|
|
542
|
+
ev.type === "agent_end"
|
|
543
|
+
) {
|
|
544
|
+
renderWidget();
|
|
545
|
+
}
|
|
479
546
|
});
|
|
480
547
|
teammateEventUnsubs.set(name, unsub);
|
|
481
548
|
renderWidget();
|
|
@@ -598,7 +665,17 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
598
665
|
await refreshTasks();
|
|
599
666
|
renderWidget();
|
|
600
667
|
|
|
601
|
-
return {
|
|
668
|
+
return {
|
|
669
|
+
ok: true,
|
|
670
|
+
name,
|
|
671
|
+
mode,
|
|
672
|
+
workspaceMode,
|
|
673
|
+
childCwd,
|
|
674
|
+
note,
|
|
675
|
+
model: childModel ?? undefined,
|
|
676
|
+
thinking: thinkingLevel,
|
|
677
|
+
warnings,
|
|
678
|
+
};
|
|
602
679
|
};
|
|
603
680
|
|
|
604
681
|
const pollLeaderInbox = async () => {
|
|
@@ -614,6 +691,10 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
614
691
|
style,
|
|
615
692
|
pendingPlanApprovals,
|
|
616
693
|
enqueueHook,
|
|
694
|
+
sendLeaderLlmMessage: (content, options) => {
|
|
695
|
+
pi.sendUserMessage(content, options);
|
|
696
|
+
},
|
|
697
|
+
delegationTracker,
|
|
617
698
|
});
|
|
618
699
|
};
|
|
619
700
|
|
|
@@ -632,6 +713,9 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
632
713
|
// use `/team task use <taskListId>` after switching.
|
|
633
714
|
taskListId = currentTeamId;
|
|
634
715
|
lastAttachClaimHeartbeatMs = 0;
|
|
716
|
+
// Clear any /team done suppression from a previous session.
|
|
717
|
+
widgetSuppressed = false;
|
|
718
|
+
autoDoneNotified = false;
|
|
635
719
|
|
|
636
720
|
// Claude-style: a persisted team config file.
|
|
637
721
|
await ensureTeamConfig(getTeamDir(currentTeamId), {
|
|
@@ -641,6 +725,15 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
641
725
|
style,
|
|
642
726
|
});
|
|
643
727
|
|
|
728
|
+
// Startup GC: silently remove stale team directories from previous sessions (24h age floor).
|
|
729
|
+
void gcStaleTeamDirs({
|
|
730
|
+
teamsRootDir: getTeamsRootDir(),
|
|
731
|
+
maxAgeMs: 24 * 60 * 60 * 1000,
|
|
732
|
+
excludeTeamIds: new Set([currentTeamId]),
|
|
733
|
+
}).catch(() => {
|
|
734
|
+
// Best-effort; never block the session.
|
|
735
|
+
});
|
|
736
|
+
|
|
644
737
|
await refreshTasks();
|
|
645
738
|
renderWidget();
|
|
646
739
|
|
|
@@ -671,12 +764,28 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
671
764
|
});
|
|
672
765
|
|
|
673
766
|
pi.on("session_switch", async (_event, ctx) => {
|
|
767
|
+
const prevTeamId = currentTeamId;
|
|
768
|
+
const prevCwd = currentCtx?.cwd;
|
|
769
|
+
|
|
674
770
|
if (currentCtx) {
|
|
675
771
|
await releaseActiveAttachClaim(currentCtx);
|
|
676
772
|
const strings = getTeamsStrings(style);
|
|
677
773
|
await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is dissolved — leader moved on`);
|
|
678
774
|
}
|
|
679
775
|
stopLoops();
|
|
776
|
+
delegationTracker.clear();
|
|
777
|
+
|
|
778
|
+
// Clean up worktrees from the old session before switching.
|
|
779
|
+
// Only clean up teams this session owns — never attached teams.
|
|
780
|
+
const prevSessionId = currentCtx?.sessionManager.getSessionId();
|
|
781
|
+
if (prevTeamId && prevTeamId === prevSessionId) {
|
|
782
|
+
const teamDir = getTeamDir(prevTeamId);
|
|
783
|
+
try {
|
|
784
|
+
await cleanupWorktrees({ teamDir, teamId: prevTeamId, repoCwd: prevCwd });
|
|
785
|
+
} catch {
|
|
786
|
+
// Best-effort — don't block session switch.
|
|
787
|
+
}
|
|
788
|
+
}
|
|
680
789
|
|
|
681
790
|
currentCtx = ctx;
|
|
682
791
|
currentTeamId = currentCtx.sessionManager.getSessionId();
|
|
@@ -684,6 +793,9 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
684
793
|
// use `/team task use <taskListId>` after switching.
|
|
685
794
|
taskListId = currentTeamId;
|
|
686
795
|
lastAttachClaimHeartbeatMs = 0;
|
|
796
|
+
// Clear any /team done suppression — new session context.
|
|
797
|
+
widgetSuppressed = false;
|
|
798
|
+
autoDoneNotified = false;
|
|
687
799
|
|
|
688
800
|
await ensureTeamConfig(getTeamDir(currentTeamId), {
|
|
689
801
|
teamId: currentTeamId,
|
|
@@ -725,8 +837,45 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
725
837
|
if (!currentCtx) return;
|
|
726
838
|
await releaseActiveAttachClaim(currentCtx);
|
|
727
839
|
stopLoops();
|
|
840
|
+
const hadTeammates = teammates.size > 0;
|
|
728
841
|
const strings = getTeamsStrings(style);
|
|
729
842
|
await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is over`);
|
|
843
|
+
|
|
844
|
+
// Clean up worktrees + branches for this team session so they don't accumulate on disk.
|
|
845
|
+
// Only clean up teams this session owns — never attached teams.
|
|
846
|
+
const sessionId = currentCtx.sessionManager.getSessionId();
|
|
847
|
+
if (currentTeamId && currentTeamId === sessionId) {
|
|
848
|
+
const teamDir = getTeamDir(currentTeamId);
|
|
849
|
+
try {
|
|
850
|
+
await cleanupWorktrees({ teamDir, teamId: currentTeamId, repoCwd: currentCtx.cwd });
|
|
851
|
+
} catch {
|
|
852
|
+
// Best-effort — don't block shutdown.
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Exit cleanup: delete own team directory if it's empty.
|
|
856
|
+
// Conservative: only if no RPC teammates were active, no online workers in
|
|
857
|
+
// config (manual/tmux), no tasks in ANY namespace, and no fresh attach claim.
|
|
858
|
+
// (Dirs with completed tasks are left for the 24h startup GC — intentionally
|
|
859
|
+
// asymmetric for safety.)
|
|
860
|
+
if (!hadTeammates) {
|
|
861
|
+
try {
|
|
862
|
+
const claim = await readTeamAttachClaim(teamDir);
|
|
863
|
+
const claimIsLive = claim !== null && !assessAttachClaimFreshness(claim).isStale;
|
|
864
|
+
if (claimIsLive) {
|
|
865
|
+
// Another session is using this team — don't delete.
|
|
866
|
+
} else {
|
|
867
|
+
// Also check config for online non-lead members (manual/tmux workers).
|
|
868
|
+
const cfg = await loadTeamConfig(teamDir);
|
|
869
|
+
const hasOnlineWorkers = cfg?.members.some((m) => m.role !== "lead" && m.status === "online") ?? false;
|
|
870
|
+
if (!hasOnlineWorkers && !(await teamDirHasAnyTasks(teamDir))) {
|
|
871
|
+
await cleanupTeamDir(getTeamsRootDir(), teamDir);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
} catch {
|
|
875
|
+
// Best-effort; never block shutdown.
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
730
879
|
});
|
|
731
880
|
|
|
732
881
|
registerTeamsTool({
|
|
@@ -735,9 +884,17 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
735
884
|
spawnTeammate,
|
|
736
885
|
getTeamId: (ctx) => currentTeamId ?? ctx.sessionManager.getSessionId(),
|
|
737
886
|
getTaskListId: () => taskListId,
|
|
887
|
+
getTracker: () => tracker,
|
|
888
|
+
getTeamConfig: () => teamConfig,
|
|
738
889
|
refreshTasks,
|
|
739
890
|
renderWidget,
|
|
891
|
+
hideWidget,
|
|
892
|
+
stopAllTeammates: async (reason: string) => {
|
|
893
|
+
if (!currentCtx) return;
|
|
894
|
+
await stopAllTeammates(currentCtx, reason);
|
|
895
|
+
},
|
|
740
896
|
pendingPlanApprovals,
|
|
897
|
+
delegationTracker,
|
|
741
898
|
});
|
|
742
899
|
|
|
743
900
|
const openWidget = async (ctx: ExtensionCommandContext) => {
|
|
@@ -841,13 +998,16 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
841
998
|
getSessionTeamId() {
|
|
842
999
|
return ctx.sessionManager.getSessionId();
|
|
843
1000
|
},
|
|
1001
|
+
getLeaderModel() {
|
|
1002
|
+
const model = currentCtx?.model;
|
|
1003
|
+
if (!model) return null;
|
|
1004
|
+
return { provider: model.provider, modelId: model.id };
|
|
1005
|
+
},
|
|
844
1006
|
suppressWidget() {
|
|
845
|
-
|
|
846
|
-
ctx.ui.setWidget("pi-teams", undefined);
|
|
1007
|
+
hideWidget();
|
|
847
1008
|
},
|
|
848
1009
|
restoreWidget() {
|
|
849
|
-
|
|
850
|
-
renderWidget();
|
|
1010
|
+
restoreWidget();
|
|
851
1011
|
},
|
|
852
1012
|
});
|
|
853
1013
|
};
|
|
@@ -893,16 +1053,21 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
893
1053
|
ctx,
|
|
894
1054
|
teammates,
|
|
895
1055
|
getTeamConfig: () => teamConfig,
|
|
1056
|
+
getTracker: () => tracker,
|
|
896
1057
|
getTasks: () => tasks,
|
|
897
1058
|
refreshTasks,
|
|
898
1059
|
renderWidget,
|
|
1060
|
+
hideWidget,
|
|
1061
|
+
restoreWidget,
|
|
899
1062
|
getTaskListId: () => taskListId,
|
|
900
1063
|
setTaskListId: (id) => {
|
|
901
1064
|
taskListId = id;
|
|
1065
|
+
delegationTracker.clear();
|
|
902
1066
|
},
|
|
903
1067
|
getActiveTeamId: () => currentTeamId ?? ctx.sessionManager.getSessionId(),
|
|
904
1068
|
setActiveTeamId: (teamId) => {
|
|
905
1069
|
currentTeamId = teamId;
|
|
1070
|
+
delegationTracker.clear();
|
|
906
1071
|
},
|
|
907
1072
|
pendingPlanApprovals,
|
|
908
1073
|
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
|
-
|
|
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);
|
|
@@ -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;
|