viberoom 0.8.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/NOTICE CHANGED
@@ -27,9 +27,11 @@ This product bundles the following third-party material:
27
27
  - Cascadia Code: Copyright 2019 Microsoft Corporation.
28
28
 
29
29
  - Vendor icons (assets/vendors/): the logos of Claude, Codex, Gemini, Cursor,
30
- OpenCode and GitHub Copilot, as published in the Agent Client Protocol
31
- registry (https://agentclientprotocol.com). Each mark belongs to its owner and
32
- is used only to identify the agent it stands for.
30
+ OpenCode, GitHub Copilot and Grok, as published in the Agent Client Protocol
31
+ registry (https://agentclientprotocol.com), and the Nous Research mark for
32
+ Hermes Agent, traced in one colour from the icon published on nousresearch.com.
33
+ Each mark belongs to its owner and is used only to identify the agent it
34
+ stands for.
33
35
 
34
36
  - ACP adapters (vendor/acp/, copied from the npm packages at build time by
35
37
  scripts/vendor-acp.mjs, each with its licence file):
package/README.md CHANGED
@@ -14,16 +14,86 @@ Open a room, summon the agents you already have, give each one a role, and let t
14
14
  </p>
15
15
 
16
16
  <p align="center">
17
- <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A room with two vibemates: one explains a git command from a screenshot, the other adds the part that changes whose problem it is">
17
+ <a href="https://todor-rusev.github.io/viberoom/demo/vibeclassic.html"><img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/readme/looks.gif" width="960" alt="The recorded session, drawn in each of the six looks in turn; click to open the interactive demo"></a>
18
18
  </p>
19
19
 
20
20
  <p align="center">
21
- <b>Claude</b> &nbsp;·&nbsp; <b>Codex</b> &nbsp;·&nbsp; <b>Gemini</b> &nbsp;·&nbsp; <b>Cursor</b> &nbsp;·&nbsp; <b>OpenCode</b> &nbsp;·&nbsp; <b>GitHub Copilot</b><br>
21
+ <a href="https://todor-rusev.github.io/viberoom/demo/vibeclassic.html"><img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/readme/try-the-demo.png" width="880" alt="Try the interactive demo: a recorded session in the real viberoom window, two rooms, six vibemates, six looks, nothing to install"></a>
22
+ </p>
23
+
24
+ <p align="center">
25
+ <b>Claude</b> &nbsp;·&nbsp; <b>Codex</b> &nbsp;·&nbsp; <b>Gemini</b> &nbsp;·&nbsp; <b>Cursor</b> &nbsp;·&nbsp; <b>OpenCode</b> &nbsp;·&nbsp; <b>GitHub Copilot</b> &nbsp;·&nbsp; <b>Grok Build</b> &nbsp;·&nbsp; <b>Hermes Agent</b><br>
22
26
  <sub>whichever of them you have installed, with your own logins, in one room</sub>
23
27
  </p>
24
28
 
25
29
  <br>
26
30
 
31
+ ## See it before you install it
32
+
33
+ Two rooms, six vibemates, one afternoon: a small CLI fixed and tested by Claude, Codex and OpenCode, and a
34
+ bakery's launch planned by Claude, Codex and Gemini, with the tool calls, the pictures, the file previews and
35
+ the Mermaid diagrams as they happened. The session is recorded as pages that run the real viberoom window
36
+ with no hub behind it. Every card below opens it in that look: walk between the rooms, open the participants,
37
+ change the look under Settings. (The pages live in [`docs/demo/`](docs/demo/) too, for a copy of your own.)
38
+
39
+ <a href="https://todor-rusev.github.io/viberoom/demo/vibeclassic.html"><img align="right" width="460" src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/readme/card-vibeclassic.jpg" alt="VibeClassic"></a>
40
+
41
+ **VibeClassic** &mdash; the default: lavender light, soft tiles, room to breathe.<br>
42
+ <a href="https://todor-rusev.github.io/viberoom/demo/vibeclassic.html">Open the demo in VibeClassic &rarr;</a>
43
+
44
+ <br clear="all">
45
+
46
+ <a href="https://todor-rusev.github.io/viberoom/demo/vibeclassic-dark.html"><img align="left" width="460" src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/readme/card-vibeclassic-dark.jpg" alt="VibeClassic Dark"></a>
47
+
48
+ **VibeClassic Dark** &mdash; the same room after dark: navy paper, the same tiles, nothing glares.<br>
49
+ <a href="https://todor-rusev.github.io/viberoom/demo/vibeclassic-dark.html">Open the demo in VibeClassic Dark &rarr;</a>
50
+
51
+ <br clear="all">
52
+
53
+ <a href="https://todor-rusev.github.io/viberoom/demo/clay.html"><img align="right" width="460" src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/readme/card-clay.jpg" alt="Clay"></a>
54
+
55
+ **Clay** &mdash; sand, terracotta and teal on warm paper; matte objects, soft corners.<br>
56
+ <a href="https://todor-rusev.github.io/viberoom/demo/clay.html">Open the demo in Clay &rarr;</a>
57
+
58
+ <br clear="all">
59
+
60
+ <a href="https://todor-rusev.github.io/viberoom/demo/eye-comfort.html"><img align="left" width="460" src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/readme/card-eye-comfort.jpg" alt="Eye Comfort"></a>
61
+
62
+ **Eye Comfort** &mdash; cream instead of white, dark grey instead of black, air between the lines: made for long evenings.<br>
63
+ <a href="https://todor-rusev.github.io/viberoom/demo/eye-comfort.html">Open the demo in Eye Comfort &rarr;</a>
64
+
65
+ <br clear="all">
66
+
67
+ <a href="https://todor-rusev.github.io/viberoom/demo/3d-clayful.html"><img align="right" width="460" src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/readme/card-3d-clayful.jpg" alt="3D clayful"></a>
68
+
69
+ **3D clayful** &mdash; cream, lavender, pink and mint with real volume: light from the top left, a wall under every object.<br>
70
+ <a href="https://todor-rusev.github.io/viberoom/demo/3d-clayful.html">Open the demo in 3D clayful &rarr;</a>
71
+
72
+ <br clear="all">
73
+
74
+ <a href="https://todor-rusev.github.io/viberoom/demo/terminal.html"><img align="left" width="460" src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/readme/card-terminal.jpg" alt="Terminal"></a>
75
+
76
+ **Terminal** &mdash; phosphor green on black, one monospace face, hairlines instead of shadows.<br>
77
+ <a href="https://todor-rusev.github.io/viberoom/demo/terminal.html">Open the demo in Terminal &rarr;</a>
78
+
79
+ <br clear="all">
80
+
81
+
82
+ ## Six looks, or your own
83
+
84
+ A look is how the window is drawn: colours, light, corners, fonts. Pick one under Settings, fine-tune it,
85
+ or ask a vibemate for "a warm paper look for long evenings": it reads every design token with what it
86
+ means, checks its draft (the hub measures whether the words read on their paper and reports the numbers,
87
+ since a vibemate cannot see colours), saves the look among your own with your name on it, and proposes
88
+ wearing it as a card you can try on first. Your looks are files: export one to share it, import one
89
+ someone sent you.
90
+
91
+ <p align="center">
92
+ <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/looks-picker.png" width="820" alt="Settings: the look picker with the six shipped looks">
93
+ </p>
94
+
95
+ <br>
96
+
27
97
  ## The idea
28
98
 
29
99
  One agent is a conversation. Three agents in one room are a **team**: one decides what "done" means,
@@ -44,7 +114,7 @@ effort and how much it may do on its own. Three vibemates from the same agent wi
44
114
  vibios behave like three different people, which is exactly the point.
45
115
 
46
116
  <p align="center">
47
- <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/summon.png" width="960" alt="Summon a vibemate: agent, model, effort, mode, name, face and a private brief">
117
+ <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/summon.png" width="960" alt="Summon a vibemate: eight agents to pick from, then a name, a face and a private brief">
48
118
  </p>
49
119
 
50
120
  <br>
@@ -59,8 +129,7 @@ vibemate gets the rules as instructions, so the room runs the way you wrote it d
59
129
  <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/rules.png" width="820" alt="Room rules with live @references to the vibemates">
60
130
  </p>
61
131
 
62
- Here is what a five-line ruleset buys you: a handoff chain that runs on its own, with the human at
63
- both ends.
132
+ Five lines of rules buy you a handoff chain that runs on its own, with the human at both ends.
64
133
 
65
134
  <p align="center">
66
135
  <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/art/handoff.svg" width="960" alt="One task, four hands: you to Pip to Nova to Rex, review loop, report back">
@@ -82,22 +151,10 @@ library marks it as theirs until you have read it.
82
151
 
83
152
  ## Let them design the room
84
153
 
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
- ## Let them design the look
92
-
93
- A look (how the window is drawn: colours, light, corners, fonts) is data, not code: which of the shipped
94
- looks it starts from, a few hues, and what it wants otherwise. Ask a vibemate for "a warm paper look for
95
- long evenings" and it has a built-in skill on what makes a look good and four tools: read every token
96
- with what it means, check a draft (the hub measures whether the words read on their paper and reports
97
- the numbers, since a vibemate cannot see colours), save the look among your own (Settings → Appearance,
98
- after the shipped ones, with your name on it), or propose wearing it as a card you can try on first. Your
99
- own looks are files under your data folder: export one to share it, import one someone sent you, delete
100
- one you are done with. A shipped look can only be fine-tuned, never rewritten.
154
+ A vibemate can design a room as well as work in it: read the room's settings and rules, check a design
155
+ and preview the brief the others would receive, save a template for you to pick under New room, or propose
156
+ a change to the room you are in. A proposal is a card in the chat with the diff; nothing changes until you
157
+ click Apply, and the room is told what you decided.
101
158
 
102
159
  <br>
103
160
 
@@ -109,8 +166,8 @@ one you are done with. A shipped look can only be fine-tuned, never rewritten.
109
166
 
110
167
  Write to the room and every vibemate answers, each after a short pause so replies do not trip over
111
168
  each other. `@Name` one of them and the rest read along. Vibemates talk to each other the same way:
112
- a reply wakes the others, `@Name` picks one, and a hop limit keeps an argument from running all night. **Hush** stops every running reply
113
- at once; the room stays quiet until you write again.
169
+ a reply wakes the others, `@Name` picks one, and a hop limit keeps an argument from running all night.
170
+ **Hush** stops every running reply at once; the room stays quiet until you write again.
114
171
 
115
172
  <br>
116
173
 
@@ -122,7 +179,7 @@ at that line. A diagram block becomes a diagram. Change one of your own messages
122
179
  the vibemates are told, or the conversation rewinds to that point.
123
180
 
124
181
  <p align="center">
125
- <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/home.png" width="960" alt="The home page: welcome back, your rooms, what you can do here">
182
+ <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A room with two vibemates: one explains a git command from a screenshot, the other adds the part that changes whose problem it is">
126
183
  </p>
127
184
 
128
185
  <br>
@@ -152,10 +209,11 @@ it in a browser tab. Later, `npm install -g viberoom@latest` and the next start
152
209
  sees your keys; every agent keeps its own login.
153
210
  - **Edit a message.** Fix what you said; the vibemates get the memo, or the conversation rewinds.
154
211
  - **Quote a message.** Select a fragment of a bubble, or copy it with Ctrl+C, and it goes into your next
155
- message as a quote with who said it and when, so the vibemates read it as that person's words, not yours.
212
+ message as a quote, with who said it and when.
156
213
  - **Your messages on a timeline.** A thin strip on the chat's right edge, one mark per message of yours:
157
214
  hover for the message with its neighbours, click to jump there.
158
- - **Pick a folder from a tree.** Browse the machine's folders when a room needs one; make a new one on the spot.
215
+ - **Nothing is cut in silence.** A persona or a rule longer than the room's limit is refused with the
216
+ numbers, and the window offers to cut it, raise the limit, or go back.
159
217
  - **Settings save themselves.** Change a setting and it is saved: on Enter, on leaving the field, on a pick.
160
218
  - **Search the room.** Everything anyone said, one search box.
161
219
  - **For geeks.** Every panel folds its technical settings behind a toggle. You never have to open it.
@@ -165,11 +223,12 @@ it in a browser tab. Later, `npm install -g viberoom@latest` and the next start
165
223
  ## What you need
166
224
 
167
225
  - Node.js 22 or newer.
168
- - At least one coding agent installed and logged in: Claude Code, Codex, Gemini CLI, Cursor, OpenCode
169
- or GitHub Copilot. viberoom finds the ones you have and offers only those. It installs none of them.
226
+ - At least one coding agent installed and logged in: Claude Code, Codex, Gemini CLI, Cursor, OpenCode,
227
+ GitHub Copilot, Grok Build or Hermes Agent. viberoom finds the ones you have and offers only those. It
228
+ installs none of them.
170
229
  - A browser. Chrome, Edge or Brave for the app window; anything modern for a tab.
171
230
 
172
- Something does not start? `viberoom doctor` checks these four things and says which one is missing.
231
+ Something does not start? `viberoom doctor` checks these things and says which one is missing.
173
232
 
174
233
  <br>
175
234
 
@@ -186,7 +245,7 @@ viberoom logs the background hub's log
186
245
 
187
246
  `Enter` sends, `Shift+Enter` is a new line, `@Name` addresses one vibemate, `/name` invokes a skill.
188
247
  Your data lives in `~/.viberoom` (or `$VIBEROOM_DATA_DIR`): settings, rooms with their history,
189
- skills and the log.
248
+ skills, looks and the log.
190
249
 
191
250
  <br>
192
251
 
@@ -195,6 +254,7 @@ skills and the log.
195
254
  - A question ("how do I ...?") goes to [Discussions → Q&A](https://github.com/todor-rusev/viberoom/discussions/categories/q-a).
196
255
  - An idea goes to [Discussions → Ideas](https://github.com/todor-rusev/viberoom/discussions/categories/ideas).
197
256
  - A bug goes to [Issues](https://github.com/todor-rusev/viberoom/issues/new/choose); the form asks for what a fix needs.
257
+ - A change you would like to make: read [CONTRIBUTING.md](CONTRIBUTING.md) first. Pull requests are credited, not merged as they are.
198
258
 
199
259
  <br>
200
260
 
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
2
+ <path fill="currentColor" d="M0.58392448,14.9254204 L0.8326,14.66295 C2.004465,13.42985 3.1678,12.2082 2.45814,10.48105 C1.50813,8.1701 2.061355,5.4619 3.820575,3.70057 C5.6495,1.8709 8.3431,1.40957 10.59295,2.336505 C11.0907,2.52161 11.5245,2.785025 11.86295,3.02993 L9.98425,3.89849 C8.235,3.163775 6.23115,3.66355 5.0081,4.88809 C3.354105,6.5426 3.019895,9.4117 4.95835,11.2656 L-0.335,15.99995 C-0.066496,15.62975 0.2538896,15.275934 0.58392448,14.9254204 Z M14.0391,2.288155 L16.33165,2.57749377e-12 L16.20795,0.172288 C14.4658,2.574355 13.6153,3.749045 14.29795,6.6879 C14.76445,8.68415 14.261,10.90255 12.63545,12.53005 C10.5861,14.58325 7.3066,15.0403 4.6059,13.19215 L6.48885,12.3193 C8.2125,12.99705 10.0983,12.69945 11.4536,11.34255 C12.80895,9.9856 13.1133,8.00925 12.4321,6.3647 C12.30265,6.05285 11.9144,5.97455 11.64275,6.1753 L6.102,10.27035 L14.0391,2.288155 Z"/>
3
+ </svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="-1 0 63 63"><path fill="currentColor" d="M25 0h10v1h-10zM28 1h10v1h-10zM19 2h4v1h-4zM30 2h10v1h-10zM16 3h10v1h-10zM32 3h10v1h-10zM14 4h14v1h-14zM33 4h10v1h-10zM12 5h17v1h-17zM34 5h10v1h-10zM11 6h19v1h-19zM35 6h10v1h-10zM9 7h22v1h-22zM35 7h11v1h-11zM8 8h21v1h-21zM30 8h2v1h-2zM36 8h10v1h-10zM7 9h20v1h-20zM31 9h1v1h-1zM37 9h7v1h-7zM6 10h19v1h-19zM26 10h1v1h-1zM30 10h3v1h-3zM37 10h6v1h-6zM47 10h1v1h-1zM5 11h7v1h-7zM13 11h11v1h-11zM30 11h2v1h-2zM38 11h1v1h-1zM41 11h1v1h-1zM47 11h2v1h-2zM8 12h4v1h-4zM13 12h4v1h-4zM21 12h1v1h-1zM27 12h6v1h-6zM38 12h1v1h-1zM41 12h1v1h-1zM43 12h2v1h-2zM46 12h3v1h-3zM9 13h2v1h-2zM27 13h7v1h-7zM38 13h11v1h-11zM12 14h1v1h-1zM15 14h8v1h-8zM27 14h7v1h-7zM39 14h11v1h-11zM3 15h4v1h-4zM9 15h26v1h-26zM39 15h11v1h-11zM3 16h32v1h-32zM36 16h15v1h-15zM3 17h32v1h-32zM36 17h15v1h-15zM2 18h49v1h-49zM2 19h50v1h-50zM2 20h17v1h-17zM20 20h32v1h-32zM2 21h17v1h-17zM20 21h32v1h-32zM2 22h18v1h-18zM21 22h31v1h-31zM2 23h11v1h-11zM14 23h6v1h-6zM21 23h32v1h-32zM2 24h11v1h-11zM17 24h2v1h-2zM29 24h24v1h-24zM3 25h8v1h-8zM19 25h3v1h-3zM30 25h23v1h-23zM3 26h7v1h-7zM18 26h6v1h-6zM30 26h23v1h-23zM4 27h6v1h-6zM11 27h1v1h-1zM20 27h3v1h-3zM24 27h1v1h-1zM30 27h7v1h-7zM41 27h12v1h-12zM5 28h2v1h-2zM8 28h5v1h-5zM20 28h2v1h-2zM24 28h1v1h-1zM30 28h24v1h-24zM9 29h4v1h-4zM21 29h1v1h-1zM30 29h24v1h-24zM9 30h2v1h-2zM12 30h1v1h-1zM30 30h24v1h-24zM9 31h3v1h-3zM30 31h24v1h-24zM9 32h2v1h-2zM30 32h25v1h-25zM9 33h2v1h-2zM30 33h25v1h-25zM9 34h1v1h-1zM29 34h26v1h-26zM8 35h2v1h-2zM29 35h26v1h-26zM8 36h2v1h-2zM29 36h27v1h-27zM8 37h2v1h-2zM29 37h27v1h-27zM8 38h3v1h-3zM12 38h4v1h-4zM29 38h27v1h-27zM8 39h3v1h-3zM13 39h1v1h-1zM16 39h1v1h-1zM29 39h27v1h-27zM8 40h4v1h-4zM28 40h6v1h-6zM35 40h22v1h-22zM8 41h4v1h-4zM28 41h6v1h-6zM35 41h18v1h-18zM54 41h3v1h-3zM8 42h5v1h-5zM28 42h6v1h-6zM35 42h18v1h-18zM54 42h3v1h-3zM7 43h6v1h-6zM28 43h5v1h-5zM35 43h18v1h-18zM55 43h2v1h-2zM0 44h1v1h-1zM7 44h7v1h-7zM28 44h5v1h-5zM34 44h19v1h-19zM55 44h3v1h-3zM0 45h1v1h-1zM7 45h7v1h-7zM28 45h25v1h-25zM55 45h3v1h-3zM0 46h1v1h-1zM7 46h9v1h-9zM19 46h4v1h-4zM28 46h6v1h-6zM36 46h17v1h-17zM55 46h3v1h-3zM0 47h2v1h-2zM6 47h18v1h-18zM28 47h5v1h-5zM36 47h17v1h-17zM55 47h3v1h-3zM60 47h1v1h-1zM0 48h3v1h-3zM6 48h19v1h-19zM28 48h4v1h-4zM36 48h17v1h-17zM54 48h4v1h-4zM60 48h1v1h-1zM0 49h9v1h-9zM10 49h16v1h-16zM28 49h25v1h-25zM54 49h4v1h-4zM60 49h1v1h-1zM1 50h7v1h-7zM9 50h43v1h-43zM53 50h5v1h-5zM60 50h1v1h-1zM0 51h1v1h-1zM2 51h5v1h-5zM9 51h18v1h-18zM29 51h29v1h-29zM59 51h2v1h-2zM0 52h2v1h-2zM8 52h18v1h-18zM30 52h27v1h-27zM59 52h2v1h-2zM1 53h3v1h-3zM6 53h19v1h-19zM27 53h1v1h-1zM31 53h26v1h-26zM59 53h2v1h-2zM2 54h9v1h-9zM12 54h11v1h-11zM27 54h1v1h-1zM32 54h25v1h-25zM58 54h2v1h-2zM4 55h6v1h-6zM12 55h12v1h-12zM25 55h2v1h-2zM28 55h1v1h-1zM33 55h23v1h-23zM57 55h3v1h-3zM6 56h1v1h-1zM11 56h13v1h-13zM26 56h1v1h-1zM28 56h1v1h-1zM32 56h4v1h-4zM38 56h3v1h-3zM46 56h1v1h-1zM49 56h6v1h-6zM56 56h3v1h-3zM6 57h2v1h-2zM9 57h16v1h-16zM27 57h1v1h-1zM30 57h3v1h-3zM36 57h3v1h-3zM47 57h2v1h-2zM50 57h8v1h-8zM7 58h6v1h-6zM14 58h11v1h-11zM28 58h3v1h-3zM35 58h3v1h-3zM49 58h1v1h-1zM51 58h6v1h-6zM8 59h4v1h-4zM14 59h15v1h-15zM34 59h3v1h-3zM50 59h1v1h-1zM52 59h3v1h-3zM13 60h15v1h-15zM34 60h2v1h-2zM50 60h4v1h-4zM12 61h11v1h-11zM24 61h1v1h-1zM33 61h2v1h-2zM51 61h2v1h-2zM12 62h10v1h-10zM23 62h2v1h-2zM30 62h1v1h-1zM33 62h1v1h-1zM52 62h1v1h-1z"/></svg>
@@ -10,7 +10,7 @@ export function loginState(probe, evidence) {
10
10
  return "unknown";
11
11
  return probe.files.length ? "missing" : "unknown";
12
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;
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|no (llm )?provider (configured|available)|401|403|oauth|token (expired|invalid))\b/i;
14
14
  const MISSING_WORDS = /\b(enoent|not found|no such file|is not recognized|command not found|spawn\w* (failed|error))\b/i;
15
15
  const TIMEOUT_WORDS = /\b(timed out|timeout|took too long|did not answer|no response)\b/i;
16
16
  export function classifyStartFailure(input) {
package/dist/hub.js CHANGED
@@ -431,7 +431,7 @@ export class Hub extends EventEmitter {
431
431
  });
432
432
  if (!choice.agentType) {
433
433
  try {
434
- room.addUnstaffed({ name: choice.name || tv.name, tagline: tv.tagline, role: tv.role, avatar: tv.avatar, skills });
434
+ room.addUnstaffed({ name: choice.name || tv.name, tagline: tv.tagline, role: tv.role, avatar: tv.avatar, skills, textCheck: "notice" });
435
435
  }
436
436
  catch (error) {
437
437
  notices.push(`${choice.name || tv.name} could not be added: ${error instanceof Error ? error.message : String(error)}`);
@@ -450,12 +450,15 @@ export class Hub extends EventEmitter {
450
450
  model: choice.model ?? tv.model,
451
451
  effort: choice.effort ?? tv.effort,
452
452
  mode: choice.mode ?? tv.mode,
453
+ textCheck: "notice",
453
454
  });
454
455
  }
455
456
  catch (error) {
456
457
  notices.push(`${choice.name || tv.name} could not be summoned: ${error instanceof Error ? error.message : String(error)}`);
457
458
  }
458
459
  }
460
+ if (room.settings.customRules.length > room.settings.briefTextLimit)
461
+ notices.push(`The room rules from the template are ${room.settings.customRules.length} characters, over this room's limit of ${room.settings.briefTextLimit}; kept as they are, but the next edit has to fit.`);
459
462
  this.saveRooms();
460
463
  return { room, notices };
461
464
  }
package/dist/jsonrpc.js CHANGED
@@ -11,7 +11,9 @@ export class RemoteError extends Error {
11
11
  rpc;
12
12
  method;
13
13
  constructor(rpc, method) {
14
- super(`${method} failed: ${rpc.message} (code ${rpc.code})`);
14
+ const data = rpc.data;
15
+ const detail = typeof data === "string" ? data : data && typeof data === "object" && typeof data.details === "string" ? data.details : "";
16
+ super(`${method} failed: ${rpc.message}${detail ? `: ${detail}` : ""} (code ${rpc.code})`);
15
17
  this.rpc = rpc;
16
18
  this.method = method;
17
19
  }
package/dist/main.js CHANGED
@@ -19,6 +19,7 @@ function parseArgs(argv) {
19
19
  port: 4810,
20
20
  portGiven: false,
21
21
  dataDir: process.env.VIBEROOM_DATA_DIR ? resolve(process.env.VIBEROOM_DATA_DIR) : resolve(homedir(), ".viberoom"),
22
+ dataDirGiven: !!process.env.VIBEROOM_DATA_DIR,
22
23
  name: undefined,
23
24
  open: true,
24
25
  browser: false,
@@ -43,6 +44,7 @@ function parseArgs(argv) {
43
44
  break;
44
45
  case "--data-dir":
45
46
  options.dataDir = resolve(next());
47
+ options.dataDirGiven = true;
46
48
  break;
47
49
  case "--open":
48
50
  options.open = true;
@@ -271,7 +273,8 @@ async function runHub(options, log, info) {
271
273
  }
272
274
  }
273
275
  }
274
- migrateLegacyData(options.dataDir, log);
276
+ if (!options.dataDirGiven)
277
+ migrateLegacyData(options.dataDir, log);
275
278
  const hub = new Hub(options.dataDir, log, options.name);
276
279
  if (options.name && hub.settings.humanName !== options.name)
277
280
  hub.updateSettings({ humanName: options.name });
package/dist/persona.js CHANGED
@@ -29,7 +29,8 @@ export const ROOM_SETTINGS_SPEC = {
29
29
  replayAfterRestart: { kind: "integer", min: 0, max: 200, default: 10, brief: false, agent: true, doc: "Messages replayed to a vibemate whose session restarts." },
30
30
  backlogCap: { kind: "integer", min: 1, max: 1000, default: 50, brief: false, agent: true, doc: "Most missed messages a vibemate reads on its next turn; older ones are dropped with a note." },
31
31
  showVendorInRoster: { kind: "boolean", default: false, brief: true, agent: true, doc: "The roster in the brief names each vibemate's vendor (Claude, Codex, ...)." },
32
- customRules: { kind: "text", max: 4000, default: "", brief: true, agent: true, doc: "The room rules, one per line; every vibemate gets them under 'Room rules (set by the human)'. @Name inside a rule is a live reference." },
32
+ briefTextLimit: { kind: "integer", min: 500, max: 32_000, default: 8000, brief: false, agent: true, doc: "Most characters a vibio (a vibemate's role) or the room rules may have; both go into every brief. Text over it is refused with the numbers, never cut." },
33
+ customRules: { kind: "text", max: 32_000, default: "", brief: true, agent: true, doc: "The room rules, one per line, at most briefTextLimit characters; every vibemate gets them under 'Room rules (set by the human)'. @Name inside a rule is a live reference." },
33
34
  refereeAction: { kind: "enum", values: ["next-header", "retry-hidden"], default: "next-header", brief: false, agent: true, doc: "On a mechanical violation (wrong language, too long): remind in the next header, or hold the reply and ask for a corrected one in a hidden turn." },
34
35
  turnTaking: { kind: "enum", values: ["parallel", "one-at-a-time"], default: "parallel", brief: false, agent: true, doc: "parallel: every addressed vibemate answers at once; one-at-a-time: one speaks, the others queue and see the earlier replies first." },
35
36
  waitWhileHumanTypes: { kind: "boolean", default: true, brief: false, agent: true, doc: "A vibemate about to start a turn waits while the human is typing." },
@@ -77,8 +78,12 @@ export function coerceSetting(key, raw) {
77
78
  throw new Error(`${key} must be ${spec.values.join(" or ")}`);
78
79
  return value;
79
80
  }
80
- case "text":
81
- return String(raw).slice(0, spec.max);
81
+ case "text": {
82
+ const value = String(raw);
83
+ if (value.length > spec.max)
84
+ throw new Error(`${key} is ${value.length} characters; at most ${spec.max}`);
85
+ return value;
86
+ }
82
87
  case "language": {
83
88
  if (raw && typeof raw === "object") {
84
89
  const o = raw;
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));
@@ -1349,6 +1368,7 @@ export class Room extends EventEmitter {
1349
1368
  humanName: this.settings.humanName,
1350
1369
  roomName: this.settings.name,
1351
1370
  base: kind === "room" ? { ...this.settings, customRules: this.renderRuleReferences(this.settings.customRules) } : undefined,
1371
+ briefTextLimit: this.settings.briefTextLimit,
1352
1372
  knownSkills: this.skills ? this.skills.library.list().map((s) => s.name) : undefined,
1353
1373
  };
1354
1374
  }
@@ -2577,7 +2597,7 @@ export class Room extends EventEmitter {
2577
2597
  this.notice(`${participant.name}: agent process exited.`, "error");
2578
2598
  }
2579
2599
  }
2580
- async openSession(agent, cwd, log, mcpServers = []) {
2600
+ async openSession(agent, cwd, log, mcpServers = [], recipe) {
2581
2601
  try {
2582
2602
  return await agent.newSession(cwd, mcpServers);
2583
2603
  }
@@ -2585,12 +2605,22 @@ export class Room extends EventEmitter {
2585
2605
  if (!isAuthRequired(error) || !agent.authMethods.length)
2586
2606
  throw error;
2587
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})` : ""}`);
2588
2610
  }
2589
2611
  const failures = [];
2590
2612
  for (const method of agent.authMethods) {
2613
+ if (method.type === "terminal") {
2614
+ failures.push(`${method.id}: needs a terminal`);
2615
+ continue;
2616
+ }
2591
2617
  log.info(`authenticate with "${method.id}"${method.name ? ` (${method.name})` : ""}`);
2592
2618
  try {
2593
- 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
+ }
2594
2624
  return await agent.newSession(cwd, mcpServers);
2595
2625
  }
2596
2626
  catch (error) {
@@ -2667,7 +2697,7 @@ export class Room extends EventEmitter {
2667
2697
  participant.mode = pick("mode") ?? participant.mode;
2668
2698
  }
2669
2699
  failStart(participant, error, fresh, stderr = []) {
2670
- participant.statusDetail = error instanceof Error ? error.message : String(error);
2700
+ participant.statusDetail = describeError(error);
2671
2701
  const recipe = getRecipe(participant.agentType ?? "");
2672
2702
  participant.trouble = classifyStartFailure({
2673
2703
  error: participant.statusDetail,
@@ -2826,7 +2856,11 @@ function isAuthRequired(error) {
2826
2856
  return error instanceof Error && /auth/i.test(error.message);
2827
2857
  }
2828
2858
  function describeError(error) {
2829
- 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;
2830
2864
  }
2831
2865
  function looksSilent(text) {
2832
2866
  const t = text.trim().toLowerCase();
package/dist/server.js CHANGED
@@ -34,6 +34,8 @@ const STATIC_FILES = {
34
34
  "/vendor-icons/cursor.svg": { file: "vendors/cursor.svg", type: "image/svg+xml", dir: "assets" },
35
35
  "/vendor-icons/opencode.svg": { file: "vendors/opencode.svg", type: "image/svg+xml", dir: "assets" },
36
36
  "/vendor-icons/copilot.svg": { file: "vendors/copilot.svg", type: "image/svg+xml", dir: "assets" },
37
+ "/vendor-icons/grok.svg": { file: "vendors/grok.svg", type: "image/svg+xml", dir: "assets" },
38
+ "/vendor-icons/hermes.svg": { file: "vendors/hermes.svg", type: "image/svg+xml", dir: "assets" },
37
39
  "/vendor/mermaid.min.js": { file: "mermaid/dist/mermaid.min.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
38
40
  "/vendor/marked.umd.js": { file: "marked/lib/marked.umd.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
39
41
  "/vendor/prism.js": { file: "prismjs/prism.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
@@ -146,7 +148,7 @@ export function startServer(hub, port, log, info, onShutdownRequest, onRestartRe
146
148
  if (req.method === "GET" && STATIC_FILES[path]) {
147
149
  const entry = STATIC_FILES[path];
148
150
  const body = await readFile(staticPath(entry));
149
- res.writeHead(200, { "Content-Type": entry.type, "Cache-Control": entry.dir === "ui" || !entry.dir ? "no-cache" : "public, max-age=3600" });
151
+ res.writeHead(200, { "Content-Type": entry.type, "Cache-Control": entry.dir === "node_modules" ? "public, max-age=3600" : "no-cache" });
150
152
  res.end(body);
151
153
  return;
152
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
5
5
  "type": "module",
6
6
  "engines": {
package/ui/app.css CHANGED
@@ -712,6 +712,9 @@ table.csv tbody tr:nth-child(even) td { background: var(--table-stripe); }
712
712
  .section textarea { resize: vertical; min-height: 76px; height: auto; padding: 10px 14px; line-height: 1.5; }
713
713
  .section input::placeholder, .section textarea::placeholder { color: var(--placeholder); font-weight: 600; }
714
714
  .section .hint { margin: -4px 0 10px; }
715
+ .field .count { font-variant-numeric: tabular-nums; color: var(--muted); }
716
+ .field .count::before { content: " · "; }
717
+ .field .count.over { color: var(--danger); font-weight: 800; }
715
718
  .three-inline { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; }
716
719
  .three-inline label { margin-bottom: 4px; }
717
720
  .rc-num { width: 76px; display: inline-flex; }
package/ui/app.js CHANGED
@@ -685,28 +685,34 @@
685
685
  }
686
686
  if (!found.size) return;
687
687
  const paths = await Promise.all([...found.keys()].map((token) => resolveInRoom(room.id, token)));
688
- let linked = false;
688
+ const resolved = new Map();
689
689
  [...found.keys()].forEach((token, i) => {
690
690
  const full = paths[i] && paths[i].path;
691
- if (!full) return;
692
- const byNode = new Map();
693
- for (const hit of found.get(token)) byNode.set(hit.node, [...(byNode.get(hit.node) || []), hit]);
694
- for (const [node, hits] of byNode) {
695
- if (!node.isConnected) continue;
696
- for (const hit of hits.sort((a, b) => b.index - a.index)) {
697
- const after = node.splitText(hit.index);
698
- after.nodeValue = after.nodeValue.slice(hit.length);
699
- const link = document.createElement("a");
700
- link.className = "open-link";
701
- link.href = "#";
702
- link.dataset.open = `${full}${hit.line}`;
703
- link.title = `${full} (relative to the room's folder)`;
704
- link.textContent = `${token}${hit.line}`;
705
- node.parentNode.insertBefore(link, after);
706
- linked = true;
707
- }
708
- }
691
+ if (full) resolved.set(token, full);
709
692
  });
693
+ if (!resolved.size) return;
694
+ const byNode = new Map();
695
+ for (const [token, hits] of found) {
696
+ if (!resolved.has(token)) continue;
697
+ for (const hit of hits) byNode.set(hit.node, [...(byNode.get(hit.node) || []), { ...hit, token }]);
698
+ }
699
+ let linked = false;
700
+ for (const [node, hits] of byNode) {
701
+ if (!node.isConnected) continue;
702
+ for (const hit of hits.sort((a, b) => b.index - a.index)) {
703
+ const full = resolved.get(hit.token);
704
+ const after = node.splitText(hit.index);
705
+ after.nodeValue = after.nodeValue.slice(hit.length);
706
+ const link = document.createElement("a");
707
+ link.className = "open-link";
708
+ link.href = "#";
709
+ link.dataset.open = `${full}${hit.line}`;
710
+ link.title = `${full} (relative to the room's folder)`;
711
+ link.textContent = `${hit.token}${hit.line}`;
712
+ node.parentNode.insertBefore(link, after);
713
+ linked = true;
714
+ }
715
+ }
710
716
  if (linked) renderPreviews(textEl, m);
711
717
  }
712
718
  function csvTable(rows) {
@@ -1106,8 +1112,8 @@
1106
1112
  const field = lastField;
1107
1113
  lastField = null;
1108
1114
  try {
1109
- await onSave();
1110
- if (field) {
1115
+ const outcome = await onSave();
1116
+ if (field && outcome !== false) {
1111
1117
  recentlySaved.set(field, Date.now());
1112
1118
  markSaved(field);
1113
1119
  }
@@ -1309,24 +1315,60 @@
1309
1315
  }, 190);
1310
1316
  }
1311
1317
  function confirmDialog(text, opts) {
1318
+ return choiceDialog(text, opts).then((choice) => choice === "ok");
1319
+ }
1320
+ function choiceDialog(text, opts) {
1312
1321
  const o = opts || {};
1313
1322
  const dialog = $("#confirm-dialog");
1314
1323
  $("#cf-title").textContent = o.title || "Are you sure?";
1315
1324
  $("#cf-text").textContent = text;
1316
- const ok = $("#cf-ok");
1317
- ok.textContent = o.okLabel || "OK";
1318
- ok.className = `btn ${o.danger ? "danger solid" : "primary"}`;
1325
+ const buttons = { ok: $("#cf-ok"), alt: $("#cf-alt"), cancel: $("#cf-cancel") };
1326
+ buttons.ok.textContent = o.okLabel || "OK";
1327
+ buttons.cancel.textContent = o.cancelLabel || "Cancel";
1328
+ buttons.alt.textContent = o.altLabel || "";
1329
+ buttons.alt.hidden = !o.altLabel;
1330
+ dialog.classList.toggle("three-way", !!o.altLabel);
1331
+ const primary = o.primary || "ok";
1332
+ for (const [name, button] of Object.entries(buttons)) button.dataset.kind = name === primary ? (o.danger ? "danger" : "primary") : "ghost";
1319
1333
  return new Promise((resolve) => {
1320
1334
  const done = () => {
1321
1335
  dialog.removeEventListener("close", done);
1322
- resolve(dialog.returnValue === "ok");
1336
+ resolve(dialog.returnValue === "ok" ? "ok" : dialog.returnValue === "alt" ? "alt" : "cancel");
1323
1337
  };
1324
1338
  dialog.addEventListener("close", done);
1325
1339
  dialog.returnValue = "";
1326
1340
  openDialog(dialog);
1327
- ok.focus();
1341
+ buttons[primary].focus();
1328
1342
  });
1329
1343
  }
1344
+
1345
+ const BRIEF_TEXT_MAX = 32000;
1346
+ function briefTextLimit(room) {
1347
+ const r = room || currentRoom();
1348
+ return (r && r.settings && Number(r.settings.briefTextLimit)) || 8000;
1349
+ }
1350
+ function bindCount(el, countEl, limitOf) {
1351
+ if (!el || !countEl) return;
1352
+ const tick = () => {
1353
+ const n = (el.isContentEditable ? rulesText(el) : el.value).length;
1354
+ const limit = limitOf();
1355
+ countEl.textContent = `${n} / ${limit}`;
1356
+ countEl.classList.toggle("over", n > limit);
1357
+ };
1358
+ el.addEventListener("input", tick);
1359
+ tick();
1360
+ }
1361
+ async function fitBriefText(text, what, limit, limitInput) {
1362
+ if (text.length <= limit) return text;
1363
+ const needed = Math.min(BRIEF_TEXT_MAX, Math.ceil(text.length / 500) * 500);
1364
+ const canRaise = text.length <= BRIEF_TEXT_MAX;
1365
+ const choice = await choiceDialog(`${what} is ${text.length} characters; this room's limit is ${limit}. Cut it at the limit, or raise the limit for this room?${canRaise ? "" : ` ${BRIEF_TEXT_MAX} is the most a room can allow.`}`, { title: "Over the room's limit", okLabel: `Cut at ${limit}`, altLabel: canRaise ? `Raise the limit to ${needed}` : "", cancelLabel: "Go back", primary: "cancel" });
1366
+ if (choice === "ok") return text.slice(0, limit);
1367
+ if (choice !== "alt") return null;
1368
+ if (limitInput) limitInput.value = String(needed);
1369
+ else await post(roomApi("/settings"), { briefTextLimit: needed });
1370
+ return text;
1371
+ }
1330
1372
  function remember(key, value) {
1331
1373
  try {
1332
1374
  localStorage.setItem(`viberoom.${key}`, String(value));
@@ -2733,7 +2775,7 @@
2733
2775
  ${field("Vibename", `<input type="text" id="pp-name" maxlength="24" value="${esc(p.name)}">`)}
2734
2776
  ${field("Vibersona", `<input type="text" id="pp-tagline" maxlength="80" value="${esc(p.tagline || "")}" placeholder="a few words under the vibename">`, "Shown under the vibename.", "Everyone in the room sees it: you, and the other vibemates in their roster.")}
2735
2777
  ${field("Vibeface", `<div id="pp-avatar-picker"></div><input type="text" id="pp-avatar" maxlength="8" value="${esc(p.avatar || "")}" placeholder="custom emoji (optional)">`)}
2736
- ${field("Vibio", `<textarea id="pp-role" rows="5" maxlength="4000" placeholder="who it is, how it speaks, what it cares about">${esc(p.role || "")}</textarea>`, "Only this vibemate reads it.", "Reaches the vibemate as refreshed instructions in its brief on its next turn; its memory is kept. The other participants never see it.")}
2778
+ ${field("Vibio", `<textarea id="pp-role" rows="5" placeholder="who it is, how it speaks, what it cares about">${esc(p.role || "")}</textarea>`, `Only this vibemate reads it.<span class="count" id="pp-role-count"></span>`, "Reaches the vibemate as refreshed instructions in its brief on its next turn; its memory is kept. The other participants never see it. A vibio and the room rules go into every brief, so the room has a limit for them (the room's settings, for geeks); text over it is never cut in silence.")}
2737
2779
  </div>
2738
2780
  ${p.trouble ? `<div class="trouble"><b>${esc(p.trouble.what)}</b><span>${esc(p.trouble.advice)}</span></div>` : ""}
2739
2781
  ${p.statusDetail && (p.status === "offline" || p.status === "error" || p.failedTurns) ? `<p class="hint" style="color:var(--danger);margin:0 4px 10px">${esc(p.statusDetail)}</p>` : ""}
@@ -2806,7 +2848,13 @@
2806
2848
  renderSkillChecks($("#pp-skills"), p.skills || []);
2807
2849
  bindSave($("#pp-skills-section"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { skills: checkedSkills($("#pp-skills")) }));
2808
2850
  bindSave($("#pp-timing"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { replyDelay: $("#pp-delay").value === "" ? null : Number($("#pp-delay").value) }));
2809
- bindSave($("#pp-persona"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { name: $("#pp-name").value, tagline: $("#pp-tagline").value, role: $("#pp-role").value, avatar: $("#pp-avatar").value }));
2851
+ bindCount($("#pp-role"), $("#pp-role-count"), () => briefTextLimit(currentRoom()));
2852
+ bindSave($("#pp-persona"), async () => {
2853
+ const role = await fitBriefText($("#pp-role").value, `${p.name}'s vibio`, briefTextLimit(currentRoom()));
2854
+ if (role === null) return false;
2855
+ await post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { name: $("#pp-name").value, tagline: $("#pp-tagline").value, role, avatar: $("#pp-avatar").value });
2856
+ return true;
2857
+ });
2810
2858
  renderConfig($("#pp-config"), p, offline);
2811
2859
  }
2812
2860
 
@@ -2907,7 +2955,7 @@
2907
2955
  ${field("Emoji", `<div id="rp-emoji-picker"></div><input type="text" id="rp-emoji" maxlength="8" value="${esc(rs.emoji || "")}" placeholder="custom emoji (optional)">`, "A face for the room, next to its name.")}
2908
2956
  ${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
2909
2957
  ${field("Folder", `<span class="dir-row"><input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false">${UI.html("button", { label: "Browse", icon: "folder", kind: "ghost", id: "rp-dir-browse", title: "Choose a folder", hook: "browse-btn" })}</span>`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
2910
- <div class="field mention-host"><span class="label">Room rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><div id="rp-rules" class="rules-editor" contenteditable="true" spellcheck="true" data-placeholder="e.g. Everyone listens to @Pesho, he is the manager. Keep answers under 3 sentences."></div><span class="hint">One rule per line; type @ to reference a participant.</span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></div>
2958
+ <div class="field mention-host"><span class="label">Room rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><div id="rp-rules" class="rules-editor" contenteditable="true" spellcheck="true" data-placeholder="e.g. Everyone listens to @Pesho, he is the manager. Keep answers under 3 sentences."></div><span class="hint">One rule per line; type @ to reference a participant.<span class="count" id="rp-rules-count"></span></span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></div>
2911
2959
  ${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
2912
2960
  </div>
2913
2961
  <div class="section">
@@ -2950,6 +2998,7 @@
2950
2998
  <label class="switch"><span class="label">Show vendor and model to other vibemates</span><input type="checkbox" id="rp-vendor" ${rs.showVendorInRoster ? "checked" : ""}></label>
2951
2999
  ${field("Replay last N chat messages after a reconnect", `${UI.html("number-field", { id: "rp-replay", value: String(rs.replayAfterRestart), min: 0, max: 200 })}`)}
2952
3000
  ${field("Missed messages a vibemate reads at most on its next turn", `${UI.html("number-field", { id: "rp-backlog", value: String(rs.backlogCap), min: 1, max: 1000 })}`, "Everything posted since its last turn counts, including while it was muted; older messages are dropped with a note in its prompt.")}
3001
+ ${field("Most characters in a vibio or in the room rules", `${UI.html("number-field", { id: "rp-text-limit", value: String(rs.briefTextLimit ?? 8000), min: 500, max: 32000, step: 500 })}`, "Both go into every brief. Text over the limit is refused with the numbers, never cut.")}
2953
3002
  </div>`,
2954
3003
  "tools, hops, referee, briefs",
2955
3004
  )}
@@ -2963,6 +3012,8 @@
2963
3012
  wireDetailsClose();
2964
3013
  rulesToNodes($("#rp-rules"), room.customRulesText != null ? room.customRulesText : rs.customRules || "", room);
2965
3014
  attachRichMentions($("#rp-rules"), $("#rp-rules-menu"));
3015
+ bindCount($("#rp-rules"), $("#rp-rules-count"), () => Number($("#rp-text-limit").value) || briefTextLimit(room));
3016
+ $("#rp-text-limit").addEventListener("input", () => $("#rp-rules").dispatchEvent(new Event("input")));
2966
3017
  $("#rp-emoji-picker").appendChild(
2967
3018
  emojiGrid(ROOM_EMOJI, rs.emoji || "", (emoji) => {
2968
3019
  $("#rp-emoji").value = emoji;
@@ -2979,10 +3030,13 @@
2979
3030
  if (name.trim() !== room.name) await post(roomApi("/rename"), { name });
2980
3031
  const dir = $("#rp-dir").value.trim();
2981
3032
  if (dir && dir !== room.dir) await post(roomApi("/dir"), { dir });
3033
+ const rules = await fitBriefText(rulesText($("#rp-rules")), "The room rules text", Number($("#rp-text-limit").value) || briefTextLimit(room), $("#rp-text-limit"));
3034
+ if (rules === null) return false;
2982
3035
  await post(roomApi("/settings"), {
2983
3036
  emoji: $("#rp-emoji").value,
2984
3037
  topic: $("#rp-topic").value,
2985
- customRules: rulesText($("#rp-rules")).slice(0, 4000),
3038
+ customRules: rules,
3039
+ briefTextLimit: Number($("#rp-text-limit").value),
2986
3040
  language: $("#rp-lang").value.trim() || "follow-human",
2987
3041
  tools: $("#rp-tools").value,
2988
3042
  maxSentences: $("#rp-maxlen").value === "" ? null : Number($("#rp-maxlen").value),
@@ -3131,6 +3185,7 @@
3131
3185
  ${field("Hop limit", `${UI.html("number-field", { id: "sp-hops", value: String(d.hopLimit), min: 0, max: 10000 })}`, "How many vibemate-to-vibemate replies may follow one message of yours before the room waits for you again.")}
3132
3186
  ${field("Full brief every N turns", `${UI.html("number-field", { id: "sp-brief-turns", value: String(d.fullBriefEveryTurns), min: 1, max: 10000 })}`, "How often a vibemate gets the whole room brief again instead of the short header.")}
3133
3187
  ${field("Full brief every N tokens", `${UI.html("number-field", { id: "sp-brief-tokens", value: String(d.fullBriefEveryTokens), min: 1000, max: 10000000, step: 1000 })}`, "…or after this much new context since its last full brief, whichever comes first.")}
3188
+ ${field("Most characters in a vibio or in the room rules", `${UI.html("number-field", { id: "sp-text-limit", value: String(d.briefTextLimit ?? 8000), min: 500, max: 32000, step: 500 })}`, "Both go into every brief. Over the limit, the text is refused with the numbers, never cut; each room can raise or lower its own.")}
3134
3189
  <label class="switch"><span class="label">Repeat core rules in every header<span class="hint">The short header before each turn repeats the room's core rules (who is here, how to address, how long to write).</span></span><input type="checkbox" id="sp-header-rules" ${d.headerRules ? "checked" : ""}></label>
3135
3190
  ${field("Tools", `<select id="sp-tools"><option value="on-request"${d.tools === "on-request" ? " selected" : ""}>Only when asked</option><option value="never"${d.tools === "never" ? " selected" : ""}>Never</option></select>`, "Whether vibemates may use their own tools (files, shell, web) without being asked to.")}
3136
3191
  </div>
@@ -3386,6 +3441,7 @@
3386
3441
  hopLimit: Number($("#sp-hops").value),
3387
3442
  fullBriefEveryTurns: Number($("#sp-brief-turns").value),
3388
3443
  fullBriefEveryTokens: Number($("#sp-brief-tokens").value),
3444
+ briefTextLimit: Number($("#sp-text-limit").value),
3389
3445
  headerRules: $("#sp-header-rules").checked,
3390
3446
  tools: $("#sp-tools").value,
3391
3447
  },
@@ -3722,7 +3778,7 @@
3722
3778
  }
3723
3779
  els.invModelCustom.hidden = !info.modelAtLaunch;
3724
3780
  const who = info.agentInfo && info.agentInfo.name ? `${info.agentInfo.name} ${info.agentInfo.version || ""}`.trim() : recipe.vendor;
3725
- els.invStatus.textContent = parts.length ? `Options from ${who} (${parts.join(", ")}; ${(info.durationMs / 1000).toFixed(1)} s)` : `${who} exposes no config options over ACP${info.modelAtLaunch ? "; the model is a launch flag (built-in list, or type one)" : ""}.`;
3781
+ els.invStatus.textContent = parts.length ? `Options from ${who} (${parts.join(", ")}; ${(info.durationMs / 1000).toFixed(1)} s)` : `${who} exposes no config options over ACP${info.modelAtLaunch ? "; the model is a launch flag (built-in list, or type one)" : ""}${info.modeAtLaunch ? "; the mode is a launch flag (a change restarts the session)" : ""}.`;
3726
3782
  } catch (error) {
3727
3783
  if (requestId !== optionsRequest) return;
3728
3784
  els.invStatus.textContent = `Could not read the vibemate's options (${error.message}); showing the built-in list.`;
@@ -4093,6 +4149,7 @@
4093
4149
  ${stpField("…or every N tokens", `${UI.html("number-field", { id: "stp-brief-tokens", value: String(st.fullBriefEveryTokens ?? 20000), min: 1000, max: 10000000, step: 1000 })}`)}
4094
4150
  ${stpField("Replay after a reconnect", `${UI.html("number-field", { id: "stp-replay", value: String(st.replayAfterRestart ?? 10), min: 0, max: 200 })}`)}
4095
4151
  ${stpField("Missed messages read at most", `${UI.html("number-field", { id: "stp-backlog", value: String(st.backlogCap ?? 50), min: 1, max: 1000 })}`)}
4152
+ ${stpField("Most characters in a vibio or the rules", `${UI.html("number-field", { id: "stp-text-limit", value: String(st.briefTextLimit ?? 8000), min: 500, max: 32000, step: 500 })}`)}
4096
4153
  ${stpSwitch("Core rules in every header", "stp-header-rules", st.headerRules !== false)}
4097
4154
  ${stpSwitch("Show vendor and model to other vibemates", "stp-vendor", !!st.showVendorInRoster)}
4098
4155
  </div>`;
@@ -4114,7 +4171,7 @@
4114
4171
  </div></div>`).join("");
4115
4172
  return `
4116
4173
  <div class="stp-section"><h5>Room</h5>${room}</div>
4117
- <div class="stp-section"><h5>Room rules</h5><textarea id="stp-rules" rows="5" maxlength="4000" placeholder="one rule per line">${esc(st.customRules || "")}</textarea></div>
4174
+ <div class="stp-section"><h5>Room rules</h5><textarea id="stp-rules" rows="5" placeholder="one rule per line">${esc(st.customRules || "")}</textarea></div>
4118
4175
  <div class="stp-section"><h5>Folder</h5><span class="dir-row"><input type="text" id="stp-dir" maxlength="1000" value="${esc(t.dir || "")}" spellcheck="false">${UI.html("button", { label: "Browse", icon: "folder", kind: "ghost", hook: "browse-btn", data: { stpBrowse: true } })}</span></div>
4119
4176
  <div class="stp-section"><h5>Vibemates · <span id="stp-vm-count">${(t.vibemates || []).length}</span></h5>${vms || '<span class="hint">none</span>'}</div>`;
4120
4177
  }
@@ -4139,7 +4196,8 @@
4139
4196
  backlogCap: num("#stp-backlog"),
4140
4197
  headerRules: $("#stp-header-rules").checked,
4141
4198
  showVendorInRoster: $("#stp-vendor").checked,
4142
- customRules: $("#stp-rules").value.slice(0, 4000),
4199
+ customRules: $("#stp-rules").value,
4200
+ briefTextLimit: num("#stp-text-limit"),
4143
4201
  };
4144
4202
  const vibemates = [...stpEls.preview.querySelectorAll(".stp-vm")].map((row) => {
4145
4203
  const v = {};
package/ui/index.html CHANGED
@@ -147,7 +147,7 @@
147
147
  <form method="dialog">
148
148
  <h3 id="cf-title">Are you sure?</h3>
149
149
  <p class="lead" id="cf-text"></p>
150
- <div class="actions"><button data-ui="button" data-kind="ghost" value="cancel" type="submit">Cancel</button><button data-ui="button" data-kind="primary" id="cf-ok" value="ok" type="submit">OK</button></div>
150
+ <div class="actions"><button data-ui="button" data-kind="ghost" id="cf-cancel" value="cancel" type="submit">Cancel</button><button data-ui="button" data-kind="ghost" id="cf-alt" value="alt" type="submit" hidden>Alt</button><button data-ui="button" data-kind="primary" id="cf-ok" value="ok" type="submit">OK</button></div>
151
151
  </form>
152
152
  </dialog>
153
153
  <dialog id="room-dialog" class="dialog">
@@ -200,7 +200,7 @@
200
200
  <div class="choice-row"><span class="label">Mode</span><div class="chips" data-for="inv-mode"></div><select id="inv-mode" hidden></select></div>
201
201
  </div>
202
202
  <div class="two-col">
203
- <label>Vibename <span class="hint">(required)</span><input id="inv-name" type="text" maxlength="24" required autocomplete="off" placeholder="letters, digits, _ or -"></label>
203
+ <label>Vibename <span class="hint req">(required)</span><input id="inv-name" type="text" maxlength="24" required autocomplete="off" placeholder="letters, digits, _ or -"></label>
204
204
  <label>Vibersona <span class="hint">(optional; a few words under the vibename)</span><input id="inv-tagline" type="text" maxlength="80" placeholder="e.g. curious analyst"></label>
205
205
  </div>
206
206
  <label>Vibeface <span class="hint">(pick one, or type any emoji)</span>
package/ui/theme.css CHANGED
@@ -615,6 +615,7 @@ pre { background: var(--code-bg); color: var(--code-ink); padding: 10px 12px; bo
615
615
  .hint { color: var(--faint); font-size: var(--fs-xs); font-weight: 600; line-height: 1.45; }
616
616
  .hint.accent { color: var(--primary); }
617
617
  .hint.warn { color: var(--warm-ink); }
618
+ .hint.req { color: var(--rose-ink); font-weight: 700; }
618
619
  .hint.error, .error { color: var(--rose-ink); font-size: var(--fs-sm); font-weight: 700; }
619
620
  .eyebrow { font-size: calc(11px * var(--fs-scale)); text-transform: uppercase; letter-spacing: 0.1em; color: var(--faint); font-weight: 800; }
620
621
  .muted { color: var(--muted); }
@@ -820,6 +821,9 @@ code .open-link { color: inherit; }
820
821
  .dialog .actions .spacer { flex: 1; }
821
822
  .dialog.wide { width: 720px; }
822
823
  .dialog.narrow { width: 440px; }
824
+ .dialog.narrow.three-way { width: 580px; }
825
+ .dialog.narrow.three-way .actions { flex-wrap: nowrap; }
826
+ .dialog.narrow.three-way .actions [data-ui="button"] { white-space: nowrap; }
823
827
  .toasts { position: fixed; right: 18px; bottom: 20px; display: flex; flex-direction: column; gap: 8px; max-width: 400px; z-index: 50; pointer-events: none; }
824
828
  .toast { pointer-events: auto; background: var(--toast-bg); color: var(--toast-ink); padding: 10px 14px; border-radius: calc(12px * var(--r-scale)); font-size: var(--fs-sm); font-weight: 700; box-shadow: var(--shadow-pop); cursor: pointer; animation: slide-in-right var(--t-slow) var(--ease-pop); line-height: 1.4; display: flex; gap: 8px; align-items: flex-start; }
825
829
  .toast .i { width: 15px; height: 15px; margin-top: 1px; opacity: 0.85; }