@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/README.md +18 -0
- package/dist/cli.js +448 -2670
- package/dist/credential-broker.js +653 -0
- package/dist/launch.js +7 -4
- package/dist/pairing-config.js +7 -4
- package/dist/runtime.js +770 -2955
- package/package.json +11 -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.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
|
|
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
|
-
"
|
|
46
|
+
"graphql": "16.11.0",
|
|
40
47
|
"picocolors": "1.1.1",
|
|
41
48
|
"pino": "10.3.1",
|
|
42
49
|
"smol-toml": "1.8.0",
|
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();
|