claude-code-kanban 4.31.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +16 -5
- package/hub-app.json +25 -0
- package/lib/net-guard.js +6 -1
- package/lib/parsers.js +21 -12
- package/lib/terminal.js +73 -8
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +12 -0
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +10 -3
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +13 -2
- package/public/app.js +683 -373
- package/public/index.html +31 -4
- package/public/style.css +149 -9
- package/public/vendor/claude-hub-sdk.js +50 -0
- package/server.js +89 -28
package/cli.js
CHANGED
|
@@ -427,14 +427,25 @@ function unreachable() {
|
|
|
427
427
|
|
|
428
428
|
class CliUnreachable extends Error { constructor() { super(unreachable()); this.code = 'unreachable'; } }
|
|
429
429
|
|
|
430
|
+
// Windows sometimes fails a loopback connect with ETIMEDOUT after ~300 ms when many
|
|
431
|
+
// connects run at once. Nothing reached the server, so even a POST is safe to send again.
|
|
432
|
+
const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
|
|
433
|
+
const isConnectTimeout = (e) => e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect';
|
|
434
|
+
|
|
430
435
|
async function cliFetch(urlPath, init) {
|
|
431
436
|
const base = cliBaseUrl();
|
|
432
437
|
if (!base) throw new CliUnreachable();
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
+
for (let i = 0; ; i++) {
|
|
439
|
+
try {
|
|
440
|
+
return await fetch(`${base}${urlPath}`, init);
|
|
441
|
+
} catch (e) {
|
|
442
|
+
if (isConnectTimeout(e) && i < CONNECT_RETRY_MS.length) {
|
|
443
|
+
await new Promise((r) => setTimeout(r, CONNECT_RETRY_MS[i]));
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (e.cause?.code === 'ECONNREFUSED' || /fetch failed/i.test(e.message)) throw new CliUnreachable();
|
|
447
|
+
throw e;
|
|
448
|
+
}
|
|
438
449
|
}
|
|
439
450
|
}
|
|
440
451
|
|
package/hub-app.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest": 1,
|
|
3
|
+
"id": "kanban",
|
|
4
|
+
"name": "Kanban",
|
|
5
|
+
"icon": "columns",
|
|
6
|
+
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
|
+
"publishes": ["session.changed"],
|
|
8
|
+
"provides": {
|
|
9
|
+
"projects": { "path": "/api/projects" },
|
|
10
|
+
"terminal": { "liveWork": "/api/terminals" }
|
|
11
|
+
},
|
|
12
|
+
"loading": {
|
|
13
|
+
"verbs": [
|
|
14
|
+
"Shuffling cards…",
|
|
15
|
+
"Herding tasks…",
|
|
16
|
+
"Waking sessions…",
|
|
17
|
+
"Sorting the board…",
|
|
18
|
+
"Pinning sticky notes…",
|
|
19
|
+
"Moving cards to done…",
|
|
20
|
+
"Nudging agents…",
|
|
21
|
+
"Drawing columns…",
|
|
22
|
+
"Reading the backlog…"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/net-guard.js
CHANGED
|
@@ -36,6 +36,7 @@ const PORT_RE = /^[0-9]{1,5}$/;
|
|
|
36
36
|
// The port is validated but not compared — the listen(0) fallbacks make the real
|
|
37
37
|
// port dynamic, so only the host is allowlisted.
|
|
38
38
|
const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
|
|
39
|
+
const KEEP_ALIVE_MS = 65000;
|
|
39
40
|
|
|
40
41
|
function isLoopbackAddress(host) {
|
|
41
42
|
if (!host) return false;
|
|
@@ -249,6 +250,10 @@ function createNetGuard(config = {}) {
|
|
|
249
250
|
const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
|
|
250
251
|
const makeServer = (handler) => {
|
|
251
252
|
const s = http.createServer(serverOpts, handler);
|
|
253
|
+
// Node's 5 s default closes idle sockets that the hub proxy and the browser would reuse, and a
|
|
254
|
+
// fresh loopback connect is the one that some Windows network filters drop. The hub's agent
|
|
255
|
+
// drops its idle sockets before this, so it never reuses one the server is closing.
|
|
256
|
+
s.keepAliveTimeout = KEEP_ALIVE_MS;
|
|
252
257
|
if (opts.onUpgrade) s.on('upgrade', opts.onUpgrade);
|
|
253
258
|
return s;
|
|
254
259
|
};
|
|
@@ -281,4 +286,4 @@ function createNetGuard(config = {}) {
|
|
|
281
286
|
return { BIND_HOST, ALLOWED_HOSTS, EXPOSED, hostGuard, originGuard, upgradeVerdict, frameGuard, listenLoopback, exposureWarning };
|
|
282
287
|
}
|
|
283
288
|
|
|
284
|
-
module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
|
|
289
|
+
module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress, KEEP_ALIVE_MS };
|
package/lib/parsers.js
CHANGED
|
@@ -1622,11 +1622,22 @@ function formatStructuredResult(input) {
|
|
|
1622
1622
|
return parts.length ? parts.join('\n\n') : null;
|
|
1623
1623
|
}
|
|
1624
1624
|
|
|
1625
|
-
//
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1629
|
-
|
|
1625
|
+
// Some subagents end their run on a tool call and never emit a text response, so
|
|
1626
|
+
// their lastMessage is empty: workflow subagents given a schema call StructuredOutput,
|
|
1627
|
+
// background subagents report through SubagentHandback. Tail-read the transcript and
|
|
1628
|
+
// take the LAST such tool_use as the agent's result. Returns null when absent.
|
|
1629
|
+
const RESULT_TOOLS = {
|
|
1630
|
+
StructuredOutput: formatStructuredResult,
|
|
1631
|
+
SubagentHandback: (input) => (typeof input.message === 'string' && input.message) || null,
|
|
1632
|
+
};
|
|
1633
|
+
const RESULT_TOOL_TEXT = Object.keys(RESULT_TOOLS).map((name) => `"${name}"`);
|
|
1634
|
+
|
|
1635
|
+
function resultFromBlock(b) {
|
|
1636
|
+
if (b?.type !== 'tool_use' || !b.input || typeof b.input !== 'object') return null;
|
|
1637
|
+
return RESULT_TOOLS[b.name]?.(b.input) ?? null;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function extractAgentResultFromTranscript(jsonlPath) {
|
|
1630
1641
|
const { openSync, readSync, closeSync } = fs;
|
|
1631
1642
|
const stat = statSync(jsonlPath);
|
|
1632
1643
|
const readSize = Math.min(1048576, stat.size);
|
|
@@ -1638,17 +1649,15 @@ function extractStructuredResultFromTranscript(jsonlPath) {
|
|
|
1638
1649
|
const text = buf.toString('utf8');
|
|
1639
1650
|
// Drop the leading partial line unless we read from the very start.
|
|
1640
1651
|
const clean = start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
|
|
1641
|
-
if (!clean.includes(
|
|
1642
|
-
// Scan from the end for the last StructuredOutput tool call.
|
|
1652
|
+
if (!RESULT_TOOL_TEXT.some((t) => clean.includes(t))) return null;
|
|
1643
1653
|
const lines = clean.split('\n');
|
|
1644
1654
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1645
|
-
if (!lines[i].includes(
|
|
1655
|
+
if (!RESULT_TOOL_TEXT.some((t) => lines[i].includes(t))) continue;
|
|
1646
1656
|
let obj;
|
|
1647
1657
|
try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
|
|
1648
1658
|
const content = obj?.message?.content;
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
if (block) return formatStructuredResult(block.input);
|
|
1659
|
+
const result = Array.isArray(content) && content.map(resultFromBlock).findLast(Boolean);
|
|
1660
|
+
if (result) return result;
|
|
1652
1661
|
}
|
|
1653
1662
|
} catch (_) {
|
|
1654
1663
|
} finally {
|
|
@@ -1821,6 +1830,6 @@ module.exports = {
|
|
|
1821
1830
|
findTerminatedTeammates,
|
|
1822
1831
|
extractPromptFromTranscript,
|
|
1823
1832
|
extractModelFromTranscript,
|
|
1824
|
-
|
|
1833
|
+
extractAgentResultFromTranscript,
|
|
1825
1834
|
extractTranscriptStats
|
|
1826
1835
|
};
|
package/lib/terminal.js
CHANGED
|
@@ -35,6 +35,16 @@ const LOW_WATER = 16 * 1024;
|
|
|
35
35
|
const HELLO_TIMEOUT_MS = 5000;
|
|
36
36
|
const BOOST_TRIES = 30;
|
|
37
37
|
const MAX_PAYLOAD = 1024 * 1024;
|
|
38
|
+
// Each restored terminal is a full claude process, so they start one at a time.
|
|
39
|
+
const RESTORE_GAP_MS = 2000;
|
|
40
|
+
// The hub stops cck with `taskkill /f /t`, which can deliver the PTY exits to cck a moment
|
|
41
|
+
// before cck itself dies. The delay keeps that teardown out of the saved list.
|
|
42
|
+
const SAVE_DELAY_MS = 1000;
|
|
43
|
+
// A fork runs under its parent's id until claude names the new one, so it has no id to resume.
|
|
44
|
+
const RESTORABLE_MODES = new Set(['resume', 'new']);
|
|
45
|
+
// The size of a PTY no browser has attached to yet; the first attach resizes it.
|
|
46
|
+
const HEADLESS_COLS = 120;
|
|
47
|
+
const HEADLESS_ROWS = 32;
|
|
38
48
|
// The hub hands its children PORT=0 and friends; a dev server started in the terminal
|
|
39
49
|
// would otherwise bind a random port, and a nested `claude` would think it runs inside one.
|
|
40
50
|
const STRIP_ENV = [
|
|
@@ -64,11 +74,12 @@ function readTerminalConfig({ argv = process.argv, env = process.env, getArgValu
|
|
|
64
74
|
env.CCK_TERMINAL_SHELL ||
|
|
65
75
|
(typeof cfg.shell === 'string' && cfg.shell) ||
|
|
66
76
|
null,
|
|
67
|
-
maxSessions: posInt(cfg.maxSessions,
|
|
77
|
+
maxSessions: posInt(cfg.maxSessions, 30),
|
|
68
78
|
fontFamily: typeof cfg.fontFamily === 'string' && cfg.fontFamily ? cfg.fontFamily : null,
|
|
69
79
|
fontSize: posInt(cfg.fontSize, 13),
|
|
70
80
|
scrollback: posInt(cfg.scrollback, 5000),
|
|
71
81
|
noFlicker: cfg.noFlicker !== false,
|
|
82
|
+
restore: cfg.restore !== false,
|
|
72
83
|
};
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -228,6 +239,9 @@ function refuse(socket, status, reason) {
|
|
|
228
239
|
* @param {(cmd: string) => string|null} o.which
|
|
229
240
|
* @param {string} [o.token]
|
|
230
241
|
* @param {() => void} [o.onChange] a terminal started, ended or moved to another session id
|
|
242
|
+
* @param {() => {sessions?: string[]}|null} [o.load] the saved list, read when config.restore is on
|
|
243
|
+
* @param {(data: {sessions: string[]}) => void} [o.save] the session ids to resume on the next start
|
|
244
|
+
* @param {object} [o.pty] stands in for @lydell/node-pty in tests
|
|
231
245
|
*/
|
|
232
246
|
function createTerminalService(o) {
|
|
233
247
|
const { config, net } = o;
|
|
@@ -240,6 +254,14 @@ function createTerminalService(o) {
|
|
|
240
254
|
let Headless = null;
|
|
241
255
|
let Serialize = null;
|
|
242
256
|
let shell = null;
|
|
257
|
+
let shuttingDown = false;
|
|
258
|
+
let saveTimer = null;
|
|
259
|
+
let lastSaved = null;
|
|
260
|
+
const saveDelayMs = o.saveDelayMs ?? SAVE_DELAY_MS;
|
|
261
|
+
const restoreGapMs = o.restoreGapMs ?? RESTORE_GAP_MS;
|
|
262
|
+
// Read before any terminal starts, so the first save keeps the ids still waiting to restore.
|
|
263
|
+
const saved = config.restore ? o.load?.()?.sessions : null;
|
|
264
|
+
const restoreQueue = Array.isArray(saved) ? saved.filter((id) => typeof id === 'string' && UUID_RE.test(id)) : [];
|
|
243
265
|
|
|
244
266
|
// Lazy and optional: a missing prebuilt or shell must hide the feature, not crash cck.
|
|
245
267
|
function load() {
|
|
@@ -251,7 +273,7 @@ function createTerminalService(o) {
|
|
|
251
273
|
return false;
|
|
252
274
|
}
|
|
253
275
|
try {
|
|
254
|
-
pty = require('@lydell/node-pty');
|
|
276
|
+
pty = o.pty || require('@lydell/node-pty');
|
|
255
277
|
({ Terminal: Headless } = require('@xterm/headless'));
|
|
256
278
|
({ SerializeAddon: Serialize } = require('@xterm/addon-serialize'));
|
|
257
279
|
const { WebSocketServer } = require('ws');
|
|
@@ -270,6 +292,47 @@ function createTerminalService(o) {
|
|
|
270
292
|
return null;
|
|
271
293
|
}
|
|
272
294
|
|
|
295
|
+
function changed() {
|
|
296
|
+
scheduleSave();
|
|
297
|
+
o.onChange?.();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function scheduleSave() {
|
|
301
|
+
if (!o.save || shuttingDown || saveTimer) return;
|
|
302
|
+
saveTimer = setTimeout(saveState, saveDelayMs);
|
|
303
|
+
saveTimer.unref();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function saveState() {
|
|
307
|
+
saveTimer = null;
|
|
308
|
+
const ids = new Set(restoreQueue);
|
|
309
|
+
for (const s of sessions.values()) if (!s.ended && RESTORABLE_MODES.has(s.mode)) ids.add(s.id);
|
|
310
|
+
const data = { sessions: [...ids] };
|
|
311
|
+
const json = JSON.stringify(data);
|
|
312
|
+
if (json === lastSaved) return;
|
|
313
|
+
lastSaved = json;
|
|
314
|
+
o.save(data);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Starts `claude --resume` for each terminal open when cck last stopped, headless like
|
|
318
|
+
// startNew. A session that runs elsewhere or lost its transcript is dropped.
|
|
319
|
+
function restore() {
|
|
320
|
+
if (!restoreQueue.length || unavailableReason()) return;
|
|
321
|
+
console.log(`Restoring ${restoreQueue.length} terminal(s)`);
|
|
322
|
+
const next = () => {
|
|
323
|
+
while (restoreQueue.length && !shuttingDown) {
|
|
324
|
+
const id = restoreQueue.shift();
|
|
325
|
+
if (sessions.has(id) || o.isLiveElsewhere(id)) continue;
|
|
326
|
+
const r = start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
|
|
327
|
+
if (!r.error) break;
|
|
328
|
+
console.log(`Could not restore terminal ${id}: ${r.error}`);
|
|
329
|
+
}
|
|
330
|
+
scheduleSave();
|
|
331
|
+
if (restoreQueue.length && !shuttingDown) setTimeout(next, restoreGapMs).unref();
|
|
332
|
+
};
|
|
333
|
+
next();
|
|
334
|
+
}
|
|
335
|
+
|
|
273
336
|
function send(ws, msg) {
|
|
274
337
|
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
|
275
338
|
}
|
|
@@ -383,7 +446,7 @@ function createTerminalService(o) {
|
|
|
383
446
|
clearInterval(s.boostTimer);
|
|
384
447
|
if (sessions.get(s.id) === s) sessions.delete(s.id);
|
|
385
448
|
o.onExit?.(s.id);
|
|
386
|
-
if (!s.ended)
|
|
449
|
+
if (!s.ended) changed();
|
|
387
450
|
for (const ws of s.sockets) {
|
|
388
451
|
send(ws, { t: 'exit', code: exitCode, ended: s.ended });
|
|
389
452
|
ws.close(1000);
|
|
@@ -392,7 +455,7 @@ function createTerminalService(o) {
|
|
|
392
455
|
});
|
|
393
456
|
sessions.set(id, s);
|
|
394
457
|
priority.raiseConsoleHosts();
|
|
395
|
-
|
|
458
|
+
changed();
|
|
396
459
|
return s;
|
|
397
460
|
}
|
|
398
461
|
|
|
@@ -428,7 +491,7 @@ function createTerminalService(o) {
|
|
|
428
491
|
s.id = picked;
|
|
429
492
|
s.mode = 'resume';
|
|
430
493
|
sessions.set(picked, s);
|
|
431
|
-
|
|
494
|
+
changed();
|
|
432
495
|
for (const ws of s.sockets) send(ws, { t: 'rekey', id: picked, elsewhere });
|
|
433
496
|
}, PICK_POLL_MS);
|
|
434
497
|
}
|
|
@@ -554,7 +617,7 @@ function createTerminalService(o) {
|
|
|
554
617
|
function kill(s) {
|
|
555
618
|
s.ended = true;
|
|
556
619
|
s.pty.kill();
|
|
557
|
-
|
|
620
|
+
changed();
|
|
558
621
|
}
|
|
559
622
|
|
|
560
623
|
function list() {
|
|
@@ -587,18 +650,20 @@ function createTerminalService(o) {
|
|
|
587
650
|
function startNew(msg, extraEnv) {
|
|
588
651
|
const reason = unavailableReason();
|
|
589
652
|
if (reason) return { status: 403, error: reason };
|
|
590
|
-
const r = start(crypto.randomUUID(), 'new', msg,
|
|
653
|
+
const r = start(crypto.randomUUID(), 'new', msg, HEADLESS_COLS, HEADLESS_ROWS, extraEnv);
|
|
591
654
|
return r.error ? r : { id: r.session.id, cwd: r.session.cwd };
|
|
592
655
|
}
|
|
593
656
|
|
|
594
657
|
function shutdown() {
|
|
658
|
+
shuttingDown = true;
|
|
659
|
+
clearTimeout(saveTimer);
|
|
595
660
|
for (const s of sessions.values()) {
|
|
596
661
|
try { s.pty.kill(); } catch { /* already gone */ }
|
|
597
662
|
}
|
|
598
663
|
sessions.clear();
|
|
599
664
|
}
|
|
600
665
|
|
|
601
|
-
return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, shutdown, unavailableReason };
|
|
666
|
+
return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, restore, shutdown, unavailableReason };
|
|
602
667
|
}
|
|
603
668
|
|
|
604
669
|
module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"files": [
|
|
54
54
|
"server.js",
|
|
55
|
+
"hub-app.json",
|
|
55
56
|
"cli.js",
|
|
56
57
|
"install.js",
|
|
57
58
|
"plugin/**/*",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.1",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -37,10 +37,17 @@ if [ "$EVENT" = "SessionStart" ]; then
|
|
|
37
37
|
exit 0
|
|
38
38
|
fi
|
|
39
39
|
|
|
40
|
-
#
|
|
40
|
+
# Stop: the session finished its turn. The marker means unread; the board deletes it on open.
|
|
41
|
+
if [ "$EVENT" = "Stop" ]; then
|
|
42
|
+
DIR="$CCK_ACTIVITY/$SESSION_ID"
|
|
43
|
+
[ -d "$DIR" ] || mkdir -p "$DIR"
|
|
44
|
+
: > "$DIR/_stop.json"
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# PostToolUse / non-waiting PreToolUse: clear waiting state, and unread since the session is working again
|
|
41
49
|
if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ] && [ "$TOOL_NAME" != "ExitPlanMode" ]; }; then
|
|
42
|
-
|
|
43
|
-
rm -f "$WFILE"
|
|
50
|
+
rm -f "$CCK_ACTIVITY/$SESSION_ID/_waiting.json" "$CCK_ACTIVITY/$SESSION_ID/_stop.json"
|
|
44
51
|
[ "$EVENT" = "PostToolUse" ] && exit 0
|
|
45
52
|
fi
|
|
46
53
|
|
|
@@ -26,6 +26,9 @@ const SERVER_INFO = path.join(CLAUDE_DIR, '.cck', 'server.json');
|
|
|
26
26
|
// because an enqueue wakes the poll immediately.
|
|
27
27
|
const WAIT_SEC = 120;
|
|
28
28
|
const RETRY_MS = 15000;
|
|
29
|
+
// Windows sometimes fails a loopback connect with ETIMEDOUT while the board is up, so that
|
|
30
|
+
// error gets a few short waits before the normal one.
|
|
31
|
+
const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
|
|
29
32
|
// `--topic dispatch` is the kanban-dispatch inbox: reports from sessions this one started.
|
|
30
33
|
const TOPIC = process.argv.includes('--topic') ? process.argv[process.argv.indexOf('--topic') + 1] : null;
|
|
31
34
|
// A dispatch report is a result, not an instruction, so a late attach still wants it.
|
|
@@ -64,13 +67,21 @@ async function poll(base) {
|
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
(async () => {
|
|
70
|
+
let connectRetries = 0;
|
|
67
71
|
for (;;) {
|
|
68
72
|
try {
|
|
69
73
|
for (const line of await poll(serverUrl())) console.log(line);
|
|
70
|
-
|
|
74
|
+
connectRetries = 0;
|
|
75
|
+
} catch (e) {
|
|
71
76
|
// No board yet, or it went away. It may come back later in the session, so keep
|
|
72
77
|
// waiting quietly -- a missing server is the normal case, not an error.
|
|
73
|
-
|
|
78
|
+
const timedOut = e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect';
|
|
79
|
+
if (timedOut && connectRetries < CONNECT_RETRY_MS.length) {
|
|
80
|
+
await sleep(CONNECT_RETRY_MS[connectRetries++]);
|
|
81
|
+
} else {
|
|
82
|
+
connectRetries = 0;
|
|
83
|
+
await sleep(RETRY_MS);
|
|
84
|
+
}
|
|
74
85
|
}
|
|
75
86
|
}
|
|
76
87
|
})();
|