viberoom 0.5.8 → 0.6.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.
package/dist/room.js CHANGED
@@ -3,20 +3,25 @@ import { EventEmitter } from "node:events";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { writeFileAtomic } from "./atomic.js";
6
- import { NOTES_ONLY_PROMPT, NOTES_REQUEST, crossedThreshold, extractNotes, isBareContextFullError, isContextFullError, overThreshold, visibleChunk } from "./context.js";
6
+ import { NOTES_ONLY_PROMPT, NOTES_REQUEST, crossedThreshold, emptyUsageReport, extractNotes, isBareContextFullError, isContextFullError, looksCompacted, overThreshold, visibleChunk } from "./context.js";
7
+ import { formatDuration } from "./duration.js";
7
8
  import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
8
9
  import { saveImages } from "./files.js";
10
+ import { agentReadableWindow, resolveQuotes } from "./quotes.js";
9
11
  import { join, resolve } from "node:path";
10
12
  import { AcpAgent } from "./acp-client.js";
11
13
  import { RemoteError } from "./jsonrpc.js";
12
14
  import { getRecipe, listRecipes } from "./recipes.js";
15
+ import { classifyStartFailure } from "./agent-health.js";
16
+ import { legacyRowTone } from "./rows.js";
13
17
  import { composeSkillBlock, skillPull, SKILL_TOOL_NAME } from "./persona.js";
14
18
  import { BUILTIN_AUTHOR, parseSkillInvocation, renderSkillBody, SKILL_NAME_PATTERN, } from "./skills.js";
15
- import { BRIEF_AFFECTING_SETTINGS, DEFAULT_ROOM_SETTINGS, REQUEST_BRIEF_MARKER, SILENT_MARKER, buildBrief, buildHeader, composeCorrectionPrompt, composePrompt, countSentences, ensureDir, } from "./persona.js";
19
+ import { templateId } from "./templates.js";
20
+ import { applyVibemateChanges, diffSettings, lintRoomDesign, ruleLines } from "./room-design.js";
21
+ import { BRIEF_AFFECTING_SETTINGS, AGENT_SETTINGS, coerceSetting, describeSettings, DEFAULT_ROOM_SETTINGS, ROOM_SETTINGS_SPEC, REQUEST_BRIEF_MARKER, NAME_PATTERN, SILENT_MARKER, buildBrief, buildHeader, composeCorrectionPrompt, composePrompt, countSentences, ensureDir, } from "./persona.js";
16
22
  import { Transcript } from "./log.js";
17
23
  const SKILL_TOOL_READY_MS = 5000;
18
24
  const COLORS = ["#6d5dfc", "#16a34a", "#d97706", "#dc2626", "#0891b2", "#be185d", "#4d7c0f", "#7c3aed"];
19
- const NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}_-]{0,23}$/u;
20
25
  const MENTION_PATTERN = /@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
21
26
  const RULE_REF_TOKEN = /@\{p:([^}]+)\}/g;
22
27
  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;
@@ -45,6 +50,8 @@ export class Room extends EventEmitter {
45
50
  runtimes = new Map();
46
51
  drafts = new Map();
47
52
  permissions = new Map();
53
+ proposals = new Map();
54
+ proposalPlans = new Map();
48
55
  optionCache;
49
56
  log;
50
57
  colorIndex = 0;
@@ -90,6 +97,11 @@ export class Room extends EventEmitter {
90
97
  for (const line of lines) {
91
98
  try {
92
99
  const message = JSON.parse(line);
100
+ if (message.kind === "system" && !message.details?.tone) {
101
+ const tone = legacyRowTone(message.text);
102
+ if (tone)
103
+ message.details = { ...(message.details ?? {}), tone };
104
+ }
93
105
  this.messages.push(message);
94
106
  if (message.seq > this.seq)
95
107
  this.seq = message.seq;
@@ -209,6 +221,19 @@ export class Room extends EventEmitter {
209
221
  appendFileSync(this.historyPath(), JSON.stringify(message) + "\n");
210
222
  this.push({ type: "message", message });
211
223
  }
224
+ messagesWithLiveDrafts() {
225
+ const live = [...this.runtimes.values()].filter((r) => r.turn?.published).map((r) => r.turn.message);
226
+ if (!live.length)
227
+ return [...this.messages];
228
+ const out = [...this.messages];
229
+ for (const draft of live) {
230
+ let at = out.length;
231
+ while (at > 0 && out[at - 1].ts > draft.ts)
232
+ at--;
233
+ out.splice(at, 0, draft);
234
+ }
235
+ return out;
236
+ }
212
237
  get humanName() {
213
238
  return this.settings.humanName;
214
239
  }
@@ -229,8 +254,9 @@ export class Room extends EventEmitter {
229
254
  settings: this.settings,
230
255
  customRulesText: this.renderRuleReferences(this.settings.customRules),
231
256
  participants: [...this.participants.values()],
232
- messages: [...this.messages, ...this.drafts.values()],
257
+ messages: this.messagesWithLiveDrafts(),
233
258
  permissions: [...this.permissions.values()].map(({ resolve: _r, ...p }) => p),
259
+ proposals: [...this.proposals.values()],
234
260
  recipes: listRecipes().map(({ build: _b, ...r }) => r),
235
261
  lastMessageAt: last?.ts ?? this.createdAt,
236
262
  };
@@ -292,13 +318,14 @@ export class Room extends EventEmitter {
292
318
  unstaffed() {
293
319
  return [...this.participants.values()].filter((p) => p.kind === "agent" && p.status === "unstaffed");
294
320
  }
295
- postHumanMessage(text, images = []) {
321
+ postHumanMessage(text, images = [], quotes = []) {
296
322
  const waiting = this.unstaffed();
297
323
  if (waiting.length)
298
324
  throw new Error(`${waiting.map((p) => p.name).join(", ")} ${waiting.length === 1 ? "has" : "have"} no coding agent yet: summon ${waiting.length === 1 ? "it" : "them"} from the roster to start the conversation`);
299
325
  const trimmed = text.trim();
300
- if (!trimmed && !images.length)
326
+ if (!trimmed && !images.length && !quotes.length)
301
327
  throw new Error("empty message");
328
+ const quoted = quotes.length ? resolveQuotes(quotes, this.messages) : [];
302
329
  const attachments = images.length ? saveImages(ensureDir(this.filesDir()), images) : [];
303
330
  this.humanTypingUntil = 0;
304
331
  const human = this.participants.get("human");
@@ -315,6 +342,8 @@ export class Room extends EventEmitter {
315
342
  };
316
343
  if (attachments.length)
317
344
  message.images = attachments;
345
+ if (quoted.length)
346
+ message.quotes = quoted;
318
347
  this.decorateHumanMessage(message);
319
348
  human.turns += 1;
320
349
  if (this.focused) {
@@ -466,6 +495,7 @@ export class Room extends EventEmitter {
466
495
  const memory = !!options.memory;
467
496
  const replay = memory ? Math.max(0, options.replay ?? this.settings.replayAfterRestart) : 0;
468
497
  const withNotes = memory && !!participant.notes;
498
+ const why = options.reason ?? "its context was cleared";
469
499
  this.dropScheduledTurn(id);
470
500
  this.cancelPermissionsOf(id);
471
501
  if (this.speaking === id)
@@ -476,18 +506,38 @@ export class Room extends EventEmitter {
476
506
  this.restoredSeen.set(id, this.seq);
477
507
  this.push({ type: "participant", participant });
478
508
  if (!online) {
479
- participant.statusDetail = withNotes ? "its context was cleared; a reconnect starts it with its notes" : "its context was cleared; a reconnect starts it with an empty head";
480
- this.postSystem(withNotes ? `${participant.name} was respawned while offline: it comes back with its notes.` : `${participant.name} was respawned while offline: it comes back knowing nothing from before.`);
509
+ participant.statusDetail = withNotes ? `${why}; a reconnect starts it with its notes` : `${why}; a reconnect starts it with an empty head`;
510
+ const comesBack = withNotes ? "it comes back with its notes" : "it comes back knowing nothing from before";
511
+ this.postSystem(options.reason ? `${participant.name} was respawned while offline (${why}): ${comesBack}.` : `${participant.name} was respawned while offline: ${comesBack}.`);
481
512
  this.push({ type: "participant", participant });
482
513
  this.log.info(`respawn of ${participant.name} (offline): stored session dropped`);
483
514
  return participant;
484
515
  }
485
516
  await this.reconnect(id, memory
486
- ? { mode: "replay", replay, memory: withNotes, reason: `its context was cleared; it comes back with ${withNotes ? "its notes and " : ""}the last ${replay} messages` }
487
- : { mode: "replay", replay: 0, reason: "its context was cleared, it remembers nothing from before" });
517
+ ? { mode: "replay", replay, memory: withNotes, reason: `${why}; it comes back with ${withNotes ? "its notes and " : ""}the last ${replay} messages` }
518
+ : { mode: "replay", replay: 0, reason: `${why}, it remembers nothing from before` });
488
519
  this.log.info(`respawn of ${participant.name}: fresh session, ${memory ? `${withNotes ? "notes + " : ""}replay ${replay}` : "no replay"}`);
489
520
  return participant;
490
521
  }
522
+ async restartWithPersona(id, patch) {
523
+ const participant = this.participants.get(id);
524
+ if (!participant || participant.kind !== "agent")
525
+ throw new Error("no such agent");
526
+ const runtime = this.runtimes.get(id);
527
+ const online = !!runtime && runtime.agent.alive;
528
+ if (online && runtime.turnActive)
529
+ throw new Error(`${participant.name} is in the middle of a reply; try again when it is idle`);
530
+ if (online) {
531
+ try {
532
+ await this.takeNotes(id);
533
+ }
534
+ catch (error) {
535
+ this.log.warn(`${participant.name}: notes before the restart failed (${describeError(error)}); it restarts with the notes it had`);
536
+ }
537
+ }
538
+ this.updatePersona(id, patch);
539
+ return this.respawnAgent(id, { memory: true, reason: "its role changed" });
540
+ }
491
541
  updateNotes(id, notes) {
492
542
  const participant = this.participants.get(id);
493
543
  if (!participant || participant.kind !== "agent")
@@ -510,7 +560,15 @@ export class Room extends EventEmitter {
510
560
  throw new Error(`${participant.name} is in the middle of a reply; try again when it is idle`);
511
561
  const header = buildHeader(this.effectiveSettings(), this.personaOf(participant), this.roster(), this.hops, ["hidden turn: notes only, nothing is posted"]);
512
562
  runtime.log.info("notes: hidden turn");
513
- await this.executeTurn(participant, runtime, [{ type: "text", text: `${header}\n\n${NOTES_ONLY_PROMPT}` }], null, true);
563
+ participant.notesTurn = true;
564
+ this.push({ type: "participant", participant });
565
+ try {
566
+ await this.executeTurn(participant, runtime, [{ type: "text", text: `${header}\n\n${NOTES_ONLY_PROMPT}` }], null, true);
567
+ }
568
+ finally {
569
+ participant.notesTurn = undefined;
570
+ this.push({ type: "participant", participant });
571
+ }
514
572
  return participant;
515
573
  }
516
574
  keepNotes(participant, runtime, notes, via) {
@@ -532,14 +590,14 @@ export class Room extends EventEmitter {
532
590
  participant.status = "error";
533
591
  participant.statusDetail = "its context filled up again right after a respawn; it needs you (respawn it by hand, with fewer replayed messages)";
534
592
  this.push({ type: "participant", participant });
535
- this.postSystem(`${participant.name} ran out of context again (${detail.slice(0, 120)}); it was respawned once already and now needs you.`, "human");
593
+ this.postSystem(`${participant.name} ran out of context again (${detail.slice(0, 120)}); it was respawned once already and now needs you.`, "human", false, { tone: "error" });
536
594
  runtime.log.warn(`context full again within 10 minutes: no automatic respawn`);
537
595
  return;
538
596
  }
539
597
  participant.autoRespawnAt = Date.now();
540
598
  this.push({ type: "participant", participant });
541
599
  const memory = !!participant.notes;
542
- this.postSystem(`${participant.name} ran out of context (${detail.slice(0, 120)}); it is respawned ${memory ? `with its notes and the last ${this.settings.replayAfterRestart} messages` : `with the last ${this.settings.replayAfterRestart} messages (it had no notes)`}.`, "human");
600
+ this.postSystem(`${participant.name} ran out of context (${detail.slice(0, 120)}); it is respawned ${memory ? `with its notes and the last ${this.settings.replayAfterRestart} messages` : `with the last ${this.settings.replayAfterRestart} messages (it had no notes)`}.`, "human", false, { tone: "error" });
543
601
  runtime.log.warn(`context full: ${detail}; respawn with ${memory ? "notes" : "no notes"}`);
544
602
  setImmediate(() => {
545
603
  void (memory ? this.respawnAgent(participant.id, { memory: true }) : this.reconnectAfterFull(participant.id)).catch((error) => this.notice(`${participant.name}: respawn after a full context failed: ${describeError(error)}`, "error"));
@@ -595,7 +653,7 @@ export class Room extends EventEmitter {
595
653
  }
596
654
  this.focused = true;
597
655
  this.push(this.roomEvent());
598
- this.postSystem(`Hush: ${stopped ? `${stopped} repl${stopped > 1 ? "ies" : "y"} stopped; ` : ""}everyone waits until ${this.humanName} writes again.`);
656
+ this.postSystem(`Hush: ${stopped ? `${stopped} repl${stopped > 1 ? "ies" : "y"} stopped; ` : ""}everyone waits until ${this.humanName} writes again.`, undefined, false, { tone: "hush" });
599
657
  }
600
658
  rename(name) {
601
659
  const trimmed = name.trim();
@@ -612,124 +670,20 @@ export class Room extends EventEmitter {
612
670
  updateSettings(patch) {
613
671
  const next = { ...this.settings };
614
672
  const changed = [];
615
- const setNumber = (key, min, max) => {
616
- if (patch[key] === undefined)
617
- return;
618
- const value = Number(patch[key]);
619
- if (!Number.isInteger(value) || value < min || value > max)
620
- throw new Error(`${key} must be an integer between ${min} and ${max}`);
621
- if (value !== next[key]) {
622
- next[key] = value;
623
- changed.push(key);
673
+ let unknownRefs = [];
674
+ for (const key of Object.keys(ROOM_SETTINGS_SPEC)) {
675
+ if (patch[key] === undefined || ROOM_SETTINGS_SPEC[key].kind === "own-path")
676
+ continue;
677
+ let value = coerceSetting(key, patch[key]);
678
+ if (key === "customRules") {
679
+ const resolved = this.resolveRuleReferences(value);
680
+ unknownRefs = resolved.unknown;
681
+ value = resolved.stored;
624
682
  }
625
- };
626
- setNumber("hopLimit", 0, 10_000);
627
- setNumber("fullBriefEveryTurns", 1, 10_000);
628
- setNumber("fullBriefEveryTokens", 1000, 10_000_000);
629
- setNumber("replayAfterRestart", 0, 200);
630
- setNumber("backlogCap", 1, 1000);
631
- if (patch.replyDelay !== undefined) {
632
- const value = Number(patch.replyDelay);
633
- if (!Number.isFinite(value) || value < 0 || value > 120)
634
- throw new Error("replyDelay must be between 0 and 120 seconds");
635
- next.replyDelay = value;
636
- }
637
- const setText = (key, max) => {
638
- if (patch[key] === undefined)
639
- return;
640
- const value = String(patch[key]).slice(0, max);
641
- if (value !== next[key]) {
683
+ if (JSON.stringify(value) !== JSON.stringify(next[key])) {
642
684
  next[key] = value;
643
685
  changed.push(key);
644
686
  }
645
- };
646
- setText("topic", 2000);
647
- setText("emoji", 8);
648
- setText("humanDescription", 200);
649
- let unknownRefs = [];
650
- if (patch.customRules !== undefined) {
651
- const resolved = this.resolveRuleReferences(String(patch.customRules).slice(0, 4000));
652
- unknownRefs = resolved.unknown;
653
- if (resolved.stored !== next.customRules) {
654
- next.customRules = resolved.stored;
655
- changed.push("customRules");
656
- }
657
- }
658
- if (patch.humanDescriptionMode !== undefined) {
659
- const mode = String(patch.humanDescriptionMode);
660
- if (mode !== "inherit" && mode !== "override" && mode !== "append" && mode !== "none")
661
- throw new Error("humanDescriptionMode must be inherit, override, append or none");
662
- if (mode !== next.humanDescriptionMode) {
663
- next.humanDescriptionMode = mode;
664
- changed.push("humanDescriptionMode");
665
- }
666
- }
667
- if (patch.refereeAction !== undefined) {
668
- const action = String(patch.refereeAction);
669
- if (action !== "next-header" && action !== "retry-hidden")
670
- throw new Error("refereeAction must be next-header or retry-hidden");
671
- if (action !== next.refereeAction) {
672
- next.refereeAction = action;
673
- changed.push("refereeAction");
674
- }
675
- }
676
- if (patch.turnTaking !== undefined) {
677
- const mode = String(patch.turnTaking);
678
- if (mode !== "parallel" && mode !== "one-at-a-time")
679
- throw new Error("turnTaking must be parallel or one-at-a-time");
680
- if (mode !== next.turnTaking) {
681
- next.turnTaking = mode;
682
- changed.push("turnTaking");
683
- }
684
- }
685
- if (patch.agentsWakeEachOther !== undefined) {
686
- const on = patch.agentsWakeEachOther === true || patch.agentsWakeEachOther === "true";
687
- if (on !== next.agentsWakeEachOther) {
688
- next.agentsWakeEachOther = on;
689
- changed.push("agentsWakeEachOther");
690
- }
691
- }
692
- if (patch.waitWhileHumanTypes !== undefined) {
693
- const on = patch.waitWhileHumanTypes === true || patch.waitWhileHumanTypes === "true";
694
- if (on !== next.waitWhileHumanTypes) {
695
- next.waitWhileHumanTypes = on;
696
- changed.push("waitWhileHumanTypes");
697
- }
698
- }
699
- if (patch.language !== undefined) {
700
- const raw = String(patch.language).trim();
701
- const language = !raw || raw === "follow-human" ? { mode: "follow-human" } : { mode: "fixed", language: raw };
702
- if (JSON.stringify(language) !== JSON.stringify(next.language)) {
703
- next.language = language;
704
- changed.push("language");
705
- }
706
- }
707
- if (patch.tools !== undefined) {
708
- const tools = String(patch.tools);
709
- if (tools !== "on-request" && tools !== "never")
710
- throw new Error("tools must be on-request or never");
711
- if (tools !== next.tools) {
712
- next.tools = tools;
713
- changed.push("tools");
714
- }
715
- }
716
- if (patch.maxSentences !== undefined) {
717
- const value = patch.maxSentences === null || patch.maxSentences === "" ? null : Number(patch.maxSentences);
718
- if (value !== null && (!Number.isInteger(value) || value < 1 || value > 100))
719
- throw new Error("maxSentences must be 1-100 or empty");
720
- if (value !== next.maxSentences) {
721
- next.maxSentences = value;
722
- changed.push("maxSentences");
723
- }
724
- }
725
- for (const key of ["headerRules", "showVendorInRoster"]) {
726
- if (patch[key] !== undefined) {
727
- const value = patch[key] === true || patch[key] === "true";
728
- if (value !== next[key]) {
729
- next[key] = value;
730
- changed.push(key);
731
- }
732
- }
733
687
  }
734
688
  this.settings = next;
735
689
  this.push(this.roomEvent());
@@ -987,20 +941,25 @@ export class Room extends EventEmitter {
987
941
  const spec = recipe.build({ model: launch.model });
988
942
  const transcript = new Transcript(join(this.dataDir, "transcripts"), name);
989
943
  log.info(`spawning ${spec.command} ${spec.args.join(" ")} (cwd ${cwd}); transcript ${transcript.path}`);
944
+ const stderrTail = [];
990
945
  let agent;
991
946
  try {
992
947
  agent = new AcpAgent({ ...spec, cwd }, {
993
948
  onSessionUpdate: (_sessionId, update) => this.onSessionUpdate(id, update),
994
949
  onPermissionRequest: (params) => this.onPermissionRequest(id, params),
995
- onStderr: (line) => log.info(`stderr: ${line}`),
950
+ onStderr: (line) => {
951
+ log.info(`stderr: ${line}`);
952
+ stderrTail.push(line);
953
+ if (stderrTail.length > 10)
954
+ stderrTail.shift();
955
+ },
996
956
  onExit: (code, signal) => this.onAgentExit(id, code, signal, agent),
997
957
  onRaw: (direction, message) => transcript.record(direction, message),
998
958
  onProtocolError: (text) => log.warn(`protocol: ${text}`),
999
959
  });
1000
960
  }
1001
961
  catch (error) {
1002
- this.failStart(participant, error, fresh);
1003
- throw error;
962
+ throw this.failStart(participant, error, fresh, stderrTail);
1004
963
  }
1005
964
  try {
1006
965
  const init = await agent.initialize({ name: "viberoom", version: "0.2.0" });
@@ -1093,6 +1052,7 @@ export class Room extends EventEmitter {
1093
1052
  this.notice(`${name}: ${w}`, "warn");
1094
1053
  participant.status = "idle";
1095
1054
  participant.statusDetail = undefined;
1055
+ participant.trouble = undefined;
1096
1056
  this.push({ type: "participant", participant });
1097
1057
  if (fresh) {
1098
1058
  const detail = this.settings.showVendorInRoster ? `${recipe.label}${participant.model ? `, model ${participant.model}` : ""}` : "agent";
@@ -1124,8 +1084,7 @@ export class Room extends EventEmitter {
1124
1084
  catch (error) {
1125
1085
  agent.kill();
1126
1086
  this.forgetRuntime(id);
1127
- this.failStart(participant, error, fresh);
1128
- throw error;
1087
+ throw this.failStart(participant, error, fresh, stderrTail);
1129
1088
  }
1130
1089
  }
1131
1090
  async removeParticipant(id) {
@@ -1169,11 +1128,20 @@ export class Room extends EventEmitter {
1169
1128
  const participant = this.participants.get(id);
1170
1129
  if (!runtime || !participant)
1171
1130
  throw new Error("no such agent");
1172
- if (!runtime.turnActive)
1173
- return;
1174
- runtime.agent.cancel(runtime.sessionId);
1175
- this.cancelPermissionsOf(id);
1176
- this.notice(`${participant.name}: stop requested.`, "info");
1131
+ if (runtime.turnActive) {
1132
+ runtime.agent.cancel(runtime.sessionId);
1133
+ this.cancelPermissionsOf(id);
1134
+ this.notice(`${participant.name}: stop requested.`, "info");
1135
+ return "turn";
1136
+ }
1137
+ if (runtime.pendingTurn || runtime.delayTimer || this.floorQueue.includes(id)) {
1138
+ this.dropScheduledTurn(id);
1139
+ this.notice(`${participant.name}: stopped before it began; the turn is dropped.`, "info");
1140
+ this.postSystem(`${participant.name} was stopped by ${this.humanName} before it began.`, undefined, false, { tone: "attention" });
1141
+ return "queued";
1142
+ }
1143
+ this.notice(`${participant.name}: nothing to stop, it is not writing.`, "info");
1144
+ return "nothing";
1177
1145
  }
1178
1146
  async setConfig(id, configId, value) {
1179
1147
  const runtime = this.runtimes.get(id);
@@ -1260,7 +1228,7 @@ export class Room extends EventEmitter {
1260
1228
  if (wanted.length) {
1261
1229
  if (this.hops >= this.hopLimit) {
1262
1230
  const who = agentTargets.length ? message.toNames.join(", ") : "the other vibemates";
1263
- this.postSystem(`Hop limit ${this.hopLimit} reached: ${who} will not be prompted until ${this.humanName} writes again.`);
1231
+ this.postSystem(`Hop limit ${this.hopLimit} reached: ${who} will not be prompted until ${this.humanName} writes again.`, undefined, false, { tone: "attention" });
1264
1232
  }
1265
1233
  else {
1266
1234
  this.hops += 1;
@@ -1294,6 +1262,256 @@ export class Room extends EventEmitter {
1294
1262
  return undefined;
1295
1263
  return { items, channel, canCreate: channel === "tool" };
1296
1264
  }
1265
+ skillsForDesign(participantId) {
1266
+ if (!this.skills)
1267
+ return undefined;
1268
+ const runtime = this.runtimes.get(participantId);
1269
+ const channel = runtime?.skillChannel === "tool" ? "tool" : "marker";
1270
+ return {
1271
+ library: this.skills.library.list().filter((s) => !s.problems.length && !s.draft && s.agentInvocable).map((s) => ({ name: s.name, description: s.description })),
1272
+ channel,
1273
+ canCreate: channel === "tool",
1274
+ };
1275
+ }
1276
+ agentInRoom(participantId) {
1277
+ const participant = this.participants.get(participantId);
1278
+ if (!participant || !this.runtimes.has(participantId))
1279
+ throw new Error("this agent is not in the room any more");
1280
+ return participant;
1281
+ }
1282
+ describeRoomForAgent(participantId) {
1283
+ const participant = this.agentInRoom(participantId);
1284
+ const shape = this.templateOf();
1285
+ const skills = this.skills ? this.skills.library.list().filter((s) => !s.problems.length && !s.draft).map((s) => ({ name: s.name, description: s.description })) : [];
1286
+ const templates = this.skills ? this.skills.templates.list().map((t) => ({ id: t.id, name: t.name, builtin: !!t.builtin })) : [];
1287
+ return {
1288
+ room: { name: this.settings.name, topic: this.settings.topic, emoji: this.settings.emoji, dir: this.dir },
1289
+ human: this.settings.humanName,
1290
+ you: participant.name,
1291
+ settings: describeSettings({ ...this.settings, customRules: this.renderRuleReferences(this.settings.customRules) }),
1292
+ rules: ruleLines(this.renderRuleReferences(this.settings.customRules)),
1293
+ vibemates: shape.vibemates.map((v) => {
1294
+ const role = v.role ?? "";
1295
+ const own = v.name === participant.name;
1296
+ return {
1297
+ name: v.name,
1298
+ tagline: v.tagline ?? "",
1299
+ ...(own ? { role } : { rolePrivate: true, roleLength: role.length }),
1300
+ avatar: v.avatar ?? "",
1301
+ skills: v.skills ?? [],
1302
+ agentType: v.agentType,
1303
+ };
1304
+ }),
1305
+ skills,
1306
+ templates,
1307
+ yourBrief: buildBrief(this.settings, this.personaOf(participant), this.roster(), undefined, this.skillsForPrompt(participant, this.runtimes.get(participant.id))),
1308
+ howTo: "Settings are proposed by key with the values above; rules are one per line in customRules; a vibemate is { name, tagline, role, avatar, skills }. Another vibemate's role is private: you learn only that it has one and how long it is, and you may still propose a new one, which the human reads in full on the card. Check a design with lint_room_design, then create_template (a file for the human to pick) or propose_room_changes (a card the human applies).",
1309
+ };
1310
+ }
1311
+ lintDesignForAgent(participantId, kind, design) {
1312
+ this.agentInRoom(participantId);
1313
+ const result = lintRoomDesign(design, { ...this.designContext(kind), skills: this.skillsForDesign(participantId) });
1314
+ return { ok: !result.errors.length, errors: result.errors.map((e) => e.message), warnings: result.warnings.map((w) => w.message), preview: result.preview };
1315
+ }
1316
+ readMessageForAgent(participantId, seq, around) {
1317
+ const participant = this.agentInRoom(participantId);
1318
+ const window = agentReadableWindow(this.messages, seq, around);
1319
+ if (!window)
1320
+ throw new Error(`no message #${seq} in this room (or it is one the vibemates do not see)`);
1321
+ const view = (m) => ({
1322
+ seq: m.seq,
1323
+ from: m.fromName,
1324
+ to: m.toNames,
1325
+ at: new Date(m.ts).toISOString(),
1326
+ text: m.text,
1327
+ ...(m.kind === "system" ? { kind: "system" } : {}),
1328
+ ...(m.edited ? { edited: true } : {}),
1329
+ ...(m.images && m.images.length ? { images: m.images.map((a, i) => ({ ref: `#${m.seq}.${a.n ?? i + 1}`, name: a.name, path: this.imagePath(a) })) } : {}),
1330
+ ...(m.quotes && m.quotes.length ? { quotes: m.quotes.map((q) => ({ n: q.n, seq: q.seq, from: q.fromName, text: q.text })) } : {}),
1331
+ });
1332
+ this.log.info(`read_message: ${participant.name} read #${seq}${around ? ` (around ${around})` : ""}`);
1333
+ return { message: view(window.message), before: window.before.map(view), after: window.after.map(view) };
1334
+ }
1335
+ designContext(kind) {
1336
+ return {
1337
+ kind,
1338
+ humanName: this.settings.humanName,
1339
+ roomName: this.settings.name,
1340
+ base: kind === "room" ? { ...this.settings, customRules: this.renderRuleReferences(this.settings.customRules) } : undefined,
1341
+ knownSkills: this.skills ? this.skills.library.list().map((s) => s.name) : undefined,
1342
+ };
1343
+ }
1344
+ createTemplateForAgent(participantId, design, replace) {
1345
+ const participant = this.agentInRoom(participantId);
1346
+ if (!this.skills)
1347
+ throw new Error("templates are not available in this hub");
1348
+ const result = lintRoomDesign(design, this.designContext("template"));
1349
+ if (result.errors.length)
1350
+ throw new Error(`not saved: ${result.errors.map((e) => e.message).join("; ")}`);
1351
+ const settings = result.settings;
1352
+ const rest = {};
1353
+ for (const key of Object.keys(design.settings ?? {}))
1354
+ if (AGENT_SETTINGS.includes(key))
1355
+ rest[key] = settings[key];
1356
+ const draft = {
1357
+ name: String(design.name).trim(),
1358
+ description: String(design.description ?? "").trim(),
1359
+ emoji: settings.emoji || undefined,
1360
+ settings: rest,
1361
+ vibemates: (design.vibemates ?? []).map((v) => {
1362
+ const out = { name: v.name.trim() };
1363
+ if (v.tagline?.trim())
1364
+ out.tagline = v.tagline.trim();
1365
+ if (v.role?.trim())
1366
+ out.role = v.role.trim();
1367
+ if (v.avatar?.trim())
1368
+ out.avatar = v.avatar.trim();
1369
+ if (v.skills?.length)
1370
+ out.skills = v.skills.map((s) => s.trim()).filter(Boolean);
1371
+ if (typeof v.replyDelay === "number")
1372
+ out.replyDelay = v.replyDelay;
1373
+ return out;
1374
+ }),
1375
+ };
1376
+ const library = this.skills.templates;
1377
+ const wanted = templateId(draft.name);
1378
+ const existing = library.list().find((t) => t.id === wanted);
1379
+ let saved;
1380
+ if (existing && replace) {
1381
+ if (existing.builtin)
1382
+ throw new Error(`"${existing.name}" is a template viberoom ships and cannot be replaced; pick another name`);
1383
+ saved = library.overwrite(wanted, draft);
1384
+ }
1385
+ else
1386
+ saved = library.save(draft);
1387
+ this.skills.templatesChanged();
1388
+ const warnings = result.warnings.map((w) => w.message);
1389
+ this.postSystem(`${participant.name} ${existing && replace ? "updated" : "created"} the room template "${saved.name}" (${saved.vibemates.map((v) => v.name).join(", ") || "no vibemates"}); it is in the picker under New room.`);
1390
+ this.log.info(`templates: ${participant.name} ${existing && replace ? "updated" : "created"} "${saved.name}" (${saved.id})`);
1391
+ return {
1392
+ ok: true,
1393
+ message: `Template "${saved.name}" saved as ${saved.id}${existing && !replace ? ` (the name was taken, so the id got a number; pass replace: true to update your own template instead)` : ""}. The human creates a room from it under New room; nothing in this room changed.${warnings.length ? ` Warnings: ${warnings.join("; ")}` : ""}`,
1394
+ id: saved.id,
1395
+ path: join(library.dir, saved.id, "template.json"),
1396
+ warnings,
1397
+ };
1398
+ }
1399
+ proposeRoomChanges(participantId, why, changes) {
1400
+ const participant = this.agentInRoom(participantId);
1401
+ const shape = this.templateOf();
1402
+ const current = shape.vibemates;
1403
+ const vibes = applyVibemateChanges(current, changes.vibemates);
1404
+ if (vibes.errors.length)
1405
+ throw new Error(`not proposed: ${vibes.errors.join("; ")}`);
1406
+ const touched = vibes.ops.flatMap((op) => [op.name, ...(op.fields ?? []).filter((f) => f.field === "name").map((f) => f.to)]);
1407
+ const result = lintRoomDesign({ settings: changes.settings, vibemates: vibes.next }, { ...this.designContext("room"), changedVibemates: touched });
1408
+ if (result.errors.length)
1409
+ throw new Error(`not proposed: ${result.errors.map((e) => e.message).join("; ")}`);
1410
+ const base = this.designContext("room").base;
1411
+ const settingChanges = diffSettings(base, result.settings);
1412
+ if (!settingChanges.length && !vibes.ops.length)
1413
+ throw new Error("not proposed: the change set leaves the room as it is");
1414
+ const touchesOwn = vibes.ops.some((op) => op.name.toLowerCase() === participant.name.toLowerCase()) || settingChanges.some((c) => c.key === "customRules");
1415
+ const proposal = {
1416
+ key: randomUUID(),
1417
+ participantId,
1418
+ participantName: participant.name,
1419
+ ts: Date.now(),
1420
+ why: String(why ?? "").trim().slice(0, 600),
1421
+ settings: settingChanges,
1422
+ vibemates: vibes.ops,
1423
+ warnings: result.warnings.map((w) => w.message),
1424
+ touchesOwn,
1425
+ status: "pending",
1426
+ };
1427
+ const ids = {};
1428
+ for (const op of vibes.ops) {
1429
+ const target = op.op === "add" ? undefined : this.findByName(op.name);
1430
+ if (target)
1431
+ ids[op.name] = target.id;
1432
+ }
1433
+ this.proposalPlans.set(proposal.key, { settings: settingChanges, vibemates: vibes.next, ops: vibes.ops, ids });
1434
+ this.proposals.set(proposal.key, proposal);
1435
+ this.push({ type: "proposal", proposal });
1436
+ const what = [...settingChanges.map((c) => c.key), ...vibes.ops.map((o) => `${o.op} ${o.name}`)].join(", ");
1437
+ this.postSystem(`${participant.name} proposes changes to the room (${what}); apply or reject them on the card.`, "human", false, { tone: "attention" });
1438
+ this.log.info(`proposal ${proposal.key} from ${participant.name}: ${what}`);
1439
+ return {
1440
+ ok: true,
1441
+ message: `Proposal sent to ${this.settings.humanName} as a card in the room (${what}). Nothing changes until they apply it; you will see a room line with the outcome.${proposal.warnings.length ? ` Warnings shown on the card: ${proposal.warnings.join("; ")}` : ""}`,
1442
+ key: proposal.key,
1443
+ warnings: proposal.warnings,
1444
+ };
1445
+ }
1446
+ async resolveProposal(key, accept) {
1447
+ const proposal = this.proposals.get(key);
1448
+ const plan = this.proposalPlans.get(key);
1449
+ if (!proposal || !plan)
1450
+ throw new Error("no such pending proposal");
1451
+ if (proposal.status !== "pending")
1452
+ return proposal;
1453
+ const what = [...proposal.settings.map((c) => c.key), ...proposal.vibemates.map((o) => `${o.op} ${o.name}`)].join(", ");
1454
+ if (!accept) {
1455
+ proposal.status = "rejected";
1456
+ this.proposalPlans.delete(key);
1457
+ this.push({ type: "proposal.resolved", key, status: "rejected" });
1458
+ this.postSystem(`${this.settings.humanName} rejected ${proposal.participantName}'s proposal (${what}).`);
1459
+ return proposal;
1460
+ }
1461
+ const skipped = [];
1462
+ for (const op of plan.ops) {
1463
+ const known = plan.ids[op.name];
1464
+ const existing = known ? this.participants.get(known) : this.findByName(op.name);
1465
+ if (op.op !== "add" && (!existing || existing.kind !== "agent" || existing.status === "left")) {
1466
+ skipped.push(`${op.op} ${op.name} (no longer in the room)`);
1467
+ continue;
1468
+ }
1469
+ if (op.op === "remove") {
1470
+ if (existing && existing.kind === "agent")
1471
+ await this.removeParticipant(existing.id);
1472
+ }
1473
+ else if (op.op === "update") {
1474
+ if (!existing || existing.kind !== "agent")
1475
+ continue;
1476
+ const target = plan.vibemates.find((v) => v.name === op.name) ?? plan.vibemates.find((v) => op.fields?.some((f) => f.field === "name" && f.to === v.name));
1477
+ const patch = {};
1478
+ for (const f of op.fields ?? []) {
1479
+ if (f.field === "name")
1480
+ patch.name = f.to;
1481
+ else if (f.field === "tagline")
1482
+ patch.tagline = target?.tagline ?? f.to;
1483
+ else if (f.field === "role")
1484
+ patch.role = target?.role ?? f.to;
1485
+ else if (f.field === "avatar")
1486
+ patch.avatar = target?.avatar ?? f.to;
1487
+ else if (f.field === "skills")
1488
+ patch.skills = target?.skills ?? [];
1489
+ else if (f.field === "replyDelay")
1490
+ patch.replyDelay = target?.replyDelay ?? null;
1491
+ }
1492
+ this.updatePersona(existing.id, patch);
1493
+ }
1494
+ else {
1495
+ const v = plan.vibemates.find((x) => x.name === op.name);
1496
+ if (!v || this.findByName(op.name))
1497
+ skipped.push(`add ${op.name} (the name is taken now)`);
1498
+ else
1499
+ this.addUnstaffed({ name: v.name, tagline: v.tagline, role: v.role, avatar: v.avatar, skills: v.skills });
1500
+ }
1501
+ }
1502
+ if (plan.settings.length) {
1503
+ const patch = {};
1504
+ for (const c of plan.settings)
1505
+ patch[c.key] = c.key === "language" ? c.to : c.to;
1506
+ this.updateSettings(patch);
1507
+ }
1508
+ proposal.status = "applied";
1509
+ proposal.skipped = skipped;
1510
+ this.proposalPlans.delete(key);
1511
+ this.push({ type: "proposal.resolved", key, status: "applied", skipped });
1512
+ this.postSystem(`${this.settings.humanName} applied ${proposal.participantName}'s proposal (${what}).${skipped.length ? ` Not applied: ${skipped.join("; ")}.` : ""}`);
1513
+ return proposal;
1514
+ }
1297
1515
  createSkillForAgent(participantId, input) {
1298
1516
  const participant = this.participants.get(participantId);
1299
1517
  if (!participant || !this.runtimes.has(participantId))
@@ -1708,6 +1926,7 @@ export class Room extends EventEmitter {
1708
1926
  const attached = seesImages && (m.to.length === 0 || m.to.includes(id));
1709
1927
  return m.images.map((a, i) => ({ n: a.n ?? i + 1, ref: `#${m.seq}.${a.n ?? i + 1}`, name: a.name, path: this.imagePath(a), mimeType: a.mimeType, attached, forNames: m.to.length ? m.toNames : [] }));
1710
1928
  };
1929
+ const backlogQuotes = (m) => m.quotes && m.quotes.length ? m.quotes.map((q) => ({ n: q.n, seq: q.seq, fromName: q.fromName, ts: q.ts, text: q.text })) : undefined;
1711
1930
  const prompt = composePrompt({
1712
1931
  brief: briefReason ? buildBrief(settings, persona, roster, runtime.notesForBrief ?? (participant.notes && (participant.notesSeq ?? -1) >= runtime.lastBriefSeq ? participant.notes : undefined), skillsForPrompt) : undefined,
1713
1932
  header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
@@ -1720,6 +1939,7 @@ export class Room extends EventEmitter {
1720
1939
  toNames: m.toNames,
1721
1940
  text: m.text,
1722
1941
  images: backlogImages(m),
1942
+ quotes: backlogQuotes(m),
1723
1943
  }),
1724
1944
  omitted,
1725
1945
  personaName: participant.name,
@@ -1819,7 +2039,7 @@ export class Room extends EventEmitter {
1819
2039
  if (isContextFullError(failure))
1820
2040
  this.contextFull(participant, runtime, failure ?? "");
1821
2041
  else
1822
- this.postSystem(`${participant.name} could not answer: ${(failure ?? "no result").slice(0, 240)}`);
2042
+ this.postSystem(`${participant.name} could not answer: ${(failure ?? "no result").slice(0, 240)}`, undefined, false, { tone: "error" });
1823
2043
  return null;
1824
2044
  }
1825
2045
  return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published, publishedAt);
@@ -1881,10 +2101,13 @@ export class Room extends EventEmitter {
1881
2101
  if (retry) {
1882
2102
  this.closeRetry(retry, cancelled ? "the correction turn was stopped; nothing was posted" : "the agent withdrew the reply");
1883
2103
  if (cancelled)
1884
- this.postSystem(`${participant.name} was stopped.`);
2104
+ this.postSystem(`${participant.name} was stopped by ${this.humanName}.`, undefined, false, { tone: "attention" });
2105
+ }
2106
+ else if (cancelled) {
2107
+ this.postSystem(`${participant.name} was stopped by ${this.humanName}.`, undefined, false, { tone: "attention" });
1885
2108
  }
1886
2109
  else {
1887
- this.postSystem(cancelled ? `${participant.name} was stopped.` : `${participant.name} read the room and has nothing to add.`);
2110
+ this.postSystem(`${participant.name} read the room and has nothing to add.`);
1888
2111
  }
1889
2112
  return null;
1890
2113
  }
@@ -1894,7 +2117,7 @@ export class Room extends EventEmitter {
1894
2117
  participant.failedTurns = (participant.failedTurns ?? 0) + 1;
1895
2118
  participant.statusDetail = `agent error: ${text.replace(/\s+/g, " ").slice(0, 120)}${text.length > 120 ? "…" : ""}`;
1896
2119
  this.push({ type: "participant", participant });
1897
- this.postSystem(`${participant.name}'s agent reported an error instead of a reply: ${text.slice(0, 200)}${text.length > 200 ? "…" : ""}`);
2120
+ this.postSystem(`${participant.name}'s agent reported an error instead of a reply: ${text.slice(0, 200)}${text.length > 200 ? "…" : ""}`, undefined, false, { tone: "error" });
1898
2121
  runtime.log.warn(`adapter error text treated as failed turn: ${text.slice(0, 200)}`);
1899
2122
  if (retry)
1900
2123
  this.closeRetry(retry, "the agent reported an error instead of a corrected reply");
@@ -1945,6 +2168,7 @@ export class Room extends EventEmitter {
1945
2168
  text,
1946
2169
  streaming: false,
1947
2170
  stopReason: result.stopReason,
2171
+ ...(cancelled ? { stoppedBy: this.humanName } : {}),
1948
2172
  usage: result.usage ?? null,
1949
2173
  durationMs,
1950
2174
  };
@@ -1952,17 +2176,17 @@ export class Room extends EventEmitter {
1952
2176
  if (publishedAt !== null && this.messages.some((x) => x.kind === "chat" && x.id !== message.id && x.ts > publishedAt)) {
1953
2177
  const at = new Date(draft.ts);
1954
2178
  const hhmm = `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
1955
- this.postSystem(`${participant.name} finished the reply started at ${hhmm} · ${Math.round(durationMs / 1000)} s`, "human", false, { refId: message.id, agentId: participant.id });
2179
+ this.postSystem(`${participant.name} finished the reply started at ${hhmm} · ${formatDuration(durationMs)}`, "human", false, { refId: message.id, agentId: participant.id });
1956
2180
  }
1957
2181
  if (retry) {
1958
2182
  this.closeRetry(retry, corrections.length ? `corrected reply posted, but it still breaks: ${corrections.map((c) => c.replace(/^reminder:\s*/i, "")).join("; ")}` : "corrected reply posted");
1959
2183
  }
1960
2184
  if (cancelled) {
1961
- this.postSystem(`${participant.name} was stopped mid-reply; the partial reply stays in the log.`);
2185
+ this.postSystem(`${participant.name} was stopped mid-reply by ${this.humanName}; what it had written stays in the room.`, "agents", false, { tone: "attention" });
1962
2186
  return null;
1963
2187
  }
1964
2188
  if (result.stopReason !== "end_turn") {
1965
- this.postSystem(`${participant.name} stopped with ${result.stopReason}.`);
2189
+ this.postSystem(`${participant.name} stopped with ${result.stopReason}.`, undefined, false, { tone: "attention" });
1966
2190
  }
1967
2191
  this.route(message);
1968
2192
  return null;
@@ -2127,21 +2351,25 @@ export class Room extends EventEmitter {
2127
2351
  }
2128
2352
  case "usage_update": {
2129
2353
  const u = update;
2354
+ if (emptyUsageReport(runtime.lastUsed, u.used)) {
2355
+ runtime.log.info(`usage report of 0 after ${runtime.lastUsed} tokens ignored (failed request?)`);
2356
+ return;
2357
+ }
2130
2358
  participant.contextUsed = u.used;
2131
2359
  participant.contextSize = u.size;
2132
2360
  if (u.cost)
2133
2361
  participant.cost = { amount: u.cost.amount, currency: u.cost.currency };
2134
- if (runtime.lastUsed > 0 && u.used < runtime.lastUsed * 0.7 && !runtime.briefPending) {
2362
+ if (looksCompacted(runtime.lastUsed, u.used) && !runtime.briefPending) {
2135
2363
  runtime.briefPending = `context shrank from ${runtime.lastUsed} to ${u.used} tokens (compaction?)`;
2136
2364
  runtime.log.info(`usage dropped ${runtime.lastUsed} -> ${u.used}; brief scheduled`);
2137
2365
  participant.contextEvent = { kind: "compacted", at: Date.now(), used: u.used, size: u.size };
2138
2366
  runtime.notesDue = overThreshold(u.used, u.size);
2139
- this.postSystem(`${participant.name} compacted its context (${Math.round(runtime.lastUsed / 1000)}k → ${Math.round(u.used / 1000)}k tokens); the room rules are re-sent with its next turn${participant.notes ? ", with its notes" : ""}.`, "human");
2367
+ this.postSystem(`${participant.name} compacted its context (${Math.round(runtime.lastUsed / 1000)}k → ${Math.round(u.used / 1000)}k tokens); the room rules are re-sent with its next turn${participant.notes ? ", with its notes" : ""}.`, "human", false, { tone: "attention" });
2140
2368
  }
2141
2369
  if (crossedThreshold(runtime.lastUsed, u.used, u.size)) {
2142
2370
  runtime.notesDue = true;
2143
2371
  participant.contextEvent = { kind: "threshold", at: Date.now(), used: u.used, size: u.size };
2144
- this.postSystem(`${participant.name} is at ${Math.round((100 * u.used) / u.size)}% of its context; it will leave notes with its next reply. You can respawn it with memory from its panel.`, "human");
2372
+ this.postSystem(`${participant.name} is at ${Math.round((100 * u.used) / u.size)}% of its context; it will leave notes with its next reply. You can respawn it with memory from its panel.`, "human", false, { tone: "attention" });
2145
2373
  runtime.log.info(`context at ${u.used}/${u.size}: notes due`);
2146
2374
  }
2147
2375
  runtime.lastUsed = u.used;
@@ -2308,8 +2536,17 @@ export class Room extends EventEmitter {
2308
2536
  participant.effort = pick("thought_level") ?? participant.effort;
2309
2537
  participant.mode = pick("mode") ?? participant.mode;
2310
2538
  }
2311
- failStart(participant, error, fresh) {
2539
+ failStart(participant, error, fresh, stderr = []) {
2312
2540
  participant.statusDetail = error instanceof Error ? error.message : String(error);
2541
+ const recipe = getRecipe(participant.agentType ?? "");
2542
+ participant.trouble = classifyStartFailure({
2543
+ error: participant.statusDetail,
2544
+ stderr,
2545
+ vendor: recipe?.vendor ?? participant.agentVendor ?? "The coding agent",
2546
+ loginCommand: recipe?.loginCommand || undefined,
2547
+ installHint: recipe?.installHint || undefined,
2548
+ loginState: recipe?.loginState,
2549
+ });
2313
2550
  this.notice(`${participant.name}: failed to start: ${participant.statusDetail}`, "error");
2314
2551
  if (fresh) {
2315
2552
  participant.status = "error";
@@ -2321,6 +2558,7 @@ export class Room extends EventEmitter {
2321
2558
  participant.status = "offline";
2322
2559
  this.push({ type: "participant", participant });
2323
2560
  }
2561
+ return new Error(`${participant.trouble.what} ${participant.trouble.advice} (${participant.statusDetail})`);
2324
2562
  }
2325
2563
  cancelPermissionsOf(id) {
2326
2564
  for (const [key, entry] of this.permissions) {