thinkpool-pair 0.7.328 → 0.7.331

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.
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # bridge/bridge-service.sh — install thinkpool-pair as an AUTO-UPDATING launchd
3
+ # service for a room, with a fast update poll so a new `npm publish` lands in the
4
+ # room within ~1 min of an idle moment (no manual restart, no re-install).
5
+ #
6
+ # The stock `thinkpool-pair install-service <ROOM>` already runs under launchd
7
+ # KeepAlive with `npx -y thinkpool-pair@latest` (so each restart fetches the newest
8
+ # publish) and sets THINKPOOL_PAIR_AUTOUPDATE=1 — but it leaves the poll interval at
9
+ # the 30-min default. This wrapper installs it, then injects the faster poll/idle
10
+ # knobs into the generated LaunchAgent plist and reloads it.
11
+ #
12
+ # Usage: bash bridge/bridge-service.sh <ROOM> [-- <extra bridge args>]
13
+ # e.g. bash bridge/bridge-service.sh P6O6I # default: --headless --auto=claude
14
+ # bash bridge/bridge-service.sh P6O6I -- --headless --auto=claude
15
+ # Env: TP_UPDATE_INTERVAL (default 60) registry poll seconds
16
+ # TP_UPDATE_IDLE (default 20) idle seconds before an update restart
17
+ # TP_FORCE=1 skip the "bare bridge already running" guard
18
+ # Remove: npx thinkpool-pair@latest uninstall-service <ROOM>
19
+ set -euo pipefail
20
+
21
+ ROOM_RAW="${1:-}"
22
+ [ -n "$ROOM_RAW" ] && [ "${ROOM_RAW#-}" = "$ROOM_RAW" ] || { echo "usage: bash bridge/bridge-service.sh <ROOM> [-- <extra bridge args>]"; exit 1; }
23
+ ROOM="$(printf '%s' "$ROOM_RAW" | tr '[:lower:]' '[:upper:]')"
24
+ shift || true
25
+ # Everything after a literal `--` is forwarded to the bridge; default to the
26
+ # headless auto-claude invocation the rooms run interactively today.
27
+ TAIL=()
28
+ if [ "${1:-}" = "--" ]; then shift; TAIL=("$@"); else TAIL=(--headless --auto=claude); fi
29
+
30
+ INTERVAL="${TP_UPDATE_INTERVAL:-60}"
31
+ IDLE="${TP_UPDATE_IDLE:-20}"
32
+ PLIST="$HOME/Library/LaunchAgents/io.thinkpool.pair.$(printf '%s' "$ROOM" | tr '[:upper:]' '[:lower:]').plist"
33
+
34
+ [ "$(uname)" = "Darwin" ] || { echo "✗ this wrapper is macOS/launchd only (Linux: use systemd via 'npx thinkpool-pair@latest install-service')"; exit 1; }
35
+
36
+ # Guard: a bare (foreground) bridge for this room would fight the service —
37
+ # two relays on one realtime channel duplicate everything. Stop it first.
38
+ # NOTE: macOS pgrep is POSIX-ERE — no \b. Match "bridge.mjs <ROOM>" with the room
39
+ # delimited by whitespace or end-of-arg. (A \b guard here once failed silently and
40
+ # serviced the live room, restarting its bridge — don't reintroduce it.)
41
+ BRIDGE_RE="bridge\.mjs[[:space:]]+${ROOM}([[:space:]]|\$)"
42
+ if [ -z "${TP_FORCE:-}" ] && pgrep -f "$BRIDGE_RE" >/dev/null 2>&1; then
43
+ echo "✗ a bare bridge is already running for ${ROOM} (pid $(pgrep -f "$BRIDGE_RE" | tr '\n' ' '))."
44
+ echo " Stop it first (close its terminal / kill it), then re-run — or TP_FORCE=1 to override."
45
+ echo " NOTE: if ${ROOM} is hosting the Claude session you're talking to RIGHT NOW, servicing it restarts the bridge and drops that chat."
46
+ exit 2
47
+ fi
48
+
49
+ echo "◆ installing auto-updating service for ${ROOM} (npx thinkpool-pair@latest ${TAIL[*]})…"
50
+ npx -y thinkpool-pair@latest install-service "$ROOM" -- "${TAIL[@]}"
51
+
52
+ [ -f "$PLIST" ] || { echo "✗ expected plist not found at $PLIST — install may have failed"; exit 3; }
53
+
54
+ # Inject the fast-poll knobs into EnvironmentVariables (Add, or Set if present).
55
+ pb() { /usr/libexec/PlistBuddy -c "$1" "$PLIST" >/dev/null 2>&1; }
56
+ pb "Add :EnvironmentVariables:THINKPOOL_PAIR_UPDATE_INTERVAL string ${INTERVAL}" || pb "Set :EnvironmentVariables:THINKPOOL_PAIR_UPDATE_INTERVAL ${INTERVAL}"
57
+ pb "Add :EnvironmentVariables:THINKPOOL_PAIR_UPDATE_IDLE string ${IDLE}" || pb "Set :EnvironmentVariables:THINKPOOL_PAIR_UPDATE_IDLE ${IDLE}"
58
+
59
+ # Reload so the new env takes effect immediately.
60
+ launchctl unload "$PLIST" 2>/dev/null || true
61
+ launchctl load "$PLIST"
62
+
63
+ echo "✓ ${ROOM} is now a launchd service:"
64
+ echo " • tracks thinkpool-pair@latest, polls npm every ${INTERVAL}s, self-restarts at the next idle ≥${IDLE}s"
65
+ echo " • plist: $PLIST"
66
+ echo " • logs: ~/.thinkpool-pair/${ROOM}.log"
67
+ echo " • remove: npx thinkpool-pair@latest uninstall-service ${ROOM}"
package/bridge.mjs CHANGED
@@ -117,7 +117,7 @@ const flowRedispatch = new Map()
117
117
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
118
118
  // broadcasts; without persistent state the cap can never bite.
119
119
  const flowBudgets = new Map()
120
- import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
120
+ import { formatPeek, PEEK, readTerminalBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
121
121
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
122
122
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
123
123
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
@@ -941,7 +941,7 @@ function recordCodeUsage (model, usage, provider) {
941
941
  let _presence = null
942
942
  const trackPresence = (payload) => { (_presence ||= makeThrottledTrack(channel, { minMs: 5000 }))(payload) }
943
943
  const channel = supabase.channel(`tpcode:${room}`, {
944
- config: { broadcast: { self: false }, presence: { key: `bridge:${name}` } },
944
+ config: { private: true, broadcast: { self: false }, presence: { key: `bridge:${name}` } },
945
945
  })
946
946
 
947
947
  // Thinkpool Flow rides its OWN topic, separate from the room's primary tpcode channel.
@@ -953,13 +953,13 @@ const flowChannel = supabase.channel(`tpflow:${room}`, {
953
953
  config: { broadcast: { self: false } },
954
954
  })
955
955
 
956
- // Source-changing Design Mode control never rides the public tpcode topic.
956
+ // Source-changing Design Mode control stays isolated from the primary room bus.
957
957
  // Membership RLS on realtime.messages gates join, receive, and send.
958
958
  const designChannel = supabase.channel(`tpdesign:${room}`, {
959
959
  config: { private: true, broadcast: { self: false } },
960
960
  })
961
961
 
962
- // Host-changing room commands are isolated from the public collaboration topic.
962
+ // Host-changing room commands are isolated from the primary collaboration topic.
963
963
  // Realtime RLS admits current room members and authenticates every send/receive.
964
964
  const controlChannel = supabase.channel(`tpcontrol:${room}`, {
965
965
  config: { private: true, broadcast: { self: false } },
@@ -1463,7 +1463,7 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
1463
1463
  if (decision !== 'allow') return { error: `The people in room ${room} declined the incoming task from ${fromLabel}.` }
1464
1464
  // Injected turn: one room-hop deep, fresh in-room budgets (the loop-breaker reset
1465
1465
  // point is code-turn — a real human turn — which this is NOT, so the deeper hop sticks).
1466
- te.roomHop = 1; te.hop = (te.hop || 0) + 1; te.peekCount = 0; te.postCount = 0; te.pairPeekCount = 0; te.crossRoomPostCount = 0
1466
+ te.roomHop = 1; te.hop = (te.hop || 0) + 1; te.peekCount = 0; te.peekRosterCount = 0; te.postCount = 0; te.pairPeekCount = 0; te.crossRoomPostCount = 0
1467
1467
  const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
1468
1468
  const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true, relaySourceName: String(fromTerminalName || '').trim().slice(0, 80) || undefined }
1469
1469
  autoNameTerminal(targetId, body)
@@ -2581,18 +2581,29 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2581
2581
  ...createViewportTools({ tool, z, manager: entry.viewport }),
2582
2582
  tool(
2583
2583
  'read_terminal',
2584
- 'Read-only view of ANOTHER terminal in this ThinkPool Code room (a sibling agent or a shell the people are using). Call with no arguments to list every open terminal with busy/idle state, last-action age, and any STUCK/BLOCKED flag; call with `terminal` (a ref, id, or command from that list) to read its recent activity. It never changes another terminal — reading only. Use the one-read roster to identify a lane that needs attention before opening its transcript.',
2584
+ 'Read-only view of ANOTHER terminal in this ThinkPool Code room (a sibling agent or a shell the people are using). The current ROOM NOW snapshot is the default roster. Call with no arguments only when that snapshot is missing or truncated; this optional full-roster lookup has its own one-call allowance. Call with `terminal` (a ref, id, name, or command) only when that lane’s detailed activity is needed. Targeted transcript reads have a separate bounded allowance: never poll, collect each finished owned worker once, then close it immediately.',
2585
2585
  {
2586
2586
  terminal: z.string().optional().describe('ref, id, or command of the terminal to read; omit to list the open terminals'),
2587
2587
  lines: z.number().int().positive().max(PEEK.maxLines).optional().describe(`how many recent lines to return (default ${PEEK.defaultLines})`),
2588
2588
  },
2589
2589
  async (args) => {
2590
- entry.peekCount = (entry.peekCount || 0) + 1
2591
- if (entry.peekCount > PEEK.perTurnCap) {
2592
- return { content: [{ type: 'text', text: `Cross-terminal read limit reached for this turn (${PEEK.perTurnCap}). Continue with what you have, or ask the people in the room.` }] }
2593
- }
2594
2590
  const sib = siblingsOf({ selfId: id, sessions, terms, names: termNames })
2595
2591
  const target = args?.terminal ? resolveSibling(sib, args.terminal) : null
2592
+ // A malformed/stale ref is a no-op, so reject it before spending either
2593
+ // allowance. Discovery and transcript reads are metered independently:
2594
+ // ROOM NOW makes the roster optional, while targeted reads stay available
2595
+ // for actual worker collection and the final review.
2596
+ if (args?.terminal && !target) {
2597
+ return { content: [{ type: 'text', text: formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args.terminal, lines: args?.lines }) }] }
2598
+ }
2599
+ const budget = readTerminalBudgetDecision({
2600
+ targeted: !!args?.terminal,
2601
+ targetedCount: entry.peekCount,
2602
+ rosterCount: entry.peekRosterCount,
2603
+ })
2604
+ entry.peekCount = budget.targetedCount
2605
+ entry.peekRosterCount = budget.rosterCount
2606
+ if (!budget.ok) return { content: [{ type: 'text', text: budget.reason }] }
2596
2607
  const targetEntry = target?.kind === 'agent' ? sessions.get(target.id) : null
2597
2608
  const text = formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args?.terminal, lines: args?.lines })
2598
2609
  + nativeClaudeFallbackHint({ parentRuntime: entry.runtime, parentModels: entry.models, targetRuntime: targetEntry?.runtime, targetLog: targetEntry?.log })
@@ -2705,6 +2716,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2705
2716
  // The injected turn starts fresh budgets, one hop deeper (loop breaker).
2706
2717
  te.hop = (entry.hop || 0) + 1
2707
2718
  te.peekCount = 0
2719
+ te.peekRosterCount = 0
2708
2720
  te.postCount = 0
2709
2721
  // Echo the injected prompt into the TARGET lane so both people see what
2710
2722
  // arrived (rides the existing code-event 'you' path — no new topic).
@@ -2762,7 +2774,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2762
2774
  if (args?.name) { delete termNames[newId]; saveNames(room, termNames) }
2763
2775
  return okText('Could not open the main conductor terminal — the machine terminal cap may have just been reached.')
2764
2776
  }
2765
- conductor.peekCount = 0; conductor.postCount = 0; conductor.spawnTimes = []
2777
+ conductor.peekCount = 0; conductor.peekRosterCount = 0; conductor.postCount = 0; conductor.spawnTimes = []
2766
2778
  announce()
2767
2779
  const msg = `[Task from main terminal ${fromRef}'s agent — opened as an independent MAIN CASCADE CONDUCTOR terminal, not an Ensemble child]\n${String(args.task).trim()}`
2768
2780
  const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
@@ -2837,7 +2849,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2837
2849
  resolvedModel: resolved.model,
2838
2850
  },
2839
2851
  })
2840
- } catch { return okText('Dispatch preview could not be built safely. No lane was created.') }
2852
+ } catch (error) {
2853
+ process.stderr.write(`\n ${A.red}✗ dispatch preview rejected — ${error?.message || error}${A.rst}\n`)
2854
+ return okText('Dispatch preview could not be built safely. No lane was created.')
2855
+ }
2841
2856
  const currentNow = Date.now()
2842
2857
  const current = dispatchContext(currentNow)
2843
2858
  const authorization = authorizeDirectDispatch({
@@ -2880,7 +2895,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2880
2895
  openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: effectiveArgs.mode, sliceType: args?.sliceType, flowReviewTargets: manualReviewSnapshots.map((item) => item.taskKey), flowReviewSnapshots: manualReviewSnapshots, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
2881
2896
  const ne = sessions.get(newId)
2882
2897
  if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
2883
- ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
2898
+ ne.peekCount = 0; ne.peekRosterCount = 0; ne.postCount = 0; ne.spawnTimes = []
2884
2899
  announce() // (defensive) ensure the web renders the new tab live
2885
2900
  if (args?.task) {
2886
2901
  // The user-visible relay reinforces (but never defines) the structural
@@ -4244,6 +4259,7 @@ channel
4244
4259
  // through here, so its deeper hop level + spent budget stick until a person
4245
4260
  // speaks again.
4246
4261
  s.peekCount = 0
4262
+ s.peekRosterCount = 0
4247
4263
  s.postCount = 0
4248
4264
  s.pairPeekCount = 0 // cross-room read budget resets with the in-room ones (Tier 1)
4249
4265
  s.crossRoomPostCount = 0 // Tier 3 cross-room post budget resets on a real human turn
@@ -4508,7 +4524,7 @@ channel
4508
4524
  const s = payload?.term && sessions.get(payload.term)
4509
4525
  const p = s && payload.id && s.pending.get(payload.id)
4510
4526
  if (!p) return
4511
- // `tpcode` is a public transport. Treat the frame only as a wake-up hint and
4527
+ // `tpcode` is membership-gated transport. Treat the frame only as a wake-up hint and
4512
4528
  // reproduce its resolved item + unique delivery attempt through owner-authenticated
4513
4529
  // RLS reads before touching the local pending resolver. A forged broadcast then
4514
4530
  // has no more authority than packet noise.
@@ -240,7 +240,6 @@ const TP_ROOM_REMINDER = [
240
240
  '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.',
241
241
  THINKPOOL_RUNTIME_TURN_REMINDER,
242
242
  'TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Only conductor-capable roles fan worker slices out with spawn_terminal. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.',
243
- '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.',
244
243
  '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.',
245
244
  'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A requested separate conductor uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
246
245
  ].join(' ')
@@ -748,14 +747,13 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
748
747
  THINKPOOL_CASCADE_RULE,
749
748
  'CRUCIAL RECONCILIATION for that workflow: it is NOT plan mode. Never call ExitPlanMode and never make the room wait behind a "plan ready — approve to start" card — your plan lives in the CHAT as a message, and your lanes live in the room\'s EXISTING terminal/lane list. Reuse only those two surfaces; there is no new Flow panel or mode to switch into, and you must not ask for one. Keep the plan and the lanes VISIBLE — that shared visibility is the whole point (it is the pair differentiator, and it catches bugs a single silent lane would hide); never collapse a decomposable build into one hidden lane just to look tidy.',
750
749
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
751
- 'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
750
+ 'CROSS-TERMINAL AWARENESS: ROOM NOW is the default roster and already satisfies the room check when it has enough detail. Do not repeat it with a no-argument read_terminal call unless it is missing or truncated. Use a targeted read_terminal call only when the current task depends on a specific lane’s detailed activity; never poll. Identify a terminal by its NAME or stable ref/id, never by an on-screen number like "Terminal 2" — positional labels renumber when a terminal is closed. The tool is read-only and its optional roster lookup is budgeted separately from bounded targeted transcript reads.',
752
751
  'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
753
- 'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Collect each worker with read_terminal and close_terminal immediately after using its result. Main conductors are independent terminals, keep their requested permission mode, and are not owned/closed through Ensemble.',
752
+ 'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Wait for ROOM NOW or a completion signal instead of polling; after a worker finishes, collect it with one targeted read_terminal call and close_terminal immediately. Main conductors are independent terminals, keep their requested permission mode, and are not owned/closed through Ensemble.',
754
753
  '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.',
755
754
  '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. Outbound list/read/post tools need the ThinkPool account bridge; a standalone owner room can still receive a paired hand-off directly and will always raise its own approval card before delivery.',
756
755
  'SUBAGENT POLICY: in this room, a main conductor delegates worker slices through visible spawn_terminal Ensemble lanes. A requested separate conductor is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
757
756
  'RESEARCH LANE: you have a `research` tool that runs a REAL multi-source web search + adversarial verification and returns each claim marked HELD or REJECTED with citations. Reach for it when the people would genuinely benefit from looking something external up or settling a question of current fact — pricing, "is X still maintained / deprecated", "is that benchmark real", a debate over facts you are not sure of. Do NOT run it unprompted or for things you already know: first OFFER in plain language ("want me to spawn a research lane on that and check it?"), and only call `research(question)` once they agree — it spends real budget (plan-gated Free 5 / Plus 100 runs a month) and takes ~a minute. When it returns, present the held/rejected findings clearly and invite both people to weigh the sources, flagging any held claim that rests on a source they might not trust — that shared scrutiny is the point.',
758
- '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.',
759
757
  '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.',
760
758
  '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.',
761
759
  ].join(' '),
@@ -18,11 +18,38 @@ export const PEEK = {
18
18
  // explicitly when a diagnosis genuinely needs deeper sibling history.
19
19
  defaultLines: 20,
20
20
  maxLines: 200,
21
- perTurnCap: 10, // read_terminal calls allowed per user turn (bridge resets it)
21
+ perTurnCap: 10, // targeted transcript reads allowed per user turn (bridge resets it)
22
+ rosterPerTurnCap: 1, // no-argument roster reads use a separate allowance; ROOM NOW is default
22
23
  lineCap: 200, // per-line truncation
23
24
  rosterPreview: 100, // last-line preview length in the roster listing
24
25
  }
25
26
 
27
+ // ROOM NOW already gives every agent a compact live roster. Keep the one optional
28
+ // full-roster lookup separate from transcript reads so a redundant discovery call
29
+ // cannot consume the budget needed to collect workers and run the final review.
30
+ // Unknown terminal refs are rejected before this helper is called and spend neither
31
+ // allowance. The bridge stores the returned counters on the current lane.
32
+ export const readTerminalBudgetDecision = ({ targeted = false, targetedCount = 0, rosterCount = 0 } = {}, limits = PEEK) => {
33
+ const nextTargeted = Math.max(0, Number(targetedCount) || 0)
34
+ const nextRoster = Math.max(0, Number(rosterCount) || 0)
35
+ if (targeted) {
36
+ if (nextTargeted >= limits.perTurnCap) return {
37
+ ok: false,
38
+ targetedCount: nextTargeted,
39
+ rosterCount: nextRoster,
40
+ reason: `Cross-terminal transcript read limit reached for this turn (${limits.perTurnCap}). Stop polling and continue with the worker results already collected; a new person-authored turn resets the allowance.`,
41
+ }
42
+ return { ok: true, targetedCount: nextTargeted + 1, rosterCount: nextRoster }
43
+ }
44
+ if (nextRoster >= limits.rosterPerTurnCap) return {
45
+ ok: false,
46
+ targetedCount: nextTargeted,
47
+ rosterCount: nextRoster,
48
+ reason: `The full terminal roster was already read this turn (${limits.rosterPerTurnCap}/${limits.rosterPerTurnCap}). Use the current ROOM NOW snapshot or read one relevant terminal by ref or name.`,
49
+ }
50
+ return { ok: true, targetedCount: nextTargeted, rosterCount: nextRoster + 1 }
51
+ }
52
+
26
53
  // One shared lane-status classifier for every roster consumer: the bridge wire,
27
54
  // read_terminal, and ROOM NOW. Timestamps stay absolute on the wire so clients can
28
55
  // keep the displayed age current without a broadcast every second.
@@ -320,7 +347,7 @@ export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees =
320
347
  return `- ${label(x)} · ${x.kind} · ${laneStatusText(x)} — ${clip(preview, limits.previewLen)}`
321
348
  })
322
349
  if (sib.length > shown.length) rows.push(`- … +${sib.length - shown.length} more (read_terminal lists all)`)
323
- out.push(`Other lanes in this room right now (read_terminal for detail):\n${rows.join('\n')}`)
350
+ out.push(`Other lanes in this room right now (this is the roster; use targeted read_terminal only for needed detail):\n${rows.join('\n')}`)
324
351
  }
325
352
  const wtAll = Array.isArray(worktrees) ? worktrees : []
326
353
  const wt = wtAll.slice(0, limits.wtCap)
@@ -447,7 +474,10 @@ export const spawnDecision = ({ hop = 0, spawnTimes = [], now = 0, spawnedLive =
447
474
  // rechecks immediately before it spends a lane. No token, provider credential,
448
475
  // host path, or mutable process object is copied into the preview.
449
476
  const DISPATCH_ARG_KEYS = Object.freeze(['mode', 'model', 'name', 'provider', 'runtime', 'sliceType', 'task'])
450
- const DISPATCH_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
477
+ // `review` is an internal Codex-only mode selected by structuredModeForSlice.
478
+ // It is not user-selectable in the spawn schema, but it must survive the immutable
479
+ // preview boundary or every Codex review lane fails before creation.
480
+ const DISPATCH_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'review'])
451
481
  const DISPATCH_RUNTIMES = new Set(['claude', 'codex', 'hermes'])
452
482
  const DISPATCH_SLICES = new Set(['scaffold', 'feature', 'fix', 'review'])
453
483
  const secretValue = /((?<![a-z0-9])sk-[a-z0-9_-]{8,}|(?<![a-z0-9])gsk_[a-z0-9_-]{8,}|(?<![a-z0-9])AIza[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,})/ig
@@ -524,7 +554,8 @@ export function buildDispatchPreview ({ args, initiatorTerminalId, roomCode, bri
524
554
  }),
525
555
  permissions: Object.freeze({ mode: exactArgs.mode, consequence: exactArgs.mode === 'bypassPermissions'
526
556
  ? 'Unattended local access within this worker’s sandbox and tool policy.'
527
- : exactArgs.mode === 'plan' ? 'Planning only; execution remains gated.' : 'Protected local actions keep their normal permission gates.' }),
557
+ : exactArgs.mode === 'review' ? 'Read-only inspection of the pinned parent snapshot; writes and approval escapes are unavailable.'
558
+ : exactArgs.mode === 'plan' ? 'Planning only; execution remains gated.' : 'Protected local actions keep their normal permission gates.' }),
528
559
  worktree: 'Automatically creates one visible lateral worker lane and an isolated git worktree on this machine.',
529
560
  caps,
530
561
  })
@@ -1,7 +1,12 @@
1
1
  // Cumulative provider snapshots are replace-in-place UI state, not transcript
2
2
  // facts. Deliver the first immediately and coalesce a burst to its latest frame;
3
3
  // any durable event flushes the pending snapshot first to preserve wire order.
4
- export function createCumulativeEventRelay(emit, waitMs = 150) {
4
+ //
5
+ // 75ms is a readable 13.3fps ceiling: quick enough for an active reply without
6
+ // turning every provider delta into a realtime broadcast or a full UI repaint.
7
+ export const CUMULATIVE_EVENT_RELAY_WAIT_MS = 75
8
+
9
+ export function createCumulativeEventRelay(emit, waitMs = CUMULATIVE_EVENT_RELAY_WAIT_MS) {
5
10
  let lastSentAt = 0
6
11
  let pending = null
7
12
  let timer = null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.328",
3
+ "version": "0.7.331",
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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bridge.mjs",
11
+ "bridge-service.sh",
11
12
  "command-guidance.mjs",
12
13
  "abort-turn-barrier.mjs",
13
14
  "host-memory.mjs",
package/terminal-name.mjs CHANGED
@@ -17,6 +17,7 @@ const ANY_HEADING = /^(?:#{1,6}\s+|(?:context|background|constraints?|inputs?|ou
17
17
  const NOISE = /^(?:context|background|for reference|here(?:'s| is)|note|current(?:ly)?|example|environment|room now|constraints?|acceptance|success criteria)\b/i
18
18
  const EXPLANATION = /^(?:because|cause|since|so that|this is because)\b/i
19
19
  const CONSTRAINT = /^(?:users? can still|keep|must|never|should|without)\b/i
20
+ const DEPENDENT_FRAGMENT = /^(?:after|before|by|during|for|from|in|instead(?: of)?|on|through|until|with|without)\b/i
20
21
  const INFORMATION_REQUEST = /^(?:which|what|who|where|when|why|how)\b/i
21
22
  const NEGATIVE_PREFERENCE = /^(?:(?:i|we)\s+)?(?:do not|don't|dont|would not|wouldn't|won't|wont)\s+(?:(?:want|wanna|need)(?:\s+to)?|use|include|choose)\b|^(?:(?:i|we)\s+)?(?:want|wanna|need)(?:\s+to)?\s+avoid\b/i
22
23
  const ISSUE = /\b(?:broken|buggy|crash(?:es|ed|ing)?|duplicate|error|fail(?:s|ed|ing|ure)?|flash(?:es|ed|ing)?|missing|no animation|not working|out of (?:scrollable )?view|stuck|wrong)\b/i
@@ -29,6 +30,7 @@ const ACTIONS = Object.freeze([
29
30
  { title: 'Harden', score: 98, re: /\b(?:harden|hardens|hardened|hardening)\b/i },
30
31
  { title: 'Snap', score: 96, re: /\b(?:snap|snaps|snapped|snapping)\b/i },
31
32
  { title: 'Improve', score: 94, re: /\b(?:improve|improves|improved|improving|optimi[sz](?:e|es|ed|ing)|\bbetter\b)\b/i },
33
+ { title: 'Rework', score: 93, re: /\b(?:rework|reworks|reworked|reworking)\b/i },
32
34
  { title: 'Implement', score: 92, re: /\b(?:implement|implements|implemented|implementing)\b/i },
33
35
  { title: 'Build', score: 90, re: /\b(?:build|builds|built|building|create|creates|created|creating)\b/i },
34
36
  { title: 'Add', score: 88, re: /\b(?:add|adds|added|adding|wire|wires|wired|wiring)\b/i },
@@ -136,7 +138,11 @@ const bestIntentClause = (text) => {
136
138
  if (EXPLANATION.test(clause)) score -= 34
137
139
  if (CONSTRAINT.test(clause)) score -= 45
138
140
  if (NEGATIVE_PREFERENCE.test(clause)) score -= 70
139
- score += Math.round((i / Math.max(1, clauses.length - 1)) * 4)
141
+ // A short dependent tail such as "From ground up" or "On mobile" adds
142
+ // scope to the preceding request; it is not a standalone task. Recency is
143
+ // deliberately not a signal here: the title should represent the main
144
+ // theme, not whichever sentence happened to come last.
145
+ if (!action && !REQUEST.test(clause) && !INFORMATION_REQUEST.test(clause) && DEPENDENT_FRAGMENT.test(clause)) score -= 24
140
146
  if (!best || score > best.score) best = { clause, score }
141
147
  }
142
148
  return best?.clause || clauses[0] || null
@@ -175,13 +181,18 @@ const titleWord = (word) => {
175
181
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
176
182
  }
177
183
 
178
- const contentWords = (value) => {
184
+ const contentWords = (value, excludedFamilies = []) => {
179
185
  const words = String(value || '').match(/[\p{L}\p{N}][\p{L}\p{N}+#.'-]*/gu) || []
180
186
  const seen = new Set()
181
187
  const result = []
182
188
  for (let word of words) {
183
189
  const lower = word.toLowerCase()
184
190
  if (SKIP.has(lower) || /^https?$/i.test(word)) continue
191
+ // ACTIONS intentionally folds synonyms into one title verb (review/test/verify
192
+ // all become Audit). Once that verb is chosen, words from the same family are
193
+ // not objects. Keeping them spent the title budget on "Audit Reviews Audits"
194
+ // before the extractor reached the actual scope.
195
+ if (excludedFamilies.some((family) => family.test(word))) continue
185
196
  if (lower === 'flashes' || lower === 'flashing') word = 'flash'
186
197
  if (lower === 'failing' || lower === 'failed' || lower === 'fails') word = 'failure'
187
198
  const key = word.toLowerCase().replace(/(?:s|ed|ing)$/i, '')
@@ -227,12 +238,13 @@ const taskTitle = (value) => {
227
238
  const before = body.slice(0, action.index)
228
239
  objectText = body.slice(action.index + action.match.length)
229
240
  const weakLead = bestAction(before)
230
- if (weakLead?.score <= 76) domain = contentWords(before.slice(weakLead.index + weakLead.match.length)).slice(0, 3)
241
+ if (weakLead?.score <= 76) domain = contentWords(before.slice(weakLead.index + weakLead.match.length), [weakLead.re]).slice(0, 3)
231
242
  objectText = objectText.split(/\s*,?\s+(?:and|then)\s+(?:(?:also)\s+)?(?=(?:add|build|create|implement|remove|replace|update|wire)\b)/i, 1)[0]
232
243
  objectText = objectText.replace(/\b(?:and|then)\s+(?:audit|check|debug|investigate|review|test|verify)\b/gi, ' ')
233
244
  }
234
- let object = contentWords(objectText)
235
- if (!object.length) object = contentWords(body)
245
+ const excludedObjectFamilies = action ? [action.re] : []
246
+ let object = contentWords(objectText, excludedObjectFamilies)
247
+ if (!object.length) object = contentWords(body, excludedObjectFamilies)
236
248
  if (informationRequest) {
237
249
  const available = object.findIndex((word) => /^available$/i.test(word))
238
250
  if (available > 0) object = [object[available], ...object.slice(0, available), ...object.slice(available + 1)]
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 14,
3
+ "bundleVersion": 15,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
7
- "version": 2,
7
+ "version": 3,
8
8
  "routes": [
9
9
  {
10
10
  "id": "room-awareness",
11
11
  "tools": ["read_terminal"],
12
12
  "trigger": "\\b(other|another|sibling|peer)\\s+(lane|terminal)|\\bread_terminal\\b",
13
- "prompt": "When the request depends on another lane, a sibling may overlap the work, or a person refers to another terminal, inspect it with read_terminal before acting."
13
+ "prompt": "When the request depends on another lane, a sibling may overlap the work, or a person refers to another terminal, use the current ROOM NOW snapshot as the default roster. Do not repeat it with a no-argument read_terminal call unless the snapshot is missing or truncated; that roster lookup has its own one-call allowance. Read only the relevant terminal by ref or name when its detailed transcript is actually needed. Never poll with read_terminal: wait for the room snapshot or completion signal to show a state change, collect each finished owned worker once, then close it immediately. Preserve the bounded targeted-read allowance for worker collection and final review."
14
14
  },
15
15
  {
16
16
  "id": "cross-room-awareness",
@@ -22,7 +22,7 @@
22
22
  "id": "visible-handoff",
23
23
  "tools": ["post_to_terminal", "post_to_session"],
24
24
  "trigger": "\\b(hand[ -]?off|tell|send|post)\\b.{0,40}\\b(lane|terminal|room|session|agent)\\b|\\b(post_to_terminal|post_to_session)\\b",
25
- "prompt": "When the people want a handoff, read the target first, then use post_to_terminal or post_to_session; let the room approval contract handle consent."
25
+ "prompt": "When the people want a handoff, read the target once if its current details are not already available, then use post_to_terminal or post_to_session; let the room approval contract handle consent."
26
26
  }
27
27
  ],
28
28
  "impact": [
@@ -41,7 +41,7 @@
41
41
  },
42
42
  {
43
43
  "id": "work-routing",
44
- "version": 5,
44
+ "version": 6,
45
45
  "providerRoutingContract": "A Claude lane may select a connected Anthropic-compatible provider by durable id, unique display name, or unique configured model. Unknown or ambiguous references fail closed and must never fall through to built-in Claude.",
46
46
  "openerParityContract": "Every top-level terminal may use spawn_terminal or open_main_terminal to open every supported structured runtime: Claude, Codex, or Hermes. Runtime-specific provider/model validation remains authoritative and occurs before inference.",
47
47
  "routes": [
@@ -49,10 +49,10 @@
49
49
  "id": "work-routing",
50
50
  "tools": ["spawn_terminal", "open_main_terminal", "close_terminal"],
51
51
  "trigger": "\\b(parallel|delegate|worker|sub[- ]?terminal|conductor|cascade|spawn_terminal|open_main_terminal|close_terminal)\\b",
52
- "prompt": "For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify them, then close_terminal. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal. Review slices are structurally read-only and must use the review slice type; they never inherit autonomous bypass authority."
52
+ "prompt": "For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify each result with one targeted read_terminal call after completion, then close_terminal immediately. Do not poll workers or repeat the ROOM NOW roster. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal. Review slices are structurally read-only and must use the review slice type; they never inherit autonomous bypass authority."
53
53
  }
54
54
  ],
55
- "expandedPrompt": "CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Review slices are structurally read-only: they resolve to a native review/Plan mode before launch and can never inherit autonomous bypass authority. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. After dispatch, remain responsible: use read_terminal to collect each owned lane, verify its claim, close_terminal immediately, and dispatch newly unblocked work; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
55
+ "expandedPrompt": "CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Review slices are structurally read-only: they resolve to a native review/Plan mode before launch and can never inherit autonomous bypass authority. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. ROOM NOW is the default roster and completion signals wake the conductor; do not spend the bounded read allowance polling or repeating a no-argument roster. After dispatch, remain responsible: once an owned worker is finished, make one targeted read_terminal call to collect its result, verify its claim, close_terminal immediately, and dispatch newly unblocked work. Preserve enough targeted reads for every open worker and the final adversarial review; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
56
56
  "impact": [
57
57
  {"path": "bridge/bridge.mjs", "diffPattern": "spawn_terminal|open_main_terminal|close_terminal|cascadeRole|spawnDepth"},
58
58
  {"path": "bridge/lane-lifecycle.mjs"},