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 +40 -6
- package/claude-session.mjs +40 -3
- 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
|
@@ -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
|
|
761
|
-
|
|
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
|