thinkpool-pair 0.7.143 → 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 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,
@@ -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. ~120 tokens/turn; changes nothing but salience.
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), coordinate via read_terminal / list_sessions; never hijack a busy sibling.',
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: `<system-reminder>\n${TP_ROOM_REMINDER}\n</system-reminder>` }]) }
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: `<system-reminder>\n${TP_ROOM_REMINDER}\n</system-reminder>` }]) } },
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() },
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.143",
3
+ "version": "0.7.144",
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": {