pi-extended-teams 2.1.16

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 (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Tmux Terminal Adapter
3
+ *
4
+ * Implements the TerminalAdapter interface for tmux terminal multiplexer.
5
+ */
6
+
7
+ import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
8
+
9
+ const PANE_SNAPSHOT_TTL_MS = 100;
10
+
11
+ type PaneSnapshot = {
12
+ panes: Map<string, string | null>;
13
+ expiresAt: number;
14
+ };
15
+
16
+ export class TmuxAdapter implements TerminalAdapter {
17
+ readonly name = "tmux";
18
+ private paneSnapshot: PaneSnapshot | null = null;
19
+
20
+ detect(): boolean {
21
+ // tmux is available if TMUX environment variable is set
22
+ return !!process.env.TMUX;
23
+ }
24
+
25
+ getCurrentPaneId(): string | null {
26
+ const paneId = process.env.TMUX_PANE?.trim();
27
+ return paneId ? paneId : null;
28
+ }
29
+
30
+ getWindowIdForPane(paneId: string | null | undefined): string | null {
31
+ const targetPaneId = paneId?.trim();
32
+ if (!targetPaneId) return null;
33
+
34
+ const snapshot = this.getPaneSnapshot();
35
+ if (snapshot) {
36
+ return snapshot.get(targetPaneId) ?? null;
37
+ }
38
+
39
+ try {
40
+ const result = execCommand("tmux", ["display-message", "-p", "-t", targetPaneId, "#{window_id}"]);
41
+ if (result.status !== 0) return null;
42
+
43
+ const windowId = result.stdout.trim();
44
+ return windowId || null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ private isPaneUsable(paneId: string | null | undefined): paneId is string {
51
+ const targetPaneId = paneId?.trim();
52
+ if (!targetPaneId) return false;
53
+
54
+ const snapshot = this.getPaneSnapshot();
55
+ if (snapshot) {
56
+ return snapshot.has(targetPaneId);
57
+ }
58
+
59
+ try {
60
+ const result = execCommand("tmux", ["display-message", "-p", "-t", targetPaneId, "#{pane_id}"]);
61
+ return result.status === 0 && result.stdout.trim() === targetPaneId;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ private getPaneSnapshot(): Map<string, string | null> | null {
68
+ const now = Date.now();
69
+ if (this.paneSnapshot && this.paneSnapshot.expiresAt > now) {
70
+ return this.paneSnapshot.panes;
71
+ }
72
+
73
+ try {
74
+ const result = execCommand("tmux", ["list-panes", "-a", "-F", "#{pane_id}\t#{window_id}"]);
75
+ if (result.status !== 0) {
76
+ this.paneSnapshot = null;
77
+ return null;
78
+ }
79
+
80
+ const panes = new Map<string, string | null>();
81
+ for (const line of result.stdout.split(/\r?\n/)) {
82
+ const [paneId, windowId] = line.split("\t");
83
+ const normalizedPaneId = paneId?.trim();
84
+ if (!normalizedPaneId) continue;
85
+
86
+ panes.set(normalizedPaneId, windowId?.trim() || null);
87
+ }
88
+
89
+ this.paneSnapshot = {
90
+ panes,
91
+ expiresAt: Date.now() + PANE_SNAPSHOT_TTL_MS,
92
+ };
93
+ return panes;
94
+ } catch {
95
+ this.paneSnapshot = null;
96
+ return null;
97
+ }
98
+ }
99
+
100
+ private invalidatePaneSnapshot(): void {
101
+ this.paneSnapshot = null;
102
+ }
103
+
104
+ private getOriginPaneId(preferredPaneId?: string | null): string | null {
105
+ if (this.isPaneUsable(preferredPaneId)) {
106
+ return preferredPaneId.trim();
107
+ }
108
+
109
+ const currentPaneId = this.getCurrentPaneId();
110
+ if (this.isPaneUsable(currentPaneId)) {
111
+ return currentPaneId.trim();
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ spawn(options: SpawnOptions): string {
118
+ const envArgs = Object.entries(options.env)
119
+ .filter(([k]) => k.startsWith("PI_"))
120
+ .map(([k, v]) => `${k}=${v}`);
121
+
122
+ const originPaneId = this.getOriginPaneId(options.anchorPaneId);
123
+ const originWindowId = this.getWindowIdForPane(originPaneId);
124
+ const tmuxArgs = [
125
+ "new-window",
126
+ "-dP",
127
+ "-F", "#{pane_id}",
128
+ "-n", options.name,
129
+ ];
130
+
131
+ if (originWindowId) {
132
+ // A bare `new-window -t @window` reuses the target window index; insert after it instead.
133
+ tmuxArgs.push("-a", "-t", originWindowId);
134
+ }
135
+
136
+ tmuxArgs.push(
137
+ "-c", options.cwd,
138
+ "env", ...envArgs,
139
+ "sh", "-c", options.command
140
+ );
141
+
142
+ const result = execCommand("tmux", tmuxArgs);
143
+
144
+ if (result.status !== 0) {
145
+ throw new Error(`tmux spawn failed with status ${result.status}: ${result.stderr}`);
146
+ }
147
+
148
+ const newPaneId = result.stdout.trim();
149
+ this.invalidatePaneSnapshot();
150
+ if (newPaneId) {
151
+ execCommand("tmux", ["select-pane", "-t", newPaneId, "-T", options.name]);
152
+ }
153
+
154
+ return newPaneId;
155
+ }
156
+
157
+ kill(paneId: string): void {
158
+ if (!paneId) return;
159
+
160
+ try {
161
+ execCommand("tmux", ["kill-pane", "-t", paneId.trim()]);
162
+ } catch {
163
+ // Ignore errors - pane may already be dead
164
+ } finally {
165
+ this.invalidatePaneSnapshot();
166
+ }
167
+ }
168
+
169
+ isAlive(paneId: string): boolean {
170
+ if (!paneId) return false;
171
+ // A pane is alive if tmux still reports it as a valid pane id.
172
+ return this.isPaneUsable(paneId.trim());
173
+ }
174
+
175
+ focusPane(paneId: string): boolean {
176
+ const targetPaneId = paneId?.trim();
177
+ if (!this.isPaneUsable(targetPaneId)) return false;
178
+
179
+ const windowId = this.getWindowIdForPane(targetPaneId);
180
+ if (windowId) {
181
+ const windowResult = execCommand("tmux", ["select-window", "-t", windowId]);
182
+ if (windowResult.status !== 0) return false;
183
+ }
184
+
185
+ const paneResult = execCommand("tmux", ["select-pane", "-t", targetPaneId]);
186
+ return paneResult.status === 0;
187
+ }
188
+
189
+ setTitle(title: string): void {
190
+ try {
191
+ const paneId = this.getCurrentPaneId();
192
+ const args = paneId
193
+ ? ["select-pane", "-t", paneId, "-T", title]
194
+ : ["select-pane", "-T", title];
195
+ execCommand("tmux", args);
196
+ } catch {
197
+ // Ignore errors
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,437 @@
1
+ import * as teams from "../utils/teams";
2
+ import * as tasks from "../utils/tasks";
3
+ import * as claims from "../utils/claims";
4
+ import * as messaging from "../utils/messaging";
5
+ import * as runtime from "../utils/runtime";
6
+ import * as writeQueue from "../utils/write-queue";
7
+ import * as reports from "../utils/report-events";
8
+ import type { Member } from "../utils/models";
9
+ import { canonicalPersistedModelSlot, loadSettings, requireFavoriteModelLevel, roleForFavoriteModelSlot } from "../utils/settings";
10
+ import type {
11
+ BroadcastMessageOnceRequest,
12
+ EnsureTeamRequest,
13
+ EnsureTeamResponse,
14
+ ObserveRuntimeOptions,
15
+ SendMessageOnceRequest,
16
+ SpawnTeammateOnceOptions,
17
+ SpawnTeammateOnceRequest,
18
+ SpawnTeammateOnceResponse,
19
+ SpawnTeammatesOnceResponse,
20
+ TeamObservation,
21
+ TeammateResolutionDetails,
22
+ TeammateHealth,
23
+ TeammateObservation,
24
+ } from "./types";
25
+
26
+ export * from "./types";
27
+ export { listTeamReportEvents } from "../utils/report-events";
28
+ export { peekInbox } from "../utils/messaging";
29
+ export { updateTaskGuarded } from "../utils/tasks";
30
+
31
+ const reportAppendQueues = new Map<string, Promise<unknown>>();
32
+
33
+ export async function appendTeamReportEvent(teamName: string, event: reports.NewTeamReportEvent) {
34
+ const previous = reportAppendQueues.get(teamName) || Promise.resolve();
35
+ const next = previous.catch(() => undefined).then(() => reports.appendTeamReportEvent(teamName, event));
36
+ let queued: Promise<unknown>;
37
+ queued = next.catch(() => undefined).finally(() => {
38
+ if (reportAppendQueues.get(teamName) === queued) reportAppendQueues.delete(teamName);
39
+ });
40
+ reportAppendQueues.set(teamName, queued);
41
+ return await next;
42
+ }
43
+
44
+ function readAgentIsKnownRunning(teamName: string, agentName: string, options: ObserveRuntimeOptions): boolean {
45
+ const key = options.readAgentKey?.(teamName, agentName) || `${teamName}:${agentName}`;
46
+ return !!options.runningReadAgents?.has(key);
47
+ }
48
+
49
+ function operationValue(source: { operationId?: string; workflowRunId?: string; metadata?: Record<string, any> }, key: "operationId" | "workflowRunId"): string | undefined {
50
+ return source[key] || source.metadata?.[key] || source.metadata?.orchestration?.[key];
51
+ }
52
+
53
+ function metadataForOperation(request: { operationId?: string; workflowRunId?: string; metadata?: Record<string, any> }): Record<string, any> | undefined {
54
+ const metadata = { ...(request.metadata || {}) };
55
+ if (request.operationId) metadata.operationId = request.operationId;
56
+ if (request.workflowRunId) metadata.workflowRunId = request.workflowRunId;
57
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
58
+ }
59
+
60
+ function canonicalPersistedMetadata(metadata: Record<string, any> | undefined): Record<string, any> | undefined {
61
+ if (!metadata || !Object.prototype.hasOwnProperty.call(metadata, "modelSlot")) return metadata;
62
+ return { ...metadata, modelSlot: canonicalPersistedModelSlot(metadata.modelSlot) };
63
+ }
64
+
65
+ function canonicalPersistedMember(member: Member): Member {
66
+ const projected = { ...member };
67
+ if (Object.prototype.hasOwnProperty.call(member, "modelSlot")) {
68
+ projected.modelSlot = canonicalPersistedModelSlot(member.modelSlot);
69
+ }
70
+ if (Object.prototype.hasOwnProperty.call(member, "metadata")) {
71
+ projected.metadata = canonicalPersistedMetadata(member.metadata);
72
+ }
73
+ return projected;
74
+ }
75
+
76
+ function canonicalPersistedQueuedSpawn(queued: writeQueue.QueuedWriteSpawn): writeQueue.QueuedWriteSpawn {
77
+ const projected = {
78
+ ...queued,
79
+ modelSlot: canonicalPersistedModelSlot(queued.modelSlot),
80
+ };
81
+ if (Object.prototype.hasOwnProperty.call(queued, "metadata")) {
82
+ projected.metadata = canonicalPersistedMetadata(queued.metadata);
83
+ }
84
+ return projected;
85
+ }
86
+
87
+ function memberResolutionDetails(member: Member, request: Partial<SpawnTeammateOnceRequest>, extras: Record<string, any> = {}): TeammateResolutionDetails & Record<string, any> {
88
+ const role = member.role ?? "write";
89
+ return {
90
+ agentId: member.agentId,
91
+ role,
92
+ requestedRole: request.modelSlot ? roleForFavoriteModelSlot(request.modelSlot) : role,
93
+ resolvedRole: role,
94
+ requestedModelSlot: canonicalPersistedModelSlot(request.modelSlot ?? null),
95
+ modelSlot: canonicalPersistedModelSlot(member.modelSlot ?? null),
96
+ model: member.model ?? null,
97
+ thinking: member.thinking ?? null,
98
+ ...extras,
99
+ };
100
+ }
101
+
102
+ function queuedResolutionDetails(teamName: string, queued: writeQueue.QueuedWriteSpawn, request: Partial<SpawnTeammateOnceRequest>, extras: Record<string, any> = {}): TeammateResolutionDetails & Record<string, any> {
103
+ const level = requireFavoriteModelLevel(loadSettings({ projectDir: queued.cwd }), queued.modelSlot);
104
+ return {
105
+ agentId: `${queued.name}@${teamName}`,
106
+ role: "write",
107
+ requestedRole: request.modelSlot ? roleForFavoriteModelSlot(request.modelSlot) : level.role,
108
+ resolvedRole: "write",
109
+ requestedModelSlot: canonicalPersistedModelSlot(request.modelSlot ?? null),
110
+ modelSlot: level.slot,
111
+ model: level.model,
112
+ thinking: level.thinking,
113
+ modelSource: "queued",
114
+ ...extras,
115
+ };
116
+ }
117
+
118
+ type OperationSource = { operationId?: string; workflowRunId?: string; metadata?: Record<string, any> };
119
+
120
+ interface IndexedCandidate<T extends OperationSource> {
121
+ item: T;
122
+ order: number;
123
+ operationId?: string;
124
+ workflowRunId?: string;
125
+ }
126
+
127
+ interface SpawnOnceTeamState {
128
+ membersByName: Map<string, IndexedCandidate<Member>>;
129
+ membersByOperation: Map<string, Array<IndexedCandidate<Member>>>;
130
+ queuedByName: Map<string, IndexedCandidate<writeQueue.QueuedWriteSpawn>>;
131
+ queuedByOperation: Map<string, Array<IndexedCandidate<writeQueue.QueuedWriteSpawn>>>;
132
+ nextMemberOrder: number;
133
+ nextQueuedOrder: number;
134
+ }
135
+
136
+ function makeIndexedCandidate<T extends OperationSource>(item: T, order: number, fallback?: OperationSource): IndexedCandidate<T> {
137
+ return {
138
+ item,
139
+ order,
140
+ operationId: operationValue(item, "operationId") || (fallback ? operationValue(fallback, "operationId") : undefined),
141
+ workflowRunId: operationValue(item, "workflowRunId") || (fallback ? operationValue(fallback, "workflowRunId") : undefined),
142
+ };
143
+ }
144
+
145
+ function indexOperationCandidate<T extends OperationSource>(index: Map<string, Array<IndexedCandidate<T>>>, candidate: IndexedCandidate<T>): void {
146
+ if (!candidate.operationId) return;
147
+ const existing = index.get(candidate.operationId) || [];
148
+ existing.push(candidate);
149
+ index.set(candidate.operationId, existing);
150
+ }
151
+
152
+ function registerMemberCandidate(state: SpawnOnceTeamState, member: Member, order: number, fallback?: OperationSource): void {
153
+ if (member.agentType !== "teammate") return;
154
+ const candidate = makeIndexedCandidate(member, order, fallback);
155
+ if (!state.membersByName.has(member.name)) state.membersByName.set(member.name, candidate);
156
+ indexOperationCandidate(state.membersByOperation, candidate);
157
+ }
158
+
159
+ function registerQueuedCandidate(state: SpawnOnceTeamState, queued: writeQueue.QueuedWriteSpawn, order: number, fallback?: OperationSource): void {
160
+ const candidate = makeIndexedCandidate(queued, order, fallback);
161
+ if (!state.queuedByName.has(queued.name)) state.queuedByName.set(queued.name, candidate);
162
+ indexOperationCandidate(state.queuedByOperation, candidate);
163
+ }
164
+
165
+ function findOperationCandidate<T extends OperationSource>(
166
+ index: Map<string, Array<IndexedCandidate<T>>>,
167
+ request: OperationSource
168
+ ): IndexedCandidate<T> | undefined {
169
+ const operationId = operationValue(request, "operationId");
170
+ if (!operationId) return undefined;
171
+ const workflowRunId = operationValue(request, "workflowRunId");
172
+ return index.get(operationId)?.find((candidate) => workflowRunId === undefined || candidate.workflowRunId === workflowRunId);
173
+ }
174
+
175
+ function earlierCandidate<T extends OperationSource>(left?: IndexedCandidate<T>, right?: IndexedCandidate<T>): IndexedCandidate<T> | undefined {
176
+ if (!left) return right;
177
+ if (!right) return left;
178
+ return left.order <= right.order ? left : right;
179
+ }
180
+
181
+ function findExistingMemberCandidate(state: SpawnOnceTeamState, request: SpawnTeammateOnceRequest): IndexedCandidate<Member> | undefined {
182
+ return earlierCandidate(
183
+ state.membersByName.get(request.name),
184
+ findOperationCandidate(state.membersByOperation, request)
185
+ );
186
+ }
187
+
188
+ function findQueuedCandidate(state: SpawnOnceTeamState, request: SpawnTeammateOnceRequest): IndexedCandidate<writeQueue.QueuedWriteSpawn> | undefined {
189
+ return earlierCandidate(
190
+ state.queuedByName.get(request.name),
191
+ findOperationCandidate(state.queuedByOperation, request)
192
+ );
193
+ }
194
+
195
+ function buildSpawnOnceTeamState(config: { members: Member[] }, queue: writeQueue.QueuedWriteSpawn[]): SpawnOnceTeamState {
196
+ const state: SpawnOnceTeamState = {
197
+ membersByName: new Map(),
198
+ membersByOperation: new Map(),
199
+ queuedByName: new Map(),
200
+ queuedByOperation: new Map(),
201
+ nextMemberOrder: config.members.length,
202
+ nextQueuedOrder: queue.length,
203
+ };
204
+
205
+ config.members.forEach((member, order) => registerMemberCandidate(state, member, order));
206
+ queue.forEach((queued, order) => registerQueuedCandidate(state, queued, order));
207
+ return state;
208
+ }
209
+
210
+ async function loadSpawnOnceTeamState(teamName: string): Promise<SpawnOnceTeamState> {
211
+ const config = await teams.readConfig(teamName);
212
+ const queue = await writeQueue.listWriteQueue(teamName).catch(() => []);
213
+ return buildSpawnOnceTeamState(config, queue);
214
+ }
215
+
216
+ function notStartedSpawnResponse(request: SpawnTeammateOnceRequest): SpawnTeammateOnceResponse {
217
+ const level = requireFavoriteModelLevel(loadSettings({ projectDir: request.cwd }), request.modelSlot);
218
+ return {
219
+ status: "not_started",
220
+ details: {
221
+ reason: "No spawn start callback supplied",
222
+ requestedRole: level.role,
223
+ requestedModelSlot: level.slot,
224
+ modelSlot: level.slot,
225
+ model: level.model,
226
+ thinking: level.thinking,
227
+ },
228
+ };
229
+ }
230
+
231
+ function responseFromStartedSpawn(
232
+ teamName: string,
233
+ request: SpawnTeammateOnceRequest,
234
+ started: Awaited<ReturnType<NonNullable<SpawnTeammateOnceOptions["start"]>>>
235
+ ): SpawnTeammateOnceResponse {
236
+ const baseDetails = started.member
237
+ ? memberResolutionDetails(started.member, request)
238
+ : started.queued
239
+ ? queuedResolutionDetails(teamName, started.queued, request)
240
+ : {};
241
+ return {
242
+ status: started.queued ? "queued" : "started",
243
+ member: started.member ? canonicalPersistedMember(started.member) : undefined,
244
+ queued: started.queued ? canonicalPersistedQueuedSpawn(started.queued) : undefined,
245
+ details: { ...baseDetails, ...started.details },
246
+ };
247
+ }
248
+
249
+ async function startSpawnTeammateOnce(
250
+ state: SpawnOnceTeamState,
251
+ request: SpawnTeammateOnceRequest,
252
+ options: SpawnTeammateOnceOptions
253
+ ): Promise<SpawnTeammateOnceResponse> {
254
+ if (!options.start) return notStartedSpawnResponse(request);
255
+
256
+ const started = await options.start({ ...request, metadata: metadataForOperation(request) });
257
+ if (started.member) registerMemberCandidate(state, started.member, state.nextMemberOrder++, request);
258
+ if (started.queued) registerQueuedCandidate(state, started.queued, state.nextQueuedOrder++, request);
259
+ return responseFromStartedSpawn(request.teamName, request, started);
260
+ }
261
+
262
+ async function observeKnownTeammate(
263
+ teamName: string,
264
+ member: Member,
265
+ options: ObserveRuntimeOptions = {}
266
+ ): Promise<TeammateObservation> {
267
+ const role = member.role ?? (member.name === "team-lead" ? "lead" : "write");
268
+ const unreadCount = (await messaging.peekInbox(teamName, member.name, true).catch(() => [])).length;
269
+ const runtimeStatus = member.name === "team-lead" ? null : await runtime.readRuntimeStatus(teamName, member.name).catch(() => null);
270
+ const now = options.now ?? Date.now();
271
+ const hasRecentHeartbeat = !!runtimeStatus?.lastHeartbeatAt && (now - runtimeStatus.lastHeartbeatAt) <= runtime.HEARTBEAT_STALE_MS;
272
+
273
+ let alive: boolean | null;
274
+ if (member.name === "team-lead") {
275
+ alive = true;
276
+ } else if (role === "read") {
277
+ alive = readAgentIsKnownRunning(teamName, member.name, options) || (!!runtimeStatus && hasRecentHeartbeat && member.isActive !== false);
278
+ } else if (member.tmuxPaneId && options.terminal?.isAlive) {
279
+ alive = options.terminal.isAlive(member.tmuxPaneId);
280
+ } else {
281
+ alive = null;
282
+ }
283
+
284
+ const startupStalled = alive === true && unreadCount > 0 && (now - member.joinedAt) > runtime.STARTUP_STALL_MS && !(runtimeStatus?.ready);
285
+ let health: TeammateHealth;
286
+ if (member.name === "team-lead") health = "lead";
287
+ else if (alive === null) health = "unknown";
288
+ else if (!alive) health = "dead";
289
+ else if (startupStalled) health = "stalled";
290
+ else if (runtimeStatus?.ready) health = hasRecentHeartbeat ? "healthy" : "idle";
291
+ else health = "starting";
292
+
293
+ return {
294
+ teamName,
295
+ agentName: member.name,
296
+ member,
297
+ role,
298
+ alive,
299
+ health,
300
+ unreadCount,
301
+ agentLoopReady: !!runtimeStatus?.ready,
302
+ hasRecentHeartbeat,
303
+ startupStalled,
304
+ runtime: runtimeStatus,
305
+ };
306
+ }
307
+
308
+ export async function observeTeammate(
309
+ teamName: string,
310
+ agentName: string,
311
+ options: ObserveRuntimeOptions = {}
312
+ ): Promise<TeammateObservation> {
313
+ const config = await teams.readConfig(teamName);
314
+ const member = config.members.find((item) => item.name === agentName);
315
+ if (!member) throw new Error(`Teammate ${agentName} not found`);
316
+ return observeKnownTeammate(teamName, member, options);
317
+ }
318
+
319
+ export async function observeTeam(
320
+ teamName: string,
321
+ options: ObserveRuntimeOptions = {}
322
+ ): Promise<TeamObservation> {
323
+ const config = await teams.readConfig(teamName);
324
+ const [taskList, currentClaims, queue, reportEvents] = await Promise.all([
325
+ tasks.listTasks(teamName).catch(() => []),
326
+ claims.listClaims(teamName).catch(() => []),
327
+ writeQueue.listWriteQueue(teamName).catch(() => []),
328
+ reports.listTeamReportEvents(teamName, { limit: options.reportLimit }).catch(() => []),
329
+ ]);
330
+ const members = await Promise.all(config.members.map((member) => observeKnownTeammate(teamName, member, options)));
331
+
332
+ return {
333
+ teamName,
334
+ config,
335
+ members,
336
+ tasks: taskList,
337
+ claims: currentClaims,
338
+ writeQueue: queue,
339
+ reports: reportEvents,
340
+ };
341
+ }
342
+
343
+ export async function ensureTeam(request: EnsureTeamRequest): Promise<EnsureTeamResponse> {
344
+ const level = requireFavoriteModelLevel(loadSettings(), request.defaultModelSlot || "read-review");
345
+ const result = await teams.ensureTeam({
346
+ name: request.teamName,
347
+ sessionId: request.sessionId,
348
+ leadAgentId: request.leadAgentId,
349
+ description: request.description,
350
+ defaultModel: level.model,
351
+ separateWindows: request.separateWindows,
352
+ metadata: metadataForOperation(request),
353
+ });
354
+ return { config: result.config, created: result.created };
355
+ }
356
+
357
+ export async function spawnTeammateOnce(
358
+ request: SpawnTeammateOnceRequest,
359
+ options: SpawnTeammateOnceOptions = {}
360
+ ): Promise<SpawnTeammateOnceResponse> {
361
+ const [result] = await spawnTeammatesOnce([request], options);
362
+ return result;
363
+ }
364
+
365
+ export async function spawnTeammatesOnce(
366
+ requests: SpawnTeammateOnceRequest[],
367
+ options: SpawnTeammateOnceOptions = {}
368
+ ): Promise<SpawnTeammatesOnceResponse> {
369
+ if (requests.length === 0) return [];
370
+
371
+ const stateByTeam = new Map<string, SpawnOnceTeamState>();
372
+ const uniqueTeamNames = [...new Set(requests.map((request) => request.teamName))];
373
+ await Promise.all(uniqueTeamNames.map(async (teamName) => {
374
+ stateByTeam.set(teamName, await loadSpawnOnceTeamState(teamName));
375
+ }));
376
+
377
+ const results: SpawnTeammateOnceResponse[] = [];
378
+ for (const request of requests) {
379
+ const state = stateByTeam.get(request.teamName);
380
+ if (!state) throw new Error(`Team ${request.teamName} not found`);
381
+
382
+ const existing = findExistingMemberCandidate(state, request)?.item;
383
+ if (existing) {
384
+ results.push({
385
+ status: "existing",
386
+ member: canonicalPersistedMember(existing),
387
+ details: memberResolutionDetails(existing, request, { existing: true, idempotent: true, queued: false, modelSource: "existing" }),
388
+ });
389
+ continue;
390
+ }
391
+
392
+ const queued = findQueuedCandidate(state, request)?.item;
393
+ if (queued) {
394
+ results.push({
395
+ status: "queued",
396
+ queued: canonicalPersistedQueuedSpawn(queued),
397
+ details: queuedResolutionDetails(request.teamName, queued, request, { existing: true, idempotent: true, queued: true }),
398
+ });
399
+ continue;
400
+ }
401
+
402
+ results.push(await startSpawnTeammateOnce(state, request, options));
403
+ }
404
+
405
+ return results;
406
+ }
407
+
408
+ export async function sendMessageOnce(request: SendMessageOnceRequest) {
409
+ return await messaging.sendPlainMessageOnceIfRunning(
410
+ request.teamName,
411
+ request.fromName,
412
+ request.toName,
413
+ request.text,
414
+ request.summary,
415
+ {
416
+ color: request.color,
417
+ operationId: request.operationId,
418
+ workflowRunId: request.workflowRunId,
419
+ metadata: metadataForOperation(request),
420
+ }
421
+ );
422
+ }
423
+
424
+ export async function broadcastMessageOnce(request: BroadcastMessageOnceRequest) {
425
+ return await messaging.broadcastMessageOnce(
426
+ request.teamName,
427
+ request.fromName,
428
+ request.text,
429
+ request.summary,
430
+ {
431
+ color: request.color,
432
+ operationId: request.operationId,
433
+ workflowRunId: request.workflowRunId,
434
+ metadata: metadataForOperation(request),
435
+ }
436
+ );
437
+ }