thinkpool-pair 0.7.40 → 0.7.42
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 +8 -1
- package/bridge.mjs +20 -6
- package/claude-session.mjs +58 -7
- package/launcher.mjs +42 -18
- package/package.json +1 -1
package/account.mjs
CHANGED
|
@@ -260,13 +260,20 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
260
260
|
const tick = async () => {
|
|
261
261
|
let rooms = []
|
|
262
262
|
try {
|
|
263
|
-
|
|
263
|
+
// Owner-only discovery (Contract C-CODE-2): a host bridges ONLY the sessions
|
|
264
|
+
// it CREATED. RLS on code_sessions returns owned OR participant rooms, so
|
|
265
|
+
// without this owner filter the supervisor auto-serves rooms you merely JOINED
|
|
266
|
+
// as a guest — spawning your machine into another owner's session that already
|
|
267
|
+
// runs its own bridge. The web watches tpacct:<session-owner>, so a
|
|
268
|
+
// participant's bridge is duplication the UI never even looks at.
|
|
269
|
+
const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id').eq('owner_id', session.user.id).order('last_active_at', { ascending: false }).limit(20)
|
|
264
270
|
rooms = data || []
|
|
265
271
|
} catch { /* transient — keep existing children, retry next tick */ }
|
|
266
272
|
const dirs = loadDirs()
|
|
267
273
|
for (const r of rooms) {
|
|
268
274
|
const room = r.code
|
|
269
275
|
if (!room || children.has(room)) continue
|
|
276
|
+
if (r.owner_id && r.owner_id !== session.user.id) continue // never serve a room we don't own (defense-in-depth behind the .eq above)
|
|
270
277
|
const dir = dirs[room] || DEFAULT_DIR // auto-serve unbound sessions in the launch dir
|
|
271
278
|
if (!fs.existsSync(dir)) {
|
|
272
279
|
if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
|
package/bridge.mjs
CHANGED
|
@@ -164,7 +164,7 @@ if (argv[0] === 'provider') { const { runProvider } = await import('./provider.m
|
|
|
164
164
|
// so launchd / systemd / CI / pipes are byte-for-byte unchanged.
|
|
165
165
|
// Spec: docs/specs/2026-06-19-bridge-unified-launcher.md (Part 1).
|
|
166
166
|
if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
167
|
-
const { runLauncher } = await import('./launcher.mjs')
|
|
167
|
+
const { runLauncher, detectState } = await import('./launcher.mjs')
|
|
168
168
|
const svc = await import('./service.mjs')
|
|
169
169
|
const BRIDGE = fileURLToPath(import.meta.url)
|
|
170
170
|
const io = {
|
|
@@ -186,9 +186,18 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
186
186
|
installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []); process.exit(0) },
|
|
187
187
|
uninstallService: ({ room = null } = {}) => { svc.uninstallService(room); process.exit(0) },
|
|
188
188
|
login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
|
|
189
|
-
|
|
189
|
+
// Provider config is NOT terminal — it writes provider.json and returns to the
|
|
190
|
+
// menu so you can pick a model, switch back, or do something else. (It used to
|
|
191
|
+
// process.exit(0), which dumped you to the shell mid-setup.)
|
|
192
|
+
setProvider: async ({ kind, base, token, model }) => {
|
|
193
|
+
const { runProvider } = await import('./provider.mjs')
|
|
194
|
+
const args = kind === 'custom'
|
|
195
|
+
? ['custom', '--base', base, '--token', token, ...(model ? ['--model', model] : [])]
|
|
196
|
+
: ['anthropic']
|
|
197
|
+
await runProvider(args)
|
|
198
|
+
},
|
|
190
199
|
}
|
|
191
|
-
await runLauncher({ actions, io })
|
|
200
|
+
await runLauncher({ actions, io, refresh: detectState })
|
|
192
201
|
process.exit(0)
|
|
193
202
|
}
|
|
194
203
|
|
|
@@ -442,7 +451,7 @@ const flushAll = () => {
|
|
|
442
451
|
bcast('pty-out', { term: id, b64 })
|
|
443
452
|
}
|
|
444
453
|
}
|
|
445
|
-
const flushTimer = setInterval(flushAll, 35)
|
|
454
|
+
const flushTimer = setInterval(flushAll, 35); flushTimer.unref?.() // don't keep the event loop alive past shutdown
|
|
446
455
|
|
|
447
456
|
// ── mockup hand-off ────────────────────────────────────────────────────
|
|
448
457
|
// render.sh (mockup-iterate) drops a manifest JSON into TP_MOCKUP_OUTBOX after
|
|
@@ -539,6 +548,11 @@ const attachedDims = () => ({
|
|
|
539
548
|
|
|
540
549
|
function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
|
|
541
550
|
if (terms.has(id)) return
|
|
551
|
+
if (terms.size >= 8) { // cap live PTYs (matches session-store KEEP=8) — no unbounded host leak
|
|
552
|
+
process.stderr.write(`\n ⚠ can't open "${cmd}" — terminal cap (8) reached; close one first.\n`)
|
|
553
|
+
bcast('term-exit', { id })
|
|
554
|
+
return
|
|
555
|
+
}
|
|
542
556
|
if (!pty) { // raw-PTY path needs node-pty; structured Claude never lands here
|
|
543
557
|
process.stderr.write(`\n ⚠ can't open "${cmd}" — node-pty isn't built (PTY mode disabled).\n`)
|
|
544
558
|
bcast('term-exit', { id })
|
|
@@ -773,7 +787,7 @@ if (attachedCmd && process.stdin.isTTY) process.stdin.setRawMode(true)
|
|
|
773
787
|
process.stdin.resume()
|
|
774
788
|
process.stdin.on('data', d => {
|
|
775
789
|
const t = attachedId && terms.get(attachedId)
|
|
776
|
-
if (t) t.term.write(d.toString('utf8'))
|
|
790
|
+
if (t) { try { t.term.write(d.toString('utf8')) } catch { /* term just exited */ } }
|
|
777
791
|
else if (d.includes(3)) shutdown() // Ctrl-C once detached/headless
|
|
778
792
|
})
|
|
779
793
|
// attached terminal follows the host's TTY size — but never below the
|
|
@@ -796,7 +810,7 @@ channel
|
|
|
796
810
|
if (!payload?.data) return
|
|
797
811
|
markActivity()
|
|
798
812
|
const t = terms.get(payload.term) || (payload.term == null && attachedId ? terms.get(attachedId) : null)
|
|
799
|
-
if (t) t.term.write(payload.data)
|
|
813
|
+
if (t) { try { t.term.write(payload.data) } catch { /* term just exited */ } }
|
|
800
814
|
})
|
|
801
815
|
.on('broadcast', { event: 'resize' }, ({ payload }) => {
|
|
802
816
|
// headless terms are web-sized (last writer wins); the attached term is
|
package/claude-session.mjs
CHANGED
|
@@ -84,8 +84,10 @@ function makeInputStream() {
|
|
|
84
84
|
return {
|
|
85
85
|
stream: gen(),
|
|
86
86
|
push(content) {
|
|
87
|
+
if (ended) return false // session ended — caller (sendTurn) can surface this
|
|
87
88
|
queue.push({ type: 'user', message: { role: 'user', content } })
|
|
88
89
|
if (wake) { wake(); wake = null }
|
|
90
|
+
return true
|
|
89
91
|
},
|
|
90
92
|
end() { ended = true; if (wake) { wake(); wake = null } },
|
|
91
93
|
}
|
|
@@ -120,7 +122,7 @@ const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
|
120
122
|
|
|
121
123
|
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission }) {
|
|
122
124
|
const ac = new AbortController()
|
|
123
|
-
|
|
125
|
+
let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
|
|
124
126
|
let sessionId = resume || null
|
|
125
127
|
let closed = false
|
|
126
128
|
let q = null // the live Query — control requests (interrupt /
|
|
@@ -150,12 +152,39 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
150
152
|
let turnActive = false // true between a sent turn and its `result`
|
|
151
153
|
let lastEvtTs = Date.now() // wall-clock of the most recent emitted event
|
|
152
154
|
let stalledSent = false // one `stalled` per stall, not a storm
|
|
155
|
+
// Auto-restart (item 1) — a transient upstream stream drop (e.g. Anthropic
|
|
156
|
+
// "Connection closed mid-response") used to leave the session half-dead:
|
|
157
|
+
// query loop gone, turnActive stuck, later sendTurns pushed into a dead
|
|
158
|
+
// stream (silent freeze). The loop now self-heals: a recoverable throw
|
|
159
|
+
// re-runs query() with resume, bounded by RESTART_MAX consecutive attempts
|
|
160
|
+
// (reset to 0 on every successful `result`). Exhaustion/non-recoverable →
|
|
161
|
+
// closed, so sendTurn stops silently queuing.
|
|
162
|
+
const RESTART_MAX = 3
|
|
163
|
+
const RECOVERABLE = /connection closed|connection reset|econnreset|etimedout|socket hang up|fetch failed|network error|socket destroyed/i
|
|
164
|
+
let restartCount = 0
|
|
165
|
+
// Force-stop a true wedge (item 3): no result, no error, just silence past
|
|
166
|
+
// FORCE_STOP_MS. Clears turnActive so between-turns updates unblock, and
|
|
167
|
+
// surfaces a recoverable error. The wedged loop is left in place (a hung
|
|
168
|
+
// native call can't be safely restarted mid-flight); if it later throws,
|
|
169
|
+
// the catch self-heals.
|
|
170
|
+
const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
|
|
171
|
+
let forceStopped = false
|
|
153
172
|
|
|
154
173
|
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
174
|
const stallTimer = setInterval(() => {
|
|
156
|
-
if (turnActive
|
|
175
|
+
if (!turnActive) return
|
|
176
|
+
const quiet = Date.now() - lastEvtTs
|
|
177
|
+
if (quiet > FORCE_STOP_MS && !forceStopped) {
|
|
178
|
+
// True wedge — no result, no error, just silence. Unblocks between-turns
|
|
179
|
+
// updates + surfaces a recoverable error. The wedged loop is left alone.
|
|
180
|
+
forceStopped = true
|
|
181
|
+
turnActive = false
|
|
182
|
+
emit({ kind: 'error', message: `agent went silent for ${Math.round(quiet / 1000)}s — force-stopped the turn; send again to resume`, recoverable: true })
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!stalledSent && quiet > STALL_MS) {
|
|
157
186
|
stalledSent = true
|
|
158
|
-
emit({ kind: 'stalled', sinceMs:
|
|
187
|
+
emit({ kind: 'stalled', sinceMs: quiet })
|
|
159
188
|
}
|
|
160
189
|
}, 5000)
|
|
161
190
|
stallTimer.unref?.()
|
|
@@ -317,9 +346,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
317
346
|
if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} cross-provider tool block(s) in the transcript before resume.\n`)
|
|
318
347
|
}
|
|
319
348
|
|
|
320
|
-
|
|
349
|
+
const runQuery = async () => {
|
|
321
350
|
try {
|
|
322
|
-
q = query({ prompt: input.stream, options: opts })
|
|
351
|
+
q = query({ prompt: input.stream, options: { ...opts, ...(sessionId ? { resume: sessionId } : {}) } })
|
|
323
352
|
for await (const m of q) {
|
|
324
353
|
if (closed) break
|
|
325
354
|
switch (m.type) {
|
|
@@ -388,6 +417,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
388
417
|
if (m.session_id) sessionId = m.session_id
|
|
389
418
|
turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
|
|
390
419
|
turnActive = false // turn settled → stall watchdog stands down
|
|
420
|
+
restartCount = 0 // a successful turn refills the auto-restart budget
|
|
421
|
+
forceStopped = false
|
|
391
422
|
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 })
|
|
392
423
|
// Surface a usage/context meter (chrome, not a transcript line). The
|
|
393
424
|
// context window % comes from the control request; cost is cumulative.
|
|
@@ -406,9 +437,29 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
406
437
|
}
|
|
407
438
|
}
|
|
408
439
|
} catch (e) {
|
|
409
|
-
if (
|
|
440
|
+
if (closed) return
|
|
441
|
+
const msg = e?.message || String(e)
|
|
442
|
+
emit({ kind: 'error', message: msg })
|
|
443
|
+
// Auto-restart a transient upstream stream drop: re-run query() with
|
|
444
|
+
// resume so the conversation continues. Bounded by RESTART_MAX (reset on
|
|
445
|
+
// each successful result) so a persistent outage can't loop forever; a
|
|
446
|
+
// fresh input stream is needed because the throw killed the old iterator.
|
|
447
|
+
if (RECOVERABLE.test(msg) && restartCount < RESTART_MAX) {
|
|
448
|
+
restartCount++
|
|
449
|
+
turnActive = false
|
|
450
|
+
emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
|
|
451
|
+
input = makeInputStream()
|
|
452
|
+
setTimeout(runQuery, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
// Non-recoverable, or budget exhausted: mark done so sendTurn stops
|
|
456
|
+
// silently queuing into a dead stream. The bridge's onEvent self-heal
|
|
457
|
+
// still fires for "No conversation found" (it reopens fresh).
|
|
458
|
+
closed = true
|
|
459
|
+
turnActive = false
|
|
410
460
|
}
|
|
411
|
-
}
|
|
461
|
+
}
|
|
462
|
+
runQuery()
|
|
412
463
|
|
|
413
464
|
return {
|
|
414
465
|
sendTurn(text) { if (!closed) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
|
package/launcher.mjs
CHANGED
|
@@ -51,10 +51,14 @@ function serviceFile(room) {
|
|
|
51
51
|
export function detectState() {
|
|
52
52
|
const auth = readJson(path.join(CFG_DIR, 'auth.json'))
|
|
53
53
|
const provider = readJson(path.join(CFG_DIR, 'provider.json'))
|
|
54
|
+
// provider.json is written with { provider, baseUrl, model } (see provider.mjs).
|
|
55
|
+
// Anything whose `provider` field is set and not "anthropic" is a custom endpoint.
|
|
56
|
+
const isCustom = !!provider?.provider && provider.provider !== 'anthropic'
|
|
54
57
|
return {
|
|
55
58
|
loggedIn: !!auth?.refresh_token,
|
|
56
59
|
email: auth?.email || auth?.user?.email || null,
|
|
57
|
-
provider:
|
|
60
|
+
provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
|
|
61
|
+
providerModel: isCustom ? (provider.model || null) : null,
|
|
58
62
|
agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
|
|
59
63
|
accountSvc: fs.existsSync(serviceFile(null)),
|
|
60
64
|
cwd: process.cwd(),
|
|
@@ -68,7 +72,11 @@ const C = {
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
// io: { ask(prompt)->Promise<string>, print(s) }. actions: real | dry-run | mock.
|
|
71
|
-
|
|
75
|
+
// refresh: optional () => state — re-read disk after a mutating action so the header
|
|
76
|
+
// reflects the change (real bridge passes detectState; tests omit it to keep the
|
|
77
|
+
// injected state stable).
|
|
78
|
+
export async function runLauncher({ actions, io, state = detectState(), refresh = null }) {
|
|
79
|
+
const resync = () => { if (refresh) state = refresh() }
|
|
72
80
|
const askChoice = async (prompt, options, def = 1) => {
|
|
73
81
|
options.forEach((o, i) => io.print(` ${C.cyan(String(i + 1))}) ${o.label}${o.hint ? ' ' + C.dim(o.hint) : ''}`))
|
|
74
82
|
const a = await io.ask(`\n ${prompt} ${C.dim(`[${def}]`)} ${C.cyan('▸')} `)
|
|
@@ -137,25 +145,41 @@ export async function runLauncher({ actions, io, state = detectState() }) {
|
|
|
137
145
|
await actions.installService({ room, agentCmd: agent.cmd })
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
const providerMenu = async () => {
|
|
149
|
+
const cur = state.provider + (state.providerModel ? `, model ${state.providerModel}` : '')
|
|
150
|
+
const p = await askChoice('\n provider', [
|
|
151
|
+
{ label: 'Anthropic (default)', hint: 'regular Claude login' },
|
|
152
|
+
{ label: 'Custom endpoint', hint: 'Z.ai GLM, OpenRouter, your own proxy' },
|
|
153
|
+
{ label: '← Back', hint: `current: ${cur}` },
|
|
154
|
+
], 3) // Enter = Back
|
|
155
|
+
if (p === 2) return
|
|
156
|
+
if (p === 0) { await actions.setProvider({ kind: 'anthropic' }); resync(); io.print('\n ' + C.green('✓ back on regular Claude.')); return }
|
|
157
|
+
// custom — every step can be aborted with a blank line, which backs out cleanly.
|
|
158
|
+
const base = (await io.ask(`\n Base url ${C.dim('(blank to cancel · SDK appends /v1/messages)')} ${C.cyan('▸')} `)).trim()
|
|
159
|
+
if (!base) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
|
|
160
|
+
const token = (await io.ask(` API token ${C.dim('(blank to cancel)')} ${C.cyan('▸')} `)).trim()
|
|
161
|
+
if (!token) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
|
|
162
|
+
const model = (await io.ask(` Model ${C.dim('(blank = endpoint default · e.g. glm-4.6)')} ${C.cyan('▸')} `)).trim()
|
|
163
|
+
await actions.setProvider({ kind: 'custom', base, token, model: model || undefined })
|
|
164
|
+
resync()
|
|
165
|
+
io.print(`\n ${C.green('✓ provider set to custom.')}\n base url: ${state.provider}\n model: ${state.providerModel || '(endpoint default)'}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
140
168
|
const settingsMenu = async () => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (
|
|
169
|
+
for (;;) {
|
|
170
|
+
const pick = await askChoice('\n change', [
|
|
171
|
+
{ label: 'LLM provider', hint: `current: ${state.provider}` },
|
|
172
|
+
{ label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
173
|
+
{ label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
|
|
174
|
+
{ label: '← Back to main menu', hint: '' },
|
|
175
|
+
], 4) // Enter = Back
|
|
176
|
+
if (pick === 3) return
|
|
177
|
+
if (pick === 0) await providerMenu()
|
|
178
|
+
else if (pick === 1) { await actions.login(); resync(); return }
|
|
149
179
|
else {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
await actions.setProvider({ kind: 'custom', base, token })
|
|
180
|
+
if (state.accountSvc) await actions.uninstallService({ room: null })
|
|
181
|
+
else io.print(' ' + C.yellow('no background service installed'))
|
|
153
182
|
}
|
|
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
183
|
}
|
|
160
184
|
}
|
|
161
185
|
|