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 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
- let branch = null
454
- try {
455
- const head = fs.readFileSync(path.join(cwd, '.git', 'HEAD'), 'utf8').trim()
456
- branch = head.startsWith('ref:') ? head.split('/').pop() : head.slice(0, 7)
457
- } catch { /* not a git repo — fine */ }
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
@@ -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 names = (ms || []).map((x) => x.model || x.id || x.name).filter(Boolean)
779
- emit({ kind: 'note', text: names.length ? `models: ${names.join(', ')}` : 'no model list available' })
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.114",
3
+ "version": "0.7.116",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {