thinkpool-pair 0.7.84 → 0.7.86

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
@@ -39,7 +39,7 @@ import { createClient } from '@supabase/supabase-js'
39
39
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
40
40
  import { z } from 'zod'
41
41
  import { startClaudeSession } from './claude-session.mjs'
42
- import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST } from './cross-terminal.mjs'
42
+ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision } from './cross-terminal.mjs'
43
43
  import { turnInFlight } from './update-gate.mjs'
44
44
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
45
45
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
@@ -965,6 +965,79 @@ function openStructured({ id, model, resume, log, commands, mode }) {
965
965
  return okText(`Delivered to terminal ${target.ref} (${target.cmd}). It will respond in its own lane; check back with read_terminal.`)
966
966
  },
967
967
  ),
968
+ // Tier C+ — SPAWN a fresh agent lane the caller OWNS. The motivating bug:
969
+ // an agent fanned a research task into SIBLINGS that were already busy (a
970
+ // release lane, a Q&A lane) because it had no way to make its own lanes.
971
+ // This opens a brand-new structured session server-side (the same path the
972
+ // web's "+ New terminal" and the restore loop use), so announce() surfaces
973
+ // its tab in the open web UI live. Auto-allowed by the PreToolUse gate but
974
+ // bounded by spawnDecision (hop-0-only, per-turn cap, the live terminal cap,
975
+ // TP_SPAWN_OFF kill-switch). A spawned lane runs one hop deep, so it cannot
976
+ // recursively spawn/post onward — the fork-bomb breaker (mirrors Tier C).
977
+ tool(
978
+ 'spawn_terminal',
979
+ '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. Bounded by the room terminal cap and a per-turn limit.',
980
+ {
981
+ name: z.string().max(80).optional().describe('a short label for the new lane so the room (and you) can find it, e.g. "Research: topologies"'),
982
+ task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
983
+ model: z.string().optional().describe('optional model for the lane, e.g. opus / sonnet / haiku'),
984
+ mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('permission mode for the new lane; defaults to inheriting YOUR current mode. Raising a lane to bypassPermissions from a non-bypass lane asks the room to confirm once.'),
985
+ },
986
+ async (args) => {
987
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
988
+ const gate = spawnDecision({ hop: entry.hop || 0, spawnCount: entry.spawnCount || 0, liveCount: sessions.size + terms.size, disabled: process.env.TP_SPAWN_OFF === '1' })
989
+ if (!gate.ok) return okText(gate.reason)
990
+ entry.spawnCount = (entry.spawnCount || 0) + 1
991
+ const newId = randomUUID()
992
+ const newRef = String(newId).slice(0, 8)
993
+ const fromRef = String(id).slice(0, 8)
994
+ // Name BEFORE opening so openStructured's own announce already carries the label.
995
+ if (args?.name) { termNames[newId] = String(args.name).slice(0, 80); saveNames(room, termNames) }
996
+ // Inherit the spawner's permission mode by default (so a bypass orchestrator's
997
+ // lanes run autonomously); an explicit `mode` overrides. The PreToolUse gate
998
+ // already confirmed any bypass-escalation from a non-bypass lane before we got here.
999
+ const childMode = args?.mode || entry.mode
1000
+ openStructured({ id: newId, model: args?.model, mode: childMode })
1001
+ const ne = sessions.get(newId)
1002
+ if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
1003
+ ne.spawnedBy = id // ownership: only the spawner may close_terminal it
1004
+ announce() // (defensive) ensure the web renders the new tab live
1005
+ if (args?.task) {
1006
+ // One hop deep: the spawned lane cannot spawn/post onward until a person
1007
+ // speaks to it (code-turn resets hop to 0). Mirrors the Tier C injection.
1008
+ ne.hop = 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnCount = 0
1009
+ 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}`
1010
+ const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true }
1011
+ stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
1012
+ try { ne.session.sendTurn(msg) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
1013
+ return okText(`Opened agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It runs in its own lane — check back with read_terminal, then close_terminal when done.`)
1014
+ }
1015
+ return okText(`Opened idle agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}. Hand it work with post_to_terminal, or a person can type into it.`)
1016
+ },
1017
+ ),
1018
+ // Tier C+ — CLOSE a lane the caller spawned. Restricted to self-spawned agent
1019
+ // lanes (spawnedBy === this id): an agent must never be able to kill a sibling
1020
+ // someone else is working in, nor the host/attached terminal. The fan-out
1021
+ // cleanup half of spawn_terminal. Routes through endStructured (same path as
1022
+ // the web's code-close), which no-ops if the id isn't a live session.
1023
+ tool(
1024
+ 'close_terminal',
1025
+ 'Close an agent lane that YOU opened with spawn_terminal (identified by ref/id/name). You can only close lanes you spawned yourself — never a sibling someone else is working in, and never the main terminal. Use it to clean up after a fan-out once you have collected the results with read_terminal.',
1026
+ {
1027
+ terminal: z.string().describe('ref, id, or name of the lane you spawned (from read_terminal)'),
1028
+ },
1029
+ async (args) => {
1030
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
1031
+ const sib = siblingsOf({ selfId: id, sessions, terms, names: termNames })
1032
+ const target = resolveSibling(sib, args?.terminal)
1033
+ if (!target) return okText(`No terminal matches "${args?.terminal}". Call read_terminal with no argument to list the open terminals.`)
1034
+ const te = sessions.get(target.id)
1035
+ if (!te) return okText(`Terminal ${target.ref} is not an agent lane (or already closed) — only spawned agent lanes can be closed this way.`)
1036
+ if (te.spawnedBy !== id) return okText(`Terminal ${target.ref} was not opened by you — you can only close lanes you spawned yourself. Leave it to the person running it.`)
1037
+ endStructured(target.id)
1038
+ return okText(`Closed agent lane ${target.ref}${target.name ? ` ("${target.name}")` : ''}.`)
1039
+ },
1040
+ ),
968
1041
  ],
969
1042
  })
970
1043
  entry.session = startClaudeSession({
@@ -253,6 +253,32 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
253
253
  if (toolName === 'mcp__thinkpool__read_terminal') {
254
254
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-terminal read).' } }
255
255
  }
256
+ // ThinkPool cross-terminal CLOSE (Tier C+) — closes only a lane the agent itself
257
+ // spawned (bridge-side spawnedBy check), never a sibling's work nor the host
258
+ // terminal. Low-risk lane management → auto-allow (surfaced as a card for visibility).
259
+ if (toolName === 'mcp__thinkpool__close_terminal') {
260
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (ThinkPool cross-terminal close — only self-spawned lanes).' } }
261
+ }
262
+ // ThinkPool cross-terminal SPAWN (Tier C+) — opens a FRESH lane the agent owns
263
+ // (never touches a sibling). A spawned lane INHERITS the spawner's permission mode
264
+ // (`mode` here is THIS lane's current mode) unless an explicit `mode` overrides it.
265
+ // The only thing worth a human card is an ESCALATION: raising a child to
266
+ // bypassPermissions from a non-bypass lane (full unattended access the operator
267
+ // hasn't already opted into). If the spawner is already in bypass, children inherit
268
+ // bypass with NO prompt (you opted in once); a non-bypass child gates itself per
269
+ // action anyway, so no spawn prompt there either. Bridge-side spawnDecision still
270
+ // enforces hop-0-only / per-turn cap / 8-terminal cap / TP_SPAWN_OFF regardless.
271
+ if (toolName === 'mcp__thinkpool__spawn_terminal') {
272
+ const childMode = toolInput?.mode || mode // inherit this lane's mode by default
273
+ const escalating = childMode === 'bypassPermissions' && mode !== 'bypassPermissions'
274
+ if (!escalating) {
275
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Auto-approved (spawn a fresh lane in ${childMode} mode — inherited, no escalation; bounded by the room caps).` } }
276
+ }
277
+ let decision = 'deny'
278
+ try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: { ...toolInput, mode: childMode }, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
279
+ const allowed = decision === 'allow'
280
+ 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.' } }
281
+ }
256
282
  // ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane —
257
283
  // human-gated EVERY time, never remembered. First a bridge-side precheck
258
284
  // (kill-switch / loop-breaker / per-turn cap) so a blocked or runaway post
@@ -386,6 +412,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
386
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.',
387
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.',
388
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.',
389
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.',
390
417
  ].join(' '),
391
418
  // Needed for live thinking-token progress (SDKThinkingTokensMessage) to
@@ -158,3 +158,31 @@ export const crossPostDecision = ({ hop = 0, postCount = 0, disabled = false } =
158
158
  if (postCount >= o.perTurnCap) return { ok: false, reason: `Cross-terminal post limit reached for this turn (${o.perTurnCap}).` }
159
159
  return { ok: true }
160
160
  }
161
+
162
+ // ── Tier C+: cross-terminal SPAWN (open a FRESH lane the agent owns) ──────────
163
+ // Pure gate for whether a session may open a new terminal on THIS call. The
164
+ // motivating bug: an agent dumped a fan-out into SIBLINGS that were already
165
+ // working (a bridge release lane, a Q&A lane) because it had no way to make its
166
+ // OWN lanes. spawn_terminal gives it fresh lanes; this gate keeps that from
167
+ // becoming a fork bomb. Bounds, in order:
168
+ // • kill-switch (TP_SPAWN_OFF)
169
+ // • hop — a lane REACHED via cross-post/spawn (hop ≥ 1) cannot spawn onward, so
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
173
+ // • perTurnCap — a per-turn runaway breaker (mirrors PEEK/CROSSPOST)
174
+ // liveCount is the caller's measured sessions.size + terms.size at call time.
175
+ 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)
179
+ }
180
+
181
+ export const spawnDecision = ({ hop = 0, spawnCount = 0, liveCount = 0, disabled = false } = {}, limits = SPAWN) => {
182
+ const o = { ...SPAWN, ...(limits || {}) }
183
+ if (disabled) return { ok: false, reason: 'Spawning terminals is disabled in this room.' }
184
+ 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.` }
186
+ if (spawnCount >= o.perTurnCap) return { ok: false, reason: `Spawn limit reached for this turn (${o.perTurnCap}).` }
187
+ return { ok: true }
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.84",
3
+ "version": "0.7.86",
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": {