thinkpool-pair 0.7.83 → 0.7.85

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 } 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'
@@ -921,15 +921,126 @@ function openStructured({ id, model, resume, log, commands, mode }) {
921
921
  if (entry.peekCount > PEEK.perTurnCap) {
922
922
  return { content: [{ type: 'text', text: `Cross-terminal read limit reached for this turn (${PEEK.perTurnCap}). Continue with what you have, or ask the people in the room.` }] }
923
923
  }
924
- const text = formatPeek({ selfId: id, sessions, terms, terminal: args?.terminal, lines: args?.lines })
924
+ const text = formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args?.terminal, lines: args?.lines })
925
925
  return { content: [{ type: 'text', text }] }
926
926
  },
927
927
  ),
928
+ // Tier C — directed cross-lane action. WRITES into a sibling AGENT lane.
929
+ // The PreToolUse gate (claude-session.mjs) already enforced: precheck passed
930
+ // + a human approved the card. This handler routes only; it re-asserts the
931
+ // bounds defensively. Targets AGENT terminals only (not human shells —
932
+ // injecting keystrokes into someone's shell is out of scope for v1).
933
+ tool(
934
+ 'post_to_terminal',
935
+ 'Send a message or task to ANOTHER AGENT terminal in this ThinkPool Code room. Use `terminal` (a ref/id/command from read_terminal) and `text` (what to send). A person in the room must approve before it is delivered, and an agent reached via a cross-post cannot post onward. Targets agent terminals only, not plain shells. Use read_terminal first to understand the sibling.',
936
+ {
937
+ terminal: z.string().describe('ref, id, or command of the AGENT terminal to send to (from read_terminal)'),
938
+ text: z.string().describe('the message or task to deliver to that terminal'),
939
+ },
940
+ async (args) => {
941
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
942
+ // Defensive re-check (the gate already passed in PreToolUse).
943
+ const gate = crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' })
944
+ if (!gate.ok) return okText(gate.reason)
945
+ const sib = siblingsOf({ selfId: id, sessions, terms, names: termNames })
946
+ const target = resolveSibling(sib, args?.terminal)
947
+ if (!target) return okText(`No terminal matches "${args?.terminal}". Call read_terminal with no argument to list the open terminals.`)
948
+ if (target.kind !== 'agent') return okText(`Terminal ${target.ref} is a shell, not an agent — cross-posting is only allowed to agent terminals.`)
949
+ const te = sessions.get(target.id)
950
+ if (!te?.session) return okText(`Terminal ${target.ref} is no longer live.`)
951
+ entry.postCount = (entry.postCount || 0) + 1
952
+ const fromRef = String(id).slice(0, 8)
953
+ const msg = `[From: terminal ${fromRef}'s agent — relayed via ThinkPool cross-terminal, approved by a person in the room]\n${args.text}`
954
+ // The injected turn starts fresh budgets, one hop deeper (loop breaker).
955
+ te.hop = (entry.hop || 0) + 1
956
+ te.peekCount = 0
957
+ te.postCount = 0
958
+ // Echo the injected prompt into the TARGET lane so both people see what
959
+ // arrived (rides the existing code-event 'you' path — no new topic).
960
+ const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true }
961
+ stampEvent(evt)
962
+ pushLog(te, evt)
963
+ bcast('code-event', { term: target.id, evt })
964
+ try { te.session.sendTurn(msg) } catch { return okText(`Could not deliver to terminal ${target.ref} — it may have just closed.`) }
965
+ return okText(`Delivered to terminal ${target.ref} (${target.cmd}). It will respond in its own lane; check back with read_terminal.`)
966
+ },
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. 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
+ },
985
+ async (args) => {
986
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
987
+ const gate = spawnDecision({ hop: entry.hop || 0, spawnCount: entry.spawnCount || 0, liveCount: sessions.size + terms.size, disabled: process.env.TP_SPAWN_OFF === '1' })
988
+ if (!gate.ok) return okText(gate.reason)
989
+ entry.spawnCount = (entry.spawnCount || 0) + 1
990
+ const newId = randomUUID()
991
+ const newRef = String(newId).slice(0, 8)
992
+ const fromRef = String(id).slice(0, 8)
993
+ // Name BEFORE opening so openStructured's own announce already carries the label.
994
+ if (args?.name) { termNames[newId] = String(args.name).slice(0, 80); saveNames(room, termNames) }
995
+ openStructured({ id: newId, model: args?.model })
996
+ const ne = sessions.get(newId)
997
+ 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.') }
998
+ ne.spawnedBy = id // ownership: only the spawner may close_terminal it
999
+ announce() // (defensive) ensure the web renders the new tab live
1000
+ if (args?.task) {
1001
+ // One hop deep: the spawned lane cannot spawn/post onward until a person
1002
+ // speaks to it (code-turn resets hop to 0). Mirrors the Tier C injection.
1003
+ ne.hop = 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnCount = 0
1004
+ 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}`
1005
+ const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true }
1006
+ stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
1007
+ 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.`) }
1008
+ 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.`)
1009
+ }
1010
+ 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.`)
1011
+ },
1012
+ ),
1013
+ // Tier C+ — CLOSE a lane the caller spawned. Restricted to self-spawned agent
1014
+ // lanes (spawnedBy === this id): an agent must never be able to kill a sibling
1015
+ // someone else is working in, nor the host/attached terminal. The fan-out
1016
+ // cleanup half of spawn_terminal. Routes through endStructured (same path as
1017
+ // the web's code-close), which no-ops if the id isn't a live session.
1018
+ tool(
1019
+ 'close_terminal',
1020
+ '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.',
1021
+ {
1022
+ terminal: z.string().describe('ref, id, or name of the lane you spawned (from read_terminal)'),
1023
+ },
1024
+ async (args) => {
1025
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
1026
+ const sib = siblingsOf({ selfId: id, sessions, terms, names: termNames })
1027
+ const target = resolveSibling(sib, args?.terminal)
1028
+ if (!target) return okText(`No terminal matches "${args?.terminal}". Call read_terminal with no argument to list the open terminals.`)
1029
+ const te = sessions.get(target.id)
1030
+ if (!te) return okText(`Terminal ${target.ref} is not an agent lane (or already closed) — only spawned agent lanes can be closed this way.`)
1031
+ 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.`)
1032
+ endStructured(target.id)
1033
+ return okText(`Closed agent lane ${target.ref}${target.name ? ` ("${target.name}")` : ''}.`)
1034
+ },
1035
+ ),
928
1036
  ],
929
1037
  })
930
1038
  entry.session = startClaudeSession({
931
1039
  cwd: process.cwd(), model, resume, mode,
932
1040
  mcpServers: { thinkpool: peekServer },
1041
+ // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
1042
+ // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
1043
+ crossPostGate: () => crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' }),
933
1044
  env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
934
1045
  onEvent: (evt) => {
935
1046
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
@@ -1216,7 +1327,13 @@ channel
1216
1327
  const s = payload?.term && sessions.get(payload.term)
1217
1328
  if (!s || payload.text == null) return
1218
1329
  markActivity()
1219
- s.peekCount = 0 // B2: fresh cross-terminal read budget per user turn
1330
+ // B2/C: a real human turn is hop 0 with fresh cross-terminal budgets. This is
1331
+ // the loop-breaker reset point — an injected (cross-posted) turn does NOT come
1332
+ // through here, so its deeper hop level + spent budget stick until a person
1333
+ // speaks again.
1334
+ s.peekCount = 0
1335
+ s.postCount = 0
1336
+ s.hop = 0
1220
1337
  const text = String(payload.text)
1221
1338
  // Echo the turn so BOTH readers (and late joiners, via the log) show who
1222
1339
  // said what — UNLESS silent (an @pool fire, which renders its own line).
@@ -120,7 +120,7 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
120
120
  */
121
121
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
122
122
 
123
- export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers }) {
123
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate }) {
124
124
  const ac = new AbortController()
125
125
  let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
126
126
  let sessionId = resume || null
@@ -248,12 +248,36 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
248
248
  // Surface it to the room when it changes; absent on models without effort.
249
249
  const lvl = hookInput.effort?.level
250
250
  if (lvl && lvl !== effort) { effort = lvl; emit({ kind: 'effort', level: effort }) }
251
- // ThinkPool's own in-process tools (B2 cross-terminal read) are read-only and
252
- // within-room — never prompt for them, in any mode. They still surface as
253
- // tool_use/tool_result cards so the room sees the peek.
254
- if (typeof toolName === 'string' && toolName.startsWith('mcp__thinkpool__')) {
251
+ // ThinkPool cross-terminal READ (B2) is read-only + within-room — never
252
+ // prompt, in any mode. It still surfaces as a tool card so the room sees the peek.
253
+ if (toolName === 'mcp__thinkpool__read_terminal') {
255
254
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-terminal read).' } }
256
255
  }
256
+ // ThinkPool cross-terminal SPAWN / CLOSE (Tier C+) — lane management the agent
257
+ // OWNS. Unlike post_to_terminal (which WRITES into someone else's working lane,
258
+ // so it's human-gated every time), these only ever open a FRESH lane or close a
259
+ // lane the agent itself spawned — they never touch a sibling's work. Auto-allow
260
+ // (surfaced as a card for visibility); the bridge-side handler enforces the real
261
+ // bounds (hop-0-only, per-turn cap, the 8-terminal cap, TP_SPAWN_OFF) and can
262
+ // only close self-spawned lanes. This is the "you open them yourself" path.
263
+ if (toolName === 'mcp__thinkpool__spawn_terminal' || toolName === 'mcp__thinkpool__close_terminal') {
264
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (ThinkPool cross-terminal lane management — bounded by the room caps; closes only self-spawned lanes).' } }
265
+ }
266
+ // ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane —
267
+ // human-gated EVERY time, never remembered. First a bridge-side precheck
268
+ // (kill-switch / loop-breaker / per-turn cap) so a blocked or runaway post
269
+ // never even raises a card; then the standard permission round-trip at 'high'
270
+ // risk (card offers Deny / Allow-once only — no "always"). Spec INV-4.
271
+ if (toolName === 'mcp__thinkpool__post_to_terminal') {
272
+ const gate = crossPostGate ? crossPostGate() : { ok: true }
273
+ if (!gate.ok) {
274
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: gate.reason || 'Cross-terminal post not allowed right now.' } }
275
+ }
276
+ let decision = 'deny'
277
+ try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
278
+ const allowed = decision === 'allow'
279
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Cross-terminal post approved in the ThinkPool room.' : 'Cross-terminal post denied in the room — do not retry; ask what to do instead.' } }
280
+ }
257
281
  // ── Plan approval — ExitPlanMode is how the agent presents its plan in
258
282
  // plan mode. Render a dedicated plan card (not the generic perm card) with
259
283
  // three outcomes: run (exit → default), accept (exit → acceptEdits), keep
@@ -370,7 +394,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
370
394
  'SHOW YOUR WORK, do not just describe it: the room is watched live from a phone, where a wall of text is painful to read. Whenever you build, change, or fix anything visual — a UI, a page, an HTML artifact, a chart, a diagram, a rendered result — capture a screenshot and surface the PNG (for example, read the image file into your turn) so it appears inline in the room. The room auto-uploads any image you surface. Default to showing a picture of the result over narrating it; err toward more screenshots, not fewer.',
371
395
  'HTML PREVIEWS: for any HTML you produce, give the room something it can actually open, by whichever path is available. (a) IN-ROOM PREVIEW: if a mockup render helper is present — one that writes a manifest into the directory named by $TP_MOCKUP_OUTBOX, such as the mockup-iterate render.sh script when the bridge was launched from the thinkpool repo — use it; it surfaces an inline card with desktop and mobile views that the room can expand. (b) SHAREABLE LINK: additionally or otherwise, publish the HTML to a GitHub Pages URL per the LINKS & ARTIFACTS rule above and post the link. Never leave an HTML artifact viewable only as a local path.',
372
396
  '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.',
373
- '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.',
397
+ '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.',
398
+ '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.',
399
+ '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. 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.',
374
400
  '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.',
375
401
  ].join(' '),
376
402
  // Needed for live thinking-token progress (SDKThinkingTokensMessage) to
@@ -59,36 +59,77 @@ export const cleanPtyTail = (buf, n) => {
59
59
  // Build the tool's text reply. `selfId` is excluded. `sessions` (structured) and
60
60
  // `terms` (PTY) are the bridge's module-scope Maps (id → entry). With no
61
61
  // `terminal`, returns the roster; with one, returns that lane's recent output.
62
- export const formatPeek = ({ selfId, sessions, terms, terminal, lines } = {}) => {
63
- const n = Math.min(PEEK.maxLines, Math.max(1, Number(lines) || PEEK.defaultLines))
62
+ // ── shared sibling resolution (used by formatPeek + the cross-post handler) ──
63
+ export const siblingRef = (id) => String(id).slice(0, 8)
64
+
65
+ // Every terminal in the room except `selfId`, normalized. Structured sessions →
66
+ // kind 'agent' (carry `log`); PTY terminals → kind 'shell' (carry `buf`). `names`
67
+ // is the bridge's id→label map (termNames) — the HUMAN names people actually use
68
+ // ("Pivot", "Grug"); without it the roster is just hashes and an agent can't map
69
+ // "what is Pivot doing" to a lane.
70
+ export const siblingsOf = ({ selfId, sessions, terms, names = {} } = {}) => {
71
+ const nm = (id) => (names && names[id]) || ''
64
72
  const sib = []
65
73
  for (const [id, s] of (sessions || new Map())) {
66
74
  if (id === selfId) continue
67
- sib.push({ id, kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [] })
75
+ sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [] })
68
76
  }
69
77
  for (const [id, t] of (terms || new Map())) {
70
78
  if (id === selfId) continue
71
- sib.push({ id, kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '' })
79
+ sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '' })
72
80
  }
73
- if (!sib.length) return 'No other terminals are open in this room right now.'
81
+ return sib
82
+ }
83
+
84
+ // True for a POSITIONAL on-screen label ("Terminal 2", "t3", bare "2"). These
85
+ // renumber when a sibling closes (Terminal 3 → Terminal 2 after a delete), so the
86
+ // bridge — which only ever knows stable UUIDs — must NEVER resolve them, or a
87
+ // delete could silently re-point a cross-post at the wrong lane. We refuse them
88
+ // explicitly (safe miss) instead. The stable handles are the UUID/ref and the
89
+ // custom name (both bound to the UUID, immune to renumbering).
90
+ export const isOrdinalRef = (q) => /^(terminal\s*)?\d+$/i.test(q) || /^t\d+$/i.test(q)
74
91
 
75
- const ref = (x) => String(x.id).slice(0, 8)
92
+ // Resolve an agent-supplied reference to one sibling, or null. Match priority:
93
+ // exact id → exact name → exact short-ref → id-prefix (≥4 chars) → name substring
94
+ // → command substring. Name matching is what makes "Pivot" / "Grug" resolvable;
95
+ // positional ordinals are refused (see isOrdinalRef).
96
+ export const resolveSibling = (siblings, terminal) => {
97
+ const q = String(terminal || '').toLowerCase().trim()
98
+ if (!q || isOrdinalRef(q)) return null
99
+ const list = Array.isArray(siblings) ? siblings : []
100
+ return list.find(x => String(x.id).toLowerCase() === q)
101
+ || list.find(x => String(x.name || '').toLowerCase() === q)
102
+ || list.find(x => String(x.ref).toLowerCase() === q)
103
+ || list.find(x => q.length >= 4 && String(x.id).toLowerCase().startsWith(q))
104
+ || list.find(x => x.name && String(x.name).toLowerCase().includes(q))
105
+ || list.find(x => String(x.cmd).toLowerCase().includes(q))
106
+ || null
107
+ }
108
+
109
+ const label = (x) => `${x.ref}${x.name ? ` · "${x.name}"` : ''}`
110
+
111
+ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, lines } = {}) => {
112
+ const n = Math.min(PEEK.maxLines, Math.max(1, Number(lines) || PEEK.defaultLines))
113
+ const sib = siblingsOf({ selfId, sessions, terms, names })
114
+ if (!sib.length) return 'No other terminals are open in this room right now.'
76
115
 
77
116
  if (!terminal) {
78
117
  const rows = sib.map(x => {
79
118
  let preview
80
119
  if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, PEEK.rosterPreview)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
81
120
  else { preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)' }
82
- return `- ${ref(x)} · ${x.kind} · ${x.cmd} — ${clip(preview, PEEK.rosterPreview)}`
121
+ return `- ${label(x)} · ${x.kind} · ${x.cmd} — ${clip(preview, PEEK.rosterPreview)}`
83
122
  })
84
- return `Other terminals in this room (call read_terminal with one of these refs to read it):\n${rows.join('\n')}`
123
+ return `Other terminals in this room (call read_terminal with a ref or name to read one):\n${rows.join('\n')}`
85
124
  }
86
125
 
87
- const q = String(terminal).toLowerCase().trim()
88
- const hit = sib.find(x => String(x.id).toLowerCase() === q)
89
- || sib.find(x => ref(x).toLowerCase() === q || String(x.id).toLowerCase().startsWith(q))
90
- || sib.find(x => String(x.cmd).toLowerCase().includes(q))
91
- if (!hit) return `No terminal matches "${terminal}". Call read_terminal with no argument to list the open terminals.`
126
+ const hit = resolveSibling(sib, terminal)
127
+ if (!hit) {
128
+ const extra = isOrdinalRef(String(terminal || '').toLowerCase().trim())
129
+ ? ` On-screen numbers like "Terminal N" shift when a terminal closes, so they can't identify a lane — use a terminal's name or ref instead.`
130
+ : ''
131
+ return `No terminal matches "${terminal}". Call read_terminal with no argument to list the open terminals (refer to one by its name or ref).${extra}`
132
+ }
92
133
 
93
134
  let body
94
135
  if (hit.kind === 'agent') {
@@ -96,5 +137,52 @@ export const formatPeek = ({ selfId, sessions, terms, terminal, lines } = {}) =>
96
137
  } else {
97
138
  body = cleanPtyTail(hit.buf, n).join('\n') || '(no output yet)'
98
139
  }
99
- return `Terminal ${ref(hit)} (${hit.kind} · ${hit.cmd}) — last ${n} lines:\n${body}`
140
+ return `Terminal ${label(hit)} (${hit.kind} · ${hit.cmd}) — last ${n} lines:\n${body}`
141
+ }
142
+
143
+ // ── Tier C: cross-terminal POST (directed, gated cross-lane action) ──────────
144
+ // Pure gate for whether a session may post into a sibling lane on THIS call:
145
+ // kill-switch, loop-breaker (hop), per-turn cap. The routing itself (sendTurn
146
+ // into the target) lives in bridge.mjs — it needs the live session. Spec INV-4:
147
+ // no text reaches a sibling without an approved code-perm; agent-originated turns
148
+ // are hop-capped so two agents can't ping-pong forever.
149
+ export const CROSSPOST = {
150
+ maxHop: 1, // human turn = hop 0; an injected turn = hop 1, which CANNOT post onward
151
+ perTurnCap: 3, // post_to_terminal calls allowed per turn
152
+ }
153
+
154
+ export const crossPostDecision = ({ hop = 0, postCount = 0, disabled = false } = {}, limits = CROSSPOST) => {
155
+ const o = { ...CROSSPOST, ...(limits || {}) }
156
+ if (disabled) return { ok: false, reason: 'Cross-terminal posting is disabled in this room.' }
157
+ if (hop >= o.maxHop) return { ok: false, reason: 'Cross-terminal hop limit reached — an agent reached via cross-post cannot itself post onward; a person must direct the next cross-terminal action.' }
158
+ if (postCount >= o.perTurnCap) return { ok: false, reason: `Cross-terminal post limit reached for this turn (${o.perTurnCap}).` }
159
+ return { ok: true }
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 }
100
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.83",
3
+ "version": "0.7.85",
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": {