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 +20 -0
- package/dist/agent-health.js +52 -0
- package/dist/context.js +6 -0
- package/dist/duration.js +12 -0
- package/dist/hub.js +13 -2
- package/dist/launcher.js +19 -2
- package/dist/main.js +77 -34
- package/dist/mcp-skills-server.js +143 -0
- package/dist/persona.js +167 -46
- package/dist/quotes.js +41 -0
- package/dist/recipes.js +28 -1
- package/dist/room-design.js +205 -0
- package/dist/room.js +388 -150
- package/dist/rows.js +19 -0
- package/dist/server.js +304 -27
- package/dist/skills.js +28 -2
- package/dist/templates.js +17 -0
- package/dist/update.js +32 -2
- package/dist/viewer.js +69 -2
- package/dist/ws.js +175 -0
- package/package.json +2 -1
- package/ui/app.css +348 -246
- package/ui/app.js +1422 -345
- package/ui/avatars.js +131 -30
- package/ui/components.css +140 -0
- package/ui/components.js +342 -0
- package/ui/icons.js +11 -0
- package/ui/index.html +58 -36
- package/ui/theme.css +457 -180
- package/ui/tokens.js +620 -0
- package/ui/ui.js +113 -0
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
|
}
|
package/dist/duration.js
ADDED
|
@@ -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
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
309
|
-
const
|
|
310
|
-
|
|
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 ${
|
|
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(
|
|
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(
|
|
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
|
|
342
|
-
const
|
|
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 ${
|
|
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 (
|
|
352
|
-
log.warn(`the hub did not answer on ${url}; terminating pid ${
|
|
386
|
+
if (live) {
|
|
387
|
+
log.warn(`the hub did not answer on ${url}; terminating pid ${live.pid}`);
|
|
353
388
|
try {
|
|
354
|
-
process.kill(
|
|
389
|
+
process.kill(live.pid);
|
|
355
390
|
}
|
|
356
391
|
catch (error) {
|
|
357
|
-
throw new Error(`could not terminate pid ${
|
|
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
|
|
371
|
-
|
|
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 ${
|
|
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
|
|
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 ${
|
|
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
|
}
|