@cordfuse/crosstalk 7.0.0-alpha.9 → 7.0.0-beta.1

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 (72) hide show
  1. package/GUIDE-CLI.md +322 -0
  2. package/GUIDE-PROMPTS.md +118 -0
  3. package/LICENSE +21 -0
  4. package/README.md +402 -61
  5. package/bin/crosstalk.js +88 -54
  6. package/commands/agent.js +69 -0
  7. package/commands/auth.js +273 -0
  8. package/commands/channel.js +54 -44
  9. package/commands/chat.js +107 -71
  10. package/commands/daemon.js +120 -0
  11. package/commands/logs.js +108 -19
  12. package/commands/message.js +125 -0
  13. package/commands/server.js +153 -0
  14. package/commands/settings.js +49 -0
  15. package/commands/status.js +37 -13
  16. package/commands/token.js +136 -0
  17. package/commands/transport.js +270 -0
  18. package/commands/version.js +3 -3
  19. package/commands/workflow.js +234 -0
  20. package/deploy/crosstalk@.service +62 -0
  21. package/deploy/install.sh +82 -0
  22. package/lib/api-client.js +77 -22
  23. package/lib/credentials.js +207 -0
  24. package/lib/nativeServer.js +173 -0
  25. package/lib/resolve.js +101 -34
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1716 -0
  29. package/src/auth/enforce.ts +68 -0
  30. package/src/auth/handlers.ts +266 -0
  31. package/src/auth/middleware.ts +132 -0
  32. package/src/auth/setup.ts +263 -0
  33. package/src/auth/tokens.ts +285 -0
  34. package/src/auth/users.ts +267 -0
  35. package/src/dispatch.ts +492 -0
  36. package/src/dispatchers.ts +91 -0
  37. package/src/filenames.ts +28 -0
  38. package/src/frontmatter.ts +26 -0
  39. package/src/init.ts +116 -0
  40. package/src/invoke.ts +201 -0
  41. package/src/log-buffer.ts +67 -0
  42. package/src/models.ts +283 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +190 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +243 -0
  47. package/src/web/auth-pages.ts +160 -0
  48. package/src/web/channels.ts +395 -0
  49. package/src/web/chat-page.ts +636 -0
  50. package/src/web/chat-pty.ts +254 -0
  51. package/src/web/dashboard.ts +129 -0
  52. package/src/web/layout.ts +237 -0
  53. package/src/web/stubs.ts +510 -0
  54. package/src/web/workflows.ts +490 -0
  55. package/src/workflow.ts +470 -0
  56. package/template/CLAUDE.md +10 -0
  57. package/template/CROSSTALK-VERSION +1 -0
  58. package/template/CROSSTALK.md +262 -0
  59. package/template/PROTOCOL.md +70 -0
  60. package/template/README.md +64 -0
  61. package/template/auth/.gitkeep +0 -0
  62. package/template/auth/README.md +224 -0
  63. package/template/data/crosstalk.yaml +196 -0
  64. package/template/gitignore +4 -0
  65. package/commands/down.js +0 -40
  66. package/commands/init.js +0 -243
  67. package/commands/pull.js +0 -22
  68. package/commands/replies.js +0 -40
  69. package/commands/restart.js +0 -29
  70. package/commands/rm.js +0 -109
  71. package/commands/run.js +0 -115
  72. package/commands/up.js +0 -135
@@ -0,0 +1,510 @@
1
+ // stubs.ts — auth/admin pages, aligned with @cordfuse/llmux.
2
+ //
3
+ // Account / Users / Tokens are now interactive (mint/revoke/create/
4
+ // delete/toggle-admin/change-passphrase). Logs + Settings remain
5
+ // read-only stubs until those subsystems have something mutable.
6
+
7
+ import { page, escapeHtml, relativeTime, TOAST_HELPER } from './layout.js';
8
+ import type { User } from '../auth/users.js';
9
+ import type { IdentityToken } from '../auth/tokens.js';
10
+
11
+ interface CommonOpts {
12
+ host: string;
13
+ alias: string;
14
+ }
15
+
16
+ // ── /account ─────────────────────────────────────────────────────────
17
+
18
+ export function accountPage(opts: CommonOpts & { user: User | null }): string {
19
+ const u = opts.user;
20
+ if (!u) {
21
+ return page({
22
+ title: `crosstalk on ${opts.host} · Account`,
23
+ host: opts.host, alias: opts.alias, activeNav: 'account', pageTitle: 'Account',
24
+ body: `<section class="card"><h3>Not signed in</h3><p class="sub">Sign in via <a href="/login">/login</a> to see account details.</p></section>`,
25
+ });
26
+ }
27
+ return page({
28
+ title: `crosstalk on ${opts.host} · Account`,
29
+ host: opts.host, alias: opts.alias, activeNav: 'account', pageTitle: 'Account',
30
+ body: `
31
+ <section class="card">
32
+ <h3>Signed in as</h3>
33
+ <div class="kv"><span class="key">username</span><span class="val brand">${escapeHtml(u.username)}</span></div>
34
+ <div class="kv"><span class="key">display name</span><span class="val">${escapeHtml(u.name)}</span></div>
35
+ <div class="kv"><span class="key">role</span><span class="val ${u.admin ? 'brand' : 'dim'}">${u.admin ? 'admin' : 'member'}</span></div>
36
+ <div class="kv"><span class="key">created</span><span class="val dim">${escapeHtml(u.createdAt)} (${escapeHtml(relativeTime(u.createdAt))})</span></div>
37
+ <div class="actions" style="margin-top:14px">
38
+ <button type="button" id="logout-btn">Sign out</button>
39
+ </div>
40
+ </section>
41
+
42
+ <section class="card">
43
+ <h3>Change passphrase</h3>
44
+ <p class="sub">Requires your current passphrase. Minimum 8 characters.</p>
45
+ <form id="passphrase-form" autocomplete="off">
46
+ <div class="field">
47
+ <label for="pp-old">current passphrase</label>
48
+ <input type="password" id="pp-old" autocomplete="current-password" required>
49
+ </div>
50
+ <div class="field">
51
+ <label for="pp-new">new passphrase</label>
52
+ <input type="password" id="pp-new" autocomplete="new-password" minlength="8" required>
53
+ </div>
54
+ <div class="field">
55
+ <label for="pp-confirm">confirm new passphrase</label>
56
+ <input type="password" id="pp-confirm" autocomplete="new-password" minlength="8" required>
57
+ </div>
58
+ <div class="actions">
59
+ <button type="submit" class="primary">Update passphrase</button>
60
+ </div>
61
+ </form>
62
+ </section>
63
+ `,
64
+ inlineScript: `${TOAST_HELPER}
65
+ document.getElementById('logout-btn').addEventListener('click', async () => {
66
+ try {
67
+ const r = await fetch('/api/auth/logout', { method: 'POST' });
68
+ if (r.ok) location.href = '/login';
69
+ else window.showToast('logout failed: ' + r.status, 'err');
70
+ } catch (e){ window.showToast('logout error: ' + e.message, 'err'); }
71
+ });
72
+ document.getElementById('passphrase-form').addEventListener('submit', async (ev) => {
73
+ ev.preventDefault();
74
+ const oldPP = document.getElementById('pp-old').value;
75
+ const newPP = document.getElementById('pp-new').value;
76
+ const confirmPP = document.getElementById('pp-confirm').value;
77
+ if (newPP !== confirmPP){ window.showToast('confirmation does not match', 'err'); return; }
78
+ try {
79
+ const r = await fetch('/api/account/passphrase', {
80
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ oldPassphrase: oldPP, newPassphrase: newPP }),
82
+ });
83
+ const body = await r.json().catch(() => ({}));
84
+ if (r.ok){
85
+ window.showToast('passphrase updated', 'ok');
86
+ document.getElementById('passphrase-form').reset();
87
+ } else {
88
+ window.showToast(body.error || 'update failed', 'err');
89
+ }
90
+ } catch (e){ window.showToast('error: ' + e.message, 'err'); }
91
+ });
92
+ `,
93
+ });
94
+ }
95
+
96
+ // ── /users (admin) ───────────────────────────────────────────────────
97
+
98
+ export function usersPage(opts: CommonOpts & { users: User[]; viewerIsAdmin: boolean; viewerUsername: string | null }): string {
99
+ if (!opts.viewerIsAdmin) {
100
+ return page({
101
+ title: `crosstalk on ${opts.host} · Users`,
102
+ host: opts.host, alias: opts.alias, activeNav: 'users', pageTitle: 'Users',
103
+ body: `<section class="card"><h3>Admin only</h3><p class="sub">Only admin users can view and manage the operator roster.</p></section>`,
104
+ });
105
+ }
106
+ const rows = opts.users.map((u) => {
107
+ const isSelf = u.username === opts.viewerUsername;
108
+ return `
109
+ <div class="user-row" data-username="${escapeHtml(u.username)}">
110
+ <div class="user-info">
111
+ <div class="user-name"><span class="brand">${escapeHtml(u.username)}</span> ${u.admin ? '<span class="chip brand">admin</span>' : ''}${isSelf ? '<span class="chip" style="color:#a371f7;border-color:#3d2466">you</span>' : ''}</div>
112
+ <div class="user-meta">${escapeHtml(u.name)} · ${escapeHtml(relativeTime(u.createdAt))}</div>
113
+ </div>
114
+ <div class="user-actions">
115
+ <button type="button" data-action="toggle-admin" data-admin="${u.admin ? 'false' : 'true'}"${isSelf ? ' disabled title="cannot change your own admin flag"' : ''}>${u.admin ? 'demote' : 'promote'}</button>
116
+ <button type="button" data-action="delete" class="danger"${isSelf ? ' disabled title="cannot delete yourself"' : ''}>delete</button>
117
+ </div>
118
+ </div>`;
119
+ }).join('');
120
+ return page({
121
+ title: `crosstalk on ${opts.host} · Users`,
122
+ host: opts.host, alias: opts.alias, activeNav: 'users', pageTitle: 'Users',
123
+ extraCss: `
124
+ .user-row{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:10px 0;border-bottom:1px solid #1f2329;flex-wrap:wrap}
125
+ .user-row:last-of-type{border-bottom:none}
126
+ .user-info{flex:1 1 auto;min-width:0}
127
+ .user-name{font-size:13px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
128
+ .user-meta{font-size:11px;color:#7a7f87;margin-top:2px}
129
+ .user-actions{display:flex;gap:6px;flex-shrink:0}
130
+ .user-actions button{padding:5px 10px;font-size:11px}
131
+ .user-actions button.danger{color:#f85149;border-color:#4a2329}
132
+ .user-actions button.danger:hover:not(:disabled){background:#1a0e0e}
133
+ `,
134
+ body: `
135
+ <section class="card">
136
+ <h3>Operators (${opts.users.length})</h3>
137
+ <p class="sub">All admins can create users and toggle admin flags. You can't demote or delete yourself.</p>
138
+ <div id="users-list">
139
+ ${rows || '<div class="empty">no users</div>'}
140
+ </div>
141
+ </section>
142
+
143
+ <section class="card">
144
+ <h3>Create user</h3>
145
+ <form id="create-form" autocomplete="off">
146
+ <div class="field">
147
+ <label for="cu-username">username</label>
148
+ <input type="text" id="cu-username" pattern="[a-z0-9_-]+" maxlength="32" required placeholder="lowercase, no spaces">
149
+ </div>
150
+ <div class="field">
151
+ <label for="cu-name">display name</label>
152
+ <input type="text" id="cu-name" required>
153
+ </div>
154
+ <div class="field">
155
+ <label for="cu-passphrase">passphrase</label>
156
+ <input type="password" id="cu-passphrase" minlength="8" required>
157
+ </div>
158
+ <div class="field">
159
+ <label><input type="checkbox" id="cu-admin"> admin</label>
160
+ </div>
161
+ <div class="actions">
162
+ <button type="submit" class="primary">Create user</button>
163
+ </div>
164
+ </form>
165
+ </section>
166
+ `,
167
+ inlineScript: `${TOAST_HELPER}
168
+ async function refreshList(){ location.reload(); }
169
+ document.getElementById('users-list').addEventListener('click', async (ev) => {
170
+ const btn = ev.target;
171
+ if (!(btn instanceof HTMLElement) || !btn.dataset.action) return;
172
+ const row = btn.closest('.user-row');
173
+ if (!row) return;
174
+ const username = row.dataset.username;
175
+ if (btn.dataset.action === 'toggle-admin'){
176
+ const next = btn.dataset.admin === 'true';
177
+ if (!confirm((next ? 'Promote' : 'Demote') + ' ' + username + '?')) return;
178
+ btn.disabled = true;
179
+ try {
180
+ const r = await fetch('/api/users/' + encodeURIComponent(username), {
181
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({ admin: next }),
183
+ });
184
+ const body = await r.json().catch(() => ({}));
185
+ if (r.ok){ window.showToast('admin flag updated', 'ok'); setTimeout(refreshList, 500); }
186
+ else { window.showToast(body.error || 'failed', 'err'); btn.disabled = false; }
187
+ } catch (e){ window.showToast('error: ' + e.message, 'err'); btn.disabled = false; }
188
+ } else if (btn.dataset.action === 'delete'){
189
+ if (!confirm('Delete user ' + username + '? This also revokes all their tokens.')) return;
190
+ btn.disabled = true;
191
+ try {
192
+ const r = await fetch('/api/users/' + encodeURIComponent(username), { method: 'DELETE' });
193
+ const body = await r.json().catch(() => ({}));
194
+ if (r.ok){ window.showToast('user deleted', 'ok'); setTimeout(refreshList, 500); }
195
+ else { window.showToast(body.error || 'failed', 'err'); btn.disabled = false; }
196
+ } catch (e){ window.showToast('error: ' + e.message, 'err'); btn.disabled = false; }
197
+ }
198
+ });
199
+ document.getElementById('create-form').addEventListener('submit', async (ev) => {
200
+ ev.preventDefault();
201
+ const payload = {
202
+ username: document.getElementById('cu-username').value.trim(),
203
+ name: document.getElementById('cu-name').value.trim(),
204
+ passphrase: document.getElementById('cu-passphrase').value,
205
+ admin: document.getElementById('cu-admin').checked,
206
+ };
207
+ try {
208
+ const r = await fetch('/api/users', {
209
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
210
+ body: JSON.stringify(payload),
211
+ });
212
+ const body = await r.json().catch(() => ({}));
213
+ if (r.ok){
214
+ window.showToast('user ' + payload.username + ' created', 'ok');
215
+ setTimeout(refreshList, 500);
216
+ } else {
217
+ window.showToast(body.error || 'create failed', 'err');
218
+ }
219
+ } catch (e){ window.showToast('error: ' + e.message, 'err'); }
220
+ });
221
+ `,
222
+ });
223
+ }
224
+
225
+ // ── /tokens ──────────────────────────────────────────────────────────
226
+
227
+ export function tokensPage(opts: CommonOpts & { tokens: IdentityToken[]; viewerUsername: string | null }): string {
228
+ const rows = opts.tokens.map((t) => `
229
+ <div class="token-row" data-token-id="${escapeHtml(t.tokenId)}">
230
+ <div class="token-info">
231
+ <div class="token-name"><span class="brand">${escapeHtml(t.name)}</span> <span class="token-id">${escapeHtml(t.tokenId)}</span></div>
232
+ <div class="token-meta">
233
+ created ${escapeHtml(relativeTime(t.createdAt))}${t.lastUsedAt ? ' · last used ' + escapeHtml(relativeTime(t.lastUsedAt)) : ' · never used'}${t.expiresAt ? ' · expires ' + escapeHtml(t.expiresAt) : ''}
234
+ </div>
235
+ </div>
236
+ <div class="token-actions">
237
+ <button type="button" data-action="revoke" class="danger">revoke</button>
238
+ </div>
239
+ </div>`).join('');
240
+ return page({
241
+ title: `crosstalk on ${opts.host} · Tokens`,
242
+ host: opts.host, alias: opts.alias, activeNav: 'tokens', pageTitle: 'Tokens',
243
+ extraCss: `
244
+ .token-row{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:10px 0;border-bottom:1px solid #1f2329;flex-wrap:wrap}
245
+ .token-row:last-of-type{border-bottom:none}
246
+ .token-info{flex:1 1 auto;min-width:0}
247
+ .token-name{font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
248
+ .token-id{font-size:10px;color:#7a7f87;font-family:ui-monospace,monospace}
249
+ .token-meta{font-size:11px;color:#7a7f87;margin-top:2px}
250
+ .token-actions button.danger{color:#f85149;border-color:#4a2329;padding:5px 10px;font-size:11px}
251
+ .token-actions button.danger:hover{background:#1a0e0e}
252
+ #new-token-secret{margin-top:10px;background:#0b0c10;border:1px solid #2d4a66;border-radius:6px;padding:10px;font:11px ui-monospace,monospace;color:#7cc4ff;word-break:break-all;display:none}
253
+ #new-token-secret.show{display:block}
254
+ #new-token-secret .copy-btn{display:inline-block;margin-left:8px;padding:3px 8px;font-size:10px}
255
+ `,
256
+ body: `
257
+ <section class="card">
258
+ <h3>Tokens${opts.viewerUsername ? ' · ' + escapeHtml(opts.viewerUsername) : ''} (${opts.tokens.length})</h3>
259
+ <p class="sub">API tokens grant bearer access to the engine API. The plaintext secret is shown once at mint — copy it before navigating away.</p>
260
+ <div id="tokens-list">
261
+ ${rows || '<div class="empty">no tokens yet — mint one below</div>'}
262
+ </div>
263
+ </section>
264
+
265
+ <section class="card">
266
+ <h3>Mint new token</h3>
267
+ <form id="mint-form" autocomplete="off">
268
+ <div class="field">
269
+ <label for="tk-name">token name</label>
270
+ <input type="text" id="tk-name" required placeholder="e.g. cachy-cli, mac-ci">
271
+ </div>
272
+ <div class="actions">
273
+ <button type="submit" class="primary">Mint</button>
274
+ </div>
275
+ </form>
276
+ <div id="new-token-secret" role="alert">
277
+ <strong>new token (copy now — won't be shown again):</strong><br>
278
+ <span id="secret-text"></span>
279
+ <button type="button" class="copy-btn" id="copy-secret">copy</button>
280
+ </div>
281
+ </section>
282
+ `,
283
+ inlineScript: `${TOAST_HELPER}
284
+ document.getElementById('tokens-list').addEventListener('click', async (ev) => {
285
+ const btn = ev.target;
286
+ if (!(btn instanceof HTMLElement) || btn.dataset.action !== 'revoke') return;
287
+ const row = btn.closest('.token-row');
288
+ if (!row) return;
289
+ const tokenId = row.dataset.tokenId;
290
+ if (!confirm('Revoke token ' + tokenId + '? This will invalidate any clients using this token.')) return;
291
+ btn.disabled = true;
292
+ try {
293
+ const r = await fetch('/api/tokens/' + encodeURIComponent(tokenId), { method: 'DELETE' });
294
+ const body = await r.json().catch(() => ({}));
295
+ if (r.ok){
296
+ window.showToast('token revoked', 'ok');
297
+ setTimeout(() => location.reload(), 500);
298
+ } else { window.showToast(body.error || 'failed', 'err'); btn.disabled = false; }
299
+ } catch (e){ window.showToast('error: ' + e.message, 'err'); btn.disabled = false; }
300
+ });
301
+ document.getElementById('mint-form').addEventListener('submit', async (ev) => {
302
+ ev.preventDefault();
303
+ const name = document.getElementById('tk-name').value.trim();
304
+ if (!name) return;
305
+ try {
306
+ const r = await fetch('/api/tokens', {
307
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({ name }),
309
+ });
310
+ const body = await r.json().catch(() => ({}));
311
+ if (r.ok){
312
+ window.showToast('token minted — copy the secret now', 'ok');
313
+ document.getElementById('secret-text').textContent = body.plaintextSecret;
314
+ document.getElementById('new-token-secret').classList.add('show');
315
+ document.getElementById('mint-form').reset();
316
+ } else { window.showToast(body.error || 'mint failed', 'err'); }
317
+ } catch (e){ window.showToast('error: ' + e.message, 'err'); }
318
+ });
319
+ document.getElementById('copy-secret').addEventListener('click', async () => {
320
+ const text = document.getElementById('secret-text').textContent;
321
+ try {
322
+ await navigator.clipboard.writeText(text);
323
+ window.showToast('copied', 'ok');
324
+ } catch { window.showToast('clipboard blocked — select + copy manually', 'err'); }
325
+ });
326
+ `,
327
+ });
328
+ }
329
+
330
+ // ── /logs (live tail via SSE) ────────────────────────────────────────
331
+
332
+ export function logsPage(opts: CommonOpts & { errorsLogged: number; transportRoot: string }): string {
333
+ return page({
334
+ title: `crosstalk on ${opts.host} · Logs`,
335
+ host: opts.host, alias: opts.alias, activeNav: 'logs', pageTitle: 'Logs',
336
+ extraCss: `
337
+ #logs-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
338
+ #logs-filter{flex:1 1 200px;background:#0b0c10;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:7px 10px;font:13px ui-monospace,monospace;outline:none;min-width:160px}
339
+ #logs-filter:focus{border-color:#2d4a66}
340
+ #logs-status{font-size:11px;color:#7a7f87}
341
+ #logs-status.connected{color:#7ee787}
342
+ #logs-status.dead{color:#f85149}
343
+ #logs-list{background:#0b0c10;border:1px solid #1f2329;border-radius:6px;padding:10px;font:11px/1.5 ui-monospace,monospace;max-height:70dvh;overflow-y:auto;overflow-x:auto;white-space:nowrap}
344
+ #logs-list .log-row{padding:2px 0;border-bottom:1px dotted #1c2128;white-space:nowrap}
345
+ #logs-list .log-row:last-child{border-bottom:none}
346
+ #logs-list .log-ts{color:#7a7f87;margin-right:8px}
347
+ #logs-list .log-event{color:#7cc4ff;font-weight:600;margin-right:6px}
348
+ #logs-list .log-fields{color:#c9d1d9}
349
+ #logs-list .log-row.error .log-event{color:#f85149}
350
+ #logs-list .log-row.warn .log-event{color:#d29922}
351
+ #logs-list .log-row.dispatch .log-event{color:#7ee787}
352
+ #logs-list .log-row.workflow .log-event{color:#a371f7}
353
+ #logs-list .log-row.hidden{display:none}
354
+ `,
355
+ body: `
356
+ <section class="card">
357
+ <h3>Logs <span class="chip">live tail</span></h3>
358
+ <p class="sub">Structured events from the engine's <code>log()</code> calls. Server-sent stream — updates as the dispatcher works. Reflects the in-process ring buffer (last 500 entries).</p>
359
+ <div class="kv"><span class="key">errors logged (since start)</span><span class="val ${opts.errorsLogged > 0 ? 'warn' : 'ok'}">${opts.errorsLogged}</span></div>
360
+ <div class="kv"><span class="key">errors file</span><span class="val dim" style="font-size:11px">${escapeHtml(opts.transportRoot)}/../state/transport/errors.log</span></div>
361
+ </section>
362
+
363
+ <section class="card">
364
+ <h3>Live tail</h3>
365
+ <div id="logs-toolbar">
366
+ <input type="text" id="logs-filter" placeholder="filter by event name (e.g. dispatch, workflow)…" autocomplete="off">
367
+ <button type="button" id="logs-pause">pause</button>
368
+ <button type="button" id="logs-clear">clear</button>
369
+ <span id="logs-status">connecting…</span>
370
+ </div>
371
+ <div id="logs-list" role="log" aria-live="polite"></div>
372
+ </section>
373
+ `,
374
+ inlineScript: `${TOAST_HELPER}
375
+ const list = document.getElementById('logs-list');
376
+ const filter = document.getElementById('logs-filter');
377
+ const pauseBtn = document.getElementById('logs-pause');
378
+ const clearBtn = document.getElementById('logs-clear');
379
+ const status = document.getElementById('logs-status');
380
+
381
+ let paused = false;
382
+ let autoscroll = true;
383
+ let lastSeq = 0;
384
+ let es = null;
385
+ let reconnectT = null;
386
+
387
+ function setStatus(text, cls){ status.textContent = text; status.className = cls || ''; }
388
+ function applyFilter(row, term){
389
+ if (!term){ row.classList.remove('hidden'); return; }
390
+ const txt = row.textContent || '';
391
+ row.classList.toggle('hidden', !txt.toLowerCase().includes(term));
392
+ }
393
+ function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
394
+
395
+ function appendEntry(entry){
396
+ if (entry.seq && entry.seq > lastSeq) lastSeq = entry.seq;
397
+ const row = document.createElement('div');
398
+ row.className = 'log-row';
399
+ // Color hint by event name prefix.
400
+ const ev = entry.event || '';
401
+ if (ev.includes('error') || ev.includes('fail') || ev.includes('crash')) row.classList.add('error');
402
+ else if (ev.includes('warn') || ev.includes('stale')) row.classList.add('warn');
403
+ else if (ev.startsWith('dispatch') || ev === 'dispatch') row.classList.add('dispatch');
404
+ else if (ev.startsWith('workflow')) row.classList.add('workflow');
405
+ const ts = (entry.ts || '').slice(11, 23); // HH:MM:SS.mmm
406
+ const fields = entry.fields || {};
407
+ const kvs = Object.keys(fields).map(k => k + '=' + (typeof fields[k] === 'string' ? fields[k] : JSON.stringify(fields[k]))).join(' ');
408
+ row.innerHTML = '<span class="log-ts">' + escapeHtml(ts) + '</span>' +
409
+ '<span class="log-event">' + escapeHtml(ev) + '</span>' +
410
+ '<span class="log-fields">' + escapeHtml(kvs) + '</span>';
411
+ applyFilter(row, filter.value.toLowerCase().trim());
412
+ list.appendChild(row);
413
+ // Cap displayed rows so the DOM doesn't grow forever.
414
+ while (list.children.length > 1000) list.removeChild(list.firstChild);
415
+ if (autoscroll && !paused) list.scrollTop = list.scrollHeight;
416
+ }
417
+
418
+ function connect(){
419
+ if (es){ try { es.close(); } catch(_){} }
420
+ setStatus('connecting…');
421
+ const url = '/api/logs/stream' + (lastSeq > 0 ? '?since=' + lastSeq : '');
422
+ es = new EventSource(url);
423
+ es.addEventListener('open', () => setStatus('live · streaming', 'connected'));
424
+ es.addEventListener('message', (ev) => {
425
+ if (paused) return;
426
+ try { appendEntry(JSON.parse(ev.data)); } catch(_) {}
427
+ });
428
+ es.addEventListener('error', () => {
429
+ setStatus('disconnected · reconnecting in 3s', 'dead');
430
+ if (reconnectT) clearTimeout(reconnectT);
431
+ reconnectT = setTimeout(connect, 3000);
432
+ });
433
+ }
434
+
435
+ // Detect manual scroll-up: if the user scrolls away from the bottom,
436
+ // stop auto-scrolling so new entries don't yank them back.
437
+ list.addEventListener('scroll', () => {
438
+ const distFromBottom = list.scrollHeight - (list.scrollTop + list.clientHeight);
439
+ autoscroll = distFromBottom < 30;
440
+ });
441
+
442
+ filter.addEventListener('input', () => {
443
+ const term = filter.value.toLowerCase().trim();
444
+ for (const row of list.children) applyFilter(row, term);
445
+ });
446
+
447
+ pauseBtn.addEventListener('click', () => {
448
+ paused = !paused;
449
+ pauseBtn.textContent = paused ? 'resume' : 'pause';
450
+ });
451
+
452
+ clearBtn.addEventListener('click', () => {
453
+ list.innerHTML = '';
454
+ window.showToast('logs cleared (server buffer unchanged)', 'ok');
455
+ });
456
+
457
+ connect();
458
+ `,
459
+ });
460
+ }
461
+
462
+ // ── /settings (read-only stub) ──────────────────────────────────────
463
+
464
+ export interface SettingsPageData extends CommonOpts {
465
+ version: string;
466
+ transportRoot: string;
467
+ claimedModels: string[];
468
+ pollSeconds: number | null;
469
+ apiPort: number;
470
+ }
471
+
472
+ export function settingsPage(d: SettingsPageData): string {
473
+ return page({
474
+ title: `crosstalk on ${d.host} · Settings`,
475
+ host: d.host, alias: d.alias, activeNav: 'settings', pageTitle: 'Settings',
476
+ body: `
477
+ <section class="card">
478
+ <h3>Engine settings <span class="chip">read-only — backlog</span></h3>
479
+ <p class="sub">Snapshot of the running engine. Editable settings are on the backlog — for now, restart the engine with different flags / env vars.</p>
480
+ <div class="kv"><span class="key">alias</span><span class="val brand">${escapeHtml(d.alias)}</span></div>
481
+ <div class="kv"><span class="key">version</span><span class="val">${escapeHtml(d.version)}</span></div>
482
+ <div class="kv"><span class="key">transport root</span><span class="val dim" style="font-size:11px">${escapeHtml(d.transportRoot)}</span></div>
483
+ <div class="kv"><span class="key">api port</span><span class="val">${d.apiPort}</span></div>
484
+ <div class="kv"><span class="key">poll cadence</span><span class="val">${d.pollSeconds !== null ? d.pollSeconds + 's' : '—'}</span></div>
485
+ <div class="kv"><span class="key">claimed models</span><span class="val">${d.claimedModels.length === 0 ? '<span class="dim">none</span>' : d.claimedModels.map((m) => '<span class="chip">' + escapeHtml(m) + '</span>').join(' ')}</span></div>
486
+ </section>
487
+ `,
488
+ });
489
+ }
490
+
491
+ // ── /about ───────────────────────────────────────────────────────────
492
+
493
+ export function aboutPage(opts: CommonOpts & { version: string; transportRoot: string }): string {
494
+ return page({
495
+ title: `crosstalk on ${opts.host} · About`,
496
+ host: opts.host, alias: opts.alias, activeNav: 'about', pageTitle: 'About',
497
+ body: `
498
+ <section class="card">
499
+ <h3>Crosstalk</h3>
500
+ <p class="sub">Async git-backed message bus + dispatcher for AI agent CLIs. Sibling product to <a href="https://github.com/cordfuse/llmux">@cordfuse/llmux</a> (interactive tmux-based session mux).</p>
501
+ <div class="kv"><span class="key">engine version</span><span class="val brand">${escapeHtml(opts.version)}</span></div>
502
+ <div class="kv"><span class="key">alias</span><span class="val">${escapeHtml(opts.alias)}</span></div>
503
+ <div class="kv"><span class="key">host</span><span class="val">${escapeHtml(opts.host)}</span></div>
504
+ <div class="kv"><span class="key">transport</span><span class="val dim" style="font-size:11px">${escapeHtml(opts.transportRoot)}</span></div>
505
+ <div class="kv"><span class="key">repo</span><span class="val"><a href="https://github.com/cordfuse/crosstalk">github.com/cordfuse/crosstalk</a></span></div>
506
+ <div class="kv"><span class="key">license</span><span class="val">MIT</span></div>
507
+ </section>
508
+ `,
509
+ });
510
+ }