thinkpool-pair 0.7.28 → 0.7.30
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/account.mjs +30 -13
- package/bridge.mjs +98 -6
- package/claude-session.mjs +34 -6
- package/launcher.mjs +184 -0
- package/package.json +3 -1
- package/service.mjs +6 -2
- package/update-gate.mjs +31 -0
package/account.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import fs from 'node:fs'
|
|
|
14
14
|
import { createClient } from '@supabase/supabase-js'
|
|
15
15
|
import os from 'node:os'
|
|
16
16
|
import { saveAuth, loadAuth, loadDirs, bindDir } from './auth-store.mjs'
|
|
17
|
+
import { isSafeToRestart } from './update-gate.mjs'
|
|
17
18
|
|
|
18
19
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
19
20
|
|
|
@@ -208,6 +209,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
208
209
|
|
|
209
210
|
const children = new Map() // room -> child process
|
|
210
211
|
const childIdle = new Map() // room -> bool (last idle report; unknown = not idle)
|
|
212
|
+
const childPeer = new Map() // room -> bool (a web client is watching this room)
|
|
213
|
+
let applyRequested = false // a user clicked "apply" in some room (Slice 3)
|
|
214
|
+
let pendingUpdate = null // newest published version once the poll sees it
|
|
211
215
|
const warned = new Set()
|
|
212
216
|
|
|
213
217
|
// Account presence — so the web dashboard can show "● Bridge active" for this
|
|
@@ -277,8 +281,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
277
281
|
delete env.THINKPOOL_PAIR_AUTOUPDATE
|
|
278
282
|
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
279
283
|
children.set(room, child)
|
|
280
|
-
|
|
281
|
-
|
|
284
|
+
childIdle.set(room, false) // unknown until it reports → never restart a fresh child
|
|
285
|
+
childPeer.set(room, false)
|
|
286
|
+
child.on('message', (m) => {
|
|
287
|
+
if (!m) return
|
|
288
|
+
if (m.t === 'idle') { childIdle.set(room, !!m.idle); childPeer.set(room, !!m.webPeer) }
|
|
289
|
+
else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
|
|
290
|
+
})
|
|
291
|
+
child.on('exit', () => { children.delete(room); childIdle.delete(room); childPeer.delete(room) }) // re-served on the next tick
|
|
292
|
+
// If an update is already pending, tell the freshly-served child immediately so
|
|
293
|
+
// its room shows the "update ready" chip without waiting for the next poll.
|
|
294
|
+
if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
|
|
282
295
|
}
|
|
283
296
|
pushPresence()
|
|
284
297
|
}
|
|
@@ -299,6 +312,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
299
312
|
}
|
|
300
313
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
|
|
301
314
|
|
|
315
|
+
// ── Slice 3 apply-gate (function scope so the child-IPC handler can call it) ──
|
|
316
|
+
// Restart the account bridge to apply a pending update ONLY when it's safe: every
|
|
317
|
+
// child idle (between turns) AND either a user clicked apply OR nobody's watching
|
|
318
|
+
// (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
|
|
319
|
+
const applyIfIdle = () => {
|
|
320
|
+
if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childPeer, requested: applyRequested })) return
|
|
321
|
+
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
|
|
322
|
+
stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
|
|
323
|
+
}
|
|
324
|
+
|
|
302
325
|
// ── auto-update (account-service tier only) ───────────────────────────────
|
|
303
326
|
// launchd (KeepAlive) / systemd (Restart=always) re-run `npx -y
|
|
304
327
|
// thinkpool-pair@latest` (no room) on every restart, so a clean exit here
|
|
@@ -312,7 +335,6 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
312
335
|
// THINKPOOL_PAIR_UPDATE_IDLE child idle seconds before a restart (default 90)
|
|
313
336
|
if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
314
337
|
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
315
|
-
let pendingUpdate = null
|
|
316
338
|
// Strictly-newer x.y.z core; ignore prerelease tails (never downgrade/churn).
|
|
317
339
|
const isNewer = (latest, cur) => {
|
|
318
340
|
const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
|
|
@@ -330,20 +352,15 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
330
352
|
return typeof j?.version === 'string' ? j.version : null
|
|
331
353
|
} catch { return null } finally { clearTimeout(to) }
|
|
332
354
|
}
|
|
333
|
-
// Idle iff every served child has reported idle. No children → trivially idle.
|
|
334
|
-
// Unknown (no report yet) counts as NOT idle, so we never cycle a fresh child.
|
|
335
|
-
const allIdle = () => { for (const room of children.keys()) if (!childIdle.get(room)) return false; return true }
|
|
336
|
-
const applyIfIdle = () => {
|
|
337
|
-
if (!pendingUpdate || stopping || !allIdle()) return
|
|
338
|
-
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
|
|
339
|
-
stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
|
|
340
|
-
}
|
|
341
355
|
const check = async () => {
|
|
342
356
|
const latest = await fetchLatest()
|
|
343
357
|
if (latest && isNewer(latest, VERSION)) {
|
|
344
|
-
if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published —
|
|
358
|
+
if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — "update ready" surfaced to each room; applies between turns.\n`)
|
|
345
359
|
pendingUpdate = latest
|
|
346
|
-
|
|
360
|
+
// Tell every served child so its room shows the chip (children never self-update;
|
|
361
|
+
// only this supervisor restart re-resolves @latest). Slice 3 nudge.
|
|
362
|
+
for (const c of children.values()) { try { c.send({ t: 'update-available', version: latest }) } catch { /* child not ready */ } }
|
|
363
|
+
applyIfIdle() // unattended fallback fires here when no room has a web peer
|
|
347
364
|
}
|
|
348
365
|
}
|
|
349
366
|
setInterval(check, POLL_MS).unref()
|
package/bridge.mjs
CHANGED
|
@@ -33,9 +33,11 @@ import fs from 'node:fs'
|
|
|
33
33
|
import path from 'node:path'
|
|
34
34
|
import readline from 'node:readline'
|
|
35
35
|
import { spawn } from 'node:child_process'
|
|
36
|
+
import { fileURLToPath } from 'node:url'
|
|
36
37
|
import { randomUUID } from 'node:crypto'
|
|
37
38
|
import { createClient } from '@supabase/supabase-js'
|
|
38
39
|
import { startClaudeSession } from './claude-session.mjs'
|
|
40
|
+
import { turnInFlight } from './update-gate.mjs'
|
|
39
41
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
|
|
40
42
|
import { stampEvent } from './event-id.mjs'
|
|
41
43
|
|
|
@@ -154,6 +156,42 @@ applyProviderEnv()
|
|
|
154
156
|
if (argv[0] === 'login') { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) }
|
|
155
157
|
if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); runBind(argv[1], argv[2]) }
|
|
156
158
|
if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
|
|
159
|
+
|
|
160
|
+
// One-command launcher: a bare invocation on a TTY (or the `setup` alias) opens the
|
|
161
|
+
// interactive menu instead of going straight to account mode. The menu only ASSEMBLES
|
|
162
|
+
// existing commands — it re-execs `<ROOM>`, or calls installService / runLogin /
|
|
163
|
+
// runProvider / runAccount directly. Non-TTY bare stays account-mode (the line below),
|
|
164
|
+
// so launchd / systemd / CI / pipes are byte-for-byte unchanged.
|
|
165
|
+
// Spec: docs/specs/2026-06-19-bridge-unified-launcher.md (Part 1).
|
|
166
|
+
if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
167
|
+
const { runLauncher } = await import('./launcher.mjs')
|
|
168
|
+
const svc = await import('./service.mjs')
|
|
169
|
+
const BRIDGE = fileURLToPath(import.meta.url)
|
|
170
|
+
const io = {
|
|
171
|
+
print: (s) => process.stderr.write(s + '\n'),
|
|
172
|
+
ask: (q) => new Promise((res) => {
|
|
173
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
174
|
+
rl.question(q, (a) => { rl.close(); res(String(a).trim()) })
|
|
175
|
+
}),
|
|
176
|
+
}
|
|
177
|
+
const actions = {
|
|
178
|
+
// Re-exec the bridge with the assembled argv; the child owns the terminal and the
|
|
179
|
+
// parent waits, exiting with the child's code (so Ctrl-C / exit behave normally).
|
|
180
|
+
pairRoom: ({ room, agentCmd, resumeArgs = [], headless }) => new Promise(() => {
|
|
181
|
+
const args = [BRIDGE, room, ...(headless ? ['--headless'] : []), '--', agentCmd, ...resumeArgs]
|
|
182
|
+
const child = spawn(process.execPath, args, { stdio: 'inherit' })
|
|
183
|
+
child.on('exit', (code) => process.exit(code == null ? 0 : code))
|
|
184
|
+
}),
|
|
185
|
+
serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
|
|
186
|
+
installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []); process.exit(0) },
|
|
187
|
+
uninstallService: ({ room = null } = {}) => { svc.uninstallService(room); process.exit(0) },
|
|
188
|
+
login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
|
|
189
|
+
setProvider: async ({ kind, base, token }) => { const { runProvider } = await import('./provider.mjs'); await runProvider(kind === 'custom' ? ['custom', '--base', base, '--token', token] : ['anthropic']); process.exit(0) },
|
|
190
|
+
}
|
|
191
|
+
await runLauncher({ actions, io })
|
|
192
|
+
process.exit(0)
|
|
193
|
+
}
|
|
194
|
+
|
|
157
195
|
if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
|
|
158
196
|
|
|
159
197
|
const room = (argv[0] || '').toUpperCase().trim()
|
|
@@ -329,6 +367,23 @@ let shuttingDown = false
|
|
|
329
367
|
let lastActivity = Date.now()
|
|
330
368
|
const markActivity = () => { lastActivity = Date.now() }
|
|
331
369
|
|
|
370
|
+
// ── Slice 3: update-ready nudge state ──────────────────────────────
|
|
371
|
+
// The newest published version once the update poll (service tier) or the account
|
|
372
|
+
// supervisor (via IPC) reports one is out. Surfaced to the room as the "update
|
|
373
|
+
// ready" chip; the restart is gated to between turns (never mid-turn, Contract #1).
|
|
374
|
+
let pendingUpdate = null
|
|
375
|
+
let applyRequested = false // a user clicked "apply" in the room
|
|
376
|
+
// A web client (not another bridge) is present — checks presence metas for a
|
|
377
|
+
// non-bridge role. Gates the unattended auto-restart: when someone's watching we
|
|
378
|
+
// wait for their apply click instead of restarting under them.
|
|
379
|
+
const webPeerPresent = () => {
|
|
380
|
+
try {
|
|
381
|
+
const st = channel.presenceState() || {}
|
|
382
|
+
for (const metas of Object.values(st)) for (const m of (metas || [])) if (m && m.role !== 'bridge') return true
|
|
383
|
+
} catch { /* noop */ }
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
|
|
332
387
|
const bcast = (event, payload) => {
|
|
333
388
|
if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
|
|
334
389
|
try {
|
|
@@ -351,6 +406,9 @@ const announce = () =>
|
|
|
351
406
|
// cwd + version: the host's working dir + thinkpool-pair version, shown in
|
|
352
407
|
// the room's welcome banner. Re-sent per announce so late joiners get them.
|
|
353
408
|
cwd, version: VERSION,
|
|
409
|
+
// pendingUpdate: a newer thinkpool-pair is out — drives the room's "update ready"
|
|
410
|
+
// chip; re-sent per announce so reloads/late-joiners see it (Slice 3).
|
|
411
|
+
pendingUpdate,
|
|
354
412
|
// updir: where room file-drops land (forward-slash normalised — the web
|
|
355
413
|
// client string-joins host paths onto it; Node accepts `/` on Windows).
|
|
356
414
|
updir: UPDIR.split(path.sep).join('/'),
|
|
@@ -602,7 +660,10 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
602
660
|
if (evt.kind === 'system' && Array.isArray(evt.commands) && evt.commands.length && !entry.commands) { entry.commands = evt.commands; announce(); persist() }
|
|
603
661
|
// Chrome events (mode / usage / clear / compact) are transient state, not
|
|
604
662
|
// transcript — broadcast + print them but keep out of the persisted/replayed log.
|
|
605
|
-
|
|
663
|
+
// 'stalled' is transient turn-liveness state (would replay as a stale frozen
|
|
664
|
+
// banner if persisted); 'compaction' is a real transcript milestone (the recap
|
|
665
|
+
// card) and is intentionally NOT chrome, so it persists + replays.
|
|
666
|
+
const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled'
|
|
606
667
|
// compact turn finished (or errored) → clear the pulsing indicator.
|
|
607
668
|
// No "done" ctl line — the SDK's own output in the transcript is the
|
|
608
669
|
// signal (compact summary on success, AbortError text on abort/too-small).
|
|
@@ -665,6 +726,16 @@ function endStructured(id) {
|
|
|
665
726
|
if (s) announce()
|
|
666
727
|
}
|
|
667
728
|
|
|
729
|
+
// Slice 3 — surface a pending update to the room as the "update ready" chip:
|
|
730
|
+
// broadcast a chrome code-event per structured term + stash it on the announce so
|
|
731
|
+
// reloads/late-joiners see it too. The restart itself is gated to between turns.
|
|
732
|
+
function surfaceUpdate(version) {
|
|
733
|
+
if (!version || pendingUpdate === version) return
|
|
734
|
+
pendingUpdate = version
|
|
735
|
+
for (const id of sessions.keys()) bcast('code-event', { term: id, evt: { kind: 'update-available', version } })
|
|
736
|
+
announce()
|
|
737
|
+
}
|
|
738
|
+
|
|
668
739
|
// After the attached CLI exits, the host's stdin stops feeding a PTY —
|
|
669
740
|
// restore the cooked terminal so Ctrl-C reaches the bridge itself.
|
|
670
741
|
const detachLocal = () => {
|
|
@@ -867,6 +938,18 @@ channel
|
|
|
867
938
|
.on('broadcast', { event: 'code-close' }, ({ payload }) => {
|
|
868
939
|
endStructured(payload?.id)
|
|
869
940
|
})
|
|
941
|
+
// Slice 3 — the user clicked "apply" on the update chip. Mark it requested; the
|
|
942
|
+
// actual restart is gated to between turns. Account-child: forward to the
|
|
943
|
+
// supervisor (it owns the restart, applied only when every child is idle).
|
|
944
|
+
// Standalone service tier: its applyIfIdle loop lands it at the next idle. Either
|
|
945
|
+
// way it never interrupts a turn (Contract #1).
|
|
946
|
+
.on('broadcast', { event: 'apply-update' }, () => {
|
|
947
|
+
if (!pendingUpdate) return
|
|
948
|
+
applyRequested = true
|
|
949
|
+
if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
|
|
950
|
+
try { process.send({ t: 'apply-update' }) } catch { /* parent gone */ }
|
|
951
|
+
}
|
|
952
|
+
})
|
|
870
953
|
// Persist + re-announce terminal renames. The web also echoes term-rename to
|
|
871
954
|
// online peers directly; storing it here is what reaches a device that joins
|
|
872
955
|
// LATER (or a second machine) — those only ever see the announce.
|
|
@@ -948,9 +1031,9 @@ setInterval(() => {
|
|
|
948
1031
|
if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
949
1032
|
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
950
1033
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
951
|
-
|
|
952
|
-
|
|
1034
|
+
// pendingUpdate is module-level now (shared with surfaceUpdate + the room chip).
|
|
953
1035
|
const isIdle = () => {
|
|
1036
|
+
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
954
1037
|
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
955
1038
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
956
1039
|
return true
|
|
@@ -974,14 +1057,18 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
974
1057
|
}
|
|
975
1058
|
const applyIfIdle = () => {
|
|
976
1059
|
if (!pendingUpdate || !isIdle()) return
|
|
1060
|
+
// Slice 3: when a web client is watching, wait for their "apply" click instead
|
|
1061
|
+
// of restarting under them. With nobody present (cloud VM / closed tab) the
|
|
1062
|
+
// unattended fallback still auto-applies at idle so headless boxes self-heal.
|
|
1063
|
+
if (!applyRequested && webPeerPresent()) return
|
|
977
1064
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
|
|
978
1065
|
shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
|
|
979
1066
|
}
|
|
980
1067
|
const check = async () => {
|
|
981
1068
|
const latest = await fetchLatest()
|
|
982
1069
|
if (latest && isNewer(latest, VERSION)) {
|
|
983
|
-
if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published —
|
|
984
|
-
pendingUpdate
|
|
1070
|
+
if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — "update ready" in the room; applies between turns.\n`)
|
|
1071
|
+
surfaceUpdate(latest) // sets pendingUpdate + broadcasts the chip + re-announces
|
|
985
1072
|
applyIfIdle()
|
|
986
1073
|
}
|
|
987
1074
|
}
|
|
@@ -995,11 +1082,16 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
995
1082
|
// the supervisor can pick a moment when EVERY child is idle to restart-update.
|
|
996
1083
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
997
1084
|
const idleNow = () => {
|
|
1085
|
+
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
998
1086
|
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
999
1087
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
1000
1088
|
return true
|
|
1001
1089
|
}
|
|
1002
|
-
|
|
1090
|
+
// The supervisor owns updates (children never self-update). It IPCs us when a newer
|
|
1091
|
+
// version is out → surface the chip; we report idle + whether a web peer is present
|
|
1092
|
+
// so it restarts only between turns and only when nobody is mid-click (Slice 3).
|
|
1093
|
+
process.on('message', (m) => { if (m && m.t === 'update-available') surfaceUpdate(m.version) })
|
|
1094
|
+
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
1003
1095
|
} else if (VERSION) {
|
|
1004
1096
|
// Foreground run (not the supervised service): it can't self-restart, but
|
|
1005
1097
|
// nudge once if a newer version is out so a relaunch with @latest picks it up.
|
package/claude-session.mjs
CHANGED
|
@@ -141,8 +141,24 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
141
141
|
// per turn on `result`.
|
|
142
142
|
let turnBaseOut = 0 // output tokens from completed messages this turn
|
|
143
143
|
let curMsgOut = 0 // latest output_tokens for the in-flight message
|
|
144
|
+
// ── stall watchdog (C2) — stream silence ≠ done; a turn can stall (deltas pause
|
|
145
|
+
// 3+ min) or abort silently with no `result`. Track turn liveness + last event
|
|
146
|
+
// time; if an active turn goes quiet past STALL_MS, emit one `stalled` chrome
|
|
147
|
+
// event so the room can stop showing a frozen "working" forever. Cleared on the
|
|
148
|
+
// next event / `result`. Refs: TS SDK #44, claude-code #38905.
|
|
149
|
+
const STALL_MS = Math.max(30000, parseInt(process.env.TP_STALL_MS, 10) || 120000)
|
|
150
|
+
let turnActive = false // true between a sent turn and its `result`
|
|
151
|
+
let lastEvtTs = Date.now() // wall-clock of the most recent emitted event
|
|
152
|
+
let stalledSent = false // one `stalled` per stall, not a storm
|
|
144
153
|
|
|
145
|
-
const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
|
|
154
|
+
const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
|
|
155
|
+
const stallTimer = setInterval(() => {
|
|
156
|
+
if (turnActive && !stalledSent && Date.now() - lastEvtTs > STALL_MS) {
|
|
157
|
+
stalledSent = true
|
|
158
|
+
emit({ kind: 'stalled', sinceMs: Date.now() - lastEvtTs })
|
|
159
|
+
}
|
|
160
|
+
}, 5000)
|
|
161
|
+
stallTimer.unref?.()
|
|
146
162
|
|
|
147
163
|
// Apply a permission-mode change to the live SDK *outside* any hook callback.
|
|
148
164
|
// setPermissionMode is a streaming control request; awaiting it from INSIDE the
|
|
@@ -319,6 +335,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
319
335
|
if (turnBaseOut + curMsgOut === 0) emit({ kind: 'thinking_tokens', tokens: m.estimated_tokens, delta: m.estimated_tokens_delta })
|
|
320
336
|
break
|
|
321
337
|
}
|
|
338
|
+
// compact_boundary — the authoritative compaction event (replaces the
|
|
339
|
+
// old heuristic): trigger ('manual' for /compact vs 'auto') + the token
|
|
340
|
+
// count before compaction. Emit a real recap card the room can pin.
|
|
341
|
+
if (m.subtype === 'compact_boundary') {
|
|
342
|
+
emit({ kind: 'compaction', trigger: m.compact_metadata?.trigger || 'auto', preTokens: m.compact_metadata?.pre_tokens ?? null })
|
|
343
|
+
break
|
|
344
|
+
}
|
|
322
345
|
if (m.session_id) sessionId = m.session_id
|
|
323
346
|
// m.slash_commands (init message) — the commands this session really
|
|
324
347
|
// supports: built-ins + the host's custom .claude/commands. Surfaced
|
|
@@ -330,7 +353,10 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
330
353
|
for (const b of (m.message?.content || [])) {
|
|
331
354
|
if (b?.type === 'tool_use' && b.id) toolStart.set(b.id, Date.now())
|
|
332
355
|
}
|
|
333
|
-
|
|
356
|
+
// parentToolUseId: non-null when this assistant message comes from a
|
|
357
|
+
// sub-agent (Task tool) — the universal nesting spine. Thread it so the
|
|
358
|
+
// room can group sub-agent activity under its parent Task card.
|
|
359
|
+
emit({ kind: 'assistant', blocks: simplifyBlocks(m.message?.content), parentToolUseId: m.parent_tool_use_id || null })
|
|
334
360
|
break
|
|
335
361
|
case 'user':
|
|
336
362
|
// tool_result blocks arrive on the user-role echo
|
|
@@ -338,7 +364,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
338
364
|
if (b?.type === 'tool_result') {
|
|
339
365
|
const start = toolStart.get(b.tool_use_id)
|
|
340
366
|
if (start != null) toolStart.delete(b.tool_use_id)
|
|
341
|
-
emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, isError: !!b.is_error, durationMs: start != null ? Date.now() - start : undefined })
|
|
367
|
+
emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, isError: !!b.is_error, durationMs: start != null ? Date.now() - start : undefined, parentToolUseId: m.parent_tool_use_id || null })
|
|
342
368
|
}
|
|
343
369
|
}
|
|
344
370
|
break
|
|
@@ -361,7 +387,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
361
387
|
case 'result':
|
|
362
388
|
if (m.session_id) sessionId = m.session_id
|
|
363
389
|
turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
|
|
364
|
-
|
|
390
|
+
turnActive = false // turn settled → stall watchdog stands down
|
|
391
|
+
emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0 })
|
|
365
392
|
// Surface a usage/context meter (chrome, not a transcript line). The
|
|
366
393
|
// context window % comes from the control request; cost is cumulative.
|
|
367
394
|
;(async () => {
|
|
@@ -384,7 +411,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
384
411
|
})()
|
|
385
412
|
|
|
386
413
|
return {
|
|
387
|
-
sendTurn(text) { if (!closed) input.push(String(text)) },
|
|
414
|
+
sendTurn(text) { if (!closed) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
|
|
388
415
|
// Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
|
|
389
416
|
// streaming control request (drives plan-mode behaviour SDK-side); the local
|
|
390
417
|
// `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
|
|
@@ -412,8 +439,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
412
439
|
// Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
|
|
413
440
|
// session alive for the next one. ac.abort() is teardown only (end()).
|
|
414
441
|
async abort() { try { await q?.interrupt?.() } catch { /* noop */ } },
|
|
415
|
-
end() { closed = true; input.end(); try { ac.abort() } catch { /* noop */ } },
|
|
442
|
+
end() { closed = true; clearInterval(stallTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
|
|
416
443
|
get sessionId() { return sessionId },
|
|
417
444
|
get mode() { return mode },
|
|
445
|
+
get turnActive() { return turnActive }, // a turn is in flight (gates between-turns update restart — Slice 3 Contract #1)
|
|
418
446
|
}
|
|
419
447
|
}
|
package/launcher.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* launcher.mjs — the one-command interactive menu for `thinkpool-pair`.
|
|
2
|
+
Spec: docs/specs/2026-06-19-bridge-unified-launcher.md (Part 1).
|
|
3
|
+
|
|
4
|
+
ONE implementation, three callers, via dependency injection:
|
|
5
|
+
• bridge.mjs → real `actions` (spawn a room bridge / installService / runLogin / …)
|
|
6
|
+
• setup-prototype → dry-run `actions` (print the command instead of running it)
|
|
7
|
+
• test-launcher → mock `actions` + scripted `io` (assert menu choice → action + args)
|
|
8
|
+
|
|
9
|
+
detectState() is real + read-only (auth.json / provider.json / PATH agents / service
|
|
10
|
+
artifact presence). runLauncher() renders the menu and calls actions.* — it NEVER runs
|
|
11
|
+
anything itself, so it is safe to unit-test and safe to dry-run. */
|
|
12
|
+
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
|
|
17
|
+
const HOME = os.homedir()
|
|
18
|
+
const CFG_DIR = path.join(HOME, '.thinkpool-pair')
|
|
19
|
+
|
|
20
|
+
export const KNOWN_AGENTS = [
|
|
21
|
+
{ label: 'Claude Code', cmd: 'claude', resume: ['--continue'] },
|
|
22
|
+
{ label: 'Codex CLI', cmd: 'codex' },
|
|
23
|
+
{ label: 'Gemini CLI', cmd: 'gemini' },
|
|
24
|
+
{ label: 'Aider', cmd: 'aider' },
|
|
25
|
+
{ label: 'Cursor CLI', cmd: 'cursor-agent' },
|
|
26
|
+
{ label: 'opencode', cmd: 'opencode' },
|
|
27
|
+
{ label: 'Copilot CLI', cmd: 'copilot' },
|
|
28
|
+
{ label: 'Goose', cmd: 'goose' },
|
|
29
|
+
{ label: 'Crush', cmd: 'crush' },
|
|
30
|
+
{ label: 'Qwen Code', cmd: 'qwen' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
function onPath(c) {
|
|
34
|
+
const exts = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']
|
|
35
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
36
|
+
if (!dir) continue
|
|
37
|
+
for (const ext of exts) { try { if (fs.existsSync(path.join(dir, c + ext))) return true } catch { /* noop */ } }
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
|
|
42
|
+
|
|
43
|
+
function serviceFile(room) {
|
|
44
|
+
const label = room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
|
|
45
|
+
if (process.platform === 'darwin') return path.join(HOME, 'Library', 'LaunchAgents', `${label}.plist`)
|
|
46
|
+
if (process.platform === 'linux') return path.join(HOME, '.config', 'systemd', 'user', `${label}.service`)
|
|
47
|
+
const startup = path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
48
|
+
return path.join(startup, `thinkpool-pair-${room || 'account'}.cmd`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function detectState() {
|
|
52
|
+
const auth = readJson(path.join(CFG_DIR, 'auth.json'))
|
|
53
|
+
const provider = readJson(path.join(CFG_DIR, 'provider.json'))
|
|
54
|
+
return {
|
|
55
|
+
loggedIn: !!auth?.refresh_token,
|
|
56
|
+
email: auth?.email || auth?.user?.email || null,
|
|
57
|
+
provider: provider?.kind === 'custom' ? `Custom (${provider.base || 'endpoint'})` : 'Anthropic (default)',
|
|
58
|
+
agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
|
|
59
|
+
accountSvc: fs.existsSync(serviceFile(null)),
|
|
60
|
+
cwd: process.cwd(),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── colors ──
|
|
65
|
+
const C = {
|
|
66
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
67
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// io: { ask(prompt)->Promise<string>, print(s) }. actions: real | dry-run | mock.
|
|
71
|
+
export async function runLauncher({ actions, io, state = detectState() }) {
|
|
72
|
+
const askChoice = async (prompt, options, def = 1) => {
|
|
73
|
+
options.forEach((o, i) => io.print(` ${C.cyan(String(i + 1))}) ${o.label}${o.hint ? ' ' + C.dim(o.hint) : ''}`))
|
|
74
|
+
const a = await io.ask(`\n ${prompt} ${C.dim(`[${def}]`)} ${C.cyan('▸')} `)
|
|
75
|
+
const n = parseInt(a, 10)
|
|
76
|
+
return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : def - 1
|
|
77
|
+
}
|
|
78
|
+
const askYesNo = async (q, def = true) => {
|
|
79
|
+
const a = (await io.ask(` ${q} ${C.dim(def ? '[Y/n]' : '[y/N]')} ${C.cyan('▸')} `)).toLowerCase()
|
|
80
|
+
return a ? a[0] === 'y' : def
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const header = () => {
|
|
84
|
+
io.print('')
|
|
85
|
+
io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
|
|
86
|
+
io.print(` ${C.dim('│ account ')} ${state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
|
|
87
|
+
io.print(` ${C.dim('│ provider ')} ${state.provider}`)
|
|
88
|
+
io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none on PATH')}`)
|
|
89
|
+
io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
|
|
90
|
+
io.print(` ${C.dim('│ service ')} ${state.accountSvc ? C.green('account service installed') : 'none'}`)
|
|
91
|
+
io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pairRoom = async () => {
|
|
95
|
+
const room = (await io.ask(`\n Room code ${C.dim('(from /code in the web app)')} ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
96
|
+
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
97
|
+
if (!state.agents.length) { io.print('\n ' + C.yellow('No coding-agent CLI on your PATH (claude / codex / gemini / aider …).')); return }
|
|
98
|
+
let agent = state.agents[0]
|
|
99
|
+
if (state.agents.length > 1) { io.print('\n Share which agent?'); agent = state.agents[await askChoice('agent', state.agents.map(a => ({ label: a.label })))] }
|
|
100
|
+
io.print('\n Run mode?')
|
|
101
|
+
const headless = (await askChoice('mode', [
|
|
102
|
+
{ label: 'Attached', hint: 'use it like your own terminal, mirrored to the web' },
|
|
103
|
+
{ label: 'Headless', hint: 'pure relay, drive entirely from the web' },
|
|
104
|
+
])) === 1
|
|
105
|
+
let cont = false
|
|
106
|
+
if (!headless && agent.resume) cont = await askYesNo(`\n Continue your latest ${agent.label} session in this directory?`, true)
|
|
107
|
+
await actions.pairRoom({ room, agentCmd: agent.cmd, resumeArgs: cont ? agent.resume : [], headless })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const serveAccount = async () => {
|
|
111
|
+
if (!state.loggedIn) {
|
|
112
|
+
io.print('\n ' + C.yellow('You are not linked to a ThinkPool account yet.'))
|
|
113
|
+
if (await askYesNo(' Link this device now?', true)) await actions.login()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
io.print(`\n Serving every session you host, from ${C.dim(state.cwd)}.\n`)
|
|
117
|
+
const how = await askChoice('run it', [
|
|
118
|
+
{ label: 'As an always-on background service', hint: 'recommended — survives reboot, auto-updates' },
|
|
119
|
+
{ label: 'Here in this terminal (foreground)', hint: 'stops when you close it' },
|
|
120
|
+
])
|
|
121
|
+
if (how === 0) await actions.installService({ room: null })
|
|
122
|
+
else await actions.serveAccountForeground()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const serviceMenu = async () => {
|
|
126
|
+
const what = await askChoice('install for', [
|
|
127
|
+
{ label: 'All my sessions (account)', hint: state.loggedIn ? '' : 'needs login first' },
|
|
128
|
+
{ label: 'One specific room', hint: 'no login needed' },
|
|
129
|
+
])
|
|
130
|
+
if (what === 0) {
|
|
131
|
+
if (!state.loggedIn) { io.print('\n ' + C.yellow('Account service needs a login first (pick “Serve all my sessions”).')); return }
|
|
132
|
+
await actions.installService({ room: null }); return
|
|
133
|
+
}
|
|
134
|
+
const room = (await io.ask(`\n Room code ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
135
|
+
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
136
|
+
const agent = state.agents[0] || { cmd: 'claude' }
|
|
137
|
+
await actions.installService({ room, agentCmd: agent.cmd })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const settingsMenu = async () => {
|
|
141
|
+
const pick = await askChoice('\n change', [
|
|
142
|
+
{ label: 'LLM provider', hint: `current: ${state.provider}` },
|
|
143
|
+
{ label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
144
|
+
{ label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
|
|
145
|
+
])
|
|
146
|
+
if (pick === 0) {
|
|
147
|
+
const p = await askChoice('\n provider', [{ label: 'Anthropic (default)' }, { label: 'Custom endpoint', hint: 'Z.ai GLM, OpenRouter, your own proxy' }])
|
|
148
|
+
if (p === 0) await actions.setProvider({ kind: 'anthropic' })
|
|
149
|
+
else {
|
|
150
|
+
const base = (await io.ask(`\n Base url ${C.dim('(SDK appends /v1/messages)')} ${C.cyan('▸')} `)).trim()
|
|
151
|
+
const token = (await io.ask(` API token ${C.cyan('▸')} `)).trim()
|
|
152
|
+
await actions.setProvider({ kind: 'custom', base, token })
|
|
153
|
+
}
|
|
154
|
+
} else if (pick === 1) {
|
|
155
|
+
await actions.login()
|
|
156
|
+
} else {
|
|
157
|
+
if (state.accountSvc) await actions.uninstallService({ room: null })
|
|
158
|
+
else io.print(' ' + C.yellow('no background service installed'))
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── menu loop ──
|
|
163
|
+
// Real actions either replace the process (pairRoom re-execs + waits; serve/install
|
|
164
|
+
// call process.exit) or run forever (account foreground), so a real "do something"
|
|
165
|
+
// choice never returns here. Dry-run/mock actions just print + return → the loop
|
|
166
|
+
// re-renders. The "press Enter" only lands on a no-op / back-out path.
|
|
167
|
+
for (;;) {
|
|
168
|
+
header()
|
|
169
|
+
io.print(' ' + C.bold('What do you want to do?') + '\n')
|
|
170
|
+
const choice = await askChoice('choose', [
|
|
171
|
+
{ label: 'Pair one room now', hint: 'run here, Ctrl-C stops it' },
|
|
172
|
+
{ label: 'Serve all my sessions', hint: 'account mode — needs login' },
|
|
173
|
+
{ label: 'Always-on background service', hint: 'survives reboot, auto-updates' },
|
|
174
|
+
{ label: 'Settings', hint: 'provider · account · remove service' },
|
|
175
|
+
{ label: 'Quit', hint: '' },
|
|
176
|
+
])
|
|
177
|
+
if (choice === 4) return // Quit
|
|
178
|
+
if (choice === 0) await pairRoom()
|
|
179
|
+
else if (choice === 1) await serveAccount()
|
|
180
|
+
else if (choice === 2) await serviceMenu()
|
|
181
|
+
else if (choice === 3) await settingsMenu()
|
|
182
|
+
await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
|
|
183
|
+
}
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.30",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bridge.mjs",
|
|
11
|
+
"launcher.mjs",
|
|
11
12
|
"claude-session.mjs",
|
|
13
|
+
"update-gate.mjs",
|
|
12
14
|
"event-id.mjs",
|
|
13
15
|
"transcript-sanitize.mjs",
|
|
14
16
|
"session-store.mjs",
|
package/service.mjs
CHANGED
|
@@ -120,9 +120,13 @@ export function installService(room, cmdArgs = []) {
|
|
|
120
120
|
for (const cmd of a.post) {
|
|
121
121
|
try { execSync(cmd, { stdio: 'inherit' }) } catch (e) { process.stderr.write(` ⚠ "${cmd}" failed: ${e.message}\n`) }
|
|
122
122
|
}
|
|
123
|
+
// Be honest about the cadence — it is NOT instant. The supervisor polls npm
|
|
124
|
+
// roughly every 30 min (THINKPOOL_PAIR_UPDATE_INTERVAL) and only restarts once
|
|
125
|
+
// every served session is idle, so a just-published version can take a while.
|
|
126
|
+
const idleSecs = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90)
|
|
123
127
|
const autoUpdate = process.platform === 'win32'
|
|
124
|
-
? '
|
|
125
|
-
:
|
|
128
|
+
? 'auto-update: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
|
|
129
|
+
: `auto-update: checks npm ~every 30 min and restarts to apply a new version once sessions are idle (≥${idleSecs}s quiet; they resume in place). Not instant — to update now, restart it (re-run install-service, or 'launchctl kickstart -k gui/$(id -u)/io.thinkpool.pair.${slug(room)}' on macOS).`
|
|
126
130
|
const removeArg = room ? ` ${room}` : ''
|
|
127
131
|
const what = room ? `room ${room}` : 'your account (auto-serves every session)'
|
|
128
132
|
process.stderr.write(` ◆ ${what}\n ◆ ${a.note}\n ◆ ${autoUpdate}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)
|
package/update-gate.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* update-gate.mjs — pure predicates for the between-turns update apply (Slice 3
|
|
2
|
+
nudge protocol, spec docs/specs/2026-06-19-bridge-unified-launcher.md Part 2).
|
|
3
|
+
|
|
4
|
+
Extracted import-safe so the "safe to restart now" rule is unit-tested and can't
|
|
5
|
+
drift. Contract #1: an update only applies at idle / between turns — never mid-turn.
|
|
6
|
+
bridge.mjs runs the bridge on import (can't be imported by a test), so the gate
|
|
7
|
+
lives here. */
|
|
8
|
+
|
|
9
|
+
// Supervisor side: is it safe to restart the account bridge to apply a pending
|
|
10
|
+
// update? True iff a pending update exists, we're not already stopping, EVERY served
|
|
11
|
+
// child is idle (a child reports idle=false while a turn is in flight — that's what
|
|
12
|
+
// keeps Contract #1), AND either a user requested apply (clicked the chip) OR — the
|
|
13
|
+
// unattended fallback — no child has a web peer present (nobody is there to click).
|
|
14
|
+
export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, childPeer, requested = false }) {
|
|
15
|
+
if (!pendingUpdate || stopping) return false
|
|
16
|
+
for (const idle of childIdle.values()) if (!idle) return false // any busy child → wait (between-turns)
|
|
17
|
+
if (requested) return true // user clicked apply + all idle → go
|
|
18
|
+
for (const present of childPeer.values()) if (present) return false // someone's watching, no click → wait
|
|
19
|
+
return true // unattended: idle + nobody watching → auto-apply
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Child/standalone side: is any structured session mid-turn? Used both to gate a
|
|
23
|
+
// local apply and to make the idle heartbeat honest (idle=false while a turn runs).
|
|
24
|
+
// Accepts a sessions Map whose entries expose turnActive directly or via .session.
|
|
25
|
+
export function turnInFlight(sessions) {
|
|
26
|
+
for (const s of (sessions?.values?.() || [])) {
|
|
27
|
+
const ta = s && (typeof s.session?.turnActive === 'boolean' ? s.session.turnActive : s.turnActive)
|
|
28
|
+
if (ta) return true
|
|
29
|
+
}
|
|
30
|
+
return false
|
|
31
|
+
}
|