viberoom 0.2.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.
Files changed (56) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +20 -0
  3. package/README.md +153 -0
  4. package/assets/icon-128.png +0 -0
  5. package/assets/icon-16.png +0 -0
  6. package/assets/icon-256.png +0 -0
  7. package/assets/icon-32.png +0 -0
  8. package/assets/icon-48.png +0 -0
  9. package/assets/icon-512.png +0 -0
  10. package/assets/icon-64.png +0 -0
  11. package/assets/icon-vector.svg +30 -0
  12. package/assets/icon.icns +0 -0
  13. package/assets/icon.ico +0 -0
  14. package/assets/icon.svg +30 -0
  15. package/assets/vendors/claude.svg +3 -0
  16. package/assets/vendors/codex.svg +3 -0
  17. package/assets/vendors/copilot.svg +5 -0
  18. package/assets/vendors/cursor.svg +3 -0
  19. package/assets/vendors/gemini.svg +3 -0
  20. package/assets/vendors/opencode.svg +3 -0
  21. package/dist/acp-client.js +137 -0
  22. package/dist/acp-types.js +2 -0
  23. package/dist/edit.js +34 -0
  24. package/dist/hub.js +348 -0
  25. package/dist/icons.js +235 -0
  26. package/dist/jsonrpc.js +109 -0
  27. package/dist/launcher.js +161 -0
  28. package/dist/log.js +35 -0
  29. package/dist/main.js +389 -0
  30. package/dist/mcp-skills-server.js +177 -0
  31. package/dist/open.js +141 -0
  32. package/dist/persona.js +217 -0
  33. package/dist/recipes.js +261 -0
  34. package/dist/room.js +2124 -0
  35. package/dist/server.js +433 -0
  36. package/dist/shortcuts.js +176 -0
  37. package/dist/skills.js +344 -0
  38. package/dist/tui.js +109 -0
  39. package/package.json +61 -0
  40. package/scripts/install.mjs +34 -0
  41. package/scripts/render-icon.mjs +84 -0
  42. package/scripts/update.mjs +29 -0
  43. package/ui/app.css +346 -0
  44. package/ui/app.js +2834 -0
  45. package/ui/avatars.js +113 -0
  46. package/ui/fonts/OFL.txt +93 -0
  47. package/ui/fonts/nunito-cyrillic-ext.woff2 +0 -0
  48. package/ui/fonts/nunito-cyrillic.woff2 +0 -0
  49. package/ui/fonts/nunito-latin-ext.woff2 +0 -0
  50. package/ui/fonts/nunito-latin.woff2 +0 -0
  51. package/ui/fonts/nunito-vietnamese.woff2 +0 -0
  52. package/ui/fonts/nunito.css +6 -0
  53. package/ui/icons.js +76 -0
  54. package/ui/index.html +217 -0
  55. package/ui/manifest.json +14 -0
  56. package/ui/theme.css +425 -0
@@ -0,0 +1,161 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { join, posix, win32 } from "node:path";
4
+ const COMMANDS = new Set(["run", "serve", "start", "stop", "status", "open", "logs", "help"]);
5
+ export function splitCommand(argv) {
6
+ const first = argv[0];
7
+ if (first && !first.startsWith("-") && COMMANDS.has(first))
8
+ return { command: first, rest: argv.slice(1) };
9
+ return { command: "run", rest: argv };
10
+ }
11
+ export function pidFilePath(dataDir) {
12
+ return join(dataDir, "hub.pid");
13
+ }
14
+ export function browserProfileDir(dataDir) {
15
+ return join(dataDir, "browser");
16
+ }
17
+ export function logFilePath(dataDir) {
18
+ return join(dataDir, "hub.log");
19
+ }
20
+ export function writePidFile(dataDir, record) {
21
+ writeFileSync(pidFilePath(dataDir), JSON.stringify(record));
22
+ }
23
+ export function readPidFile(dataDir) {
24
+ try {
25
+ const raw = readFileSync(pidFilePath(dataDir), "utf8");
26
+ const parsed = JSON.parse(raw);
27
+ if (typeof parsed.pid !== "number")
28
+ return null;
29
+ return { pid: parsed.pid, port: Number(parsed.port ?? 0), build: String(parsed.build ?? ""), startedAt: Number(parsed.startedAt ?? 0) };
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function isProcessAlive(pid) {
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ }
40
+ catch (error) {
41
+ return error.code === "EPERM";
42
+ }
43
+ }
44
+ const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
45
+ export function rotateLog(path, limit = LOG_ROTATE_BYTES) {
46
+ try {
47
+ if (!existsSync(path) || statSync(path).size < limit)
48
+ return false;
49
+ renameSync(path, `${path}.1`);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ export function tailFile(path, lines) {
57
+ try {
58
+ const text = readFileSync(path, "utf8");
59
+ return text.split(/\r?\n/).filter((l) => l.length).slice(-lines).join("\n");
60
+ }
61
+ catch {
62
+ return "";
63
+ }
64
+ }
65
+ export function findChromium(env = process.env, platform = process.platform, exists = existsSync) {
66
+ const candidates = [];
67
+ const P = platform === "win32" ? win32 : posix;
68
+ if (platform === "win32") {
69
+ const roots = [env["ProgramFiles"], env["ProgramFiles(x86)"], env["LOCALAPPDATA"]].filter((r) => !!r);
70
+ for (const root of roots)
71
+ candidates.push(P.join(root, "Google", "Chrome", "Application", "chrome.exe"));
72
+ for (const root of roots)
73
+ candidates.push(P.join(root, "Microsoft", "Edge", "Application", "msedge.exe"));
74
+ for (const root of roots)
75
+ candidates.push(P.join(root, "Chromium", "Application", "chrome.exe"));
76
+ }
77
+ else if (platform === "darwin") {
78
+ candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser");
79
+ }
80
+ else {
81
+ const dirs = (env.PATH ?? "").split(P.delimiter).filter(Boolean);
82
+ for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"]) {
83
+ for (const dir of dirs)
84
+ candidates.push(P.join(dir, name));
85
+ }
86
+ }
87
+ return candidates.find((c) => exists(c)) ?? null;
88
+ }
89
+ function findBounds(node) {
90
+ if (!node || typeof node !== "object")
91
+ return null;
92
+ const o = node;
93
+ if (["left", "top", "right", "bottom"].every((k) => typeof o[k] === "number"))
94
+ return o;
95
+ for (const value of Object.values(o)) {
96
+ const found = findBounds(value);
97
+ if (found)
98
+ return found;
99
+ }
100
+ return null;
101
+ }
102
+ export function savedWindowPlacement(profileDir) {
103
+ try {
104
+ const prefs = JSON.parse(readFileSync(join(profileDir, "Default", "Preferences"), "utf8"));
105
+ const browser = prefs.browser;
106
+ const bounds = findBounds(browser?.app_window_placement);
107
+ if (!bounds)
108
+ return null;
109
+ const width = bounds.right - bounds.left;
110
+ const height = bounds.bottom - bounds.top;
111
+ if (width < 200 || height < 150)
112
+ return null;
113
+ const placement = { left: bounds.left, top: bounds.top, width, height, maximized: !!bounds.maximized };
114
+ const wa = [bounds.work_area_left, bounds.work_area_top, bounds.work_area_right, bounds.work_area_bottom];
115
+ if (wa.every((v) => typeof v === "number"))
116
+ placement.workArea = { left: wa[0], top: wa[1], right: wa[2], bottom: wa[3] };
117
+ return placement;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ export function windowFlags(placement) {
124
+ const wa = placement.workArea;
125
+ if (placement.maximized && wa && wa.right - wa.left >= 200 && wa.bottom - wa.top >= 150) {
126
+ return [`--window-position=${wa.left},${wa.top}`, `--window-size=${wa.right - wa.left},${wa.bottom - wa.top}`];
127
+ }
128
+ let { left, top, width, height } = placement;
129
+ if (wa) {
130
+ width = Math.min(width, wa.right - wa.left);
131
+ height = Math.min(height, wa.bottom - wa.top);
132
+ left = Math.max(wa.left, Math.min(left, wa.right - width));
133
+ top = Math.max(wa.top, Math.min(top, wa.bottom - height));
134
+ }
135
+ return [`--window-position=${left},${top}`, `--window-size=${width},${height}`];
136
+ }
137
+ export function appWindowArgs(url, profileDir, freshProfile, placement = null, platform = process.platform) {
138
+ const args = [
139
+ `--app=${url}`,
140
+ `--user-data-dir=${profileDir}`,
141
+ "--no-first-run",
142
+ "--no-default-browser-check",
143
+ "--disable-background-mode",
144
+ "--disable-extensions",
145
+ "--disable-component-extensions-with-background-pages",
146
+ ];
147
+ if (placement)
148
+ args.push(...windowFlags(placement));
149
+ else if (freshProfile)
150
+ args.push("--window-size=1500,950");
151
+ if (platform === "linux")
152
+ args.push("--class=viberoom");
153
+ return args;
154
+ }
155
+ export function openUrlCommand(url, platform = process.platform) {
156
+ if (platform === "win32")
157
+ return `start "" "${url}"`;
158
+ if (platform === "darwin")
159
+ return `open "${url}"`;
160
+ return `xdg-open "${url}"`;
161
+ }
package/dist/log.js ADDED
@@ -0,0 +1,35 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export class Logger {
5
+ scope;
6
+ constructor(scope) {
7
+ this.scope = scope;
8
+ }
9
+ info(message) {
10
+ process.stderr.write(`[${timestamp()}] [${this.scope}] ${message}\n`);
11
+ }
12
+ warn(message) {
13
+ process.stderr.write(`[${timestamp()}] [${this.scope}] WARN ${message}\n`);
14
+ }
15
+ error(message) {
16
+ process.stderr.write(`[${timestamp()}] [${this.scope}] ERROR ${message}\n`);
17
+ }
18
+ child(scope) {
19
+ return new Logger(`${this.scope}/${scope}`);
20
+ }
21
+ }
22
+ function timestamp() {
23
+ return new Date().toISOString().slice(11, 23);
24
+ }
25
+ export class Transcript {
26
+ path;
27
+ constructor(directory, name) {
28
+ mkdirSync(directory, { recursive: true });
29
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
30
+ this.path = join(directory, `${name}-${stamp}.jsonl`);
31
+ }
32
+ record(direction, message) {
33
+ appendFileSync(this.path, JSON.stringify({ t: Date.now(), dir: direction === "out" ? "C->A" : "A->C", msg: message }) + "\n");
34
+ }
35
+ }
package/dist/main.js ADDED
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env node
2
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
3
+ import { exec, spawn, spawnSync } from "node:child_process";
4
+ import { appendFileSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { Hub } from "./hub.js";
9
+ import { Logger } from "./log.js";
10
+ import { startServer } from "./server.js";
11
+ import { appWindowArgs, savedWindowPlacement, findChromium, isProcessAlive, logFilePath, openUrlCommand, pidFilePath, readPidFile, rotateLog, splitCommand, tailFile, writePidFile, } from "./launcher.js";
12
+ import { aumidSyncScript, installShortcuts, windowsShortcutPaths } from "./shortcuts.js";
13
+ import { runMenu } from "./tui.js";
14
+ function parseArgs(argv) {
15
+ const { command, rest } = splitCommand(argv);
16
+ const options = {
17
+ command,
18
+ port: 4810,
19
+ dataDir: process.env.VIBEROOM_DATA_DIR ? resolve(process.env.VIBEROOM_DATA_DIR) : resolve(homedir(), ".viberoom"),
20
+ name: undefined,
21
+ open: true,
22
+ browser: false,
23
+ menu: true,
24
+ };
25
+ for (let i = 0; i < rest.length; i++) {
26
+ const arg = rest[i];
27
+ const next = () => {
28
+ const value = rest[++i];
29
+ if (value === undefined)
30
+ throw new Error(`missing value for ${arg}`);
31
+ return value;
32
+ };
33
+ switch (arg) {
34
+ case "--port":
35
+ options.port = Number(next());
36
+ break;
37
+ case "--name":
38
+ options.name = next();
39
+ break;
40
+ case "--data-dir":
41
+ options.dataDir = resolve(next());
42
+ break;
43
+ case "--open":
44
+ options.open = true;
45
+ options.menu = false;
46
+ break;
47
+ case "--no-open":
48
+ options.open = false;
49
+ options.menu = false;
50
+ break;
51
+ case "--browser":
52
+ options.browser = true;
53
+ options.menu = false;
54
+ break;
55
+ case "-h":
56
+ case "--help":
57
+ options.command = "help";
58
+ break;
59
+ default:
60
+ throw new Error(`unknown argument: ${arg}`);
61
+ }
62
+ }
63
+ return options;
64
+ }
65
+ function printHelp() {
66
+ process.stdout.write(`viberoom: group chat rooms for a human and several coding agents (Agent Client Protocol)
67
+
68
+ Usage: viberoom [command] [--port 4810] [--data-dir <dir>] [--name Human] [--no-open] [--browser]
69
+
70
+ Commands
71
+ (none) in a terminal: a small menu (desktop icon / app window / browser / run here);
72
+ with --open, --no-open or --browser, or without a terminal: run the hub in this
73
+ process and open the window; if a hub is already running, open it (or replace it
74
+ when this build is newer)
75
+ start run the hub hidden in the background (log in <data-dir>/hub.log) and open the window
76
+ stop stop the background hub
77
+ status show whether a hub is running, its build and address
78
+ open open the window of the running hub
79
+ logs print the last lines of the background hub's log
80
+
81
+ Options
82
+ --port localhost port for the web UI (default 4810)
83
+ --data-dir where settings, rooms, history, skills and transcripts live (default ~/.viberoom, or $VIBEROOM_DATA_DIR)
84
+ --name the human's display name (optional; the first start asks in the browser)
85
+ --no-open do not open a window
86
+ --browser open the default browser instead of a Chromium app window
87
+ `);
88
+ }
89
+ function buildInfo() {
90
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
91
+ const built = statSync(fileURLToPath(import.meta.url)).mtime;
92
+ return { name: pkg.name, version: pkg.version, build: built.toISOString() };
93
+ }
94
+ async function runningInstance(port) {
95
+ const url = `http://127.0.0.1:${port}/`;
96
+ try {
97
+ const res = await fetch(`${url}api/settings`, { signal: AbortSignal.timeout(1500) });
98
+ if (!res.ok)
99
+ return null;
100
+ const body = (await res.json());
101
+ if (typeof body.humanName !== "string")
102
+ return null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ try {
108
+ const res = await fetch(`${url}api/version`, { signal: AbortSignal.timeout(1500) });
109
+ if (!res.ok)
110
+ return { url, build: null };
111
+ const info = (await res.json());
112
+ return { url, build: typeof info.build === "string" ? info.build : null };
113
+ }
114
+ catch {
115
+ return { url, build: null };
116
+ }
117
+ }
118
+ async function waitUntil(check, timeoutMs, stepMs = 250) {
119
+ const deadline = Date.now() + timeoutMs;
120
+ while (Date.now() < deadline) {
121
+ if (await check())
122
+ return true;
123
+ await new Promise((resolve) => setTimeout(resolve, stepMs));
124
+ }
125
+ return false;
126
+ }
127
+ async function isUp(url) {
128
+ try {
129
+ await fetch(`${url}api/settings`, { signal: AbortSignal.timeout(500) });
130
+ return true;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ async function stopInstance(url, log) {
137
+ try {
138
+ await fetch(`${url}api/shutdown`, { method: "POST", signal: AbortSignal.timeout(1500) });
139
+ }
140
+ catch (error) {
141
+ log.warn(`could not ask the running hub to stop: ${String(error)}`);
142
+ return false;
143
+ }
144
+ return waitUntil(async () => !(await isUp(url)), 10_000);
145
+ }
146
+ function migrateLegacyData(dataDir, log) {
147
+ const legacy = resolve(fileURLToPath(new URL("../data/", import.meta.url)));
148
+ if (existsSync(join(dataDir, "rooms.json")) || !existsSync(join(legacy, "rooms.json")))
149
+ return;
150
+ if (resolve(legacy) === resolve(dataDir))
151
+ return;
152
+ mkdirSync(dataDir, { recursive: true });
153
+ cpSync(legacy, dataDir, { recursive: true });
154
+ log.info(`copied existing rooms and settings from ${legacy} to ${dataDir} (the old folder is left untouched)`);
155
+ }
156
+ function reapHiddenBrowser(profileDir, log) {
157
+ if (process.platform !== "win32")
158
+ return;
159
+ const needle = profileDir.replace(/'/g, "''");
160
+ const script = `$ps = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like '*${needle}*' -and $_.CommandLine -notlike '*--type=*' }; foreach ($p in $ps) { $proc = Get-Process -Id $p.ProcessId -ErrorAction SilentlyContinue; if ($proc -and $proc.MainWindowHandle -eq 0) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue; "killed $($p.ProcessId)" } elseif ($proc) { "open $($p.ProcessId)" } }`;
161
+ try {
162
+ const r = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", timeout: 10_000, windowsHide: true });
163
+ for (const line of (r.stdout || "").split(/\r?\n/).filter(Boolean)) {
164
+ if (line.startsWith("killed"))
165
+ log.info(`closed a leftover browser process that had no window (pid ${line.slice(7)})`);
166
+ else if (line.startsWith("open"))
167
+ log.warn(`the app window is already open (browser pid ${line.slice(5)}); a second window joins it and the saved placement applies only after both are closed`);
168
+ }
169
+ }
170
+ catch {
171
+ }
172
+ }
173
+ function openWindow(url, options, log) {
174
+ const chromium = options.browser ? null : findChromium();
175
+ if (chromium) {
176
+ const profile = join(options.dataDir, "browser");
177
+ const fresh = !existsSync(profile);
178
+ mkdirSync(profile, { recursive: true });
179
+ reapHiddenBrowser(profile, log);
180
+ const placement = fresh ? null : savedWindowPlacement(profile);
181
+ const args = appWindowArgs(url, profile, fresh, placement);
182
+ const where = placement
183
+ ? `last seen at ${placement.left},${placement.top} ${placement.width}x${placement.height}${placement.maximized ? " maximized" : ""}${placement.workArea ? ` on the screen ${placement.workArea.left},${placement.workArea.top}-${placement.workArea.right},${placement.workArea.bottom}` : ""}; flags ${args.filter((a) => a.startsWith("--window-")).join(" ")}`
184
+ : "no saved placement";
185
+ log.info(`opening the app window with ${chromium}: ${where}`);
186
+ try {
187
+ appendFileSync(logFilePath(options.dataDir), `[${new Date().toISOString()}] [launcher] app window: ${where}\n`);
188
+ }
189
+ catch {
190
+ }
191
+ spawn(chromium, args, { detached: true, stdio: "ignore" }).unref();
192
+ if (process.platform === "win32") {
193
+ const shortcuts = windowsShortcutPaths(homedir(), process.env, true).filter((p) => existsSync(p));
194
+ if (shortcuts.length)
195
+ spawn("powershell", ["-NoProfile", "-Command", aumidSyncScript(profile, shortcuts)], { detached: true, stdio: "ignore", windowsHide: true }).unref();
196
+ }
197
+ return;
198
+ }
199
+ log.info("opening the default browser");
200
+ exec(openUrlCommand(url), () => undefined);
201
+ }
202
+ async function runHub(options, log, info) {
203
+ const background = options.command === "serve";
204
+ if (!background) {
205
+ const running = await runningInstance(options.port);
206
+ if (running && running.build === info.build) {
207
+ log.info(`viberoom is already running at ${running.url} (same build); opening it.`);
208
+ process.stdout.write(`${running.url}\n`);
209
+ if (options.open)
210
+ openWindow(running.url, options, log);
211
+ return;
212
+ }
213
+ if (running) {
214
+ log.info(`an older viberoom build is running at ${running.url}; replacing it with the build from ${info.build}`);
215
+ if (!(await stopInstance(running.url, log))) {
216
+ throw new Error(`the older viberoom hub on port ${options.port} did not stop; stop it (viberoom stop, or Ctrl+C in its terminal) and run viberoom again`);
217
+ }
218
+ }
219
+ }
220
+ migrateLegacyData(options.dataDir, log);
221
+ const hub = new Hub(options.dataDir, log, options.name);
222
+ if (options.name && hub.settings.humanName !== options.name)
223
+ hub.updateSettings({ humanName: options.name });
224
+ let shuttingDown = false;
225
+ let server;
226
+ const shutdown = async () => {
227
+ if (shuttingDown)
228
+ return;
229
+ shuttingDown = true;
230
+ log.info("shutting down: closing agent sessions");
231
+ await hub.shutdown();
232
+ server?.close();
233
+ if (background)
234
+ rmSync(pidFilePath(options.dataDir), { force: true });
235
+ process.exit(0);
236
+ };
237
+ try {
238
+ server = await startServer(hub, options.port, log.child("http"), info, () => void shutdown());
239
+ }
240
+ catch (error) {
241
+ const code = error.code;
242
+ if (code === "EADDRINUSE") {
243
+ throw new Error(`port ${options.port} is taken by another program (not a viberoom hub). Pick another port: viberoom --port 4811`);
244
+ }
245
+ throw error;
246
+ }
247
+ hub.setHubUrl(server.url);
248
+ if (background)
249
+ writePidFile(options.dataDir, { pid: process.pid, port: options.port, build: info.build, startedAt: Date.now() });
250
+ 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(", ")})`);
251
+ process.stdout.write(`${server.url}\n`);
252
+ if (options.open && !background)
253
+ openWindow(server.url, options, log);
254
+ process.on("SIGINT", () => void shutdown());
255
+ process.on("SIGTERM", () => void shutdown());
256
+ process.on("SIGHUP", () => void shutdown());
257
+ }
258
+ async function startBackground(options, log, info) {
259
+ const url = `http://127.0.0.1:${options.port}/`;
260
+ const running = await runningInstance(options.port);
261
+ if (running && running.build === info.build) {
262
+ log.info(`viberoom is already running at ${url} (same build).`);
263
+ process.stdout.write(`${url}\n`);
264
+ if (options.open)
265
+ openWindow(url, options, log);
266
+ return;
267
+ }
268
+ if (running) {
269
+ log.info(`an older viberoom build is running at ${url}; replacing it with the build from ${info.build}`);
270
+ if (!(await stopInstance(url, log)))
271
+ throw new Error(`the older viberoom hub on port ${options.port} did not stop; try: viberoom stop`);
272
+ }
273
+ mkdirSync(options.dataDir, { recursive: true });
274
+ const logPath = logFilePath(options.dataDir);
275
+ rotateLog(logPath);
276
+ const fd = openSync(logPath, "a");
277
+ const args = [fileURLToPath(import.meta.url), "serve", "--port", String(options.port), "--data-dir", options.dataDir, "--no-open"];
278
+ if (options.name)
279
+ args.push("--name", options.name);
280
+ const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", fd, fd], windowsHide: true });
281
+ child.unref();
282
+ closeSync(fd);
283
+ log.info(`hub started in the background (pid ${child.pid}); log: ${logPath}`);
284
+ const up = await waitUntil(() => isUp(url), 20_000);
285
+ if (!up)
286
+ throw new Error(`the hub did not come up within 20 s; see ${logPath}`);
287
+ process.stdout.write(`${url}\n`);
288
+ if (options.open)
289
+ openWindow(url, options, log);
290
+ }
291
+ async function stopBackground(options, log) {
292
+ const url = `http://127.0.0.1:${options.port}/`;
293
+ const record = readPidFile(options.dataDir);
294
+ if (await isUp(url)) {
295
+ const ok = await stopInstance(url, log);
296
+ if (ok) {
297
+ log.info(`hub on port ${options.port} stopped`);
298
+ rmSync(pidFilePath(options.dataDir), { force: true });
299
+ return;
300
+ }
301
+ }
302
+ if (record && isProcessAlive(record.pid)) {
303
+ log.warn(`the hub did not answer on ${url}; terminating pid ${record.pid}`);
304
+ try {
305
+ process.kill(record.pid);
306
+ }
307
+ catch (error) {
308
+ throw new Error(`could not terminate pid ${record.pid}: ${String(error)}`);
309
+ }
310
+ rmSync(pidFilePath(options.dataDir), { force: true });
311
+ return;
312
+ }
313
+ rmSync(pidFilePath(options.dataDir), { force: true });
314
+ log.info("no hub is running");
315
+ }
316
+ async function showStatus(options) {
317
+ const url = `http://127.0.0.1:${options.port}/`;
318
+ const running = await runningInstance(options.port);
319
+ const record = readPidFile(options.dataDir);
320
+ if (running) {
321
+ const pid = record && isProcessAlive(record.pid) ? ` (background pid ${record.pid}, started ${new Date(record.startedAt).toLocaleString()})` : " (foreground or another data folder)";
322
+ process.stdout.write(`running at ${url}${pid}\nbuild: ${running.build ?? "unknown (older build)"}\ndata: ${options.dataDir}\nlog: ${logFilePath(options.dataDir)}\n`);
323
+ }
324
+ else {
325
+ process.stdout.write(`not running on port ${options.port}${record ? ` (stale pid file: ${record.pid})` : ""}\n`);
326
+ if (record && !isProcessAlive(record.pid))
327
+ rmSync(pidFilePath(options.dataDir), { force: true });
328
+ }
329
+ }
330
+ async function main() {
331
+ const log = new Logger("hub");
332
+ const options = parseArgs(process.argv.slice(2));
333
+ const info = buildInfo();
334
+ switch (options.command) {
335
+ case "help":
336
+ printHelp();
337
+ return;
338
+ case "start":
339
+ await startBackground(options, log, info);
340
+ return;
341
+ case "stop":
342
+ await stopBackground(options, log);
343
+ return;
344
+ case "status":
345
+ await showStatus(options);
346
+ return;
347
+ case "open": {
348
+ const url = `http://127.0.0.1:${options.port}/`;
349
+ if (!(await isUp(url)))
350
+ throw new Error(`no hub is running on port ${options.port}; start one with: viberoom start`);
351
+ openWindow(url, options, log);
352
+ return;
353
+ }
354
+ case "logs": {
355
+ const path = logFilePath(options.dataDir);
356
+ process.stdout.write(`${path}\n${tailFile(path, 60)}\n`);
357
+ return;
358
+ }
359
+ case "run":
360
+ if (options.menu) {
361
+ const choice = await runMenu("viberoom: rooms for you and your coding agents");
362
+ if (choice === "quit")
363
+ return;
364
+ if (choice === "window" || choice === "browser") {
365
+ options.browser = choice === "browser";
366
+ await startBackground(options, log, info);
367
+ return;
368
+ }
369
+ if (choice === "shortcut") {
370
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
371
+ const result = installShortcuts({ root: fileURLToPath(new URL("..", import.meta.url)), dataDir: options.dataDir, node: process.execPath, version: pkg.version, desktop: true });
372
+ for (const file of result.files)
373
+ process.stdout.write(`wrote: ${file}\n`);
374
+ for (const note of result.notes)
375
+ process.stdout.write(`${note}\n`);
376
+ return;
377
+ }
378
+ }
379
+ await runHub(options, log, info);
380
+ return;
381
+ case "serve":
382
+ await runHub(options, log, info);
383
+ return;
384
+ }
385
+ }
386
+ main().catch((error) => {
387
+ process.stderr.write(`viberoom: ${error instanceof Error ? error.message : String(error)}\n`);
388
+ process.exit(1);
389
+ });