@cabane/companion 0.6.215 → 0.6.217

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.215",
3
+ "version": "0.6.217",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect an agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",
@@ -14,6 +14,14 @@
14
14
  "cabane-companion": "./dist/bin.js"
15
15
  },
16
16
  "main": "./dist/cli.js",
17
+ "exports": {
18
+ ".": "./dist/cli.js",
19
+ "./runtime": "./dist/runtime.js",
20
+ "./pairing": "./dist/pairing-config.js",
21
+ "./credential-broker": "./dist/credential-broker.js",
22
+ "./package.json": "./package.json",
23
+ "./*": "./*"
24
+ },
17
25
  "files": [
18
26
  "dist"
19
27
  ],
@@ -21,7 +29,7 @@
21
29
  "node": ">=22"
22
30
  },
23
31
  "scripts": {
24
- "build": "tsup && node scripts/copy-static.mjs",
32
+ "build": "tsup",
25
33
  "dev": "tsx watch src/cli.ts",
26
34
  "lint": "eslint .",
27
35
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -33,10 +41,9 @@
33
41
  "@anthropic-ai/claude-agent-sdk": "0.3.310",
34
42
  "@clack/prompts": "1.8.1",
35
43
  "@openai/codex-sdk": "0.164.0",
36
- "@hono/node-server": "1.19.14",
37
44
  "commander": "12.1.0",
38
45
  "eventsource-parser": "3.0.8",
39
- "hono": "4.12.18",
46
+ "graphql": "16.11.0",
40
47
  "picocolors": "1.1.1",
41
48
  "pino": "10.3.1",
42
49
  "smol-toml": "1.8.0",
@@ -1,545 +0,0 @@
1
- // Cabane Companion dashboard frontend. Vanilla JS, no build step.
2
- // Subscribes to /api/events (SSE) for live updates and falls back to REST
3
- // fetches for the initial paint + the available-workspaces list.
4
-
5
- const $ = (id) => document.getElementById(id);
6
-
7
- // the dashboard is read-only: the connect/disconnect lifecycle lives in the
8
- // cabane app. This frontend shows status, per-workspace state, the dispatch feed,
9
- // and logs, plus local-only Stop / Restart / Reload-config.
10
- const state = {
11
- status: null, // latest /api/status snapshot
12
- dispatches: [], // newest-first wire records
13
- expanded: new Set(), // dispatch ids whose detail is open
14
- logsOpen: false,
15
- online: true, // is the companion process reachable (SSE up)?
16
- };
17
-
18
- // ---- tiny fetch helpers ----
19
-
20
- async function getJSON(path) {
21
- const res = await fetch(path);
22
- if (!res.ok) throw new Error((await safeError(res)) || `${res.status}`);
23
- return res.json();
24
- }
25
-
26
- async function postJSON(path, body) {
27
- const res = await fetch(path, {
28
- method: 'POST',
29
- headers: { 'content-type': 'application/json' },
30
- body: JSON.stringify(body || {}),
31
- });
32
- if (!res.ok) throw new Error((await safeError(res)) || `${res.status}`);
33
- return res.json().catch(() => ({}));
34
- }
35
-
36
- async function safeError(res) {
37
- try {
38
- const j = await res.json();
39
- return j && j.error;
40
- } catch {
41
- return null;
42
- }
43
- }
44
-
45
- // ---- formatting ----
46
-
47
- function timeAgo(iso) {
48
- if (!iso) return 'never';
49
- const secs = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
50
- if (secs < 60) return `${secs}s ago`;
51
- const mins = Math.round(secs / 60);
52
- if (mins < 60) return `${mins}m ago`;
53
- const hrs = Math.round(mins / 60);
54
- if (hrs < 24) return `${hrs}h ago`;
55
- return `${Math.round(hrs / 24)}d ago`;
56
- }
57
-
58
- function clock(iso) {
59
- const d = iso ? new Date(iso) : new Date();
60
- return d.toTimeString().slice(0, 8);
61
- }
62
-
63
- function hostOf(url) {
64
- try {
65
- return new URL(url).host;
66
- } catch {
67
- return null;
68
- }
69
- }
70
-
71
- // Freshness for the status dots. a connected, idle companion is healthy,
72
- // not a warning — so connected = green, full stop. red = offline / not
73
- // connected to cabane. amber stays defined (in CSS) for a future genuinely-
74
- // degraded signal, but we don't downgrade a quiet-but-connected companion to it.
75
- function freshness(connected) {
76
- if (!state.online || !connected) return 'red';
77
- return 'green';
78
- }
79
-
80
- // ---- rendering ----
81
-
82
- function render() {
83
- renderStatus();
84
- renderHarnesses();
85
- renderConnected();
86
- renderDispatches();
87
- }
88
-
89
- // The agents this device exposes to Cabane. `harnesses` is null until the
90
- // first detection lands (show "checking…"); once detected, a list of three, and
91
- // if NONE are exposed the loud empty state — the headline requirement. Each row
92
- // shows a state badge, version when known, and a one-line guide. Agents are
93
- // detected, never enabled from here: signing in is what connects one.
94
- const HARNESS_BADGE = {
95
- exposed: 'Exposed',
96
- needs_attention: 'Needs attention',
97
- not_detected: 'Not detected',
98
- };
99
-
100
- function renderHarnesses() {
101
- const s = state.status;
102
- const harnesses = s && s.harnesses;
103
- const list = $('harness-list');
104
- const checking = $('harness-checking');
105
- const emptyBanner = $('harness-empty');
106
-
107
- // Before the first probe (null) — or while offline with no snapshot yet — show
108
- // the checking placeholder, not the scary empty state.
109
- if (!harnesses) {
110
- list.innerHTML = '';
111
- emptyBanner.hidden = true;
112
- checking.hidden = false;
113
- return;
114
- }
115
- checking.hidden = true;
116
-
117
- const anyExposed = harnesses.some((h) => h.state === 'exposed');
118
- emptyBanner.hidden = anyExposed;
119
-
120
- list.innerHTML = '';
121
- for (const h of harnesses) {
122
- const li = document.createElement('li');
123
- li.className = 'harness-row';
124
-
125
- const name = el('span', 'harness-name', h.label);
126
- li.append(name);
127
- if (h.version) li.append(el('span', 'harness-version', `v${h.version}`));
128
-
129
- const badge = el(
130
- 'span',
131
- `harness-badge harness-badge--${h.state}`,
132
- HARNESS_BADGE[h.state] || h.state,
133
- );
134
- li.append(el('span', 'harness-spacer'), badge);
135
-
136
- if (h.detail) li.append(el('p', 'harness-detail', h.detail));
137
-
138
- list.append(li);
139
- }
140
- }
141
-
142
- function renderStatus() {
143
- const s = state.status;
144
- const dot = $('status-dot');
145
- const title = $('status-title');
146
- if (!state.online) {
147
- dot.className = 'dot dot--red';
148
- title.textContent = 'Cabane Companion — Offline';
149
- $('conn-line').textContent = 'Companion process not reachable. Re-run `cabane start`.';
150
- $('listen-line').textContent = '';
151
- document.title = '⚠ Companion — Offline';
152
- return;
153
- }
154
- if (!s) return;
155
- const overall = freshness(s.connected);
156
- dot.className = `dot dot--${overall}`;
157
- const listening = s.connected;
158
- title.textContent = listening
159
- ? 'Cabane Companion — Listening'
160
- : 'Cabane Companion — Disconnected';
161
- document.title = listening ? '● Companion — Listening' : '⚠ Companion — Disconnected';
162
-
163
- const cabaneHost = hostOf(s.cabane_url) || 'cabane';
164
- if (s.connected) {
165
- $('conn-line').textContent = `Connected to ${cabaneHost}`;
166
- } else {
167
- // this branch means the companion process is up (the dashboard reached
168
- // it) but the companion's own event stream to cabane is down — NOT that cabane
169
- // the app is unreachable. Point at the companion connection so the copy isn't
170
- // misleading. The stream auto-retries; `cabane-companion start` is the remedy
171
- // if the process was never (re)started.
172
- const last = s.last_event_at_overall
173
- ? ` — last connected ${timeAgo(s.last_event_at_overall)}`
174
- : '';
175
- $('conn-line').textContent =
176
- `⚠ Companion isn't connected to ${cabaneHost}${last}. Make sure \`cabane start\` is running.`;
177
- }
178
- $('listen-line').textContent = listening
179
- ? `Listening — last event ${timeAgo(s.last_event_at_overall)}`
180
- : 'Not connected to any workspace stream.';
181
-
182
- $('ver-companion').textContent = s.companion_version || '—';
183
- }
184
-
185
- function renderConnected() {
186
- const s = state.status;
187
- const list = $('connected-list');
188
- list.innerHTML = '';
189
- const wss = (s && s.workspaces) || [];
190
- $('connected-count').textContent = wss.length ? `(${wss.length})` : '';
191
- $('connected-empty').hidden = wss.length > 0;
192
- for (const ws of wss) {
193
- const li = document.createElement('li');
194
- li.className = 'ws-row';
195
- // a terminal auth failure (rotated/revoked PAT) is a dead end the
196
- // operator has to act on — render it red with a clear "re-add" line, not as
197
- // the transient grey of a reconnecting stream.
198
- const f = ws.auth_failed ? 'red' : freshness(ws.connected);
199
-
200
- const dot = el('span', `dot dot--${f}`);
201
- const name = el('span', 'ws-name', ws.name);
202
- const meta = el(
203
- 'span',
204
- 'ws-meta',
205
- ws.auth_failed
206
- ? 'disconnected — re-add this workspace in the cabane app'
207
- : `last event ${timeAgo(ws.last_event_at)} · ${ws.dispatch_count_today} today`,
208
- );
209
- if (ws.auth_failed) {
210
- li.classList.add('ws-row--auth-failed');
211
- meta.classList.add('ws-meta--alert');
212
- }
213
- const spacer = el('span', 'spacer');
214
-
215
- // read-only — assignment lives in the cabane app. The companion pulls which
216
- // agents to run; this view just observes them.
217
- li.append(dot, name, meta, spacer);
218
-
219
- // The agents this device runs in this workspace, pulled from Cabane.
220
- const agents = ws.agents || [];
221
- if (agents.length) {
222
- const sub = el('ul', 'ws-agents');
223
- for (const a of agents) {
224
- const row = el('li', 'ws-agent');
225
- const handle = el('span', 'ws-agent-name', `@${a.username}`);
226
- row.append(handle);
227
- if (!a.has_credential) {
228
- const warn = el('span', 'ws-agent-warn', 'no credential — re-assign in cabane');
229
- row.append(warn);
230
- } else if (a.missing_secrets && a.missing_secrets.length) {
231
- const warn = el(
232
- 'span',
233
- 'ws-agent-warn',
234
- `needs secret(s) this device lacks: ${a.missing_secrets.join(', ')}`,
235
- );
236
- row.append(warn);
237
- }
238
- sub.append(row);
239
- }
240
- li.append(sub);
241
- }
242
- list.append(li);
243
- }
244
- }
245
-
246
- function renderDispatches() {
247
- const list = $('dispatch-list');
248
- list.innerHTML = '';
249
- $('dispatch-empty').hidden = state.dispatches.length > 0;
250
- for (const d of state.dispatches) {
251
- const li = document.createElement('li');
252
- li.className = 'dispatch-row';
253
-
254
- const head = el('div', 'dispatch-head');
255
- head.append(
256
- el('span', 'time', clock(d.timestamp)),
257
- el('span', 'ws', d.workspace_slug),
258
- el('span', 'preview', d.message || '(no message)'),
259
- verdict(d),
260
- );
261
- head.addEventListener('click', () => {
262
- if (state.expanded.has(d.id)) state.expanded.delete(d.id);
263
- else state.expanded.add(d.id);
264
- renderDispatches();
265
- });
266
- li.append(head);
267
-
268
- if (state.expanded.has(d.id)) {
269
- const detail = el('div', 'dispatch-detail');
270
-
271
- // show both halves of the exchange — the triggering message and
272
- // the agent's reply — plus the error if the turn failed.
273
- detail.append(detailBlock('Message', d.message || '(no message body captured)'));
274
-
275
- if (d.reply) {
276
- detail.append(detailBlock('Reply', d.reply));
277
- } else if (d.status === 'running') {
278
- detail.append(detailBlock('Reply', 'still running…', 'muted'));
279
- } else if (d.status === 'replied') {
280
- detail.append(detailBlock('Reply', '(no reply text captured)', 'muted'));
281
- }
282
-
283
- if (d.error) {
284
- detail.append(detailBlock('Error', d.error, 'err-text'));
285
- }
286
- li.append(detail);
287
- }
288
- list.append(li);
289
- }
290
- }
291
-
292
- function verdict(d) {
293
- if (d.status === 'running') return el('span', 'verdict run', '… running');
294
- if (d.status === 'replied') {
295
- const secs = d.duration_ms ? ` (${(d.duration_ms / 1000).toFixed(1)}s)` : '';
296
- return el('span', 'verdict ok', `✓ replied${secs}`);
297
- }
298
- return el('span', 'verdict err', '⚠ error');
299
- }
300
-
301
- // ---- DOM helpers ----
302
-
303
- function el(tag, className, text) {
304
- const node = document.createElement(tag);
305
- if (className) node.className = className;
306
- if (text !== undefined) node.textContent = text;
307
- return node;
308
- }
309
-
310
- // a labeled section inside an expanded dispatch row (Message / Reply /
311
- // Error). `bodyClass` adds a modifier to the body (e.g. 'muted', 'err-text').
312
- function detailBlock(label, body, bodyClass) {
313
- const block = el('div', 'd-block');
314
- block.append(
315
- el('p', 'd-label', label),
316
- el('div', `d-body${bodyClass ? ` ${bodyClass}` : ''}`, body),
317
- );
318
- return block;
319
- }
320
-
321
- let toastTimer = null;
322
- function toast(msg, isError) {
323
- const t = $('toast');
324
- t.textContent = msg;
325
- t.className = `toast${isError ? ' error' : ''}`;
326
- t.hidden = false;
327
- clearTimeout(toastTimer);
328
- toastTimer = setTimeout(() => {
329
- t.hidden = true;
330
- }, 4000);
331
- }
332
-
333
- // ---- actions ----
334
-
335
- async function control(action) {
336
- try {
337
- await postJSON('/api/control', { action });
338
- if (action === 'stop') toast('Stopping the companion…');
339
- if (action === 'restart') toast('Restarting the companion…');
340
- if (action === 'reload-config') toast('Reloaded config.');
341
- } catch (err) {
342
- toast(err.message || 'control failed', true);
343
- }
344
- }
345
-
346
- // Re-probe the harnesses now. The response carries the refreshed status;
347
- // the SSE status:changed also lands, so either updates the surface.
348
- async function recheckHarnesses() {
349
- try {
350
- const { status } = await postJSON('/api/harnesses/recheck');
351
- if (status) {
352
- state.status = status;
353
- render();
354
- }
355
- } catch (err) {
356
- toast(err.message || "couldn't recheck harnesses", true);
357
- }
358
- }
359
-
360
- // Enable/expose a config-driven harness. On success the refreshed status
361
- // re-renders (the row flips to exposed). A failure — most often an unreachable
362
- // opencode URL — surfaces as a toast in place; the config is left untouched.
363
-
364
- // ---- logs ----
365
-
366
- let logsTimer = null;
367
- async function loadLogs() {
368
- try {
369
- const { lines } = await getJSON('/api/logs?lines=200');
370
- const view = $('logs-view');
371
- view.textContent = lines.join('\n') || '(log is empty)';
372
- view.scrollTop = view.scrollHeight;
373
- } catch (err) {
374
- $('logs-view').textContent = `couldn't read logs: ${err.message}`;
375
- }
376
- }
377
-
378
- function setLogsOpen(open) {
379
- state.logsOpen = open;
380
- $('logs-panel').hidden = !open;
381
- clearInterval(logsTimer);
382
- if (open) {
383
- void loadLogs();
384
- logsTimer = setInterval(() => {
385
- if (!document.hidden) void loadLogs();
386
- }, 5000);
387
- }
388
- }
389
-
390
- // ---- settings ----
391
-
392
- function renderSettings() {
393
- const s = state.status;
394
- if (!s) return;
395
- $('set-url').textContent = s.cabane_url || '—';
396
- $('set-port').textContent = s.dashboard_url ? new URL(s.dashboard_url).port || '80' : '—';
397
- }
398
-
399
- // Flash a green ✓ next to the control that was just saved, then clear it.
400
- const savedTimers = new WeakMap();
401
- function flashSaved(checkEl) {
402
- if (!checkEl) return;
403
- checkEl.hidden = false;
404
- clearTimeout(savedTimers.get(checkEl));
405
- savedTimers.set(
406
- checkEl,
407
- setTimeout(() => {
408
- checkEl.hidden = true;
409
- }, 2500),
410
- );
411
- }
412
-
413
- async function saveSettings(patch, checkEl) {
414
- try {
415
- const { status } = await postJSON('/api/settings', patch);
416
- if (status) {
417
- state.status = status;
418
- render();
419
- }
420
- flashSaved(checkEl);
421
- } catch (err) {
422
- toast(err.message || "couldn't save setting", true);
423
- }
424
- }
425
-
426
- // ---- SSE wiring ----
427
-
428
- function connectSSE() {
429
- const es = new EventSource('/api/events');
430
-
431
- es.addEventListener('status:changed', (e) => {
432
- state.online = true;
433
- state.status = JSON.parse(e.data);
434
- render();
435
- renderSettings();
436
- });
437
-
438
- const upsertDispatch = (e) => {
439
- const rec = JSON.parse(e.data);
440
- const idx = state.dispatches.findIndex((d) => d.id === rec.id);
441
- if (idx >= 0) state.dispatches[idx] = rec;
442
- else state.dispatches.unshift(rec);
443
- if (state.dispatches.length > 50) state.dispatches.length = 50;
444
- renderDispatches();
445
- };
446
- es.addEventListener('dispatch:started', upsertDispatch);
447
- es.addEventListener('dispatch:completed', upsertDispatch);
448
- es.addEventListener('dispatch:error', upsertDispatch);
449
-
450
- // workspace:connected / workspace:disconnected still arrive when a local
451
- // `add` + reload-config changes the runner set; the accompanying
452
- // status:changed re-renders, so no extra handling is needed here.
453
-
454
- es.onerror = () => {
455
- // EventSource auto-reconnects; if the companion is actually gone the retries
456
- // keep failing and we show the offline state. readyState 2 = closed.
457
- if (es.readyState === EventSource.CLOSED || es.readyState === EventSource.CONNECTING) {
458
- state.online = false;
459
- renderStatus();
460
- }
461
- };
462
- es.onopen = () => {
463
- if (!state.online) {
464
- state.online = true;
465
- void boot();
466
- }
467
- };
468
- }
469
-
470
- // ---- boot ----
471
-
472
- async function boot() {
473
- try {
474
- state.status = await getJSON('/api/status');
475
- state.online = true;
476
- } catch {
477
- state.online = false;
478
- }
479
- try {
480
- const { dispatches } = await getJSON('/api/dispatches?limit=20');
481
- state.dispatches = dispatches;
482
- } catch {
483
- /* leave existing */
484
- }
485
- render();
486
- renderSettings();
487
- // Seed settings controls from config-derived status.
488
- const s = state.status;
489
- if (s) {
490
- $('set-loglevel').value = 'debug';
491
- // Auto-open defaults OFF, so the toggle seeds unchecked to match.
492
- $('set-autoopen').checked = false;
493
- }
494
- }
495
-
496
- function wireControls() {
497
- for (const btn of document.querySelectorAll('[data-toggle]')) {
498
- btn.addEventListener('click', () => {
499
- const id = btn.getAttribute('data-toggle');
500
- const panel = $(id);
501
- const open = panel.hidden;
502
- panel.hidden = !open;
503
- if (id === 'logs-panel') setLogsOpen(open);
504
- });
505
- }
506
- $('stop-btn').addEventListener('click', () => {
507
- if (window.confirm('Stop the companion? Replies will stop until you run it again.')) {
508
- void control('stop');
509
- }
510
- });
511
- $('restart-btn').addEventListener('click', () => void control('restart'));
512
- $('recheck-btn').addEventListener('click', () => void recheckHarnesses());
513
- $('set-loglevel').addEventListener('change', (e) => {
514
- void saveSettings({ logLevel: e.target.value }, $('check-loglevel'));
515
- void control('reload-config');
516
- });
517
- $('set-autoopen').addEventListener('change', (e) => {
518
- void saveSettings({ autoOpen: e.target.checked }, $('check-autoopen'));
519
- });
520
- }
521
-
522
- // Refresh relative timestamps + dot freshness every second without a network
523
- // round-trip.
524
- setInterval(() => {
525
- if (state.online && state.status) {
526
- renderStatus();
527
- renderConnected();
528
- }
529
- }, 1000);
530
-
531
- // Re-probe harnesses when the window regains focus (a user tabs away to
532
- // install/log into a harness, then back), throttled so rapid focus flaps don't
533
- // spam the probes. The heartbeat cadence covers the background case.
534
- let lastRecheck = 0;
535
- window.addEventListener('focus', () => {
536
- if (!state.online) return;
537
- const now = Date.now();
538
- if (now - lastRecheck < 5000) return;
539
- lastRecheck = now;
540
- void recheckHarnesses();
541
- });
542
-
543
- wireControls();
544
- void boot();
545
- connectSSE();