thinkpool-pair 0.7.119 → 0.7.120
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 +10 -0
- package/bridge.mjs +20 -7
- package/claude-session.mjs +13 -3
- package/package.json +1 -1
- package/service.mjs +10 -1
package/account.mjs
CHANGED
|
@@ -489,7 +489,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
489
489
|
const busPeek = (targetRoom, terminal, lines) => busRequestFirst('peer-peek-req', { targetRoom, terminal, lines }, CROSSROOM_BUS.busTimeoutMs)
|
|
490
490
|
const busPost = (targetRoom, terminal, text, fromRoom) => busRequestFirst('peer-post-req', { targetRoom, terminal, text, fromRoom, fromHost: machine }, 180_000)
|
|
491
491
|
|
|
492
|
+
// Re-entrancy guard: bind-room fires tick() AND a 15s interval fires tick(); tick
|
|
493
|
+
// awaits a code_sessions query before children.set(), so two overlapping runs can
|
|
494
|
+
// both see children.has(room) false and DOUBLE-SPAWN a 2nd bridge for one room
|
|
495
|
+
// (two bridges per room = the CASCADE-delete / ghost-terminal hazard). Serialize.
|
|
496
|
+
let ticking = false
|
|
492
497
|
const tick = async () => {
|
|
498
|
+
if (ticking) return
|
|
499
|
+
ticking = true
|
|
500
|
+
try { await tickBody() } finally { ticking = false }
|
|
501
|
+
}
|
|
502
|
+
const tickBody = async () => {
|
|
493
503
|
// Hold the account claim before serving anything (cross-machine guard). When
|
|
494
504
|
// standing by, make sure we're serving nothing and wait for the next tick.
|
|
495
505
|
if (!(await refreshClaim())) {
|
package/bridge.mjs
CHANGED
|
@@ -274,12 +274,17 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
274
274
|
restartUpdateService: ({ room = null } = {}) => {
|
|
275
275
|
let target = null
|
|
276
276
|
try { target = execSync('npm view thinkpool-pair version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { /* offline / no npm */ }
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
277
|
+
if (!/^\d+\.\d+\.\d+/.test(target || '')) {
|
|
278
|
+
// Can't reach npm to learn the newest version — DON'T silently re-pin this
|
|
279
|
+
// launcher's PKG_VERSION (npx may have run a stale cached launcher, so it could
|
|
280
|
+
// be OLDER than what's installed). Just RESTART the current pinned service (no
|
|
281
|
+
// version change) and say so honestly — never call a same-version restart an
|
|
282
|
+
// "update".
|
|
283
|
+
process.stderr.write(`\n ⚠ couldn't reach npm to check for a newer version — restarting the current version, NO update applied. Try again when online.\n`)
|
|
284
|
+
svc.restartService(room)
|
|
285
|
+
return
|
|
282
286
|
}
|
|
287
|
+
process.stderr.write(`\n ◆ updating the background service to v${target} (newest published) and restarting…\n`)
|
|
283
288
|
svc.installService(room, [], { version: target, staleProof: true }) // rewrites plist/unit at resolved version + reloads = update + restart
|
|
284
289
|
},
|
|
285
290
|
login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
|
|
@@ -1806,6 +1811,10 @@ function endStructured(id) {
|
|
|
1806
1811
|
function cleanupFlowLanes(flowId, graceMs = 4000) {
|
|
1807
1812
|
if (!flowId) return
|
|
1808
1813
|
const marker = `flow:${flowId}`
|
|
1814
|
+
// Drop this flow's budget ledger — else the map grows unbounded over the bridge's
|
|
1815
|
+
// life AND a re-run reusing the flowId reads a stale spend already at the cap, so
|
|
1816
|
+
// canDispatch refuses the fresh wave (the "flow won't start again" bug).
|
|
1817
|
+
flowBudgets.delete(flowId)
|
|
1809
1818
|
setTimeout(() => {
|
|
1810
1819
|
for (const [sid, e] of [...sessions]) {
|
|
1811
1820
|
if (e.flowSessionId === flowId || e.spawnedBy === marker) {
|
|
@@ -2610,7 +2619,11 @@ process.on('uncaughtException', (err) => {
|
|
|
2610
2619
|
try { process.stderr.write(`\n ⚠ uncaughtException: ${err?.stack || err}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
|
|
2611
2620
|
shutdown(1)
|
|
2612
2621
|
})
|
|
2622
|
+
// A stray unhandledRejection is NOT fatal — this long-running process has many
|
|
2623
|
+
// fire-and-forget promises (best-effort fetches, supabase awaits) and one that
|
|
2624
|
+
// wasn't .catch()'d must not tear down EVERY room + force a resume-storm. Log it
|
|
2625
|
+
// loudly and keep serving; a rejection that truly wedges a session surfaces
|
|
2626
|
+
// through the stall watchdog or a synchronous throw (→ uncaughtException above).
|
|
2613
2627
|
process.on('unhandledRejection', (reason) => {
|
|
2614
|
-
try { process.stderr.write(`\n ⚠ unhandledRejection: ${reason?.stack || reason}\n
|
|
2615
|
-
shutdown(1)
|
|
2628
|
+
try { process.stderr.write(`\n ⚠ unhandledRejection (non-fatal, continuing): ${reason?.stack || reason}\n`) } catch { /* noop */ }
|
|
2616
2629
|
})
|
package/claude-session.mjs
CHANGED
|
@@ -193,6 +193,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
193
193
|
const RESTART_MAX = 3
|
|
194
194
|
const RECOVERABLE = /connection closed|connection reset|econnreset|etimedout|socket hang up|fetch failed|network error|socket destroyed/i
|
|
195
195
|
let restartCount = 0
|
|
196
|
+
let restartTimer = null // the pending auto-restart backoff — cancelled by end()
|
|
196
197
|
// Force-stop a true wedge (item 3): no result, no error, just silence past
|
|
197
198
|
// FORCE_STOP_MS. Clears turnActive so between-turns updates unblock, and
|
|
198
199
|
// surfaces a recoverable error. The wedged loop is left in place (a hung
|
|
@@ -733,7 +734,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
733
734
|
if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) { turnActive = false; break }
|
|
734
735
|
turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
|
|
735
736
|
turnActive = false // turn settled → stall watchdog stands down
|
|
736
|
-
restartCount = 0
|
|
737
|
+
if (m.subtype === 'success') restartCount = 0 // ONLY a real success refills the
|
|
738
|
+
// auto-restart budget — else a flapping connection that lands one aborted turn
|
|
739
|
+
// between drops refills every cycle and loops far past RESTART_MAX.
|
|
737
740
|
forceStopped = false
|
|
738
741
|
emit({ kind: 'result', subtype: m.subtype, sessionId, model: curModel, 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 })
|
|
739
742
|
// Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
|
|
@@ -775,8 +778,15 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
775
778
|
restartCount++
|
|
776
779
|
turnActive = false
|
|
777
780
|
emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
|
|
781
|
+
// End the OLD input stream before swapping — else its generator leaks
|
|
782
|
+
// (suspended forever) and a turn pushed into it after the throw is silently
|
|
783
|
+
// lost (push returns false on an ended stream, so sendTurn can surface it).
|
|
784
|
+
const oldInput = input
|
|
778
785
|
input = makeInputStream()
|
|
779
|
-
|
|
786
|
+
try { oldInput.end() } catch { /* noop */ }
|
|
787
|
+
// Track the timer so end() can cancel it, and re-check `closed` at fire time
|
|
788
|
+
// so a session torn down during the backoff doesn't spawn a zombie SDK child.
|
|
789
|
+
restartTimer = setTimeout(() => { restartTimer = null; if (!closed) runQuery() }, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
|
|
780
790
|
return
|
|
781
791
|
}
|
|
782
792
|
// Non-recoverable, or budget exhausted: mark done so sendTurn stops
|
|
@@ -839,7 +849,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
839
849
|
// SDK already surfaced one for the interrupt. (restored from 0.7.49)
|
|
840
850
|
if (turnActive) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
|
|
841
851
|
},
|
|
842
|
-
end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
|
|
852
|
+
end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); clearTimeout(restartTimer); if (sugTimer) clearTimeout(sugTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
|
|
843
853
|
get sessionId() { return sessionId },
|
|
844
854
|
get mode() { return mode },
|
|
845
855
|
get turnActive() { return turnActive }, // a turn is in flight (gates between-turns update restart — Slice 3 Contract #1)
|
package/package.json
CHANGED
package/service.mjs
CHANGED
|
@@ -24,7 +24,16 @@ import { execSync } from 'node:child_process'
|
|
|
24
24
|
|
|
25
25
|
// Service identity. Account mode has no room → a single stable id so there's
|
|
26
26
|
// exactly one account service per machine (a second install replaces it).
|
|
27
|
-
|
|
27
|
+
// Room codes flow UNESCAPED into launchctl/systemd shell strings + plist paths, so
|
|
28
|
+
// this is the security chokepoint: reject anything that isn't a plain alnum code
|
|
29
|
+
// before it can inject shell (`A;rm -rf ~`) or corrupt the service label. Every
|
|
30
|
+
// room-interpolating path (buildArtifact, restart/uninstall/serviceActive) calls
|
|
31
|
+
// label(), so one guard here covers them all.
|
|
32
|
+
const ROOM_CODE_RE = /^[A-Za-z0-9]{2,16}$/
|
|
33
|
+
const label = (room) => {
|
|
34
|
+
if (room && !ROOM_CODE_RE.test(room)) throw new Error(`invalid room code: ${JSON.stringify(room)} (expected 2–16 alphanumerics)`)
|
|
35
|
+
return room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
|
|
36
|
+
}
|
|
28
37
|
const slug = (room) => room || 'account'
|
|
29
38
|
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
30
39
|
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
|