viberoom 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +20 -0
  3. package/README.md +153 -0
  4. package/assets/icon-128.png +0 -0
  5. package/assets/icon-16.png +0 -0
  6. package/assets/icon-256.png +0 -0
  7. package/assets/icon-32.png +0 -0
  8. package/assets/icon-48.png +0 -0
  9. package/assets/icon-512.png +0 -0
  10. package/assets/icon-64.png +0 -0
  11. package/assets/icon-vector.svg +30 -0
  12. package/assets/icon.icns +0 -0
  13. package/assets/icon.ico +0 -0
  14. package/assets/icon.svg +30 -0
  15. package/assets/vendors/claude.svg +3 -0
  16. package/assets/vendors/codex.svg +3 -0
  17. package/assets/vendors/copilot.svg +5 -0
  18. package/assets/vendors/cursor.svg +3 -0
  19. package/assets/vendors/gemini.svg +3 -0
  20. package/assets/vendors/opencode.svg +3 -0
  21. package/dist/acp-client.js +137 -0
  22. package/dist/acp-types.js +2 -0
  23. package/dist/edit.js +34 -0
  24. package/dist/hub.js +348 -0
  25. package/dist/icons.js +235 -0
  26. package/dist/jsonrpc.js +109 -0
  27. package/dist/launcher.js +161 -0
  28. package/dist/log.js +35 -0
  29. package/dist/main.js +389 -0
  30. package/dist/mcp-skills-server.js +177 -0
  31. package/dist/open.js +141 -0
  32. package/dist/persona.js +217 -0
  33. package/dist/recipes.js +261 -0
  34. package/dist/room.js +2124 -0
  35. package/dist/server.js +433 -0
  36. package/dist/shortcuts.js +176 -0
  37. package/dist/skills.js +344 -0
  38. package/dist/tui.js +109 -0
  39. package/package.json +61 -0
  40. package/scripts/install.mjs +34 -0
  41. package/scripts/render-icon.mjs +84 -0
  42. package/scripts/update.mjs +29 -0
  43. package/ui/app.css +346 -0
  44. package/ui/app.js +2834 -0
  45. package/ui/avatars.js +113 -0
  46. package/ui/fonts/OFL.txt +93 -0
  47. package/ui/fonts/nunito-cyrillic-ext.woff2 +0 -0
  48. package/ui/fonts/nunito-cyrillic.woff2 +0 -0
  49. package/ui/fonts/nunito-latin-ext.woff2 +0 -0
  50. package/ui/fonts/nunito-latin.woff2 +0 -0
  51. package/ui/fonts/nunito-vietnamese.woff2 +0 -0
  52. package/ui/fonts/nunito.css +6 -0
  53. package/ui/icons.js +76 -0
  54. package/ui/index.html +217 -0
  55. package/ui/manifest.json +14 -0
  56. package/ui/theme.css +425 -0
package/dist/room.js ADDED
@@ -0,0 +1,2124 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { EventEmitter } from "node:events";
3
+ import { randomUUID } from "node:crypto";
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
5
+ import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
6
+ import { join, resolve } from "node:path";
7
+ import { AcpAgent } from "./acp-client.js";
8
+ import { RemoteError } from "./jsonrpc.js";
9
+ import { getRecipe, listRecipes } from "./recipes.js";
10
+ import { composeSkillBlock, SKILL_MARKER_PATTERN, SKILL_TOOL_NAME } from "./persona.js";
11
+ import { BUILTIN_AUTHOR, parseSkillInvocation, renderSkillBody, SKILL_NAME_PATTERN, } from "./skills.js";
12
+ import { BRIEF_AFFECTING_SETTINGS, DEFAULT_ROOM_SETTINGS, REQUEST_BRIEF_MARKER, SILENT_MARKER, buildBrief, buildHeader, composeCorrectionPrompt, composePrompt, countSentences, ensureDir, } from "./persona.js";
13
+ import { Transcript } from "./log.js";
14
+ const SKILL_TOOL_READY_MS = 5000;
15
+ const COLORS = ["#6d5dfc", "#16a34a", "#d97706", "#dc2626", "#0891b2", "#be185d", "#4d7c0f", "#7c3aed"];
16
+ const NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}_-]{0,23}$/u;
17
+ const MENTION_PATTERN = /@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
18
+ const RULE_REF_TOKEN = /@\{p:([^}]+)\}/g;
19
+ const ADAPTER_ERROR_PATTERN = /^(?:Warning: Falling back from WebSockets|unexpected status \d{3}|Error when talking to|API Error|You have exhausted your (?:daily )?quota|Rate limit|429 |5\d\d )/i;
20
+ export class Room extends EventEmitter {
21
+ id;
22
+ name;
23
+ dir;
24
+ dataDir;
25
+ createdAt;
26
+ settings;
27
+ hops = 0;
28
+ focused = false;
29
+ participants = new Map();
30
+ messages = [];
31
+ seq = 0;
32
+ programHumanDescription;
33
+ bypassPermissionsByDefault;
34
+ skills;
35
+ earlySkillReady = new Set();
36
+ speaking = null;
37
+ floorQueue = [];
38
+ humanTypingUntil = 0;
39
+ typingTimer = null;
40
+ closing = false;
41
+ runtimes = new Map();
42
+ drafts = new Map();
43
+ permissions = new Map();
44
+ optionCache;
45
+ log;
46
+ colorIndex = 0;
47
+ departed = new Map();
48
+ restoredSeen = new Map();
49
+ constructor(options) {
50
+ super();
51
+ this.id = options.id;
52
+ this.name = options.name;
53
+ this.dir = options.dir;
54
+ this.dataDir = options.dataDir;
55
+ this.createdAt = options.createdAt;
56
+ this.programHumanDescription = options.programHumanDescription;
57
+ this.bypassPermissionsByDefault = options.bypassPermissionsByDefault;
58
+ this.skills = options.skills;
59
+ this.settings = { ...DEFAULT_ROOM_SETTINGS, ...(options.settings ?? {}), name: options.name, humanName: options.humanName };
60
+ this.log = options.log;
61
+ this.optionCache = options.optionCache ?? new Map();
62
+ mkdirSync(this.dataDir, { recursive: true });
63
+ this.participants.set("human", {
64
+ id: "human",
65
+ name: options.humanName,
66
+ kind: "human",
67
+ status: "idle",
68
+ turns: 0,
69
+ color: "#111827",
70
+ });
71
+ this.loadHistory();
72
+ }
73
+ historyPath() {
74
+ return join(this.dataDir, "history.jsonl");
75
+ }
76
+ loadHistory() {
77
+ if (!existsSync(this.historyPath()))
78
+ return;
79
+ const lines = readFileSync(this.historyPath(), "utf8").split("\n").filter((l) => l.trim());
80
+ for (const line of lines) {
81
+ try {
82
+ const message = JSON.parse(line);
83
+ this.messages.push(message);
84
+ if (message.seq > this.seq)
85
+ this.seq = message.seq;
86
+ }
87
+ catch {
88
+ }
89
+ }
90
+ }
91
+ restore(stored) {
92
+ for (const s of stored) {
93
+ const recipe = getRecipe(s.agentType);
94
+ this.participants.set(s.id, {
95
+ id: s.id,
96
+ name: s.name,
97
+ kind: "agent",
98
+ agentType: s.agentType,
99
+ agentLabel: recipe?.label ?? s.agentType,
100
+ agentVendor: recipe?.vendor ?? s.agentType,
101
+ status: "offline",
102
+ statusDetail: "not connected since the hub restarted",
103
+ turns: 0,
104
+ color: s.color,
105
+ tagline: s.tagline,
106
+ role: s.role,
107
+ avatar: s.avatar || undefined,
108
+ muted: s.muted,
109
+ replyDelay: s.replyDelay,
110
+ skills: normalizeSkillList(s.skills),
111
+ launch: s.launch,
112
+ sessionId: s.sessionId,
113
+ supportsLoad: s.supportsLoad,
114
+ sawFromSeq: s.sawFromSeq,
115
+ violations: 0,
116
+ briefsSent: 0,
117
+ failedTurns: 0,
118
+ });
119
+ if (s.lastSeenSeq !== undefined)
120
+ this.restoredSeen.set(s.id, s.lastSeenSeq);
121
+ this.colorIndex++;
122
+ }
123
+ }
124
+ toStored() {
125
+ const { name: _n, humanName: _h, ...settings } = this.settings;
126
+ return {
127
+ id: this.id,
128
+ name: this.name,
129
+ dir: this.dir,
130
+ createdAt: this.createdAt,
131
+ settings,
132
+ participants: [...this.participants.values()]
133
+ .filter((p) => p.kind === "agent" && p.agentType)
134
+ .map((p) => ({
135
+ id: p.id,
136
+ name: p.name,
137
+ agentType: p.agentType,
138
+ tagline: p.tagline ?? "",
139
+ role: p.role ?? "",
140
+ avatar: p.avatar ?? "",
141
+ color: p.color,
142
+ launch: p.launch ?? { model: p.model ?? null, effort: p.effort ?? null, mode: p.mode ?? null },
143
+ muted: !!p.muted,
144
+ replyDelay: p.replyDelay,
145
+ skills: p.skills && p.skills.length ? [...p.skills] : undefined,
146
+ sessionId: p.sessionId,
147
+ supportsLoad: p.supportsLoad,
148
+ lastSeenSeq: this.runtimes.get(p.id)?.lastSeenSeq ?? this.restoredSeen.get(p.id),
149
+ sawFromSeq: p.sawFromSeq,
150
+ })),
151
+ };
152
+ }
153
+ commit(message) {
154
+ this.messages.push(message);
155
+ appendFileSync(this.historyPath(), JSON.stringify(message) + "\n");
156
+ this.push({ type: "message", message });
157
+ }
158
+ get humanName() {
159
+ return this.settings.humanName;
160
+ }
161
+ get hopLimit() {
162
+ return this.settings.hopLimit;
163
+ }
164
+ snapshot() {
165
+ const last = this.messages.length ? this.messages[this.messages.length - 1] : null;
166
+ return {
167
+ id: this.id,
168
+ name: this.name,
169
+ dir: this.dir,
170
+ createdAt: this.createdAt,
171
+ humanName: this.humanName,
172
+ hopLimit: this.hopLimit,
173
+ hops: this.hops,
174
+ focused: this.focused,
175
+ settings: this.settings,
176
+ customRulesText: this.renderRuleReferences(this.settings.customRules),
177
+ participants: [...this.participants.values()],
178
+ messages: [...this.messages, ...this.drafts.values()],
179
+ permissions: [...this.permissions.values()].map(({ resolve: _r, ...p }) => p),
180
+ recipes: listRecipes().map(({ build: _b, ...r }) => r),
181
+ lastMessageAt: last?.ts ?? this.createdAt,
182
+ };
183
+ }
184
+ applyProgramSettings(program) {
185
+ const changed = [];
186
+ this.bypassPermissionsByDefault = program.bypassPermissionsByDefault;
187
+ if (program.humanName !== this.settings.humanName) {
188
+ this.settings = { ...this.settings, humanName: program.humanName };
189
+ const human = this.participants.get("human");
190
+ human.name = program.humanName;
191
+ this.push({ type: "participant", participant: human });
192
+ changed.push("human name");
193
+ }
194
+ if (program.humanDescription !== this.programHumanDescription) {
195
+ this.programHumanDescription = program.humanDescription;
196
+ if (this.settings.humanDescriptionMode !== "override")
197
+ changed.push("human description");
198
+ }
199
+ if (changed.length) {
200
+ for (const runtime of this.runtimes.values())
201
+ runtime.briefPending = `room rules: ${changed.join(", ")}`;
202
+ this.push(this.roomEvent());
203
+ }
204
+ }
205
+ effectiveSettings() {
206
+ const own = this.settings.humanDescription.trim();
207
+ const program = this.programHumanDescription.trim();
208
+ let humanDescription = own;
209
+ if (this.settings.humanDescriptionMode === "inherit")
210
+ humanDescription = program;
211
+ else if (this.settings.humanDescriptionMode === "append")
212
+ humanDescription = [program, own].filter(Boolean).join(" ");
213
+ else if (this.settings.humanDescriptionMode === "none")
214
+ humanDescription = "";
215
+ return { ...this.settings, humanDescription, customRules: this.renderRuleReferences(this.settings.customRules) };
216
+ }
217
+ resolveRuleReferences(text) {
218
+ const unknown = [];
219
+ const stored = text.replace(MENTION_PATTERN, (whole, name) => {
220
+ const p = this.findByName(name);
221
+ if (p)
222
+ return `@{p:${p.id}}`;
223
+ if (!unknown.includes(name))
224
+ unknown.push(name);
225
+ return whole;
226
+ });
227
+ return { stored, unknown };
228
+ }
229
+ renderRuleReferences(stored) {
230
+ return stored.replace(RULE_REF_TOKEN, (_whole, id) => {
231
+ const p = this.participants.get(id);
232
+ if (p)
233
+ return `@${p.name}`;
234
+ const gone = this.departed.get(id);
235
+ return gone ? `@${gone} (no longer in the room)` : "@(a participant who left)";
236
+ });
237
+ }
238
+ postHumanMessage(text) {
239
+ const trimmed = text.trim();
240
+ if (!trimmed)
241
+ throw new Error("empty message");
242
+ this.humanTypingUntil = 0;
243
+ const human = this.participants.get("human");
244
+ const message = {
245
+ id: randomUUID(),
246
+ seq: ++this.seq,
247
+ from: human.id,
248
+ fromName: human.name,
249
+ to: [],
250
+ toNames: [],
251
+ text: trimmed,
252
+ ts: Date.now(),
253
+ kind: "chat",
254
+ };
255
+ this.decorateHumanMessage(message);
256
+ human.turns += 1;
257
+ if (this.focused) {
258
+ this.focused = false;
259
+ this.push(this.roomEvent());
260
+ }
261
+ this.commit(message);
262
+ this.route(message);
263
+ return message;
264
+ }
265
+ decorateHumanMessage(message) {
266
+ const mentions = this.parseMentions(message.text);
267
+ message.to = mentions.ids;
268
+ message.toNames = mentions.names;
269
+ delete message.skill;
270
+ const invocation = parseSkillInvocation(message.text);
271
+ if (invocation) {
272
+ const skill = this.skills?.library.get(invocation.name);
273
+ if (!skill)
274
+ this.notice(`No skill named "${invocation.name}" in the library; sent as plain text.`, "warn");
275
+ else if (!skill.userInvocable)
276
+ this.notice(`Skill "${skill.name}" is not user-invocable; sent as plain text.`, "warn");
277
+ else if (skill.problems.length)
278
+ this.notice(`Skill "${skill.name}" has problems (${skill.problems.join("; ")}); sent as plain text.`, "warn");
279
+ else
280
+ message.skill = { name: skill.name, args: invocation.args };
281
+ }
282
+ }
283
+ agentReadStates() {
284
+ const out = [];
285
+ for (const p of this.participants.values()) {
286
+ if (p.kind !== "agent" || p.status === "left")
287
+ continue;
288
+ const runtime = this.runtimes.get(p.id);
289
+ if (runtime)
290
+ out.push({ id: p.id, name: p.name, lastSeenSeq: runtime.lastSeenSeq, active: runtime.turnActive, online: true });
291
+ else
292
+ out.push({ id: p.id, name: p.name, lastSeenSeq: this.restoredSeen.get(p.id) ?? -1, active: false, online: false });
293
+ }
294
+ return out;
295
+ }
296
+ editableMessage(messageId) {
297
+ const message = this.messages.find((m) => m.id === messageId);
298
+ if (!message || message.kind !== "chat" || message.from !== "human")
299
+ throw new Error("only your own chat messages can be edited");
300
+ return message;
301
+ }
302
+ previewEdit(messageId) {
303
+ const message = this.editableMessage(messageId);
304
+ const { removed } = partitionHistory(this.messages, message.seq);
305
+ const { restart, untouched, offline } = affectedByEdit(this.agentReadStates(), message.seq);
306
+ return {
307
+ seq: message.seq,
308
+ laterMessages: removed.filter((m) => m.kind === "chat").length,
309
+ laterRecords: removed.length,
310
+ restart: restart.map((a) => a.name),
311
+ untouched: untouched.map((a) => a.name),
312
+ offline: offline.map((a) => a.name),
313
+ };
314
+ }
315
+ async editMessage(messageId, text, mode) {
316
+ const trimmed = text.trim();
317
+ if (!trimmed)
318
+ throw new Error("empty message");
319
+ const message = this.editableMessage(messageId);
320
+ if (trimmed === message.text)
321
+ throw new Error("the text is unchanged");
322
+ const { restart, offline } = affectedByEdit(this.agentReadStates(), message.seq);
323
+ const previous = message.text;
324
+ message.edited = { ts: Date.now(), previous };
325
+ message.text = trimmed;
326
+ this.decorateHumanMessage(message);
327
+ this.humanTypingUntil = 0;
328
+ if (this.focused) {
329
+ this.focused = false;
330
+ this.push(this.roomEvent());
331
+ }
332
+ if (mode === "notify") {
333
+ this.rewriteHistory();
334
+ this.push({ type: "message", message });
335
+ if (restart.length || offline.length)
336
+ this.postSystem(editNotice(this.humanName, previous, trimmed), "agents");
337
+ this.log.info(`edit (notify) of #${message.seq}: ${restart.length} agents had the old version`);
338
+ this.route(message);
339
+ return { restarted: [], removed: 0 };
340
+ }
341
+ const { kept, removed } = partitionHistory(this.messages, message.seq);
342
+ for (const a of restart) {
343
+ const runtime = this.runtimes.get(a.id);
344
+ if (!runtime)
345
+ continue;
346
+ this.dropScheduledTurn(a.id);
347
+ this.cancelPermissionsOf(a.id);
348
+ }
349
+ this.messages.splice(0, this.messages.length, ...kept);
350
+ this.appendDeleted(removed, message.seq);
351
+ this.rewriteHistory();
352
+ this.push({ type: "messages.truncated", fromSeq: message.seq });
353
+ this.push({ type: "message", message });
354
+ if (this.speaking && restart.some((a) => a.id === this.speaking))
355
+ this.speaking = null;
356
+ const restarted = [];
357
+ for (const a of restart) {
358
+ await this.retireRuntime(a.id);
359
+ try {
360
+ await this.reconnect(a.id, { mode: "replay", reason: "the conversation was rewritten" });
361
+ restarted.push(a.name);
362
+ }
363
+ catch (error) {
364
+ this.notice(`${a.name} could not be restarted after the rewrite: ${describeError(error)}`, "error");
365
+ }
366
+ }
367
+ for (const a of offline) {
368
+ const p = this.participants.get(a.id);
369
+ if (!p)
370
+ continue;
371
+ p.sessionId = undefined;
372
+ p.statusDetail = "the conversation was rewritten while it was offline; reconnect replays the new history";
373
+ this.restoredSeen.set(a.id, Math.max(0, message.seq - 1));
374
+ this.push({ type: "participant", participant: p });
375
+ }
376
+ this.postSystem(rewriteNotice(this.humanName, removed.filter((m) => m.kind === "chat").length, restarted));
377
+ this.log.info(`edit (rewrite) of #${message.seq}: ${removed.length} records removed; restarted ${restarted.join(", ") || "nobody"}`);
378
+ this.startNext();
379
+ this.route(message);
380
+ return { restarted, removed: removed.length };
381
+ }
382
+ async retireRuntime(id) {
383
+ const runtime = this.runtimes.get(id);
384
+ const participant = this.participants.get(id);
385
+ if (!runtime || !participant)
386
+ return;
387
+ runtime.retiring = true;
388
+ if (runtime.turnActive)
389
+ runtime.agent.cancel(runtime.sessionId);
390
+ try {
391
+ await Promise.race([runtime.agent.closeSession(runtime.sessionId), delay(1500)]);
392
+ }
393
+ catch {
394
+ }
395
+ runtime.agent.kill();
396
+ this.forgetRuntime(id);
397
+ participant.status = "offline";
398
+ participant.statusDetail = undefined;
399
+ }
400
+ rewriteHistory() {
401
+ const path = this.historyPath();
402
+ const tmp = `${path}.tmp`;
403
+ writeFileSync(tmp, this.messages.map((m) => JSON.stringify(m)).join("\n") + (this.messages.length ? "\n" : ""));
404
+ renameSync(tmp, path);
405
+ }
406
+ appendDeleted(records, editedSeq) {
407
+ if (!records.length)
408
+ return;
409
+ const lines = [JSON.stringify({ deletedAt: Date.now(), reason: "rewrite", editedSeq }), ...records.map((m) => JSON.stringify(m))];
410
+ appendFileSync(join(this.dataDir, "history.deleted.jsonl"), lines.join("\n") + "\n");
411
+ }
412
+ focus() {
413
+ let stopped = 0;
414
+ for (const [id, runtime] of this.runtimes) {
415
+ this.dropScheduledTurn(id);
416
+ if (runtime.turnActive) {
417
+ runtime.agent.cancel(runtime.sessionId);
418
+ this.cancelPermissionsOf(id);
419
+ stopped++;
420
+ }
421
+ }
422
+ this.focused = true;
423
+ this.push(this.roomEvent());
424
+ this.postSystem(`Hush: ${stopped ? `${stopped} repl${stopped > 1 ? "ies" : "y"} stopped; ` : ""}everyone waits until ${this.humanName} writes again.`);
425
+ }
426
+ rename(name) {
427
+ const trimmed = name.trim();
428
+ if (!trimmed || trimmed.length > 60)
429
+ throw new Error("room name must be 1-60 characters");
430
+ if (trimmed === this.name)
431
+ return;
432
+ this.name = trimmed;
433
+ this.settings = { ...this.settings, name: trimmed };
434
+ for (const runtime of this.runtimes.values())
435
+ runtime.briefPending = "room rules: name";
436
+ this.push(this.roomEvent());
437
+ }
438
+ updateSettings(patch) {
439
+ const next = { ...this.settings };
440
+ const changed = [];
441
+ const setNumber = (key, min, max) => {
442
+ if (patch[key] === undefined)
443
+ return;
444
+ const value = Number(patch[key]);
445
+ if (!Number.isInteger(value) || value < min || value > max)
446
+ throw new Error(`${key} must be an integer between ${min} and ${max}`);
447
+ if (value !== next[key]) {
448
+ next[key] = value;
449
+ changed.push(key);
450
+ }
451
+ };
452
+ setNumber("hopLimit", 0, 10_000);
453
+ setNumber("fullBriefEveryTurns", 1, 10_000);
454
+ setNumber("fullBriefEveryTokens", 1000, 10_000_000);
455
+ setNumber("replayAfterRestart", 0, 200);
456
+ setNumber("backlogCap", 1, 1000);
457
+ if (patch.replyDelay !== undefined) {
458
+ const value = Number(patch.replyDelay);
459
+ if (!Number.isFinite(value) || value < 0 || value > 120)
460
+ throw new Error("replyDelay must be between 0 and 120 seconds");
461
+ next.replyDelay = value;
462
+ }
463
+ const setText = (key, max) => {
464
+ if (patch[key] === undefined)
465
+ return;
466
+ const value = String(patch[key]).slice(0, max);
467
+ if (value !== next[key]) {
468
+ next[key] = value;
469
+ changed.push(key);
470
+ }
471
+ };
472
+ setText("topic", 2000);
473
+ setText("emoji", 8);
474
+ setText("humanDescription", 200);
475
+ let unknownRefs = [];
476
+ if (patch.customRules !== undefined) {
477
+ const resolved = this.resolveRuleReferences(String(patch.customRules).slice(0, 4000));
478
+ unknownRefs = resolved.unknown;
479
+ if (resolved.stored !== next.customRules) {
480
+ next.customRules = resolved.stored;
481
+ changed.push("customRules");
482
+ }
483
+ }
484
+ if (patch.humanDescriptionMode !== undefined) {
485
+ const mode = String(patch.humanDescriptionMode);
486
+ if (mode !== "inherit" && mode !== "override" && mode !== "append" && mode !== "none")
487
+ throw new Error("humanDescriptionMode must be inherit, override, append or none");
488
+ if (mode !== next.humanDescriptionMode) {
489
+ next.humanDescriptionMode = mode;
490
+ changed.push("humanDescriptionMode");
491
+ }
492
+ }
493
+ if (patch.refereeAction !== undefined) {
494
+ const action = String(patch.refereeAction);
495
+ if (action !== "next-header" && action !== "retry-hidden")
496
+ throw new Error("refereeAction must be next-header or retry-hidden");
497
+ if (action !== next.refereeAction) {
498
+ next.refereeAction = action;
499
+ changed.push("refereeAction");
500
+ }
501
+ }
502
+ if (patch.turnTaking !== undefined) {
503
+ const mode = String(patch.turnTaking);
504
+ if (mode !== "parallel" && mode !== "one-at-a-time")
505
+ throw new Error("turnTaking must be parallel or one-at-a-time");
506
+ if (mode !== next.turnTaking) {
507
+ next.turnTaking = mode;
508
+ changed.push("turnTaking");
509
+ }
510
+ }
511
+ if (patch.waitWhileHumanTypes !== undefined) {
512
+ const on = patch.waitWhileHumanTypes === true || patch.waitWhileHumanTypes === "true";
513
+ if (on !== next.waitWhileHumanTypes) {
514
+ next.waitWhileHumanTypes = on;
515
+ changed.push("waitWhileHumanTypes");
516
+ }
517
+ }
518
+ if (patch.language !== undefined) {
519
+ const raw = String(patch.language).trim();
520
+ const language = !raw || raw === "follow-human" ? { mode: "follow-human" } : { mode: "fixed", language: raw };
521
+ if (JSON.stringify(language) !== JSON.stringify(next.language)) {
522
+ next.language = language;
523
+ changed.push("language");
524
+ }
525
+ }
526
+ if (patch.tools !== undefined) {
527
+ const tools = String(patch.tools);
528
+ if (tools !== "on-request" && tools !== "never")
529
+ throw new Error("tools must be on-request or never");
530
+ if (tools !== next.tools) {
531
+ next.tools = tools;
532
+ changed.push("tools");
533
+ }
534
+ }
535
+ if (patch.maxSentences !== undefined) {
536
+ const value = patch.maxSentences === null || patch.maxSentences === "" ? null : Number(patch.maxSentences);
537
+ if (value !== null && (!Number.isInteger(value) || value < 1 || value > 100))
538
+ throw new Error("maxSentences must be 1-100 or empty");
539
+ if (value !== next.maxSentences) {
540
+ next.maxSentences = value;
541
+ changed.push("maxSentences");
542
+ }
543
+ }
544
+ for (const key of ["headerRules", "showVendorInRoster"]) {
545
+ if (patch[key] !== undefined) {
546
+ const value = patch[key] === true || patch[key] === "true";
547
+ if (value !== next[key]) {
548
+ next[key] = value;
549
+ changed.push(key);
550
+ }
551
+ }
552
+ }
553
+ this.settings = next;
554
+ this.push(this.roomEvent());
555
+ const briefChanges = changed.filter((c) => BRIEF_AFFECTING_SETTINGS.includes(c));
556
+ if (briefChanges.length) {
557
+ for (const runtime of this.runtimes.values())
558
+ runtime.briefPending = `room rules: ${briefChanges.join(", ")}`;
559
+ this.postSystem(`Room settings updated (${briefChanges.join(", ")}); agents get refreshed instructions on their next turn.`);
560
+ }
561
+ if (unknownRefs.length) {
562
+ this.notice(`Custom rules mention ${unknownRefs.map((n) => `@${n}`).join(", ")}, who ${unknownRefs.length > 1 ? "are" : "is"} not in the room; left as plain text.`, "warn");
563
+ }
564
+ return this.settings;
565
+ }
566
+ updatePersona(id, patch) {
567
+ const participant = this.participants.get(id);
568
+ const runtime = this.runtimes.get(id);
569
+ if (!participant || participant.kind !== "agent")
570
+ throw new Error("no such agent");
571
+ const changed = [];
572
+ if (patch.name !== undefined) {
573
+ const name = patch.name.trim();
574
+ if (!NAME_PATTERN.test(name))
575
+ throw new Error("name must be 1-24 letters, digits, _ or - (no spaces)");
576
+ const taken = this.findByName(name);
577
+ if (taken && taken.id !== id)
578
+ throw new Error(`name "${name}" is already taken`);
579
+ if (name !== participant.name) {
580
+ this.postSystem(`${participant.name} is now called ${name}.`);
581
+ participant.name = name;
582
+ changed.push("name");
583
+ if (this.settings.customRules.includes(`@{p:${id}}`)) {
584
+ for (const other of this.runtimes.values())
585
+ other.briefPending = other.briefPending ?? "room rules: a referenced participant was renamed";
586
+ }
587
+ }
588
+ }
589
+ if (patch.tagline !== undefined && patch.tagline.trim() !== (participant.tagline ?? "")) {
590
+ participant.tagline = patch.tagline.trim().slice(0, 80);
591
+ changed.push("tagline");
592
+ }
593
+ if (patch.role !== undefined && patch.role.trim() !== (participant.role ?? "")) {
594
+ participant.role = patch.role.trim().slice(0, 4000);
595
+ changed.push("role");
596
+ }
597
+ if (patch.avatar !== undefined) {
598
+ participant.avatar = patch.avatar.trim().slice(0, 8) || undefined;
599
+ }
600
+ if (patch.replyDelay !== undefined) {
601
+ if (patch.replyDelay === null)
602
+ participant.replyDelay = undefined;
603
+ else {
604
+ const value = Number(patch.replyDelay);
605
+ if (!Number.isFinite(value) || value < 0 || value > 120)
606
+ throw new Error("replyDelay must be between 0 and 120 seconds");
607
+ participant.replyDelay = value;
608
+ }
609
+ }
610
+ if (patch.skills !== undefined) {
611
+ const next = normalizeSkillList(patch.skills) ?? [];
612
+ const current = participant.skills ?? [];
613
+ if (next.join("\n") !== current.join("\n")) {
614
+ participant.skills = next;
615
+ changed.push("skills");
616
+ }
617
+ }
618
+ if (changed.length && runtime)
619
+ runtime.briefPending = `your persona: ${changed.join(", ")}`;
620
+ this.push({ type: "participant", participant });
621
+ return participant;
622
+ }
623
+ setMuted(id, muted) {
624
+ const participant = this.participants.get(id);
625
+ if (!participant || participant.kind !== "agent")
626
+ throw new Error("no such agent");
627
+ if (!!participant.muted === muted)
628
+ return participant;
629
+ participant.muted = muted;
630
+ const runtime = this.runtimes.get(id);
631
+ if (muted && runtime) {
632
+ this.dropScheduledTurn(id);
633
+ if (runtime.turnActive) {
634
+ runtime.agent.cancel(runtime.sessionId);
635
+ this.cancelPermissionsOf(id);
636
+ }
637
+ }
638
+ this.push({ type: "participant", participant });
639
+ this.postSystem(muted ? `${participant.name} is muted and receives no prompts.` : `${participant.name} is unmuted.`);
640
+ return participant;
641
+ }
642
+ async discoverOptions(recipeId, refresh = false) {
643
+ const recipe = getRecipe(recipeId);
644
+ if (!recipe)
645
+ throw new Error(`unknown agent type: ${recipeId}`);
646
+ if (recipe.unavailableReason)
647
+ throw new Error(`${recipe.label}: ${recipe.unavailableReason}`);
648
+ const cached = this.optionCache.get(recipeId);
649
+ if (cached && !refresh)
650
+ return cached;
651
+ const cwd = ensureDir(join(this.dataDir, ".probe"));
652
+ const log = this.log.child(`probe:${recipeId}`);
653
+ const launch = recipe.build({ model: null });
654
+ const agent = new AcpAgent({ ...launch, cwd }, {
655
+ onSessionUpdate: () => undefined,
656
+ onPermissionRequest: async () => ({ outcome: { outcome: "cancelled" } }),
657
+ onStderr: (line) => log.info(`stderr: ${line}`),
658
+ onExit: () => undefined,
659
+ });
660
+ const started = Date.now();
661
+ try {
662
+ const info = await Promise.race([
663
+ (async () => {
664
+ const init = await agent.initialize({ name: "viberoom", version: "0.2.0" });
665
+ const session = await this.openSession(agent, cwd, log);
666
+ const result = {
667
+ recipeId,
668
+ agentInfo: { name: init.agentInfo?.name ?? null, version: init.agentInfo?.version ?? null },
669
+ authMethods: agent.authMethods.map((m) => m.id),
670
+ modes: session.modes ?? null,
671
+ configOptions: session.configOptions ?? [],
672
+ modelAtLaunch: !!recipe.modelAtLaunch,
673
+ discoveredAt: Date.now(),
674
+ durationMs: 0,
675
+ };
676
+ try {
677
+ await Promise.race([agent.closeSession(session.sessionId), delay(1500)]);
678
+ }
679
+ catch {
680
+ }
681
+ return result;
682
+ })(),
683
+ delay(30_000).then(() => {
684
+ throw new Error("agent did not answer initialize/session/new within 30 s");
685
+ }),
686
+ ]);
687
+ info.durationMs = Date.now() - started;
688
+ this.optionCache.set(recipeId, info);
689
+ log.info(`options discovered in ${info.durationMs} ms: ${info.configOptions.map((o) => o.id).join(", ") || "none"}`);
690
+ return info;
691
+ }
692
+ finally {
693
+ agent.kill();
694
+ }
695
+ }
696
+ async inviteAgent(options) {
697
+ const recipe = getRecipe(options.agentType);
698
+ if (!recipe)
699
+ throw new Error(`unknown agent type: ${options.agentType}`);
700
+ if (recipe.unavailableReason)
701
+ throw new Error(`${recipe.label}: ${recipe.unavailableReason}`);
702
+ const name = options.name.trim();
703
+ if (!NAME_PATTERN.test(name))
704
+ throw new Error("name must be 1-24 letters, digits, _ or - (no spaces)");
705
+ if (this.findByName(name))
706
+ throw new Error(`name "${name}" is already taken`);
707
+ const id = `${recipe.id}-${name.toLowerCase()}`;
708
+ const launch = {
709
+ model: options.model ?? recipe.defaultModel,
710
+ effort: options.effort ?? recipe.defaultEffort,
711
+ mode: options.mode ?? (this.bypassPermissionsByDefault ? recipe.bypassMode ?? recipe.defaultMode : recipe.defaultMode),
712
+ };
713
+ const participant = {
714
+ id,
715
+ name,
716
+ kind: "agent",
717
+ agentType: recipe.id,
718
+ agentLabel: recipe.label,
719
+ agentVendor: recipe.vendor,
720
+ status: "starting",
721
+ turns: 0,
722
+ color: COLORS[this.colorIndex++ % COLORS.length],
723
+ tagline: (options.tagline ?? "").trim().slice(0, 80),
724
+ role: (options.role ?? "").trim().slice(0, 4000),
725
+ avatar: (options.avatar ?? "").trim().slice(0, 8) || undefined,
726
+ replyDelay: options.replyDelay === undefined || options.replyDelay === null ? undefined : Math.max(0, Math.min(120, Number(options.replyDelay) || 0)),
727
+ skills: normalizeSkillList(options.skills ?? undefined),
728
+ launch,
729
+ violations: 0,
730
+ briefsSent: 0,
731
+ failedTurns: 0,
732
+ };
733
+ this.participants.set(id, participant);
734
+ this.push({ type: "participant", participant });
735
+ await this.startAgent(participant, launch, true);
736
+ return participant;
737
+ }
738
+ async reconnect(id, options = { mode: "replay" }) {
739
+ const participant = this.participants.get(id);
740
+ if (!participant || participant.kind !== "agent")
741
+ throw new Error("no such agent");
742
+ if (this.runtimes.has(id) || participant.status === "starting")
743
+ return participant;
744
+ participant.status = "starting";
745
+ participant.statusDetail = undefined;
746
+ this.push({ type: "participant", participant });
747
+ const launch = participant.launch ?? { model: participant.model ?? null, effort: participant.effort ?? null, mode: participant.mode ?? null };
748
+ await this.startAgent(participant, launch, false, options);
749
+ return participant;
750
+ }
751
+ async startAgent(participant, launch, fresh, reconnectOptions) {
752
+ const recipe = getRecipe(participant.agentType ?? "");
753
+ if (!recipe)
754
+ throw new Error(`unknown agent type: ${participant.agentType}`);
755
+ const id = participant.id;
756
+ const name = participant.name;
757
+ const log = this.log.child(name);
758
+ const cwd = ensureDir(this.dir);
759
+ const spec = recipe.build({ model: launch.model });
760
+ const transcript = new Transcript(join(this.dataDir, "transcripts"), name);
761
+ log.info(`spawning ${spec.command} ${spec.args.join(" ")} (cwd ${cwd}); transcript ${transcript.path}`);
762
+ let agent;
763
+ try {
764
+ agent = new AcpAgent({ ...spec, cwd }, {
765
+ onSessionUpdate: (_sessionId, update) => this.onSessionUpdate(id, update),
766
+ onPermissionRequest: (params) => this.onPermissionRequest(id, params),
767
+ onStderr: (line) => log.info(`stderr: ${line}`),
768
+ onExit: (code, signal) => this.onAgentExit(id, code, signal, agent),
769
+ onRaw: (direction, message) => transcript.record(direction, message),
770
+ onProtocolError: (text) => log.warn(`protocol: ${text}`),
771
+ });
772
+ }
773
+ catch (error) {
774
+ this.failStart(participant, error, fresh);
775
+ throw error;
776
+ }
777
+ try {
778
+ const init = await agent.initialize({ name: "viberoom", version: "0.2.0" });
779
+ participant.agentInfo = { name: init.agentInfo?.name, version: init.agentInfo?.version };
780
+ if (agent.authMethods.length) {
781
+ log.info(`auth methods advertised: ${agent.authMethods.map((m) => m.id).join(", ")}`);
782
+ }
783
+ participant.supportsLoad = agent.supportsLoadSession;
784
+ const mcp = this.skillMcpServers(id);
785
+ const mcpToken = mcp ? mcp.token : null;
786
+ const mcpServers = mcp ? [mcp.server] : [];
787
+ let session = null;
788
+ let origin = fresh ? "new" : "replayed";
789
+ if (!fresh && reconnectOptions?.mode === "load") {
790
+ if (!participant.sessionId)
791
+ this.notice(`${name}: no stored session to load; starting a new one with replayed history.`, "warn");
792
+ else if (!agent.supportsLoadSession)
793
+ this.notice(`${name}: this agent does not support session/load; starting a new session with replayed history.`, "warn");
794
+ else {
795
+ try {
796
+ log.info(`session/load ${participant.sessionId}`);
797
+ session = await agent.loadSession(participant.sessionId, cwd, mcpServers);
798
+ origin = "loaded";
799
+ }
800
+ catch (error) {
801
+ this.notice(`${name}: session/load failed (${describeError(error)}); starting a new session with replayed history.`, "warn");
802
+ }
803
+ }
804
+ }
805
+ if (!session)
806
+ session = await this.openSession(agent, cwd, log, mcpServers);
807
+ participant.sessionId = session.sessionId;
808
+ participant.sessionOrigin = origin;
809
+ const storedSeen = this.restoredSeen.get(id);
810
+ const runtime = {
811
+ agent,
812
+ sessionId: session.sessionId,
813
+ transcript,
814
+ log,
815
+ firstTurnDone: false,
816
+ lastSeenSeq: this.seq,
817
+ turnStartSeq: this.seq,
818
+ turnActive: false,
819
+ pendingTurn: false,
820
+ turn: null,
821
+ turnsSinceBrief: 0,
822
+ usedAtBrief: 0,
823
+ briefSentThisTurn: false,
824
+ lastUsed: 0,
825
+ briefPending: null,
826
+ headerNotes: [],
827
+ briefRequestedAtSeq: -1,
828
+ replayOwnUntilSeq: fresh || origin === "loaded" ? -1 : this.seq,
829
+ delayTimer: null,
830
+ addressed: false,
831
+ retiring: false,
832
+ mcpToken,
833
+ sessionStartedAt: Date.now(),
834
+ skillChannel: mcp ? "pending" : "marker",
835
+ skillReadyWaiters: [],
836
+ pendingSkills: [],
837
+ skillPulledAtSeq: -1,
838
+ skillPulledName: "",
839
+ };
840
+ participant.skillChannel = runtime.skillChannel;
841
+ this.runtimes.set(id, runtime);
842
+ if (mcpToken && this.earlySkillReady.delete(mcpToken))
843
+ this.skillToolReady(id, mcpToken);
844
+ this.restoredSeen.delete(id);
845
+ participant.configOptions = session.configOptions ?? undefined;
846
+ if (session.modes) {
847
+ participant.mode = session.modes.currentModeId;
848
+ participant.modes = session.modes.availableModes;
849
+ }
850
+ this.applyConfigSummary(participant);
851
+ const warnings = await this.applyConfig(runtime, participant, {
852
+ model: recipe.modelAtLaunch ? null : launch.model,
853
+ effort: launch.effort,
854
+ mode: launch.mode,
855
+ });
856
+ if (recipe.modelAtLaunch && launch.model)
857
+ participant.model = launch.model;
858
+ for (const w of warnings)
859
+ this.notice(`${name}: ${w}`, "warn");
860
+ participant.status = "idle";
861
+ participant.statusDetail = undefined;
862
+ this.push({ type: "participant", participant });
863
+ if (fresh) {
864
+ const detail = this.settings.showVendorInRoster ? `${recipe.label}${participant.model ? `, model ${participant.model}` : ""}` : "agent";
865
+ this.postSystem(`${name} joined the room (${detail}${participant.tagline ? `; "${participant.tagline}"` : ""}).`);
866
+ runtime.lastSeenSeq = this.seq;
867
+ participant.sawFromSeq = this.seq + 1;
868
+ }
869
+ else if (origin === "loaded") {
870
+ runtime.lastSeenSeq = storedSeen ?? Math.max(0, this.seq - this.settings.replayAfterRestart);
871
+ runtime.firstTurnDone = true;
872
+ runtime.briefPending = "reconnected: your stored session was restored";
873
+ this.postSystem(`${name} is back in the room (session restored).`);
874
+ if (participant.sawFromSeq === undefined)
875
+ participant.sawFromSeq = runtime.lastSeenSeq + 1;
876
+ }
877
+ else {
878
+ this.postSystem(reconnectOptions?.reason ? `${name} restarted: ${reconnectOptions.reason}.` : `${name} is back in the room.`);
879
+ const replay = Math.max(0, reconnectOptions?.replay ?? this.settings.replayAfterRestart);
880
+ const chats = this.messages.filter((m) => m.kind === "chat");
881
+ const firstReplayed = replay > 0 && chats.length ? chats[Math.max(0, chats.length - replay)] : undefined;
882
+ runtime.lastSeenSeq = firstReplayed ? Math.max(0, firstReplayed.seq - 1) : this.seq;
883
+ participant.sawFromSeq = runtime.lastSeenSeq + 1;
884
+ }
885
+ this.restoredSeen.delete(id);
886
+ this.push({ type: "participant", participant });
887
+ runtime.turnStartSeq = runtime.lastSeenSeq;
888
+ log.info(`ready: session ${session.sessionId}`);
889
+ }
890
+ catch (error) {
891
+ agent.kill();
892
+ this.forgetRuntime(id);
893
+ this.failStart(participant, error, fresh);
894
+ throw error;
895
+ }
896
+ }
897
+ async removeParticipant(id) {
898
+ const participant = this.participants.get(id);
899
+ if (!participant || participant.kind !== "agent")
900
+ throw new Error("no such agent");
901
+ const runtime = this.runtimes.get(id);
902
+ if (runtime) {
903
+ this.dropScheduledTurn(id);
904
+ if (runtime.turnActive)
905
+ runtime.agent.cancel(runtime.sessionId);
906
+ this.cancelPermissionsOf(id);
907
+ try {
908
+ await Promise.race([runtime.agent.closeSession(runtime.sessionId), delay(2000)]);
909
+ }
910
+ catch (error) {
911
+ runtime.log.warn(`session/close failed: ${String(error)}`);
912
+ }
913
+ runtime.agent.kill();
914
+ this.forgetRuntime(id);
915
+ if (this.speaking === id) {
916
+ this.speaking = null;
917
+ this.startNext();
918
+ }
919
+ }
920
+ participant.status = "left";
921
+ this.participants.delete(id);
922
+ this.departed.set(id, participant.name);
923
+ this.push({ type: "participant.removed", id });
924
+ this.postSystem(`${participant.name} left the room.`);
925
+ if (RULE_REF_TOKEN.test(this.settings.customRules)) {
926
+ RULE_REF_TOKEN.lastIndex = 0;
927
+ if (this.settings.customRules.includes(`@{p:${id}}`)) {
928
+ for (const runtime of this.runtimes.values())
929
+ runtime.briefPending = "room rules: a referenced participant left";
930
+ }
931
+ }
932
+ }
933
+ cancelTurn(id) {
934
+ const runtime = this.runtimes.get(id);
935
+ const participant = this.participants.get(id);
936
+ if (!runtime || !participant)
937
+ throw new Error("no such agent");
938
+ if (!runtime.turnActive)
939
+ return;
940
+ runtime.agent.cancel(runtime.sessionId);
941
+ this.cancelPermissionsOf(id);
942
+ this.notice(`${participant.name}: stop requested.`, "info");
943
+ }
944
+ async setConfig(id, configId, value) {
945
+ const runtime = this.runtimes.get(id);
946
+ const participant = this.participants.get(id);
947
+ if (!runtime || !participant)
948
+ throw new Error("no such agent (offline?)");
949
+ const hasOption = participant.configOptions?.some((o) => o.id === configId);
950
+ if (!hasOption && configId === "mode" && participant.modes?.some((m) => m.id === value)) {
951
+ await runtime.agent.setMode(runtime.sessionId, String(value));
952
+ participant.mode = String(value);
953
+ }
954
+ else {
955
+ participant.configOptions = await runtime.agent.setConfigOption(runtime.sessionId, configId, value);
956
+ this.applyConfigSummary(participant);
957
+ }
958
+ participant.launch = { model: participant.model ?? null, effort: participant.effort ?? null, mode: participant.mode ?? null };
959
+ this.push({ type: "participant", participant });
960
+ }
961
+ resolvePermission(key, optionId) {
962
+ const entry = this.permissions.get(key);
963
+ if (!entry)
964
+ throw new Error("no such pending permission");
965
+ this.permissions.delete(key);
966
+ entry.resolve(optionId ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } });
967
+ this.push({ type: "permission.resolved", key, optionId });
968
+ }
969
+ postNotice(text) {
970
+ this.postSystem(text);
971
+ }
972
+ async shutdown() {
973
+ this.closing = true;
974
+ if (this.typingTimer) {
975
+ clearTimeout(this.typingTimer);
976
+ this.typingTimer = null;
977
+ }
978
+ for (const [id, runtime] of this.runtimes) {
979
+ if (runtime.delayTimer)
980
+ clearTimeout(runtime.delayTimer);
981
+ runtime.delayTimer = null;
982
+ runtime.pendingTurn = false;
983
+ try {
984
+ if (runtime.turnActive)
985
+ runtime.agent.cancel(runtime.sessionId);
986
+ await Promise.race([runtime.agent.closeSession(runtime.sessionId), delay(1000)]);
987
+ }
988
+ catch {
989
+ }
990
+ runtime.agent.kill();
991
+ this.forgetRuntime(id);
992
+ }
993
+ }
994
+ route(message) {
995
+ const from = this.participants.get(message.from);
996
+ const live = (id) => this.runtimes.has(id) && !this.participants.get(id)?.muted;
997
+ const agentTargets = message.to.filter(live);
998
+ let targets = [];
999
+ if (from?.kind === "human") {
1000
+ this.hops = 0;
1001
+ targets = message.to.length ? agentTargets : [...this.runtimes.keys()].filter(live);
1002
+ if (message.skill) {
1003
+ const skill = this.skills?.library.get(message.skill.name);
1004
+ if (!message.to.length)
1005
+ targets = targets.filter((id) => this.hasSkill(this.participants.get(id), message.skill.name));
1006
+ if (!targets.length)
1007
+ this.notice(`Nobody in this room has the skill "${message.skill.name}"; attach it to an agent first, or address one with @.`, "warn");
1008
+ if (skill) {
1009
+ for (const id of targets) {
1010
+ const runtime = this.runtimes.get(id);
1011
+ if (runtime)
1012
+ runtime.pendingSkills.push({ name: skill.name, text: renderSkillBody(skill.body, message.skill.args), invokedBy: from.name, extraFiles: skill.extraFiles });
1013
+ }
1014
+ }
1015
+ targets = [...targets];
1016
+ }
1017
+ if (!message.to.length && !message.skill && this.settings.turnTaking === "one-at-a-time" && targets.length > 1)
1018
+ targets = shuffle(targets);
1019
+ }
1020
+ else if (this.focused) {
1021
+ targets = [];
1022
+ }
1023
+ else if (agentTargets.length) {
1024
+ if (this.hops >= this.hopLimit) {
1025
+ this.postSystem(`Hop limit ${this.hopLimit} reached: ${message.toNames.join(", ")} will not be prompted until ${this.humanName} writes again.`);
1026
+ }
1027
+ else {
1028
+ this.hops += 1;
1029
+ targets = agentTargets;
1030
+ }
1031
+ }
1032
+ this.push(this.roomEvent());
1033
+ for (const id of targets)
1034
+ this.requestTurn(id, message.to.includes(id) || !!message.skill);
1035
+ }
1036
+ hasSkill(participant, name) {
1037
+ if (!participant?.skills)
1038
+ return false;
1039
+ const lower = name.toLowerCase();
1040
+ return participant.skills.some((s) => s.toLowerCase() === lower);
1041
+ }
1042
+ attachedSkills(participant) {
1043
+ if (!this.skills || !participant.skills?.length)
1044
+ return [];
1045
+ return this.skills.library.list().filter((s) => !s.problems.length && !s.draft && this.hasSkill(participant, s.name));
1046
+ }
1047
+ skillsForPrompt(participant, runtime) {
1048
+ if (!this.skills)
1049
+ return undefined;
1050
+ const items = this.attachedSkills(participant)
1051
+ .filter((s) => s.agentInvocable)
1052
+ .map((s) => ({ name: s.name, description: s.description }));
1053
+ const channel = runtime.skillChannel === "tool" ? "tool" : "marker";
1054
+ if (!items.length && channel !== "tool")
1055
+ return undefined;
1056
+ return { items, channel, canCreate: channel === "tool" };
1057
+ }
1058
+ createSkillForAgent(participantId, input) {
1059
+ const participant = this.participants.get(participantId);
1060
+ if (!participant || !this.runtimes.has(participantId))
1061
+ throw new Error("this agent is not in the room any more");
1062
+ if (!this.skills)
1063
+ throw new Error("skills are not available in this hub");
1064
+ const library = this.skills.library;
1065
+ const name = String(input.name ?? "").trim();
1066
+ const existing = library.get(name);
1067
+ if (input.op === "create" && existing) {
1068
+ throw new Error(`a skill named "${existing.name}" already exists (author ${existing.author}); use update_skill for an agent-made skill, or pick another name`);
1069
+ }
1070
+ if (input.op === "update") {
1071
+ if (!existing)
1072
+ throw new Error(`no skill named "${name}" to update; use create_skill`);
1073
+ if (!existing.author.startsWith("agent:"))
1074
+ throw new Error(`skill "${existing.name}" was written by the human and is read-only for agents; ask in the room or create a new one`);
1075
+ }
1076
+ const draft = {
1077
+ name: existing?.name ?? name,
1078
+ description: String(input.description ?? ""),
1079
+ argumentHint: input.argumentHint ? String(input.argumentHint) : "",
1080
+ body: String(input.instructions ?? ""),
1081
+ userInvocable: input.userInvocable,
1082
+ agentInvocable: input.agentInvocable,
1083
+ author: existing?.author ?? `agent:${participant.name}@${this.id}`,
1084
+ reviewed: false,
1085
+ draft: existing ? existing.draft : this.skills.needApproval(),
1086
+ };
1087
+ const lint = library.lint(draft);
1088
+ if (lint.errors.length)
1089
+ throw new Error(`not saved: ${lint.errors.map((e) => e.message).join("; ")}`);
1090
+ const warnings = lint.warnings.map((w) => w.message);
1091
+ if (input.dryRun)
1092
+ return { ok: true, message: `dry run: "${draft.name}" would be ${input.op === "create" ? "created" : "updated"}${warnings.length ? ` with warnings: ${warnings.join("; ")}` : ""}`, warnings };
1093
+ const saved = this.skills.save(draft);
1094
+ const awaiting = saved.draft ? " It is a draft until the human approves it in Settings; it cannot be attached or loaded before that." : "";
1095
+ this.postSystem(`${participant.name} ${input.op === "create" ? "created" : "updated"} the skill "${saved.name}" (${saved.description.slice(0, 80)}${saved.description.length > 80 ? "…" : ""}).${saved.draft ? " Awaiting the human's approval." : ""}`);
1096
+ this.log.info(`skills: ${participant.name} ${input.op}d "${saved.name}"${saved.draft ? " (draft)" : ""}`);
1097
+ return {
1098
+ ok: true,
1099
+ message: `Skill "${saved.name}" ${input.op === "create" ? "created" : "updated"} in the shared library.${awaiting}${warnings.length ? ` Warnings: ${warnings.join("; ")}` : ""}${saved.draft ? "" : " Use attach_skill to give it to yourself or to other agents."}`,
1100
+ warnings,
1101
+ };
1102
+ }
1103
+ attachSkillForAgent(participantId, name, to) {
1104
+ const participant = this.participants.get(participantId);
1105
+ if (!participant || !this.runtimes.has(participantId))
1106
+ throw new Error("this agent is not in the room any more");
1107
+ if (!this.skills)
1108
+ throw new Error("skills are not available in this hub");
1109
+ const skill = this.skills.library.get(String(name ?? "").trim());
1110
+ if (!skill)
1111
+ throw new Error(`no skill named "${name}" in the library`);
1112
+ if (skill.problems.length)
1113
+ throw new Error(`skill "${skill.name}" cannot be attached (${skill.problems.join("; ")})`);
1114
+ if (skill.draft)
1115
+ throw new Error(`skill "${skill.name}" is a draft awaiting the human's approval; it cannot be attached yet`);
1116
+ const targets = [];
1117
+ const unknown = [];
1118
+ if (to === "me" || (Array.isArray(to) && to.length === 0))
1119
+ targets.push(participant);
1120
+ else {
1121
+ for (const raw of Array.isArray(to) ? to : [String(to)]) {
1122
+ const wanted = String(raw).trim();
1123
+ if (!wanted || wanted.toLowerCase() === "me" || wanted.toLowerCase() === participant.name.toLowerCase()) {
1124
+ if (!targets.includes(participant))
1125
+ targets.push(participant);
1126
+ continue;
1127
+ }
1128
+ const other = this.findByName(wanted.replace(/^@/, ""));
1129
+ if (!other || other.kind !== "agent" || other.status === "left")
1130
+ unknown.push(wanted);
1131
+ else if (!targets.includes(other))
1132
+ targets.push(other);
1133
+ }
1134
+ }
1135
+ if (unknown.length)
1136
+ throw new Error(`no such agent in this room: ${unknown.join(", ")} (agents here: ${[...this.participants.values()].filter((p) => p.kind === "agent" && p.status !== "left").map((p) => p.name).join(", ")})`);
1137
+ const attached = [];
1138
+ for (const target of targets) {
1139
+ if (this.hasSkill(target, skill.name))
1140
+ continue;
1141
+ target.skills = [...(target.skills ?? []), skill.name];
1142
+ attached.push(target.name);
1143
+ const runtime = this.runtimes.get(target.id);
1144
+ if (runtime)
1145
+ runtime.briefPending = runtime.briefPending ?? `your skills: "${skill.name}" attached${target.id === participant.id ? "" : ` by ${participant.name}`}`;
1146
+ this.push({ type: "participant", participant: target });
1147
+ }
1148
+ const others = attached.filter((n) => n !== participant.name);
1149
+ if (others.length)
1150
+ this.postSystem(`${participant.name} attached the skill "${skill.name}" to ${others.join(", ")}.`);
1151
+ this.log.info(`skills: ${participant.name} attached "${skill.name}" to ${attached.join(", ") || "nobody new"}`);
1152
+ const already = targets.filter((t) => !attached.includes(t.name)).map((t) => t.name);
1153
+ return {
1154
+ ok: true,
1155
+ message: `${attached.length ? `Skill "${skill.name}" attached to ${attached.map((n) => (n === participant.name ? "you" : n)).join(", ")}.` : ""}${already.length ? ` ${already.map((n) => (n === participant.name ? "You" : n)).join(", ")} already had it.` : ""}`.trim(),
1156
+ };
1157
+ }
1158
+ skillMcpServers(participantId) {
1159
+ const hubUrl = this.skills?.hubUrl();
1160
+ if (!this.skills || !hubUrl)
1161
+ return null;
1162
+ const token = this.skills.issueToken(this.id, participantId);
1163
+ return {
1164
+ token,
1165
+ server: {
1166
+ name: "viberoom",
1167
+ command: process.execPath,
1168
+ args: [this.skills.serverScript],
1169
+ env: [
1170
+ { name: "VIBEROOM_HUB", value: hubUrl },
1171
+ { name: "VIBEROOM_TOKEN", value: token },
1172
+ ],
1173
+ },
1174
+ };
1175
+ }
1176
+ skillToolReady(participantId, token) {
1177
+ const runtime = this.runtimes.get(participantId);
1178
+ const participant = this.participants.get(participantId);
1179
+ if (!runtime || runtime.mcpToken !== token) {
1180
+ this.earlySkillReady.add(token);
1181
+ return;
1182
+ }
1183
+ if (!participant)
1184
+ return;
1185
+ const late = runtime.skillChannel === "marker";
1186
+ runtime.skillChannel = "tool";
1187
+ participant.skillChannel = "tool";
1188
+ for (const wake of runtime.skillReadyWaiters.splice(0))
1189
+ wake();
1190
+ runtime.log.info(`skills: the ${SKILL_TOOL_NAME} tool is available${late ? " (late; brief will be refreshed)" : ""}`);
1191
+ if (late && this.attachedSkills(participant).length)
1192
+ runtime.briefPending = runtime.briefPending ?? "your skills: the load_skill tool became available";
1193
+ this.push({ type: "participant", participant });
1194
+ }
1195
+ async awaitSkillChannel(participant, runtime) {
1196
+ if (runtime.skillChannel !== "pending")
1197
+ return;
1198
+ const remaining = runtime.sessionStartedAt + SKILL_TOOL_READY_MS - Date.now();
1199
+ if (remaining > 0) {
1200
+ await new Promise((resolve) => {
1201
+ const timer = setTimeout(resolve, remaining);
1202
+ runtime.skillReadyWaiters.push(() => {
1203
+ clearTimeout(timer);
1204
+ resolve();
1205
+ });
1206
+ });
1207
+ }
1208
+ if (runtime.skillChannel === "pending") {
1209
+ runtime.skillChannel = "marker";
1210
+ participant.skillChannel = "marker";
1211
+ runtime.log.info("skills: no MCP tools listed in time; using the [skill:name] marker");
1212
+ this.push({ type: "participant", participant });
1213
+ }
1214
+ }
1215
+ loadSkillForAgent(participantId, name) {
1216
+ const participant = this.participants.get(participantId);
1217
+ const runtime = this.runtimes.get(participantId);
1218
+ if (!participant || !runtime)
1219
+ throw new Error("this agent is not in the room any more");
1220
+ const skill = this.resolveAgentSkill(participant, name);
1221
+ if (!skill.ok)
1222
+ throw new Error(skill.reason);
1223
+ const text = composeSkillBlock({ name: skill.skill.name, text: renderSkillBody(skill.skill.body, ""), extraFiles: skill.skill.extraFiles });
1224
+ this.commit({
1225
+ id: randomUUID(),
1226
+ seq: ++this.seq,
1227
+ from: participant.id,
1228
+ fromName: participant.name,
1229
+ to: [],
1230
+ toNames: [],
1231
+ text: `loaded skill "${skill.skill.name}"`,
1232
+ ts: Date.now(),
1233
+ kind: "hidden",
1234
+ details: { skill: skill.skill.name, via: "tool", outcome: "delivered as a tool result" },
1235
+ });
1236
+ runtime.log.info(`skills: "${skill.skill.name}" loaded through the tool`);
1237
+ return { name: skill.skill.name, text };
1238
+ }
1239
+ resolveAgentSkill(participant, name) {
1240
+ if (!this.skills)
1241
+ return { ok: false, reason: "skills are not available in this hub" };
1242
+ const skill = this.skills.library.get(name);
1243
+ const builtin = !!skill && skill.author === BUILTIN_AUTHOR && !skill.draft;
1244
+ const mine = this.attachedSkills(participant).filter((s) => s.agentInvocable).map((s) => s.name);
1245
+ const list = mine.length ? `your skills: ${mine.join(", ")}` : "you have no skills";
1246
+ if (!builtin && (!this.hasSkill(participant, name) || !mine.some((s) => s.toLowerCase() === name.toLowerCase()))) {
1247
+ return { ok: false, reason: `"${name}" is not one of your skills (${list})` };
1248
+ }
1249
+ if (!skill || skill.problems.length)
1250
+ return { ok: false, reason: `skill "${name}" cannot be loaded right now (${skill ? skill.problems.join("; ") : "missing"})` };
1251
+ return { ok: true, skill };
1252
+ }
1253
+ skillChanged(name) {
1254
+ for (const [id, runtime] of this.runtimes) {
1255
+ const participant = this.participants.get(id);
1256
+ if (participant && this.hasSkill(participant, name))
1257
+ runtime.briefPending = runtime.briefPending ?? `your skills: "${name}" changed`;
1258
+ }
1259
+ }
1260
+ isSkillToolCall(runtime, params) {
1261
+ const pattern = /load_skill/i;
1262
+ const call = params.toolCall;
1263
+ if (pattern.test(call.title ?? ""))
1264
+ return true;
1265
+ if (call.rawInput && pattern.test(JSON.stringify(call.rawInput)))
1266
+ return true;
1267
+ const known = runtime.turn?.message.toolCalls?.find((t) => t.toolCallId === call.toolCallId);
1268
+ return !!known && pattern.test(known.title ?? "");
1269
+ }
1270
+ forgetRuntime(id) {
1271
+ const runtime = this.runtimes.get(id);
1272
+ if (runtime?.mcpToken)
1273
+ this.skills?.revokeToken(runtime.mcpToken);
1274
+ if (runtime?.delayTimer)
1275
+ clearTimeout(runtime.delayTimer);
1276
+ this.runtimes.delete(id);
1277
+ }
1278
+ requestTurn(id, addressed = false) {
1279
+ const runtime = this.runtimes.get(id);
1280
+ const participant = this.participants.get(id);
1281
+ if (!runtime || !participant)
1282
+ return;
1283
+ runtime.pendingTurn = true;
1284
+ if (addressed && !runtime.addressed) {
1285
+ runtime.addressed = true;
1286
+ if (this.floorQueue.includes(id)) {
1287
+ this.floorQueue.splice(this.floorQueue.indexOf(id), 1);
1288
+ this.enqueueForFloor(id);
1289
+ }
1290
+ }
1291
+ if (runtime.turnActive || runtime.delayTimer || this.floorQueue.includes(id))
1292
+ return;
1293
+ const others = [...this.runtimes.keys()].filter((otherId) => otherId !== id && this.participants.get(otherId)?.status !== "left").length;
1294
+ const roomDelay = others >= 1 ? (this.settings.replyDelay ?? 0) : 0;
1295
+ const maxMs = Math.max(0, participant.replyDelay ?? roomDelay) * 1000;
1296
+ const waitMs = maxMs > 0 ? Math.round(Math.random() * maxMs) : 0;
1297
+ if (participant.status === "idle") {
1298
+ participant.status = "queued";
1299
+ this.push({ type: "participant", participant });
1300
+ }
1301
+ runtime.delayTimer = setTimeout(() => {
1302
+ runtime.delayTimer = null;
1303
+ this.tryStartTurn(id);
1304
+ }, waitMs);
1305
+ if (waitMs)
1306
+ runtime.log.info(`reply delay ${waitMs} ms`);
1307
+ }
1308
+ tryStartTurn(id) {
1309
+ const runtime = this.runtimes.get(id);
1310
+ if (!runtime || !runtime.pendingTurn)
1311
+ return;
1312
+ if (this.humanIsTyping()) {
1313
+ this.enqueueForFloor(id);
1314
+ this.armTypingTimer();
1315
+ return;
1316
+ }
1317
+ if (this.settings.turnTaking === "one-at-a-time" && this.speaking && this.speaking !== id) {
1318
+ this.enqueueForFloor(id);
1319
+ return;
1320
+ }
1321
+ void this.runTurn(id);
1322
+ }
1323
+ enqueueForFloor(id) {
1324
+ if (this.floorQueue.includes(id))
1325
+ return;
1326
+ const runtime = this.runtimes.get(id);
1327
+ if (runtime?.addressed) {
1328
+ const firstPlain = this.floorQueue.findIndex((other) => !this.runtimes.get(other)?.addressed);
1329
+ if (firstPlain >= 0) {
1330
+ this.floorQueue.splice(firstPlain, 0, id);
1331
+ return;
1332
+ }
1333
+ }
1334
+ this.floorQueue.push(id);
1335
+ }
1336
+ humanIsTyping() {
1337
+ return this.settings.waitWhileHumanTypes && Date.now() < this.humanTypingUntil;
1338
+ }
1339
+ humanTyping() {
1340
+ this.humanTypingUntil = Date.now() + 4000;
1341
+ this.armTypingTimer();
1342
+ }
1343
+ armTypingTimer() {
1344
+ if (this.typingTimer)
1345
+ return;
1346
+ const wait = Math.max(50, this.humanTypingUntil - Date.now() + 20);
1347
+ this.typingTimer = setTimeout(() => {
1348
+ this.typingTimer = null;
1349
+ if (this.humanIsTyping()) {
1350
+ this.armTypingTimer();
1351
+ return;
1352
+ }
1353
+ if (!this.speaking)
1354
+ this.startNext();
1355
+ }, wait);
1356
+ }
1357
+ startNext() {
1358
+ if (this.humanIsTyping()) {
1359
+ this.armTypingTimer();
1360
+ return;
1361
+ }
1362
+ while (this.floorQueue.length) {
1363
+ const id = this.floorQueue.shift();
1364
+ const runtime = this.runtimes.get(id);
1365
+ const participant = this.participants.get(id);
1366
+ if (!runtime || !participant || !runtime.pendingTurn || participant.muted || this.focused)
1367
+ continue;
1368
+ void this.runTurn(id);
1369
+ return;
1370
+ }
1371
+ }
1372
+ dropScheduledTurn(id) {
1373
+ const runtime = this.runtimes.get(id);
1374
+ const participant = this.participants.get(id);
1375
+ if (runtime) {
1376
+ runtime.pendingTurn = false;
1377
+ runtime.addressed = false;
1378
+ if (runtime.delayTimer) {
1379
+ clearTimeout(runtime.delayTimer);
1380
+ runtime.delayTimer = null;
1381
+ }
1382
+ }
1383
+ const i = this.floorQueue.indexOf(id);
1384
+ if (i >= 0)
1385
+ this.floorQueue.splice(i, 1);
1386
+ if (participant && participant.status === "queued") {
1387
+ participant.status = "idle";
1388
+ this.push({ type: "participant", participant });
1389
+ }
1390
+ }
1391
+ async runTurn(id) {
1392
+ const participant = this.participants.get(id);
1393
+ const runtime = this.runtimes.get(id);
1394
+ if (!participant || !runtime)
1395
+ return;
1396
+ this.speaking = id;
1397
+ runtime.addressed = false;
1398
+ try {
1399
+ await this.runTurnInner(id, participant, runtime);
1400
+ }
1401
+ finally {
1402
+ if (this.speaking === id)
1403
+ this.speaking = null;
1404
+ if (participant.status === "queued") {
1405
+ participant.status = "idle";
1406
+ this.push({ type: "participant", participant });
1407
+ }
1408
+ if (runtime.pendingTurn && runtime.agent.alive)
1409
+ this.requestTurn(id);
1410
+ this.startNext();
1411
+ }
1412
+ }
1413
+ async runTurnInner(id, participant, runtime) {
1414
+ runtime.pendingTurn = false;
1415
+ if (!runtime.agent.alive || participant.muted || this.focused)
1416
+ return;
1417
+ await this.awaitSkillChannel(participant, runtime);
1418
+ if (!runtime.agent.alive || participant.muted || this.focused)
1419
+ return;
1420
+ const unreadAll = this.messages.filter((m) => m.kind !== "hidden" && m.seq > runtime.lastSeenSeq && (m.kind === "system" || m.from !== id || m.seq <= runtime.replayOwnUntilSeq));
1421
+ if (!unreadAll.some((m) => m.kind === "chat"))
1422
+ return;
1423
+ const cap = this.settings.backlogCap;
1424
+ const omitted = Math.max(0, unreadAll.length - cap);
1425
+ const unread = omitted ? unreadAll.slice(omitted) : unreadAll;
1426
+ runtime.turnStartSeq = runtime.lastSeenSeq;
1427
+ runtime.lastSeenSeq = this.seq;
1428
+ const persona = this.personaOf(participant);
1429
+ const roster = this.roster();
1430
+ const settings = this.effectiveSettings();
1431
+ const tokensSinceBrief = runtime.lastUsed - runtime.usedAtBrief;
1432
+ let briefReason = null;
1433
+ if (!runtime.firstTurnDone)
1434
+ briefReason = "first turn";
1435
+ else if (runtime.briefPending)
1436
+ briefReason = runtime.briefPending;
1437
+ else if (runtime.turnsSinceBrief >= this.settings.fullBriefEveryTurns)
1438
+ briefReason = `every ${this.settings.fullBriefEveryTurns} turns`;
1439
+ else if (tokensSinceBrief >= this.settings.fullBriefEveryTokens)
1440
+ briefReason = `${tokensSinceBrief} tokens since last brief`;
1441
+ const notes = [...runtime.headerNotes];
1442
+ runtime.headerNotes = [];
1443
+ if (briefReason && runtime.firstTurnDone) {
1444
+ if (briefReason.startsWith("requested"))
1445
+ notes.push("full brief re-sent as requested");
1446
+ else if (briefReason.startsWith("room rules") || briefReason.startsWith("your persona") || briefReason.startsWith("your skills"))
1447
+ notes.push(`instructions updated (${briefReason})`);
1448
+ else
1449
+ notes.push(`full brief re-attached (${briefReason})`);
1450
+ }
1451
+ const attached = runtime.pendingSkills.splice(0);
1452
+ for (const s of attached) {
1453
+ notes.push(s.invokedBy ? `${s.invokedBy} invoked your skill "${s.name}"; its instructions are attached below, follow them` : `skill "${s.name}" attached below as you asked; the same messages follow`);
1454
+ }
1455
+ const skillsForPrompt = this.skillsForPrompt(participant, runtime);
1456
+ const promptText = composePrompt({
1457
+ brief: briefReason ? buildBrief(settings, persona, roster, undefined, skillsForPrompt) : undefined,
1458
+ header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
1459
+ skills: attached.map((s) => composeSkillBlock({ name: s.name, text: s.text, invokedBy: s.invokedBy, extraFiles: s.extraFiles })),
1460
+ backlog: unread.map((m) => m.kind === "system"
1461
+ ? { kind: "event", text: m.text }
1462
+ : { kind: "message", fromName: m.from === id ? `${m.fromName} (you, earlier)` : m.fromName, toNames: m.toNames, text: m.text }),
1463
+ omitted,
1464
+ personaName: participant.name,
1465
+ });
1466
+ if (briefReason) {
1467
+ runtime.briefPending = null;
1468
+ runtime.turnsSinceBrief = 0;
1469
+ runtime.briefSentThisTurn = true;
1470
+ participant.briefsSent = (participant.briefsSent ?? 0) + 1;
1471
+ }
1472
+ runtime.firstTurnDone = true;
1473
+ runtime.replayOwnUntilSeq = -1;
1474
+ runtime.log.info(`turn: ${unread.length} unread (${omitted} omitted), brief=${briefReason ?? "no"}, notes=${notes.length}`);
1475
+ const retry = await this.executeTurn(participant, runtime, promptText, null);
1476
+ if (retry) {
1477
+ await this.executeTurn(participant, runtime, retry.prompt, retry);
1478
+ }
1479
+ }
1480
+ async executeTurn(participant, runtime, promptText, retry) {
1481
+ const id = participant.id;
1482
+ runtime.turnActive = true;
1483
+ participant.status = "thinking";
1484
+ participant.statusDetail = undefined;
1485
+ this.push({ type: "participant", participant });
1486
+ const draft = {
1487
+ id: randomUUID(),
1488
+ seq: 0,
1489
+ from: id,
1490
+ fromName: participant.name,
1491
+ to: [],
1492
+ toNames: [],
1493
+ text: "",
1494
+ ts: Date.now(),
1495
+ kind: "chat",
1496
+ streaming: true,
1497
+ toolCalls: [],
1498
+ };
1499
+ this.drafts.set(draft.id, draft);
1500
+ runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now() };
1501
+ this.push({ type: "message", message: draft });
1502
+ let result = null;
1503
+ let failure = null;
1504
+ try {
1505
+ result = await runtime.agent.prompt(runtime.sessionId, [{ type: "text", text: promptText }]);
1506
+ }
1507
+ catch (error) {
1508
+ failure = error instanceof Error ? error.message : String(error);
1509
+ }
1510
+ const startedAt = runtime.turn.startedAt;
1511
+ runtime.turn = null;
1512
+ runtime.turnActive = false;
1513
+ this.drafts.delete(draft.id);
1514
+ participant.turns += 1;
1515
+ if (!retry)
1516
+ runtime.turnsSinceBrief += 1;
1517
+ if (runtime.briefSentThisTurn) {
1518
+ runtime.usedAtBrief = runtime.lastUsed;
1519
+ runtime.briefSentThisTurn = false;
1520
+ }
1521
+ if (runtime.retiring) {
1522
+ this.push({ type: "message.removed", id: draft.id });
1523
+ if (retry)
1524
+ this.closeRetry(retry, "the session was replaced; nothing was posted");
1525
+ return null;
1526
+ }
1527
+ if (failure || !result) {
1528
+ participant.status = runtime.agent.alive ? "idle" : "error";
1529
+ participant.statusDetail = `last turn failed: ${(failure ?? "no result").replace(/\s+/g, " ").slice(0, 120)}`;
1530
+ participant.failedTurns = (participant.failedTurns ?? 0) + 1;
1531
+ this.push({ type: "participant", participant });
1532
+ this.push({ type: "message.removed", id: draft.id });
1533
+ this.notice(`${participant.name}: turn failed: ${failure ?? "no result"}`, "error");
1534
+ this.postSystem(`${participant.name} could not answer: ${(failure ?? "no result").slice(0, 240)}`);
1535
+ runtime.log.error(`turn failed: ${failure}`);
1536
+ if (retry)
1537
+ this.closeRetry(retry, "the correction turn failed; nothing was posted");
1538
+ return null;
1539
+ }
1540
+ return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry);
1541
+ }
1542
+ finalizeTurn(participant, runtime, draft, result, durationMs, retry) {
1543
+ participant.status = "idle";
1544
+ this.push({ type: "participant", participant });
1545
+ const text = draft.text.trim();
1546
+ const cancelled = result.stopReason === "cancelled";
1547
+ if (cancelled)
1548
+ runtime.briefPending = runtime.briefPending ?? "previous turn was cancelled";
1549
+ if (!retry && text.toLowerCase() === REQUEST_BRIEF_MARKER) {
1550
+ this.push({ type: "message.removed", id: draft.id });
1551
+ if (runtime.briefRequestedAtSeq === runtime.lastSeenSeq) {
1552
+ this.notice(`${participant.name} asked for the brief twice on the same messages; treated as silent.`, "warn");
1553
+ return null;
1554
+ }
1555
+ runtime.briefRequestedAtSeq = runtime.lastSeenSeq;
1556
+ runtime.lastSeenSeq = runtime.turnStartSeq;
1557
+ runtime.briefPending = "requested by the agent";
1558
+ this.notice(`${participant.name} asked for the room brief (hidden turn); re-sending with the same messages.`, "info");
1559
+ this.requestTurn(participant.id);
1560
+ return null;
1561
+ }
1562
+ const pull = !retry ? text.match(SKILL_MARKER_PATTERN) : null;
1563
+ if (pull) {
1564
+ this.push({ type: "message.removed", id: draft.id });
1565
+ return this.handleSkillPull(participant, runtime, text, pull[1]);
1566
+ }
1567
+ if (!text || text.toLowerCase() === SILENT_MARKER) {
1568
+ this.push({ type: "message.removed", id: draft.id });
1569
+ if (retry) {
1570
+ this.closeRetry(retry, cancelled ? "the correction turn was stopped; nothing was posted" : "the agent withdrew the reply");
1571
+ if (cancelled)
1572
+ this.postSystem(`${participant.name} was stopped.`);
1573
+ }
1574
+ else {
1575
+ this.postSystem(cancelled ? `${participant.name} was stopped.` : `${participant.name} read the room and has nothing to add.`);
1576
+ }
1577
+ return null;
1578
+ }
1579
+ if (!(draft.toolCalls?.length) && ADAPTER_ERROR_PATTERN.test(text)) {
1580
+ this.push({ type: "message.removed", id: draft.id });
1581
+ participant.failedTurns = (participant.failedTurns ?? 0) + 1;
1582
+ participant.statusDetail = `agent error: ${text.replace(/\s+/g, " ").slice(0, 120)}${text.length > 120 ? "…" : ""}`;
1583
+ this.push({ type: "participant", participant });
1584
+ this.postSystem(`${participant.name}'s agent reported an error instead of a reply: ${text.slice(0, 200)}${text.length > 200 ? "…" : ""}`);
1585
+ runtime.log.warn(`adapter error text treated as failed turn: ${text.slice(0, 200)}`);
1586
+ if (retry)
1587
+ this.closeRetry(retry, "the agent reported an error instead of a corrected reply");
1588
+ return null;
1589
+ }
1590
+ const mentions = this.parseMentions(text);
1591
+ const corrections = this.referee(participant, text, mentions);
1592
+ if (corrections.length) {
1593
+ participant.violations = (participant.violations ?? 0) + corrections.length;
1594
+ runtime.log.info(`referee${retry ? " (after retry)" : ""}: ${corrections.join(" | ")}`);
1595
+ if (!retry && this.settings.refereeAction === "retry-hidden" && !cancelled) {
1596
+ this.push({ type: "message.removed", id: draft.id });
1597
+ participant.retries = (participant.retries ?? 0) + 1;
1598
+ this.push({ type: "participant", participant });
1599
+ const record = {
1600
+ id: randomUUID(),
1601
+ seq: ++this.seq,
1602
+ from: participant.id,
1603
+ fromName: participant.name,
1604
+ to: [],
1605
+ toNames: [],
1606
+ text: `Reply held back; correction requested: ${corrections.map((c) => c.replace(/^reminder:\s*/i, "")).join("; ")}`,
1607
+ ts: Date.now(),
1608
+ kind: "hidden",
1609
+ details: { original: text, corrections },
1610
+ };
1611
+ this.commit(record);
1612
+ const header = buildHeader(this.effectiveSettings(), this.personaOf(participant), this.roster(), this.hops, [
1613
+ "your last reply was not posted; see the correction below",
1614
+ ]);
1615
+ return {
1616
+ prompt: composeCorrectionPrompt({ header, originalText: text, corrections, personaName: participant.name }),
1617
+ original: text,
1618
+ corrections,
1619
+ record,
1620
+ };
1621
+ }
1622
+ runtime.headerNotes.push(...corrections);
1623
+ this.push({ type: "participant", participant });
1624
+ }
1625
+ const message = {
1626
+ ...draft,
1627
+ seq: ++this.seq,
1628
+ to: mentions.ids,
1629
+ toNames: mentions.names,
1630
+ text,
1631
+ streaming: false,
1632
+ stopReason: result.stopReason,
1633
+ usage: result.usage ?? null,
1634
+ durationMs,
1635
+ };
1636
+ this.commit(message);
1637
+ if (retry) {
1638
+ this.closeRetry(retry, corrections.length ? `corrected reply posted, but it still breaks: ${corrections.map((c) => c.replace(/^reminder:\s*/i, "")).join("; ")}` : "corrected reply posted");
1639
+ }
1640
+ if (cancelled) {
1641
+ this.postSystem(`${participant.name} was stopped mid-reply; the partial reply stays in the log.`);
1642
+ return null;
1643
+ }
1644
+ if (result.stopReason !== "end_turn") {
1645
+ this.postSystem(`${participant.name} stopped with ${result.stopReason}.`);
1646
+ }
1647
+ this.route(message);
1648
+ return null;
1649
+ }
1650
+ handleSkillPull(participant, runtime, original, name) {
1651
+ const record = {
1652
+ id: randomUUID(),
1653
+ seq: ++this.seq,
1654
+ from: participant.id,
1655
+ fromName: participant.name,
1656
+ to: [],
1657
+ toNames: [],
1658
+ text: `asked for skill "${name}"`,
1659
+ ts: Date.now(),
1660
+ kind: "hidden",
1661
+ details: { original, skill: name, via: "marker" },
1662
+ };
1663
+ const resolved = this.resolveAgentSkill(participant, name);
1664
+ if (!resolved.ok) {
1665
+ record.details = { ...record.details, outcome: `not delivered: ${resolved.reason}` };
1666
+ this.commit(record);
1667
+ runtime.headerNotes.push(`you asked for skill "${name}" but ${resolved.reason}`);
1668
+ runtime.log.info(`skills: pull of "${name}" refused: ${resolved.reason}`);
1669
+ return null;
1670
+ }
1671
+ if (runtime.skillPulledAtSeq === runtime.lastSeenSeq && runtime.skillPulledName === resolved.skill.name) {
1672
+ record.details = { ...record.details, outcome: "not delivered: asked twice on the same messages; treated as silent" };
1673
+ this.commit(record);
1674
+ this.notice(`${participant.name} asked for skill "${name}" twice on the same messages; treated as silent.`, "warn");
1675
+ return null;
1676
+ }
1677
+ runtime.skillPulledAtSeq = runtime.lastSeenSeq;
1678
+ runtime.skillPulledName = resolved.skill.name;
1679
+ runtime.pendingSkills.push({ name: resolved.skill.name, text: renderSkillBody(resolved.skill.body, ""), extraFiles: resolved.skill.extraFiles });
1680
+ runtime.lastSeenSeq = runtime.turnStartSeq;
1681
+ record.details = { ...record.details, outcome: "delivered in a hidden turn" };
1682
+ this.commit(record);
1683
+ runtime.log.info(`skills: "${resolved.skill.name}" pulled with the marker; re-running the turn`);
1684
+ this.requestTurn(participant.id);
1685
+ return null;
1686
+ }
1687
+ closeRetry(retry, outcome) {
1688
+ retry.record.details = { ...(retry.record.details ?? { original: retry.original, corrections: retry.corrections }), outcome };
1689
+ this.push({ type: "message", message: retry.record });
1690
+ }
1691
+ referee(participant, text, mentions) {
1692
+ const corrections = [];
1693
+ const unknown = [];
1694
+ for (const match of text.matchAll(MENTION_PATTERN)) {
1695
+ if (!this.findByName(match[1]) && !unknown.includes(match[1]))
1696
+ unknown.push(match[1]);
1697
+ }
1698
+ if (unknown.length) {
1699
+ const known = [...this.participants.values()].map((p) => p.name).join(", ");
1700
+ corrections.push(`reminder: ${unknown.map((u) => `@${u}`).join(", ")} ${unknown.length > 1 ? "are" : "is"} not in the room; participants are ${known}`);
1701
+ }
1702
+ if (mentions.ids.includes(participant.id)) {
1703
+ corrections.push("reminder: do not address yourself with @");
1704
+ }
1705
+ if (this.settings.maxSentences) {
1706
+ const sentences = countSentences(text);
1707
+ if (sentences > this.settings.maxSentences) {
1708
+ corrections.push(`reminder: keep replies to at most ${this.settings.maxSentences} sentences (last reply had ${sentences})`);
1709
+ }
1710
+ }
1711
+ return corrections;
1712
+ }
1713
+ onSessionUpdate(id, update) {
1714
+ const runtime = this.runtimes.get(id);
1715
+ const participant = this.participants.get(id);
1716
+ if (!runtime || !participant)
1717
+ return;
1718
+ const turn = runtime.turn;
1719
+ switch (update.sessionUpdate) {
1720
+ case "agent_message_chunk": {
1721
+ if (!turn)
1722
+ return;
1723
+ const u = update;
1724
+ let text = contentText(u.content);
1725
+ const messageId = u.messageId ?? null;
1726
+ if (messageId && !turn.sawMessageId && turn.message.text) {
1727
+ const notices = (turn.message.notices ??= []);
1728
+ notices.push(turn.message.text.trim());
1729
+ turn.message.text = "";
1730
+ this.push({ type: "message", message: turn.message });
1731
+ }
1732
+ else if (messageId !== turn.messageId && turn.message.text) {
1733
+ text = "\n\n" + text;
1734
+ }
1735
+ if (messageId)
1736
+ turn.sawMessageId = true;
1737
+ turn.messageId = messageId;
1738
+ turn.message.text += text;
1739
+ this.push({ type: "chunk", id: turn.message.id, text });
1740
+ return;
1741
+ }
1742
+ case "agent_thought_chunk": {
1743
+ if (!turn)
1744
+ return;
1745
+ const text = contentText(update.content);
1746
+ turn.message.thought = (turn.message.thought ?? "") + text;
1747
+ this.push({ type: "thought", id: turn.message.id, text });
1748
+ return;
1749
+ }
1750
+ case "tool_call":
1751
+ case "tool_call_update": {
1752
+ if (!turn)
1753
+ return;
1754
+ const u = update;
1755
+ const calls = (turn.message.toolCalls ??= []);
1756
+ let view = calls.find((c) => c.toolCallId === u.toolCallId);
1757
+ if (!view) {
1758
+ view = { toolCallId: u.toolCallId, title: u.title ?? u.name ?? "tool call" };
1759
+ calls.push(view);
1760
+ }
1761
+ if (u.title)
1762
+ view.title = u.title;
1763
+ if (u.kind !== undefined)
1764
+ view.kind = u.kind;
1765
+ if (u.status !== undefined)
1766
+ view.status = u.status;
1767
+ if (u.rawInput !== undefined)
1768
+ view.rawInput = u.rawInput;
1769
+ this.push({ type: "toolcall", id: turn.message.id, toolCall: view });
1770
+ return;
1771
+ }
1772
+ case "plan": {
1773
+ if (!turn)
1774
+ return;
1775
+ const entries = update.entries;
1776
+ turn.message.plan = entries;
1777
+ this.push({ type: "plan", id: turn.message.id, entries });
1778
+ return;
1779
+ }
1780
+ case "usage_update": {
1781
+ const u = update;
1782
+ participant.contextUsed = u.used;
1783
+ participant.contextSize = u.size;
1784
+ if (u.cost)
1785
+ participant.cost = { amount: u.cost.amount, currency: u.cost.currency };
1786
+ if (runtime.lastUsed > 0 && u.used < runtime.lastUsed * 0.7 && !runtime.briefPending) {
1787
+ runtime.briefPending = `context shrank from ${runtime.lastUsed} to ${u.used} tokens (compaction?)`;
1788
+ runtime.log.info(`usage dropped ${runtime.lastUsed} -> ${u.used}; brief scheduled`);
1789
+ }
1790
+ runtime.lastUsed = u.used;
1791
+ this.push({ type: "participant", participant });
1792
+ return;
1793
+ }
1794
+ case "config_option_update": {
1795
+ participant.configOptions = update.configOptions;
1796
+ this.applyConfigSummary(participant);
1797
+ this.push({ type: "participant", participant });
1798
+ return;
1799
+ }
1800
+ case "current_mode_update": {
1801
+ participant.mode = update.currentModeId;
1802
+ this.push({ type: "participant", participant });
1803
+ return;
1804
+ }
1805
+ case "available_commands_update":
1806
+ case "session_info_update":
1807
+ case "user_message_chunk":
1808
+ return;
1809
+ default:
1810
+ runtime.log.info(`unhandled session update: ${update.sessionUpdate}`);
1811
+ }
1812
+ }
1813
+ onPermissionRequest(id, params) {
1814
+ const participant = this.participants.get(id);
1815
+ const runtime = this.runtimes.get(id);
1816
+ if (runtime && this.isSkillToolCall(runtime, params)) {
1817
+ const option = params.options.find((o) => o.kind === "allow_always") ?? params.options.find((o) => o.kind === "allow_once");
1818
+ if (option) {
1819
+ runtime.log.info(`skills: ${SKILL_TOOL_NAME} permission auto-approved (${option.optionId})`);
1820
+ return Promise.resolve({ outcome: { outcome: "selected", optionId: option.optionId } });
1821
+ }
1822
+ }
1823
+ return new Promise((resolve) => {
1824
+ const entry = {
1825
+ key: randomUUID(),
1826
+ participantId: id,
1827
+ toolCall: params.toolCall,
1828
+ options: params.options,
1829
+ ts: Date.now(),
1830
+ resolve,
1831
+ };
1832
+ this.permissions.set(entry.key, entry);
1833
+ const { resolve: _r, ...view } = entry;
1834
+ this.push({ type: "permission", permission: view });
1835
+ this.notice(`${participant?.name ?? id} asks for permission: ${params.toolCall.title ?? params.toolCall.toolCallId}`, "info");
1836
+ });
1837
+ }
1838
+ onAgentExit(id, code, signal, agent) {
1839
+ const participant = this.participants.get(id);
1840
+ const runtime = this.runtimes.get(id);
1841
+ if (!participant)
1842
+ return;
1843
+ if (!runtime || runtime.agent !== agent)
1844
+ return;
1845
+ this.cancelPermissionsOf(id);
1846
+ if (this.closing || runtime.retiring) {
1847
+ this.forgetRuntime(id);
1848
+ return;
1849
+ }
1850
+ if (runtime) {
1851
+ runtime.log.warn(`agent process exited (code ${code}, signal ${signal})`);
1852
+ this.forgetRuntime(id);
1853
+ }
1854
+ if (participant.status !== "left") {
1855
+ if (runtime)
1856
+ this.restoredSeen.set(id, runtime.lastSeenSeq);
1857
+ participant.status = "offline";
1858
+ participant.statusDetail = `process exited (code ${code}${signal ? `, signal ${signal}` : ""}); reconnect to continue`;
1859
+ this.push({ type: "participant", participant });
1860
+ this.notice(`${participant.name}: agent process exited.`, "error");
1861
+ }
1862
+ }
1863
+ async openSession(agent, cwd, log, mcpServers = []) {
1864
+ try {
1865
+ return await agent.newSession(cwd, mcpServers);
1866
+ }
1867
+ catch (error) {
1868
+ if (!isAuthRequired(error) || !agent.authMethods.length)
1869
+ throw error;
1870
+ log.info(`session/new needs authentication: ${describeError(error)}`);
1871
+ }
1872
+ const failures = [];
1873
+ for (const method of agent.authMethods) {
1874
+ log.info(`authenticate with "${method.id}"${method.name ? ` (${method.name})` : ""}`);
1875
+ try {
1876
+ await agent.authenticate(method.id);
1877
+ return await agent.newSession(cwd, mcpServers);
1878
+ }
1879
+ catch (error) {
1880
+ failures.push(`${method.id}: ${describeError(error)}`);
1881
+ }
1882
+ }
1883
+ throw new Error(`authentication failed; log in with the agent's own CLI first. ${failures.join("; ")}`);
1884
+ }
1885
+ async applyConfig(runtime, participant, wanted) {
1886
+ const warnings = [];
1887
+ const plan = [
1888
+ ["model", wanted.model],
1889
+ ["thought_level", wanted.effort],
1890
+ ["mode", wanted.mode],
1891
+ ];
1892
+ for (const [category, value] of plan) {
1893
+ if (!value)
1894
+ continue;
1895
+ const option = participant.configOptions?.find((o) => o.category === category);
1896
+ if (!option || option.type !== "select") {
1897
+ if (category === "mode" && participant.modes?.some((m) => m.id === value)) {
1898
+ try {
1899
+ await runtime.agent.setMode(runtime.sessionId, value);
1900
+ participant.mode = value;
1901
+ }
1902
+ catch (error) {
1903
+ warnings.push(`mode: set_mode failed: ${describeError(error)}`);
1904
+ }
1905
+ continue;
1906
+ }
1907
+ warnings.push(`${category}: the agent exposes no such config option; left at its default`);
1908
+ continue;
1909
+ }
1910
+ const values = flattenOptions(option.options);
1911
+ if (!values.some((v) => v.value === value)) {
1912
+ warnings.push(`${category}: "${value}" is not offered (${values.map((v) => v.value).join(", ")}); left at ${String(option.currentValue)}`);
1913
+ continue;
1914
+ }
1915
+ if (option.currentValue === value)
1916
+ continue;
1917
+ try {
1918
+ participant.configOptions = await runtime.agent.setConfigOption(runtime.sessionId, option.id, value);
1919
+ }
1920
+ catch (error) {
1921
+ warnings.push(`${category}: set_config_option failed: ${error instanceof Error ? error.message : String(error)}`);
1922
+ }
1923
+ }
1924
+ const recipe = participant.agentType ? getRecipe(participant.agentType) : undefined;
1925
+ if (recipe?.bypassConfig && wanted.mode && wanted.mode === recipe.bypassMode) {
1926
+ for (const [optionId, value] of Object.entries(recipe.bypassConfig)) {
1927
+ const option = participant.configOptions?.find((o) => o.id === optionId);
1928
+ if (!option || option.type !== "select" || option.currentValue === value)
1929
+ continue;
1930
+ if (!flattenOptions(option.options).some((v) => v.value === value))
1931
+ continue;
1932
+ try {
1933
+ participant.configOptions = await runtime.agent.setConfigOption(runtime.sessionId, option.id, value);
1934
+ }
1935
+ catch (error) {
1936
+ warnings.push(`${optionId}: set_config_option failed: ${error instanceof Error ? error.message : String(error)}`);
1937
+ }
1938
+ }
1939
+ }
1940
+ this.applyConfigSummary(participant);
1941
+ return warnings;
1942
+ }
1943
+ applyConfigSummary(participant) {
1944
+ const pick = (category) => {
1945
+ const option = participant.configOptions?.find((o) => o.category === category);
1946
+ return option ? String(option.currentValue) : undefined;
1947
+ };
1948
+ participant.model = pick("model") ?? participant.model;
1949
+ participant.effort = pick("thought_level") ?? participant.effort;
1950
+ participant.mode = pick("mode") ?? participant.mode;
1951
+ }
1952
+ failStart(participant, error, fresh) {
1953
+ participant.statusDetail = error instanceof Error ? error.message : String(error);
1954
+ this.notice(`${participant.name}: failed to start: ${participant.statusDetail}`, "error");
1955
+ if (fresh) {
1956
+ participant.status = "error";
1957
+ this.push({ type: "participant", participant });
1958
+ this.participants.delete(participant.id);
1959
+ this.push({ type: "participant.removed", id: participant.id });
1960
+ }
1961
+ else {
1962
+ participant.status = "offline";
1963
+ this.push({ type: "participant", participant });
1964
+ }
1965
+ }
1966
+ cancelPermissionsOf(id) {
1967
+ for (const [key, entry] of this.permissions) {
1968
+ if (entry.participantId !== id)
1969
+ continue;
1970
+ this.permissions.delete(key);
1971
+ entry.resolve({ outcome: { outcome: "cancelled" } });
1972
+ this.push({ type: "permission.resolved", key, optionId: null });
1973
+ }
1974
+ }
1975
+ personaOf(participant) {
1976
+ return { name: participant.name, tagline: participant.tagline ?? "", role: participant.role ?? "" };
1977
+ }
1978
+ roster() {
1979
+ return [...this.participants.values()]
1980
+ .filter((p) => p.status !== "left")
1981
+ .map((p) => ({ name: p.name, kind: p.kind, vendor: p.agentVendor ?? p.agentLabel, tagline: p.tagline || undefined }));
1982
+ }
1983
+ parseMentions(text) {
1984
+ const ids = [];
1985
+ const names = [];
1986
+ for (const match of text.matchAll(MENTION_PATTERN)) {
1987
+ const participant = this.findByName(match[1]);
1988
+ if (participant && !ids.includes(participant.id)) {
1989
+ ids.push(participant.id);
1990
+ names.push(participant.name);
1991
+ }
1992
+ }
1993
+ return { ids, names };
1994
+ }
1995
+ findByName(name) {
1996
+ const lower = name.toLowerCase();
1997
+ for (const p of this.participants.values())
1998
+ if (p.name.toLowerCase() === lower)
1999
+ return p;
2000
+ return undefined;
2001
+ }
2002
+ postSystem(text, audience) {
2003
+ const message = {
2004
+ id: randomUUID(),
2005
+ seq: ++this.seq,
2006
+ from: "system",
2007
+ fromName: "",
2008
+ to: [],
2009
+ toNames: [],
2010
+ text,
2011
+ ts: Date.now(),
2012
+ kind: "system",
2013
+ };
2014
+ if (audience)
2015
+ message.audience = audience;
2016
+ this.commit(message);
2017
+ }
2018
+ async setDir(dir) {
2019
+ const next = resolve(String(dir ?? "").trim());
2020
+ if (!dir.trim())
2021
+ throw new Error("working directory is required");
2022
+ if (!existsSync(next) || !statSync(next).isDirectory())
2023
+ throw new Error(`working directory does not exist: ${next}`);
2024
+ if (next === this.dir)
2025
+ return { dir: next, restarted: [] };
2026
+ const previous = this.dir;
2027
+ this.dir = next;
2028
+ const restarted = [];
2029
+ for (const id of [...this.runtimes.keys()]) {
2030
+ const p = this.participants.get(id);
2031
+ if (!p)
2032
+ continue;
2033
+ await this.retireRuntime(id);
2034
+ try {
2035
+ await this.reconnect(id, { mode: "replay", reason: "the room moved to another folder" });
2036
+ restarted.push(p.name);
2037
+ }
2038
+ catch (error) {
2039
+ this.notice(`${p.name} could not be restarted in the new folder: ${describeError(error)}`, "error");
2040
+ }
2041
+ }
2042
+ for (const p of this.participants.values()) {
2043
+ if (p.kind === "agent" && !this.runtimes.has(p.id))
2044
+ p.sessionId = undefined;
2045
+ }
2046
+ this.push(this.roomEvent());
2047
+ this.postSystem(`The room moved to ${next}${restarted.length ? `; ${restarted.join(", ")} restarted there` : ""}.`);
2048
+ this.log.info(`working directory: ${previous} -> ${next}`);
2049
+ return { dir: next, restarted };
2050
+ }
2051
+ roomEvent() {
2052
+ return {
2053
+ type: "room",
2054
+ hopLimit: this.hopLimit,
2055
+ hops: this.hops,
2056
+ settings: this.settings,
2057
+ customRulesText: this.renderRuleReferences(this.settings.customRules),
2058
+ focused: this.focused,
2059
+ name: this.name,
2060
+ dir: this.dir,
2061
+ };
2062
+ }
2063
+ notice(text, level) {
2064
+ if (level === "error")
2065
+ this.log.error(text);
2066
+ else if (level === "warn")
2067
+ this.log.warn(text);
2068
+ else
2069
+ this.log.info(text);
2070
+ this.push({ type: "notice", text, level, ts: Date.now() });
2071
+ }
2072
+ push(event) {
2073
+ this.emit("event", event);
2074
+ }
2075
+ }
2076
+ function isAuthRequired(error) {
2077
+ if (error instanceof RemoteError)
2078
+ return error.rpc.code === -32000 || /auth/i.test(error.rpc.message);
2079
+ return error instanceof Error && /auth/i.test(error.message);
2080
+ }
2081
+ function describeError(error) {
2082
+ return error instanceof Error ? error.message : String(error);
2083
+ }
2084
+ function contentText(block) {
2085
+ if (block.type === "text")
2086
+ return block.text;
2087
+ return `[${block.type}]`;
2088
+ }
2089
+ function flattenOptions(options) {
2090
+ if (!options)
2091
+ return [];
2092
+ const out = [];
2093
+ for (const entry of options) {
2094
+ if ("options" in entry)
2095
+ out.push(...entry.options);
2096
+ else
2097
+ out.push(entry);
2098
+ }
2099
+ return out;
2100
+ }
2101
+ function delay(ms) {
2102
+ return new Promise((resolve) => setTimeout(resolve, ms));
2103
+ }
2104
+ function normalizeSkillList(list) {
2105
+ if (!list)
2106
+ return undefined;
2107
+ const out = [];
2108
+ for (const raw of list) {
2109
+ const name = String(raw).trim();
2110
+ if (!SKILL_NAME_PATTERN.test(name))
2111
+ continue;
2112
+ if (!out.some((s) => s.toLowerCase() === name.toLowerCase()))
2113
+ out.push(name);
2114
+ }
2115
+ return out.length ? out : undefined;
2116
+ }
2117
+ function shuffle(items) {
2118
+ const out = [...items];
2119
+ for (let i = out.length - 1; i > 0; i--) {
2120
+ const j = Math.floor(Math.random() * (i + 1));
2121
+ [out[i], out[j]] = [out[j], out[i]];
2122
+ }
2123
+ return out;
2124
+ }