thinkpool-pair 0.7.119 → 0.7.121
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 +31 -10
- package/claude-session.mjs +23 -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) },
|
|
@@ -1666,12 +1671,20 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1666
1671
|
// 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
|
|
1667
1672
|
// composer UI, broadcast to both clients but never logged/persisted/replayed.
|
|
1668
1673
|
const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion'
|
|
1669
|
-
// compact turn finished (or errored) → clear the pulsing indicator
|
|
1670
|
-
//
|
|
1671
|
-
//
|
|
1674
|
+
// compact turn finished (or errored) → clear the pulsing indicator AND resolve the
|
|
1675
|
+
// "◈ compacting context…" line so it doesn't sit there forever looking stuck (Max asked
|
|
1676
|
+
// "is it compacted?" — 2026-07-02). On success post an explicit "◈ context compacted"
|
|
1677
|
+
// control line, paired with the start line; on error the SDK's AbortError/too-small
|
|
1678
|
+
// text is the signal, so no line. Same control-event shape as ctlLine (kind:'control').
|
|
1672
1679
|
if ((evt.kind === 'result' || evt.kind === 'error') && entry.compacting) {
|
|
1673
1680
|
entry.compacting = false
|
|
1674
1681
|
bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
1682
|
+
if (evt.kind === 'result') {
|
|
1683
|
+
const doneEvt = { kind: 'control', text: '◈ context compacted', by: entry.compactBy || null }
|
|
1684
|
+
pushLog(entry, doneEvt)
|
|
1685
|
+
bcast('code-event', { term: id, evt: doneEvt })
|
|
1686
|
+
}
|
|
1687
|
+
entry.compactBy = null
|
|
1675
1688
|
}
|
|
1676
1689
|
// Single tail: stamp into the log (transcript kinds only), broadcast live,
|
|
1677
1690
|
// print locally, persist. Shared so a deferred image event re-enters it once
|
|
@@ -1806,6 +1819,10 @@ function endStructured(id) {
|
|
|
1806
1819
|
function cleanupFlowLanes(flowId, graceMs = 4000) {
|
|
1807
1820
|
if (!flowId) return
|
|
1808
1821
|
const marker = `flow:${flowId}`
|
|
1822
|
+
// Drop this flow's budget ledger — else the map grows unbounded over the bridge's
|
|
1823
|
+
// life AND a re-run reusing the flowId reads a stale spend already at the cap, so
|
|
1824
|
+
// canDispatch refuses the fresh wave (the "flow won't start again" bug).
|
|
1825
|
+
flowBudgets.delete(flowId)
|
|
1809
1826
|
setTimeout(() => {
|
|
1810
1827
|
for (const [sid, e] of [...sessions]) {
|
|
1811
1828
|
if (e.flowSessionId === flowId || e.spawnedBy === marker) {
|
|
@@ -2610,7 +2627,11 @@ process.on('uncaughtException', (err) => {
|
|
|
2610
2627
|
try { process.stderr.write(`\n ⚠ uncaughtException: ${err?.stack || err}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
|
|
2611
2628
|
shutdown(1)
|
|
2612
2629
|
})
|
|
2630
|
+
// A stray unhandledRejection is NOT fatal — this long-running process has many
|
|
2631
|
+
// fire-and-forget promises (best-effort fetches, supabase awaits) and one that
|
|
2632
|
+
// wasn't .catch()'d must not tear down EVERY room + force a resume-storm. Log it
|
|
2633
|
+
// loudly and keep serving; a rejection that truly wedges a session surfaces
|
|
2634
|
+
// through the stall watchdog or a synchronous throw (→ uncaughtException above).
|
|
2613
2635
|
process.on('unhandledRejection', (reason) => {
|
|
2614
|
-
try { process.stderr.write(`\n ⚠ unhandledRejection: ${reason?.stack || reason}\n
|
|
2615
|
-
shutdown(1)
|
|
2636
|
+
try { process.stderr.write(`\n ⚠ unhandledRejection (non-fatal, continuing): ${reason?.stack || reason}\n`) } catch { /* noop */ }
|
|
2616
2637
|
})
|
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
|
|
@@ -655,6 +656,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
655
656
|
// count before compaction. Emit a real recap card the room can pin.
|
|
656
657
|
if (m.subtype === 'compact_boundary') {
|
|
657
658
|
emit({ kind: 'compaction', trigger: m.compact_metadata?.trigger || 'auto', preTokens: m.compact_metadata?.pre_tokens ?? null })
|
|
659
|
+
// Refresh the ctx% meter RIGHT AFTER compaction — it otherwise only updates at
|
|
660
|
+
// turn-end, so the mode row kept showing the stale PRE-compaction window
|
|
661
|
+
// ("ctx 90% even though we just compacted", Max 2026-07-02). Re-query the live
|
|
662
|
+
// context usage so the meter drops to the compacted size immediately.
|
|
663
|
+
;(async () => {
|
|
664
|
+
try {
|
|
665
|
+
const c = await q?.getContextUsage?.()
|
|
666
|
+
if (c) { emit({ kind: 'usage', ctx: { used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model } }); if (c.model) curModel = c.model }
|
|
667
|
+
} catch { /* control req may be unavailable */ }
|
|
668
|
+
})()
|
|
658
669
|
break
|
|
659
670
|
}
|
|
660
671
|
if (m.session_id) sessionId = m.session_id
|
|
@@ -733,7 +744,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
733
744
|
if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) { turnActive = false; break }
|
|
734
745
|
turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
|
|
735
746
|
turnActive = false // turn settled → stall watchdog stands down
|
|
736
|
-
restartCount = 0
|
|
747
|
+
if (m.subtype === 'success') restartCount = 0 // ONLY a real success refills the
|
|
748
|
+
// auto-restart budget — else a flapping connection that lands one aborted turn
|
|
749
|
+
// between drops refills every cycle and loops far past RESTART_MAX.
|
|
737
750
|
forceStopped = false
|
|
738
751
|
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
752
|
// Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
|
|
@@ -775,8 +788,15 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
775
788
|
restartCount++
|
|
776
789
|
turnActive = false
|
|
777
790
|
emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
|
|
791
|
+
// End the OLD input stream before swapping — else its generator leaks
|
|
792
|
+
// (suspended forever) and a turn pushed into it after the throw is silently
|
|
793
|
+
// lost (push returns false on an ended stream, so sendTurn can surface it).
|
|
794
|
+
const oldInput = input
|
|
778
795
|
input = makeInputStream()
|
|
779
|
-
|
|
796
|
+
try { oldInput.end() } catch { /* noop */ }
|
|
797
|
+
// Track the timer so end() can cancel it, and re-check `closed` at fire time
|
|
798
|
+
// so a session torn down during the backoff doesn't spawn a zombie SDK child.
|
|
799
|
+
restartTimer = setTimeout(() => { restartTimer = null; if (!closed) runQuery() }, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
|
|
780
800
|
return
|
|
781
801
|
}
|
|
782
802
|
// Non-recoverable, or budget exhausted: mark done so sendTurn stops
|
|
@@ -839,7 +859,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
839
859
|
// SDK already surfaced one for the interrupt. (restored from 0.7.49)
|
|
840
860
|
if (turnActive) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
|
|
841
861
|
},
|
|
842
|
-
end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
|
|
862
|
+
end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); clearTimeout(restartTimer); if (sugTimer) clearTimeout(sugTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
|
|
843
863
|
get sessionId() { return sessionId },
|
|
844
864
|
get mode() { return mode },
|
|
845
865
|
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, `'\\''`)}'`
|