viberoom 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -146,6 +146,8 @@ it in a browser tab. Later, `npm install -g viberoom@latest` and the next start
146
146
  or GitHub Copilot. viberoom finds the ones you have and offers only those. It installs none of them.
147
147
  - A browser. Chrome, Edge or Brave for the app window; anything modern for a tab.
148
148
 
149
+ Something does not start? `viberoom doctor` checks these four things and says which one is missing.
150
+
149
151
  <br>
150
152
 
151
153
  ## Use
@@ -0,0 +1,42 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ export const NOTES_THRESHOLD = 0.8;
3
+ export const NOTES_REQUEST = "end your reply with a <notes> block: up to 10 lines on this room, your task and where you are with it; the block is kept by the hub for a restart and is not posted";
4
+ export const NOTES_ONLY_PROMPT = "Reply with a <notes> block only: up to 10 lines on this room, your task and where you are with it, written for a future you that starts with an empty head. Nothing else; nothing is posted.";
5
+ const OPEN = /<notes>/i;
6
+ const BLOCK = /\s*<notes>([\s\S]*?)(?:<\/notes>|$)\s*/i;
7
+ export function extractNotes(text) {
8
+ const m = BLOCK.exec(text);
9
+ if (!m)
10
+ return { visible: text, notes: null };
11
+ const notes = m[1].trim();
12
+ const before = text.slice(0, m.index).replace(/\s+$/, "");
13
+ const after = text.slice(m.index + m[0].length).replace(/^\s+/, "");
14
+ const visible = before && after ? `${before} ${after}` : before || after;
15
+ return { visible, notes: notes || null };
16
+ }
17
+ export function visibleChunk(textBefore, chunk) {
18
+ if (OPEN.test(textBefore))
19
+ return "";
20
+ const at = chunk.search(OPEN);
21
+ return at < 0 ? chunk : chunk.slice(0, at);
22
+ }
23
+ export function crossedThreshold(previousUsed, used, size, threshold = NOTES_THRESHOLD) {
24
+ if (!(size > 0))
25
+ return false;
26
+ return used / size >= threshold && previousUsed / size < threshold;
27
+ }
28
+ export function overThreshold(used, size, threshold = NOTES_THRESHOLD) {
29
+ return size > 0 && used / size >= threshold;
30
+ }
31
+ const CONTEXT_FULL = /context (window )?(is )?(full|too long|exceeded|limit)|prompt is too long|maximum context length|input (length|is too long)|too many tokens|exceeds the (context|token) (window|limit)|context_length_exceeded/i;
32
+ export function isContextFullError(text) {
33
+ return !!text && CONTEXT_FULL.test(text);
34
+ }
35
+ const BARE_PREFIX = /^\s*(?:\[?(?:error|api error|request failed)\]?\s*[:\-]?\s*)?(?:\d{3}\s+)?/i;
36
+ export function isBareContextFullError(text) {
37
+ if (!text || text.length > 240 || text.includes("\n"))
38
+ return false;
39
+ const rest = text.replace(BARE_PREFIX, "");
40
+ const m = CONTEXT_FULL.exec(rest);
41
+ return !!m && m.index === 0;
42
+ }
package/dist/launcher.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join, posix, win32 } from "node:path";
4
- const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "help"]);
4
+ const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "doctor", "help"]);
5
5
  export function splitCommand(argv) {
6
6
  const first = argv[0];
7
7
  if (first && !first.startsWith("-") && COMMANDS.has(first))
@@ -174,6 +174,12 @@ export function appWindowArgs(url, profileDir, freshProfile, placement = null, p
174
174
  args.push("--class=viberoom");
175
175
  return args;
176
176
  }
177
+ export function browserAdvice(chromium, platform = process.platform) {
178
+ if (chromium)
179
+ return null;
180
+ const names = platform === "darwin" ? "Chrome, Edge, Brave or Chromium" : platform === "win32" ? "Chrome, Edge or Chromium" : "google-chrome, chromium, microsoft-edge or brave-browser on PATH";
181
+ return `No Chromium-based browser found (${names}); viberoom opens in a tab of your default browser instead. Install one of them for the app window.`;
182
+ }
177
183
  export function openUrlCommand(url, platform = process.platform) {
178
184
  if (platform === "win32")
179
185
  return `start "" "${url}"`;
package/dist/main.js CHANGED
@@ -8,9 +8,10 @@ import { fileURLToPath } from "node:url";
8
8
  import { Hub } from "./hub.js";
9
9
  import { Logger } from "./log.js";
10
10
  import { startServer } from "./server.js";
11
- import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
11
+ import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, browserAdvice, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
12
12
  import { aumidSyncScript, installShortcuts, windowsShortcutPaths } from "./shortcuts.js";
13
- import { runMenu } from "./tui.js";
13
+ import { askEnter, renderInstalled, runMenu, unicodeSupported } from "./tui.js";
14
+ import { listRecipes } from "./recipes.js";
14
15
  function parseArgs(argv) {
15
16
  const { command, rest } = splitCommand(argv);
16
17
  const options = {
@@ -77,6 +78,7 @@ Commands
77
78
  status show whether a hub is running, its build and address
78
79
  open open the window of the running hub
79
80
  logs print the last lines of the background hub's log
81
+ doctor check Node, the browser, the coding agents and the hub; say what is missing and why
80
82
 
81
83
  Options
82
84
  --port localhost port for the web UI (default 4810)
@@ -198,9 +200,39 @@ function openWindow(url, options, log) {
198
200
  }
199
201
  return;
200
202
  }
203
+ const advice = options.browser ? null : browserAdvice(null);
204
+ if (advice) {
205
+ log.warn(advice);
206
+ process.stderr.write(`${advice}\n`);
207
+ try {
208
+ appendFileSync(logFilePath(options.dataDir), `[${new Date().toISOString()}] [launcher] ${advice}\n`);
209
+ }
210
+ catch {
211
+ }
212
+ }
201
213
  log.info("opening the default browser");
202
214
  exec(openUrlCommand(url), () => undefined);
203
215
  }
216
+ async function runDoctor(options, info) {
217
+ const lines = [];
218
+ const major = Number(process.versions.node.split(".")[0]);
219
+ lines.push(`viberoom ${info.version} (build ${info.build})`);
220
+ lines.push(`${major >= 22 ? "ok " : "FAIL"} node ${process.versions.node}${major >= 22 ? "" : " (viberoom needs Node 22 or newer: https://nodejs.org)"}`);
221
+ const chromium = findChromium();
222
+ lines.push(chromium ? `ok browser for the app window: ${chromium}` : `warn ${browserAdvice(null)}`);
223
+ const recipes = listRecipes();
224
+ const found = recipes.filter((r) => !r.unavailableReason);
225
+ lines.push(`${found.length ? "ok " : "warn"} coding agents: ${found.length ? found.map((r) => r.vendor).join(", ") : "none found"}${found.length ? "" : " (install and log in to at least one: Claude Code, Codex, Gemini CLI, Cursor, OpenCode or GitHub Copilot)"}`);
226
+ for (const r of recipes.filter((r) => r.unavailableReason))
227
+ lines.push(` ${r.vendor}: ${r.unavailableReason}`);
228
+ const running = await runningInstance(options.port);
229
+ lines.push(running ? `ok hub running at ${running.url} (build ${running.build ?? "unknown"})` : `info no hub on port ${options.port}: start one with "viberoom start" (or "viberoom start --browser" without a Chromium browser)`);
230
+ lines.push(` data: ${options.dataDir}`);
231
+ lines.push(` log: ${logFilePath(options.dataDir)}`);
232
+ process.stdout.write(lines.join("\n") + "\n");
233
+ if (major < 22)
234
+ process.exitCode = 1;
235
+ }
204
236
  async function runHub(options, log, info) {
205
237
  const background = options.command === "serve";
206
238
  if (!background) {
@@ -367,6 +399,9 @@ async function main() {
367
399
  openWindow(url, options, log);
368
400
  return;
369
401
  }
402
+ case "doctor":
403
+ await runDoctor(options, info);
404
+ return;
370
405
  case "logs": {
371
406
  const path = logFilePath(options.dataDir);
372
407
  process.stdout.write(`${path}\n${tailFile(path, 60)}\n`);
@@ -385,10 +420,12 @@ async function main() {
385
420
  if (choice === "shortcut") {
386
421
  const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
387
422
  const result = installShortcuts({ root: fileURLToPath(new URL("..", import.meta.url)), dataDir: options.dataDir, node: process.execPath, version: pkg.version, desktop: true });
388
- for (const file of result.files)
389
- process.stdout.write(`wrote: ${file}\n`);
390
- for (const note of result.notes)
391
- process.stdout.write(`${note}\n`);
423
+ const advice = browserAdvice(findChromium());
424
+ process.stdout.write(renderInstalled({ files: result.files, notes: result.notes, platform: process.platform, browserAdvice: advice && advice.replace("viberoom opens", "the icon opens viberoom") }, { color: !process.env.NO_COLOR, unicode: unicodeSupported(), columns: process.stdout.columns }));
425
+ if (await askEnter()) {
426
+ process.stdout.write("\n");
427
+ await startBackground(options, log, info);
428
+ }
392
429
  return;
393
430
  }
394
431
  }
package/dist/recipes.js CHANGED
@@ -276,6 +276,28 @@ const recipes = [
276
276
  }),
277
277
  },
278
278
  ];
279
+ const fakeAgent = process.env.VIBEROOM_FAKE_AGENT;
280
+ if (fakeAgent) {
281
+ recipes.push({
282
+ id: "fake",
283
+ label: "Fake (test agent)",
284
+ vendor: "Fake",
285
+ icon: "",
286
+ tested: false,
287
+ note: "A scripted ACP agent for the hub's own probes; present only with VIBEROOM_FAKE_AGENT.",
288
+ modelPresets: [],
289
+ defaultModel: null,
290
+ effortPresets: [],
291
+ defaultEffort: null,
292
+ modePresets: [],
293
+ defaultMode: null,
294
+ unavailableReason: null,
295
+ installedAt: fakeAgent,
296
+ installHint: "",
297
+ bypassMode: null,
298
+ build: () => ({ command: process.execPath, args: [fakeAgent], env: {} }),
299
+ });
300
+ }
279
301
  export function listRecipes() {
280
302
  return recipes;
281
303
  }
package/dist/room.js CHANGED
@@ -3,6 +3,7 @@ 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
7
  import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
7
8
  import { saveImages } from "./files.js";
8
9
  import { join, resolve } from "node:path";
@@ -29,6 +30,7 @@ export class Room extends EventEmitter {
29
30
  hops = 0;
30
31
  focused = false;
31
32
  participants = new Map();
33
+ lastTurnHidden = false;
32
34
  messages = [];
33
35
  seq = 0;
34
36
  programHumanDescription;
@@ -124,6 +126,9 @@ export class Room extends EventEmitter {
124
126
  sessionId: s.sessionId,
125
127
  supportsLoad: s.supportsLoad,
126
128
  sawFromSeq: s.sawFromSeq,
129
+ notes: s.notes,
130
+ notesAt: s.notesAt,
131
+ notesSeq: s.notesSeq,
127
132
  violations: 0,
128
133
  briefsSent: 0,
129
134
  failedTurns: 0,
@@ -193,6 +198,9 @@ export class Room extends EventEmitter {
193
198
  supportsLoad: p.supportsLoad,
194
199
  lastSeenSeq: this.runtimes.get(p.id)?.lastSeenSeq ?? this.restoredSeen.get(p.id),
195
200
  sawFromSeq: p.sawFromSeq,
201
+ notes: p.notes,
202
+ notesAt: p.notesAt,
203
+ notesSeq: p.notesSeq,
196
204
  })),
197
205
  };
198
206
  }
@@ -450,11 +458,14 @@ export class Room extends EventEmitter {
450
458
  this.route(message);
451
459
  return { restarted, removed: removed.length };
452
460
  }
453
- async respawnAgent(id) {
461
+ async respawnAgent(id, options = {}) {
454
462
  const participant = this.participants.get(id);
455
463
  if (!participant || participant.kind !== "agent")
456
464
  throw new Error("no such agent");
457
465
  const online = this.runtimes.has(id);
466
+ const memory = !!options.memory;
467
+ const replay = memory ? Math.max(0, options.replay ?? this.settings.replayAfterRestart) : 0;
468
+ const withNotes = memory && !!participant.notes;
458
469
  this.dropScheduledTurn(id);
459
470
  this.cancelPermissionsOf(id);
460
471
  if (this.speaking === id)
@@ -465,16 +476,86 @@ export class Room extends EventEmitter {
465
476
  this.restoredSeen.set(id, this.seq);
466
477
  this.push({ type: "participant", participant });
467
478
  if (!online) {
468
- participant.statusDetail = "its context was cleared; a reconnect starts it with an empty head";
469
- this.postSystem(`${participant.name} was respawned while offline: it comes back knowing nothing from before.`);
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.`);
470
481
  this.push({ type: "participant", participant });
471
482
  this.log.info(`respawn of ${participant.name} (offline): stored session dropped`);
472
483
  return participant;
473
484
  }
474
- await this.reconnect(id, { mode: "replay", replay: 0, reason: "its context was cleared, it remembers nothing from before" });
475
- this.log.info(`respawn of ${participant.name}: fresh session, no replay`);
485
+ 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" });
488
+ this.log.info(`respawn of ${participant.name}: fresh session, ${memory ? `${withNotes ? "notes + " : ""}replay ${replay}` : "no replay"}`);
476
489
  return participant;
477
490
  }
491
+ updateNotes(id, notes) {
492
+ const participant = this.participants.get(id);
493
+ if (!participant || participant.kind !== "agent")
494
+ throw new Error("no such agent");
495
+ const text = notes.trim().slice(0, 4000);
496
+ participant.notes = text || undefined;
497
+ participant.notesAt = text ? this.runtimes.get(id)?.lastUsed ?? participant.notesAt : undefined;
498
+ participant.notesSeq = text ? this.seq : undefined;
499
+ this.push({ type: "participant", participant });
500
+ return participant;
501
+ }
502
+ async takeNotes(id) {
503
+ const participant = this.participants.get(id);
504
+ const runtime = this.runtimes.get(id);
505
+ if (!participant || participant.kind !== "agent")
506
+ throw new Error("no such agent");
507
+ if (!runtime || !runtime.agent.alive)
508
+ throw new Error(`${participant.name} is not online`);
509
+ if (runtime.turnActive)
510
+ throw new Error(`${participant.name} is in the middle of a reply; try again when it is idle`);
511
+ const header = buildHeader(this.effectiveSettings(), this.personaOf(participant), this.roster(), this.hops, ["hidden turn: notes only, nothing is posted"]);
512
+ runtime.log.info("notes: hidden turn");
513
+ await this.executeTurn(participant, runtime, [{ type: "text", text: `${header}\n\n${NOTES_ONLY_PROMPT}` }], null, true);
514
+ return participant;
515
+ }
516
+ keepNotes(participant, runtime, notes, via) {
517
+ if (!notes)
518
+ return false;
519
+ participant.notes = notes.slice(0, 4000);
520
+ participant.notesAt = runtime.lastUsed;
521
+ participant.notesSeq = this.seq;
522
+ runtime.notesDue = false;
523
+ runtime.notesMisses = 0;
524
+ this.push({ type: "participant", participant });
525
+ runtime.log.info(`notes: ${notes.split("\n").length} lines kept (${via}, context ${runtime.lastUsed})`);
526
+ return true;
527
+ }
528
+ contextFull(participant, runtime, detail) {
529
+ participant.contextEvent = { kind: "full", at: Date.now(), used: runtime.lastUsed, size: participant.contextSize };
530
+ const recently = participant.autoRespawnAt !== undefined && Date.now() - participant.autoRespawnAt < 10 * 60_000;
531
+ if (recently) {
532
+ participant.status = "error";
533
+ participant.statusDetail = "its context filled up again right after a respawn; it needs you (respawn it by hand, with fewer replayed messages)";
534
+ 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");
536
+ runtime.log.warn(`context full again within 10 minutes: no automatic respawn`);
537
+ return;
538
+ }
539
+ participant.autoRespawnAt = Date.now();
540
+ this.push({ type: "participant", participant });
541
+ 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");
543
+ runtime.log.warn(`context full: ${detail}; respawn with ${memory ? "notes" : "no notes"}`);
544
+ setImmediate(() => {
545
+ 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"));
546
+ });
547
+ }
548
+ async reconnectAfterFull(id) {
549
+ const participant = this.participants.get(id);
550
+ if (!participant)
551
+ return;
552
+ this.dropScheduledTurn(id);
553
+ this.cancelPermissionsOf(id);
554
+ if (this.runtimes.has(id))
555
+ await this.retireRuntime(id);
556
+ participant.sessionId = undefined;
557
+ await this.reconnect(id, { mode: "replay", reason: "its context was full; it comes back with the last messages" });
558
+ }
478
559
  async retireRuntime(id) {
479
560
  const runtime = this.runtimes.get(id);
480
561
  const participant = this.participants.get(id);
@@ -973,6 +1054,11 @@ export class Room extends EventEmitter {
973
1054
  briefPending: null,
974
1055
  headerNotes: [],
975
1056
  briefRequestedAtSeq: -1,
1057
+ notesDue: false,
1058
+ notesMisses: 0,
1059
+ notesAskedThisTurn: false,
1060
+ lastBriefSeq: -1,
1061
+ notesForBrief: reconnectOptions?.memory && participant.notes ? participant.notes : null,
976
1062
  replayOwnUntilSeq: fresh || origin === "loaded" ? -1 : this.seq,
977
1063
  delayTimer: null,
978
1064
  addressed: false,
@@ -1597,6 +1683,11 @@ export class Room extends EventEmitter {
1597
1683
  briefReason = `${tokensSinceBrief} tokens since last brief`;
1598
1684
  const notes = [...runtime.headerNotes];
1599
1685
  runtime.headerNotes = [];
1686
+ if (briefReason && overThreshold(runtime.lastUsed, participant.contextSize ?? 0))
1687
+ runtime.notesDue = true;
1688
+ runtime.notesAskedThisTurn = runtime.notesDue;
1689
+ if (runtime.notesDue)
1690
+ notes.push(NOTES_REQUEST);
1600
1691
  if (briefReason && runtime.firstTurnDone) {
1601
1692
  if (briefReason.startsWith("requested"))
1602
1693
  notes.push("full brief re-sent as requested");
@@ -1618,7 +1709,7 @@ export class Room extends EventEmitter {
1618
1709
  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 : [] }));
1619
1710
  };
1620
1711
  const prompt = composePrompt({
1621
- brief: briefReason ? buildBrief(settings, persona, roster, undefined, skillsForPrompt) : undefined,
1712
+ brief: briefReason ? buildBrief(settings, persona, roster, runtime.notesForBrief ?? (participant.notes && (participant.notesSeq ?? -1) >= runtime.lastBriefSeq ? participant.notes : undefined), skillsForPrompt) : undefined,
1622
1713
  header: buildHeader(settings, persona, roster, this.hops, notes, skillsForPrompt),
1623
1714
  skills: attached.map((s) => composeSkillBlock({ name: s.name, text: s.text, invokedBy: s.invokedBy, extraFiles: s.extraFiles })),
1624
1715
  backlog: unread.map((m) => m.kind === "system"
@@ -1637,6 +1728,8 @@ export class Room extends EventEmitter {
1637
1728
  runtime.briefPending = null;
1638
1729
  runtime.turnsSinceBrief = 0;
1639
1730
  runtime.briefSentThisTurn = true;
1731
+ runtime.lastBriefSeq = this.seq;
1732
+ runtime.notesForBrief = null;
1640
1733
  participant.briefsSent = (participant.briefsSent ?? 0) + 1;
1641
1734
  }
1642
1735
  runtime.firstTurnDone = true;
@@ -1664,7 +1757,7 @@ export class Room extends EventEmitter {
1664
1757
  }
1665
1758
  return blocks;
1666
1759
  }
1667
- async executeTurn(participant, runtime, blocks, retry) {
1760
+ async executeTurn(participant, runtime, blocks, retry, hidden = false) {
1668
1761
  const id = participant.id;
1669
1762
  runtime.turnActive = true;
1670
1763
  participant.status = "thinking";
@@ -1684,7 +1777,7 @@ export class Room extends EventEmitter {
1684
1777
  toolCalls: [],
1685
1778
  };
1686
1779
  this.drafts.set(draft.id, draft);
1687
- runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now(), published: false };
1780
+ runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now(), published: false, hidden };
1688
1781
  let result = null;
1689
1782
  let failure = null;
1690
1783
  try {
@@ -1696,6 +1789,7 @@ export class Room extends EventEmitter {
1696
1789
  const startedAt = runtime.turn.startedAt;
1697
1790
  const published = runtime.turn.published;
1698
1791
  const publishedAt = runtime.turn.publishedAt ?? null;
1792
+ this.lastTurnHidden = !!runtime.turn.hidden;
1699
1793
  runtime.turn = null;
1700
1794
  runtime.turnActive = false;
1701
1795
  this.drafts.delete(draft.id);
@@ -1719,10 +1813,13 @@ export class Room extends EventEmitter {
1719
1813
  this.push({ type: "participant", participant });
1720
1814
  this.push({ type: "message.removed", id: draft.id });
1721
1815
  this.notice(`${participant.name}: turn failed: ${failure ?? "no result"}`, "error");
1722
- this.postSystem(`${participant.name} could not answer: ${(failure ?? "no result").slice(0, 240)}`);
1723
1816
  runtime.log.error(`turn failed: ${failure}`);
1724
1817
  if (retry)
1725
1818
  this.closeRetry(retry, "the correction turn failed; nothing was posted");
1819
+ if (isContextFullError(failure))
1820
+ this.contextFull(participant, runtime, failure ?? "");
1821
+ else
1822
+ this.postSystem(`${participant.name} could not answer: ${(failure ?? "no result").slice(0, 240)}`);
1726
1823
  return null;
1727
1824
  }
1728
1825
  return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published, publishedAt);
@@ -1730,10 +1827,36 @@ export class Room extends EventEmitter {
1730
1827
  finalizeTurn(participant, runtime, draft, result, durationMs, retry, published, publishedAt = null) {
1731
1828
  participant.status = "idle";
1732
1829
  this.push({ type: "participant", participant });
1733
- const text = draft.text.trim();
1734
1830
  const cancelled = result.stopReason === "cancelled";
1735
1831
  if (cancelled)
1736
1832
  runtime.briefPending = runtime.briefPending ?? "previous turn was cancelled";
1833
+ const extracted = extractNotes(draft.text);
1834
+ const hadNotes = this.keepNotes(participant, runtime, extracted.notes, runtime.turn?.hidden || draft.streaming === undefined ? "hidden turn" : "reply");
1835
+ draft.text = extracted.visible;
1836
+ const text = draft.text.trim();
1837
+ if (this.lastTurnHidden) {
1838
+ this.lastTurnHidden = false;
1839
+ this.commit({
1840
+ id: randomUUID(),
1841
+ seq: ++this.seq,
1842
+ from: participant.id,
1843
+ fromName: participant.name,
1844
+ to: [],
1845
+ toNames: [],
1846
+ text: hadNotes ? `took notes (${(participant.notes ?? "").split("\n").length} lines)` : "was asked for notes and gave none",
1847
+ ts: Date.now(),
1848
+ kind: "hidden",
1849
+ details: { original: draft.text, outcome: hadNotes ? "notes kept" : "no <notes> block in the reply" },
1850
+ });
1851
+ return null;
1852
+ }
1853
+ if (runtime.notesAskedThisTurn && !hadNotes && !retry && !cancelled) {
1854
+ runtime.notesMisses += 1;
1855
+ if (runtime.notesMisses >= 2) {
1856
+ runtime.notesMisses = 0;
1857
+ setImmediate(() => void this.takeNotes(participant.id).catch((error) => runtime.log.warn(`notes: hidden turn failed: ${describeError(error)}`)));
1858
+ }
1859
+ }
1737
1860
  if (!retry && text.toLowerCase() === REQUEST_BRIEF_MARKER) {
1738
1861
  this.push({ type: "message.removed", id: draft.id });
1739
1862
  if (runtime.briefRequestedAtSeq === runtime.lastSeenSeq) {
@@ -1765,7 +1888,8 @@ export class Room extends EventEmitter {
1765
1888
  }
1766
1889
  return null;
1767
1890
  }
1768
- if (!(draft.toolCalls?.length) && ADAPTER_ERROR_PATTERN.test(text)) {
1891
+ const bareContextFull = isBareContextFullError(text);
1892
+ if (!(draft.toolCalls?.length) && (ADAPTER_ERROR_PATTERN.test(text) || bareContextFull)) {
1769
1893
  this.push({ type: "message.removed", id: draft.id });
1770
1894
  participant.failedTurns = (participant.failedTurns ?? 0) + 1;
1771
1895
  participant.statusDetail = `agent error: ${text.replace(/\s+/g, " ").slice(0, 120)}${text.length > 120 ? "…" : ""}`;
@@ -1774,6 +1898,8 @@ export class Room extends EventEmitter {
1774
1898
  runtime.log.warn(`adapter error text treated as failed turn: ${text.slice(0, 200)}`);
1775
1899
  if (retry)
1776
1900
  this.closeRetry(retry, "the agent reported an error instead of a corrected reply");
1901
+ if (bareContextFull)
1902
+ this.contextFull(participant, runtime, text);
1777
1903
  return null;
1778
1904
  }
1779
1905
  const mentions = this.parseMentions(text);
@@ -1907,7 +2033,7 @@ export class Room extends EventEmitter {
1907
2033
  return corrections;
1908
2034
  }
1909
2035
  showDraft(turn) {
1910
- if (turn.published)
2036
+ if (turn.published || turn.hidden)
1911
2037
  return;
1912
2038
  turn.published = true;
1913
2039
  turn.publishedAt = Date.now();
@@ -1944,13 +2070,15 @@ export class Room extends EventEmitter {
1944
2070
  if (messageId)
1945
2071
  turn.sawMessageId = true;
1946
2072
  turn.messageId = messageId;
2073
+ const shown = visibleChunk(turn.message.text, text);
1947
2074
  turn.message.text += text;
1948
2075
  if (!turn.published) {
1949
- if (!looksSilent(turn.message.text))
2076
+ if (!looksSilent(turn.message.text) && shown)
1950
2077
  this.showDraft(turn);
1951
2078
  return;
1952
2079
  }
1953
- this.push({ type: "chunk", id: turn.message.id, text });
2080
+ if (shown)
2081
+ this.push({ type: "chunk", id: turn.message.id, text: shown });
1954
2082
  return;
1955
2083
  }
1956
2084
  case "agent_thought_chunk": {
@@ -2006,6 +2134,15 @@ export class Room extends EventEmitter {
2006
2134
  if (runtime.lastUsed > 0 && u.used < runtime.lastUsed * 0.7 && !runtime.briefPending) {
2007
2135
  runtime.briefPending = `context shrank from ${runtime.lastUsed} to ${u.used} tokens (compaction?)`;
2008
2136
  runtime.log.info(`usage dropped ${runtime.lastUsed} -> ${u.used}; brief scheduled`);
2137
+ participant.contextEvent = { kind: "compacted", at: Date.now(), used: u.used, size: u.size };
2138
+ 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");
2140
+ }
2141
+ if (crossedThreshold(runtime.lastUsed, u.used, u.size)) {
2142
+ runtime.notesDue = true;
2143
+ 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");
2145
+ runtime.log.info(`context at ${u.used}/${u.size}: notes due`);
2009
2146
  }
2010
2147
  runtime.lastUsed = u.used;
2011
2148
  this.push({ type: "participant", participant });
package/dist/server.js CHANGED
@@ -496,15 +496,21 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
496
496
  sendJson(res, 200, { ok: true, ...result });
497
497
  return;
498
498
  }
499
- const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute|respawn|staff)$/);
499
+ const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute|respawn|staff|notes|take-notes)$/);
500
500
  if (participantAction) {
501
501
  const room = hub.getRoom(decodeURIComponent(participantAction[1]));
502
502
  const id = decodeURIComponent(participantAction[2]);
503
503
  const action = participantAction[3];
504
504
  if (action === "cancel")
505
505
  room.cancelTurn(id);
506
- else if (action === "respawn")
507
- await room.respawnAgent(id);
506
+ else if (action === "respawn") {
507
+ const replay = body.replay === undefined || body.replay === null || body.replay === "" ? undefined : Number(body.replay);
508
+ await room.respawnAgent(id, { memory: body.memory === true || body.memory === "true", replay: replay !== undefined && Number.isFinite(replay) ? Math.max(0, Math.min(500, Math.round(replay))) : undefined });
509
+ }
510
+ else if (action === "notes")
511
+ room.updateNotes(id, String(body.notes ?? ""));
512
+ else if (action === "take-notes")
513
+ await room.takeNotes(id);
508
514
  else if (action === "staff") {
509
515
  const participant = await room.staff(id, {
510
516
  agentType: String(body.agentType ?? ""),
package/dist/tui.js CHANGED
@@ -72,6 +72,68 @@ export function renderDone(choice, title, opts, items = MENU) {
72
72
  export function menuLineCount(items = MENU) {
73
73
  return items.length + 5;
74
74
  }
75
+ export function renderInstalled(o, opts) {
76
+ const g = opts.unicode ? GLYPHS.unicode : GLYPHS.ascii;
77
+ const dim = (t) => paint(opts.color, "2", t);
78
+ const bold = (t) => paint(opts.color, "1", t);
79
+ const green = (t) => paint(opts.color, "32", t);
80
+ const bar = paint(opts.color, "36", g.bar);
81
+ const steps = o.platform === "win32"
82
+ ? [
83
+ `Press the ${bold("Windows key")}, type ${bold("viberoom")}, press Enter.`,
84
+ `Or double-click ${bold("viberoom")} on the Desktop.`,
85
+ `Pin it: once the window is open, right-click its icon in the taskbar and choose "Pin to taskbar".`,
86
+ ]
87
+ : o.platform === "darwin"
88
+ ? [
89
+ `Press ${bold("⌘ Space")}, type ${bold("viberoom")}, press Enter (Spotlight); or open it from Launchpad.`,
90
+ `It lives in ${bold("~/Applications/viberoom.app")}; drag it to the Dock to keep it there.`,
91
+ `If macOS asks whether to open it the first time, choose Open: the app was made on this machine.`,
92
+ ]
93
+ : [
94
+ `Press the ${bold("Super key")}, type ${bold("viberoom")}, press Enter; it is in the applications menu.`,
95
+ `On the Desktop: right-click ${bold("viberoom.desktop")} and choose "Allow launching" once if your desktop asks.`,
96
+ `Pin it: right-click the running icon in the dock and choose "Add to favorites" (or your desktop's equivalent).`,
97
+ ];
98
+ const lines = [
99
+ `${dim(g.top)} ${bold("The desktop icon is installed")}`,
100
+ dim(g.bar),
101
+ `${green(g.done)} ${bold("How to start viberoom from now on")}`,
102
+ ...steps.map((t) => `${bar} ${t}`),
103
+ bar,
104
+ `${green(g.done)} ${bold("What happens")}`,
105
+ `${bar} The icon starts the hub in the background and opens the app window.`,
106
+ `${bar} Closing the window keeps the hub running; "viberoom stop" in a terminal ends it.`,
107
+ `${bar} A newer version: the app tells you with a bubble over your avatar (Settings → Updates).`,
108
+ ];
109
+ if (o.browserAdvice)
110
+ lines.push(bar, `${paint(opts.color, "33", "!")} ${o.browserAdvice}`);
111
+ if (o.files.length || o.notes.length) {
112
+ lines.push(bar, `${green(g.done)} ${bold("Written")}`);
113
+ for (const f of o.files)
114
+ lines.push(`${bar} ${dim(f)}`);
115
+ for (const n of o.notes)
116
+ lines.push(`${bar} ${dim(n)}`);
117
+ }
118
+ lines.push(bar, `${dim(g.bottom)} ${dim("Press Enter to open viberoom now, or q to leave it for later.")}`);
119
+ return lines.join("\n") + "\n";
120
+ }
121
+ export function askEnter(stdin = process.stdin, stdout = process.stdout) {
122
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function")
123
+ return Promise.resolve(false);
124
+ return new Promise((resolve) => {
125
+ const onData = (data) => {
126
+ stdin.off("data", onData);
127
+ stdin.setRawMode(false);
128
+ stdin.pause();
129
+ const s = data.toString();
130
+ resolve(s === "\r" || s === "\n");
131
+ };
132
+ stdin.setRawMode(true);
133
+ stdin.resume();
134
+ stdin.on("data", onData);
135
+ });
136
+ }
75
137
  export function runMenu(title, stdin = process.stdin, stdout = process.stdout) {
76
138
  if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function")
77
139
  return Promise.resolve(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
5
5
  "type": "module",
6
6
  "engines": {
@@ -13,12 +13,14 @@
13
13
  {
14
14
  "name": "Proposer",
15
15
  "tagline": "makes the strongest case for a design",
16
- "role": "You propose designs and defend them with evidence from the code. State the approach, its cost, and what it makes easy. When the Skeptic finds a real hole, concede it and adjust the proposal rather than restating it. You read code; you never edit it."
16
+ "role": "You propose designs and defend them with evidence from the code. State the approach, its cost, and what it makes easy. When the Skeptic finds a real hole, concede it and adjust the proposal rather than restating it. You read code; you never edit it.",
17
+ "avatar": "🎨"
17
18
  },
18
19
  {
19
20
  "name": "Skeptic",
20
21
  "tagline": "finds what breaks it",
21
- "role": "You look for what breaks a proposed design: the edge case, the migration cost, the thing that exists in the code and contradicts the plan. Name files and lines. Be specific and brief; a hole is worth more than a list of doubts. When a proposal survives, say so. You read code; you never edit it."
22
+ "role": "You look for what breaks a proposed design: the edge case, the migration cost, the thing that exists in the code and contradicts the plan. Name files and lines. Be specific and brief; a hole is worth more than a list of doubts. When a proposal survives, say so. You read code; you never edit it.",
23
+ "avatar": "🧐"
22
24
  }
23
25
  ]
24
26
  }
@@ -11,7 +11,8 @@
11
11
  {
12
12
  "name": "Explainer",
13
13
  "tagline": "explains the code, never touches it",
14
- "role": "You explain how this codebase works. Read whatever you need, then answer in plain language with file paths and line numbers, quoting the code when it settles a question. Structure long answers as a short walk through the flow. You never edit, create or delete files."
14
+ "role": "You explain how this codebase works. Read whatever you need, then answer in plain language with file paths and line numbers, quoting the code when it settles a question. Structure long answers as a short walk through the flow. You never edit, create or delete files.",
15
+ "avatar": "🎓"
15
16
  }
16
17
  ]
17
18
  }
@@ -11,7 +11,8 @@
11
11
  {
12
12
  "name": "Pair",
13
13
  "tagline": "codes with you, one step at a time",
14
- "role": "You are a pair programmer. The human drives: you read the code first, make one small change at a time, run or test it, and report what changed with file paths. Propose the next step; do not take it until asked. When unsure, ask one precise question rather than guessing."
14
+ "role": "You are a pair programmer. The human drives: you read the code first, make one small change at a time, run or test it, and report what changed with file paths. Propose the next step; do not take it until asked. When unsure, ask one precise question rather than guessing.",
15
+ "avatar": "🤝"
15
16
  }
16
17
  ]
17
18
  }
@@ -11,18 +11,20 @@
11
11
  "turnTaking": "parallel",
12
12
  "agentsWakeEachOther": true,
13
13
  "hopLimit": 24,
14
- "customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to the one who has worked on that part the most, or most recently; if neither has touched it, to Wren. A question without @ goes to Quinn.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case. The builder reports to the human, never to the other vibemate. A build is reported as: what changed (the files, and the commit if the project uses version control), how to verify it (a command, a script, a screenshot), what was not verified, what is left.\nMeasure before diagnosing; say no with a reason and a cheaper alternative; keep replies short.\nWork only on your own task, never on the other's; do not touch what the other is working on.\nReply only when addressed. On a message to everyone, only the one it concerns answers; the other stays silent. Never add to, correct or comment on the other's reply unless the human asks.\nFollow the conventions of the project you work in (its instructions, notes, tests). Where it keeps decision notes, write the decision there before reporting it here.\nRead every proposal and analyse it; agree or reject only with a real argument. Never concede to be agreeable, never object to seem rigorous.\nReply in the human's language. Code identifiers, commands, file paths, protocol and tool names stay exactly as in the code; established technical terms (kernel, thread, commit, pull request, layout) stay in English."
14
+ "customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to the one who has worked on that part the most, or most recently; if neither has touched it, to Wren. A question without @ goes to Quinn.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case. Whoever built reports to the human. Address the other vibemate too only when the report changes something it works on or relies on: a shared file, an interface, a convention, a measurement that overturns its claim; then say in one line what you want from it (\"nothing, for your context\" counts). Otherwise it reads the report later, unaddressed. A report is never a request for review; only the human asks for one. A build is reported as: what changed (the files, and the commit if the project uses version control), how to verify it (a command, a script, a screenshot), what was not verified, what is left.\nMeasure before diagnosing; say no with a reason and a cheaper alternative; keep replies short.\nWork only on your own task, never on the other's; do not touch what the other is working on.\nReply only when addressed. On a message to everyone, only the one it concerns answers; the other stays silent. Never add to, correct or comment on the other's reply unless the human asks.\nFollow the conventions of the project you work in (its instructions, notes, tests). Where it keeps decision notes, write the decision there before reporting it here.\nRead every proposal and analyse it; agree or reject only with a real argument. Never concede to be agreeable, never object to seem rigorous.\nReply in the human's language. Code identifiers, commands, file paths, protocol and tool names stay exactly as in the code; established technical terms (kernel, thread, commit, pull request, layout) stay in English."
15
15
  },
16
16
  "vibemates": [
17
17
  {
18
18
  "name": "Wren",
19
19
  "tagline": "builds by default; explains and reviews when asked",
20
- "role": "You are Wren, one of two equal vibemates, Wren and Quinn. You lean to building: a task without an address is yours when it touches what you have built, or when neither of you has; before you change anything, read what is already there, the notes, the docs and the code, and keep each change small. Everything else is in the room rules."
20
+ "role": "You are Wren, one of two equal vibemates, Wren and Quinn. You lean to building: a task without an address is yours when it touches what you have built, or when neither of you has; before you change anything, read what is already there, the notes, the docs and the code, and keep each change small. Everything else is in the room rules.",
21
+ "avatar": "🔨"
21
22
  },
22
23
  {
23
24
  "name": "Quinn",
24
25
  "tagline": "explains by default; builds and reviews when asked",
25
- "role": "You are Quinn, one of two equal vibemates, Wren and Quinn. You lean to explaining: a question without an address is yours; when you review, it is by a criterion, not an impression. Everything else is in the room rules."
26
+ "role": "You are Quinn, one of two equal vibemates, Wren and Quinn. You lean to explaining: a question without an address is yours; when you review, it is by a criterion, not an impression. Everything else is in the room rules.",
27
+ "avatar": "💡"
26
28
  }
27
29
  ]
28
30
  }
package/ui/app.css CHANGED
@@ -92,6 +92,49 @@
92
92
  .side-label { padding: 12px 20px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--faint); font-weight: 800; }
93
93
  .side-list { list-style: none; margin: 0; padding: 4px 14px 10px; overflow-y: auto; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 6px; }
94
94
  .side-list li { display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 14px; cursor: pointer; transition: background var(--t-fast); animation: slide-in-left var(--t-base) var(--ease-out); }
95
+ .participants .avatar.has-life { cursor: pointer; }
96
+ .participants li { position: relative; }
97
+ .participants .p-gear { position: absolute; top: 4px; right: 6px; width: 24px; height: 24px; opacity: 0; transition: opacity var(--t-fast); }
98
+ .participants .p-gear .i { width: 14px; height: 14px; }
99
+ .participants li:hover .p-gear, .participants li.selected .p-gear { opacity: 1; }
100
+ .participants .life-q { position: absolute; top: -5px; right: -5px; width: 15px; height: 15px; border-radius: 50%; background: #fff; color: var(--muted); font-size: 10px; font-weight: 800; display: grid; place-items: center; box-shadow: var(--shadow-tile); }
101
+ .participants .avatar .life { position: absolute; inset: -4px; width: calc(100% + 8px); height: calc(100% + 8px); pointer-events: none; overflow: visible; }
102
+ .life path { fill: none; stroke-width: 3; }
103
+ .life .life-track { stroke: var(--softer); }
104
+ .life-unknown .life-track { stroke: var(--softer); stroke-width: 2; stroke-dasharray: 1 4; stroke-linecap: round; }
105
+ .life .life-arc { stroke-linecap: round; transition: stroke-dasharray var(--t-base) var(--ease-out), stroke var(--t-fast); }
106
+ .life-ok .life-arc, .life-fresh .life-arc { stroke: var(--st-ready-dot); }
107
+ .life-warm .life-arc { stroke: var(--st-thinking-dot); }
108
+ .life-hot .life-arc { stroke: var(--st-error-dot); }
109
+ .participants .avatar.life-attn .life-track { stroke: var(--attention); animation: pulse 1.2s ease-in-out infinite; }
110
+ .lp-event { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; color: var(--muted); }
111
+ .lp-event.fresh { color: var(--attention-ink); }
112
+ .lp-event .i { width: 14px; height: 14px; flex: none; }
113
+ .life-pop { position: fixed; z-index: 60; display: flex; gap: 6px; align-items: flex-start; width: 330px; padding: 12px 8px 12px 14px; background: #fff; border-radius: 6px 18px 18px 18px; box-shadow: var(--shadow-pop); font-size: 13px; animation: bubble-in var(--t-base) var(--ease-out); }
114
+ .life-pop::before { content: ""; position: absolute; left: -6px; top: 18px; width: 12px; height: 12px; background: #fff; border-radius: 0 0 0 3px; transform: rotate(45deg); }
115
+ .life-pop .lp-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
116
+ .life-pop .lp-side { flex: none; display: flex; flex-direction: column; gap: 2px; }
117
+ .life-pop .icon-btn.sm { width: 26px; height: 26px; }
118
+ .lp-head { display: flex; align-items: center; gap: 10px; }
119
+ .lp-head b { font-size: 14px; }
120
+ .lp-sub { color: var(--muted); font-size: 12px; font-weight: 600; margin-top: 2px; }
121
+ .lp-sub b { color: var(--ink); }
122
+ .life-bar { height: 6px; border-radius: 3px; background: var(--softer); overflow: hidden; }
123
+ .life-bar i { display: block; height: 100%; border-radius: 3px; transition: width var(--t-base) var(--ease-out); }
124
+ .life-bar.life-ok i, .life-bar.life-fresh i { background: var(--st-ready-dot); }
125
+ .life-bar.life-warm i { background: var(--st-thinking-dot); }
126
+ .life-bar.life-hot i { background: var(--st-error-dot); }
127
+ .life-bar.life-unknown { background: repeating-linear-gradient(90deg, var(--softer) 0 4px, transparent 4px 7px); }
128
+ .lp-kv { font-size: 12.5px; }
129
+ .lp-kv .i { width: 12px; height: 12px; vertical-align: -2px; }
130
+ .lp-kv .link-btn { border: 0; background: none; padding: 0; font: inherit; font-weight: 700; color: var(--primary); cursor: pointer; }
131
+ .lp-kv .link-btn:disabled { color: var(--muted); cursor: default; }
132
+ .lp-editor { display: flex; flex-direction: column; gap: 6px; }
133
+ .lp-editor textarea { width: 100%; box-sizing: border-box; font: 600 12px/1.45 var(--font); }
134
+ .lp-notes { margin: 0; padding: 8px 10px; background: var(--soft); border-radius: 10px; font: 600 12px/1.45 var(--font); white-space: pre-wrap; max-height: 140px; overflow: auto; color: var(--ink-2); }
135
+ .lp-respawn { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
136
+ .lp-with { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--muted); }
137
+ .lp-with input { width: 58px; padding: 4px 6px; margin: 0; }
95
138
  .side-list li:hover { background: var(--soft); }
96
139
  .side-list li.selected { background: var(--lav); }
97
140
  .side-list li.offline .avatar .av-tile { filter: grayscale(0.6); opacity: 0.8; }
@@ -206,6 +249,7 @@
206
249
  .day { align-self: center; color: var(--faint); font-size: 11px; font-weight: 800; padding: 3px 12px; margin: 2px 0; }
207
250
  .msg { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; max-width: 100%; animation: bubble-in var(--t-base) var(--ease-out); }
208
251
  .messages .msg { content-visibility: auto; contain-intrinsic-size: auto 120px; flex-shrink: 0; }
252
+ .messages .msgs-page { display: flex; flex-direction: column; gap: 14px; flex-shrink: 0; content-visibility: auto; contain-intrinsic-size: auto 6000px; }
209
253
  .msg.agent { padding-right: 40px; }
210
254
  .msg.mine { align-items: flex-end; padding-left: 40px; }
211
255
  .head-av { display: inline-flex; width: 32px; height: 32px; padding: 4px; box-sizing: content-box; flex: none; }
@@ -214,6 +258,7 @@
214
258
  .msg.mine .head { flex-direction: row-reverse; }
215
259
  .msg.system { justify-content: center; }
216
260
  .msg.hidden-by-search { display: none; }
261
+ .messages.searching .msgs-page { content-visibility: visible; }
217
262
  .sys { color: var(--muted); font-size: 11px; font-weight: 700; padding: 2px 10px; max-width: 80%; text-align: center; }
218
263
  .sys.warn { color: var(--warm-ink); background: var(--warm); border-radius: var(--r-pill); padding: 6px 12px; font-weight: 800; }
219
264
  .bubble-col { display: flex; flex-direction: column; gap: 6px; min-width: 0; width: 100%; max-width: min(1040px, 100%); }
@@ -272,17 +317,17 @@
272
317
  .jump-latest { position: absolute; right: 30px; bottom: 96px; z-index: 5; display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px 0 10px; border: 0; border-radius: var(--r-pill); background: var(--card); color: var(--primary); font: inherit; font-size: 12px; font-weight: 800; box-shadow: var(--shadow-pop); cursor: pointer; animation: rise var(--t-base) var(--ease-out); }
273
318
  .jump-latest:hover { background: var(--soft); }
274
319
  .done-notes { position: absolute; left: 30px; bottom: 96px; z-index: 5; display: flex; flex-direction: column-reverse; gap: 6px; }
275
- .done-note { display: inline-flex; align-items: center; gap: 8px; max-width: 230px; padding: 6px 12px 6px 8px; border: 0; border-radius: 14px; background: var(--warm); color: var(--warm-ink); font: inherit; text-align: left; cursor: pointer; box-shadow: var(--shadow-pop); animation: rise var(--t-base) var(--ease-out); overflow: hidden; }
276
- .done-note:hover { background: var(--note-hover); }
320
+ .done-note { display: inline-flex; align-items: center; gap: 8px; max-width: 230px; padding: 6px 12px 6px 8px; border: 0; border-radius: 14px; background: var(--done); color: var(--done-ink); font: inherit; text-align: left; cursor: pointer; box-shadow: var(--shadow-pop); animation: rise var(--t-base) var(--ease-out); overflow: hidden; }
321
+ .done-note:hover { background: var(--done-hover); }
277
322
  .done-note .avatar { flex: none; }
278
323
  .done-go { display: inline-flex; align-items: center; gap: 8px; min-width: 0; padding: 6px 4px 6px 8px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
279
- .done-go:hover { background: var(--note-hover); }
324
+ .done-go:hover { background: var(--done-hover); }
280
325
  .done-go .avatar { flex: none; }
281
326
  .done-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
282
327
  .done-text b { font-size: 12px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
283
328
  .done-text small { font-size: 11px; font-weight: 600; opacity: 0.8; white-space: nowrap; }
284
329
  .done-x { border: 0; background: transparent; color: inherit; opacity: 0.6; font-size: 15px; line-height: 1; padding: 0 9px; cursor: pointer; }
285
- .done-x:hover { opacity: 1; background: var(--note-hover); }
330
+ .done-x:hover { opacity: 1; background: var(--done-hover); }
286
331
  .jump-latest .i { width: 16px; height: 16px; }
287
332
  .bubble { max-width: 100%; background: #fff; border-radius: 6px 18px 18px 18px; padding: 12px 16px; min-width: 0; font-size: 14.5px; font-weight: 600; line-height: 1.55; color: var(--ink-2); box-shadow: 0 2px 0 rgba(28, 27, 51, 0.06), 0 1px 3px rgba(28, 27, 51, 0.04); }
288
333
  .msg.mine .bubble { background: var(--grad-primary); color: #fff; border-radius: 18px 18px 6px 18px; box-shadow: 0 8px 18px -10px rgba(91, 91, 240, 0.7); padding: 11px 16px; }
@@ -494,6 +539,7 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
494
539
  .lightbox img { max-width: calc(100vw - 48px); max-height: calc(100vh - 48px); border-radius: 12px; box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.6); background: #fff; }
495
540
  .composer textarea { flex: 1; display: block; box-sizing: border-box; resize: none; padding: 8px 0; margin: 0; border: 0; border-radius: 0; background: transparent; height: 36px; min-height: 36px; line-height: 20px; font-size: 14px; font-weight: 600; outline: none; color: var(--ink); overflow-y: auto; }
496
541
  .composer textarea::placeholder { color: var(--placeholder); font-weight: 600; }
542
+ @supports (field-sizing: content) { .composer textarea { field-sizing: content; height: auto; } }
497
543
  .send-btn { width: 44px; height: 44px; border-radius: 14px; border: 0; background: var(--grad-primary); color: #fff; display: grid; place-content: center; box-shadow: var(--shadow-primary); flex: none; transition: transform var(--t-fast) var(--ease-out), filter var(--t-fast); }
498
544
  .send-btn .i { width: 18px; height: 18px; display: block; }
499
545
  .composer-clear { width: 28px; height: 28px; border-radius: 50%; border: 0; background: transparent; color: var(--placeholder); font-size: 20px; line-height: 1; cursor: pointer; flex: none; margin-right: 2px; transition: background var(--t-fast), color var(--t-fast); }
@@ -630,8 +676,8 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
630
676
  .composer.gated textarea { cursor: not-allowed; }
631
677
  .check-list.locked { pointer-events: none; opacity: 0.6; }
632
678
  .msg.system.done { justify-content: center; }
633
- .done-row { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px 6px 8px; border: 0; border-radius: var(--r-pill); background: var(--warm); color: var(--warm-ink); font: inherit; font-size: 12px; font-weight: 800; cursor: pointer; box-shadow: var(--edge); }
634
- .done-row:hover { background: var(--note-hover); }
679
+ .done-row { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px 6px 8px; border: 0; border-radius: var(--r-pill); background: var(--done); color: var(--done-ink); font: inherit; font-size: 12px; font-weight: 800; cursor: pointer; box-shadow: var(--edge); }
680
+ .done-row:hover { background: var(--done-hover); }
635
681
  .done-notes { flex-direction: column; }
636
682
  .done-note.new { background: var(--card); color: var(--ink-2); }
637
683
  .done-note.new:hover { background: var(--soft); }
package/ui/app.js CHANGED
@@ -555,7 +555,7 @@
555
555
  }
556
556
  function fmtTokens(n) {
557
557
  if (n === undefined || n === null) return "";
558
- return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
558
+ return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
559
559
  }
560
560
  function fmtCost(cost) {
561
561
  return cost ? `${cost.amount.toFixed(3)} ${cost.currency}` : "";
@@ -1170,7 +1170,7 @@
1170
1170
  if (!li) {
1171
1171
  li = document.createElement("li");
1172
1172
  li.dataset.id = p.id;
1173
- li.innerHTML = avatarHtml + bodyHtml;
1173
+ li.innerHTML = avatarHtml + bodyHtml + (p.kind === "agent" ? `<button type="button" class="icon-btn sm p-gear" title="Open ${esc(p.name)}'s panel">${ic("settings")}</button>` : "");
1174
1174
  li.dataset.avatar = avatarHtml;
1175
1175
  if (statusClass) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
1176
1176
  li.dataset.body = bodyHtml;
@@ -1189,10 +1189,12 @@
1189
1189
  }
1190
1190
  }
1191
1191
  if (li.className !== className) li.className = className;
1192
+ if (p.kind === "agent" && !unstaffed) patchLifeRing(li, p);
1192
1193
  if (li !== els.participants.children[ordered.indexOf(p)]) els.participants.appendChild(li);
1193
1194
  rows.delete(p.id);
1194
1195
  }
1195
1196
  for (const li of rows.values()) li.remove();
1197
+ renderLifePop();
1196
1198
  renderHushButton(room);
1197
1199
  els.reconnectAllBtn.hidden = offlineAgents(room).length === 0;
1198
1200
  }
@@ -1255,7 +1257,7 @@
1255
1257
  if (d.skill) {
1256
1258
  el.innerHTML = `
1257
1259
  <details class="hidden-turn">
1258
- <summary>${ic("skills")} hub ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.via ? ` (${esc(d.via)})` : ""}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : ""}</summary>
1260
+ <summary>${ic("skills")} room ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.via ? ` (${esc(d.via)})` : ""}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : ""}</summary>
1259
1261
  <div class="hidden-body">
1260
1262
  ${d.original ? `<div class="hidden-label">Held reply (nobody in the room saw it)</div><div class="hidden-text">${esc(d.original)}</div>` : ""}
1261
1263
  <div class="hidden-label">What happened</div>
@@ -1266,7 +1268,7 @@
1266
1268
  }
1267
1269
  el.innerHTML = `
1268
1270
  <details class="hidden-turn">
1269
- <summary>${ic("tool")} hub ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : " · <em>waiting for the corrected reply…</em>"}</summary>
1271
+ <summary>${ic("tool")} room ↔ ${esc(m.fromName)} · ${esc(m.text)}${d.outcome ? ` · <em>${esc(d.outcome)}</em>` : " · <em>waiting for the corrected reply…</em>"}</summary>
1270
1272
  <div class="hidden-body">
1271
1273
  <div class="hidden-label">Held reply (nobody in the room saw it)</div>
1272
1274
  <div class="hidden-text">${esc(d.original || "")}</div>
@@ -1574,9 +1576,29 @@
1574
1576
  return el;
1575
1577
  }
1576
1578
 
1579
+ const PAGE_SIZE = 50;
1580
+ function placeInList(el) {
1581
+ let page = els.messages.lastElementChild;
1582
+ if (!page || !page.classList.contains("msgs-page") || page.childElementCount >= PAGE_SIZE) {
1583
+ page = document.createElement("div");
1584
+ page.className = "msgs-page";
1585
+ els.messages.appendChild(page);
1586
+ }
1587
+ page.appendChild(el);
1588
+ }
1589
+ function topInList(el) {
1590
+ const page = el.parentElement;
1591
+ if (!page || !page.classList.contains("msgs-page")) return el.offsetTop;
1592
+ if (els.messages.classList.contains("searching") || page.firstElementChild.checkVisibility({ contentVisibilityAuto: true })) return page.offsetTop + el.offsetTop;
1593
+ let i = 0;
1594
+ for (let n = el.previousElementSibling; n; n = n.previousElementSibling) i++;
1595
+ return page.offsetTop + (page.offsetHeight * i) / page.childElementCount;
1596
+ }
1597
+
1577
1598
  function renderMessages() {
1578
1599
  const room = currentRoom();
1579
1600
  els.messages.innerHTML = "";
1601
+ els.messages.classList.toggle("searching", !!state.search);
1580
1602
  if (!room) return;
1581
1603
  if (!room.messages.length) {
1582
1604
  els.messages.innerHTML = `<div class="empty"><div class="art">${ic("chat")}</div><strong>${esc(room.name)}</strong> is quiet.<br>Summon a vibemate from the left, then say hello. Use @Name to address someone; without @ every vibemate hears you.</div>`;
@@ -1592,20 +1614,20 @@
1592
1614
  d.className = "day";
1593
1615
  d.textContent = day;
1594
1616
  d.title = new Date(m.ts).toLocaleDateString([], { weekday: "long", day: "numeric", month: "long", year: "numeric" });
1595
- els.messages.appendChild(d);
1617
+ placeInList(d);
1596
1618
  lastDay = day;
1597
1619
  }
1598
1620
  for (const [seq, agents] of markers) {
1599
1621
  if (placed.has(seq) || !(m.seq >= seq)) continue;
1600
1622
  if (m.seq > 0) {
1601
1623
  placed.add(seq);
1602
- els.messages.appendChild(dividerElement(agents));
1624
+ placeInList(dividerElement(agents));
1603
1625
  }
1604
1626
  }
1605
- els.messages.appendChild(messageElement(room, m));
1627
+ placeInList(messageElement(room, m));
1606
1628
  }
1607
1629
  for (const [seq, agents] of markers) {
1608
- if (!placed.has(seq)) els.messages.appendChild(dividerElement(agents));
1630
+ if (!placed.has(seq)) placeInList(dividerElement(agents));
1609
1631
  }
1610
1632
  for (const perm of room.permissions) renderPermission(room, perm);
1611
1633
  refreshSeen(room);
@@ -1638,7 +1660,7 @@
1638
1660
  else {
1639
1661
  const empty = els.messages.querySelector(".empty");
1640
1662
  if (empty) empty.remove();
1641
- els.messages.appendChild(messageElement(room, m));
1663
+ placeInList(messageElement(room, m));
1642
1664
  if (m.from === "human") refreshSeen(room);
1643
1665
  if (m.streaming && m.from !== "human") renderSideRoom();
1644
1666
  else if (!stick && m.kind === "chat") noteNew(room, m);
@@ -1917,7 +1939,8 @@
1917
1939
  <div class="section danger">
1918
1940
  ${sectionTitle("bolt", "Respawn")}
1919
1941
  <p class="hint">${esc(p.name)} comes back with an empty head: it forgets this conversation entirely. The room's history stays and you still see everything.${geekTip("A session's context cannot be erased, so the vibemate's process and session are closed and it starts a new one with no replay. Its stored session is dropped too, or a later reconnect would bring the old context back. Same thing as typing /respawn @Name in the composer.")}</p>
1920
- <div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button></div>
1942
+ <div class="row-btns start"><button class="btn danger sm" data-act="respawn">${ic("bolt")}Respawn ${esc(p.name)}</button><label class="lp-with"><button type="button" class="btn sm" data-act="respawn-mem">With the last</button><input type="number" id="pp-respawn-n" min="0" max="500" value="${room.settings.replayAfterRestart ?? 10}"> messages</label></div>
1943
+ <p class="hint">With memory: a new session that gets only ${p.notes ? "its own notes and " : ""}the last N messages of this room; the rest is gone.</p>
1921
1944
  </div>
1922
1945
  <div class="section">
1923
1946
  ${sectionTitle("info", "Stats")}
@@ -1937,17 +1960,9 @@
1937
1960
  )}`;
1938
1961
  wireDetailsClose();
1939
1962
  const respawnBtn = els.detailsInner.querySelector('button[data-act="respawn"]');
1940
- if (respawnBtn) {
1941
- respawnBtn.addEventListener("click", async () => {
1942
- const ok = await confirmDialog(`${p.name} forgets this whole conversation and starts over. You keep the history; it does not.`, { title: `Respawn ${p.name}?`, okLabel: "Respawn", danger: true });
1943
- if (!ok) return;
1944
- try {
1945
- await post(roomApi(`/participants/${encodeURIComponent(p.id)}/respawn`));
1946
- } catch (e) {
1947
- showError(e);
1948
- }
1949
- });
1950
- }
1963
+ if (respawnBtn) respawnBtn.addEventListener("click", () => respawnWith(p, 0));
1964
+ const respawnMem = els.detailsInner.querySelector('button[data-act="respawn-mem"]');
1965
+ if (respawnMem) respawnMem.addEventListener("click", () => respawnWith(p, Number($("#pp-respawn-n").value) || 0));
1951
1966
  els.detailsInner.querySelectorAll(".action").forEach((btn) => {
1952
1967
  btn.addEventListener("click", async () => {
1953
1968
  const act = btn.dataset.act;
@@ -3450,21 +3465,33 @@
3450
3465
 
3451
3466
  let composerMin = Number(recall("composerH")) || 0;
3452
3467
  const composerCeiling = () => Math.max(120, els.app.clientHeight - 260);
3468
+ const fieldSizing = CSS.supports("field-sizing", "content");
3453
3469
  let autosizeQueued = false;
3454
3470
  function autosizeSoon() {
3455
- if (autosizeQueued) return;
3471
+ if (fieldSizing || autosizeQueued) return;
3456
3472
  autosizeQueued = true;
3457
3473
  requestAnimationFrame(() => {
3458
3474
  autosizeQueued = false;
3459
3475
  autosize();
3460
3476
  });
3461
3477
  }
3478
+ let composerBounds = "";
3462
3479
  function autosize() {
3463
3480
  const min = Math.max(36, composerMin);
3464
3481
  const cap = Math.max(180, min);
3482
+ if (fieldSizing) {
3483
+ const max = Math.min(composerCeiling(), cap);
3484
+ if (composerBounds === `${min}/${max}`) return;
3485
+ composerBounds = `${min}/${max}`;
3486
+ els.input.style.minHeight = `${min}px`;
3487
+ els.input.style.maxHeight = `${max}px`;
3488
+ return;
3489
+ }
3465
3490
  els.input.style.height = "auto";
3466
3491
  els.input.style.height = Math.min(composerCeiling(), Math.max(min, Math.min(cap, els.input.scrollHeight))) + "px";
3467
3492
  }
3493
+ window.addEventListener("resize", autosize);
3494
+ autosize();
3468
3495
  {
3469
3496
  const grip = $("#composer-grip");
3470
3497
  let drag = null;
@@ -3625,6 +3652,13 @@
3625
3652
  if (!li || !room) return;
3626
3653
  const p = findById(room, li.dataset.id);
3627
3654
  if (!p) return;
3655
+ if (p.kind === "agent" && p.status !== "unstaffed" && e.target.closest(".avatar")) {
3656
+ if (!lifePop || lifePop.id !== p.id) openLifePop(p, li);
3657
+ else if (lifePop.hover) lifePop.hover = false;
3658
+ else closeLifePop();
3659
+ return;
3660
+ }
3661
+ if (e.target.closest(".p-gear")) return openDetails({ kind: "participant", id: p.id });
3628
3662
  if (e.target.closest(".stop-btn")) return void post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`)).catch(showError);
3629
3663
  if (e.target.closest(".reconnect-btn")) return openReconnectDialog(room);
3630
3664
  if (e.target.closest("button")) return;
@@ -4125,7 +4159,7 @@
4125
4159
  if (!nodes.length) return;
4126
4160
  const total = els.messages.scrollHeight || 1;
4127
4161
  const h = Math.max(0, t.ticks.clientHeight - TICK_H);
4128
- const tops = nodes.map((el) => el.offsetTop);
4162
+ const tops = nodes.map(topInList);
4129
4163
  const frag = document.createDocumentFragment();
4130
4164
  nodes.forEach((el, i) => {
4131
4165
  const tick = document.createElement("div");
@@ -4148,7 +4182,7 @@
4148
4182
  t.view.style.height = `${Math.max(8, (m.clientHeight / total) * h)}px`;
4149
4183
  const top = m.scrollTop;
4150
4184
  const bottom = m.scrollTop + m.clientHeight;
4151
- const inView = t.items.map((el) => el.offsetTop + el.offsetHeight > top && el.offsetTop < bottom);
4185
+ const inView = t.items.map((el) => { const y = topInList(el); return y + el.offsetHeight > top && y < bottom; });
4152
4186
  inView.forEach((on, i) => {
4153
4187
  const tick = t.ticks.children[i];
4154
4188
  if (tick) tick.classList.toggle("in-view", on);
@@ -4290,8 +4324,10 @@
4290
4324
  renderPins();
4291
4325
  }
4292
4326
  function updateTimelineView() { for (const t of timelines) t.updateView(); }
4327
+ const composerFollowers = [$("#timeline"), $("#timeline-left"), els.mentionMenu, els.emojiMenu];
4293
4328
  new ResizeObserver(() => {
4294
- els.app.style.setProperty("--composer-h", `${els.composer.offsetHeight}px`);
4329
+ const h = `${els.composer.offsetHeight}px`;
4330
+ for (const el of composerFollowers) el.style.setProperty("--composer-h", h);
4295
4331
  renderTimeline();
4296
4332
  }).observe(els.composer);
4297
4333
  new ResizeObserver(() => renderTimeline()).observe(els.messages);
@@ -4332,6 +4368,181 @@
4332
4368
  else scrollToBottom();
4333
4369
  });
4334
4370
 
4371
+ function lifeOf(p) {
4372
+ if (!p.contextSize) return (p.turns || 0) === 0 && !p.contextUsed ? { left: 100, tone: "fresh" } : { left: null, tone: "unknown" };
4373
+ const left = Math.max(0, Math.min(100, 100 - Math.round((100 * (p.contextUsed || 0)) / p.contextSize)));
4374
+ return { left, tone: left <= 20 ? "hot" : left <= 50 ? "warm" : "ok" };
4375
+ }
4376
+ const LIFE_PATH = "M26 2.5H32.5A17 17 0 0 1 49.5 19.5V32.5A17 17 0 0 1 32.5 49.5H19.5A17 17 0 0 1 2.5 32.5V19.5A17 17 0 0 1 19.5 2.5H26";
4377
+ const LIFE_RING = `<svg class="life" viewBox="0 0 52 52" aria-hidden="true"><path class="life-track" d="${LIFE_PATH}" pathLength="100"/><path class="life-arc" d="${LIFE_PATH}" pathLength="100"/></svg>`;
4378
+ function patchLifeRing(li, p) {
4379
+ const av = li.querySelector(".avatar");
4380
+ if (!av) return;
4381
+ let ring = av.querySelector(".life");
4382
+ if (!ring) {
4383
+ av.insertAdjacentHTML("afterbegin", LIFE_RING);
4384
+ ring = av.querySelector(".life");
4385
+ av.classList.add("has-life");
4386
+ }
4387
+ const { left, tone } = lifeOf(p);
4388
+ const cls = `life life-${tone}`;
4389
+ if (ring.getAttribute("class") !== cls) ring.setAttribute("class", cls);
4390
+ ring.querySelector(".life-arc").style.strokeDasharray = left === null ? "0 100" : `${left} 100`;
4391
+ const q = av.querySelector(".life-q");
4392
+ if (tone === "unknown" && !q) av.insertAdjacentHTML("beforeend", '<span class="life-q" title="This agent does not report its context">?</span>');
4393
+ else if (tone !== "unknown" && q) q.remove();
4394
+ const ev = recentContextEvent(p);
4395
+ av.classList.toggle("life-attn", !!ev && ev.kind !== "threshold");
4396
+ av.title = (tone === "fresh" ? "Context: fresh, nothing used yet." : tone === "unknown" ? "Context: not reported by this agent." : `Context ${fmtTokens(p.contextUsed)} of ${fmtTokens(p.contextSize)} used · ${left} % left.`) + (ev ? ` ${contextEventText(p, ev)}.` : "") + " Click for details.";
4397
+ }
4398
+ const CONTEXT_EVENT_FRESH_MS = 15 * 60 * 1000;
4399
+ function recentContextEvent(p) {
4400
+ const ev = p.contextEvent;
4401
+ return ev && Date.now() - ev.at < CONTEXT_EVENT_FRESH_MS ? ev : null;
4402
+ }
4403
+ function contextEventText(p, ev) {
4404
+ const when = new Date(ev.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
4405
+ if (ev.kind === "compacted") return `Compacted its context at ${when} (now ${fmtTokens(ev.used)})`;
4406
+ if (ev.kind === "full") return `Ran out of context at ${when}; respawned with memory`;
4407
+ return `Over 80 % since ${when}; leaves notes with its replies`;
4408
+ }
4409
+ let lifePop = null;
4410
+ let hoverTimer = null;
4411
+ function openLifePop(p, li, hover) {
4412
+ closeLifePop();
4413
+ const el = document.createElement("div");
4414
+ el.className = "life-pop";
4415
+ document.body.appendChild(el);
4416
+ lifePop = { id: p.id, anchor: li.querySelector(".avatar"), el, n: null, hover: !!hover };
4417
+ el.addEventListener("mouseenter", () => clearTimeout(hoverTimer));
4418
+ el.addEventListener("mouseleave", () => lifePop && lifePop.hover && scheduleHoverClose());
4419
+ renderLifePop();
4420
+ }
4421
+ function scheduleHoverClose() {
4422
+ clearTimeout(hoverTimer);
4423
+ hoverTimer = setTimeout(() => {
4424
+ if (lifePop && lifePop.hover && !lifePop.el.matches(":hover") && !lifePop.anchor.matches(":hover")) closeLifePop();
4425
+ }, 180);
4426
+ }
4427
+ els.participants.addEventListener("mouseover", (e) => {
4428
+ const av = e.target.closest("li[data-id] .avatar");
4429
+ const li = av && av.closest("li[data-id]");
4430
+ if (!av || (e.relatedTarget && av.contains(e.relatedTarget))) return;
4431
+ const room = currentRoom();
4432
+ const p = room && findById(room, li.dataset.id);
4433
+ if (!p || p.kind !== "agent" || p.status === "unstaffed") return;
4434
+ clearTimeout(hoverTimer);
4435
+ if (lifePop && lifePop.id === p.id) return;
4436
+ hoverTimer = setTimeout(() => {
4437
+ if (!lifePop || lifePop.hover) openLifePop(p, li, true);
4438
+ }, 220);
4439
+ });
4440
+ els.participants.addEventListener("mouseout", (e) => {
4441
+ const av = e.target.closest("li[data-id] .avatar");
4442
+ const li = av && av.closest("li[data-id]");
4443
+ if (!av || (e.relatedTarget && av.contains(e.relatedTarget))) return;
4444
+ clearTimeout(hoverTimer);
4445
+ if (lifePop && lifePop.hover && lifePop.id === li.dataset.id) scheduleHoverClose();
4446
+ });
4447
+ function closeLifePop() {
4448
+ if (!lifePop) return;
4449
+ lifePop.el.remove();
4450
+ lifePop = null;
4451
+ }
4452
+ function renderLifePop() {
4453
+ if (!lifePop) return;
4454
+ const room = currentRoom();
4455
+ const p = room && findById(room, lifePop.id);
4456
+ if (!p || state.view !== "room" || !lifePop.anchor.isConnected) return closeLifePop();
4457
+ const el = lifePop.el;
4458
+ if (el.contains(document.activeElement)) return;
4459
+ const { left, tone } = lifeOf(p);
4460
+ const last = [...room.messages].reverse().find((m) => m.from === p.id && m.usage);
4461
+ const n = lifePop.n ?? room.settings.replayAfterRestart ?? 10;
4462
+ el.innerHTML = `<div class="lp-main">
4463
+ <div class="lp-head">${avatar(p, 28, {})}<div><b>${esc(p.name)}</b><div class="lp-sub">${tone === "fresh" ? "fresh session: nothing used yet" : tone === "unknown" ? "context not reported by this agent" : `context ${fmtTokens(p.contextUsed)} of ${fmtTokens(p.contextSize)} used · <b>${left} % left</b>`}</div></div></div>
4464
+ <div class="life-bar life-${tone}"><i style="width:${left ?? 0}%"></i></div>
4465
+ ${p.contextEvent ? `<div class="lp-event${recentContextEvent(p) && p.contextEvent.kind !== "threshold" ? " fresh" : ""}">${ic("info")} ${esc(contextEventText(p, p.contextEvent))}</div>` : ""}
4466
+ <div class="kv lp-kv">
4467
+ <span>Turns</span><span>${p.turns}</span>
4468
+ <span>Last reply</span><span>${last ? `<span title="tokens in">${ic("arrow-down")} ${fmtTokens(last.usage.inputTokens)}</span> <span title="tokens out">${ic("arrow-up")} ${fmtTokens(last.usage.outputTokens)}</span>` : "—"}</span>
4469
+ <span>Cost (estimate)</span><span>${fmtCost(p.cost) || "—"}</span>
4470
+ <span>Briefs sent</span><span>${p.briefsSent ?? 0}</span>
4471
+ <span>Notes</span><span>${p.notes ? `taken at ${fmtTokens(p.notesAt || 0)} tokens` : "none yet"}${p.status === "offline" || p.status === "unstaffed" ? "" : ` · <button type="button" class="link-btn lp-take" title="A hidden turn: the vibemate writes 10 lines for a future restart; nothing is posted">${p.notes ? "refresh" : "take now"}</button>`}${p.notes ? ` · <button type="button" class="link-btn lp-edit">edit</button>` : ""}</span>
4472
+ </div>
4473
+ ${p.notes ? `<pre class="lp-notes">${esc(p.notes)}</pre><div class="lp-editor" hidden><textarea class="lp-notes-area" rows="6" maxlength="4000">${esc(p.notes)}</textarea><div class="row-btns"><button type="button" class="btn sm ghost lp-notes-clear">Clear</button><button type="button" class="btn sm primary lp-notes-save">Save</button></div></div>` : ""}
4474
+ <div class="lp-respawn">
4475
+ <button type="button" class="btn sm danger lp-empty" title="A new session that knows nothing of this conversation">${ic("bolt")}Respawn, empty head</button>
4476
+ <label class="lp-with"><button type="button" class="btn sm lp-mem" title="A new session that re-reads only the last N messages${p.notes ? " and its own notes" : ""}">Respawn with the last</button><input type="number" class="lp-n" min="0" max="500" value="${n}"> messages</label>
4477
+ </div>
4478
+ </div>
4479
+ <div class="lp-side"><button type="button" class="icon-btn sm lp-x" title="Close">${ic("close")}</button><button type="button" class="icon-btn sm lp-more" title="Open this vibemate's panel">${ic("settings")}</button></div>`;
4480
+ el.querySelector(".lp-x").addEventListener("click", closeLifePop);
4481
+ el.querySelector(".lp-more").addEventListener("click", () => {
4482
+ closeLifePop();
4483
+ openDetails({ kind: "participant", id: p.id });
4484
+ });
4485
+ el.querySelector(".lp-empty").addEventListener("click", () => respawnWith(p, 0));
4486
+ el.querySelector(".lp-mem").addEventListener("click", () => respawnWith(p, Number(el.querySelector(".lp-n").value) || 0));
4487
+ el.querySelector(".lp-n").addEventListener("input", (e) => (lifePop.n = Number(e.target.value) || 0));
4488
+ const take = el.querySelector(".lp-take");
4489
+ if (take)
4490
+ take.addEventListener("click", async () => {
4491
+ take.disabled = true;
4492
+ take.textContent = "asking…";
4493
+ try {
4494
+ await post(roomApi(`/participants/${encodeURIComponent(p.id)}/take-notes`));
4495
+ } catch (e) {
4496
+ showError(e);
4497
+ renderLifePop();
4498
+ }
4499
+ });
4500
+ const edit = el.querySelector(".lp-edit");
4501
+ if (edit)
4502
+ edit.addEventListener("click", () => {
4503
+ el.querySelector(".lp-notes").hidden = true;
4504
+ el.querySelector(".lp-editor").hidden = false;
4505
+ el.querySelector(".lp-notes-area").focus();
4506
+ });
4507
+ const saveNotes = async (text) => {
4508
+ try {
4509
+ await post(roomApi(`/participants/${encodeURIComponent(p.id)}/notes`), { notes: text });
4510
+ } catch (e) {
4511
+ showError(e);
4512
+ }
4513
+ el.querySelector(".lp-notes-area").blur();
4514
+ renderLifePop();
4515
+ };
4516
+ const save = el.querySelector(".lp-notes-save");
4517
+ if (save) save.addEventListener("click", () => saveNotes(el.querySelector(".lp-notes-area").value));
4518
+ const clear = el.querySelector(".lp-notes-clear");
4519
+ if (clear) clear.addEventListener("click", () => saveNotes(""));
4520
+ const z = zoomFactor();
4521
+ const r = lifePop.anchor.getBoundingClientRect();
4522
+ el.style.left = `${Math.round((r.right + 12) / z)}px`;
4523
+ el.style.top = `${Math.round(Math.max(8, Math.min(r.top / z - 10, window.innerHeight / z - el.offsetHeight - 8)))}px`;
4524
+ }
4525
+ async function respawnWith(p, n) {
4526
+ const text =
4527
+ n > 0
4528
+ ? `${p.name} starts over with a new session that gets only ${p.notes ? "its own notes and " : ""}the last ${n} messages of this room. You keep the history; the rest of its memory is gone.`
4529
+ : `${p.name} forgets this whole conversation and starts over. You keep the history; it does not.`;
4530
+ const ok = await confirmDialog(text, { title: `Respawn ${p.name}?`, okLabel: "Respawn", danger: true });
4531
+ if (!ok) return;
4532
+ closeLifePop();
4533
+ try {
4534
+ await post(roomApi(`/participants/${encodeURIComponent(p.id)}/respawn`), { memory: n > 0, replay: n });
4535
+ } catch (e) {
4536
+ showError(e);
4537
+ }
4538
+ }
4539
+ document.addEventListener("click", (e) => {
4540
+ if (lifePop && !e.target.closest(".life-pop") && !e.target.closest("#participants .avatar")) closeLifePop();
4541
+ });
4542
+ document.addEventListener("keydown", (e) => {
4543
+ if (e.key === "Escape" && lifePop) closeLifePop();
4544
+ });
4545
+
4335
4546
  const pinsBtn = $("#pins-btn");
4336
4547
  const pinsPanel = $("#pins-panel");
4337
4548
  function pinnedMessages(room) {
package/ui/theme.css CHANGED
@@ -67,6 +67,9 @@
67
67
  --attention-shadow: 0 8px 18px -10px rgba(232, 100, 42, 0.7);
68
68
  --unread-grad: linear-gradient(135deg, #ff7a45, #f0452c);
69
69
  --note-hover: #ffeaa8;
70
+ --done: var(--st-ready);
71
+ --done-ink: var(--st-ready-ink);
72
+ --done-hover: #c4efdb;
70
73
  --tick-mine: #cdcdf9;
71
74
  --tick-mine-view: #a9a9f5;
72
75
  --tick-fallback: #9ca3af;