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,441 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { writeJsonAtomic } from "./atomic-json";
5
+ import { InboxMessage, TeamConfig } from "./models";
6
+ import { withLock } from "./lock";
7
+ import { configPath, inboxPath } from "./paths";
8
+ import { readConfig } from "./teams";
9
+ import { assertLifecycleTombstoneAbsent, withLifecycleTombstoneLock } from "./lifecycle-tombstone";
10
+
11
+ export interface MessageMetadataOptions {
12
+ id?: string;
13
+ operationId?: string;
14
+ workflowRunId?: string;
15
+ metadata?: Record<string, any>;
16
+ /** Admission fence for startup messages that belong to one lifecycle run. */
17
+ expectedRecipientRunId?: string;
18
+ }
19
+
20
+ export interface SendPlainMessageOptions extends MessageMetadataOptions {
21
+ color?: string;
22
+ }
23
+
24
+ export interface SendPlainMessageOnceResult {
25
+ message: InboxMessage;
26
+ delivered: boolean;
27
+ }
28
+
29
+ export interface ReadInboxTailOptions {
30
+ unreadOnly?: boolean;
31
+ markAsRead?: boolean;
32
+ }
33
+
34
+ export function nowIso(): string {
35
+ return new Date().toISOString();
36
+ }
37
+
38
+ interface AppendRunningMessageOptions {
39
+ operationId?: string;
40
+ workflowRunId?: string;
41
+ expectedRecipientRunId?: string;
42
+ }
43
+
44
+ async function appendRunningMessage(
45
+ teamName: string,
46
+ agentName: string,
47
+ message: InboxMessage,
48
+ options: AppendRunningMessageOptions = {}
49
+ ): Promise<SendPlainMessageOnceResult> {
50
+ const appendUnderInboxLock = async (): Promise<SendPlainMessageOnceResult> => {
51
+ const p = ensureInboxFile(teamName, agentName);
52
+ return withLock(p, async () => {
53
+ const messages = readInboxRaw(p);
54
+ if (options.operationId) {
55
+ const existing = messages.find(item => messageOperationMatches(item, options.operationId!, options.workflowRunId));
56
+ if (existing) return { message: existing, delivered: false };
57
+ }
58
+ messages.push(message);
59
+ writeJsonAtomic(p, messages);
60
+ return { message, delivered: true };
61
+ });
62
+ };
63
+
64
+ // The lead has always been an out-of-band recipient: final reports must remain
65
+ // deliverable after the sender is fenced and the lead roster entry has no run id.
66
+ if (agentName === "team-lead") return appendUnderInboxLock();
67
+
68
+ const teamConfigPath = configPath(teamName);
69
+ return withLifecycleTombstoneLock(teamName, agentName, async lifecycleLock => {
70
+ assertLifecycleTombstoneAbsent(teamName, agentName, lifecycleLock.read());
71
+ return withLock(teamConfigPath, async () => {
72
+ if (!fs.existsSync(teamConfigPath)) {
73
+ throw new Error(`Cannot send message to ${agentName}: agent is not running.`);
74
+ }
75
+ const config = JSON.parse(fs.readFileSync(teamConfigPath, "utf-8")) as TeamConfig;
76
+ const recipient = Array.isArray(config.members)
77
+ ? config.members.find(member => member.name === agentName)
78
+ : undefined;
79
+ if (
80
+ !recipient
81
+ || recipient.isActive === false
82
+ || (options.expectedRecipientRunId !== undefined && recipient.lifecycleRunId !== options.expectedRecipientRunId)
83
+ ) {
84
+ throw new Error(`Cannot send message to ${agentName}: agent is not running.`);
85
+ }
86
+ return appendUnderInboxLock();
87
+ });
88
+ });
89
+ }
90
+
91
+ export async function requireRunningMessageRecipient(teamName: string, recipient: string): Promise<void> {
92
+ if (recipient === "team-lead") return;
93
+ const teamConfigPath = configPath(teamName);
94
+ await withLifecycleTombstoneLock(teamName, recipient, async lifecycleLock => {
95
+ assertLifecycleTombstoneAbsent(teamName, recipient, lifecycleLock.read());
96
+ await withLock(teamConfigPath, async () => {
97
+ if (!fs.existsSync(teamConfigPath)) throw new Error(`Cannot send message to ${recipient}: agent is not running.`);
98
+ const config = JSON.parse(fs.readFileSync(teamConfigPath, "utf-8")) as TeamConfig;
99
+ const recipientMember = config.members.find(member => member.name === recipient);
100
+ if (!recipientMember || recipientMember.isActive === false) {
101
+ throw new Error(`Cannot send message to ${recipient}: agent is not running.`);
102
+ }
103
+ });
104
+ });
105
+ }
106
+
107
+ export async function sendPlainMessageIfRunning(
108
+ teamName: string,
109
+ fromName: string,
110
+ toName: string,
111
+ text: string,
112
+ summary: string,
113
+ color?: string,
114
+ options: MessageMetadataOptions = {}
115
+ ): Promise<void> {
116
+ const message = buildInboxMessage(fromName, text, summary, { ...options, color });
117
+ await appendRunningMessage(teamName, toName, message, {
118
+ expectedRecipientRunId: options.expectedRecipientRunId,
119
+ });
120
+ }
121
+
122
+ function ensureInboxFile(teamName: string, agentName: string): string {
123
+ const p = inboxPath(teamName, agentName);
124
+ const dir = path.dirname(p);
125
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
126
+ return p;
127
+ }
128
+
129
+ function readInboxRaw(p: string): InboxMessage[] {
130
+ if (!fs.existsSync(p)) return [];
131
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
132
+ return Array.isArray(parsed) ? parsed : [];
133
+ }
134
+
135
+ export function messageOperationMatches(message: InboxMessage, operationId: string, workflowRunId?: string): boolean {
136
+ const messageOperationId = message.operationId || message.metadata?.operationId;
137
+ const messageWorkflowRunId = message.workflowRunId || message.metadata?.workflowRunId;
138
+ return messageOperationId === operationId && (workflowRunId === undefined || messageWorkflowRunId === workflowRunId);
139
+ }
140
+
141
+ function cloneInboxMessage(message: InboxMessage): InboxMessage {
142
+ return {
143
+ ...message,
144
+ metadata: message.metadata ? { ...message.metadata } : undefined,
145
+ };
146
+ }
147
+
148
+ function cloneInboxMessages(messages: InboxMessage[]): InboxMessage[] {
149
+ return messages.map(cloneInboxMessage);
150
+ }
151
+
152
+ function normalizeInboxLimit(limit: number): number {
153
+ if (!Number.isInteger(limit) || limit < 0) {
154
+ throw new Error("Inbox limit must be a non-negative integer");
155
+ }
156
+ return limit;
157
+ }
158
+
159
+ function selectInboxMessages(allMsgs: InboxMessage[], unreadOnly: boolean, limit?: number): InboxMessage[] {
160
+ const matches = (message: InboxMessage) => !unreadOnly || !message.read;
161
+
162
+ if (limit === undefined) {
163
+ return unreadOnly ? allMsgs.filter(matches) : allMsgs;
164
+ }
165
+
166
+ const normalizedLimit = normalizeInboxLimit(limit);
167
+ if (normalizedLimit === 0) return [];
168
+
169
+ const result: InboxMessage[] = [];
170
+ for (let index = allMsgs.length - 1; index >= 0 && result.length < normalizedLimit; index--) {
171
+ const message = allMsgs[index];
172
+ if (matches(message)) result.push(message);
173
+ }
174
+
175
+ return result.reverse();
176
+ }
177
+
178
+ function markMessagesRead(messages: InboxMessage[]): boolean {
179
+ let changed = false;
180
+ for (const message of messages) {
181
+ if (!message.read) {
182
+ message.read = true;
183
+ changed = true;
184
+ }
185
+ }
186
+ return changed;
187
+ }
188
+
189
+ export async function appendMessage(teamName: string, agentName: string, message: InboxMessage) {
190
+ const p = ensureInboxFile(teamName, agentName);
191
+
192
+ await withLock(p, async () => {
193
+ const msgs = readInboxRaw(p);
194
+ msgs.push(message);
195
+ writeJsonAtomic(p, msgs);
196
+ });
197
+ }
198
+
199
+ export async function appendMessageOnce(
200
+ teamName: string,
201
+ agentName: string,
202
+ message: InboxMessage & { operationId: string }
203
+ ): Promise<SendPlainMessageOnceResult> {
204
+ const p = ensureInboxFile(teamName, agentName);
205
+
206
+ return await withLock(p, async () => {
207
+ const msgs = readInboxRaw(p);
208
+ const existing = msgs.find((item) => messageOperationMatches(item, message.operationId, message.workflowRunId));
209
+ if (existing) return { message: existing, delivered: false };
210
+
211
+ msgs.push(message);
212
+ writeJsonAtomic(p, msgs);
213
+ return { message, delivered: true };
214
+ });
215
+ }
216
+
217
+ export async function appendMessageOnceIfRunning(
218
+ teamName: string,
219
+ agentName: string,
220
+ message: InboxMessage & { operationId: string },
221
+ options: Pick<MessageMetadataOptions, "expectedRecipientRunId"> = {}
222
+ ): Promise<SendPlainMessageOnceResult> {
223
+ return appendRunningMessage(teamName, agentName, message, {
224
+ operationId: message.operationId,
225
+ workflowRunId: message.workflowRunId,
226
+ expectedRecipientRunId: options.expectedRecipientRunId,
227
+ });
228
+ }
229
+
230
+ export async function readInbox(
231
+ teamName: string,
232
+ agentName: string,
233
+ unreadOnly = false,
234
+ markAsRead = true
235
+ ): Promise<InboxMessage[]> {
236
+ const p = inboxPath(teamName, agentName);
237
+ if (!fs.existsSync(p)) return [];
238
+
239
+ return await withLock(p, async () => {
240
+ const allMsgs = readInboxRaw(p);
241
+ const result = selectInboxMessages(allMsgs, unreadOnly);
242
+
243
+ if (markAsRead && result.length > 0 && markMessagesRead(result)) {
244
+ writeJsonAtomic(p, allMsgs);
245
+ }
246
+
247
+ return cloneInboxMessages(result);
248
+ });
249
+ }
250
+
251
+ export async function readInboxTail(
252
+ teamName: string,
253
+ agentName: string,
254
+ limit: number,
255
+ options: ReadInboxTailOptions = {}
256
+ ): Promise<InboxMessage[]> {
257
+ const normalizedLimit = normalizeInboxLimit(limit);
258
+ const p = inboxPath(teamName, agentName);
259
+ if (!fs.existsSync(p)) return [];
260
+
261
+ return await withLock(p, async () => {
262
+ const allMsgs = readInboxRaw(p);
263
+ const result = selectInboxMessages(allMsgs, options.unreadOnly === true, normalizedLimit);
264
+
265
+ if (options.markAsRead === true && result.length > 0 && markMessagesRead(result)) {
266
+ writeJsonAtomic(p, allMsgs);
267
+ }
268
+
269
+ return cloneInboxMessages(result);
270
+ });
271
+ }
272
+
273
+ export async function removeInboxMessagesByOperationUnderLifecycleLock(
274
+ teamName: string,
275
+ agentName: string,
276
+ operationId: string,
277
+ workflowRunId?: string
278
+ ): Promise<number> {
279
+ const p = inboxPath(teamName, agentName);
280
+ if (!fs.existsSync(p)) return 0;
281
+
282
+ return withLock(p, async () => {
283
+ if (!fs.existsSync(p)) return 0;
284
+ const messages = readInboxRaw(p);
285
+ const remaining = messages.filter((message) => !messageOperationMatches(message, operationId, workflowRunId));
286
+ const removed = messages.length - remaining.length;
287
+ if (removed > 0) writeJsonAtomic(p, remaining);
288
+ return removed;
289
+ });
290
+ }
291
+
292
+ export async function findInboxMessageByOperation(
293
+ teamName: string,
294
+ agentName: string,
295
+ operationId: string,
296
+ workflowRunId?: string
297
+ ): Promise<InboxMessage | undefined> {
298
+ const p = inboxPath(teamName, agentName);
299
+ if (!fs.existsSync(p)) return undefined;
300
+
301
+ return await withLock(p, async () => {
302
+ const message = readInboxRaw(p).find((item) => messageOperationMatches(item, operationId, workflowRunId));
303
+ return message ? cloneInboxMessage(message) : undefined;
304
+ });
305
+ }
306
+
307
+ export function buildInboxMessage(
308
+ fromName: string,
309
+ text: string,
310
+ summary: string,
311
+ options: SendPlainMessageOptions = {}
312
+ ): InboxMessage {
313
+ return {
314
+ id: options.id || crypto.randomUUID(),
315
+ from: fromName,
316
+ text,
317
+ timestamp: nowIso(),
318
+ read: false,
319
+ summary,
320
+ color: options.color,
321
+ operationId: options.operationId,
322
+ workflowRunId: options.workflowRunId,
323
+ metadata: options.metadata,
324
+ };
325
+ }
326
+
327
+ export async function sendPlainMessage(
328
+ teamName: string,
329
+ fromName: string,
330
+ toName: string,
331
+ text: string,
332
+ summary: string,
333
+ color?: string,
334
+ options: MessageMetadataOptions = {}
335
+ ) {
336
+ const msg = buildInboxMessage(fromName, text, summary, { ...options, color });
337
+ await appendMessage(teamName, toName, msg);
338
+ }
339
+
340
+ export async function sendPlainMessageOnce(
341
+ teamName: string,
342
+ fromName: string,
343
+ toName: string,
344
+ text: string,
345
+ summary: string,
346
+ options: SendPlainMessageOptions & { operationId: string }
347
+ ): Promise<SendPlainMessageOnceResult> {
348
+ const msg = buildInboxMessage(fromName, text, summary, options) as InboxMessage & { operationId: string };
349
+ return await appendMessageOnce(teamName, toName, msg);
350
+ }
351
+
352
+ export async function sendPlainMessageOnceIfRunning(
353
+ teamName: string,
354
+ fromName: string,
355
+ toName: string,
356
+ text: string,
357
+ summary: string,
358
+ options: SendPlainMessageOptions & { operationId: string }
359
+ ): Promise<SendPlainMessageOnceResult> {
360
+ const msg = buildInboxMessage(fromName, text, summary, options) as InboxMessage & { operationId: string };
361
+ return appendMessageOnceIfRunning(teamName, toName, msg, {
362
+ expectedRecipientRunId: options.expectedRecipientRunId,
363
+ });
364
+ }
365
+
366
+ export async function peekInbox(
367
+ teamName: string,
368
+ agentName: string,
369
+ unreadOnly = false
370
+ ): Promise<InboxMessage[]> {
371
+ return await readInbox(teamName, agentName, unreadOnly, false);
372
+ }
373
+
374
+ /**
375
+ * Broadcasts a message to all team members except the sender.
376
+ * @param teamName The name of the team
377
+ * @param fromName The name of the sender
378
+ * @param text The message text
379
+ * @param summary A short summary of the message
380
+ * @param color An optional color for the message
381
+ */
382
+ export async function broadcastMessage(
383
+ teamName: string,
384
+ fromName: string,
385
+ text: string,
386
+ summary: string,
387
+ color?: string,
388
+ options: MessageMetadataOptions = {}
389
+ ) {
390
+ const config = await readConfig(teamName);
391
+
392
+ // Create an array of delivery promises for all members except the sender
393
+ const deliveryPromises = config.members
394
+ .filter((member) => member.name !== fromName)
395
+ .map((member) => sendPlainMessageIfRunning(teamName, fromName, member.name, text, summary, color, options));
396
+
397
+ // Execute deliveries in parallel and wait for all to settle
398
+ const results = await Promise.allSettled(deliveryPromises);
399
+
400
+ // Log failures for diagnostics
401
+ const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
402
+ if (failures.length > 0) {
403
+ console.error(`Broadcast partially failed: ${failures.length} messages could not be delivered.`);
404
+ // Optionally log individual errors
405
+ failures.forEach((f) => console.error(`- Delivery error:`, f.reason));
406
+ }
407
+ }
408
+
409
+ export interface BroadcastMessageOnceRecipientResult {
410
+ recipient: string;
411
+ delivered: boolean;
412
+ message?: InboxMessage;
413
+ error?: string;
414
+ }
415
+
416
+ export async function broadcastMessageOnce(
417
+ teamName: string,
418
+ fromName: string,
419
+ text: string,
420
+ summary: string,
421
+ options: SendPlainMessageOptions & { operationId: string }
422
+ ): Promise<BroadcastMessageOnceRecipientResult[]> {
423
+ const config = await readConfig(teamName);
424
+ const recipients = config.members
425
+ .filter((member) => member.name !== fromName)
426
+ .map((member) => member.name);
427
+ // Report every recipient; operation IDs make retries idempotent.
428
+ const results = await Promise.allSettled(
429
+ recipients.map((recipient) => sendPlainMessageOnceIfRunning(teamName, fromName, recipient, text, summary, options))
430
+ );
431
+
432
+ return results.map((result, index) => (
433
+ result.status === "fulfilled"
434
+ ? { recipient: recipients[index], ...result.value }
435
+ : {
436
+ recipient: recipients[index],
437
+ delivered: false,
438
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
439
+ }
440
+ ));
441
+ }
@@ -0,0 +1,231 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ buildPreferredModelsFromSettings,
7
+ isKnownQualifiedModel,
8
+ isQualifiedModel,
9
+ listPreferredQualifiedModels,
10
+ loadModelResolutionConfig,
11
+ loadPiModelSettings,
12
+ normalizeQualifiedModel,
13
+ parseQualifiedModel,
14
+ sortAvailableModels,
15
+ stripThinkingSuffix,
16
+ type AvailableModel,
17
+ } from "./model-resolution";
18
+
19
+ const availableModels: AvailableModel[] = [
20
+ { provider: "openrouter", model: "gpt-5" },
21
+ { provider: "openai-codex", model: "gpt-5.4" },
22
+ { provider: "claude-agent-sdk", model: "claude-sonnet-4-6" },
23
+ { provider: "claude-agent-sdk", model: "claude-opus-4-7" },
24
+ { provider: "kimi-coding", model: "kimi-for-coding" },
25
+ ];
26
+
27
+ describe("qualified model helpers", () => {
28
+ it("parses qualified models", () => {
29
+ expect(parseQualifiedModel("openai-codex/gpt-5.4")).toEqual({
30
+ provider: "openai-codex",
31
+ model: "gpt-5.4",
32
+ });
33
+ });
34
+
35
+ it("strips thinking suffixes", () => {
36
+ expect(stripThinkingSuffix("openai-codex/gpt-5.4:high")).toBe("openai-codex/gpt-5.4");
37
+ expect(stripThinkingSuffix("openai-codex/gpt-5.4:max")).toBe("openai-codex/gpt-5.4");
38
+ });
39
+
40
+ it("detects qualified models", () => {
41
+ expect(isQualifiedModel("openai-codex/gpt-5.4")).toBe(true);
42
+ expect(isQualifiedModel("gpt-5")).toBe(false);
43
+ });
44
+
45
+ it("normalizes qualified models", () => {
46
+ expect(normalizeQualifiedModel("openai-codex/gpt-5.4:high")).toBe("openai-codex/gpt-5.4");
47
+ expect(normalizeQualifiedModel("gpt-5")).toBeNull();
48
+ });
49
+
50
+ it("checks known qualified models", () => {
51
+ expect(isKnownQualifiedModel("openai-codex/gpt-5.4", availableModels)).toBe(true);
52
+ expect(isKnownQualifiedModel("openrouter/gpt-4o", availableModels)).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("loadModelResolutionConfig", () => {
57
+ const testRoot = path.join(os.tmpdir(), `pi-extended-teams-model-config-${Date.now()}`);
58
+ const homeDir = path.join(testRoot, "home");
59
+ const projectDir = path.join(testRoot, "project");
60
+
61
+ beforeEach(() => {
62
+ fs.rmSync(testRoot, { recursive: true, force: true });
63
+ fs.mkdirSync(path.join(homeDir, ".pi"), { recursive: true });
64
+ fs.mkdirSync(path.join(projectDir, ".pi"), { recursive: true });
65
+ });
66
+
67
+ afterEach(() => {
68
+ fs.rmSync(testRoot, { recursive: true, force: true });
69
+ });
70
+
71
+ it("loads empty defaults when no config files exist", () => {
72
+ fs.rmSync(path.join(homeDir, ".pi"), { recursive: true, force: true });
73
+ fs.rmSync(path.join(projectDir, ".pi"), { recursive: true, force: true });
74
+
75
+ const config = loadModelResolutionConfig({ homeDir, projectDir });
76
+
77
+ expect(config.providerPriority).toEqual([]);
78
+ });
79
+
80
+ it("loads global config", () => {
81
+ fs.writeFileSync(
82
+ path.join(homeDir, ".pi", "pi-extended-teams.json"),
83
+ JSON.stringify({ providerPriority: ["openai-codex", "claude-agent-sdk"] }, null, 2)
84
+ );
85
+
86
+ const config = loadModelResolutionConfig({ homeDir, projectDir });
87
+
88
+ expect(config.providerPriority).toEqual(["openai-codex", "claude-agent-sdk"]);
89
+ });
90
+
91
+ it("lets project config override global config", () => {
92
+ fs.writeFileSync(
93
+ path.join(homeDir, ".pi", "pi-extended-teams.json"),
94
+ JSON.stringify({ providerPriority: ["claude-agent-sdk"] }, null, 2)
95
+ );
96
+ fs.writeFileSync(
97
+ path.join(projectDir, ".pi", "pi-extended-teams.json"),
98
+ JSON.stringify({ providerPriority: ["openai-codex"] }, null, 2)
99
+ );
100
+
101
+ const config = loadModelResolutionConfig({ homeDir, projectDir });
102
+
103
+ expect(config.providerPriority).toEqual(["openai-codex"]);
104
+ });
105
+
106
+ it("keeps legacy pi-teams config as a lower-priority compatibility path", () => {
107
+ fs.writeFileSync(
108
+ path.join(homeDir, ".pi", "pi-teams.json"),
109
+ JSON.stringify({ providerPriority: ["claude-agent-sdk"] }, null, 2)
110
+ );
111
+ fs.writeFileSync(
112
+ path.join(homeDir, ".pi", "pi-extended-teams.json"),
113
+ JSON.stringify({ providerPriority: ["openai-codex"] }, null, 2)
114
+ );
115
+
116
+ const config = loadModelResolutionConfig({ homeDir, projectDir });
117
+
118
+ expect(config.providerPriority).toEqual(["openai-codex"]);
119
+ });
120
+ });
121
+
122
+ describe("loadPiModelSettings", () => {
123
+ const testRoot = path.join(os.tmpdir(), `pi-extended-teams-pi-settings-${Date.now()}`);
124
+ const homeDir = path.join(testRoot, "home");
125
+ const projectDir = path.join(testRoot, "project");
126
+
127
+ beforeEach(() => {
128
+ fs.rmSync(testRoot, { recursive: true, force: true });
129
+ fs.mkdirSync(path.join(homeDir, ".pi", "agent"), { recursive: true });
130
+ fs.mkdirSync(path.join(projectDir, ".pi"), { recursive: true });
131
+ });
132
+
133
+ afterEach(() => {
134
+ fs.rmSync(testRoot, { recursive: true, force: true });
135
+ });
136
+
137
+ it("loads global pi settings", () => {
138
+ fs.writeFileSync(
139
+ path.join(homeDir, ".pi", "agent", "settings.json"),
140
+ JSON.stringify({
141
+ defaultProvider: "openai-codex",
142
+ defaultModel: "gpt-5.4",
143
+ enabledModels: ["claude-*"],
144
+ }, null, 2)
145
+ );
146
+
147
+ const settings = loadPiModelSettings({ homeDir, projectDir });
148
+
149
+ expect(settings.defaultProvider).toBe("openai-codex");
150
+ expect(settings.defaultModel).toBe("gpt-5.4");
151
+ expect(settings.enabledModels).toEqual(["claude-*"]);
152
+ });
153
+
154
+ it("lets project pi settings override global settings", () => {
155
+ fs.writeFileSync(
156
+ path.join(homeDir, ".pi", "agent", "settings.json"),
157
+ JSON.stringify({ defaultProvider: "claude-agent-sdk", defaultModel: "claude-opus-4-7" }, null, 2)
158
+ );
159
+ fs.writeFileSync(
160
+ path.join(projectDir, ".pi", "settings.json"),
161
+ JSON.stringify({ defaultProvider: "openai-codex", defaultModel: "gpt-5.4" }, null, 2)
162
+ );
163
+
164
+ const settings = loadPiModelSettings({ homeDir, projectDir });
165
+
166
+ expect(settings.defaultProvider).toBe("openai-codex");
167
+ expect(settings.defaultModel).toBe("gpt-5.4");
168
+ });
169
+ });
170
+
171
+ describe("preferred model selection", () => {
172
+ it("builds preferred models from explicit preferences and pi settings", () => {
173
+ const preferred = buildPreferredModelsFromSettings(
174
+ availableModels,
175
+ {
176
+ defaultProvider: "openai-codex",
177
+ defaultModel: "gpt-5.4",
178
+ enabledModels: ["claude-*", "kimi-for-coding"],
179
+ },
180
+ ["claude-agent-sdk/claude-opus-4-7"]
181
+ );
182
+
183
+ expect(preferred).toEqual([
184
+ "claude-agent-sdk/claude-opus-4-7",
185
+ "openai-codex/gpt-5.4",
186
+ "claude-agent-sdk/claude-sonnet-4-6",
187
+ "kimi-coding/kimi-for-coding",
188
+ ]);
189
+ });
190
+
191
+ it("lists preferred qualified models from on-disk settings", () => {
192
+ const testRoot = path.join(os.tmpdir(), `pi-extended-teams-preferred-${Date.now()}`);
193
+ const homeDir = path.join(testRoot, "home");
194
+
195
+ fs.mkdirSync(path.join(homeDir, ".pi", "agent"), { recursive: true });
196
+ fs.writeFileSync(
197
+ path.join(homeDir, ".pi", "agent", "settings.json"),
198
+ JSON.stringify({
199
+ defaultProvider: "openai-codex",
200
+ defaultModel: "gpt-5.4",
201
+ enabledModels: ["claude-*"],
202
+ }, null, 2)
203
+ );
204
+
205
+ const preferred = listPreferredQualifiedModels(availableModels, { homeDir });
206
+ expect(preferred).toEqual([
207
+ "openai-codex/gpt-5.4",
208
+ "claude-agent-sdk/claude-sonnet-4-6",
209
+ "claude-agent-sdk/claude-opus-4-7",
210
+ ]);
211
+
212
+ fs.rmSync(testRoot, { recursive: true, force: true });
213
+ });
214
+ });
215
+
216
+ describe("sortAvailableModels", () => {
217
+ it("sorts preferred models first, then provider priority, then alphabetically", () => {
218
+ const sorted = sortAvailableModels(availableModels, {
219
+ preferredModels: ["openai-codex/gpt-5.4", "claude-agent-sdk/claude-sonnet-4-6"],
220
+ providerPriority: ["kimi-coding", "claude-agent-sdk"],
221
+ });
222
+
223
+ expect(sorted.map((model) => model.qualified)).toEqual([
224
+ "openai-codex/gpt-5.4",
225
+ "claude-agent-sdk/claude-sonnet-4-6",
226
+ "kimi-coding/kimi-for-coding",
227
+ "claude-agent-sdk/claude-opus-4-7",
228
+ "openrouter/gpt-5",
229
+ ]);
230
+ });
231
+ });