viberoom 0.5.9 → 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/recipes.js CHANGED
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { createRequire } from "node:module";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { loginState } from "./agent-health.js";
8
9
  const isWindows = process.platform === "win32";
9
10
  function resolvePackageEntry(packageName, relativeEntry) {
10
11
  try {
@@ -167,6 +168,9 @@ const recipes = [
167
168
  unavailableReason: claudeExe ? null : "Claude Code not found",
168
169
  installedAt: claudeExe,
169
170
  installHint: "install Claude Code (npm install -g @anthropic-ai/claude-code, or the native installer) and log in with `claude`",
171
+ loginCommand: "claude",
172
+ login: { env: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"], files: [".claude/.credentials.json"], command: "claude", fileless: ["darwin"] },
173
+ loginState: "unknown",
170
174
  build: ({ model }) => ({
171
175
  command: process.execPath,
172
176
  args: [claudeAdapter],
@@ -190,6 +194,9 @@ const recipes = [
190
194
  unavailableReason: codexExe ? null : "Codex CLI not found",
191
195
  installedAt: codexExe,
192
196
  installHint: "install Codex (npm install -g @openai/codex) and log in with `codex login`",
197
+ loginCommand: "codex login",
198
+ login: { env: ["CODEX_API_KEY", "OPENAI_API_KEY"], files: [".codex/auth.json"], command: "codex login" },
199
+ loginState: "unknown",
193
200
  build: () => ({
194
201
  command: process.execPath,
195
202
  args: [codexAdapter],
@@ -213,6 +220,9 @@ const recipes = [
213
220
  unavailableReason: geminiEntry ? null : "Gemini CLI not found",
214
221
  installedAt: geminiEntry,
215
222
  installHint: "npm install -g @google/gemini-cli, then sign in once (gemini)",
223
+ loginCommand: "gemini",
224
+ login: { env: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"], files: [".gemini/google_accounts.json", ".gemini/oauth_creds.json"], command: "gemini" },
225
+ loginState: "unknown",
216
226
  modelAtLaunch: true,
217
227
  build: ({ model }) => ({
218
228
  command: process.execPath,
@@ -237,6 +247,9 @@ const recipes = [
237
247
  unavailableReason: cursorAgent ? null : "Cursor CLI not found",
238
248
  installedAt: cursorAgent?.index ?? null,
239
249
  installHint: "install cursor-agent (cursor.com/cli), then agent login",
250
+ loginCommand: "cursor-agent login",
251
+ login: { env: ["CURSOR_API_KEY"], files: [], command: "cursor-agent login" },
252
+ loginState: "unknown",
240
253
  build: () => ({
241
254
  command: cursorAgent?.node ?? "",
242
255
  args: [cursorAgent?.index ?? "", "acp"],
@@ -260,6 +273,9 @@ const recipes = [
260
273
  unavailableReason: openCodeExe ? null : "OpenCode not found",
261
274
  installedAt: openCodeExe,
262
275
  installHint: "npm install -g opencode-ai (or curl -fsSL https://opencode.ai/install | bash), then opencode providers",
276
+ loginCommand: "opencode auth login",
277
+ login: { env: [], files: [".local/share/opencode/auth.json", "AppData/Local/opencode/auth.json", ".config/opencode/auth.json"], command: "opencode auth login" },
278
+ loginState: "unknown",
263
279
  build: () => ({
264
280
  command: openCodeExe ?? "",
265
281
  args: ["acp"],
@@ -286,6 +302,9 @@ const recipes = [
286
302
  unavailableReason: copilotExe ? null : "GitHub Copilot CLI not found",
287
303
  installedAt: copilotExe,
288
304
  installHint: "winget install GitHub.Copilot / brew install copilot-cli / npm install -g @github/copilot, then copilot login",
305
+ loginCommand: "copilot",
306
+ login: { env: ["GITHUB_TOKEN", "GH_TOKEN", "COPILOT_API_KEY"], files: [], command: "copilot" },
307
+ loginState: "unknown",
289
308
  modelAtLaunch: true,
290
309
  build: ({ model }) => ({
291
310
  command: copilotExe ?? "",
@@ -311,13 +330,21 @@ if (fakeAgent) {
311
330
  unavailableReason: null,
312
331
  installedAt: fakeAgent,
313
332
  installHint: "",
333
+ loginCommand: "",
334
+ loginState: "ok",
314
335
  bypassMode: null,
315
336
  build: () => ({ command: process.execPath, args: [fakeAgent], env: {} }),
316
337
  });
317
338
  }
339
+ function loginEvidence() {
340
+ return { env: process.env, platform: process.platform, exists: (relative) => existsSync(join(homedir(), ...relative.split("/"))) };
341
+ }
318
342
  export function listRecipes() {
343
+ const evidence = loginEvidence();
344
+ for (const recipe of recipes)
345
+ recipe.loginState = recipe.unavailableReason ? "unknown" : loginState(recipe.login, evidence);
319
346
  return recipes;
320
347
  }
321
348
  export function getRecipe(id) {
322
- return recipes.find((r) => r.id === id);
349
+ return listRecipes().find((r) => r.id === id);
323
350
  }
package/dist/room.js CHANGED
@@ -4,12 +4,16 @@ import { randomUUID } from "node:crypto";
4
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { writeFileAtomic } from "./atomic.js";
6
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
19
  import { templateId } from "./templates.js";
@@ -93,6 +97,11 @@ export class Room extends EventEmitter {
93
97
  for (const line of lines) {
94
98
  try {
95
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
+ }
96
105
  this.messages.push(message);
97
106
  if (message.seq > this.seq)
98
107
  this.seq = message.seq;
@@ -212,6 +221,19 @@ export class Room extends EventEmitter {
212
221
  appendFileSync(this.historyPath(), JSON.stringify(message) + "\n");
213
222
  this.push({ type: "message", message });
214
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
+ }
215
237
  get humanName() {
216
238
  return this.settings.humanName;
217
239
  }
@@ -232,7 +254,7 @@ export class Room extends EventEmitter {
232
254
  settings: this.settings,
233
255
  customRulesText: this.renderRuleReferences(this.settings.customRules),
234
256
  participants: [...this.participants.values()],
235
- messages: [...this.messages, ...this.drafts.values()],
257
+ messages: this.messagesWithLiveDrafts(),
236
258
  permissions: [...this.permissions.values()].map(({ resolve: _r, ...p }) => p),
237
259
  proposals: [...this.proposals.values()],
238
260
  recipes: listRecipes().map(({ build: _b, ...r }) => r),
@@ -296,13 +318,14 @@ export class Room extends EventEmitter {
296
318
  unstaffed() {
297
319
  return [...this.participants.values()].filter((p) => p.kind === "agent" && p.status === "unstaffed");
298
320
  }
299
- postHumanMessage(text, images = []) {
321
+ postHumanMessage(text, images = [], quotes = []) {
300
322
  const waiting = this.unstaffed();
301
323
  if (waiting.length)
302
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`);
303
325
  const trimmed = text.trim();
304
- if (!trimmed && !images.length)
326
+ if (!trimmed && !images.length && !quotes.length)
305
327
  throw new Error("empty message");
328
+ const quoted = quotes.length ? resolveQuotes(quotes, this.messages) : [];
306
329
  const attachments = images.length ? saveImages(ensureDir(this.filesDir()), images) : [];
307
330
  this.humanTypingUntil = 0;
308
331
  const human = this.participants.get("human");
@@ -319,6 +342,8 @@ export class Room extends EventEmitter {
319
342
  };
320
343
  if (attachments.length)
321
344
  message.images = attachments;
345
+ if (quoted.length)
346
+ message.quotes = quoted;
322
347
  this.decorateHumanMessage(message);
323
348
  human.turns += 1;
324
349
  if (this.focused) {
@@ -470,6 +495,7 @@ export class Room extends EventEmitter {
470
495
  const memory = !!options.memory;
471
496
  const replay = memory ? Math.max(0, options.replay ?? this.settings.replayAfterRestart) : 0;
472
497
  const withNotes = memory && !!participant.notes;
498
+ const why = options.reason ?? "its context was cleared";
473
499
  this.dropScheduledTurn(id);
474
500
  this.cancelPermissionsOf(id);
475
501
  if (this.speaking === id)
@@ -480,18 +506,38 @@ export class Room extends EventEmitter {
480
506
  this.restoredSeen.set(id, this.seq);
481
507
  this.push({ type: "participant", participant });
482
508
  if (!online) {
483
- 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";
484
- 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}.`);
485
512
  this.push({ type: "participant", participant });
486
513
  this.log.info(`respawn of ${participant.name} (offline): stored session dropped`);
487
514
  return participant;
488
515
  }
489
516
  await this.reconnect(id, memory
490
- ? { mode: "replay", replay, memory: withNotes, reason: `its context was cleared; it comes back with ${withNotes ? "its notes and " : ""}the last ${replay} messages` }
491
- : { 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` });
492
519
  this.log.info(`respawn of ${participant.name}: fresh session, ${memory ? `${withNotes ? "notes + " : ""}replay ${replay}` : "no replay"}`);
493
520
  return participant;
494
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
+ }
495
541
  updateNotes(id, notes) {
496
542
  const participant = this.participants.get(id);
497
543
  if (!participant || participant.kind !== "agent")
@@ -514,7 +560,15 @@ export class Room extends EventEmitter {
514
560
  throw new Error(`${participant.name} is in the middle of a reply; try again when it is idle`);
515
561
  const header = buildHeader(this.effectiveSettings(), this.personaOf(participant), this.roster(), this.hops, ["hidden turn: notes only, nothing is posted"]);
516
562
  runtime.log.info("notes: hidden turn");
517
- 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
+ }
518
572
  return participant;
519
573
  }
520
574
  keepNotes(participant, runtime, notes, via) {
@@ -536,14 +590,14 @@ export class Room extends EventEmitter {
536
590
  participant.status = "error";
537
591
  participant.statusDetail = "its context filled up again right after a respawn; it needs you (respawn it by hand, with fewer replayed messages)";
538
592
  this.push({ type: "participant", participant });
539
- 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" });
540
594
  runtime.log.warn(`context full again within 10 minutes: no automatic respawn`);
541
595
  return;
542
596
  }
543
597
  participant.autoRespawnAt = Date.now();
544
598
  this.push({ type: "participant", participant });
545
599
  const memory = !!participant.notes;
546
- 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" });
547
601
  runtime.log.warn(`context full: ${detail}; respawn with ${memory ? "notes" : "no notes"}`);
548
602
  setImmediate(() => {
549
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"));
@@ -599,7 +653,7 @@ export class Room extends EventEmitter {
599
653
  }
600
654
  this.focused = true;
601
655
  this.push(this.roomEvent());
602
- 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" });
603
657
  }
604
658
  rename(name) {
605
659
  const trimmed = name.trim();
@@ -887,20 +941,25 @@ export class Room extends EventEmitter {
887
941
  const spec = recipe.build({ model: launch.model });
888
942
  const transcript = new Transcript(join(this.dataDir, "transcripts"), name);
889
943
  log.info(`spawning ${spec.command} ${spec.args.join(" ")} (cwd ${cwd}); transcript ${transcript.path}`);
944
+ const stderrTail = [];
890
945
  let agent;
891
946
  try {
892
947
  agent = new AcpAgent({ ...spec, cwd }, {
893
948
  onSessionUpdate: (_sessionId, update) => this.onSessionUpdate(id, update),
894
949
  onPermissionRequest: (params) => this.onPermissionRequest(id, params),
895
- 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
+ },
896
956
  onExit: (code, signal) => this.onAgentExit(id, code, signal, agent),
897
957
  onRaw: (direction, message) => transcript.record(direction, message),
898
958
  onProtocolError: (text) => log.warn(`protocol: ${text}`),
899
959
  });
900
960
  }
901
961
  catch (error) {
902
- this.failStart(participant, error, fresh);
903
- throw error;
962
+ throw this.failStart(participant, error, fresh, stderrTail);
904
963
  }
905
964
  try {
906
965
  const init = await agent.initialize({ name: "viberoom", version: "0.2.0" });
@@ -993,6 +1052,7 @@ export class Room extends EventEmitter {
993
1052
  this.notice(`${name}: ${w}`, "warn");
994
1053
  participant.status = "idle";
995
1054
  participant.statusDetail = undefined;
1055
+ participant.trouble = undefined;
996
1056
  this.push({ type: "participant", participant });
997
1057
  if (fresh) {
998
1058
  const detail = this.settings.showVendorInRoster ? `${recipe.label}${participant.model ? `, model ${participant.model}` : ""}` : "agent";
@@ -1024,8 +1084,7 @@ export class Room extends EventEmitter {
1024
1084
  catch (error) {
1025
1085
  agent.kill();
1026
1086
  this.forgetRuntime(id);
1027
- this.failStart(participant, error, fresh);
1028
- throw error;
1087
+ throw this.failStart(participant, error, fresh, stderrTail);
1029
1088
  }
1030
1089
  }
1031
1090
  async removeParticipant(id) {
@@ -1069,11 +1128,20 @@ export class Room extends EventEmitter {
1069
1128
  const participant = this.participants.get(id);
1070
1129
  if (!runtime || !participant)
1071
1130
  throw new Error("no such agent");
1072
- if (!runtime.turnActive)
1073
- return;
1074
- runtime.agent.cancel(runtime.sessionId);
1075
- this.cancelPermissionsOf(id);
1076
- 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";
1077
1145
  }
1078
1146
  async setConfig(id, configId, value) {
1079
1147
  const runtime = this.runtimes.get(id);
@@ -1160,7 +1228,7 @@ export class Room extends EventEmitter {
1160
1228
  if (wanted.length) {
1161
1229
  if (this.hops >= this.hopLimit) {
1162
1230
  const who = agentTargets.length ? message.toNames.join(", ") : "the other vibemates";
1163
- 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" });
1164
1232
  }
1165
1233
  else {
1166
1234
  this.hops += 1;
@@ -1245,6 +1313,25 @@ export class Room extends EventEmitter {
1245
1313
  const result = lintRoomDesign(design, { ...this.designContext(kind), skills: this.skillsForDesign(participantId) });
1246
1314
  return { ok: !result.errors.length, errors: result.errors.map((e) => e.message), warnings: result.warnings.map((w) => w.message), preview: result.preview };
1247
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
+ }
1248
1335
  designContext(kind) {
1249
1336
  return {
1250
1337
  kind,
@@ -1347,7 +1434,7 @@ export class Room extends EventEmitter {
1347
1434
  this.proposals.set(proposal.key, proposal);
1348
1435
  this.push({ type: "proposal", proposal });
1349
1436
  const what = [...settingChanges.map((c) => c.key), ...vibes.ops.map((o) => `${o.op} ${o.name}`)].join(", ");
1350
- this.postSystem(`${participant.name} proposes changes to the room (${what}); apply or reject them on the card.`, "human");
1437
+ this.postSystem(`${participant.name} proposes changes to the room (${what}); apply or reject them on the card.`, "human", false, { tone: "attention" });
1351
1438
  this.log.info(`proposal ${proposal.key} from ${participant.name}: ${what}`);
1352
1439
  return {
1353
1440
  ok: true,
@@ -1839,6 +1926,7 @@ export class Room extends EventEmitter {
1839
1926
  const attached = seesImages && (m.to.length === 0 || m.to.includes(id));
1840
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 : [] }));
1841
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;
1842
1930
  const prompt = composePrompt({
1843
1931
  brief: briefReason ? buildBrief(settings, persona, roster, runtime.notesForBrief ?? (participant.notes && (participant.notesSeq ?? -1) >= runtime.lastBriefSeq ? participant.notes : undefined), skillsForPrompt) : undefined,
1844
1932
  header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
@@ -1851,6 +1939,7 @@ export class Room extends EventEmitter {
1851
1939
  toNames: m.toNames,
1852
1940
  text: m.text,
1853
1941
  images: backlogImages(m),
1942
+ quotes: backlogQuotes(m),
1854
1943
  }),
1855
1944
  omitted,
1856
1945
  personaName: participant.name,
@@ -1950,7 +2039,7 @@ export class Room extends EventEmitter {
1950
2039
  if (isContextFullError(failure))
1951
2040
  this.contextFull(participant, runtime, failure ?? "");
1952
2041
  else
1953
- 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" });
1954
2043
  return null;
1955
2044
  }
1956
2045
  return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published, publishedAt);
@@ -2012,10 +2101,13 @@ export class Room extends EventEmitter {
2012
2101
  if (retry) {
2013
2102
  this.closeRetry(retry, cancelled ? "the correction turn was stopped; nothing was posted" : "the agent withdrew the reply");
2014
2103
  if (cancelled)
2015
- 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" });
2016
2108
  }
2017
2109
  else {
2018
- 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.`);
2019
2111
  }
2020
2112
  return null;
2021
2113
  }
@@ -2025,7 +2117,7 @@ export class Room extends EventEmitter {
2025
2117
  participant.failedTurns = (participant.failedTurns ?? 0) + 1;
2026
2118
  participant.statusDetail = `agent error: ${text.replace(/\s+/g, " ").slice(0, 120)}${text.length > 120 ? "…" : ""}`;
2027
2119
  this.push({ type: "participant", participant });
2028
- 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" });
2029
2121
  runtime.log.warn(`adapter error text treated as failed turn: ${text.slice(0, 200)}`);
2030
2122
  if (retry)
2031
2123
  this.closeRetry(retry, "the agent reported an error instead of a corrected reply");
@@ -2076,6 +2168,7 @@ export class Room extends EventEmitter {
2076
2168
  text,
2077
2169
  streaming: false,
2078
2170
  stopReason: result.stopReason,
2171
+ ...(cancelled ? { stoppedBy: this.humanName } : {}),
2079
2172
  usage: result.usage ?? null,
2080
2173
  durationMs,
2081
2174
  };
@@ -2083,17 +2176,17 @@ export class Room extends EventEmitter {
2083
2176
  if (publishedAt !== null && this.messages.some((x) => x.kind === "chat" && x.id !== message.id && x.ts > publishedAt)) {
2084
2177
  const at = new Date(draft.ts);
2085
2178
  const hhmm = `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
2086
- 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 });
2087
2180
  }
2088
2181
  if (retry) {
2089
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");
2090
2183
  }
2091
2184
  if (cancelled) {
2092
- 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" });
2093
2186
  return null;
2094
2187
  }
2095
2188
  if (result.stopReason !== "end_turn") {
2096
- this.postSystem(`${participant.name} stopped with ${result.stopReason}.`);
2189
+ this.postSystem(`${participant.name} stopped with ${result.stopReason}.`, undefined, false, { tone: "attention" });
2097
2190
  }
2098
2191
  this.route(message);
2099
2192
  return null;
@@ -2271,12 +2364,12 @@ export class Room extends EventEmitter {
2271
2364
  runtime.log.info(`usage dropped ${runtime.lastUsed} -> ${u.used}; brief scheduled`);
2272
2365
  participant.contextEvent = { kind: "compacted", at: Date.now(), used: u.used, size: u.size };
2273
2366
  runtime.notesDue = overThreshold(u.used, u.size);
2274
- 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" });
2275
2368
  }
2276
2369
  if (crossedThreshold(runtime.lastUsed, u.used, u.size)) {
2277
2370
  runtime.notesDue = true;
2278
2371
  participant.contextEvent = { kind: "threshold", at: Date.now(), used: u.used, size: u.size };
2279
- 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" });
2280
2373
  runtime.log.info(`context at ${u.used}/${u.size}: notes due`);
2281
2374
  }
2282
2375
  runtime.lastUsed = u.used;
@@ -2443,8 +2536,17 @@ export class Room extends EventEmitter {
2443
2536
  participant.effort = pick("thought_level") ?? participant.effort;
2444
2537
  participant.mode = pick("mode") ?? participant.mode;
2445
2538
  }
2446
- failStart(participant, error, fresh) {
2539
+ failStart(participant, error, fresh, stderr = []) {
2447
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
+ });
2448
2550
  this.notice(`${participant.name}: failed to start: ${participant.statusDetail}`, "error");
2449
2551
  if (fresh) {
2450
2552
  participant.status = "error";
@@ -2456,6 +2558,7 @@ export class Room extends EventEmitter {
2456
2558
  participant.status = "offline";
2457
2559
  this.push({ type: "participant", participant });
2458
2560
  }
2561
+ return new Error(`${participant.trouble.what} ${participant.trouble.advice} (${participant.statusDetail})`);
2459
2562
  }
2460
2563
  cancelPermissionsOf(id) {
2461
2564
  for (const [key, entry] of this.permissions) {
package/dist/rows.js ADDED
@@ -0,0 +1,19 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ const LEGACY = [
3
+ [/^(Hush|Focus):/, "hush"],
4
+ [/ could not answer: /, "error"],
5
+ [/'s agent reported an error instead of a reply/, "error"],
6
+ [/ ran out of context/, "error"],
7
+ [/ was stopped\b/, "attention"],
8
+ [/^Hop limit \d+ reached/, "attention"],
9
+ [/ is at \d+% of its context/, "attention"],
10
+ [/ compacted its context/, "attention"],
11
+ [/ proposes changes to the room/, "attention"],
12
+ [/ was respawned while offline/, "attention"],
13
+ ];
14
+ export function legacyRowTone(text) {
15
+ for (const [pattern, tone] of LEGACY)
16
+ if (pattern.test(text))
17
+ return tone;
18
+ return undefined;
19
+ }