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 +40 -6
- package/claude-session.mjs +21 -2
- 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 }`
|
|
@@ -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
|
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):
|
|
@@ -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
|
|
779
|
-
|
|
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
|