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 +25 -5
- package/claude-session.mjs +22 -6
- package/cross-terminal.mjs +47 -9
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -81,7 +81,7 @@ const flowRedispatch = new Map()
|
|
|
81
81
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
82
82
|
// broadcasts; without persistent state the cap can never bite.
|
|
83
83
|
const flowBudgets = new Map()
|
|
84
|
-
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision, 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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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)
|
package/claude-session.mjs
CHANGED
|
@@ -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
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
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'
|
package/cross-terminal.mjs
CHANGED
|
@@ -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
|
-
// •
|
|
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),
|
|
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,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|