@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.
- package/CHANGELOG.md +26 -0
- package/README.md +72 -9
- package/WORKFLOW.md +110 -0
- package/docs/claude-parity.md +18 -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 +216 -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 +387 -11
- package/extensions/teams/leader.ts +126 -52
- package/extensions/teams/mailbox.ts +6 -1
- package/extensions/teams/model-policy.ts +117 -0
- 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 +4 -2
- package/scripts/integration-cleanup-test.mts +419 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +10 -0
- package/scripts/smoke-test.mts +701 -3
- 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 {
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
...(
|
|
637
|
+
...(childModel ? { model: childModel } : {}),
|
|
628
638
|
},
|
|
629
639
|
});
|
|
630
640
|
|
|
631
641
|
await refreshTasks();
|
|
632
642
|
renderWidget();
|
|
633
643
|
|
|
634
|
-
return {
|
|
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
|
-
|
|
879
|
-
ctx.ui.setWidget("pi-teams", undefined);
|
|
949
|
+
hideWidget();
|
|
880
950
|
},
|
|
881
951
|
restoreWidget() {
|
|
882
|
-
|
|
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
|
-
|
|
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;
|