@melihmucuk/pi-crew 1.0.12 → 1.0.14

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 (68) hide show
  1. package/docs/architecture.md +24 -6
  2. package/extension/agent-discovery.ts +791 -0
  3. package/extension/bootstrap-session.ts +131 -0
  4. package/extension/index.ts +65 -0
  5. package/extension/integration/register-command.ts +59 -0
  6. package/extension/integration/register-renderers.ts +77 -0
  7. package/extension/integration/register-tools.ts +39 -0
  8. package/extension/integration/tool-presentation.ts +50 -0
  9. package/extension/integration/tools/crew-abort.ts +121 -0
  10. package/extension/integration/tools/crew-done.ts +42 -0
  11. package/extension/integration/tools/crew-list.ts +91 -0
  12. package/extension/integration/tools/crew-respond.ts +57 -0
  13. package/extension/integration/tools/crew-spawn.ts +88 -0
  14. package/extension/integration/tools/tool-deps.ts +16 -0
  15. package/extension/integration.ts +15 -0
  16. package/extension/runtime/crew-runtime.ts +426 -0
  17. package/extension/runtime/delivery-coordinator.ts +131 -0
  18. package/extension/runtime/overflow-recovery.ts +211 -0
  19. package/extension/runtime/subagent-registry.ts +85 -0
  20. package/extension/runtime/subagent-state.ts +73 -0
  21. package/extension/status-widget.ts +107 -0
  22. package/extension/subagent-messages.ts +124 -0
  23. package/extension/tool-registry.ts +19 -0
  24. package/package.json +11 -14
  25. package/dist/agent-discovery.d.ts +0 -29
  26. package/dist/agent-discovery.js +0 -527
  27. package/dist/bootstrap-session.d.ts +0 -21
  28. package/dist/bootstrap-session.js +0 -74
  29. package/dist/index.d.ts +0 -2
  30. package/dist/index.js +0 -46
  31. package/dist/integration/register-command.d.ts +0 -3
  32. package/dist/integration/register-command.js +0 -51
  33. package/dist/integration/register-renderers.d.ts +0 -2
  34. package/dist/integration/register-renderers.js +0 -53
  35. package/dist/integration/register-tools.d.ts +0 -3
  36. package/dist/integration/register-tools.js +0 -25
  37. package/dist/integration/tool-presentation.d.ts +0 -29
  38. package/dist/integration/tool-presentation.js +0 -28
  39. package/dist/integration/tools/crew-abort.d.ts +0 -2
  40. package/dist/integration/tools/crew-abort.js +0 -79
  41. package/dist/integration/tools/crew-done.d.ts +0 -2
  42. package/dist/integration/tools/crew-done.js +0 -28
  43. package/dist/integration/tools/crew-list.d.ts +0 -2
  44. package/dist/integration/tools/crew-list.js +0 -72
  45. package/dist/integration/tools/crew-respond.d.ts +0 -2
  46. package/dist/integration/tools/crew-respond.js +0 -32
  47. package/dist/integration/tools/crew-spawn.d.ts +0 -2
  48. package/dist/integration/tools/crew-spawn.js +0 -48
  49. package/dist/integration/tools/tool-deps.d.ts +0 -9
  50. package/dist/integration/tools/tool-deps.js +0 -1
  51. package/dist/integration.d.ts +0 -3
  52. package/dist/integration.js +0 -8
  53. package/dist/runtime/crew-runtime.d.ts +0 -62
  54. package/dist/runtime/crew-runtime.js +0 -285
  55. package/dist/runtime/delivery-coordinator.d.ts +0 -26
  56. package/dist/runtime/delivery-coordinator.js +0 -86
  57. package/dist/runtime/overflow-recovery.d.ts +0 -3
  58. package/dist/runtime/overflow-recovery.js +0 -155
  59. package/dist/runtime/subagent-registry.d.ts +0 -14
  60. package/dist/runtime/subagent-registry.js +0 -58
  61. package/dist/runtime/subagent-state.d.ts +0 -36
  62. package/dist/runtime/subagent-state.js +0 -34
  63. package/dist/status-widget.d.ts +0 -3
  64. package/dist/status-widget.js +0 -84
  65. package/dist/subagent-messages.d.ts +0 -33
  66. package/dist/subagent-messages.js +0 -59
  67. package/dist/tool-registry.d.ts +0 -5
  68. package/dist/tool-registry.js +0 -13
@@ -0,0 +1,131 @@
1
+ import {
2
+ type SteeringPayload,
3
+ sendRemainingNote,
4
+ sendSteeringMessage,
5
+ type SendMessageFn,
6
+ } from "../subagent-messages.js";
7
+
8
+ export interface ActiveRuntimeBinding {
9
+ sessionId: string;
10
+ isIdle: () => boolean;
11
+ sendMessage: SendMessageFn;
12
+ }
13
+
14
+ interface PendingMessage {
15
+ ownerSessionId: string;
16
+ payload: SteeringPayload;
17
+ queuedAt: number;
18
+ }
19
+
20
+ export class DeliveryCoordinator {
21
+ private binding: ActiveRuntimeBinding | undefined;
22
+ private pendingMessages: PendingMessage[] = [];
23
+ private flushScheduled = false;
24
+
25
+ activateSession(
26
+ binding: ActiveRuntimeBinding,
27
+ countRunningForOwner: (ownerSessionId: string, excludeId: string) => number,
28
+ ): void {
29
+ this.binding = binding;
30
+ // Delay flush to next macrotask. session_start fires before pi-core
31
+ // calls _reconnectToAgent(), so synchronous delivery would emit agent
32
+ // events while the session listener is disconnected, losing JSONL persistence.
33
+ if (this.pendingMessages.some((entry) => entry.ownerSessionId === binding.sessionId)) {
34
+ this.flushScheduled = true;
35
+ setTimeout(() => {
36
+ this.flushScheduled = false;
37
+ this.flushPending(countRunningForOwner);
38
+ }, 0);
39
+ }
40
+ }
41
+
42
+ deactivateSession(sessionId: string): void {
43
+ if (this.binding?.sessionId === sessionId) {
44
+ this.binding = undefined;
45
+ }
46
+ }
47
+
48
+ deliver(
49
+ ownerSessionId: string,
50
+ payload: SteeringPayload,
51
+ countRunningForOwner: (ownerSessionId: string, excludeId: string) => number,
52
+ ): void {
53
+ if (!this.binding || ownerSessionId !== this.binding.sessionId || this.flushScheduled) {
54
+ this.pendingMessages.push({ ownerSessionId, payload, queuedAt: Date.now() });
55
+ return;
56
+ }
57
+
58
+ this.send(ownerSessionId, payload, countRunningForOwner);
59
+ }
60
+
61
+ /**
62
+ * Remove pending messages older than the TTL.
63
+ * Called during activateSession to prevent unbounded memory growth.
64
+ */
65
+ private cleanStaleMessages(): void {
66
+ const maxAgeMs = 86_400_000; // 24 hours
67
+ const cutoff = Date.now() - maxAgeMs;
68
+ this.pendingMessages = this.pendingMessages.filter(
69
+ (entry) => entry.queuedAt >= cutoff,
70
+ );
71
+ }
72
+
73
+ private flushPending(
74
+ countRunningForOwner: (ownerSessionId: string, excludeId: string) => number,
75
+ ): void {
76
+ if (!this.binding) return;
77
+ const targetSessionId = this.binding.sessionId;
78
+
79
+ // Clean up stale messages first (older than TTL)
80
+ this.cleanStaleMessages();
81
+
82
+ const toDeliver: PendingMessage[] = [];
83
+ const remaining: PendingMessage[] = [];
84
+
85
+ for (const entry of this.pendingMessages) {
86
+ if (entry.ownerSessionId === targetSessionId) {
87
+ toDeliver.push(entry);
88
+ } else {
89
+ // Keep all other messages - they may be for sessions that will be reactivated later
90
+ remaining.push(entry);
91
+ }
92
+ }
93
+
94
+ // Keep messages for other sessions
95
+ this.pendingMessages = remaining;
96
+
97
+ // Deliver messages for the active session
98
+ for (const entry of toDeliver) {
99
+ this.send(entry.ownerSessionId, entry.payload, countRunningForOwner);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Result messages always go first. If more subagents are still running and the
105
+ * owner is idle, queue the result without triggering, then queue the separate
106
+ * remaining note with triggerTurn so the next turn sees both in order.
107
+ */
108
+ private send(
109
+ ownerSessionId: string,
110
+ payload: SteeringPayload,
111
+ countRunningForOwner: (ownerSessionId: string, excludeId: string) => number,
112
+ ): void {
113
+ if (!this.binding || this.binding.sessionId !== ownerSessionId) {
114
+ this.pendingMessages.push({ ownerSessionId, payload, queuedAt: Date.now() });
115
+ return;
116
+ }
117
+
118
+ const remaining = countRunningForOwner(ownerSessionId, payload.id);
119
+ const isIdle = this.binding.isIdle();
120
+ const triggerResultTurn = !(isIdle && remaining > 0);
121
+
122
+ sendSteeringMessage(payload, this.binding.sendMessage, {
123
+ isIdle,
124
+ triggerTurn: triggerResultTurn,
125
+ });
126
+ sendRemainingNote(remaining, this.binding.sendMessage, {
127
+ isIdle,
128
+ triggerTurn: isIdle && remaining > 0,
129
+ });
130
+ }
131
+ }
@@ -0,0 +1,211 @@
1
+ import type { AgentSession, AgentSessionEvent } from "@mariozechner/pi-coding-agent";
2
+
3
+ const OVERFLOW_RECOVERY_TIMEOUT_MS = 120_000;
4
+
5
+ /**
6
+ * Short grace period for the first terminal agent_end after prompt() resolves.
7
+ * If this window expires, we still wait the full recovery timeout.
8
+ */
9
+ const INITIAL_AGENT_END_WAIT_MS = 5_000;
10
+
11
+ type PhaseWaitResult = "done" | "timeout" | "cancelled";
12
+
13
+ export type OverflowRecoveryResult = "none" | "recovered" | "failed";
14
+
15
+ interface DeferredPhase {
16
+ promise: Promise<void>;
17
+ resolve: () => void;
18
+ isDone: () => boolean;
19
+ }
20
+
21
+ function createDeferredPhase(): DeferredPhase {
22
+ let done = false;
23
+ let resolveFn: (() => void) | undefined;
24
+
25
+ const promise = new Promise<void>((resolve) => {
26
+ resolveFn = () => {
27
+ if (done) return;
28
+ done = true;
29
+ resolve();
30
+ };
31
+ });
32
+
33
+ return {
34
+ promise,
35
+ resolve: () => resolveFn?.(),
36
+ isDone: () => done,
37
+ };
38
+ }
39
+
40
+ class OverflowRecoveryTracker {
41
+ private overflowDetected = false;
42
+ private compactionWillRetry = false;
43
+
44
+ private autoRetryActive = false;
45
+ private readonly initialAgentEnd = createDeferredPhase();
46
+ private compactionEnd: DeferredPhase | undefined;
47
+ private retryAgentEnd: DeferredPhase | undefined;
48
+ private overflowAutoRetryEnd: DeferredPhase | undefined;
49
+ private timers: ReturnType<typeof setTimeout>[] = [];
50
+
51
+ handleEvent(event: AgentSessionEvent): void {
52
+ switch (event.type) {
53
+ case "agent_end":
54
+ this.onAgentEnd();
55
+ break;
56
+ case "compaction_start":
57
+ this.onCompactionStart(event.reason);
58
+ break;
59
+ case "compaction_end":
60
+ this.onCompactionEnd(event.reason, event.willRetry);
61
+ break;
62
+ case "auto_retry_start":
63
+ this.onAutoRetryStart();
64
+ break;
65
+ case "auto_retry_end":
66
+ this.onAutoRetryEnd();
67
+ break;
68
+ default:
69
+ break;
70
+ }
71
+ }
72
+
73
+ async awaitCompletion(signal: AbortSignal): Promise<OverflowRecoveryResult> {
74
+ const cancelPromise = new Promise<void>((resolve) => {
75
+ if (signal.aborted) {
76
+ resolve();
77
+ return;
78
+ }
79
+ signal.addEventListener("abort", () => resolve(), { once: true });
80
+ });
81
+
82
+ try {
83
+ let initialEnd = await this.waitForPhase(
84
+ this.initialAgentEnd.promise,
85
+ INITIAL_AGENT_END_WAIT_MS,
86
+ cancelPromise,
87
+ );
88
+
89
+ if (initialEnd === "timeout") {
90
+ initialEnd = await this.waitForPhase(
91
+ this.initialAgentEnd.promise,
92
+ OVERFLOW_RECOVERY_TIMEOUT_MS,
93
+ cancelPromise,
94
+ );
95
+ }
96
+
97
+ if (initialEnd !== "done") {
98
+ return this.overflowDetected ? "failed" : "none";
99
+ }
100
+
101
+ if (!this.overflowDetected) return "none";
102
+
103
+ if (this.compactionEnd) {
104
+ const compactionEnd = await this.waitForPhase(
105
+ this.compactionEnd.promise,
106
+ OVERFLOW_RECOVERY_TIMEOUT_MS,
107
+ cancelPromise,
108
+ );
109
+ if (compactionEnd !== "done") return "failed";
110
+ }
111
+
112
+ if (!this.compactionWillRetry) return "failed";
113
+
114
+ if (this.retryAgentEnd) {
115
+ const retryEnd = await this.waitForPhase(
116
+ this.retryAgentEnd.promise,
117
+ OVERFLOW_RECOVERY_TIMEOUT_MS,
118
+ cancelPromise,
119
+ );
120
+ if (retryEnd !== "done") return "failed";
121
+ }
122
+
123
+ if (this.overflowAutoRetryEnd) {
124
+ const autoRetryEnd = await this.waitForPhase(
125
+ this.overflowAutoRetryEnd.promise,
126
+ OVERFLOW_RECOVERY_TIMEOUT_MS,
127
+ cancelPromise,
128
+ );
129
+ if (autoRetryEnd !== "done") return "failed";
130
+ }
131
+
132
+ return "recovered";
133
+ } finally {
134
+ for (const timer of this.timers) clearTimeout(timer);
135
+ }
136
+ }
137
+
138
+ private async waitForPhase(
139
+ phasePromise: Promise<void>,
140
+ timeoutMs: number,
141
+ cancelPromise: Promise<void>,
142
+ ): Promise<PhaseWaitResult> {
143
+ return Promise.race([
144
+ phasePromise.then(() => "done" as const),
145
+ cancelPromise.then(() => "cancelled" as const),
146
+ new Promise<"timeout">((resolve) => {
147
+ this.timers.push(setTimeout(() => resolve("timeout"), timeoutMs));
148
+ }),
149
+ ]);
150
+ }
151
+
152
+ // agent_end can be followed immediately by auto_retry_start in the same
153
+ // _processAgentEvent tick. Resolve on microtask so we can ignore retrying
154
+ // attempts and only accept terminal agent_end events.
155
+ private onAgentEnd(): void {
156
+ queueMicrotask(() => {
157
+ if (this.autoRetryActive) return;
158
+
159
+ if (!this.initialAgentEnd.isDone()) {
160
+ this.initialAgentEnd.resolve();
161
+ return;
162
+ }
163
+
164
+ this.retryAgentEnd?.resolve();
165
+ });
166
+ }
167
+
168
+ private onCompactionStart(reason: "manual" | "threshold" | "overflow"): void {
169
+ if (reason !== "overflow") return;
170
+ this.overflowDetected = true;
171
+ this.compactionEnd ??= createDeferredPhase();
172
+ }
173
+
174
+ private onCompactionEnd(reason: "manual" | "threshold" | "overflow", willRetry: boolean): void {
175
+ if (reason !== "overflow") return;
176
+
177
+ this.compactionWillRetry = willRetry;
178
+ if (willRetry) {
179
+ this.retryAgentEnd ??= createDeferredPhase();
180
+ }
181
+ this.compactionEnd?.resolve();
182
+ }
183
+
184
+ private onAutoRetryStart(): void {
185
+ this.autoRetryActive = true;
186
+ if (this.overflowDetected) {
187
+ this.overflowAutoRetryEnd ??= createDeferredPhase();
188
+ }
189
+ }
190
+
191
+ private onAutoRetryEnd(): void {
192
+ this.autoRetryActive = false;
193
+ this.overflowAutoRetryEnd?.resolve();
194
+ }
195
+ }
196
+
197
+ export async function runPromptWithOverflowRecovery(
198
+ session: AgentSession,
199
+ text: string,
200
+ signal: AbortSignal,
201
+ ): Promise<OverflowRecoveryResult> {
202
+ const tracker = new OverflowRecoveryTracker();
203
+ const unsubscribe = session.subscribe((event) => tracker.handleEvent(event));
204
+
205
+ try {
206
+ await session.prompt(text);
207
+ return await tracker.awaitCompletion(signal);
208
+ } finally {
209
+ unsubscribe();
210
+ }
211
+ }
@@ -0,0 +1,85 @@
1
+ import type { AgentConfig } from "../agent-discovery.js";
2
+ import type { AbortableAgentSummary, ActiveAgentSummary, SubagentState } from "./subagent-state.js";
3
+ import {
4
+ buildAbortableAgentSummary,
5
+ buildActiveAgentSummary,
6
+ generateId,
7
+ isAbortableStatus,
8
+ } from "./subagent-state.js";
9
+
10
+ export class SubagentRegistry {
11
+ private activeAgents = new Map<string, SubagentState>();
12
+
13
+ create(agentConfig: AgentConfig, task: string, ownerSessionId: string): SubagentState {
14
+ const id = generateId(agentConfig.name, new Set(this.activeAgents.keys()));
15
+ const state: SubagentState = {
16
+ id,
17
+ agentConfig,
18
+ task,
19
+ status: "running",
20
+ ownerSessionId,
21
+ session: null,
22
+ turns: 0,
23
+ contextTokens: 0,
24
+ model: undefined,
25
+ };
26
+
27
+ this.activeAgents.set(id, state);
28
+ return state;
29
+ }
30
+
31
+ get(id: string): SubagentState | undefined {
32
+ return this.activeAgents.get(id);
33
+ }
34
+
35
+ hasState(state: SubagentState): boolean {
36
+ return this.activeAgents.get(state.id) === state;
37
+ }
38
+
39
+ delete(id: string): void {
40
+ this.activeAgents.delete(id);
41
+ }
42
+
43
+ countRunningForOwner(ownerSessionId: string, excludeId: string): number {
44
+ let count = 0;
45
+ for (const state of this.activeAgents.values()) {
46
+ if (
47
+ state.id !== excludeId &&
48
+ state.ownerSessionId === ownerSessionId &&
49
+ state.status === "running"
50
+ ) {
51
+ count++;
52
+ }
53
+ }
54
+ return count;
55
+ }
56
+
57
+ getAbortableAgents(): AbortableAgentSummary[] {
58
+ return Array.from(this.activeAgents.values())
59
+ .filter((state) => isAbortableStatus(state.status))
60
+ .map(buildAbortableAgentSummary);
61
+ }
62
+
63
+ getActiveSummariesForOwner(ownerSessionId: string): ActiveAgentSummary[] {
64
+ return Array.from(this.activeAgents.values())
65
+ .filter(
66
+ (state) => isAbortableStatus(state.status) && state.ownerSessionId === ownerSessionId,
67
+ )
68
+ .map(buildActiveAgentSummary);
69
+ }
70
+
71
+ getOwnedAbortableIds(ownerSessionId: string): string[] {
72
+ return Array.from(this.activeAgents.values())
73
+ .filter(
74
+ (state) =>
75
+ state.ownerSessionId === ownerSessionId && isAbortableStatus(state.status),
76
+ )
77
+ .map((state) => state.id);
78
+ }
79
+
80
+ getAllRunning(): SubagentState[] {
81
+ return Array.from(this.activeAgents.values()).filter((state) =>
82
+ isAbortableStatus(state.status),
83
+ );
84
+ }
85
+ }
@@ -0,0 +1,73 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
3
+ import type { AgentConfig } from "../agent-discovery.js";
4
+ import type { SubagentStatus } from "../subagent-messages.js";
5
+
6
+ export interface SubagentState {
7
+ id: string;
8
+ agentConfig: AgentConfig;
9
+ task: string;
10
+ status: SubagentStatus;
11
+ ownerSessionId: string;
12
+ session: AgentSession | null;
13
+ turns: number;
14
+ contextTokens: number;
15
+ model: string | undefined;
16
+ error?: string;
17
+ result?: string;
18
+ promptAbortController?: AbortController;
19
+ unsubscribe?: () => void;
20
+ }
21
+
22
+ export interface ActiveAgentSummary {
23
+ id: string;
24
+ agentName: string;
25
+ status: SubagentStatus;
26
+ turns: number;
27
+ contextTokens: number;
28
+ model: string | undefined;
29
+ }
30
+
31
+ export interface AbortableAgentSummary {
32
+ id: string;
33
+ agentName: string;
34
+ }
35
+
36
+ export function generateId(name: string, existingIds: Set<string>): string {
37
+ for (let i = 0; i < 10; i++) {
38
+ const id = `${name}-${randomBytes(4).toString("hex")}`;
39
+ if (!existingIds.has(id)) return id;
40
+ }
41
+ return `${name}-${randomBytes(8).toString("hex")}`;
42
+ }
43
+
44
+ // Status may change externally via abort(). Standalone function avoids TS narrowing.
45
+ export function isAborted(state: SubagentState): boolean {
46
+ return state.status === "aborted";
47
+ }
48
+
49
+ export function isAbortableStatus(status: SubagentStatus): boolean {
50
+ return status === "running" || status === "waiting";
51
+ }
52
+
53
+ export function buildActiveAgentSummary(
54
+ state: SubagentState,
55
+ ): ActiveAgentSummary {
56
+ return {
57
+ id: state.id,
58
+ agentName: state.agentConfig.name,
59
+ status: state.status,
60
+ turns: state.turns,
61
+ contextTokens: state.contextTokens,
62
+ model: state.model,
63
+ };
64
+ }
65
+
66
+ export function buildAbortableAgentSummary(
67
+ state: SubagentState,
68
+ ): AbortableAgentSummary {
69
+ return {
70
+ id: state.id,
71
+ agentName: state.agentConfig.name,
72
+ };
73
+ }
@@ -0,0 +1,107 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import type { ActiveAgentSummary } from "./runtime/crew-runtime.js";
4
+ import type { CrewRuntime } from "./runtime/crew-runtime.js";
5
+
6
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ const SPINNER_INTERVAL_MS = 80;
8
+
9
+ function formatTokens(tokens: number): string {
10
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
11
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
12
+ return String(tokens);
13
+ }
14
+
15
+ function buildLine(agent: ActiveAgentSummary, frame: string): string {
16
+ const model = agent.model ?? "…";
17
+ const icon = agent.status === "waiting" ? "⏳" : frame;
18
+ return `${icon} ${agent.id} (${model}) · turn ${agent.turns} · ${formatTokens(agent.contextTokens)} ctx`;
19
+ }
20
+
21
+ interface WidgetState {
22
+ ctx: ExtensionContext;
23
+ text: Text;
24
+ // biome-ignore lint: TUI type from factory param
25
+ tui: any;
26
+ timer: ReturnType<typeof setInterval>;
27
+ frameIndex: number;
28
+ }
29
+
30
+ let widget: WidgetState | undefined;
31
+
32
+ function disposeWidget(state: WidgetState): void {
33
+ clearInterval(state.timer);
34
+ if (widget === state) {
35
+ widget = undefined;
36
+ }
37
+ }
38
+
39
+ function clearWidget(): void {
40
+ const current = widget;
41
+ if (!current) return;
42
+ disposeWidget(current);
43
+ current.ctx.ui.setWidget("crew-status", undefined);
44
+ }
45
+
46
+ function hasRunningAgent(agents: ActiveAgentSummary[]): boolean {
47
+ return agents.some((agent) => agent.status === "running");
48
+ }
49
+
50
+ function syncWidgetText(state: WidgetState, agents: ActiveAgentSummary[]): void {
51
+ const frame = SPINNER_FRAMES[state.frameIndex % SPINNER_FRAMES.length];
52
+ const lines = agents.map((agent) => buildLine(agent, frame));
53
+ state.text.setText(lines.join("\n"));
54
+ state.tui.requestRender();
55
+ }
56
+
57
+ export function updateWidget(ctx: ExtensionContext, crew: CrewRuntime): void {
58
+ if (!ctx.hasUI) {
59
+ clearWidget();
60
+ return;
61
+ }
62
+
63
+ const ownerSessionId = ctx.sessionManager.getSessionId();
64
+ const running = crew.getActiveSummariesForOwner(ownerSessionId);
65
+ if (running.length === 0) {
66
+ clearWidget();
67
+ return;
68
+ }
69
+
70
+ if (widget && widget.ctx !== ctx) {
71
+ clearWidget();
72
+ }
73
+
74
+ if (widget) {
75
+ syncWidgetText(widget, running);
76
+ return;
77
+ }
78
+
79
+ ctx.ui.setWidget("crew-status", (tui, _theme) => {
80
+ const text = new Text("", 1, 0);
81
+ const state: WidgetState = {
82
+ ctx,
83
+ text,
84
+ tui,
85
+ frameIndex: 0,
86
+ timer: setInterval(() => {
87
+ const agents = crew.getActiveSummariesForOwner(ownerSessionId);
88
+ if (agents.length === 0) {
89
+ clearWidget();
90
+ return;
91
+ }
92
+ if (!hasRunningAgent(agents)) return;
93
+ state.frameIndex++;
94
+ syncWidgetText(state, agents);
95
+ }, SPINNER_INTERVAL_MS),
96
+ };
97
+
98
+ widget = state;
99
+ syncWidgetText(state, running);
100
+
101
+ return Object.assign(text, {
102
+ dispose() {
103
+ disposeWidget(state);
104
+ },
105
+ });
106
+ });
107
+ }