thinkpool-pair 0.7.153 → 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 })
@@ -2266,7 +2270,7 @@ channel
2266
2270
  s.postCount = 0
2267
2271
  s.pairPeekCount = 0 // cross-room read budget resets with the in-room ones (Tier 1)
2268
2272
  s.crossRoomPostCount = 0 // Tier 3 cross-room post budget resets on a real human turn
2269
- 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)
2270
2274
  s.hop = 0
2271
2275
  s.roomHop = 0 // a human turn is room-hop 0 — clears any injected cross-room hop depth
2272
2276
  const text = String(payload.text)
@@ -227,15 +227,25 @@ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {
227
227
  // own terminals, and the human's terminals never eat the dispatch budget. Under
228
228
  // BYOK-only the tokens are the user's, so this is feature-shaping + machine
229
229
  // safety, NOT a ThinkPool-COGS control.
230
- // • 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).
231
237
  // The caller measures: spawnedLive (live sessions with spawnedBy set), totalLive
232
- // (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).
233
242
  export const SPAWN = {
234
- maxHop: 1, // human turn = hop 0; a spawned/injected lane = hop ≥ 1, CANNOT spawn onward
235
- perTurnCap: 4, // spawn_terminal calls allowed per turn
236
- freeMaxSpawned: 3, // Free plan dispatch ceiling (concurrent spawned lanes)
237
- plusMaxSpawned: 6, // Plus plan dispatch ceiling
238
- 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)
239
249
  }
240
250
 
241
251
  // Plan → dispatch ceiling. Anything not 'plus' is treated as Free (the safe default).
@@ -244,14 +254,24 @@ export const maxSpawnedFor = (plan, limits = SPAWN) => {
244
254
  return plan === 'plus' ? o.plusMaxSpawned : o.freeMaxSpawned
245
255
  }
246
256
 
247
- 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) => {
248
265
  const o = { ...SPAWN, ...(limits || {}) }
249
266
  if (disabled) return { ok: false, reason: 'Spawning terminals is disabled in this room.' }
250
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.' }
251
268
  if (totalLive >= o.machineMax) return { ok: false, reason: `Room is at the machine cap (${o.machineMax} live lanes) — close one before opening another.` }
252
269
  const cap = maxSpawnedFor(plan, o)
253
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.` }
254
- 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
+ }
255
275
  return { ok: true }
256
276
  }
257
277
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.153",
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": {