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.
- package/README.md +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- 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
|
+
});
|