viberoom 0.7.0 → 0.9.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/dist/recipes.js CHANGED
@@ -1,12 +1,21 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { createRequire } from "node:module";
6
- import { dirname, join } from "node:path";
6
+ import { basename, dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { loginState } from "./agent-health.js";
9
9
  const isWindows = process.platform === "win32";
10
+ const ICON_VERSION = (() => {
11
+ try {
12
+ return String(createRequire(import.meta.url)("../package.json").version ?? "0");
13
+ }
14
+ catch {
15
+ return "0";
16
+ }
17
+ })();
18
+ const iconUrl = (id) => `/vendor-icons/${id}.svg?v=${ICON_VERSION}`;
10
19
  function resolvePackageEntry(packageName, relativeEntry) {
11
20
  try {
12
21
  const require = createRequire(import.meta.url);
@@ -141,6 +150,46 @@ function resolveClaudeCode() {
141
150
  function resolveCodex() {
142
151
  return resolveGlobalNpmBin("codex") ?? resolveOnPath(["codex"]);
143
152
  }
153
+ function realHome() {
154
+ try {
155
+ return realpathSync(homedir());
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ function resolveGrok() {
162
+ const exe = isWindows ? "grok.exe" : "grok";
163
+ const home = realHome();
164
+ const dirs = [process.env.GROK_BIN_DIR, process.env.GROK_HOME && join(process.env.GROK_HOME, "bin"), join(homedir(), ".grok", "bin"), home && join(home, ".grok", "bin")];
165
+ for (const dir of dirs)
166
+ if (dir && existsSync(join(dir, exe)))
167
+ return join(dir, exe);
168
+ const onPath = resolveOnPath(["grok"]);
169
+ return onPath && !/\.(cmd|bat|ps1)$/i.test(onPath) ? onPath : null;
170
+ }
171
+ function resolveHermes() {
172
+ const hermesHome = process.env.HERMES_HOME || (isWindows ? process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "hermes") : join(homedir(), ".hermes"));
173
+ const scripts = isWindows ? "Scripts" : "bin";
174
+ const exe = (name) => (isWindows ? `${name}.exe` : name);
175
+ const candidates = [];
176
+ if (hermesHome) {
177
+ const venv = join(hermesHome, "hermes-agent", "venv");
178
+ candidates.push({ command: join(venv, scripts, exe("hermes-acp")), args: [] }, { command: join(venv, scripts, exe("hermes")), args: ["acp"] });
179
+ if (isWindows)
180
+ candidates.push({ command: join(hermesHome, "bin", "hermes-acp.exe"), args: [] }, { command: join(hermesHome, "bin", "hermes.exe"), args: ["acp"] });
181
+ }
182
+ if (!isWindows) {
183
+ candidates.push({ command: join(homedir(), ".local", "bin", "hermes"), args: ["acp"] }, { command: "/usr/local/lib/hermes-agent/venv/bin/hermes-acp", args: [] }, { command: "/usr/local/bin/hermes", args: ["acp"] });
184
+ }
185
+ for (const candidate of candidates)
186
+ if (existsSync(candidate.command))
187
+ return candidate;
188
+ const onPath = resolveOnPath(["hermes-acp", "hermes"]);
189
+ if (onPath && !/\.(cmd|bat|ps1)$/i.test(onPath))
190
+ return { command: onPath, args: /^hermes-acp/i.test(basename(onPath)) ? [] : ["acp"] };
191
+ return null;
192
+ }
144
193
  const vendorDir = join(dirname(fileURLToPath(import.meta.url)), "..", "vendor", "acp");
145
194
  const claudeAdapter = join(vendorDir, "claude-agent-acp", "dist", "index.js");
146
195
  const codexAdapter = join(vendorDir, "codex-acp", "dist", "index.js");
@@ -151,12 +200,14 @@ const geminiEntry = resolvePackageEntry("@google/gemini-cli", join("bundle", "ge
151
200
  const cursorAgent = resolveCursorAgent();
152
201
  const openCodeExe = resolveOpenCode();
153
202
  const copilotExe = resolveCopilot();
203
+ const grokExe = resolveGrok();
204
+ const hermesLaunch = resolveHermes();
154
205
  const recipes = [
155
206
  {
156
207
  id: "claude",
157
208
  label: "Claude (claude-agent-acp)",
158
209
  vendor: "Claude",
159
- icon: "/vendor-icons/claude.svg",
210
+ icon: iconUrl("claude"),
160
211
  tested: true,
161
212
  note: "Adapter around the Claude Agent SDK, driving the Claude Code installed on this machine with its login and settings.",
162
213
  modelPresets: ["haiku", "sonnet", "opus", "default"],
@@ -182,7 +233,7 @@ const recipes = [
182
233
  id: "codex",
183
234
  label: "Codex (codex-acp)",
184
235
  vendor: "Codex",
185
- icon: "/vendor-icons/codex.svg",
236
+ icon: iconUrl("codex"),
186
237
  tested: true,
187
238
  note: "Adapter around the Codex App Server of the Codex CLI installed on this machine; uses its login (~/.codex) or CODEX_API_KEY. Mode 'agent' edits the working directory without asking; 'read-only' for a chat-only participant.",
188
239
  modelPresets: ["gpt-5.4-mini", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"],
@@ -208,7 +259,7 @@ const recipes = [
208
259
  id: "gemini",
209
260
  label: "Gemini CLI (gemini --acp)",
210
261
  vendor: "Gemini",
211
- icon: "/vendor-icons/gemini.svg",
262
+ icon: iconUrl("gemini"),
212
263
  tested: true,
213
264
  note: "Native ACP mode of the globally installed Gemini CLI; uses the machine's Gemini login / API key. Exposes no config options over ACP: the model is fixed at launch (--model).",
214
265
  modelPresets: ["gemini-3.8-flash", "gemini-3.7-flash"],
@@ -235,7 +286,7 @@ const recipes = [
235
286
  id: "cursor",
236
287
  label: "Cursor (cursor-agent acp)",
237
288
  vendor: "Cursor",
238
- icon: "/vendor-icons/cursor.svg",
289
+ icon: iconUrl("cursor"),
239
290
  tested: true,
240
291
  note: "Cursor's CLI agent in native ACP mode; uses the machine's Cursor login (agent login) or CURSOR_API_KEY. Mode 'agent' edits without asking; 'ask' is read-only Q&A. Models: see the session settings after joining.",
241
292
  modelPresets: [],
@@ -261,7 +312,7 @@ const recipes = [
261
312
  id: "opencode",
262
313
  label: "OpenCode (opencode acp)",
263
314
  vendor: "OpenCode",
264
- icon: "/vendor-icons/opencode.svg",
315
+ icon: iconUrl("opencode"),
265
316
  tested: false,
266
317
  note: "The open-source coding agent in native ACP mode; the model list comes from the providers configured in OpenCode (opencode providers). Mode 'plan' is read-only; 'build' edits the working directory (OpenCode's own permission config decides what still asks; the questions arrive here).",
267
318
  modelPresets: [],
@@ -286,7 +337,7 @@ const recipes = [
286
337
  id: "copilot",
287
338
  label: "GitHub Copilot (copilot --acp)",
288
339
  vendor: "Copilot",
289
- icon: "/vendor-icons/copilot.svg",
340
+ icon: iconUrl("copilot"),
290
341
  tested: false,
291
342
  note: "GitHub Copilot CLI in native ACP mode; uses the machine's Copilot login (copilot login). Session modes agent / plan / autopilot; the 'allow_all' option decides whether tool calls ask for permission. Exposes no model option over ACP: the model is fixed at launch (--model, e.g. auto).",
292
343
  modelPresets: ["auto"],
@@ -312,6 +363,57 @@ const recipes = [
312
363
  args: ["--acp", ...(model ? ["--model", model] : [])],
313
364
  }),
314
365
  },
366
+ {
367
+ id: "grok",
368
+ label: "Grok Build (grok agent stdio)",
369
+ vendor: "Grok",
370
+ icon: iconUrl("grok"),
371
+ tested: true,
372
+ note: "xAI's coding agent in its native ACP mode; uses this machine's Grok login (grok login) or XAI_API_KEY. It reports no session modes over ACP, so the mode is a launch flag: 'ask-first' asks before every tool call, 'always-approve' never does; a change restarts the session with its notes kept. Model and reasoning effort are session options (the model also goes on the launch command).",
373
+ modelPresets: ["grok-4.6", "grok-4.5"],
374
+ defaultModel: null,
375
+ effortPresets: ["xhigh", "high", "medium", "low"],
376
+ defaultEffort: null,
377
+ modePresets: ["ask-first", "always-approve"],
378
+ defaultMode: "ask-first",
379
+ bypassMode: "always-approve",
380
+ unavailableReason: grokExe ? null : "Grok Build not found",
381
+ installedAt: grokExe,
382
+ installHint: "curl -fsSL https://x.ai/cli/install.sh | bash (Windows: irm https://x.ai/cli/install.ps1 | iex), or npm install -g @xai-official/grok; then grok login",
383
+ loginCommand: "grok login",
384
+ login: { env: ["XAI_API_KEY", "GROK_DEPLOYMENT_KEY"], files: [".grok/auth.json"], command: "grok login" },
385
+ loginState: "unknown",
386
+ modeAtLaunch: true,
387
+ build: ({ model, mode }) => ({
388
+ command: grokExe ?? "",
389
+ args: ["agent", ...(model ? ["-m", model] : []), ...(mode === "always-approve" ? ["--always-approve"] : []), "stdio"],
390
+ }),
391
+ },
392
+ {
393
+ id: "hermes",
394
+ label: "Hermes Agent (hermes acp)",
395
+ vendor: "Hermes",
396
+ icon: iconUrl("hermes"),
397
+ tested: true,
398
+ note: "Nous Research's open-source agent in its native ACP mode; the provider and model are the ones configured in Hermes (hermes model). Mode 'default' asks before edits, 'accept_edits' auto-allows workspace and /tmp edits, 'dont_ask' auto-allows file edits except sensitive paths; a tool call that still needs permission arrives here with Hermes' own choices: once, this session, or always.",
399
+ modelPresets: [],
400
+ defaultModel: null,
401
+ effortPresets: [],
402
+ defaultEffort: null,
403
+ modePresets: ["default", "accept_edits", "dont_ask"],
404
+ defaultMode: "default",
405
+ bypassMode: "dont_ask",
406
+ unavailableReason: hermesLaunch ? null : "Hermes Agent not found",
407
+ installedAt: hermesLaunch?.command ?? null,
408
+ installHint: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash (Windows: iex (irm https://hermes-agent.nousresearch.com/install.ps1)), then hermes model",
409
+ loginCommand: "hermes model",
410
+ login: { env: [], files: [], command: "hermes model" },
411
+ loginState: "unknown",
412
+ build: () => ({
413
+ command: hermesLaunch?.command ?? "",
414
+ args: hermesLaunch?.args ?? [],
415
+ }),
416
+ },
315
417
  ];
316
418
  const fakeAgent = process.env.VIBEROOM_FAKE_AGENT;
317
419
  if (fakeAgent) {
@@ -1,11 +1,11 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { AGENT_SETTINGS, DEFAULT_ROOM_SETTINGS, NAME_PATTERN, ROOM_SETTINGS_SPEC, SILENT_MARKER, REQUEST_BRIEF_MARKER, buildBrief, coerceSetting } from "./persona.js";
3
3
  export const TAGLINE_MAX = 80;
4
- export const ROLE_MAX = 4000;
4
+ export const ROLE_MAX = 8000;
5
5
  export const AVATAR_MAX = 8;
6
6
  export const RULES_SOFT_MAX = 12;
7
7
  export const ROLE_SOFT_MAX = 1200;
8
- export const RULES_NEAR_LIMIT = 3000;
8
+ export const RULES_NEAR_LIMIT = 0.75;
9
9
  export function ruleLines(customRules) {
10
10
  return customRules
11
11
  .split(/\r?\n/)
@@ -57,6 +57,7 @@ export function lintRoomDesign(design, context) {
57
57
  if (!vibemates.length)
58
58
  error("template-no-vibemates", "a template needs at least one vibemate");
59
59
  }
60
+ const roleMax = raw.briefTextLimit !== undefined ? settings.briefTextLimit : context.briefTextLimit ?? settings.briefTextLimit ?? ROLE_MAX;
60
61
  const seen = new Map();
61
62
  const touched = context.changedVibemates ? new Set(context.changedVibemates.map((n) => n.trim().toLowerCase())) : null;
62
63
  for (const [i, v] of vibemates.entries()) {
@@ -79,8 +80,8 @@ export function lintRoomDesign(design, context) {
79
80
  error("tagline-too-long", `${who}: the tagline is at most ${TAGLINE_MAX} characters (it is the one line the others see)`);
80
81
  else if (soft && !(v?.tagline ?? "").trim())
81
82
  warn("vibemate-no-tagline", `${who} has no tagline: the others read it in the roster to know what this one leans to`);
82
- if ((v?.role ?? "").length > ROLE_MAX)
83
- error("role-too-long", `${who}: the role is at most ${ROLE_MAX} characters`);
83
+ if ((v?.role ?? "").length > roleMax)
84
+ error("role-too-long", `${who}: the role is ${v.role.length} characters; the limit is ${roleMax} (the briefTextLimit setting)`);
84
85
  else if (soft && !(v?.role ?? "").trim())
85
86
  warn("vibemate-no-role", `${who} has no role: without one it is the agent's default self, not a character`);
86
87
  else if (soft && (v.role ?? "").length > ROLE_SOFT_MAX)
@@ -109,8 +110,10 @@ export function lintRoomDesign(design, context) {
109
110
  const rules = ruleLines(settings.customRules);
110
111
  if (rules.length > RULES_SOFT_MAX)
111
112
  warn("too-many-rules", `${rules.length} rules: every vibemate carries them on every turn; ${RULES_SOFT_MAX} is a full protocol, longer belongs in a skill`);
112
- if (settings.customRules.length > RULES_NEAR_LIMIT)
113
- warn("rules-near-limit", `the rules are ${settings.customRules.length} characters; the hub stores at most 4000`);
113
+ if (settings.customRules.length > roleMax)
114
+ error("rules-too-long", `the rules are ${settings.customRules.length} characters; the limit is ${roleMax} (the briefTextLimit setting)`);
115
+ else if (settings.customRules.length > roleMax * RULES_NEAR_LIMIT)
116
+ warn("rules-near-limit", `the rules are ${settings.customRules.length} characters; the limit is ${roleMax}`);
114
117
  for (const rule of rules) {
115
118
  for (const m of BRIEF_MECHANICS)
116
119
  if (m.pattern.test(rule))
package/dist/room.js CHANGED
@@ -21,6 +21,7 @@ import { applyVibemateChanges, diffSettings, lintRoomDesign, ruleLines } from ".
21
21
  import { BRIEF_AFFECTING_SETTINGS, AGENT_SETTINGS, coerceSetting, describeSettings, DEFAULT_ROOM_SETTINGS, ROOM_SETTINGS_SPEC, REQUEST_BRIEF_MARKER, NAME_PATTERN, SILENT_MARKER, buildBrief, buildHeader, composeCorrectionPrompt, composePrompt, countSentences, ensureDir, } from "./persona.js";
22
22
  import { Transcript } from "./log.js";
23
23
  const SKILL_TOOL_READY_MS = 5000;
24
+ const AUTH_WAIT_MS = 8000;
24
25
  const COLORS = ["#6d5dfc", "#16a34a", "#d97706", "#dc2626", "#0891b2", "#be185d", "#4d7c0f", "#7c3aed"];
25
26
  const MENTION_PATTERN = /@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
26
27
  const RULE_REF_TOKEN = /@\{p:([^}]+)\}/g;
@@ -113,7 +114,7 @@ export class Room extends EventEmitter {
113
114
  restore(stored) {
114
115
  for (const s of stored) {
115
116
  if (!s.agentType) {
116
- this.addUnstaffed({ name: s.name, tagline: s.tagline, role: s.role, avatar: s.avatar, skills: s.skills, color: s.color, id: s.id });
117
+ this.addUnstaffed({ name: s.name, tagline: s.tagline, role: s.role, avatar: s.avatar, skills: s.skills, color: s.color, id: s.id, textCheck: "keep" });
117
118
  continue;
118
119
  }
119
120
  const recipe = getRecipe(s.agentType);
@@ -505,14 +506,8 @@ export class Room extends EventEmitter {
505
506
  participant.sessionId = undefined;
506
507
  this.restoredSeen.set(id, this.seq);
507
508
  this.push({ type: "participant", participant });
508
- if (!online) {
509
- participant.statusDetail = withNotes ? `${why}; a reconnect starts it with its notes` : `${why}; a reconnect starts it with an empty head`;
510
- const comesBack = withNotes ? "it comes back with its notes" : "it comes back knowing nothing from before";
511
- this.postSystem(options.reason ? `${participant.name} was respawned while offline (${why}): ${comesBack}.` : `${participant.name} was respawned while offline: ${comesBack}.`);
512
- this.push({ type: "participant", participant });
513
- this.log.info(`respawn of ${participant.name} (offline): stored session dropped`);
514
- return participant;
515
- }
509
+ if (!online)
510
+ this.log.info(`respawn of ${participant.name} (offline): stored session dropped, starting it now`);
516
511
  await this.reconnect(id, memory
517
512
  ? { mode: "replay", replay, memory: withNotes, reason: `${why}; it comes back with ${withNotes ? "its notes and " : ""}the last ${replay} messages` }
518
513
  : { mode: "replay", replay: 0, reason: `${why}, it remembers nothing from before` });
@@ -676,7 +671,7 @@ export class Room extends EventEmitter {
676
671
  continue;
677
672
  let value = coerceSetting(key, patch[key]);
678
673
  if (key === "customRules") {
679
- const resolved = this.resolveRuleReferences(value);
674
+ const resolved = this.resolveRuleReferences(this.guardBriefText("The room rules text", value, "refuse", next.briefTextLimit));
680
675
  unknownRefs = resolved.unknown;
681
676
  value = resolved.stored;
682
677
  }
@@ -699,6 +694,14 @@ export class Room extends EventEmitter {
699
694
  }
700
695
  return this.settings;
701
696
  }
697
+ guardBriefText(what, text, check = "refuse", limit = this.settings.briefTextLimit) {
698
+ if (check === "keep" || text.length <= limit)
699
+ return text;
700
+ if (check === "refuse")
701
+ throw new Error(`${what} is ${text.length} characters; this room's limit is ${limit} (the briefTextLimit setting). Shorten it, move the instructions into a skill, or raise the limit.`);
702
+ this.postSystem(`${what} is ${text.length} characters, over this room's limit of ${limit}; kept as it is, but the next edit has to fit (shorten it or raise briefTextLimit).`, "human", false, { tone: "attention" });
703
+ return text;
704
+ }
702
705
  updatePersona(id, patch) {
703
706
  const participant = this.participants.get(id);
704
707
  const runtime = this.runtimes.get(id);
@@ -727,7 +730,7 @@ export class Room extends EventEmitter {
727
730
  changed.push("tagline");
728
731
  }
729
732
  if (patch.role !== undefined && patch.role.trim() !== (participant.role ?? "")) {
730
- participant.role = patch.role.trim().slice(0, 4000);
733
+ participant.role = this.guardBriefText(`${participant.name}'s vibio`, patch.role.trim());
731
734
  changed.push("role");
732
735
  }
733
736
  if (patch.avatar !== undefined) {
@@ -786,7 +789,7 @@ export class Room extends EventEmitter {
786
789
  return cached;
787
790
  const cwd = ensureDir(join(this.dataDir, ".probe"));
788
791
  const log = this.log.child(`probe:${recipeId}`);
789
- const launch = recipe.build({ model: null });
792
+ const launch = recipe.build({ model: null, mode: null });
790
793
  const agent = new AcpAgent({ ...launch, cwd }, {
791
794
  onSessionUpdate: () => undefined,
792
795
  onPermissionRequest: async () => ({ outcome: { outcome: "cancelled" } }),
@@ -798,7 +801,7 @@ export class Room extends EventEmitter {
798
801
  const info = await Promise.race([
799
802
  (async () => {
800
803
  const init = await agent.initialize({ name: "viberoom", version: "0.2.0" });
801
- const session = await this.openSession(agent, cwd, log);
804
+ const session = await this.openSession(agent, cwd, log, [], recipe);
802
805
  const result = {
803
806
  recipeId,
804
807
  agentInfo: { name: init.agentInfo?.name ?? null, version: init.agentInfo?.version ?? null },
@@ -806,6 +809,7 @@ export class Room extends EventEmitter {
806
809
  modes: session.modes ?? null,
807
810
  configOptions: session.configOptions ?? [],
808
811
  modelAtLaunch: !!recipe.modelAtLaunch,
812
+ modeAtLaunch: !!recipe.modeAtLaunch,
809
813
  discoveredAt: Date.now(),
810
814
  durationMs: 0,
811
815
  };
@@ -858,7 +862,7 @@ export class Room extends EventEmitter {
858
862
  turns: 0,
859
863
  color: options.color ?? COLORS[this.colorIndex++ % COLORS.length],
860
864
  tagline: (options.tagline ?? "").trim().slice(0, 80),
861
- role: (options.role ?? "").trim().slice(0, 4000),
865
+ role: this.guardBriefText(`${name}'s vibio`, (options.role ?? "").trim(), options.textCheck),
862
866
  avatar: (options.avatar ?? "").trim().slice(0, 8) || undefined,
863
867
  replyDelay: options.replyDelay === undefined || options.replyDelay === null ? undefined : Math.max(0, Math.min(120, Number(options.replyDelay) || 0)),
864
868
  skills: normalizeSkillList(options.skills ?? undefined),
@@ -888,7 +892,7 @@ export class Room extends EventEmitter {
888
892
  turns: 0,
889
893
  color: input.color ?? COLORS[this.colorIndex++ % COLORS.length],
890
894
  tagline: (input.tagline ?? "").trim().slice(0, 80),
891
- role: (input.role ?? "").trim().slice(0, 4000),
895
+ role: this.guardBriefText(`${name}'s vibio`, (input.role ?? "").trim(), input.textCheck),
892
896
  avatar: (input.avatar ?? "").trim().slice(0, 8) || undefined,
893
897
  skills: normalizeSkillList(input.skills),
894
898
  violations: 0,
@@ -938,7 +942,7 @@ export class Room extends EventEmitter {
938
942
  const name = participant.name;
939
943
  const log = this.log.child(name);
940
944
  const cwd = ensureDir(this.dir);
941
- const spec = recipe.build({ model: launch.model });
945
+ const spec = recipe.build({ model: launch.model, mode: launch.mode });
942
946
  const transcript = new Transcript(join(this.dataDir, "transcripts"), name);
943
947
  log.info(`spawning ${spec.command} ${spec.args.join(" ")} (cwd ${cwd}); transcript ${transcript.path}`);
944
948
  const stderrTail = [];
@@ -990,7 +994,7 @@ export class Room extends EventEmitter {
990
994
  }
991
995
  }
992
996
  if (!session)
993
- session = await this.openSession(agent, cwd, log, mcpServers);
997
+ session = await this.openSession(agent, cwd, log, mcpServers, recipe);
994
998
  participant.sessionId = session.sessionId;
995
999
  participant.sessionOrigin = origin;
996
1000
  const storedSeen = this.restoredSeen.get(id);
@@ -1044,10 +1048,15 @@ export class Room extends EventEmitter {
1044
1048
  const warnings = await this.applyConfig(runtime, participant, {
1045
1049
  model: recipe.modelAtLaunch ? null : launch.model,
1046
1050
  effort: launch.effort,
1047
- mode: launch.mode,
1051
+ mode: recipe.modeAtLaunch ? null : launch.mode,
1048
1052
  });
1049
1053
  if (recipe.modelAtLaunch && launch.model)
1050
1054
  participant.model = launch.model;
1055
+ if (recipe.modeAtLaunch) {
1056
+ if (!participant.modes?.length)
1057
+ participant.modes = recipe.modePresets.map((id) => ({ id, name: id }));
1058
+ participant.mode = launch.mode ?? recipe.defaultMode ?? participant.mode;
1059
+ }
1051
1060
  for (const w of warnings)
1052
1061
  this.notice(`${name}: ${w}`, "warn");
1053
1062
  participant.status = "idle";
@@ -1148,6 +1157,16 @@ export class Room extends EventEmitter {
1148
1157
  const participant = this.participants.get(id);
1149
1158
  if (!runtime || !participant)
1150
1159
  throw new Error("no such agent (offline?)");
1160
+ const recipe = getRecipe(participant.agentType ?? "");
1161
+ if (recipe?.modeAtLaunch && configId === "mode") {
1162
+ if (!recipe.modePresets.includes(String(value)))
1163
+ throw new Error(`${participant.name}: no mode "${value}" (${recipe.modePresets.join(", ")})`);
1164
+ participant.launch = { model: participant.launch?.model ?? null, effort: participant.launch?.effort ?? null, mode: String(value) };
1165
+ participant.mode = String(value);
1166
+ this.push({ type: "participant", participant });
1167
+ await this.respawnAgent(id, { memory: true, reason: "its mode changed" });
1168
+ return;
1169
+ }
1151
1170
  const hasOption = participant.configOptions?.some((o) => o.id === configId);
1152
1171
  if (!hasOption && configId === "mode" && participant.modes?.some((m) => m.id === value)) {
1153
1172
  await runtime.agent.setMode(runtime.sessionId, String(value));
@@ -1205,6 +1224,11 @@ export class Room extends EventEmitter {
1205
1224
  const skill = this.skills?.library.get(message.skill.name);
1206
1225
  if (!message.to.length)
1207
1226
  targets = targets.filter((id) => this.hasSkill(this.participants.get(id), message.skill.name));
1227
+ if (!targets.length && !message.to.length) {
1228
+ const alone = [...this.runtimes.keys()].filter(live);
1229
+ if (alone.length === 1)
1230
+ targets = alone;
1231
+ }
1208
1232
  if (!targets.length)
1209
1233
  this.notice(`Nobody in this room has the skill "${message.skill.name}"; attach it to an agent first, or address one with @.`, "warn");
1210
1234
  if (skill) {
@@ -1241,13 +1265,19 @@ export class Room extends EventEmitter {
1241
1265
  this.requestTurn(id, message.to.includes(id) || !!message.skill);
1242
1266
  }
1243
1267
  hasSkill(participant, name) {
1268
+ if (this.isBuiltinSkill(name))
1269
+ return true;
1244
1270
  if (!participant?.skills)
1245
1271
  return false;
1246
1272
  const lower = name.toLowerCase();
1247
1273
  return participant.skills.some((s) => s.toLowerCase() === lower);
1248
1274
  }
1275
+ isBuiltinSkill(name) {
1276
+ const skill = this.skills?.library.get(name);
1277
+ return !!skill && skill.author === BUILTIN_AUTHOR && !skill.draft && !skill.problems.length;
1278
+ }
1249
1279
  attachedSkills(participant) {
1250
- if (!this.skills || !participant.skills?.length)
1280
+ if (!this.skills)
1251
1281
  return [];
1252
1282
  return this.skills.library.list().filter((s) => !s.problems.length && !s.draft && this.hasSkill(participant, s.name));
1253
1283
  }
@@ -1338,6 +1368,7 @@ export class Room extends EventEmitter {
1338
1368
  humanName: this.settings.humanName,
1339
1369
  roomName: this.settings.name,
1340
1370
  base: kind === "room" ? { ...this.settings, customRules: this.renderRuleReferences(this.settings.customRules) } : undefined,
1371
+ briefTextLimit: this.settings.briefTextLimit,
1341
1372
  knownSkills: this.skills ? this.skills.library.list().map((s) => s.name) : undefined,
1342
1373
  };
1343
1374
  }
@@ -1443,6 +1474,116 @@ export class Room extends EventEmitter {
1443
1474
  warnings: proposal.warnings,
1444
1475
  };
1445
1476
  }
1477
+ async describeLooksForAgent(participantId) {
1478
+ const participant = this.agentInRoom(participantId);
1479
+ if (!this.skills?.looks || !this.skills.appearance)
1480
+ throw new Error("looks are not available in this hub");
1481
+ const described = await this.skills.looks.describe();
1482
+ return { you: participant.name, human: this.settings.humanName, appearance: this.skills.appearance.current(), ...described };
1483
+ }
1484
+ async lintLookForAgent(participantId, raw) {
1485
+ this.agentInRoom(participantId);
1486
+ if (!this.skills?.looks)
1487
+ throw new Error("looks are not available in this hub");
1488
+ try {
1489
+ const checked = await this.skills.looks.check(raw);
1490
+ return { ok: checked.lint.ok, id: checked.spec.id, errors: checked.lint.errors.map((e) => ({ key: e.key, message: e.message })), warnings: checked.lint.warnings.map((w) => ({ key: w.key, message: w.message })), report: checked.lint.report };
1491
+ }
1492
+ catch (error) {
1493
+ return { ok: false, errors: [{ key: "spec", message: error instanceof Error ? error.message : String(error) }], warnings: [], report: [] };
1494
+ }
1495
+ }
1496
+ async createLookForAgent(participantId, raw, replace) {
1497
+ const participant = this.agentInRoom(participantId);
1498
+ if (!this.skills?.looks)
1499
+ throw new Error("looks are not available in this hub");
1500
+ let saved;
1501
+ try {
1502
+ saved = await this.skills.looks.save(raw, { author: participant.name, replace });
1503
+ }
1504
+ catch (error) {
1505
+ throw new Error(`not saved: ${error instanceof Error ? error.message : String(error)}`);
1506
+ }
1507
+ const warnings = saved.lint.warnings.map((w) => w.message);
1508
+ this.postSystem(`${participant.name} saved the look "${saved.spec.label}" (Settings → Appearance).`);
1509
+ this.log.info(`${participant.name} saved look ${saved.spec.id}`);
1510
+ return {
1511
+ ok: true,
1512
+ message: `Saved the look "${saved.spec.label}" (id ${saved.spec.id}) among ${this.settings.humanName}'s own looks: it is in Settings → Appearance now, after the looks viberoom ships. Nothing is worn until ${this.settings.humanName} picks it; propose_look_changes with look "${saved.spec.id}" offers it as a card.${warnings.length ? ` Warnings: ${warnings.join("; ")}` : ""}`,
1513
+ id: saved.spec.id,
1514
+ warnings,
1515
+ };
1516
+ }
1517
+ async proposeLookChanges(participantId, why, changes) {
1518
+ const participant = this.agentInRoom(participantId);
1519
+ if (!this.skills?.appearance || !this.skills.looks)
1520
+ throw new Error("looks are not available in this hub");
1521
+ const current = this.skills.appearance.current();
1522
+ const patch = {};
1523
+ const rows = [];
1524
+ if (changes.look !== undefined) {
1525
+ const lookId = String(changes.look).trim();
1526
+ const known = await this.skills.appearance.ownAdjustments(lookId);
1527
+ if (!known)
1528
+ throw new Error(`not proposed: no look "${lookId}" (the looks viberoom ships, or one of ${this.settings.humanName}'s own by its id)`);
1529
+ patch.look = lookId;
1530
+ if (lookId !== current.look)
1531
+ rows.push({ key: "look", from: current.look, to: lookId });
1532
+ }
1533
+ if (changes.adjust !== undefined) {
1534
+ if (!changes.adjust || typeof changes.adjust !== "object" || Array.isArray(changes.adjust))
1535
+ throw new Error("not proposed: adjust is an object of adjustable keys and values");
1536
+ const lookId = String(changes.look ?? current.look);
1537
+ const own = await this.skills.appearance.ownAdjustments(lookId);
1538
+ if (!own)
1539
+ throw new Error(`not proposed: no look "${lookId}" to fine-tune`);
1540
+ const merged = { ...(current.custom?.[lookId] ?? {}), ...changes.adjust };
1541
+ patch.custom = { [lookId]: merged };
1542
+ const previewed = this.skills.appearance.preview({ custom: { [lookId]: merged } });
1543
+ for (const key of Object.keys(changes.adjust)) {
1544
+ const from = current.custom?.[lookId]?.[key] ?? own.values[key] ?? "";
1545
+ const to = previewed.custom[lookId]?.[key] ?? "";
1546
+ if (String(from).toLowerCase() !== String(to).toLowerCase())
1547
+ rows.push({ key: `${key} (${own.label})`, from, to });
1548
+ }
1549
+ }
1550
+ for (const key of ["chatFontSize", "font", "mono"]) {
1551
+ if (changes[key] === undefined)
1552
+ continue;
1553
+ patch[key] = changes[key];
1554
+ const previewed = this.skills.appearance.preview({ [key]: changes[key] });
1555
+ if (String(previewed[key]) !== String(current[key]))
1556
+ rows.push({ key: key === "chatFontSize" ? "text size" : key === "font" ? "font" : "code font", from: current[key], to: previewed[key] });
1557
+ }
1558
+ this.skills.appearance.preview(patch);
1559
+ if (!rows.length)
1560
+ throw new Error("not proposed: the change leaves the window as it is");
1561
+ const proposal = {
1562
+ key: randomUUID(),
1563
+ participantId,
1564
+ participantName: participant.name,
1565
+ ts: Date.now(),
1566
+ why: String(why ?? "").trim().slice(0, 600),
1567
+ settings: [],
1568
+ vibemates: [],
1569
+ appearance: rows,
1570
+ warnings: [],
1571
+ touchesOwn: false,
1572
+ status: "pending",
1573
+ };
1574
+ this.proposalPlans.set(proposal.key, { settings: [], vibemates: [], ops: [], ids: {}, appearance: patch });
1575
+ this.proposals.set(proposal.key, proposal);
1576
+ this.push({ type: "proposal", proposal });
1577
+ const what = rows.map((c) => c.key).join(", ");
1578
+ this.postSystem(`${participant.name} proposes a change to how the window looks (${what}); apply or reject it on the card.`, "human", false, { tone: "attention" });
1579
+ this.log.info(`look proposal ${proposal.key} from ${participant.name}: ${what}`);
1580
+ return {
1581
+ ok: true,
1582
+ message: `Proposal sent to ${this.settings.humanName} as a card in the room (${what}). It changes the whole window, not this room alone; nothing changes until they apply it, and you will see a room line with the outcome.`,
1583
+ key: proposal.key,
1584
+ warnings: [],
1585
+ };
1586
+ }
1446
1587
  async resolveProposal(key, accept) {
1447
1588
  const proposal = this.proposals.get(key);
1448
1589
  const plan = this.proposalPlans.get(key);
@@ -1450,7 +1591,7 @@ export class Room extends EventEmitter {
1450
1591
  throw new Error("no such pending proposal");
1451
1592
  if (proposal.status !== "pending")
1452
1593
  return proposal;
1453
- const what = [...proposal.settings.map((c) => c.key), ...proposal.vibemates.map((o) => `${o.op} ${o.name}`)].join(", ");
1594
+ const what = [...proposal.settings.map((c) => c.key), ...proposal.vibemates.map((o) => `${o.op} ${o.name}`), ...(proposal.appearance ?? []).map((c) => c.key)].join(", ");
1454
1595
  if (!accept) {
1455
1596
  proposal.status = "rejected";
1456
1597
  this.proposalPlans.delete(key);
@@ -1505,6 +1646,16 @@ export class Room extends EventEmitter {
1505
1646
  patch[c.key] = c.key === "language" ? c.to : c.to;
1506
1647
  this.updateSettings(patch);
1507
1648
  }
1649
+ if (plan.appearance) {
1650
+ try {
1651
+ if (!this.skills?.appearance)
1652
+ throw new Error("looks are not available in this hub");
1653
+ this.skills.appearance.apply(plan.appearance);
1654
+ }
1655
+ catch (error) {
1656
+ skipped.push(`appearance (${error instanceof Error ? error.message : String(error)})`);
1657
+ }
1658
+ }
1508
1659
  proposal.status = "applied";
1509
1660
  proposal.skipped = skipped;
1510
1661
  this.proposalPlans.delete(key);
@@ -1697,10 +1848,9 @@ export class Room extends EventEmitter {
1697
1848
  if (!this.skills)
1698
1849
  return { ok: false, reason: "skills are not available in this hub" };
1699
1850
  const skill = this.skills.library.get(name);
1700
- const builtin = !!skill && skill.author === BUILTIN_AUTHOR && !skill.draft;
1701
1851
  const mine = this.attachedSkills(participant).filter((s) => s.agentInvocable).map((s) => s.name);
1702
1852
  const list = mine.length ? `your skills: ${mine.join(", ")}` : "you have no skills";
1703
- if (!builtin && (!this.hasSkill(participant, name) || !mine.some((s) => s.toLowerCase() === name.toLowerCase()))) {
1853
+ if (!this.hasSkill(participant, name) || !mine.some((s) => s.toLowerCase() === name.toLowerCase())) {
1704
1854
  return { ok: false, reason: `"${name}" is not one of your skills (${list})` };
1705
1855
  }
1706
1856
  if (!skill || skill.problems.length)
@@ -2447,7 +2597,7 @@ export class Room extends EventEmitter {
2447
2597
  this.notice(`${participant.name}: agent process exited.`, "error");
2448
2598
  }
2449
2599
  }
2450
- async openSession(agent, cwd, log, mcpServers = []) {
2600
+ async openSession(agent, cwd, log, mcpServers = [], recipe) {
2451
2601
  try {
2452
2602
  return await agent.newSession(cwd, mcpServers);
2453
2603
  }
@@ -2455,12 +2605,22 @@ export class Room extends EventEmitter {
2455
2605
  if (!isAuthRequired(error) || !agent.authMethods.length)
2456
2606
  throw error;
2457
2607
  log.info(`session/new needs authentication: ${describeError(error)}`);
2608
+ if (recipe?.loginState === "missing")
2609
+ throw new Error(`authentication required; log in with the agent's own CLI first${recipe.loginCommand ? ` (${recipe.loginCommand})` : ""}`);
2458
2610
  }
2459
2611
  const failures = [];
2460
2612
  for (const method of agent.authMethods) {
2613
+ if (method.type === "terminal") {
2614
+ failures.push(`${method.id}: needs a terminal`);
2615
+ continue;
2616
+ }
2461
2617
  log.info(`authenticate with "${method.id}"${method.name ? ` (${method.name})` : ""}`);
2462
2618
  try {
2463
- await agent.authenticate(method.id);
2619
+ const done = await Promise.race([agent.authenticate(method.id).then(() => true), delay(AUTH_WAIT_MS).then(() => false)]);
2620
+ if (!done) {
2621
+ failures.push(`${method.id}: no answer in ${AUTH_WAIT_MS / 1000} s (a sign-in that needs you)`);
2622
+ continue;
2623
+ }
2464
2624
  return await agent.newSession(cwd, mcpServers);
2465
2625
  }
2466
2626
  catch (error) {
@@ -2537,7 +2697,7 @@ export class Room extends EventEmitter {
2537
2697
  participant.mode = pick("mode") ?? participant.mode;
2538
2698
  }
2539
2699
  failStart(participant, error, fresh, stderr = []) {
2540
- participant.statusDetail = error instanceof Error ? error.message : String(error);
2700
+ participant.statusDetail = describeError(error);
2541
2701
  const recipe = getRecipe(participant.agentType ?? "");
2542
2702
  participant.trouble = classifyStartFailure({
2543
2703
  error: participant.statusDetail,
@@ -2696,7 +2856,11 @@ function isAuthRequired(error) {
2696
2856
  return error instanceof Error && /auth/i.test(error.message);
2697
2857
  }
2698
2858
  function describeError(error) {
2699
- return error instanceof Error ? error.message : String(error);
2859
+ if (!(error instanceof Error))
2860
+ return String(error);
2861
+ const data = error.rpc?.data ?? error.data;
2862
+ const detail = typeof data === "string" ? data : data && typeof data === "object" && typeof data.details === "string" ? data.details : "";
2863
+ return detail && !error.message.includes(detail) ? `${error.message}: ${detail}` : error.message;
2700
2864
  }
2701
2865
  function looksSilent(text) {
2702
2866
  const t = text.trim().toLowerCase();