thinkpool-pair 0.7.31 → 0.7.33
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 +23 -13
- 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
|
@@ -904,7 +904,10 @@ channel
|
|
|
904
904
|
// said what — UNLESS silent (an @pool synthesis, which renders its own line).
|
|
905
905
|
const echoYou = () => {
|
|
906
906
|
if (payload.silent) return
|
|
907
|
-
|
|
907
|
+
// Display the clean body (web sends `body` when the agent `text` carries host
|
|
908
|
+
// file paths) + the uploaded attachments, so the partner shows the image — not
|
|
909
|
+
// a raw /var path. The agent already got the full `text` via sendTurn above.
|
|
910
|
+
const evt = { kind: 'you', text: payload.body != null ? String(payload.body) : text, cid: payload.cid, by: payload.by, ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}) }
|
|
908
911
|
pushLog(s, evt)
|
|
909
912
|
bcast('code-event', { term: payload.term, evt })
|
|
910
913
|
}
|
|
@@ -1056,12 +1059,15 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1056
1059
|
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
1057
1060
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
1058
1061
|
// pendingUpdate is module-level now (shared with surfaceUpdate + the room chip).
|
|
1059
|
-
|
|
1062
|
+
// Between turns = safe to restart per Contract #1 (no turn in flight, no bytes
|
|
1063
|
+
// mid-flush). Idle = that PLUS the quiet window — the extra conservatism only the
|
|
1064
|
+
// unattended auto-apply needs; an explicit click restarts on between-turns alone.
|
|
1065
|
+
const betweenTurns = () => {
|
|
1060
1066
|
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
1061
|
-
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
1062
1067
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
1063
1068
|
return true
|
|
1064
1069
|
}
|
|
1070
|
+
const isIdle = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
|
|
1065
1071
|
// Newer? Compare the numeric x.y.z core; ignore prerelease tails to stay safe.
|
|
1066
1072
|
const isNewer = (latest, cur) => {
|
|
1067
1073
|
const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
|
|
@@ -1080,11 +1086,13 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1080
1086
|
} catch { return null } finally { clearTimeout(to) }
|
|
1081
1087
|
}
|
|
1082
1088
|
const applyIfIdle = () => {
|
|
1083
|
-
if (!pendingUpdate
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
|
|
1089
|
+
if (!pendingUpdate) return
|
|
1090
|
+
// Explicit "apply" → go at the next between-turns gap (the click means the user
|
|
1091
|
+
// opted in; don't make them wait out the quiet window). Unattended (no click) →
|
|
1092
|
+
// require the full idle window AND nobody watching, so a headless box self-heals
|
|
1093
|
+
// without ever restarting under someone who's mid-session.
|
|
1094
|
+
if (applyRequested) { if (!betweenTurns()) return }
|
|
1095
|
+
else if (!isIdle() || webPeerPresent()) return
|
|
1088
1096
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
|
|
1089
1097
|
shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
|
|
1090
1098
|
}
|
|
@@ -1105,17 +1113,19 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1105
1113
|
// supervisor restart re-resolves @latest. Instead we report our idle state so
|
|
1106
1114
|
// the supervisor can pick a moment when EVERY child is idle to restart-update.
|
|
1107
1115
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
1108
|
-
const
|
|
1116
|
+
const betweenTurns = () => {
|
|
1109
1117
|
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
1110
|
-
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
1111
1118
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
1112
1119
|
return true
|
|
1113
1120
|
}
|
|
1121
|
+
const idleNow = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
|
|
1114
1122
|
// The supervisor owns updates (children never self-update). It IPCs us when a newer
|
|
1115
|
-
// version is out → surface the chip; we report
|
|
1116
|
-
//
|
|
1123
|
+
// version is out → surface the chip; we report `between` (safe to restart now, per
|
|
1124
|
+
// Contract #1) + `idle` (the quiet window, for the unattended fallback) + whether a
|
|
1125
|
+
// web peer is present, so the supervisor restarts on an explicit click between turns
|
|
1126
|
+
// and otherwise waits for true idle with nobody watching (Slice 3).
|
|
1117
1127
|
process.on('message', (m) => { if (m && m.t === 'update-available') surfaceUpdate(m.version) })
|
|
1118
|
-
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
1128
|
+
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
1119
1129
|
} else if (VERSION) {
|
|
1120
1130
|
// Foreground run (not the supervised service): it can't self-restart, but
|
|
1121
1131
|
// nudge once if a newer version is out so a relaunch with @latest picks it up.
|
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
|
}
|