thinkpool-pair 0.7.113 → 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
@@ -121,6 +121,24 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
121
121
  */
122
122
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
123
123
 
124
+ // Per-turn salience reminder. The full ThinkPool Code ruleset lives in
125
+ // `appendSystemPrompt` (baked once at session start), but on a long session — heavy
126
+ // tool output, and especially after auto-compaction — the model's attention drifts
127
+ // off a system prompt that sits behind the large host CLAUDE.md. The classic tell:
128
+ // the agent hands the room a host-local path (`open …`, "it's in ~/claude-shots/")
129
+ // or narrates instead of showing, forgetting it's driven from a phone. So we re-state
130
+ // the highest-drift rules as a compact <system-reminder> appended to EVERY user turn,
131
+ // adjacent to where the model's attention actually is — the same trick the host
132
+ // harness uses to keep CLAUDE.md alive. ~120 tokens/turn; changes nothing but salience.
133
+ const TP_ROOM_REMINDER = [
134
+ 'You are Claude in a ThinkPool Code room, driven live from a phone or browser — NOT a local terminal. Keep using the room\'s features.',
135
+ 'ARTIFACTS: never hand the room a local path, a file:// URL, a localhost address, an `open …`/Preview reference, or "it\'s in ~/claude-shots/" — the room cannot see the host. Surface every image by reading the PNG into your turn so it renders inline; for HTML use the $TP_MOCKUP_OUTBOX render helper (inline desktop+mobile card) and/or publish to a GitHub Pages URL and post that link.',
136
+ 'SHOW, don\'t narrate: a picture of the result beats a wall of text on a phone — err toward more screenshots.',
137
+ 'ENSEMBLE: fan parallel work out with spawn_terminal (your own fresh lanes), coordinate via read_terminal / list_sessions; never hijack a busy sibling.',
138
+ 'FLOW / PLANS: write plans as normal chat messages as you form them — the room does not surface plan files or ExitPlanMode text.',
139
+ 'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
140
+ ].join(' ')
141
+
124
142
  export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null }) {
125
143
  const ac = new AbortController()
126
144
  let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
@@ -134,6 +152,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
134
152
  // open a terminal with TP_MCP_STRICT=1 (below) for the B-side to isolate MCP's share.
135
153
  const spawnT0 = Date.now()
136
154
  let readyLogged = false
155
+ let modelsSent = false // one-shot: emit the SDK's supported-model list on first init
137
156
  let q = null // the live Query — control requests (interrupt /
138
157
  // setPermissionMode) route through it once streaming.
139
158
  // The session is CREATED in the caller's chosen mode (not hard-coded default):
@@ -632,6 +651,20 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
632
651
  // supports: built-ins + the host's custom .claude/commands. Surfaced
633
652
  // so the room composer's autocomplete lists what ACTUALLY exists.
634
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
+ }
635
668
  break
636
669
  case 'assistant':
637
670
  // Stamp tool-call start times so tool_result can report a duration.
@@ -736,7 +769,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
736
769
  runQuery()
737
770
 
738
771
  return {
739
- sendTurn(text) { if (!closed) { turnActive = true; sawSuggestion = false; if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
772
+ sendTurn(text) { if (!closed) { turnActive = true; sawSuggestion = false; if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: String(text) }, { type: 'text', text: `<system-reminder>\n${TP_ROOM_REMINDER}\n</system-reminder>` }]) } },
740
773
  // Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
741
774
  // streaming control request (drives plan-mode behaviour SDK-side); the local
742
775
  // `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
@@ -757,8 +790,12 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
757
790
  async listModels() {
758
791
  try {
759
792
  const ms = await q?.supportedModels?.()
760
- const names = (ms || []).map((x) => x.model || x.id || x.name).filter(Boolean)
761
- 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' })
762
799
  } catch { emit({ kind: 'note', text: 'usage: /model <name>' }) }
763
800
  },
764
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.113",
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": {