thinkpool-pair 0.7.152 → 0.7.154

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, formatRoomNow } from './cross-terminal.mjs'
84
+ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, 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'
@@ -1593,16 +1593,20 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1593
1593
  },
1594
1594
  async (args) => {
1595
1595
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
1596
+ const now = Date.now()
1596
1597
  const gate = spawnDecision({
1597
1598
  hop: entry.hop || 0,
1598
- spawnCount: entry.spawnCount || 0,
1599
+ spawnTimes: entry.spawnTimes || [], // sliding-window burst breaker (replenishes on wall-clock, not on a human turn)
1600
+ now,
1599
1601
  spawnedLive: [...sessions.values()].filter((s) => s.spawnedBy).length, // dispatch budget counts ONLY spawned lanes
1600
1602
  totalLive: sessions.size + terms.size, // machine cap counts everything
1601
1603
  plan: ownerPlan, // Free 3 / Plus 6 dispatch ceiling
1602
1604
  disabled: process.env.TP_SPAWN_OFF === '1',
1603
1605
  })
1604
1606
  if (!gate.ok) return okText(gate.reason)
1605
- entry.spawnCount = (entry.spawnCount || 0) + 1
1607
+ // Record this spawn, pruning timestamps that have already left the window
1608
+ // (identical predicate to recentSpawnCount) so the array can't grow unbounded.
1609
+ entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t > now - SPAWN.windowMs), now]
1606
1610
  const newId = randomUUID()
1607
1611
  const newRef = String(newId).slice(0, 8)
1608
1612
  const fromRef = String(id).slice(0, 8)
@@ -1623,7 +1627,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1623
1627
  if (args?.task) {
1624
1628
  // One hop deep: the spawned lane cannot spawn/post onward until a person
1625
1629
  // speaks to it (code-turn resets hop to 0). Mirrors the Tier C injection.
1626
- ne.hop = 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnCount = 0
1630
+ ne.hop = 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
1627
1631
  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}`
1628
1632
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true }
1629
1633
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
@@ -1780,6 +1784,22 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1780
1784
  // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
1781
1785
  // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
1782
1786
  crossPostGate: () => crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' }),
1787
+ // Tier C consent scope (2026-07-03) — the PreToolUse gate asks whether THIS lane
1788
+ // spawned the post's target, so a parent→child post auto-allows without a human
1789
+ // tap. Resolves the sibling exactly like the post_to_terminal handler does, then
1790
+ // checks the ownership marker (spawn_terminal sets ne.spawnedBy = spawner id). A
1791
+ // flow lane's spawnedBy is `flow:<id>` (not a session id), so it never matches
1792
+ // here — flow posts ride the bypass rule instead. Fails safe (false → card).
1793
+ // Spec: docs/specs/2026-07-03-xpost-consent-scope.md.
1794
+ didSpawnTarget: (terminalRef) => {
1795
+ try {
1796
+ const sib = siblingsOf({ selfId: id, sessions, terms, names: termNames })
1797
+ const target = resolveSibling(sib, terminalRef)
1798
+ if (!target) return false
1799
+ const te = sessions.get(target.id)
1800
+ return !!te && te.spawnedBy === id
1801
+ } catch { return false }
1802
+ },
1783
1803
  // Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
1784
1804
  // the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
1785
1805
  crossRoomPostGate: () => crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' }),
@@ -2250,7 +2270,7 @@ channel
2250
2270
  s.postCount = 0
2251
2271
  s.pairPeekCount = 0 // cross-room read budget resets with the in-room ones (Tier 1)
2252
2272
  s.crossRoomPostCount = 0 // Tier 3 cross-room post budget resets on a real human turn
2253
- s.spawnCount = 0 // EN-M3 — spawn budget MUST reset too, else turn 2+ can never spawn a lane
2273
+ s.spawnTimes = [] // EN-M3 — a real human turn clears the spawn burst window instantly (the window also self-replenishes on wall-clock for UNATTENDED cascades that never reach this handler)
2254
2274
  s.hop = 0
2255
2275
  s.roomHop = 0 // a human turn is room-hop 0 — clears any injected cross-room hop depth
2256
2276
  const text = String(payload.text)
@@ -16,6 +16,7 @@ import { randomUUID } from 'node:crypto'
16
16
  import { query } from '@anthropic-ai/claude-agent-sdk'
17
17
  import { sanitizeSession } from './transcript-sanitize.mjs'
18
18
  import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
19
+ import { crossPostNeedsCard } from './cross-terminal.mjs'
19
20
 
20
21
  // ── risk classification — the accent/danger tier of the permission card ──
21
22
  // low (read-only) · medium (writes/runs) · network (leaves the machine) ·
@@ -186,7 +187,7 @@ export async function oneShotSummary({ prompt, env, cwd, timeoutMs = 25000 }) {
186
187
  return null
187
188
  }
188
189
 
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
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null }) {
190
191
  // Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
191
192
  // room's CURRENT state — sibling lanes, active git worktrees — or null. The static
192
193
  // rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
@@ -482,16 +483,31 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
482
483
  const allowed = decision === 'allow'
483
484
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Bypass spawn approved in the ThinkPool room — the new lane runs autonomously.' : 'Bypass spawn denied in the room — do not retry as bypass; re-spawn without mode:bypassPermissions to get a gated lane, or ask what to do.' } }
484
485
  }
485
- // ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane —
486
- // human-gated EVERY time, never remembered. First a bridge-side precheck
487
- // (kill-switch / loop-breaker / per-turn cap) so a blocked or runaway post
488
- // never even raises a card; then the standard permission round-trip at 'high'
489
- // risk (card offers Deny / Allow-once only — no "always"). Spec INV-4.
486
+ // ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane.
487
+ // First a bridge-side precheck (kill-switch / loop-breaker / per-turn cap) so a
488
+ // blocked or runaway post never even raises a card. THEN the consent SCOPE
489
+ // (crossPostNeedsCard, 2026-07-03): a post auto-allows with NO card in exactly
490
+ // two cases — (a) this lane is in bypassPermissions (bypass means bypass), or
491
+ // (b) this lane SPAWNED the target (parent→child orchestration; bridge-side
492
+ // didSpawnTarget resolves the sibling + checks spawnedBy). Every OTHER post — a
493
+ // non-owner into a lane it did not spawn, in any non-bypass mode — still round-
494
+ // trips the 'high'-risk card (Deny / Allow-once only). This unblocks automated
495
+ // conductor→lane orchestration (the 3-stacked-Dispatch-cards bug) WITHOUT loosening
496
+ // the invariant: a lane can never be posted into by a non-owner without a human tap,
497
+ // except in bypass. Cross-ROOM post_to_session (dual-consent) is untouched below.
498
+ // Spec: docs/specs/2026-07-03-xpost-consent-scope.md.
490
499
  if (toolName === 'mcp__thinkpool__post_to_terminal') {
491
500
  const gate = crossPostGate ? crossPostGate() : { ok: true }
492
501
  if (!gate.ok) {
493
502
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: gate.reason || 'Cross-terminal post not allowed right now.' } }
494
503
  }
504
+ const spawnedByMe = didSpawnTarget ? !!didSpawnTarget(toolInput?.terminal) : false
505
+ if (!crossPostNeedsCard({ mode, spawnedByMe })) {
506
+ const why = mode === 'bypassPermissions'
507
+ ? 'Auto-approved (cross-terminal post in bypassPermissions mode — bypass means bypass).'
508
+ : 'Auto-approved (cross-terminal post into a lane you spawned — parent→child orchestration, no per-post tap).'
509
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: why } }
510
+ }
495
511
  let decision = 'deny'
496
512
  try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
497
513
  const allowed = decision === 'allow'
@@ -193,6 +193,24 @@ export const crossPostDecision = ({ hop = 0, postCount = 0, disabled = false } =
193
193
  return { ok: true }
194
194
  }
195
195
 
196
+ // ── Tier C consent SCOPE — does this post_to_terminal need a human card? ──────
197
+ // Runs AFTER crossPostDecision's kill-switch/hop/cap precheck passes, and decides
198
+ // only whether a person must TAP a card before the (already-permitted) post is
199
+ // delivered. Two — and only two — cases auto-allow (no card):
200
+ // (1) bypassPermissions mode — bypass means bypass, cross-lane posts included.
201
+ // Without this, a conductor's post into its OWN lane queued behind a human
202
+ // tap even in a bypass session (the 3-stacked-Dispatch-cards bug, 2026-07-03).
203
+ // (2) the SENDER SPAWNED the target lane (parent→child orchestration) — a
204
+ // conductor posting into a lane it Dispatched with spawn_terminal is not a
205
+ // stranger writing into someone else's work, so no per-post tap is needed.
206
+ // The CARD REMAINS for every other post: a non-owner posting into a lane it did
207
+ // not spawn, in any non-bypass mode. That is the load-bearing invariant — a lane
208
+ // can never be posted into by a non-owner without a human tap, except in bypass.
209
+ // Cross-ROOM post_to_session is a SEPARATE path (dual-consent) and is untouched.
210
+ // Spec: docs/specs/2026-07-03-xpost-consent-scope.md
211
+ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {}) =>
212
+ !(mode === 'bypassPermissions' || spawnedByMe === true)
213
+
196
214
  // ── Tier C+: cross-terminal SPAWN (open a FRESH lane the agent owns) ──────────
197
215
  // Pure gate for whether a session may open a new terminal on THIS call. The
198
216
  // motivating bug: an agent dumped a fan-out into SIBLINGS that were already
@@ -209,15 +227,25 @@ export const crossPostDecision = ({ hop = 0, postCount = 0, disabled = false } =
209
227
  // own terminals, and the human's terminals never eat the dispatch budget. Under
210
228
  // BYOK-only the tokens are the user's, so this is feature-shaping + machine
211
229
  // safety, NOT a ThinkPool-COGS control.
212
- // • perTurnCap — a per-turn runaway breaker (mirrors PEEK/CROSSPOST)
230
+ // • burst window — a SLIDING-window runaway breaker: ≤ perWindowCap spawns per
231
+ // rolling windowMs. Replaces the old per-HUMAN-turn cap, which starved an
232
+ // UNATTENDED conductor: agent turns woken by Monitor/task notifications never
233
+ // traverse the 'code-turn' handler that reset the per-turn counter, so a cascade
234
+ // that spent its 4 spawns could NEVER spawn again until a person typed. The
235
+ // window replenishes on wall-clock alone — no turn boundary required — while a
236
+ // real human turn still clears it instantly (the reset point is unchanged).
213
237
  // The caller measures: spawnedLive (live sessions with spawnedBy set), totalLive
214
- // (sessions.size + terms.size), and resolves `plan` from the room owner.
238
+ // (sessions.size + terms.size), resolves `plan` from the room owner, and passes the
239
+ // lane's recent spawn timestamps (`spawnTimes`) plus the current clock (`now`) — so
240
+ // this stays a PURE function (no Date.now inside; the clock is injected, like
241
+ // spawnCount was before).
215
242
  export const SPAWN = {
216
- maxHop: 1, // human turn = hop 0; a spawned/injected lane = hop ≥ 1, CANNOT spawn onward
217
- perTurnCap: 4, // spawn_terminal calls allowed per turn
218
- freeMaxSpawned: 3, // Free plan dispatch ceiling (concurrent spawned lanes)
219
- plusMaxSpawned: 6, // Plus plan dispatch ceiling
220
- machineMax: 10, // hard cap on TOTAL live lanes (the laptop/runaway backstop)
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)
245
+ windowMs: 10 * 60 * 1000, // sliding window over which perWindowCap applies (10 min)
246
+ freeMaxSpawned: 3, // Free plan dispatch ceiling (concurrent spawned lanes)
247
+ plusMaxSpawned: 6, // Plus plan dispatch ceiling
248
+ machineMax: 10, // hard cap on TOTAL live lanes (the laptop/runaway backstop)
221
249
  }
222
250
 
223
251
  // Plan → dispatch ceiling. Anything not 'plus' is treated as Free (the safe default).
@@ -226,14 +254,24 @@ export const maxSpawnedFor = (plan, limits = SPAWN) => {
226
254
  return plan === 'plus' ? o.plusMaxSpawned : o.freeMaxSpawned
227
255
  }
228
256
 
229
- export const spawnDecision = ({ hop = 0, spawnCount = 0, spawnedLive = 0, totalLive = 0, plan = 'free', disabled = false } = {}, limits = SPAWN) => {
257
+ // Count spawn timestamps still inside the sliding window ending at `now`. Timestamps
258
+ // strictly older than `now - windowMs` have replenished (fallen out of the window).
259
+ // Non-finite entries are ignored defensively. Exported so the bridge can prune its
260
+ // stored spawnTimes with the identical predicate (no unbounded growth).
261
+ export const recentSpawnCount = (spawnTimes, now, windowMs = SPAWN.windowMs) =>
262
+ (Array.isArray(spawnTimes) ? spawnTimes : []).filter((t) => Number.isFinite(t) && t > now - windowMs).length
263
+
264
+ export const spawnDecision = ({ hop = 0, spawnTimes = [], now = 0, spawnedLive = 0, totalLive = 0, plan = 'free', disabled = false } = {}, limits = SPAWN) => {
230
265
  const o = { ...SPAWN, ...(limits || {}) }
231
266
  if (disabled) return { ok: false, reason: 'Spawning terminals is disabled in this room.' }
232
267
  if (hop >= o.maxHop) return { ok: false, reason: 'Spawn limit reached — a terminal reached via cross-terminal cannot itself open further terminals; a person must open the next lane.' }
233
268
  if (totalLive >= o.machineMax) return { ok: false, reason: `Room is at the machine cap (${o.machineMax} live lanes) — close one before opening another.` }
234
269
  const cap = maxSpawnedFor(plan, o)
235
270
  if (spawnedLive >= cap) return { ok: false, reason: `Dispatch ceiling reached (${cap} lane${cap === 1 ? '' : 's'} on the ${plan} plan)${plan !== 'plus' ? ` — Plus raises it to ${o.plusMaxSpawned}.` : '.'} Close a dispatched lane to open another.` }
236
- if (spawnCount >= o.perTurnCap) return { ok: false, reason: `Spawn limit reached for this turn (${o.perTurnCap}).` }
271
+ if (recentSpawnCount(spawnTimes, now, o.windowMs) >= o.perWindowCap) {
272
+ const mins = Math.round(o.windowMs / 60000)
273
+ return { ok: false, reason: `Spawn burst limit reached (${o.perWindowCap} in ${mins} min) — this replenishes as the window slides forward, or instantly when a person types into this lane.` }
274
+ }
237
275
  return { ok: true }
238
276
  }
239
277
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.152",
3
+ "version": "0.7.154",
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": {