thinkpool-pair 0.7.114 → 0.7.115

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 }`
@@ -660,7 +667,7 @@ const termNames = loadNames(room)
660
667
 
661
668
  const announce = () =>
662
669
  bcast('bridge', {
663
- v: 2, name, repo: repoLabel, branch,
670
+ v: 2, name, repo: repoLabel, branch: readBranch(),
664
671
  // cwd + version: the host's working dir + thinkpool-pair version, shown in
665
672
  // the room's welcome banner. Re-sent per announce so late joiners get them.
666
673
  cwd, version: VERSION,
@@ -1759,6 +1766,23 @@ function endStructured(id) {
1759
1766
  if (s) announce()
1760
1767
  }
1761
1768
 
1769
+ // Close every lane of a FINISHED flow — its task lanes AND its conductor — after a grace
1770
+ // so the final output / assembled result stays readable. A flow lane is keyed by
1771
+ // flowSessionId (task lanes) or spawnedBy `flow:<id>` (the conductor). Without this a flow
1772
+ // that assembles or is cancelled leaves its lanes running forever with no owner to close
1773
+ // them (only the spawner can close_terminal) — the dormant-lane bug Max hit.
1774
+ function cleanupFlowLanes(flowId, graceMs = 4000) {
1775
+ if (!flowId) return
1776
+ const marker = `flow:${flowId}`
1777
+ setTimeout(() => {
1778
+ for (const [sid, e] of [...sessions]) {
1779
+ if (e.flowSessionId === flowId || e.spawnedBy === marker) {
1780
+ try { endStructured(sid) } catch { /* already gone */ }
1781
+ }
1782
+ }
1783
+ }, graceMs)
1784
+ }
1785
+
1762
1786
  // Slice 3 — surface a pending update to the room as the "update ready" chip:
1763
1787
  // broadcast a chrome code-event per structured term + stash it on the announce so
1764
1788
  // reloads/late-joiners see it too. The restart itself is gated to between turns.
@@ -2311,6 +2335,7 @@ flowChannel
2311
2335
  bcast('flow-assembled', { term: 'flow', flowId: payload.flowId, previewUrl, outDir, files: merged.files, conflicts: merged.conflicts }, flowChannel)
2312
2336
  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
2337
  announce()
2338
+ cleanupFlowLanes(payload.flowId) // flow assembled → retire its lanes + conductor (no orphans)
2314
2339
  } catch (e) {
2315
2340
  process.stderr.write(`\n ${A.yel}◆ flow assemble failed: ${e?.message || e}${A.rst}\n`)
2316
2341
  }
@@ -2359,6 +2384,15 @@ flowChannel
2359
2384
  process.stderr.write(`\n ${A.yel}◆ flow slice reverted — ${payload.taskKey} (${branch}).${A.rst}\n`)
2360
2385
  } catch (e) { process.stderr.write(`\n ${A.yel}◆ flow revert failed: ${e?.message || e}${A.rst}\n`) }
2361
2386
  })
2387
+ .on('broadcast', { event: 'flow-cancel' }, ({ payload }) => {
2388
+ // A user cancelled the flow (or it was interrupted). The client also flips the DB
2389
+ // status; here we CLOSE the flow's lanes + conductor so they don't orphan (only the
2390
+ // spawner could close_terminal them otherwise — the dormant-lane bug). Host-gated.
2391
+ if (!payload?.flowId) return
2392
+ if (payload.host && payload.host !== name) return
2393
+ cleanupFlowLanes(payload.flowId, 600)
2394
+ process.stderr.write(`\n ${A.yel}◆ flow cancelled — closing its lanes (${String(payload.flowId).replace(/-/g, '').slice(0, 8)}).${A.rst}\n`)
2395
+ })
2362
2396
  .subscribe()
2363
2397
 
2364
2398
  // 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):
@@ -650,6 +651,20 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
650
651
  // supports: built-ins + the host's custom .claude/commands. Surfaced
651
652
  // so the room composer's autocomplete lists what ACTUALLY exists.
652
653
  emit({ kind: 'system', sessionId, model: m.model || model || null, commands: Array.isArray(m.slash_commands) ? m.slash_commands : undefined })
654
+ // Dynamic model list — ask the SDK for its OWN supported models and surface
655
+ // them to the room so the /model picker renders live options (incl. new
656
+ // models like Fable) instead of a hardcoded three-item list. Fire-and-forget:
657
+ // older rooms just never receive it and keep their static fallback; a query
658
+ // that lacks supportedModels() (old SDK) silently no-ops. One-shot per session.
659
+ if (!modelsSent) {
660
+ modelsSent = true
661
+ Promise.resolve(q?.supportedModels?.()).then((ms) => {
662
+ const models = (ms || [])
663
+ .map((x) => ({ value: x?.value, displayName: x?.displayName, description: x?.description }))
664
+ .filter((x) => x.value)
665
+ if (models.length) emit({ kind: 'models', models })
666
+ }).catch(() => { /* no list available — room keeps its fallback */ })
667
+ }
653
668
  break
654
669
  case 'assistant':
655
670
  // Stamp tool-call start times so tool_result can report a duration.
@@ -775,8 +790,12 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
775
790
  async listModels() {
776
791
  try {
777
792
  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' })
793
+ const models = (ms || [])
794
+ .map((x) => ({ value: x?.value, displayName: x?.displayName, description: x?.description }))
795
+ .filter((x) => x.value)
796
+ // Structured event drives the picker; the note is a human-readable echo for the log.
797
+ if (models.length) emit({ kind: 'models', models })
798
+ emit({ kind: 'note', text: models.length ? `models: ${models.map((x) => x.value).join(', ')}` : 'no model list available' })
780
799
  } catch { emit({ kind: 'note', text: 'usage: /model <name>' }) }
781
800
  },
782
801
  // 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.115",
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": {