@cabane/companion 0.6.216 → 0.6.218
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/README.md +1 -1
- package/dist/cli.js +415 -2677
- package/dist/launch.js +3 -3
- package/dist/pairing-config.js +3 -3
- package/dist/runtime.js +718 -2941
- package/package.json +2 -4
- package/dist/static/app.js +0 -545
- package/dist/static/index.html +0 -122
- package/dist/static/styles.css +0 -531
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.218",
|
|
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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=22"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
|
-
"build": "tsup
|
|
32
|
+
"build": "tsup",
|
|
33
33
|
"dev": "tsx watch src/cli.ts",
|
|
34
34
|
"lint": "eslint .",
|
|
35
35
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -41,10 +41,8 @@
|
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "0.3.310",
|
|
42
42
|
"@clack/prompts": "1.8.1",
|
|
43
43
|
"@openai/codex-sdk": "0.164.0",
|
|
44
|
-
"@hono/node-server": "1.19.14",
|
|
45
44
|
"commander": "12.1.0",
|
|
46
45
|
"eventsource-parser": "3.0.8",
|
|
47
|
-
"hono": "4.12.18",
|
|
48
46
|
"graphql": "16.11.0",
|
|
49
47
|
"picocolors": "1.1.1",
|
|
50
48
|
"pino": "10.3.1",
|
package/dist/static/app.js
DELETED
|
@@ -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();
|
package/dist/static/index.html
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>Cabane Companion</title>
|
|
7
|
-
<link rel="stylesheet" href="/static/styles.css" />
|
|
8
|
-
<!-- the Cabane app-icon — ink tile, ember A-frame mark. Reads on a
|
|
9
|
-
light or dark browser tab. -->
|
|
10
|
-
<link
|
|
11
|
-
rel="icon"
|
|
12
|
-
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='112' ry='112' fill='%23141413'/><g fill='none' stroke='%23c8552a' stroke-width='2.52' stroke-linecap='round' transform='translate(116 151) scale(11.67)'><path d='M 1.26 16.74 L 12 1.26'/><path d='M 12 1.26 L 22.74 16.74'/></g></svg>"
|
|
13
|
-
/>
|
|
14
|
-
</head>
|
|
15
|
-
<body>
|
|
16
|
-
<main class="shell">
|
|
17
|
-
<header class="topbar">
|
|
18
|
-
<div class="brand">
|
|
19
|
-
<svg class="logo" viewBox="0 0 24 18" fill="none" aria-hidden="true">
|
|
20
|
-
<path
|
|
21
|
-
d="M 1.26 16.74 L 12 1.26"
|
|
22
|
-
stroke="currentColor"
|
|
23
|
-
stroke-width="2.52"
|
|
24
|
-
stroke-linecap="round"
|
|
25
|
-
/>
|
|
26
|
-
<path
|
|
27
|
-
d="M 12 1.26 L 22.74 16.74"
|
|
28
|
-
stroke="currentColor"
|
|
29
|
-
stroke-width="2.52"
|
|
30
|
-
stroke-linecap="round"
|
|
31
|
-
/>
|
|
32
|
-
</svg>
|
|
33
|
-
<span id="status-dot" class="dot dot--unknown" aria-hidden="true"></span>
|
|
34
|
-
<h1 id="status-title">Cabane Companion</h1>
|
|
35
|
-
</div>
|
|
36
|
-
<p id="conn-line" class="conn-line">Connecting…</p>
|
|
37
|
-
<p id="listen-line" class="listen-line"></p>
|
|
38
|
-
</header>
|
|
39
|
-
|
|
40
|
-
<!-- CT586: the harnesses this device exposes to Cabane. Primary panel — a
|
|
41
|
-
device exposing NONE is useless, so the loud empty state lives here. -->
|
|
42
|
-
<section class="panel" id="harnesses-panel">
|
|
43
|
-
<h2>
|
|
44
|
-
Harnesses
|
|
45
|
-
<button type="button" id="recheck-btn" class="link-btn" title="Re-check now">
|
|
46
|
-
Recheck
|
|
47
|
-
</button>
|
|
48
|
-
</h2>
|
|
49
|
-
<div id="harness-empty" class="harness-empty" hidden>
|
|
50
|
-
<p class="harness-empty-title">Cabane can’t run anything on this device yet.</p>
|
|
51
|
-
<p class="harness-empty-body">
|
|
52
|
-
Install and sign in to a harness — Claude Code, Codex, or opencode — then it’ll appear
|
|
53
|
-
here as ready. Until then, agents assigned to this device have nothing to run on.
|
|
54
|
-
</p>
|
|
55
|
-
</div>
|
|
56
|
-
<ul id="harness-list" class="harness-list"></ul>
|
|
57
|
-
<p id="harness-checking" class="empty">Checking harnesses…</p>
|
|
58
|
-
</section>
|
|
59
|
-
|
|
60
|
-
<section class="panel" id="connected-panel">
|
|
61
|
-
<h2>Connected workspaces <span id="connected-count" class="count"></span></h2>
|
|
62
|
-
<ul id="connected-list" class="ws-list"></ul>
|
|
63
|
-
<p id="connected-empty" class="empty" hidden>
|
|
64
|
-
No agents assigned yet. In a workspace, open an agent's page and choose this device under
|
|
65
|
-
Where they run.
|
|
66
|
-
</p>
|
|
67
|
-
</section>
|
|
68
|
-
|
|
69
|
-
<section class="panel" id="dispatches-panel">
|
|
70
|
-
<h2>Recent dispatches</h2>
|
|
71
|
-
<ul id="dispatch-list" class="dispatch-list"></ul>
|
|
72
|
-
<p id="dispatch-empty" class="empty" hidden>
|
|
73
|
-
No dispatches yet. Post a message in cabane and it'll show up here.
|
|
74
|
-
</p>
|
|
75
|
-
</section>
|
|
76
|
-
|
|
77
|
-
<footer class="actions">
|
|
78
|
-
<button type="button" data-toggle="logs-panel">Open logs</button>
|
|
79
|
-
<button type="button" data-toggle="settings-panel">Settings</button>
|
|
80
|
-
<button type="button" id="restart-btn" class="ghost">Restart</button>
|
|
81
|
-
<button type="button" id="stop-btn" class="ghost danger">Stop</button>
|
|
82
|
-
</footer>
|
|
83
|
-
|
|
84
|
-
<section class="panel collapsible" id="logs-panel" hidden>
|
|
85
|
-
<h2>Logs <span class="hint">~/.cabane/companion.log</span></h2>
|
|
86
|
-
<pre id="logs-view" class="logs">Loading…</pre>
|
|
87
|
-
</section>
|
|
88
|
-
|
|
89
|
-
<section class="panel collapsible" id="settings-panel" hidden>
|
|
90
|
-
<h2>Settings</h2>
|
|
91
|
-
<div class="setting">
|
|
92
|
-
<label>Cabane URL</label>
|
|
93
|
-
<span id="set-url" class="readonly">—</span>
|
|
94
|
-
</div>
|
|
95
|
-
<div class="setting">
|
|
96
|
-
<label>Dashboard port</label>
|
|
97
|
-
<span id="set-port" class="readonly">—</span>
|
|
98
|
-
<span class="hint">change in ~/.cabane/config.json (needs restart)</span>
|
|
99
|
-
</div>
|
|
100
|
-
<div class="setting">
|
|
101
|
-
<label for="set-loglevel">Log level</label>
|
|
102
|
-
<select id="set-loglevel">
|
|
103
|
-
<option value="warn">warn</option>
|
|
104
|
-
<option value="info">info</option>
|
|
105
|
-
<option value="debug">debug</option>
|
|
106
|
-
</select>
|
|
107
|
-
<span id="check-loglevel" class="saved-check" hidden>✓</span>
|
|
108
|
-
</div>
|
|
109
|
-
<div class="setting">
|
|
110
|
-
<label for="set-autoopen">Auto-open browser on start</label>
|
|
111
|
-
<input type="checkbox" id="set-autoopen" />
|
|
112
|
-
<span id="check-autoopen" class="saved-check" hidden>✓</span>
|
|
113
|
-
</div>
|
|
114
|
-
</section>
|
|
115
|
-
|
|
116
|
-
<p class="version">companion <span id="ver-companion">—</span></p>
|
|
117
|
-
</main>
|
|
118
|
-
|
|
119
|
-
<div id="toast" class="toast" hidden></div>
|
|
120
|
-
<script src="/static/app.js"></script>
|
|
121
|
-
</body>
|
|
122
|
-
</html>
|