thinkpool-pair 0.7.142 → 0.7.144
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 -1
- package/claude-session.mjs +25 -6
- package/cross-terminal.mjs +34 -0
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -81,7 +81,7 @@ const flowRedispatch = new Map()
|
|
|
81
81
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
82
82
|
// broadcasts; without persistent state the cap can never bite.
|
|
83
83
|
const flowBudgets = new Map()
|
|
84
|
-
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision } from './cross-terminal.mjs'
|
|
84
|
+
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
|
|
85
85
|
import { turnInFlight } from './update-gate.mjs'
|
|
86
86
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, loadSummary, saveSummary } from './session-store.mjs'
|
|
87
87
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
|
|
@@ -1244,6 +1244,24 @@ function restoredTurnOpen(log) {
|
|
|
1244
1244
|
return false
|
|
1245
1245
|
}
|
|
1246
1246
|
|
|
1247
|
+
// Active-worktree snapshot for the per-turn ROOM NOW tail. Cached ~60s per cwd —
|
|
1248
|
+
// a subprocess on every turn of every lane would be waste, and worktrees change on
|
|
1249
|
+
// minutes-scale. Plain `git worktree list` output (path · sha · [branch]) is the
|
|
1250
|
+
// readable shape an agent acts on. Fail-quiet [] — a snapshot must never break a turn.
|
|
1251
|
+
const _wtCache = new Map() // cwd → { at, list }
|
|
1252
|
+
function worktreeSnapshot(cwd) {
|
|
1253
|
+
const key = cwd || process.cwd()
|
|
1254
|
+
const hit = _wtCache.get(key)
|
|
1255
|
+
if (hit && Date.now() - hit.at < 60_000) return hit.list
|
|
1256
|
+
let list = []
|
|
1257
|
+
try {
|
|
1258
|
+
list = execFileSync('git', ['-C', key, 'worktree', 'list'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
1259
|
+
.split('\n').map(l => l.trim()).filter(Boolean)
|
|
1260
|
+
} catch { list = [] } // not a git repo / git missing → no worktree line in ROOM NOW
|
|
1261
|
+
_wtCache.set(key, { at: Date.now(), list })
|
|
1262
|
+
return list
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1247
1265
|
// The Phase-2 path: instead of a PTY, run Claude Code through the Agent SDK and
|
|
1248
1266
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1249
1267
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
@@ -1658,6 +1676,14 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1658
1676
|
entry.deferred = !!defer // a lazily-booted idle terminal — the background warmer will boot it
|
|
1659
1677
|
entry.session = startClaudeSession({
|
|
1660
1678
|
cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
|
|
1679
|
+
// Live ROOM NOW tail appended to every turn's <system-reminder>: sibling lanes
|
|
1680
|
+
// (same registries the read_terminal roster uses) + active git worktrees (only
|
|
1681
|
+
// when >1 — a solo checkout has nothing to coordinate about). Keeps every lane
|
|
1682
|
+
// aware of the room's CURRENT state, not just its features (2026-07-02 ask).
|
|
1683
|
+
roomContext: () => {
|
|
1684
|
+
const wt = worktreeSnapshot(cwd || process.cwd())
|
|
1685
|
+
return formatRoomNow({ selfId: id, sessions, terms, names: termNames, worktrees: wt.length > 1 ? wt : [] })
|
|
1686
|
+
},
|
|
1661
1687
|
// defer: a restored-idle terminal cold-boots its query only on first use — restore is
|
|
1662
1688
|
// then instant for display, and only used terminals pay the ~50s MCP boot (Max, 2026-07-02).
|
|
1663
1689
|
lazy: !!defer,
|
|
@@ -2215,6 +2241,19 @@ channel
|
|
|
2215
2241
|
if (await budgetBlocked(s, payload.term)) return
|
|
2216
2242
|
s.session.sendTurn(text)
|
|
2217
2243
|
echoYou()
|
|
2244
|
+
// Pool-order fix (2026-07-02): emit the "dispatched to agent" marker HERE, right after
|
|
2245
|
+
// the @pool you-line, so it inherits the NEXT seq (seqable('pool') is true) and orders
|
|
2246
|
+
// correctly for EVERY viewer. It used to be pushed web-side with the FIRER's wall-clock
|
|
2247
|
+
// ts; a remote viewer sorted that against the bridge-clock you-line ts, so the two
|
|
2248
|
+
// inverted — "Pool dispatched" showed ABOVE "<name> @pool" for the OTHER person (the
|
|
2249
|
+
// firer's own view was right only because their optimistic you-line shared its clock).
|
|
2250
|
+
// seq ordering is clock-independent → consistent for firer + peers, and pushLog archives
|
|
2251
|
+
// it durably so it survives reload/replay too. Skips silent turns (no you-line to trail).
|
|
2252
|
+
if (payload.pooled && !payload.silent) {
|
|
2253
|
+
const note = { kind: 'pool', text: 'dispatched to agent' }
|
|
2254
|
+
pushLog(s, note) // stamps ts+cid, assigns seq = you-line.seq + 1, archives durably
|
|
2255
|
+
bcast('code-event', { term: payload.term, evt: note })
|
|
2256
|
+
}
|
|
2218
2257
|
})
|
|
2219
2258
|
.on('broadcast', { event: 'code-perm' }, ({ payload }) => {
|
|
2220
2259
|
const s = payload?.term && sessions.get(payload.term)
|
package/claude-session.mjs
CHANGED
|
@@ -129,12 +129,17 @@ const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
|
129
129
|
// or narrates instead of showing, forgetting it's driven from a phone. So we re-state
|
|
130
130
|
// the highest-drift rules as a compact <system-reminder> appended to EVERY user turn,
|
|
131
131
|
// adjacent to where the model's attention actually is — the same trick the host
|
|
132
|
-
// harness uses to keep CLAUDE.md alive. ~
|
|
132
|
+
// harness uses to keep CLAUDE.md alive. ~250 tokens/turn; changes nothing but salience.
|
|
133
|
+
// A live "ROOM NOW" tail (sibling lanes + active worktrees) is appended per turn by
|
|
134
|
+
// the bridge's roomContext callback — see roomReminder() inside startClaudeSession.
|
|
133
135
|
const TP_ROOM_REMINDER = [
|
|
134
136
|
'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.',
|
|
137
|
+
'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 — mockups and mockup-iterate output included — use the $TP_MOCKUP_OUTBOX render helper (inline desktop+mobile card) and/or publish to a GitHub Pages URL and post that link.',
|
|
138
|
+
'REMOTE USER: the people here may have no terminal and no access to this host — never ask them to run a local command, open a local file, or check something on the machine. Anything host-side, YOU run, and show the result.',
|
|
136
139
|
'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),
|
|
140
|
+
'ENSEMBLE: delegated work goes through the room — fan parallel work out with spawn_terminal (your own fresh lanes, visible and steerable by the people here), NOT the built-in invisible Task/Agent subagents; never hijack a busy sibling.',
|
|
141
|
+
'PEER: before substantive work, check what the other lanes are doing (the ROOM NOW snapshot below, read_terminal for detail; list_sessions/read_session across rooms) — coordinate on shared files/branches instead of colliding.',
|
|
142
|
+
'WORKTREES: parallel lanes share one repo — before code edits run `git worktree list`; if linked worktrees exist, take your OWN worktree + branch, never the shared checkout or a branch another lane is on.',
|
|
138
143
|
'FLOW / PLANS: write plans as normal chat messages as you form them — the room does not surface plan files or ExitPlanMode text.',
|
|
139
144
|
'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
|
|
140
145
|
].join(' ')
|
|
@@ -181,7 +186,17 @@ export async function oneShotSummary({ prompt, env, cwd, timeoutMs = 25000 }) {
|
|
|
181
186
|
return null
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
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, lazy = false }) {
|
|
189
|
+
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, lazy = false, roomContext = null }) {
|
|
190
|
+
// Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
|
|
191
|
+
// room's CURRENT state — sibling lanes, active git worktrees — or null. The static
|
|
192
|
+
// rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
|
|
193
|
+
// the room's STATE (2026-07-02 ask: "aware at all points that it's in a ThinkPool
|
|
194
|
+
// Code session"). A broken snapshot must never break a turn — fail-quiet to static.
|
|
195
|
+
const roomReminder = () => {
|
|
196
|
+
let live = null
|
|
197
|
+
try { live = roomContext ? roomContext() : null } catch { live = null }
|
|
198
|
+
return `<system-reminder>\n${TP_ROOM_REMINDER}${live ? `\n\n${live}` : ''}\n</system-reminder>`
|
|
199
|
+
}
|
|
185
200
|
const ac = new AbortController()
|
|
186
201
|
let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
|
|
187
202
|
let sessionId = resume || null
|
|
@@ -619,6 +634,10 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
619
634
|
'CROSS-TERMINAL SPAWN: when a person asks you to fan work out across multiple agents — research lanes, parallel sub-tasks, a swarm — open YOUR OWN fresh lanes with spawn_terminal(name?, task?, model?); do NOT dump the work into siblings that are already busy (a sibling is mid-task unless read_terminal shows it idle, and hijacking it derails their work). spawn_terminal opens a brand-new Claude lane and, if you pass `task`, hands it that task immediately; it appears as a new terminal in the room. A spawned lane INHERITS your current permission mode by default — so if you are in bypass, your lanes run autonomously with no per-action clicking; pass `mode` only to override. Raising a lane to bypass from a non-bypass lane asks the room to confirm once (everything else spawns without a prompt). Collect each lane\'s result later with read_terminal (a spawned lane cannot post back to you), and tidy up with close_terminal once you have what you need — you may only close lanes you spawned yourself. You are bounded: a few new lanes per turn, a per-plan dispatch ceiling on concurrent spawned lanes (Free 3 / Plus 6) that is separate from the human terminals so it never blocks them, an overall room machine cap, and a lane you were spawned/cross-posted into cannot itself spawn (so no runaway). This is how you "open the terminals yourself" instead of asking a person to.',
|
|
620
635
|
'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
|
|
621
636
|
'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. All of this works only under the ThinkPool account bridge; a standalone room sees just its own terminals.',
|
|
637
|
+
'SUBAGENT POLICY: in this room, delegated work goes through the Ensemble. When you fan out research, parallel sub-tasks, reviews, or any substantive multi-step delegated work, open spawn_terminal lanes — they are visible in the room, the people can watch and steer them, and every other lane can peer at their work. Do NOT reach for the built-in Task/Agent subagents for that work: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble. The only exception is a trivial, seconds-scale read-only lookup where a room lane would be pure overhead.',
|
|
638
|
+
'PEER FIRST: other lanes may be working in the same repo as you, right now. Before starting substantive work — and before any code edit that could overlap another lane — check what the room is doing: the ROOM NOW snapshot appended to your latest turn, or read_terminal for detail; list_sessions/read_session when the question spans your other rooms. If a sibling is touching the same files or branch, coordinate (read its lane, or raise it in chat) instead of colliding.',
|
|
639
|
+
'WORKTREES: parallel lanes share one machine and usually one repo. Run `git worktree list` before your first code edit; if linked worktrees exist, the shared main checkout is contended (and may be guard-blocked) — do your work in your OWN worktree on your OWN branch (`git worktree add <dir> -b <branch>`), and never edit a checkout or ride a branch another lane is using.',
|
|
640
|
+
'REMOTE USER: the people driving this room may be on a phone, with NO terminal and no access to this host. Never ask them to run a local command, open a local file, or "go check" something on the machine — anything that must run on the host, you run yourself and show the output in the room. Only suggest actions they can actually do from the room UI or a browser.',
|
|
622
641
|
'WRITE PLANS INTO THE CHAT: whenever you form or revise a plan — because the room is in Plan mode, or because someone asked you to plan, design, or think it through first — write the actual plan out as a normal message in the room as you develop it: the approach, the concrete steps, the files you will touch, the open questions. The room does NOT surface plan files at all, and the plan-approval card does not reliably carry the plan text, so a plan that lives only in a plan file or only inside ExitPlanMode is INVISIBLE to the people you are working with — they just see "plan ready" with no content. The chat is the canonical place your plan lives; put it there so the room can read and react to it before you proceed.',
|
|
623
642
|
].join(' '),
|
|
624
643
|
// Needed for live thinking-token progress (SDKThinkingTokensMessage) to
|
|
@@ -846,7 +865,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
846
865
|
restartTimer = null
|
|
847
866
|
if (closed) return
|
|
848
867
|
runQuery()
|
|
849
|
-
if (replay != null) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: replay }, { type: 'text', text:
|
|
868
|
+
if (replay != null) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: replay }, { type: 'text', text: roomReminder() }]) }
|
|
850
869
|
}, 300)
|
|
851
870
|
break
|
|
852
871
|
}
|
|
@@ -987,7 +1006,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
987
1006
|
return {
|
|
988
1007
|
// A lazy (restored-idle) session cold-boots the query on its first turn. input.push
|
|
989
1008
|
// is queue-backed, so the pushed turn buffers and runs once the query is ready.
|
|
990
|
-
sendTurn(text) { if (!closed) { if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: String(text) }, { type: 'text', text:
|
|
1009
|
+
sendTurn(text) { if (!closed) { if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: String(text) }, { type: 'text', text: roomReminder() }]) } },
|
|
991
1010
|
// Cold-boot the query WITHOUT sending a turn — the background warmer calls this on
|
|
992
1011
|
// lazily-restored idle terminals so they're ready before the user clicks them.
|
|
993
1012
|
warm() { if (!started && !closed) runQuery() },
|
package/cross-terminal.mjs
CHANGED
|
@@ -140,6 +140,40 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
|
|
|
140
140
|
return `Terminal ${label(hit)} (${hit.kind} · ${hit.cmd}) — last ${n} lines:\n${body}`
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// ── Live per-turn room snapshot ("ROOM NOW") ─────────────────────────────────
|
|
144
|
+
// The compact tail claude-session appends to EVERY user turn's <system-reminder>
|
|
145
|
+
// (via the bridge's roomContext callback), so a lane sees the room's CURRENT
|
|
146
|
+
// state — sibling lanes + the host's active git worktrees — adjacent to where its
|
|
147
|
+
// attention actually is, instead of only knowing the peek tools exist. Same data
|
|
148
|
+
// as the formatPeek roster, tighter: capped lanes, shorter previews. Returns null
|
|
149
|
+
// when there is nothing to show (the reminder then carries no tail at all).
|
|
150
|
+
// Spec ask (2026-07-02): "terminals need to be completely aware of the session."
|
|
151
|
+
export const ROOMNOW = { laneCap: 8, previewLen: 70, wtCap: 10 }
|
|
152
|
+
export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees = [] } = {}, limits = ROOMNOW) => {
|
|
153
|
+
const sib = siblingsOf({ selfId, sessions, terms, names })
|
|
154
|
+
const out = []
|
|
155
|
+
if (sib.length) {
|
|
156
|
+
const shown = sib.slice(0, limits.laneCap)
|
|
157
|
+
const rows = shown.map(x => {
|
|
158
|
+
let preview
|
|
159
|
+
if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, limits.previewLen)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
|
|
160
|
+
else preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)'
|
|
161
|
+
return `- ${label(x)} · ${x.kind} — ${clip(preview, limits.previewLen)}`
|
|
162
|
+
})
|
|
163
|
+
if (sib.length > shown.length) rows.push(`- … +${sib.length - shown.length} more (read_terminal lists all)`)
|
|
164
|
+
out.push(`Other lanes in this room right now (read_terminal for detail):\n${rows.join('\n')}`)
|
|
165
|
+
}
|
|
166
|
+
const wtAll = Array.isArray(worktrees) ? worktrees : []
|
|
167
|
+
const wt = wtAll.slice(0, limits.wtCap)
|
|
168
|
+
if (wt.length) {
|
|
169
|
+
const rows = wt.map(w => `- ${clip(w, limits.previewLen + 30)}`)
|
|
170
|
+
if (wtAll.length > wt.length) rows.push(`- … +${wtAll.length - wt.length} more (\`git worktree list\` shows all)`)
|
|
171
|
+
out.push(`Active git worktrees on this host (code edits go in your OWN worktree — never one another lane is on):\n${rows.join('\n')}`)
|
|
172
|
+
}
|
|
173
|
+
if (!out.length) return null
|
|
174
|
+
return `ROOM NOW (live snapshot, this turn):\n${out.join('\n')}`
|
|
175
|
+
}
|
|
176
|
+
|
|
143
177
|
// ── Tier C: cross-terminal POST (directed, gated cross-lane action) ──────────
|
|
144
178
|
// Pure gate for whether a session may post into a sibling lane on THIS call:
|
|
145
179
|
// kill-switch, loop-breaker (hop), per-turn cap. The routing itself (sendTurn
|