thinkpool-pair 0.7.34 → 0.7.35
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 +6 -4
- package/bridge.mjs +39 -14
- package/package.json +1 -1
- package/update-gate.mjs +11 -3
package/account.mjs
CHANGED
|
@@ -208,7 +208,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
208
208
|
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n Auto-serving your sessions from ${DEFAULT_DIR}\n (point one at another repo: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
|
|
209
209
|
|
|
210
210
|
const children = new Map() // room -> child process
|
|
211
|
-
const childIdle = new Map()
|
|
211
|
+
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
212
|
+
const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
|
|
212
213
|
const childPeer = new Map() // room -> bool (a web client is watching this room)
|
|
213
214
|
let applyRequested = false // a user clicked "apply" in some room (Slice 3)
|
|
214
215
|
let pendingUpdate = null // newest published version once the poll sees it
|
|
@@ -282,13 +283,14 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
282
283
|
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
283
284
|
children.set(room, child)
|
|
284
285
|
childIdle.set(room, false) // unknown until it reports → never restart a fresh child
|
|
286
|
+
childBetween.set(room, false)
|
|
285
287
|
childPeer.set(room, false)
|
|
286
288
|
child.on('message', (m) => {
|
|
287
289
|
if (!m) return
|
|
288
|
-
if (m.t === 'idle') { childIdle.set(room, !!m.idle); childPeer.set(room, !!m.webPeer) }
|
|
290
|
+
if (m.t === 'idle') { childIdle.set(room, !!m.idle); childBetween.set(room, m.between != null ? !!m.between : !!m.idle); childPeer.set(room, !!m.webPeer) }
|
|
289
291
|
else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
|
|
290
292
|
})
|
|
291
|
-
child.on('exit', () => { children.delete(room); childIdle.delete(room); childPeer.delete(room) }) // re-served on the next tick
|
|
293
|
+
child.on('exit', () => { children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room) }) // re-served on the next tick
|
|
292
294
|
// If an update is already pending, tell the freshly-served child immediately so
|
|
293
295
|
// its room shows the "update ready" chip without waiting for the next poll.
|
|
294
296
|
if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
|
|
@@ -317,7 +319,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
317
319
|
// child idle (between turns) AND either a user clicked apply OR nobody's watching
|
|
318
320
|
// (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
|
|
319
321
|
const applyIfIdle = () => {
|
|
320
|
-
if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childPeer, requested: applyRequested })) return
|
|
322
|
+
if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
|
|
321
323
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
|
|
322
324
|
stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
|
|
323
325
|
}
|
package/bridge.mjs
CHANGED
|
@@ -650,7 +650,13 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
650
650
|
if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
|
|
651
651
|
// Persist the permission mode alongside the transcript so a bridge restart
|
|
652
652
|
// restores the session in the SAME mode (a bypass room stays bypass on resume).
|
|
653
|
-
const
|
|
653
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode })
|
|
654
|
+
const persist = () => saveSession(room, id, sessionData())
|
|
655
|
+
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
656
|
+
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
657
|
+
// 1.5s saveSession debounce window) and on shutdown (so events since the last
|
|
658
|
+
// debounced write aren't lost). Contract #2: restart resumes, no lost messages.
|
|
659
|
+
entry.flush = () => flushSession(room, id, sessionData())
|
|
654
660
|
entry.session = startClaudeSession({
|
|
655
661
|
cwd: process.cwd(), model, resume, mode,
|
|
656
662
|
env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
|
|
@@ -708,6 +714,10 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
708
714
|
: `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
|
|
709
715
|
}),
|
|
710
716
|
})
|
|
717
|
+
// Brand-new session (no restored log): write its record now so a restart that lands
|
|
718
|
+
// before the first debounced save still restores this id instead of dropping it (the
|
|
719
|
+
// room would otherwise show a fresh empty terminal + strand the transcript).
|
|
720
|
+
if (!entry.log.length) entry.flush()
|
|
711
721
|
announce()
|
|
712
722
|
if (!entry.log.length) process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
|
|
713
723
|
return entry
|
|
@@ -1002,7 +1012,11 @@ channel
|
|
|
1002
1012
|
// only runs on a fresh start (guard above), so a network re-subscribe
|
|
1003
1013
|
// with live sessions won't re-spawn them.
|
|
1004
1014
|
if (wantStructured(startCmd)) {
|
|
1005
|
-
|
|
1015
|
+
// Restore EVERY saved rec — a freshly opened session (persisted on open) may
|
|
1016
|
+
// have an empty log + no sessionId yet and must still come back under its id.
|
|
1017
|
+
// Closed sessions are unlinked by deleteSession, so a file existing on disk ==
|
|
1018
|
+
// a session that was live at shutdown (never an explicitly closed one).
|
|
1019
|
+
const all = loadAll(room)
|
|
1006
1020
|
if (all.length) for (const rec of all) openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode })
|
|
1007
1021
|
// Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
|
|
1008
1022
|
// (autoAgent set, no attachedCmd) the web's `?new=1` flow opens the first
|
|
@@ -1059,12 +1073,15 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1059
1073
|
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
1060
1074
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
1061
1075
|
// pendingUpdate is module-level now (shared with surfaceUpdate + the room chip).
|
|
1062
|
-
|
|
1076
|
+
// Between turns = safe to restart per Contract #1 (no turn in flight, no bytes
|
|
1077
|
+
// mid-flush). Idle = that PLUS the quiet window — the extra conservatism only the
|
|
1078
|
+
// unattended auto-apply needs; an explicit click restarts on between-turns alone.
|
|
1079
|
+
const betweenTurns = () => {
|
|
1063
1080
|
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
1064
|
-
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
1065
1081
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
1066
1082
|
return true
|
|
1067
1083
|
}
|
|
1084
|
+
const isIdle = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
|
|
1068
1085
|
// Newer? Compare the numeric x.y.z core; ignore prerelease tails to stay safe.
|
|
1069
1086
|
const isNewer = (latest, cur) => {
|
|
1070
1087
|
const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
|
|
@@ -1083,11 +1100,13 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1083
1100
|
} catch { return null } finally { clearTimeout(to) }
|
|
1084
1101
|
}
|
|
1085
1102
|
const applyIfIdle = () => {
|
|
1086
|
-
if (!pendingUpdate
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
|
|
1103
|
+
if (!pendingUpdate) return
|
|
1104
|
+
// Explicit "apply" → go at the next between-turns gap (the click means the user
|
|
1105
|
+
// opted in; don't make them wait out the quiet window). Unattended (no click) →
|
|
1106
|
+
// require the full idle window AND nobody watching, so a headless box self-heals
|
|
1107
|
+
// without ever restarting under someone who's mid-session.
|
|
1108
|
+
if (applyRequested) { if (!betweenTurns()) return }
|
|
1109
|
+
else if (!isIdle() || webPeerPresent()) return
|
|
1091
1110
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
|
|
1092
1111
|
shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
|
|
1093
1112
|
}
|
|
@@ -1108,17 +1127,19 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1108
1127
|
// supervisor restart re-resolves @latest. Instead we report our idle state so
|
|
1109
1128
|
// the supervisor can pick a moment when EVERY child is idle to restart-update.
|
|
1110
1129
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
1111
|
-
const
|
|
1130
|
+
const betweenTurns = () => {
|
|
1112
1131
|
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
1113
|
-
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
1114
1132
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
1115
1133
|
return true
|
|
1116
1134
|
}
|
|
1135
|
+
const idleNow = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
|
|
1117
1136
|
// The supervisor owns updates (children never self-update). It IPCs us when a newer
|
|
1118
|
-
// version is out → surface the chip; we report
|
|
1119
|
-
//
|
|
1137
|
+
// version is out → surface the chip; we report `between` (safe to restart now, per
|
|
1138
|
+
// Contract #1) + `idle` (the quiet window, for the unattended fallback) + whether a
|
|
1139
|
+
// web peer is present, so the supervisor restarts on an explicit click between turns
|
|
1140
|
+
// and otherwise waits for true idle with nobody watching (Slice 3).
|
|
1120
1141
|
process.on('message', (m) => { if (m && m.t === 'update-available') surfaceUpdate(m.version) })
|
|
1121
|
-
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
1142
|
+
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
1122
1143
|
} else if (VERSION) {
|
|
1123
1144
|
// Foreground run (not the supervised service): it can't self-restart, but
|
|
1124
1145
|
// nudge once if a newer version is out so a relaunch with @latest picks it up.
|
|
@@ -1164,6 +1185,10 @@ async function shutdown(code = 0, farewell = true) {
|
|
|
1164
1185
|
// correct and must not hang on the (possibly dead) realtime socket. This is the
|
|
1165
1186
|
// bug behind the watchdog/auto-update raw process.exit() paths, which skipped
|
|
1166
1187
|
// teardown entirely and orphaned agent processes + left stale presence.
|
|
1188
|
+
// Flush structured session state synchronously BEFORE ending sessions. saveSession
|
|
1189
|
+
// debounces ~1.5s, so without this any events since the last write are lost on exit
|
|
1190
|
+
// (Contract #2). writeFileSync, well inside the 1500ms hard-exit backstop above.
|
|
1191
|
+
for (const s of sessions.values()) { try { s.flush?.() } catch { /* noop */ } }
|
|
1167
1192
|
for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
|
|
1168
1193
|
for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }
|
|
1169
1194
|
detachLocal()
|
package/package.json
CHANGED
package/update-gate.mjs
CHANGED
|
@@ -11,10 +11,18 @@
|
|
|
11
11
|
// child is idle (a child reports idle=false while a turn is in flight — that's what
|
|
12
12
|
// keeps Contract #1), AND either a user requested apply (clicked the chip) OR — the
|
|
13
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 }) {
|
|
14
|
+
export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, childBetween, childPeer, requested = false }) {
|
|
15
15
|
if (!pendingUpdate || stopping) return false
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
if (requested) {
|
|
17
|
+
// Explicit "apply" → restart at the next BETWEEN-TURNS gap (no turn in flight),
|
|
18
|
+
// NOT the full quiet window — in an active room that 90s of silence never comes,
|
|
19
|
+
// so the click would otherwise do nothing. Fall back to childIdle when a child
|
|
20
|
+
// doesn't report `between` (older child during the mixed-version update itself).
|
|
21
|
+
const gate = childBetween instanceof Map ? childBetween : childIdle
|
|
22
|
+
for (const ready of gate.values()) if (!ready) return false // any busy child → wait (Contract #1)
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
for (const idle of childIdle.values()) if (!idle) return false // unattended: full idle window
|
|
18
26
|
for (const present of childPeer.values()) if (present) return false // someone's watching, no click → wait
|
|
19
27
|
return true // unattended: idle + nobody watching → auto-apply
|
|
20
28
|
}
|