thinkpool-pair 0.7.114 → 0.7.116
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/bridge.mjs +57 -7
- package/claude-session.mjs +44 -5
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -450,11 +450,18 @@ const repoLabel = path.basename(cwd)
|
|
|
450
450
|
// thinkpool-pair's own version — surfaced in the room's welcome banner.
|
|
451
451
|
let VERSION = null
|
|
452
452
|
try { VERSION = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { /* unknown — banner omits it */ }
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
453
|
+
// Read the CURRENT branch LIVE (not once at boot) so the room's mode row reflects a
|
|
454
|
+
// branch switch instead of showing whatever branch the dir was on when the bridge spawned
|
|
455
|
+
// (the stale-branch bug). Handles a .git dir (normal checkout) AND a .git FILE (linked
|
|
456
|
+
// worktree → "gitdir: <path>"). Cheap byte reads, no subprocess.
|
|
457
|
+
function readBranch() {
|
|
458
|
+
try {
|
|
459
|
+
let gitPath = path.join(cwd, '.git')
|
|
460
|
+
if (fs.statSync(gitPath).isFile()) gitPath = fs.readFileSync(gitPath, 'utf8').replace(/^gitdir:\s*/i, '').trim()
|
|
461
|
+
const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim()
|
|
462
|
+
return head.startsWith('ref:') ? head.split('/').pop() : head.slice(0, 7)
|
|
463
|
+
} catch { return null /* not a git repo — fine */ }
|
|
464
|
+
}
|
|
458
465
|
|
|
459
466
|
// ── room file drops ────────────────────────────────────────────────
|
|
460
467
|
// Files dropped/pasted in the web room arrive as `file-put { id, name, url }`
|
|
@@ -522,6 +529,22 @@ function recordRoomSpend (tokens) {
|
|
|
522
529
|
if (BUDGET_OFF || !codeAuthToken || !room || !tokens) return
|
|
523
530
|
budgetRpc('record_spend', { p_room: room, p_tokens: tokens }).catch(() => { /* best-effort — next turn's check reconciles */ })
|
|
524
531
|
}
|
|
532
|
+
// Fire-and-forget: log a completed Code turn's BYOK token usage per-MODEL into
|
|
533
|
+
// chat_usage (via record_code_usage → attributes to the room owner) so the
|
|
534
|
+
// Settings "AI usage" card counts ThinkPool Code, not just chat. Independent of
|
|
535
|
+
// TP_BUDGET_OFF (that only governs the cap ledger; usage accounting should still
|
|
536
|
+
// run). input = full input incl. cache; cached = cache-read slice; both mapped to
|
|
537
|
+
// pricing.js's rowCostEur formula. No model → skip (can't price/label it).
|
|
538
|
+
function recordCodeUsage (model, usage) {
|
|
539
|
+
if (!codeAuthToken || !room || !model) return
|
|
540
|
+
const u = usage || {}
|
|
541
|
+
const input = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0)
|
|
542
|
+
const output = u.output_tokens || 0
|
|
543
|
+
const cached = u.cache_read_input_tokens || 0
|
|
544
|
+
if (!input && !output) return
|
|
545
|
+
budgetRpc('record_code_usage', { p_room: room, p_model: model, p_input: input, p_output: output, p_cached: cached })
|
|
546
|
+
.catch(() => { /* best-effort — usage accounting is not load-bearing for the turn */ })
|
|
547
|
+
}
|
|
525
548
|
// Pre-flight gate. Returns true if the turn must be REFUSED (room cap reached). Refusal is
|
|
526
549
|
// legible (a ◆ control line both readers + late-joiners see), never a silent stall.
|
|
527
550
|
async function budgetBlocked (s, term) {
|
|
@@ -660,7 +683,7 @@ const termNames = loadNames(room)
|
|
|
660
683
|
|
|
661
684
|
const announce = () =>
|
|
662
685
|
bcast('bridge', {
|
|
663
|
-
v: 2, name, repo: repoLabel, branch,
|
|
686
|
+
v: 2, name, repo: repoLabel, branch: readBranch(),
|
|
664
687
|
// cwd + version: the host's working dir + thinkpool-pair version, shown in
|
|
665
688
|
// the room's welcome banner. Re-sent per announce so late joiners get them.
|
|
666
689
|
cwd, version: VERSION,
|
|
@@ -1606,7 +1629,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1606
1629
|
}
|
|
1607
1630
|
// Budget caps — fold EVERY completed turn's output tokens into the room's durable
|
|
1608
1631
|
// monthly ledger (lanes + normal turns), so check_budget can pre-flight the next one.
|
|
1609
|
-
if (evt.kind === 'result' && evt.usage) recordRoomSpend(evt.usage.output_tokens || 0)
|
|
1632
|
+
if (evt.kind === 'result' && evt.usage) { recordRoomSpend(evt.usage.output_tokens || 0); recordCodeUsage(evt.model, evt.usage) }
|
|
1610
1633
|
// FL-B1 (lane) — a flow lane that ENDS ITS TURN is done: a bypass lane runs its slice
|
|
1611
1634
|
// to completion in one turn, then narrates "done" and stops. Models often skip the
|
|
1612
1635
|
// explicit signal (FLOW_DONE / mark_flow_done), which left the slice stuck 'dispatched'
|
|
@@ -1759,6 +1782,23 @@ function endStructured(id) {
|
|
|
1759
1782
|
if (s) announce()
|
|
1760
1783
|
}
|
|
1761
1784
|
|
|
1785
|
+
// Close every lane of a FINISHED flow — its task lanes AND its conductor — after a grace
|
|
1786
|
+
// so the final output / assembled result stays readable. A flow lane is keyed by
|
|
1787
|
+
// flowSessionId (task lanes) or spawnedBy `flow:<id>` (the conductor). Without this a flow
|
|
1788
|
+
// that assembles or is cancelled leaves its lanes running forever with no owner to close
|
|
1789
|
+
// them (only the spawner can close_terminal) — the dormant-lane bug Max hit.
|
|
1790
|
+
function cleanupFlowLanes(flowId, graceMs = 4000) {
|
|
1791
|
+
if (!flowId) return
|
|
1792
|
+
const marker = `flow:${flowId}`
|
|
1793
|
+
setTimeout(() => {
|
|
1794
|
+
for (const [sid, e] of [...sessions]) {
|
|
1795
|
+
if (e.flowSessionId === flowId || e.spawnedBy === marker) {
|
|
1796
|
+
try { endStructured(sid) } catch { /* already gone */ }
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}, graceMs)
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1762
1802
|
// Slice 3 — surface a pending update to the room as the "update ready" chip:
|
|
1763
1803
|
// broadcast a chrome code-event per structured term + stash it on the announce so
|
|
1764
1804
|
// reloads/late-joiners see it too. The restart itself is gated to between turns.
|
|
@@ -2311,6 +2351,7 @@ flowChannel
|
|
|
2311
2351
|
bcast('flow-assembled', { term: 'flow', flowId: payload.flowId, previewUrl, outDir, files: merged.files, conflicts: merged.conflicts }, flowChannel)
|
|
2312
2352
|
process.stderr.write(`\n ${A.mag}◆ flow assembled — ${merged.files.length} file(s)${merged.conflicts.length ? `, ${merged.conflicts.length} conflict(s)` : ''}${previewUrl ? ` · preview ${previewUrl}` : ''} (flow ${short}).${A.rst}\n`)
|
|
2313
2353
|
announce()
|
|
2354
|
+
cleanupFlowLanes(payload.flowId) // flow assembled → retire its lanes + conductor (no orphans)
|
|
2314
2355
|
} catch (e) {
|
|
2315
2356
|
process.stderr.write(`\n ${A.yel}◆ flow assemble failed: ${e?.message || e}${A.rst}\n`)
|
|
2316
2357
|
}
|
|
@@ -2359,6 +2400,15 @@ flowChannel
|
|
|
2359
2400
|
process.stderr.write(`\n ${A.yel}◆ flow slice reverted — ${payload.taskKey} (${branch}).${A.rst}\n`)
|
|
2360
2401
|
} catch (e) { process.stderr.write(`\n ${A.yel}◆ flow revert failed: ${e?.message || e}${A.rst}\n`) }
|
|
2361
2402
|
})
|
|
2403
|
+
.on('broadcast', { event: 'flow-cancel' }, ({ payload }) => {
|
|
2404
|
+
// A user cancelled the flow (or it was interrupted). The client also flips the DB
|
|
2405
|
+
// status; here we CLOSE the flow's lanes + conductor so they don't orphan (only the
|
|
2406
|
+
// spawner could close_terminal them otherwise — the dormant-lane bug). Host-gated.
|
|
2407
|
+
if (!payload?.flowId) return
|
|
2408
|
+
if (payload.host && payload.host !== name) return
|
|
2409
|
+
cleanupFlowLanes(payload.flowId, 600)
|
|
2410
|
+
process.stderr.write(`\n ${A.yel}◆ flow cancelled — closing its lanes (${String(payload.flowId).replace(/-/g, '').slice(0, 8)}).${A.rst}\n`)
|
|
2411
|
+
})
|
|
2362
2412
|
.subscribe()
|
|
2363
2413
|
|
|
2364
2414
|
// Watchdog — if the realtime channel is stuck (never connected, or dropped and
|
package/claude-session.mjs
CHANGED
|
@@ -152,6 +152,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
152
152
|
// open a terminal with TP_MCP_STRICT=1 (below) for the B-side to isolate MCP's share.
|
|
153
153
|
const spawnT0 = Date.now()
|
|
154
154
|
let readyLogged = false
|
|
155
|
+
let modelsSent = false // one-shot: emit the SDK's supported-model list on first init
|
|
155
156
|
let q = null // the live Query — control requests (interrupt /
|
|
156
157
|
// setPermissionMode) route through it once streaming.
|
|
157
158
|
// The session is CREATED in the caller's chosen mode (not hard-coded default):
|
|
@@ -170,6 +171,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
170
171
|
// per turn on `result`.
|
|
171
172
|
let turnBaseOut = 0 // output tokens from completed messages this turn
|
|
172
173
|
let curMsgOut = 0 // latest output_tokens for the in-flight message
|
|
174
|
+
let curModel = model || null // latest model id seen (init/system + ctx); stamped on
|
|
175
|
+
// the result event so the bridge can attribute BYOK Code
|
|
176
|
+
// spend per-model (record_code_usage → chat_usage).
|
|
173
177
|
// ── stall watchdog (C2) — stream silence ≠ done; a turn can stall (deltas pause
|
|
174
178
|
// 3+ min) or abort silently with no `result`. Track turn liveness + last event
|
|
175
179
|
// time; if an active turn goes quiet past STALL_MS, emit one `stalled` chrome
|
|
@@ -196,6 +200,22 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
196
200
|
// the catch self-heals.
|
|
197
201
|
const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
|
|
198
202
|
let forceStopped = false
|
|
203
|
+
// Waiting on a HUMAN decision (permission card / plan gate / AskUserQuestion)
|
|
204
|
+
// is NOT a wedge — the turn is correctly idle until the person answers. Every
|
|
205
|
+
// interactive gate goes through requestPermission, so wrap it once to bump a
|
|
206
|
+
// counter while a card is pending; the stall watchdog below stands down while
|
|
207
|
+
// awaitingUser > 0. Without this, a slow human answer (e.g. a late
|
|
208
|
+
// AskUserQuestion pick) trips FORCE_STOP_MS, the turn is force-stopped, and the
|
|
209
|
+
// answer lands on a dead turn ("agent went silent … send again to resume").
|
|
210
|
+
// On release, stamp lastEvtTs = now so the resumed turn isn't force-stopped on
|
|
211
|
+
// the very next tick by a now-stale (minutes-old) timestamp.
|
|
212
|
+
let awaitingUser = 0
|
|
213
|
+
const _requestPermission = requestPermission
|
|
214
|
+
requestPermission = async (req) => {
|
|
215
|
+
awaitingUser++
|
|
216
|
+
try { return await _requestPermission?.(req) }
|
|
217
|
+
finally { awaitingUser = Math.max(0, awaitingUser - 1); lastEvtTs = Date.now() }
|
|
218
|
+
}
|
|
199
219
|
// Set while an interrupt is settling. q.interrupt() doesn't end the turn cleanly on this
|
|
200
220
|
// SDK — it surfaces a REDUNDANT pair of terminal results (subtype `aborted` AND
|
|
201
221
|
// `error_during_execution`) and re-inits the session. abort() emits the single canonical
|
|
@@ -224,7 +244,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
224
244
|
|
|
225
245
|
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 */ } }
|
|
226
246
|
const stallTimer = setInterval(() => {
|
|
227
|
-
if (!turnActive) return
|
|
247
|
+
if (!turnActive || awaitingUser > 0) return // awaiting a human decision ≠ wedged
|
|
228
248
|
const quiet = Date.now() - lastEvtTs
|
|
229
249
|
if (quiet > FORCE_STOP_MS && !forceStopped) {
|
|
230
250
|
// True wedge — no result, no error, just silence. Unblocks between-turns
|
|
@@ -649,7 +669,22 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
649
669
|
// m.slash_commands (init message) — the commands this session really
|
|
650
670
|
// supports: built-ins + the host's custom .claude/commands. Surfaced
|
|
651
671
|
// so the room composer's autocomplete lists what ACTUALLY exists.
|
|
672
|
+
curModel = m.model || model || curModel
|
|
652
673
|
emit({ kind: 'system', sessionId, model: m.model || model || null, commands: Array.isArray(m.slash_commands) ? m.slash_commands : undefined })
|
|
674
|
+
// Dynamic model list — ask the SDK for its OWN supported models and surface
|
|
675
|
+
// them to the room so the /model picker renders live options (incl. new
|
|
676
|
+
// models like Fable) instead of a hardcoded three-item list. Fire-and-forget:
|
|
677
|
+
// older rooms just never receive it and keep their static fallback; a query
|
|
678
|
+
// that lacks supportedModels() (old SDK) silently no-ops. One-shot per session.
|
|
679
|
+
if (!modelsSent) {
|
|
680
|
+
modelsSent = true
|
|
681
|
+
Promise.resolve(q?.supportedModels?.()).then((ms) => {
|
|
682
|
+
const models = (ms || [])
|
|
683
|
+
.map((x) => ({ value: x?.value, displayName: x?.displayName, description: x?.description }))
|
|
684
|
+
.filter((x) => x.value)
|
|
685
|
+
if (models.length) emit({ kind: 'models', models })
|
|
686
|
+
}).catch(() => { /* no list available — room keeps its fallback */ })
|
|
687
|
+
}
|
|
653
688
|
break
|
|
654
689
|
case 'assistant':
|
|
655
690
|
// Stamp tool-call start times so tool_result can report a duration.
|
|
@@ -700,7 +735,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
700
735
|
turnActive = false // turn settled → stall watchdog stands down
|
|
701
736
|
restartCount = 0 // a successful turn refills the auto-restart budget
|
|
702
737
|
forceStopped = false
|
|
703
|
-
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 })
|
|
738
|
+
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 })
|
|
704
739
|
// Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
|
|
705
740
|
// the window, generate one from the last assistant reply. (Skip aborted turns.)
|
|
706
741
|
if (sugTimer) clearTimeout(sugTimer)
|
|
@@ -711,7 +746,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
711
746
|
let ctx = null
|
|
712
747
|
try {
|
|
713
748
|
const c = await q?.getContextUsage?.()
|
|
714
|
-
if (c) ctx = { used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model }
|
|
749
|
+
if (c) { ctx = { used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model }; if (c.model) curModel = c.model }
|
|
715
750
|
} catch { /* control req may be unavailable */ }
|
|
716
751
|
const u = m.usage || {}
|
|
717
752
|
emit({ kind: 'usage', costUsd: m.total_cost_usd ?? null, tokens: (u.input_tokens || 0) + (u.output_tokens || 0), ctx })
|
|
@@ -775,8 +810,12 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
775
810
|
async listModels() {
|
|
776
811
|
try {
|
|
777
812
|
const ms = await q?.supportedModels?.()
|
|
778
|
-
const
|
|
779
|
-
|
|
813
|
+
const models = (ms || [])
|
|
814
|
+
.map((x) => ({ value: x?.value, displayName: x?.displayName, description: x?.description }))
|
|
815
|
+
.filter((x) => x.value)
|
|
816
|
+
// Structured event drives the picker; the note is a human-readable echo for the log.
|
|
817
|
+
if (models.length) emit({ kind: 'models', models })
|
|
818
|
+
emit({ kind: 'note', text: models.length ? `models: ${models.map((x) => x.value).join(', ')}` : 'no model list available' })
|
|
780
819
|
} catch { emit({ kind: 'note', text: 'usage: /model <name>' }) }
|
|
781
820
|
},
|
|
782
821
|
// Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
|