@controlflow-ai/daemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +360 -0
  2. package/bin/console.js +2 -0
  3. package/bin/daemon.js +2 -0
  4. package/bin/pal.js +2 -0
  5. package/bin/server.js +2 -0
  6. package/package.json +31 -0
  7. package/src/agent-runtime.ts +285 -0
  8. package/src/app.ts +745 -0
  9. package/src/args.ts +54 -0
  10. package/src/artifacts.ts +85 -0
  11. package/src/cli.ts +284 -0
  12. package/src/client.ts +310 -0
  13. package/src/coco.ts +52 -0
  14. package/src/codex.ts +41 -0
  15. package/src/coding-agent-runtime.ts +20 -0
  16. package/src/config.ts +106 -0
  17. package/src/console.ts +349 -0
  18. package/src/daemon-client.ts +91 -0
  19. package/src/daemon.ts +580 -0
  20. package/src/db.ts +2830 -0
  21. package/src/failure-message.ts +17 -0
  22. package/src/format.ts +13 -0
  23. package/src/http.ts +55 -0
  24. package/src/lark/agent-runtime.ts +142 -0
  25. package/src/lark/cli.ts +549 -0
  26. package/src/lark/credentials.ts +105 -0
  27. package/src/lark/daemon-integration.ts +108 -0
  28. package/src/lark/dispatcher.ts +374 -0
  29. package/src/lark/event-router.ts +329 -0
  30. package/src/lark/inbound-events.ts +131 -0
  31. package/src/lark/server-integration.ts +445 -0
  32. package/src/lark/setup.ts +326 -0
  33. package/src/lark/ws-daemon.ts +224 -0
  34. package/src/lark-fixture-diagnostics.ts +56 -0
  35. package/src/lark-fixture.ts +277 -0
  36. package/src/local-api.ts +155 -0
  37. package/src/local-auth.ts +45 -0
  38. package/src/migrations/001_initial.ts +61 -0
  39. package/src/migrations/002_daemon_deliveries.ts +52 -0
  40. package/src/migrations/003_sessions_runs.ts +49 -0
  41. package/src/migrations/004_message_idempotency.ts +21 -0
  42. package/src/migrations/005_artifacts.ts +24 -0
  43. package/src/migrations/006_lark_channel_foundation.ts +119 -0
  44. package/src/migrations/007_agents_a0.ts +17 -0
  45. package/src/migrations/008_b0_chat_history.ts +31 -0
  46. package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
  47. package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
  48. package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
  49. package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
  50. package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
  51. package/src/migrations/014_agents_runtime.ts +10 -0
  52. package/src/migrations/015_agent_runtime_sessions.ts +15 -0
  53. package/src/migrations/016_room_participants.ts +27 -0
  54. package/src/migrations/017_unified_room_delivery.ts +203 -0
  55. package/src/migrations/018_room_display_names.ts +36 -0
  56. package/src/migrations/019_computer_connections.ts +63 -0
  57. package/src/migrations/020_computer_agent_assignments.ts +20 -0
  58. package/src/migrations/021_provider_identity_bindings.ts +32 -0
  59. package/src/migrations.ts +85 -0
  60. package/src/neeko.ts +23 -0
  61. package/src/provider-identity.ts +40 -0
  62. package/src/runtime-registry.ts +41 -0
  63. package/src/server-auth.ts +13 -0
  64. package/src/server.ts +63 -0
  65. package/src/token-file.ts +57 -0
  66. package/src/types.ts +408 -0
  67. package/src/web.ts +565 -0
package/src/web.ts ADDED
@@ -0,0 +1,565 @@
1
+ export function dashboardHtml(): string {
2
+ return `<!doctype html>
3
+ <html lang="zh-CN">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>Pal Rooms</title>
8
+ <style>
9
+ :root {
10
+ color-scheme: light;
11
+ --ink: #181714;
12
+ --muted: #6b675f;
13
+ --faint: #938d82;
14
+ --line: #d9d0c2;
15
+ --paper: #f5efe4;
16
+ --panel: #fffaf1;
17
+ --panel-2: #faf3e7;
18
+ --active: #1f6f5b;
19
+ --active-soft: #dceee6;
20
+ --blue: #235bd8;
21
+ --blue-soft: #dfe8ff;
22
+ --danger: #a33a2b;
23
+ --shadow: 0 18px 44px rgba(41, 35, 26, 0.11);
24
+ --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body {
28
+ margin: 0;
29
+ min-height: 100vh;
30
+ color: var(--ink);
31
+ background:
32
+ linear-gradient(rgba(24, 23, 20, 0.035) 1px, transparent 1px),
33
+ linear-gradient(90deg, rgba(24, 23, 20, 0.035) 1px, transparent 1px),
34
+ var(--paper);
35
+ background-size: 28px 28px;
36
+ font-family: "Avenir Next", "Gill Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
37
+ }
38
+ button, input, textarea, select { font: inherit; min-width: 0; }
39
+ button {
40
+ border: 1px solid var(--ink);
41
+ background: var(--ink);
42
+ color: #fffaf1;
43
+ min-height: 38px;
44
+ padding: 0 12px;
45
+ cursor: pointer;
46
+ font-weight: 800;
47
+ }
48
+ button.secondary { background: var(--panel); color: var(--ink); border-color: var(--line); }
49
+ button.icon { width: 38px; padding: 0; }
50
+ button:disabled { opacity: .55; cursor: not-allowed; }
51
+ input, textarea, select {
52
+ width: 100%;
53
+ border: 1px solid var(--line);
54
+ background: #fffdf8;
55
+ color: var(--ink);
56
+ padding: 10px 11px;
57
+ outline: none;
58
+ }
59
+ textarea { resize: vertical; min-height: 88px; line-height: 1.45; }
60
+ input:focus, textarea:focus, select:focus { border-color: var(--ink); box-shadow: 0 0 0 3px rgba(24, 23, 20, .08); }
61
+ .app {
62
+ display: grid;
63
+ grid-template-columns: 300px minmax(360px, 1fr) 330px;
64
+ gap: 14px;
65
+ width: min(1540px, calc(100vw - 28px));
66
+ min-height: calc(100vh - 28px);
67
+ margin: 14px auto;
68
+ }
69
+ .panel {
70
+ min-width: 0;
71
+ border: 1px solid var(--line);
72
+ background: rgba(255, 250, 241, .92);
73
+ box-shadow: var(--shadow);
74
+ }
75
+ .sidebar, .inspector { display: grid; grid-template-rows: auto auto 1fr; overflow: hidden; }
76
+ .brand, .panel-head, .composer, .toolbox { border-bottom: 1px solid var(--line); padding: 14px; }
77
+ .brand h1 { margin: 0; font-size: 30px; line-height: .92; letter-spacing: 0; }
78
+ .brand p, .hint, .meta, .empty, .error { color: var(--muted); font-size: 12px; line-height: 1.45; }
79
+ .create-room, .create-agent { display: grid; gap: 8px; padding: 14px; border-bottom: 1px solid var(--line); background: var(--panel-2); }
80
+ .form-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
81
+ .rooms, .agents, .members, .messages { overflow: auto; padding: 10px; }
82
+ .room, .agent, .member {
83
+ display: grid;
84
+ gap: 7px;
85
+ width: 100%;
86
+ margin-bottom: 8px;
87
+ padding: 11px;
88
+ border: 1px solid var(--line);
89
+ background: #fffdf8;
90
+ text-align: left;
91
+ }
92
+ .room { cursor: pointer; }
93
+ .room.active { border-color: var(--ink); background: #fff7d9; }
94
+ .room-title, .agent-title, .member-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; font-weight: 900; }
95
+ .room-title span, .agent-title span, .member-title span {
96
+ min-width: 0;
97
+ overflow: hidden;
98
+ text-overflow: ellipsis;
99
+ white-space: nowrap;
100
+ }
101
+ .pills { display: flex; flex-wrap: wrap; gap: 5px; }
102
+ .pill {
103
+ display: inline-flex;
104
+ align-items: center;
105
+ min-height: 22px;
106
+ padding: 2px 7px;
107
+ border: 1px solid var(--line);
108
+ background: var(--panel);
109
+ color: var(--muted);
110
+ font-size: 11px;
111
+ font-weight: 800;
112
+ max-width: 100%;
113
+ overflow: hidden;
114
+ text-overflow: ellipsis;
115
+ }
116
+ .pill.good { color: var(--active); border-color: #a7d7c4; background: var(--active-soft); }
117
+ .pill.blue { color: var(--blue); border-color: #b7c8f6; background: var(--blue-soft); }
118
+ .chat { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }
119
+ .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 74px; min-width: 0; background: var(--panel); }
120
+ .panel-head > div { min-width: 0; }
121
+ .panel-head h2 { margin: 0; font-size: 22px; letter-spacing: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
122
+ .panel-head .meta { font-family: var(--mono); overflow-wrap: anywhere; word-break: break-word; }
123
+ .messages { display: flex; flex-direction: column; gap: 10px; background: rgba(250, 243, 231, .7); }
124
+ .message {
125
+ max-width: min(760px, 92%);
126
+ border: 1px solid var(--line);
127
+ background: #fffdf8;
128
+ padding: 10px 12px;
129
+ box-shadow: 5px 5px 0 rgba(24, 23, 20, .045);
130
+ }
131
+ .message.mine { align-self: flex-end; border-color: #b7c8f6; background: #f4f7ff; }
132
+ .message.agent { border-color: #a7d7c4; background: #f3fbf7; }
133
+ .message-head { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 7px; }
134
+ .message-body { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.52; }
135
+ .composer { display: grid; gap: 8px; background: var(--panel); }
136
+ .composer.readonly { display: block; }
137
+ .composer-controls { display: grid; grid-template-columns: 150px auto; gap: 8px; align-items: end; }
138
+ .split { display: grid; grid-template-columns: minmax(0, 1fr) 120px; gap: 8px; }
139
+ .toolbox { display: grid; gap: 8px; background: var(--panel-2); }
140
+ .section-title { margin: 0; color: var(--muted); font-size: 12px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
141
+ .empty, .error, .readonly-note { border: 1px dashed var(--line); padding: 18px; text-align: center; background: rgba(255, 253, 248, .65); }
142
+ .error { display: none; color: var(--danger); border-color: rgba(163, 58, 43, .35); background: #fff1ee; }
143
+ .readonly-note { color: var(--muted); line-height: 1.5; }
144
+ .mention-wrap { position: relative; }
145
+ .mention-menu {
146
+ position: absolute;
147
+ left: 0;
148
+ right: 0;
149
+ bottom: calc(100% + 6px);
150
+ z-index: 20;
151
+ display: none;
152
+ max-height: 220px;
153
+ overflow: auto;
154
+ border: 1px solid var(--ink);
155
+ background: #fffdf8;
156
+ box-shadow: var(--shadow);
157
+ }
158
+ .mention-option {
159
+ display: grid;
160
+ grid-template-columns: minmax(0, 1fr) auto;
161
+ gap: 8px;
162
+ width: 100%;
163
+ min-height: 36px;
164
+ padding: 8px 10px;
165
+ border: 0;
166
+ border-bottom: 1px solid var(--line);
167
+ background: #fffdf8;
168
+ color: var(--ink);
169
+ text-align: left;
170
+ }
171
+ .mention-option:hover, .mention-option.active { background: #fff7d9; }
172
+ .mention-option span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
173
+ .toast {
174
+ position: fixed;
175
+ left: 50%;
176
+ bottom: 18px;
177
+ transform: translateX(-50%);
178
+ display: none;
179
+ max-width: min(560px, calc(100vw - 28px));
180
+ border: 1px solid var(--ink);
181
+ background: var(--ink);
182
+ color: #fffaf1;
183
+ padding: 11px 13px;
184
+ font-size: 13px;
185
+ box-shadow: var(--shadow);
186
+ }
187
+ @media (max-width: 1120px) {
188
+ .app { grid-template-columns: 280px minmax(0, 1fr); }
189
+ .inspector { grid-column: 1 / -1; grid-template-rows: auto auto; }
190
+ .inspector .agents { max-height: 320px; }
191
+ }
192
+ @media (max-width: 760px) {
193
+ .app { width: 100%; min-height: 100vh; margin: 0; grid-template-columns: 1fr; }
194
+ .sidebar, .inspector { max-height: none; }
195
+ .chat { min-height: 68vh; }
196
+ .composer-controls, .split, .form-row { grid-template-columns: 1fr; }
197
+ .message { max-width: 100%; }
198
+ }
199
+ </style>
200
+ </head>
201
+ <body>
202
+ <main class="app">
203
+ <aside class="panel sidebar">
204
+ <header class="brand">
205
+ <h1>Pal<br>Rooms</h1>
206
+ <p>Web chat for local rooms, agent invitations, and lightweight agent management.</p>
207
+ </header>
208
+ <form id="room-form" class="create-room">
209
+ <p class="section-title">Create Room</p>
210
+ <div class="form-row">
211
+ <input id="room-name" name="name" placeholder="general" autocomplete="off" required>
212
+ <button class="icon" title="Create room" aria-label="Create room">+</button>
213
+ </div>
214
+ <select id="room-kind" name="kind" aria-label="Room kind">
215
+ <option value="group">Group room</option>
216
+ <option value="dm">DM room</option>
217
+ </select>
218
+ </form>
219
+ <div id="rooms" class="rooms"></div>
220
+ </aside>
221
+
222
+ <section class="panel chat">
223
+ <header class="panel-head">
224
+ <div>
225
+ <h2 id="room-title">Select a room</h2>
226
+ <div id="room-meta" class="meta">No active room</div>
227
+ </div>
228
+ <button id="refresh" class="secondary icon" title="Refresh" aria-label="Refresh">↻</button>
229
+ </header>
230
+ <div id="messages" class="messages"></div>
231
+ <form id="message-form" class="composer">
232
+ <div class="mention-wrap">
233
+ <div id="mention-menu" class="mention-menu"></div>
234
+ <textarea id="message-content" placeholder="Type a message. Use @ to mention room members." required></textarea>
235
+ </div>
236
+ <div class="composer-controls">
237
+ <input id="message-sender" value="owner" autocomplete="off" aria-label="Sender">
238
+ <button id="send-message">Send</button>
239
+ </div>
240
+ </form>
241
+ </section>
242
+
243
+ <aside class="panel inspector">
244
+ <header class="panel-head">
245
+ <div>
246
+ <h2>Agents</h2>
247
+ <div class="meta">Use runtime=codex for executable demo agents.</div>
248
+ </div>
249
+ </header>
250
+ <div class="toolbox">
251
+ <form id="invite-form" class="split">
252
+ <select id="invite-agent" aria-label="Agent to invite"></select>
253
+ <select id="invite-mode" aria-label="Delivery mode">
254
+ <option value="mentions">Mentions</option>
255
+ <option value="all">All</option>
256
+ <option value="periodic">Periodic</option>
257
+ <option value="muted">Muted</option>
258
+ <option value="off">Off</option>
259
+ </select>
260
+ <button>Invite</button>
261
+ </form>
262
+ <form id="agent-form" class="create-agent">
263
+ <p class="section-title">Create or Update Agent</p>
264
+ <input id="agent-key" placeholder="codex" autocomplete="off" required>
265
+ <input id="agent-name" placeholder="Codex" autocomplete="off" required>
266
+ <input id="agent-runtime" placeholder="codex" autocomplete="off">
267
+ <input id="agent-computer" placeholder="machine id" autocomplete="off">
268
+ <button>Create agent</button>
269
+ </form>
270
+ <form id="computer-form" class="create-agent">
271
+ <p class="section-title">Provision Computer</p>
272
+ <input id="computer-name" placeholder="Bill's Team" autocomplete="off">
273
+ <button>Generate command</button>
274
+ <textarea id="computer-command" readonly rows="4" placeholder="Daemon command"></textarea>
275
+ </form>
276
+ <div id="members" class="members"></div>
277
+ </div>
278
+ <div id="agents" class="agents"></div>
279
+ </aside>
280
+ </main>
281
+ <div id="error" class="error" role="alert"></div>
282
+ <div id="toast" class="toast" role="status"></div>
283
+ <script>
284
+ const state = { rooms: [], agents: [], members: [], mentionables: [], messages: [], selectedRoomId: null, mentionIndex: 0 };
285
+ const root = (id) => document.getElementById(id);
286
+ const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' }[char]));
287
+ async function api(path, options) {
288
+ const response = await fetch(path, options);
289
+ const payload = await response.json().catch(() => ({}));
290
+ if (!response.ok || payload.ok === false) throw new Error(payload.message || payload.code || 'Request failed');
291
+ return payload.data || {};
292
+ }
293
+ function showToast(text) {
294
+ const el = root('toast');
295
+ el.textContent = text;
296
+ el.style.display = 'block';
297
+ clearTimeout(showToast.timer);
298
+ showToast.timer = setTimeout(() => { el.style.display = 'none'; }, 2600);
299
+ }
300
+ function showError(error) {
301
+ const el = root('error');
302
+ el.textContent = error instanceof Error ? error.message : String(error);
303
+ el.style.display = 'block';
304
+ clearTimeout(showError.timer);
305
+ showError.timer = setTimeout(() => { el.style.display = 'none'; }, 5000);
306
+ }
307
+ function pill(value, cls) {
308
+ return '<span class="pill ' + (cls || '') + '">' + escapeHtml(value) + '</span>';
309
+ }
310
+ function empty(text) {
311
+ return '<div class="empty">' + escapeHtml(text) + '</div>';
312
+ }
313
+ function activeRoom() {
314
+ return state.rooms.find((room) => room.id === state.selectedRoomId) || state.rooms[0] || null;
315
+ }
316
+ function roomLabel(room) {
317
+ return room ? (room.display_name || room.name) : '';
318
+ }
319
+ function canWriteRoom(room) {
320
+ return room && room.provider === 'web';
321
+ }
322
+ function mentionLabel(member) {
323
+ return member.display_name || member.participant_id;
324
+ }
325
+ function mentionToken(member) {
326
+ return member.kind === 'agent' ? member.participant_id : mentionLabel(member).replace(/\\s+/g, '');
327
+ }
328
+ function renderRooms() {
329
+ root('rooms').innerHTML = state.rooms.length ? state.rooms.map((room) => (
330
+ '<article class="room ' + (room.id === state.selectedRoomId ? 'active' : '') + '" onclick="selectRoom(\\'' + room.id + '\\')">' +
331
+ '<div class="room-title"><span title="' + escapeHtml(room.name) + '">#' + escapeHtml(roomLabel(room)) + '</span>' + pill(room.kind, room.kind === 'dm' ? 'blue' : 'good') + '</div>' +
332
+ '<div class="pills">' + pill(room.provider) + (canWriteRoom(room) ? pill('writable', 'good') : pill('read-only')) + pill((room.message_count || 0) + ' messages') + '</div>' +
333
+ '<div class="meta">' + escapeHtml(room.id) + '</div></article>'
334
+ )).join('') : empty('No rooms yet. Create one above.');
335
+ }
336
+ function renderMessages() {
337
+ const room = activeRoom();
338
+ root('room-title').textContent = room ? '#' + roomLabel(room) : 'Select a room';
339
+ root('room-title').title = room?.name || '';
340
+ root('room-meta').textContent = room ? room.id + ' · ' + room.kind + ' · ' + room.provider + (canWriteRoom(room) ? '' : ' · read-only from Web') : 'No active room';
341
+ root('message-form').style.display = room ? (canWriteRoom(room) ? 'grid' : 'block') : 'none';
342
+ root('message-form').className = canWriteRoom(room) ? 'composer' : 'composer readonly';
343
+ if (room && !canWriteRoom(room)) {
344
+ root('message-form').innerHTML = '<div class="readonly-note">This room is owned by ' + escapeHtml(room.provider) + '. Web human sending is disabled because Pal cannot send as your Feishu user account. Agent replies still go through the provider channel.</div>';
345
+ } else if (room && !root('message-content')) {
346
+ root('message-form').innerHTML = '<div class="mention-wrap"><div id="mention-menu" class="mention-menu"></div><textarea id="message-content" placeholder="Type a message. Use @ to mention room members." required></textarea></div><div class="composer-controls"><input id="message-sender" value="owner" autocomplete="off" aria-label="Sender"><button id="send-message">Send</button></div>';
347
+ bindComposer();
348
+ }
349
+ root('messages').innerHTML = state.messages.length ? state.messages.map((message) => {
350
+ const senderInput = root('message-sender');
351
+ const kind = senderInput && message.sender === senderInput.value.trim() ? ' mine' : (state.agents.some((agent) => agent.agent_key === message.sender) ? ' agent' : '');
352
+ const mentions = (message.mentions || []).map((mention) => pill('@' + mention, 'blue')).join('');
353
+ return '<article class="message' + kind + '"><div class="message-head">' + pill('@' + message.sender, kind.includes('agent') ? 'good' : '') +
354
+ (message.recipient ? pill('to @' + message.recipient) : '') + pill('#' + message.id) + mentions + '</div>' +
355
+ '<div class="message-body">' + escapeHtml(message.content) + '</div><div class="meta">' + escapeHtml(message.created_at) + '</div></article>';
356
+ }).join('') : empty(room ? 'No messages in this room yet.' : 'Create or select a room.');
357
+ root('messages').scrollTop = root('messages').scrollHeight;
358
+ }
359
+ function renderAgents() {
360
+ root('invite-agent').innerHTML = state.agents.length ? state.agents.map((agent) => '<option value="' + escapeHtml(agent.agent_key) + '">' + escapeHtml(agent.display_name) + ' · ' + escapeHtml(agent.agent_key) + '</option>').join('') : '<option value="">No agents</option>';
361
+ root('agents').innerHTML = state.agents.length ? state.agents.map((agent) => (
362
+ '<article class="agent"><div class="agent-title"><span>' + escapeHtml(agent.display_name) + '</span>' + pill(agent.runtime || 'no runtime', agent.runtime === 'codex' ? 'good' : '') + '</div>' +
363
+ '<div class="pills">' + pill(agent.agent_key) + pill(agent.id) + '</div>' +
364
+ '<div class="meta">' + escapeHtml(agent.description || 'No description') + '</div></article>'
365
+ )).join('') : empty('No agents yet. Create codex to start.');
366
+ }
367
+ function renderMembers() {
368
+ root('members').innerHTML = state.members.length ? state.members.map((member) => (
369
+ '<article class="member"><div class="member-title"><span>' + escapeHtml(member.display_name || member.participant_id) + '</span>' + pill(member.kind, member.kind === 'agent' ? 'good' : '') + '</div>' +
370
+ '<div class="pills">' + pill(member.source) + pill(member.status) + '</div></article>'
371
+ )).join('') : empty('No known members in this room.');
372
+ }
373
+ function currentMentionQuery() {
374
+ const input = root('message-content');
375
+ if (!input) return null;
376
+ const cursor = input.selectionStart ?? input.value.length;
377
+ const prefix = input.value.slice(0, cursor);
378
+ const match = prefix.match(/(^|\\s)@([\\w.\\-\\u4e00-\\u9fff]*)$/);
379
+ if (!match) return null;
380
+ return { query: match[2].toLowerCase(), start: cursor - match[2].length - 1, end: cursor };
381
+ }
382
+ function renderMentionMenu() {
383
+ const menu = root('mention-menu');
384
+ if (!menu) return;
385
+ const active = currentMentionQuery();
386
+ if (!active) {
387
+ menu.style.display = 'none';
388
+ menu.innerHTML = '';
389
+ return;
390
+ }
391
+ const options = state.mentionables.filter((member) => {
392
+ const haystack = (mentionLabel(member) + ' ' + member.participant_id).toLowerCase();
393
+ return haystack.includes(active.query);
394
+ }).slice(0, 8);
395
+ if (!options.length) {
396
+ menu.style.display = 'none';
397
+ menu.innerHTML = '';
398
+ return;
399
+ }
400
+ state.mentionIndex = Math.min(state.mentionIndex, options.length - 1);
401
+ menu.innerHTML = options.map((member, index) => '<button type="button" class="mention-option ' + (index === state.mentionIndex ? 'active' : '') + '" data-mention="' + escapeHtml(member.participant_id) + '"><span>@' + escapeHtml(mentionLabel(member)) + '</span>' + pill(member.kind, member.kind === 'agent' ? 'good' : '') + '</button>').join('');
402
+ menu.style.display = 'block';
403
+ [...menu.querySelectorAll('.mention-option')].forEach((button, index) => {
404
+ button.addEventListener('mousedown', (event) => {
405
+ event.preventDefault();
406
+ insertMention(options[index]);
407
+ });
408
+ });
409
+ }
410
+ function insertMention(member) {
411
+ const input = root('message-content');
412
+ const active = currentMentionQuery();
413
+ if (!input || !active) return;
414
+ const token = '@' + mentionToken(member) + ' ';
415
+ input.value = input.value.slice(0, active.start) + token + input.value.slice(active.end);
416
+ const cursor = active.start + token.length;
417
+ input.focus();
418
+ input.setSelectionRange(cursor, cursor);
419
+ renderMentionMenu();
420
+ }
421
+ function bindComposer() {
422
+ const input = root('message-content');
423
+ const form = root('message-form');
424
+ if (!input || input.dataset.bound === '1') return;
425
+ input.dataset.bound = '1';
426
+ input.addEventListener('input', () => { state.mentionIndex = 0; renderMentionMenu(); });
427
+ input.addEventListener('keydown', (event) => {
428
+ const menu = root('mention-menu');
429
+ if (!menu || menu.style.display !== 'block') return;
430
+ const count = menu.querySelectorAll('.mention-option').length;
431
+ if (event.key === 'ArrowDown') {
432
+ event.preventDefault();
433
+ state.mentionIndex = (state.mentionIndex + 1) % count;
434
+ renderMentionMenu();
435
+ } else if (event.key === 'ArrowUp') {
436
+ event.preventDefault();
437
+ state.mentionIndex = (state.mentionIndex + count - 1) % count;
438
+ renderMentionMenu();
439
+ } else if (event.key === 'Enter' && !event.shiftKey) {
440
+ const button = menu.querySelectorAll('.mention-option')[state.mentionIndex];
441
+ if (button) {
442
+ event.preventDefault();
443
+ button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
444
+ }
445
+ } else if (event.key === 'Escape') {
446
+ menu.style.display = 'none';
447
+ }
448
+ });
449
+ form.addEventListener('submit', sendMessage);
450
+ }
451
+ async function loadRooms() {
452
+ const data = await api('/api/rooms');
453
+ state.rooms = data.rooms || [];
454
+ if (!state.selectedRoomId || !state.rooms.some((room) => room.id === state.selectedRoomId)) {
455
+ state.selectedRoomId = state.rooms[0]?.id || null;
456
+ }
457
+ renderRooms();
458
+ }
459
+ async function loadAgents() {
460
+ const data = await api('/api/agents');
461
+ state.agents = data.agents || [];
462
+ renderAgents();
463
+ }
464
+ async function loadMessages() {
465
+ const room = activeRoom();
466
+ if (!room) {
467
+ state.messages = [];
468
+ state.members = [];
469
+ state.mentionables = [];
470
+ renderMessages();
471
+ renderMembers();
472
+ return;
473
+ }
474
+ const [messages, members, mentionables] = await Promise.all([
475
+ api('/api/messages?chat_id=' + encodeURIComponent(room.id) + '&limit=100'),
476
+ api('/api/rooms/' + encodeURIComponent(room.id) + '/members'),
477
+ api('/api/rooms/' + encodeURIComponent(room.id) + '/mentionables'),
478
+ ]);
479
+ state.messages = messages.messages || [];
480
+ state.members = members.participants || [];
481
+ state.mentionables = mentionables.participants || state.members;
482
+ renderMessages();
483
+ renderMembers();
484
+ }
485
+ async function refresh() {
486
+ await Promise.all([loadRooms(), loadAgents()]);
487
+ await loadMessages();
488
+ }
489
+ window.selectRoom = async (id) => {
490
+ state.selectedRoomId = id;
491
+ renderRooms();
492
+ await loadMessages().catch(showError);
493
+ };
494
+ root('refresh').addEventListener('click', () => refresh().catch(showError));
495
+ root('room-form').addEventListener('submit', async (event) => {
496
+ event.preventDefault();
497
+ const name = root('room-name').value.trim();
498
+ if (!name) return;
499
+ const data = await api('/api/rooms', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, kind: root('room-kind').value }) }).catch((error) => { showError(error); return null; });
500
+ if (!data) return;
501
+ root('room-name').value = '';
502
+ state.selectedRoomId = data.room.id;
503
+ await refresh();
504
+ showToast('Room created: #' + data.room.name);
505
+ });
506
+ root('agent-form').addEventListener('submit', async (event) => {
507
+ event.preventDefault();
508
+ const agent_key = root('agent-key').value.trim();
509
+ const display_name = root('agent-name').value.trim();
510
+ const runtime = root('agent-runtime').value.trim() || null;
511
+ const computer_id = root('agent-computer').value.trim();
512
+ if (!agent_key || !display_name) return;
513
+ const saved = await api('/api/agents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent_key, display_name, runtime }) }).catch((error) => { showError(error); return null; });
514
+ if (!saved) return;
515
+ if (computer_id) {
516
+ await api('/api/agents/' + encodeURIComponent(agent_key) + '/assignment', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ computer_id }) }).catch((error) => { showError(error); return null; });
517
+ }
518
+ root('agent-key').value = '';
519
+ root('agent-name').value = '';
520
+ root('agent-runtime').value = '';
521
+ root('agent-computer').value = '';
522
+ await loadAgents();
523
+ showToast('Agent saved: @' + agent_key);
524
+ });
525
+ root('computer-form').addEventListener('submit', async (event) => {
526
+ event.preventDefault();
527
+ const name = root('computer-name').value.trim();
528
+ const data = await api('/api/computers/provision', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: name || undefined, server_url: location.origin }) }).catch((error) => { showError(error); return null; });
529
+ if (!data) return;
530
+ root('computer-command').value = data.command;
531
+ showToast('Computer provisioned: ' + data.computer.id);
532
+ });
533
+ root('invite-form').addEventListener('submit', async (event) => {
534
+ event.preventDefault();
535
+ const room = activeRoom();
536
+ const agent = root('invite-agent').value;
537
+ if (!room || !agent) return;
538
+ const mode = root('invite-mode').value;
539
+ const data = await api('/api/rooms/' + encodeURIComponent(room.id) + '/agents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent, mode }) }).catch((error) => { showError(error); return null; });
540
+ if (!data) return;
541
+ await loadMessages();
542
+ showToast('@' + agent + ' invited with ' + data.subscription.mode + ' mode');
543
+ });
544
+ async function sendMessage(event) {
545
+ event.preventDefault();
546
+ const room = activeRoom();
547
+ if (!canWriteRoom(room)) return;
548
+ const content = root('message-content')?.value.trim();
549
+ const sender = root('message-sender')?.value.trim();
550
+ if (!room || !content || !sender) return;
551
+ const mentions = Array.from(new Set((content.match(/@[\\w.\\-\\u4e00-\\u9fff]+/g) || []).map((item) => item.slice(1))));
552
+ const data = await api('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room_id: room.id, sender, content, mentions }) }).catch((error) => { showError(error); return null; });
553
+ if (!data) return;
554
+ root('message-content').value = '';
555
+ await loadMessages();
556
+ await loadRooms();
557
+ renderRooms();
558
+ showToast('Message sent · deliveries=' + ((data.deliveries || []).length));
559
+ }
560
+ bindComposer();
561
+ refresh().catch(showError);
562
+ </script>
563
+ </body>
564
+ </html>`;
565
+ }