thinkpool-pair 0.7.181 → 0.7.183

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
@@ -744,18 +744,11 @@ let myServeUid = null // this bridge's authed uid (null = anon)
744
744
  refreshOwnerPlan() // fire-and-forget on boot (codeAuthToken now set from env OR the stored login)
745
745
 
746
746
  // ── Pre-flight budget caps — spec docs/specs/2026-07-01-budget-caps.md (nexos steal #1).
747
- // The bridge calls the durable ledger RPCs as the OWNER (codeAuthToken); is_session_member
748
- // passes for this room, so the room-scope cap enforces. Member-scope attribution needs the
749
- // SENDER's uid (not available here — a turn carries a display label + cid, not a uuid), so
750
- // the baseline records/enforces ROOM scope only; per-member caps wait on the quota-fairness
751
- // work. TP_BUDGET_OFF disables the whole path. `roomCapped` caches whether a cap exists so a
752
- // transient ledger error fails CLOSED only for a room we KNOW is capped, and open otherwise.
747
+ // TP_BUDGET_OFF disables the spend-ledger path entirely (record_spend). The per-room output-
748
+ // token CAP was removed (2026-07) — redundant for BYOK (the provider enforces spend limits),
749
+ // a per-turn check_budget round-trip on every authed turn, and a fail-closed hazard. The
750
+ // spend LEDGER stays: it powers the Session-info "Session tokens" stat.
753
751
  const BUDGET_OFF = process.env.TP_BUDGET_OFF === '1'
754
- let roomCapped = false
755
- function monthResetLabel () {
756
- const d = new Date()
757
- return new Date(d.getFullYear(), d.getMonth() + 1, 1).toISOString().slice(0, 10)
758
- }
759
752
  async function budgetRpc (fn, args) {
760
753
  const r = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${fn}`, {
761
754
  method: 'POST',
@@ -828,36 +821,6 @@ function recordCodeUsage (model, usage) {
828
821
  budgetRpc('record_code_usage', { p_room: room, p_model: model, p_input: input, p_output: output, p_cached: cached })
829
822
  .catch(() => { /* best-effort — usage accounting is not load-bearing for the turn */ })
830
823
  }
831
- // Pre-flight gate. Returns true if the turn must be REFUSED (room cap reached). Refusal is
832
- // legible (a ◆ control line both readers + late-joiners see), never a silent stall.
833
- async function budgetBlocked (s, term) {
834
- if (BUDGET_OFF || !codeAuthToken) return false
835
- const refuse = (text) => {
836
- const evt = { kind: 'control', text }
837
- try { pushLog(s, evt) } catch { /* no session log */ }
838
- bcast('code-event', { term, evt })
839
- return true
840
- }
841
- try {
842
- const rows = await budgetRpc('check_budget', { p_room: room })
843
- const r = Array.isArray(rows) ? rows[0] : rows
844
- roomCapped = !!(r && r.remaining_tokens != null)
845
- if (r && r.ok === false) {
846
- process.stderr.write(`\n ${A.yel}◆ turn refused — ${r.tightest_scope} budget cap reached (room ${room}).${A.rst}\n`)
847
- return refuse(`◆ Turn blocked — this room hit its ${r.tightest_scope || 'budget'} cap. Resets ${monthResetLabel()}, or raise the cap.`)
848
- }
849
- return false
850
- } catch (e) {
851
- // Fail-closed ONLY where we know a cap exists — a transient ledger/DNS blip must never
852
- // brick an UNCAPPED room (that would be a worse outage than the cap it's guarding).
853
- if (roomCapped) {
854
- process.stderr.write(`\n ${A.yel}◆ turn refused — budget check failed, room is capped (fail-closed).${A.rst}\n`)
855
- return refuse('◆ Turn blocked — budget check is unavailable and this room has a cap set. Try again in a moment.')
856
- }
857
- process.stderr.write(`\n ${A.dim}◆ budget check skipped (${e.message}) — room uncapped, proceeding.${A.rst}\n`)
858
- return false
859
- }
860
- }
861
824
  // Throttled presence track() (presence.mjs) — a reconnect storm re-fires SUBSCRIBED and
862
825
  // the handler re-track()s; without throttle that spirals into the per-client presence
863
826
  // rate limit (ClientPresenceRateLimitReached). Lazy so it binds `channel` (the chain
@@ -1937,9 +1900,10 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1937
1900
  // This opens a brand-new structured session server-side (the same path the
1938
1901
  // web's "+ New terminal" and the restore loop use), so announce() surfaces
1939
1902
  // its tab in the open web UI live. Auto-allowed by the PreToolUse gate but
1940
- // bounded by spawnDecision (hop-0-only, per-turn cap, the live terminal cap,
1941
- // TP_SPAWN_OFF kill-switch). A spawned lane runs one hop deep, so it cannot
1942
- // recursively spawn/post onward — the fork-bomb breaker (mirrors Tier C).
1903
+ // bounded by spawnDecision (depth-2 hop bound, burst window, the live terminal
1904
+ // cap, TP_SPAWN_OFF kill-switch). A person's lane (hop 0) may spawn conductors
1905
+ // (hop 1) which may spawn workers (hop 2); hop 2 can never spawn onward — the
1906
+ // fork-bomb breaker, now one level deeper (2026-07-10 cascade-spawn-depth spec).
1943
1907
  tool(
1944
1908
  'spawn_terminal',
1945
1909
  'Open a NEW agent terminal (a fresh Claude lane) in this ThinkPool Code room — YOUR lane to use, not a sibling that is already working. Use it to fan work out: give it an initial `task` and it runs in parallel in its own lane; collect its result later with read_terminal, and close it with close_terminal when done. By default the new lane INHERITS your current permission mode — so if you are in bypass, it runs autonomously with no per-action clicking; pass `mode` to override. ALWAYS prefer spawning a fresh lane over post_to_terminal into one that is already busy. Tier guidance: default worker lanes to opus; use sonnet for mechanical work (renames, boilerplate, doc sweeps); reserve your own model for planning/review. Bounded by the room terminal cap and a per-turn limit.',
@@ -1984,9 +1948,11 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1984
1948
  ne.spawnedBy = id // ownership: only the spawner may close_terminal it
1985
1949
  announce() // (defensive) ensure the web renders the new tab live
1986
1950
  if (args?.task) {
1987
- // One hop deep: the spawned lane cannot spawn/post onward until a person
1988
- // speaks to it (code-turn resets hop to 0). Mirrors the Tier C injection.
1989
- ne.hop = 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
1951
+ // One level deeper than the spawner: hop = parent.hop + 1, so the depth-2
1952
+ // bound in spawnDecision binds at every level (hop-0 person's lane → hop-1
1953
+ // conductor → hop-2 worker, which can never spawn onward). A person speaking
1954
+ // to any lane still resets it to hop 0 (code-turn). Mirrors Tier C injection.
1955
+ ne.hop = (entry.hop || 0) + 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
1990
1956
  const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool cross-terminal; you are a fresh lane it opened for you]\n${args.task}`
1991
1957
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true }
1992
1958
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
@@ -2244,8 +2210,9 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2244
2210
  const b = flowBudgets.get(entry.flowSessionId)
2245
2211
  if (b) flowBudgets.set(entry.flowSessionId, recordSpend(b, evt.usage.output_tokens || 0))
2246
2212
  }
2247
- // Budget caps — fold EVERY completed turn's output tokens into the room's durable
2248
- // monthly ledger (lanes + normal turns), so check_budget can pre-flight the next one.
2213
+ // Fold EVERY completed turn's output tokens into the room's durable monthly ledger
2214
+ // (lanes + normal turns) — powers the Session-info "Session tokens" stat. (The cap that
2215
+ // consumed this ledger was removed; the accounting stays.)
2249
2216
  if (evt.kind === 'result' && evt.usage) { recordRoomSpend(evt.usage.output_tokens || 0); recordCodeUsage(evt.model, evt.usage) }
2250
2217
  // FL-B1 (lane) — a flow lane that ENDS ITS TURN is done: a bypass lane runs its slice
2251
2218
  // to completion in one turn, then narrates "done" and stops. Models often skip the
@@ -2870,10 +2837,6 @@ channel
2870
2837
  // control lines (like /model) so both members AND late-joiners see it. Scope is honest —
2871
2838
  // the Agent SDK exposes tokens + cost, not plan/rate-limit meters, and the card says so.
2872
2839
  if (/^\/usage\s*$/.test(text)) { ctlLine(usageReportLine(s.model, s.log)); return }
2873
- // Pre-flight budget gate (room scope) — refuse the billable agent turn if the room hit
2874
- // its cap. The slash controls above (/model, /clear, /compact) are intentionally exempt:
2875
- // they let a user manage or REDUCE spend even when capped. Legible refusal, no silent stall.
2876
- if (await budgetBlocked(s, payload.term)) return
2877
2840
  // Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
2878
2841
  // restore recap fired on init-`system`. Prepend the recap to THIS turn (one turn, not two
2879
2842
  // — a separate recap turn would race + double-fire) and clear the flag so the init handler
@@ -503,7 +503,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
503
503
  // hasn't already opted into). If the spawner is already in bypass, children inherit
504
504
  // bypass with NO prompt (you opted in once); a non-bypass child gates itself per
505
505
  // action anyway, so no spawn prompt there either. Bridge-side spawnDecision still
506
- // enforces hop-0-only / per-turn cap / 8-terminal cap / TP_SPAWN_OFF regardless.
506
+ // enforces the depth-2 hop bound / burst window / machineMax / TP_SPAWN_OFF regardless.
507
507
  if (toolName === 'mcp__thinkpool__spawn_terminal') {
508
508
  const childMode = toolInput?.mode || mode // inherit this lane's mode by default
509
509
  const escalating = childMode === 'bypassPermissions' && mode !== 'bypassPermissions'
@@ -690,7 +690,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
690
690
  'RUN IT, DO NOT ASK: verify your own work before calling it done. Actually run or serve what you changed, observe that it behaves correctly, and show the evidence in the room — a screenshot of the running result, the passing test output, or the real response — rather than telling the user "this should work, go test it." If you could not verify something, say exactly what is unverified. This room follows verify-before-claiming: runtime evidence you produced, not assertion.',
691
691
  '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.',
692
692
  '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.',
693
- '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.',
693
+ '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 sliding spawn-burst window, 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 machine cap on live lanes, and a DEPTH bound — a lane opened by a person may spawn workers, and those workers may spawn one further level (a conductor dispatching its crew), but that second level can never spawn onward (so no runaway). This is how you "open the terminals yourself" instead of asking a person to.',
694
694
  '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.',
695
695
  '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.',
696
696
  '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.',
@@ -240,12 +240,21 @@ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {
240
240
  // this stays a PURE function (no Date.now inside; the clock is injected, like
241
241
  // spawnCount was before).
242
242
  export const SPAWN = {
243
- maxHop: 1, // human turn = hop 0; a spawned/injected lane = hop ≥ 1, CANNOT spawn onward
244
- perWindowCap: 4, // spawn_terminal calls allowed per rolling window (burst breaker)
243
+ maxHop: 2, // human turn = hop 0; its spawned lane (hop 1, a "conductor") MAY spawn
244
+ // workers (hop 2); hop ≥ 2 can never spawn onward. Depth-2 cascades,
245
+ // fork-bomb still impossible by construction — every level also passes
246
+ // machineMax + the plan ceiling + the burst window + the kill-switch.
247
+ // Was 1 until 2026-07-10 (docs/specs/2026-07-10-cascade-spawn-depth.md):
248
+ // conductors dispatched by a person's lane could not dispatch at all.
249
+ perWindowCap: 6, // spawn_terminal calls allowed per rolling window (burst breaker);
250
+ // 6 matches plusMaxSpawned so one wave can open a full crew (was 4)
245
251
  windowMs: 10 * 60 * 1000, // sliding window over which perWindowCap applies (10 min)
246
252
  freeMaxSpawned: 3, // Free plan dispatch ceiling (concurrent spawned lanes)
247
253
  plusMaxSpawned: 6, // Plus plan dispatch ceiling
248
- machineMax: 10, // hard cap on TOTAL live lanes (the laptop/runaway backstop)
254
+ machineMax: 12, // hard cap on TOTAL live lanes (the laptop/runaway backstop). 10 → 12
255
+ // 2026-07-10: finished-but-open lanes were starving slots. NOT higher:
256
+ // at ~10 live lanes CPU contention corrupted Lighthouse runs that day —
257
+ // this cap protects the host and measurement quality, not just runaway.
249
258
  }
250
259
 
251
260
  // Plan → dispatch ceiling. Anything not 'plus' is treated as Free (the safe default).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.181",
3
+ "version": "0.7.183",
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": {