viberoom 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hub.js CHANGED
@@ -113,6 +113,7 @@ export class Hub extends EventEmitter {
113
113
  diagrams: { preset: "pop", primary: null },
114
114
  editor: { ...DEFAULT_EDITOR_SETTINGS },
115
115
  appearance: { ...DEFAULT_APPEARANCE },
116
+ checkForUpdates: true,
116
117
  roomDefaults: {},
117
118
  vendorPresets: {},
118
119
  };
@@ -165,6 +166,8 @@ export class Hub extends EventEmitter {
165
166
  next.profileCompleted = patch.profileCompleted === true || patch.profileCompleted === "true";
166
167
  if (patch.agentSkillsNeedApproval !== undefined)
167
168
  next.agentSkillsNeedApproval = patch.agentSkillsNeedApproval === true || patch.agentSkillsNeedApproval === "true";
169
+ if (patch.checkForUpdates !== undefined)
170
+ next.checkForUpdates = patch.checkForUpdates === true || patch.checkForUpdates === "true";
168
171
  if (patch.diagrams !== undefined && typeof patch.diagrams === "object" && patch.diagrams) {
169
172
  const d = patch.diagrams;
170
173
  const preset = String(d.preset ?? next.diagrams?.preset ?? "pop");
@@ -432,9 +435,15 @@ export class Hub extends EventEmitter {
432
435
  else
433
436
  this.log.info(`removed room ${id}`);
434
437
  }
438
+ update = null;
439
+ setUpdate(update) {
440
+ this.update = update;
441
+ this.emit("event", { type: "update", update });
442
+ }
435
443
  snapshot() {
436
444
  return {
437
445
  settings: this.settings,
446
+ update: this.update,
438
447
  recipes: listRecipes().map(({ build: _b, ...r }) => r),
439
448
  skills: this.skills.list(),
440
449
  roomDefaults: { ...DEFAULT_ROOM_SETTINGS, ...this.settings.roomDefaults },
package/dist/main.js CHANGED
@@ -86,6 +86,7 @@ Options
86
86
  --browser open the default browser instead of a Chromium app window
87
87
  `);
88
88
  }
89
+ import { checkForUpdate } from "./update.js";
89
90
  function buildInfo() {
90
91
  const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
91
92
  const built = statSync(fileURLToPath(import.meta.url)).mtime;
@@ -251,6 +252,15 @@ async function runHub(options, log, info) {
251
252
  }
252
253
  }
253
254
  hub.setHubUrl(server.url);
255
+ if (hub.settings.checkForUpdates) {
256
+ void checkForUpdate(hub.dataDir, info.version).then((update) => {
257
+ hub.setUpdate(update);
258
+ if (update.available)
259
+ log.info(`viberoom ${update.latest} is available (this is ${update.current})`);
260
+ else if (update.error)
261
+ log.warn(`update check failed: ${update.error}`);
262
+ });
263
+ }
254
264
  if (background)
255
265
  writePidFile(options.dataDir, { pid: process.pid, port: options.port, build: info.build, startedAt: Date.now() });
256
266
  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(", ")})`);
package/dist/server.js CHANGED
@@ -40,6 +40,7 @@ const STATIC_FILES = {
40
40
  "/vendor/mermaid.min.js": { file: "mermaid/dist/mermaid.min.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
41
41
  "/vendor/marked.umd.js": { file: "marked/lib/marked.umd.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
42
42
  };
43
+ import { checkForUpdate, installUpdate, restartWithNewBuild, runsFromSourceCheckout } from "./update.js";
43
44
  export function startServer(hub, port, log, info, onShutdownRequest) {
44
45
  const uiDir = fileURLToPath(new URL("../ui/", import.meta.url));
45
46
  const assetsDir = fileURLToPath(new URL("../assets/", import.meta.url));
@@ -178,6 +179,28 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
178
179
  sendJson(res, 200, snapshot());
179
180
  return;
180
181
  }
182
+ if (req.method === "GET" && path === "/api/update") {
183
+ if (url.searchParams.get("check") === "1")
184
+ hub.setUpdate(await checkForUpdate(hub.dataDir, info.version, { force: true }));
185
+ sendJson(res, 200, hub.update ?? { current: info.version, latest: null, available: false, checkedAt: null, error: null });
186
+ return;
187
+ }
188
+ if (req.method === "POST" && path === "/api/update/install") {
189
+ const mainUrl = new URL("./main.js", import.meta.url).href;
190
+ if (runsFromSourceCheckout(mainUrl))
191
+ throw new Error("this viberoom runs from a source checkout; update it with git pull and npm run update");
192
+ const latest = hub.update?.available ? hub.update.latest : null;
193
+ if (!latest)
194
+ throw new Error("no newer version is known; check for updates first");
195
+ log.info(`installing viberoom ${latest} (npm install -g)`);
196
+ const result = await installUpdate(latest);
197
+ if (!result.ok)
198
+ throw new Error(`npm install failed: ${result.output.slice(-600) || "no output"}`);
199
+ log.info(`viberoom ${latest} installed; starting the new build, which replaces this hub`);
200
+ sendJson(res, 200, { ok: true, version: latest });
201
+ setTimeout(() => restartWithNewBuild(mainUrl, port, hub.dataDir), 300);
202
+ return;
203
+ }
181
204
  if (req.method === "GET" && path === "/api/version") {
182
205
  sendJson(res, 200, info);
183
206
  return;
package/dist/update.js ADDED
@@ -0,0 +1,95 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ export const REGISTRY_URL = "https://registry.npmjs.org/viberoom/latest";
7
+ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
8
+ const CHECK_FILE = "update-check.json";
9
+ export function parseVersion(v) {
10
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(v.trim());
11
+ if (!m)
12
+ return null;
13
+ return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], prerelease: !!m[4] };
14
+ }
15
+ export function compareVersions(a, b) {
16
+ const pa = parseVersion(a);
17
+ const pb = parseVersion(b);
18
+ if (!pa || !pb)
19
+ return 0;
20
+ for (let i = 0; i < 3; i++)
21
+ if (pa.parts[i] !== pb.parts[i])
22
+ return pa.parts[i] - pb.parts[i];
23
+ if (pa.prerelease !== pb.prerelease)
24
+ return pa.prerelease ? -1 : 1;
25
+ return 0;
26
+ }
27
+ export function readCheckRecord(dataDir) {
28
+ const file = join(dataDir, CHECK_FILE);
29
+ if (!existsSync(file))
30
+ return null;
31
+ try {
32
+ const raw = JSON.parse(readFileSync(file, "utf8"));
33
+ if (typeof raw.checkedAt !== "string")
34
+ return null;
35
+ return { checkedAt: raw.checkedAt, latest: typeof raw.latest === "string" ? raw.latest : null, error: typeof raw.error === "string" ? raw.error : null };
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ export function toInfo(current, record) {
42
+ const latest = record?.latest ?? null;
43
+ return { current, latest, available: latest !== null && compareVersions(latest, current) > 0, checkedAt: record?.checkedAt ?? null, error: record?.error ?? null };
44
+ }
45
+ export async function checkForUpdate(dataDir, current, options = {}) {
46
+ const now = options.now ?? Date.now;
47
+ const previous = readCheckRecord(dataDir);
48
+ if (!options.force && previous && now() - Date.parse(previous.checkedAt) < CHECK_INTERVAL_MS)
49
+ return toInfo(current, previous);
50
+ const doFetch = options.fetchImpl ?? fetch;
51
+ let record;
52
+ try {
53
+ const res = await doFetch(REGISTRY_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(options.timeoutMs ?? 6000) });
54
+ if (!res.ok)
55
+ throw new Error(`registry answered ${res.status}`);
56
+ const body = (await res.json());
57
+ if (typeof body.version !== "string" || !parseVersion(body.version))
58
+ throw new Error("registry answer had no version");
59
+ record = { checkedAt: new Date(now()).toISOString(), latest: body.version, error: null };
60
+ }
61
+ catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ record = { checkedAt: new Date(now()).toISOString(), latest: previous?.latest ?? null, error: message.replace(/\s+/g, " ").slice(0, 200) };
64
+ }
65
+ writeFileSync(join(dataDir, CHECK_FILE), JSON.stringify(record, null, 2));
66
+ return toInfo(current, record);
67
+ }
68
+ export function runsFromSourceCheckout(mainModuleUrl) {
69
+ const path = decodeURIComponent(new URL(mainModuleUrl).pathname);
70
+ return !/\/node_modules\/viberoom\//.test(path);
71
+ }
72
+ export function installCommandLine(version) {
73
+ if (!parseVersion(version))
74
+ throw new Error(`not a version: ${version}`);
75
+ return `npm install -g viberoom@${version} --no-audit --no-fund`;
76
+ }
77
+ export function installUpdate(version) {
78
+ const line = installCommandLine(version);
79
+ return new Promise((resolve) => {
80
+ const child = process.platform === "win32" ? spawn(line, { shell: true, windowsHide: true }) : spawn("npm", line.split(" ").slice(1));
81
+ let output = "";
82
+ const collect = (chunk) => {
83
+ output = (output + chunk.toString()).slice(-4000);
84
+ };
85
+ child.stdout?.on("data", collect);
86
+ child.stderr?.on("data", collect);
87
+ child.on("error", (error) => resolve({ ok: false, output: `${output}\n${error.message}`.trim() }));
88
+ child.on("close", (code) => resolve({ ok: code === 0, output: output.trim() }));
89
+ });
90
+ }
91
+ export function restartWithNewBuild(mainModuleUrl, port, dataDir) {
92
+ const main = fileURLToPath(mainModuleUrl);
93
+ const child = spawn(process.execPath, [main, "start", "--port", String(port), "--data-dir", dataDir, "--no-open"], { detached: true, stdio: "ignore", windowsHide: true });
94
+ child.unref();
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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": {
package/ui/app.css CHANGED
@@ -44,7 +44,15 @@
44
44
  .rail-toggle { width: 44px; height: 24px; margin-top: 8px; border-radius: 8px; border: 0; background: transparent; color: var(--faint); display: grid; place-content: center; padding: 0; align-self: center; transition: background var(--t-fast), color var(--t-fast); }
45
45
  .rail-toggle .i { width: 16px; height: 16px; }
46
46
  .rail-toggle:hover { background: #fff; color: var(--primary); }
47
- .rail-foot { margin-top: auto; display: flex; flex-direction: column; gap: 0; align-items: center; }
47
+ .rail-foot { position: relative; margin-top: auto; display: flex; flex-direction: column; gap: 0; align-items: center; }
48
+ .update-pop { position: absolute; left: 12px; bottom: calc(100% - 2px); z-index: 30; display: flex; gap: 6px; align-items: flex-start; width: 250px; padding: 10px 8px 10px 14px; background: #fff; border-radius: 18px 18px 18px 6px; box-shadow: 0 10px 28px -12px rgba(28, 27, 51, 0.45), var(--shadow-tile); font-size: 12.5px; font-weight: 600; color: var(--ink-2); animation: bubble-in 0.45s var(--ease-out); }
49
+ .update-pop::after { content: ""; position: absolute; left: 14px; bottom: -6px; width: 12px; height: 12px; background: #fff; border-radius: 0 0 3px 0; transform: rotate(45deg); box-shadow: 3px 3px 4px -3px rgba(28, 27, 51, 0.25); }
50
+ .update-pop .up-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
51
+ .update-pop .up-text { line-height: 1.4; }
52
+ .update-pop .up-text b { color: var(--ink); }
53
+ .update-pop .up-go { align-self: flex-start; }
54
+ .update-pop .up-side { flex: none; display: flex; flex-direction: column; gap: 2px; }
55
+ .update-pop .icon-btn.sm { width: 26px; height: 26px; }
48
56
  .shell.rail-open .rail-foot { align-items: stretch; padding: 0 14px; }
49
57
  .rail-item { position: relative; display: flex; align-items: center; justify-content: center; gap: 10px; width: 44px; height: 44px; padding: 0; border: 0; background: transparent; border-radius: 14px; color: var(--muted); font-weight: 800; font-size: 13px; white-space: nowrap; transition: background var(--t-fast), color var(--t-fast), box-shadow var(--t-fast); }
50
58
  .shell.rail-open .rail-item { width: auto; justify-content: flex-start; padding: 0 12px 0 0; }
package/ui/app.js CHANGED
@@ -823,6 +823,51 @@
823
823
  maybeOfferReconnect();
824
824
  }
825
825
 
826
+ function renderUpdatePop() {
827
+ const old = $("#update-pop");
828
+ const u = state.update;
829
+ const show = u && u.available && u.latest && recall("updateDismissed") !== u.latest;
830
+ if (!show) {
831
+ if (old && !old.dataset.busy) old.remove();
832
+ return;
833
+ }
834
+ if (old && old.dataset.version === u.latest) return;
835
+ if (old) old.remove();
836
+ const pop = document.createElement("div");
837
+ pop.id = "update-pop";
838
+ pop.className = "update-pop";
839
+ pop.dataset.version = u.latest;
840
+ pop.innerHTML = `<div class="up-main"><div class="up-text"><b>viberoom ${esc(u.latest)}</b> is out. You have ${esc(u.current)}.</div><button type="button" class="btn sm primary up-go">Update now and restart</button></div>
841
+ <div class="up-side"><button type="button" class="icon-btn sm up-x" title="Not now">${ic("close")}</button><button type="button" class="icon-btn sm up-settings" title="Update settings">${ic("settings")}</button></div>`;
842
+ pop.querySelector(".up-x").addEventListener("click", () => {
843
+ remember("updateDismissed", u.latest);
844
+ pop.remove();
845
+ });
846
+ pop.querySelector(".up-settings").addEventListener("click", () => setView("settings"));
847
+ pop.querySelector(".up-go").addEventListener("click", () => installUpdate(pop, u.latest));
848
+ els.rail.querySelector(".rail-foot").appendChild(pop);
849
+ }
850
+ async function installUpdate(pop, version) {
851
+ const go = pop.querySelector(".up-go");
852
+ const text = pop.querySelector(".up-text");
853
+ pop.dataset.busy = "1";
854
+ go.disabled = true;
855
+ go.classList.add("loading");
856
+ text.innerHTML = `Installing <b>viberoom ${esc(version)}</b>… this takes a moment.`;
857
+ try {
858
+ await post("/api/update/install", {});
859
+ go.classList.remove("loading");
860
+ text.innerHTML = `<b>viberoom ${esc(version)}</b> is installed. Restarting…`;
861
+ go.hidden = true;
862
+ } catch (e) {
863
+ delete pop.dataset.busy;
864
+ go.classList.remove("loading");
865
+ go.disabled = false;
866
+ go.textContent = "Try again";
867
+ text.innerHTML = `<span class="error">${esc(e.message || String(e))}</span>`;
868
+ }
869
+ }
870
+
826
871
  function renderRail() {
827
872
  els.rail.querySelectorAll(".rail-item[data-nav]").forEach((b) => {
828
873
  const nav = b.dataset.nav;
@@ -2204,6 +2249,12 @@
2204
2249
  </div>
2205
2250
  </div>
2206
2251
  <div>
2252
+ <div class="section" id="sp-update">
2253
+ ${sectionTitle("refresh", "Updates")}
2254
+ <label class="switch"><span class="label">Check for updates once a day<span class="hint">At start, one request to the npm registry for the latest viberoom version; nothing else leaves this machine. A newer version shows as a bubble over your avatar.</span></span><input type="checkbox" id="sp-updates" ${s.checkForUpdates !== false ? "checked" : ""}></label>
2255
+ <p class="hint" id="sp-update-status">${updateStatusText()}</p>
2256
+ <button type="button" class="btn sm" id="sp-update-check">Check now</button>
2257
+ </div>
2207
2258
  <div class="section">
2208
2259
  ${sectionTitle("spark", "Vibemates on this machine")}
2209
2260
  ${machine || '<p class="hint">No supported vibemate is installed yet.</p>'}
@@ -2288,6 +2339,20 @@
2288
2339
  });
2289
2340
  $("#sp-font").addEventListener("change", () => (sample.style.fontFamily = FONTS.text[$("#sp-font").value].stack));
2290
2341
  $("#sp-mono").addEventListener("change", () => sample.querySelectorAll("code").forEach((c) => (c.style.fontFamily = FONTS.mono[$("#sp-mono").value].stack)));
2342
+ $("#sp-update-check").addEventListener("click", async () => {
2343
+ const b = $("#sp-update-check");
2344
+ b.disabled = true;
2345
+ b.classList.add("loading");
2346
+ try {
2347
+ state.update = await get("/api/update?check=1");
2348
+ $("#sp-update-status").textContent = updateStatusText();
2349
+ renderUpdatePop();
2350
+ } catch (e) {
2351
+ showError(e);
2352
+ }
2353
+ b.disabled = false;
2354
+ b.classList.remove("loading");
2355
+ });
2291
2356
  bindSave($("#sp-form"), $("#sp-save"), async () => {
2292
2357
  const vendorPresets = {};
2293
2358
  els.pageInner.querySelectorAll("input[data-vendor]").forEach((inp) => {
@@ -2297,6 +2362,7 @@
2297
2362
  await post("/api/settings", {
2298
2363
  bypassPermissionsByDefault: $("#sp-bypass").checked,
2299
2364
  agentSkillsNeedApproval: $("#sp-skill-approval").checked,
2365
+ checkForUpdates: $("#sp-updates").checked,
2300
2366
  diagrams: { preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null },
2301
2367
  editor: { mode: $("#sp-editor-mode").value, command: $("#sp-editor-cmd").value },
2302
2368
  appearance: { chatFontSize: Number($("#sp-chat-fs").value), font: $("#sp-font").value, mono: $("#sp-mono").value },
@@ -2314,6 +2380,16 @@
2314
2380
  });
2315
2381
  }
2316
2382
 
2383
+ function updateStatusText() {
2384
+ const u = state.update;
2385
+ const v = state.version ? state.version.version : "?";
2386
+ if (!u || !u.checkedAt) return `This is viberoom ${v}; not checked yet.`;
2387
+ const when = new Date(u.checkedAt).toLocaleString();
2388
+ if (u.available) return `viberoom ${u.latest} is available (this is ${u.current}); checked ${when}.`;
2389
+ if (u.error) return `Could not reach the registry (${u.error}); checked ${when}.`;
2390
+ return `This is viberoom ${u.current}, the latest; checked ${when}.`;
2391
+ }
2392
+
2317
2393
  function skillBadges(sk) {
2318
2394
  const out = [];
2319
2395
  if (sk.userInvocable === false) out.push('<span class="badge">vibemate only</span>');
@@ -3163,6 +3239,8 @@
3163
3239
  function loadSnapshot(snapshot) {
3164
3240
  state.settings = snapshot.settings;
3165
3241
  applyAppearance();
3242
+ state.update = snapshot.update || null;
3243
+ renderUpdatePop();
3166
3244
  state.version = snapshot.version || null;
3167
3245
  state.skills = snapshot.skills || [];
3168
3246
  state.recipes = snapshot.recipes || [];
@@ -3346,6 +3424,11 @@
3346
3424
  if (state.detailsOpen && state.selection.kind === "me" && !editingInDetails()) renderDetails();
3347
3425
  if (state.view === "room") renderSideRoom();
3348
3426
  });
3427
+ es.addEventListener("update", (e) => {
3428
+ state.update = JSON.parse(e.data).update;
3429
+ renderUpdatePop();
3430
+ if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
3431
+ });
3349
3432
  es.addEventListener("reset", () => location.href = "/");
3350
3433
  }
3351
3434
  function releaseStream() {