thinkpool-pair 0.7.88 → 0.7.90

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
@@ -421,6 +421,28 @@ const safeName = (n) => String(n || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').sli
421
421
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
422
422
  realtime: { params: { eventsPerSecond: 60 } },
423
423
  })
424
+
425
+ // Owner's plan (free|plus) — gates the Ensemble dispatch ceiling (Free 3 / Plus 6).
426
+ // Resolved from the owner token (codeAuthToken); RLS lets a user read their OWN
427
+ // profiles row. Cached; defaults to 'free' (the safe lower ceiling) until the fetch
428
+ // lands. Under BYOK-only the tokens are the user's, so this is feature-shaping, not a
429
+ // COGS control — a stale/failed fetch just means a conservative ceiling, never a leak.
430
+ let ownerPlan = 'free'
431
+ async function refreshOwnerPlan() {
432
+ const tok = codeAuthToken
433
+ if (!tok) return
434
+ try {
435
+ const { data, error } = await supabase.auth.getUser(tok)
436
+ const uid = data?.user?.id
437
+ if (error || !uid) return
438
+ const r = await fetch(`${SUPABASE_URL}/rest/v1/profiles?select=plan&id=eq.${uid}`,
439
+ { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${tok}` } })
440
+ if (!r.ok) return
441
+ const rows = await r.json()
442
+ if (rows?.[0]?.plan) ownerPlan = rows[0].plan === 'plus' ? 'plus' : 'free'
443
+ } catch { /* DNS-flaky / offline — keep the safe default; the next token refresh retries */ }
444
+ }
445
+ refreshOwnerPlan() // fire-and-forget on boot (codeAuthToken may be set from env)
424
446
  // Throttled presence track() (presence.mjs) — a reconnect storm re-fires SUBSCRIBED and
425
447
  // the handler re-track()s; without throttle that spirals into the per-client presence
426
448
  // rate limit (ClientPresenceRateLimitReached). Lazy so it binds `channel` (the chain
@@ -557,7 +579,7 @@ const announce = () =>
557
579
  // the chosen label instead of defaulting to Normal / unnamed. `spawned` (set
558
580
  // when an agent Dispatched this lane via spawn_terminal) lets the client keep
559
581
  // it OUT of the top tab strip and show it only in the Ensemble menu.
560
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, ...(s.spawnedBy ? { spawned: true } : {}) })),
582
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}) })),
561
583
  ],
562
584
  })
563
585
 
@@ -868,9 +890,12 @@ function restoredTurnOpen(log) {
868
890
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
869
891
  // persist to the host file; tool calls round-trip through the perm card; the
870
892
  // rolling log replays to joiners and survives bridge restarts (session-store).
871
- function openStructured({ id, model, resume, log, commands, mode }) {
893
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
872
894
  if (sessions.has(id)) return
873
- const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode }
895
+ // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
896
+ // session store so the Ensemble flag survives a bridge restart (else a respin
897
+ // stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
898
+ const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, spawnedBy: spawnedBy || undefined }
874
899
  // C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
875
900
  // so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
876
901
  // an old seq, which is what keeps the between-turns restart dup-free (see
@@ -894,7 +919,7 @@ function openStructured({ id, model, resume, log, commands, mode }) {
894
919
  if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
895
920
  // Persist the permission mode alongside the transcript so a bridge restart
896
921
  // restores the session in the SAME mode (a bypass room stays bypass on resume).
897
- const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode })
922
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, spawnedBy: entry.spawnedBy })
898
923
  const persist = () => saveSession(room, id, sessionData())
899
924
  // Synchronous flush of this session's record. Used on open (so a brand-new session
900
925
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -987,7 +1012,14 @@ function openStructured({ id, model, resume, log, commands, mode }) {
987
1012
  },
988
1013
  async (args) => {
989
1014
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
990
- const gate = spawnDecision({ hop: entry.hop || 0, spawnCount: entry.spawnCount || 0, liveCount: sessions.size + terms.size, disabled: process.env.TP_SPAWN_OFF === '1' })
1015
+ const gate = spawnDecision({
1016
+ hop: entry.hop || 0,
1017
+ spawnCount: entry.spawnCount || 0,
1018
+ spawnedLive: [...sessions.values()].filter((s) => s.spawnedBy).length, // dispatch budget counts ONLY spawned lanes
1019
+ totalLive: sessions.size + terms.size, // machine cap counts everything
1020
+ plan: ownerPlan, // Free 3 / Plus 6 dispatch ceiling
1021
+ disabled: process.env.TP_SPAWN_OFF === '1',
1022
+ })
991
1023
  if (!gate.ok) return okText(gate.reason)
992
1024
  entry.spawnCount = (entry.spawnCount || 0) + 1
993
1025
  const newId = randomUUID()
@@ -1449,7 +1481,7 @@ channel
1449
1481
  // Closed sessions are unlinked by deleteSession, so a file existing on disk ==
1450
1482
  // a session that was live at shutdown (never an explicitly closed one).
1451
1483
  const all = loadAll(room)
1452
- if (all.length) for (const rec of all) openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode })
1484
+ if (all.length) for (const rec of all) openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy })
1453
1485
  // Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
1454
1486
  // (autoAgent set, no attachedCmd) the web's `?new=1` flow opens the first
1455
1487
  // terminal; opening one here too raced it into TWO terminals — each a
@@ -1575,7 +1607,7 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
1575
1607
  if (m.t === 'update-available') surfaceUpdate(m.version)
1576
1608
  // Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
1577
1609
  // it so our authed writes (code-mockup) never go stale on a long session (L16).
1578
- else if (m.t === 'token') codeAuthToken = m.accessToken || null
1610
+ else if (m.t === 'token') { codeAuthToken = m.accessToken || null; refreshOwnerPlan() }
1579
1611
  })
1580
1612
  setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
1581
1613
  } else if (VERSION) {
@@ -412,7 +412,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
412
412
  '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.',
413
413
  '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.',
414
414
  '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.',
415
- '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: at most a few new lanes per turn and 8 terminals total in the room, 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.',
415
+ '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.',
416
416
  '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.',
417
417
  ].join(' '),
418
418
  // Needed for live thinking-token progress (SDKThinkingTokensMessage) to
@@ -168,21 +168,37 @@ export const crossPostDecision = ({ hop = 0, postCount = 0, disabled = false } =
168
168
  // • kill-switch (TP_SPAWN_OFF)
169
169
  // • hop — a lane REACHED via cross-post/spawn (hop ≥ 1) cannot spawn onward, so
170
170
  // a spawned agent can't recursively spawn (the loop breaker, mirrors CROSSPOST)
171
- // • maxTerms — the hard live-terminal cap (matches openTerm's cap of 8); the
172
- // real backstop, since spawned lanes are bounded by it regardless of turns
171
+ // • machineMax — overall hard cap on LIVE lanes (sessions+terms): the runaway /
172
+ // don't-melt-the-laptop backstop, plan-INDEPENDENT.
173
+ // • dispatch ceiling — a SEPARATE, plan-tiered budget counting ONLY spawned lanes
174
+ // (Free 3 / Plus 6). Counts spawned lanes ONLY, so they never block the human's
175
+ // own terminals, and the human's terminals never eat the dispatch budget. Under
176
+ // BYOK-only the tokens are the user's, so this is feature-shaping + machine
177
+ // safety, NOT a ThinkPool-COGS control.
173
178
  // • perTurnCap — a per-turn runaway breaker (mirrors PEEK/CROSSPOST)
174
- // liveCount is the caller's measured sessions.size + terms.size at call time.
179
+ // The caller measures: spawnedLive (live sessions with spawnedBy set), totalLive
180
+ // (sessions.size + terms.size), and resolves `plan` from the room owner.
175
181
  export const SPAWN = {
176
- maxHop: 1, // human turn = hop 0; a spawned/injected lane = hop ≥ 1, which CANNOT spawn onward
177
- perTurnCap: 4, // spawn_terminal calls allowed per turn (total still capped by maxTerms)
178
- maxTerms: 8, // hard cap on live terminals in the room (matches openTerm)
182
+ maxHop: 1, // human turn = hop 0; a spawned/injected lane = hop ≥ 1, CANNOT spawn onward
183
+ perTurnCap: 4, // spawn_terminal calls allowed per turn
184
+ freeMaxSpawned: 3, // Free plan dispatch ceiling (concurrent spawned lanes)
185
+ plusMaxSpawned: 6, // Plus plan dispatch ceiling
186
+ machineMax: 10, // hard cap on TOTAL live lanes (the laptop/runaway backstop)
179
187
  }
180
188
 
181
- export const spawnDecision = ({ hop = 0, spawnCount = 0, liveCount = 0, disabled = false } = {}, limits = SPAWN) => {
189
+ // Plan → dispatch ceiling. Anything not 'plus' is treated as Free (the safe default).
190
+ export const maxSpawnedFor = (plan, limits = SPAWN) => {
191
+ const o = { ...SPAWN, ...(limits || {}) }
192
+ return plan === 'plus' ? o.plusMaxSpawned : o.freeMaxSpawned
193
+ }
194
+
195
+ export const spawnDecision = ({ hop = 0, spawnCount = 0, spawnedLive = 0, totalLive = 0, plan = 'free', disabled = false } = {}, limits = SPAWN) => {
182
196
  const o = { ...SPAWN, ...(limits || {}) }
183
197
  if (disabled) return { ok: false, reason: 'Spawning terminals is disabled in this room.' }
184
198
  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.' }
185
- if (liveCount >= o.maxTerms) return { ok: false, reason: `Terminal cap (${o.maxTerms}) reached — close a terminal before opening another.` }
199
+ if (totalLive >= o.machineMax) return { ok: false, reason: `Room is at the machine cap (${o.machineMax} live lanes) — close one before opening another.` }
200
+ const cap = maxSpawnedFor(plan, o)
201
+ 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.` }
186
202
  if (spawnCount >= o.perTurnCap) return { ok: false, reason: `Spawn limit reached for this turn (${o.perTurnCap}).` }
187
203
  return { ok: true }
188
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.88",
3
+ "version": "0.7.90",
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": {