viberoom 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,6 +80,16 @@ library marks it as theirs until you have read it.
80
80
 
81
81
  <br>
82
82
 
83
+ ## Let them design the room
84
+
85
+ A vibemate can design a room as well as work in it. It has a built-in skill on what makes rules and
86
+ roles good, and four hub tools: read the room's settings and rules, check a design and preview the
87
+ brief the others would receive, save a template for you to pick under New room, or propose a change to
88
+ the room you are in. A proposal is a card in the chat with the diff; nothing changes until you click
89
+ Apply, and the room is told what you decided.
90
+
91
+ <br>
92
+
83
93
  ## Talk to all, or to one
84
94
 
85
95
  <p align="center">
@@ -130,6 +140,8 @@ it in a browser tab. Later, `npm install -g viberoom@latest` and the next start
130
140
  - **Nothing leaves your machine** except what each agent sends to its own provider. viberoom never
131
141
  sees your keys; every agent keeps its own login.
132
142
  - **Edit a message.** Fix what you said; the vibemates get the memo, or the conversation rewinds.
143
+ - **Quote a message.** Select a fragment of a bubble, or copy it with Ctrl+C, and it goes into your next
144
+ message as a quote — with who said it and when, so the vibemates read it as that person's words, not yours.
133
145
  - **Your messages on a timeline.** A thin strip on the chat's right edge, one mark per message of yours:
134
146
  hover for the message with its neighbours, click to jump there.
135
147
  - **Pick a folder from a tree.** Browse the machine's folders when a room needs one; make a new one on the spot.
@@ -167,6 +179,14 @@ skills and the log.
167
179
 
168
180
  <br>
169
181
 
182
+ ## Questions, ideas, bugs
183
+
184
+ - A question ("how do I ...?") goes to [Discussions → Q&A](https://github.com/todor-rusev/viberoom/discussions/categories/q-a).
185
+ - An idea goes to [Discussions → Ideas](https://github.com/todor-rusev/viberoom/discussions/categories/ideas).
186
+ - A bug goes to [Issues](https://github.com/todor-rusev/viberoom/issues/new/choose); the form asks for what a fix needs.
187
+
188
+ <br>
189
+
170
190
  ## Development
171
191
 
172
192
  ```sh
@@ -0,0 +1,52 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ export function loginState(probe, evidence) {
3
+ if (!probe)
4
+ return "unknown";
5
+ if (probe.env.some((name) => (evidence.env[name] ?? "").trim().length > 0))
6
+ return "ok";
7
+ if (probe.files.some((file) => evidence.exists(file)))
8
+ return "ok";
9
+ if (probe.fileless?.includes(evidence.platform))
10
+ return "unknown";
11
+ return probe.files.length ? "missing" : "unknown";
12
+ }
13
+ const LOGIN_WORDS = /\b(not logged in|log ?in required|login required|please log ?in|unauthori[sz]ed|authentication (failed|required|error)|invalid api key|api key (is )?(missing|not set|invalid)|no credentials|credentials not found|401|403|oauth|token (expired|invalid))\b/i;
14
+ const MISSING_WORDS = /\b(enoent|not found|no such file|is not recognized|command not found|spawn\w* (failed|error))\b/i;
15
+ const TIMEOUT_WORDS = /\b(timed out|timeout|took too long|did not answer|no response)\b/i;
16
+ export function classifyStartFailure(input) {
17
+ const text = [input.error, ...(input.stderr ?? [])].join("\n");
18
+ const login = input.loginCommand ? `run \`${input.loginCommand}\` in a terminal, then try again` : `log in to ${input.vendor} in a terminal, then try again`;
19
+ if (LOGIN_WORDS.test(text) || input.loginState === "missing") {
20
+ return {
21
+ kind: "login",
22
+ what: `${input.vendor} is installed here but the hub could not get past its login.`,
23
+ advice: `${login}. The hub never asks for credentials itself: it uses the login the vendor's own CLI keeps on this machine.`,
24
+ };
25
+ }
26
+ if (MISSING_WORDS.test(text)) {
27
+ return {
28
+ kind: "not-installed",
29
+ what: `The ${input.vendor} program could not be started: this machine could not find or run it.`,
30
+ advice: input.installHint ? `Install or repair it: ${input.installHint}. Then run \`viberoom doctor\`, which lists what was found.` : "Reinstall it, then run `viberoom doctor`, which lists what was found.",
31
+ };
32
+ }
33
+ if (TIMEOUT_WORDS.test(text)) {
34
+ return {
35
+ kind: "timeout",
36
+ what: `${input.vendor} started but did not answer the hub in time.`,
37
+ advice: `Start it yourself once in a terminal (\`${input.loginCommand ?? input.vendor.toLowerCase()}\`): a first run that asks something — a login, a trust prompt, an update — blocks the protocol until it is answered.`,
38
+ };
39
+ }
40
+ if (/\bexited\b|\bexit code\b|\bsignal\b|\bclosed\b/i.test(text)) {
41
+ return {
42
+ kind: "crash",
43
+ what: `${input.vendor} started and then stopped before the hub could talk to it.`,
44
+ advice: `Run it once in a terminal to see what it prints; \`viberoom logs\` has the hub's side, with the agent's own last lines.`,
45
+ };
46
+ }
47
+ return {
48
+ kind: "unknown",
49
+ what: `${input.vendor} could not be started.`,
50
+ advice: `Run it once in a terminal to see what it says; \`viberoom logs\` has the hub's side, with the agent's own last lines.`,
51
+ };
52
+ }
package/dist/context.js CHANGED
@@ -25,6 +25,12 @@ export function crossedThreshold(previousUsed, used, size, threshold = NOTES_THR
25
25
  return false;
26
26
  return used / size >= threshold && previousUsed / size < threshold;
27
27
  }
28
+ export function emptyUsageReport(previousUsed, used) {
29
+ return previousUsed > 0 && used === 0;
30
+ }
31
+ export function looksCompacted(previousUsed, used) {
32
+ return previousUsed > 0 && used > 0 && used < previousUsed * 0.7;
33
+ }
28
34
  export function overThreshold(used, size, threshold = NOTES_THRESHOLD) {
29
35
  return size > 0 && used / size >= threshold;
30
36
  }
@@ -0,0 +1,12 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ export function formatDuration(ms) {
3
+ const total = Math.max(0, Math.round(ms / 1000));
4
+ if (total < 60)
5
+ return `${total} s`;
6
+ const seconds = String(total % 60).padStart(2, "0");
7
+ const minutes = Math.floor(total / 60) % 60;
8
+ const hours = Math.floor(total / 3600);
9
+ if (!hours)
10
+ return `${minutes}m ${seconds}s`;
11
+ return `${hours}h ${String(minutes).padStart(2, "0")}m ${seconds}s`;
12
+ }
package/dist/hub.js CHANGED
@@ -10,7 +10,7 @@ import { listRecipes } from "./recipes.js";
10
10
  import { DEFAULT_ROOM_SETTINGS } from "./persona.js";
11
11
  import { Room } from "./room.js";
12
12
  import { SkillLibrary } from "./skills.js";
13
- import { TemplateLibrary } from "./templates.js";
13
+ import { TemplateLibrary, roomSettingsFromTemplate } from "./templates.js";
14
14
  export const TEXT_FONTS = ["nunito", "inter", "noto-sans", "arial", "system"];
15
15
  export const MONO_FONTS = ["jetbrains-mono", "fira-code", "source-code-pro", "system"];
16
16
  export const DEFAULT_APPEARANCE = { chatFontSize: 14.5, font: "nunito", mono: "jetbrains-mono" };
@@ -45,6 +45,8 @@ export class Hub extends EventEmitter {
45
45
  library: this.skills,
46
46
  serverScript: fileURLToPath(new URL("./mcp-skills-server.js", import.meta.url)),
47
47
  hubUrl: () => this.hubUrl,
48
+ templates: this.templates,
49
+ templatesChanged: () => this.emit("event", { type: "templates" }),
48
50
  needApproval: () => this.settings.agentSkillsNeedApproval === true,
49
51
  save: (draft) => {
50
52
  const { body: _b, ...meta } = this.saveSkillInternal(draft);
@@ -114,6 +116,7 @@ export class Hub extends EventEmitter {
114
116
  editor: { ...DEFAULT_EDITOR_SETTINGS },
115
117
  appearance: { ...DEFAULT_APPEARANCE },
116
118
  checkForUpdates: true,
119
+ reconnectMode: "replay",
117
120
  roomDefaults: {},
118
121
  vendorPresets: {},
119
122
  };
@@ -183,6 +186,14 @@ export class Hub extends EventEmitter {
183
186
  }
184
187
  if (!next.diagrams)
185
188
  next.diagrams = { preset: "pop", primary: null };
189
+ if (patch.reconnectMode !== undefined) {
190
+ const mode = String(patch.reconnectMode);
191
+ if (mode !== "replay" && mode !== "load")
192
+ throw new Error("reconnectMode must be replay or load");
193
+ next.reconnectMode = mode;
194
+ }
195
+ if (next.reconnectMode !== "load")
196
+ next.reconnectMode = "replay";
186
197
  if (patch.editor !== undefined && typeof patch.editor === "object" && patch.editor) {
187
198
  const e = patch.editor;
188
199
  const mode = String(e.mode ?? next.editor?.mode ?? "auto");
@@ -332,7 +343,7 @@ export class Hub extends EventEmitter {
332
343
  const template = this.templates.get(input.templateId);
333
344
  if (!template)
334
345
  throw new Error(`no such template: ${input.templateId}`);
335
- const { room, notices } = this.createRoom({ name: input.name, dir: input.dir || template.dir || null, settings: template.settings });
346
+ const { room, notices } = this.createRoom({ name: input.name, dir: input.dir || template.dir || null, settings: roomSettingsFromTemplate(template) });
336
347
  const installed = new Set(listRecipes().filter((r) => !r.unavailableReason).map((r) => r.id));
337
348
  for (const [i, tv] of template.vibemates.entries()) {
338
349
  const choice = { ...(input.vibemates[i] ?? { name: tv.name, agentType: "" }) };
package/dist/launcher.js CHANGED
@@ -1,6 +1,6 @@
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
- import { join, posix, win32 } from "node:path";
3
+ import { join, posix, resolve, win32 } from "node:path";
4
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];
@@ -26,7 +26,7 @@ export function readPidFile(dataDir) {
26
26
  const parsed = JSON.parse(raw);
27
27
  if (typeof parsed.pid !== "number")
28
28
  return null;
29
- return { pid: parsed.pid, port: Number(parsed.port ?? 0), build: String(parsed.build ?? ""), startedAt: Number(parsed.startedAt ?? 0) };
29
+ return { pid: parsed.pid, port: Number(parsed.port ?? 0), build: String(parsed.build ?? ""), startedAt: Number(parsed.startedAt ?? 0), ...(parsed.foreground ? { foreground: true } : {}) };
30
30
  }
31
31
  catch {
32
32
  return null;
@@ -41,6 +41,23 @@ export function isProcessAlive(pid) {
41
41
  return error.code === "EPERM";
42
42
  }
43
43
  }
44
+ export function sameDataDir(a, b) {
45
+ const norm = (p) => {
46
+ const r = resolve(p).replace(/[\\/]+$/, "");
47
+ return process.platform === "win32" ? r.toLowerCase() : r;
48
+ };
49
+ return norm(a) === norm(b);
50
+ }
51
+ export function hubPortFor(port, portGiven, liveRecord) {
52
+ if (portGiven || !liveRecord || !liveRecord.port)
53
+ return port;
54
+ return liveRecord.port;
55
+ }
56
+ export function foreignHub(identity, dataDir) {
57
+ if (!identity || !identity.dataDir)
58
+ return null;
59
+ return sameDataDir(identity.dataDir, dataDir) ? null : identity.dataDir;
60
+ }
44
61
  const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
45
62
  export function rotateLog(path, limit = LOG_ROTATE_BYTES) {
46
63
  try {
package/dist/main.js CHANGED
@@ -8,7 +8,7 @@ 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, browserAdvice, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
11
+ import { appWindowArgs, recordedWindowPlacement, savedWindowPlacement, browserAdvice, findChromium, foreignHub, hubPortFor, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
12
12
  import { aumidSyncScript, installShortcuts, windowsShortcutPaths } from "./shortcuts.js";
13
13
  import { askEnter, renderInstalled, runMenu, unicodeSupported } from "./tui.js";
14
14
  import { listRecipes } from "./recipes.js";
@@ -17,11 +17,13 @@ function parseArgs(argv) {
17
17
  const options = {
18
18
  command,
19
19
  port: 4810,
20
+ portGiven: false,
20
21
  dataDir: process.env.VIBEROOM_DATA_DIR ? resolve(process.env.VIBEROOM_DATA_DIR) : resolve(homedir(), ".viberoom"),
21
22
  name: undefined,
22
23
  open: true,
23
24
  browser: false,
24
25
  menu: true,
26
+ force: false,
25
27
  };
26
28
  for (let i = 0; i < rest.length; i++) {
27
29
  const arg = rest[i];
@@ -34,6 +36,7 @@ function parseArgs(argv) {
34
36
  switch (arg) {
35
37
  case "--port":
36
38
  options.port = Number(next());
39
+ options.portGiven = true;
37
40
  break;
38
41
  case "--name":
39
42
  options.name = next();
@@ -49,6 +52,9 @@ function parseArgs(argv) {
49
52
  options.open = false;
50
53
  options.menu = false;
51
54
  break;
55
+ case "--force":
56
+ options.force = true;
57
+ break;
52
58
  case "--browser":
53
59
  options.browser = true;
54
60
  options.menu = false;
@@ -74,8 +80,9 @@ Commands
74
80
  process and open the window; if a hub is already running, open it (or replace it
75
81
  when this build is newer)
76
82
  start run the hub hidden in the background (log in <data-dir>/hub.log) and open the window
77
- stop stop the background hub
78
- status show whether a hub is running, its build and address
83
+ stop stop this data folder's hub (found through its pid file; a hub of another folder on
84
+ the port is left alone; --port names a port explicitly)
85
+ status show whether this data folder's hub is running, its build and address
79
86
  open open the window of the running hub
80
87
  logs print the last lines of the background hub's log
81
88
  doctor check Node, the browser, the coding agents and the hub; say what is missing and why
@@ -88,11 +95,11 @@ Options
88
95
  --browser open the default browser instead of a Chromium app window
89
96
  `);
90
97
  }
91
- import { checkForUpdate } from "./update.js";
98
+ import { checkForUpdate, newerSourceThanBuild } from "./update.js";
92
99
  function buildInfo() {
93
100
  const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
94
101
  const built = statSync(fileURLToPath(import.meta.url)).mtime;
95
- return { name: pkg.name, version: pkg.version, build: built.toISOString() };
102
+ return { name: pkg.name, version: pkg.version, build: built.toISOString(), staleSource: newerSourceThanBuild(import.meta.url) };
96
103
  }
97
104
  async function runningInstance(port) {
98
105
  const url = `http://127.0.0.1:${port}/`;
@@ -110,14 +117,21 @@ async function runningInstance(port) {
110
117
  try {
111
118
  const res = await fetch(`${url}api/version`, { signal: AbortSignal.timeout(1500) });
112
119
  if (!res.ok)
113
- return { url, build: null };
120
+ return { url, build: null, dataDir: null, pid: null };
114
121
  const info = (await res.json());
115
- return { url, build: typeof info.build === "string" ? info.build : null };
122
+ return { url, build: typeof info.build === "string" ? info.build : null, dataDir: typeof info.dataDir === "string" ? info.dataDir : null, pid: typeof info.pid === "number" ? info.pid : null };
116
123
  }
117
124
  catch {
118
- return { url, build: null };
125
+ return { url, build: null, dataDir: null, pid: null };
119
126
  }
120
127
  }
128
+ function liveRecord(dataDir) {
129
+ const record = readPidFile(dataDir);
130
+ return record && isProcessAlive(record.pid) ? record : null;
131
+ }
132
+ function otherHubMessage(port, other, mine) {
133
+ return `port ${port} is used by a viberoom hub whose rooms are in ${other}, not in ${mine}; it was left alone. Run this one on another port (--port ${port + 1}) or stop that hub first: viberoom stop --data-dir "${other}"`;
134
+ }
121
135
  async function waitUntil(check, timeoutMs, stepMs = 250) {
122
136
  const deadline = Date.now() + timeoutMs;
123
137
  while (Date.now() < deadline) {
@@ -225,7 +239,9 @@ async function runDoctor(options, info) {
225
239
  const found = recipes.filter((r) => !r.unavailableReason);
226
240
  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)"}`);
227
241
  for (const r of recipes.filter((r) => r.unavailableReason))
228
- lines.push(` ${r.vendor}: ${r.unavailableReason}`);
242
+ lines.push(` ${r.vendor}: ${r.unavailableReason} (${r.installHint})`);
243
+ for (const r of found.filter((r) => r.loginState === "missing"))
244
+ lines.push(`warn ${r.vendor}: installed, not logged in: run \`${r.loginCommand}\``);
229
245
  const running = await runningInstance(options.port);
230
246
  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)`);
231
247
  lines.push(` data: ${options.dataDir}`);
@@ -238,6 +254,9 @@ async function runHub(options, log, info) {
238
254
  const background = options.command === "serve";
239
255
  if (!background) {
240
256
  const running = await runningInstance(options.port);
257
+ const other = foreignHub(running, options.dataDir);
258
+ if (other)
259
+ throw new Error(otherHubMessage(options.port, other, options.dataDir));
241
260
  if (running && running.build === info.build) {
242
261
  log.info(`viberoom is already running at ${running.url} (same build); opening it.`);
243
262
  process.stdout.write(`${running.url}\n`);
@@ -265,14 +284,13 @@ async function runHub(options, log, info) {
265
284
  log.info("shutting down: closing agent sessions");
266
285
  await hub.shutdown();
267
286
  server?.close();
268
- if (background)
269
- rmSync(pidFilePath(options.dataDir), { force: true });
287
+ rmSync(pidFilePath(options.dataDir), { force: true });
270
288
  process.exit(0);
271
289
  };
272
290
  const listenDeadline = Date.now() + 15_000;
273
291
  for (;;) {
274
292
  try {
275
- server = await startServer(hub, options.port, log.child("http"), info, () => void shutdown());
293
+ server = await startServer(hub, options.port, log.child("http"), info, () => void shutdown(), () => handOverToFreshHub(options, log));
276
294
  break;
277
295
  }
278
296
  catch (error) {
@@ -294,9 +312,10 @@ async function runHub(options, log, info) {
294
312
  log.warn(`update check failed: ${update.error}`);
295
313
  });
296
314
  }
297
- if (background)
298
- writePidFile(options.dataDir, { pid: process.pid, port: options.port, build: info.build, startedAt: Date.now() });
315
+ writePidFile(options.dataDir, { pid: process.pid, port: options.port, build: info.build, startedAt: Date.now(), ...(background ? {} : { foreground: true }) });
299
316
  log.info(`viberoom ${info.version} (build ${info.build}) is open at ${server.url} (data: ${hub.dataDir}; rooms: ${[...hub.rooms.values()].map((r) => r.name).join(", ")})`);
317
+ if (info.staleSource)
318
+ log.warn(`the code on disk is newer than this build (${info.staleSource} changed after dist/ was compiled): the UI is served live, the hub is not; rebuild and restart with: node scripts/update.mjs`);
300
319
  process.stdout.write(`${server.url}\n`);
301
320
  if (options.open && !background)
302
321
  openWindow(server.url, options, log);
@@ -304,10 +323,22 @@ async function runHub(options, log, info) {
304
323
  process.on("SIGTERM", () => void shutdown());
305
324
  process.on("SIGHUP", () => void shutdown());
306
325
  }
326
+ function handOverToFreshHub(options, log) {
327
+ const args = [fileURLToPath(import.meta.url), "start", "--force", "--port", String(options.port), "--data-dir", options.dataDir, "--no-open"];
328
+ if (options.name)
329
+ args.push("--name", options.name);
330
+ const child = spawn(process.execPath, args, { cwd: options.dataDir, detached: true, stdio: "ignore", windowsHide: true });
331
+ child.unref();
332
+ log.info(`restart requested from the window: a fresh hub is starting (pid ${child.pid})`);
333
+ }
307
334
  async function startBackground(options, log, info) {
308
- const url = `http://127.0.0.1:${options.port}/`;
309
- const running = await runningInstance(options.port);
310
- if (running && running.build === info.build) {
335
+ const port = hubPortFor(options.port, options.portGiven, liveRecord(options.dataDir));
336
+ const url = `http://127.0.0.1:${port}/`;
337
+ const running = await runningInstance(port);
338
+ const other = foreignHub(running, options.dataDir);
339
+ if (other)
340
+ throw new Error(otherHubMessage(port, other, options.dataDir));
341
+ if (running && running.build === info.build && !options.force) {
311
342
  log.info(`viberoom is already running at ${url} (same build).`);
312
343
  process.stdout.write(`${url}\n`);
313
344
  if (options.open)
@@ -317,20 +348,20 @@ async function startBackground(options, log, info) {
317
348
  if (running) {
318
349
  log.info(`an older viberoom build is running at ${url}; replacing it with the build from ${info.build}`);
319
350
  if (!(await stopInstance(url, log)))
320
- throw new Error(`the older viberoom hub on port ${options.port} did not stop; try: viberoom stop`);
351
+ throw new Error(`the older viberoom hub on port ${port} did not stop; try: viberoom stop`);
321
352
  }
322
353
  mkdirSync(options.dataDir, { recursive: true });
323
354
  const logPath = logFilePath(options.dataDir);
324
355
  rotateLog(logPath);
325
356
  const fd = openSync(logPath, "a");
326
- const args = [fileURLToPath(import.meta.url), "serve", "--port", String(options.port), "--data-dir", options.dataDir, "--no-open"];
357
+ const args = [fileURLToPath(import.meta.url), "serve", "--port", String(port), "--data-dir", options.dataDir, "--no-open"];
327
358
  if (options.name)
328
359
  args.push("--name", options.name);
329
360
  const child = spawn(process.execPath, args, { cwd: options.dataDir, detached: true, stdio: ["ignore", fd, fd], windowsHide: true });
330
361
  child.unref();
331
362
  closeSync(fd);
332
363
  log.info(`hub started in the background (pid ${child.pid}); log: ${logPath}`);
333
- const up = await waitUntil(async () => (await runningInstance(options.port))?.build === info.build, 20_000);
364
+ const up = await waitUntil(async () => (await runningInstance(port))?.build === info.build, 20_000);
334
365
  if (!up)
335
366
  throw new Error(`the hub did not come up within 20 s; see ${logPath}`);
336
367
  process.stdout.write(`${url}\n`);
@@ -338,23 +369,27 @@ async function startBackground(options, log, info) {
338
369
  openWindow(url, options, log);
339
370
  }
340
371
  async function stopBackground(options, log) {
341
- const url = `http://127.0.0.1:${options.port}/`;
342
- const record = readPidFile(options.dataDir);
372
+ const live = liveRecord(options.dataDir);
373
+ const port = hubPortFor(options.port, options.portGiven, live);
374
+ const url = `http://127.0.0.1:${port}/`;
375
+ const other = foreignHub(await runningInstance(port), options.dataDir);
376
+ if (other)
377
+ throw new Error(`the hub on port ${port} keeps its rooms in ${other}, not in ${options.dataDir}, so it was left running. To stop that one: viberoom stop --data-dir "${other}"`);
343
378
  if (await isUp(url)) {
344
379
  const ok = await stopInstance(url, log);
345
380
  if (ok) {
346
- log.info(`hub on port ${options.port} stopped`);
381
+ log.info(`hub on port ${port} stopped`);
347
382
  rmSync(pidFilePath(options.dataDir), { force: true });
348
383
  return;
349
384
  }
350
385
  }
351
- if (record && isProcessAlive(record.pid)) {
352
- log.warn(`the hub did not answer on ${url}; terminating pid ${record.pid}`);
386
+ if (live) {
387
+ log.warn(`the hub did not answer on ${url}; terminating pid ${live.pid}`);
353
388
  try {
354
- process.kill(record.pid);
389
+ process.kill(live.pid);
355
390
  }
356
391
  catch (error) {
357
- throw new Error(`could not terminate pid ${record.pid}: ${String(error)}`);
392
+ throw new Error(`could not terminate pid ${live.pid}: ${String(error)}`);
358
393
  }
359
394
  rmSync(pidFilePath(options.dataDir), { force: true });
360
395
  return;
@@ -363,15 +398,22 @@ async function stopBackground(options, log) {
363
398
  log.info("no hub is running");
364
399
  }
365
400
  async function showStatus(options) {
366
- const url = `http://127.0.0.1:${options.port}/`;
367
- const running = await runningInstance(options.port);
368
401
  const record = readPidFile(options.dataDir);
402
+ const live = record && isProcessAlive(record.pid) ? record : null;
403
+ const port = hubPortFor(options.port, options.portGiven, live);
404
+ const url = `http://127.0.0.1:${port}/`;
405
+ const running = await runningInstance(port);
369
406
  if (running) {
370
- const pid = record && isProcessAlive(record.pid) ? ` (background pid ${record.pid}, started ${new Date(record.startedAt).toLocaleString()})` : " (foreground or another data folder)";
371
- process.stdout.write(`running at ${url}${pid}\nbuild: ${running.build ?? "unknown (older build)"}\ndata: ${options.dataDir}\nlog: ${logFilePath(options.dataDir)}\n`);
407
+ const other = foreignHub(running, options.dataDir);
408
+ const who = other
409
+ ? ` (a hub of another data folder: ${other}; this folder's hub is not running)`
410
+ : live
411
+ ? ` (${live.foreground ? "pid" : "background pid"} ${live.pid}, started ${new Date(live.startedAt).toLocaleString()})`
412
+ : " (foreground or another data folder)";
413
+ process.stdout.write(`running at ${url}${who}\nbuild: ${running.build ?? "unknown (older build)"}\ndata: ${other ?? options.dataDir}\nlog: ${logFilePath(other ?? options.dataDir)}\n`);
372
414
  }
373
415
  else {
374
- process.stdout.write(`not running on port ${options.port}${record ? ` (stale pid file: ${record.pid})` : ""}\n`);
416
+ process.stdout.write(`not running on port ${port}${record ? ` (stale pid file: ${record.pid})` : ""}\n`);
375
417
  if (record && !isProcessAlive(record.pid))
376
418
  rmSync(pidFilePath(options.dataDir), { force: true });
377
419
  }
@@ -394,9 +436,10 @@ async function main() {
394
436
  await showStatus(options);
395
437
  return;
396
438
  case "open": {
397
- const url = `http://127.0.0.1:${options.port}/`;
439
+ const port = hubPortFor(options.port, options.portGiven, liveRecord(options.dataDir));
440
+ const url = `http://127.0.0.1:${port}/`;
398
441
  if (!(await isUp(url)))
399
- throw new Error(`no hub is running on port ${options.port}; start one with: viberoom start`);
442
+ throw new Error(`no hub is running on port ${port}; start one with: viberoom start`);
400
443
  openWindow(url, options, log);
401
444
  return;
402
445
  }
@@ -13,6 +13,33 @@ const SKILL_FIELDS = {
13
13
  agent_invocable: { type: "boolean", description: "optional (default true): agents may load it themselves" },
14
14
  dry_run: { type: "boolean", description: "optional: only lint, write nothing" },
15
15
  };
16
+ const DESIGN_FIELDS = {
17
+ kind: { type: "string", enum: ["template", "room"], description: "template: a whole template (name, description, vibemates); room: a change to this room, starting from its current settings" },
18
+ name: { type: "string", description: "the template's name (1-40 characters; the id is derived from it)" },
19
+ description: { type: "string", description: "what the room is for and how it feels, two sentences; shown in the picker" },
20
+ emoji: { type: "string", description: "optional: the room's emoji" },
21
+ settings: {
22
+ type: "object",
23
+ description: "room settings by key, only the ones you set; describe_room lists the keys with their meaning, bounds and defaults. Rules go in customRules, one per line.",
24
+ additionalProperties: true,
25
+ },
26
+ vibemates: {
27
+ type: "array",
28
+ description: "the vibemates: name (1-24 letters, digits, _ or -), tagline (the one line the others see, up to 80 characters), role (who this one is and which way it leans; private), avatar (one emoji), skills (names from the library)",
29
+ items: {
30
+ type: "object",
31
+ properties: {
32
+ name: { type: "string" },
33
+ tagline: { type: "string" },
34
+ role: { type: "string" },
35
+ avatar: { type: "string" },
36
+ skills: { type: "array", items: { type: "string" } },
37
+ replyDelay: { type: "number", description: "optional: seconds this vibemate waits before a turn, overriding the room's delay" },
38
+ },
39
+ required: ["name"],
40
+ },
41
+ },
42
+ };
16
43
  const TOOLS = [
17
44
  {
18
45
  name: TOOL_NAME,
@@ -45,6 +72,71 @@ const TOOLS = [
45
72
  required: ["name"],
46
73
  },
47
74
  },
75
+ {
76
+ name: "describe_room",
77
+ description: "The facts about this room before you design anything: its settings with their meaning, bounds, defaults and current values; the rules; the vibemates (name, tagline, role, avatar, skills); the skill library; the templates that exist; and the brief you yourself receive. Read-only. Load the built-in skill \"room-designer\" for what makes rules and roles good.",
78
+ inputSchema: { type: "object", properties: {} },
79
+ annotations: { readOnlyHint: true },
80
+ },
81
+ {
82
+ name: "lint_room_design",
83
+ description: "Check a room design without saving anything: the same errors and warnings create_template / propose_room_changes would give, plus a preview of the brief the first vibemate would receive (exactly what the room will read). kind \"template\" checks a whole template; kind \"room\" checks a change to this room, starting from its current settings.",
84
+ inputSchema: { type: "object", properties: DESIGN_FIELDS, required: ["kind"] },
85
+ annotations: { readOnlyHint: true },
86
+ },
87
+ {
88
+ name: "create_template",
89
+ description: "Save a room template into the human's library: a file the human picks under New room to create a room with these settings, rules and vibemates. No effect on any existing room. The hub checks the design first (errors stop the save, warnings come back with it). A taken name gets a numbered id unless replace is true and the template is one you or the human made.",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ name: DESIGN_FIELDS.name,
94
+ description: DESIGN_FIELDS.description,
95
+ emoji: DESIGN_FIELDS.emoji,
96
+ settings: DESIGN_FIELDS.settings,
97
+ vibemates: DESIGN_FIELDS.vibemates,
98
+ replace: { type: "boolean", description: "optional: overwrite the template with this name instead of saving a numbered copy (never a template viberoom ships)" },
99
+ },
100
+ required: ["name", "description", "vibemates"],
101
+ },
102
+ },
103
+ {
104
+ name: "propose_room_changes",
105
+ description: "Propose changes to this room: settings by key (rules in customRules, one per line) and vibemates to add, update or remove. The hub checks the change set like a template, then shows the human a card with the diff and the warnings; nothing changes until the human clicks Apply, and the room gets a line with the outcome. A new vibemate is added waiting for the human to pick its coding agent. Say in why what the change fixes.",
106
+ inputSchema: {
107
+ type: "object",
108
+ properties: {
109
+ why: { type: "string", description: "one or two sentences: what this change fixes or enables; shown on the card" },
110
+ settings: DESIGN_FIELDS.settings,
111
+ vibemates: {
112
+ type: "object",
113
+ description: "optional: vibemates to add (full entries), update (by name; give only the fields that change; newName renames) or remove (names)",
114
+ properties: {
115
+ add: DESIGN_FIELDS.vibemates,
116
+ update: {
117
+ type: "array",
118
+ items: { type: "object", properties: { name: { type: "string" }, newName: { type: "string" }, tagline: { type: "string" }, role: { type: "string" }, avatar: { type: "string" }, skills: { type: "array", items: { type: "string" } }, replyDelay: { type: "number" } }, required: ["name"] },
119
+ },
120
+ remove: { type: "array", items: { type: "string" } },
121
+ },
122
+ },
123
+ },
124
+ required: ["why"],
125
+ },
126
+ },
127
+ {
128
+ name: "read_message",
129
+ description: "One message of this room by its number: the whole of a message that was quoted to you as \"> Name (#N, time): …\", or any message whose #N you have seen. Returns who wrote it, to whom, when, its text, its images as file paths and, with around > 0, up to that many messages before and after it. Read-only; the human sees the call like any other tool call.",
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: {
133
+ seq: { type: "integer", description: "the message number, the N of #N" },
134
+ around: { type: "integer", minimum: 0, maximum: 5, description: "optional: how many neighbouring messages to include on each side (default 0, at most 5)" },
135
+ },
136
+ required: ["seq"],
137
+ },
138
+ annotations: { readOnlyHint: true },
139
+ },
48
140
  ];
49
141
  let readySent = false;
50
142
  function send(message) {
@@ -144,6 +236,57 @@ async function handle(message) {
144
236
  reply(id, { content: [{ type: "text", text: String(res.body.message ?? "attached") }] });
145
237
  return;
146
238
  }
239
+ if (name === "describe_room") {
240
+ const res = await hub(`/api/mcp/room?token=${encodeURIComponent(TOKEN)}`);
241
+ if (!res.ok)
242
+ return errorResult("the room could not be described", res);
243
+ reply(id, { content: [{ type: "text", text: JSON.stringify(res.body, null, 2) }] });
244
+ return;
245
+ }
246
+ if (name === "lint_room_design") {
247
+ const res = await hub("/api/mcp/design/lint", {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({ token: TOKEN, ...args }),
251
+ });
252
+ if (!res.ok)
253
+ return errorResult("the design could not be checked", res);
254
+ reply(id, { content: [{ type: "text", text: JSON.stringify(res.body, null, 2) }], isError: res.body.ok === false ? true : undefined });
255
+ return;
256
+ }
257
+ if (name === "create_template") {
258
+ const res = await hub("/api/mcp/templates", {
259
+ method: "POST",
260
+ headers: { "content-type": "application/json" },
261
+ body: JSON.stringify({ token: TOKEN, ...args }),
262
+ });
263
+ if (!res.ok)
264
+ return errorResult("the template could not be saved", res);
265
+ reply(id, { content: [{ type: "text", text: String(res.body.message ?? "saved") }] });
266
+ return;
267
+ }
268
+ if (name === "propose_room_changes") {
269
+ const res = await hub("/api/mcp/propose", {
270
+ method: "POST",
271
+ headers: { "content-type": "application/json" },
272
+ body: JSON.stringify({ token: TOKEN, ...args }),
273
+ });
274
+ if (!res.ok)
275
+ return errorResult("the proposal could not be made", res);
276
+ reply(id, { content: [{ type: "text", text: String(res.body.message ?? "proposed") }] });
277
+ return;
278
+ }
279
+ if (name === "read_message") {
280
+ const seq = Number(args.seq);
281
+ if (!Number.isInteger(seq))
282
+ return fail(id, -32602, "read_message needs seq: the message number, the N of #N");
283
+ const around = Number(args.around);
284
+ const res = await hub(`/api/mcp/message?token=${encodeURIComponent(TOKEN)}&seq=${seq}${Number.isInteger(around) && around > 0 ? `&around=${around}` : ""}`);
285
+ if (!res.ok)
286
+ return errorResult("the message could not be read", res);
287
+ reply(id, { content: [{ type: "text", text: JSON.stringify(res.body, null, 2) }] });
288
+ return;
289
+ }
147
290
  fail(id, -32602, `unknown tool: ${name}`);
148
291
  return;
149
292
  }