thinkpool-pair 0.7.364 → 0.7.366

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.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
@@ -1,789 +1 @@
1
- /* ThinkPool Code — B2 cross-terminal peek (read-only sibling access).
2
- Pure formatting for the in-process `read_terminal` MCP tool that bridge.mjs
3
- wires into every structured Claude session. Lets an agent PULL a sibling
4
- terminal's recent output on demand (vs B1's always-on @pool digest) so it can
5
- reason across lanes when its own work depends on another terminal.
6
-
7
- Bridge-local on purpose — it cannot import the frontend's code-constants, so
8
- the structured-event compaction here mirrors that file's digestLine shape.
9
- Spec: docs/specs/2026-06-28-cross-terminal-awareness.md */
10
-
11
- import { createHash } from 'node:crypto'
12
- import { BLOCKED_REASON, projectLaneLifecycle } from './lane-lifecycle.mjs'
13
-
14
- // Bounds — the pull tool is self-limiting (the agent only calls it when it needs
15
- // to), but a runaway loop must still be capped (the cost-guard / C10 class).
16
- export const PEEK = {
17
- // Keep the implicit pull concise for consumer turns. Agents can request more
18
- // explicitly when a diagnosis genuinely needs deeper sibling history.
19
- defaultLines: 20,
20
- maxLines: 200,
21
- perTurnCap: 10, // targeted transcript reads allowed per accepted room turn
22
- rosterPerTurnCap: 1, // no-argument roster reads use a separate allowance; ROOM NOW is default
23
- lineCap: 200, // per-line truncation
24
- rosterPreview: 100, // last-line preview length in the roster listing
25
- }
26
-
27
- // ROOM NOW already gives every agent a compact live roster. Keep the one optional
28
- // full-roster lookup separate from transcript reads so a redundant discovery call
29
- // cannot consume the budget needed to collect workers and run the final review.
30
- // Unknown terminal refs are rejected before this helper is called and spend neither
31
- // allowance. The bridge stores the returned counters on the current lane.
32
- export const readTerminalBudgetDecision = ({ targeted = false, targetedCount = 0, rosterCount = 0 } = {}, limits = PEEK) => {
33
- const nextTargeted = Math.max(0, Number(targetedCount) || 0)
34
- const nextRoster = Math.max(0, Number(rosterCount) || 0)
35
- if (targeted) {
36
- if (nextTargeted >= limits.perTurnCap) return {
37
- ok: false,
38
- targetedCount: nextTargeted,
39
- rosterCount: nextRoster,
40
- reason: `Cross-terminal transcript read limit reached for this turn (${limits.perTurnCap}). Stop polling and continue with the worker results already collected; a fresh accepted room turn resets the allowance.`,
41
- }
42
- return { ok: true, targetedCount: nextTargeted + 1, rosterCount: nextRoster }
43
- }
44
- if (nextRoster >= limits.rosterPerTurnCap) return {
45
- ok: false,
46
- targetedCount: nextTargeted,
47
- rosterCount: nextRoster,
48
- reason: `The full terminal roster was already read this turn (${limits.rosterPerTurnCap}/${limits.rosterPerTurnCap}). Use the current ROOM NOW snapshot or read one relevant terminal by ref or name.`,
49
- }
50
- return { ok: true, targetedCount: nextTargeted, rosterCount: nextRoster + 1 }
51
- }
52
-
53
- // Provider/model reconstruction can deliver a fresh accepted agent turn without
54
- // another browser `code-turn` frame (context-carry and interrupted-resume paths).
55
- // Key read allowances to the bridge-owned turn revision as a second reset seam,
56
- // so a reconstructed runtime never inherits ten reads spent by the old model.
57
- // This intentionally resets only read allowances; hop/post/spawn loop breakers
58
- // remain tied to real person ingress and cannot be escaped by reconstruction.
59
- export const readTerminalTurnBudgetDecision = ({
60
- targeted = false,
61
- targetedCount = 0,
62
- rosterCount = 0,
63
- budgetTurnRev = null,
64
- currentTurnRev = 0,
65
- } = {}, limits = PEEK) => {
66
- const current = Math.max(0, Number(currentTurnRev) || 0)
67
- const previous = budgetTurnRev == null
68
- ? null
69
- : Number.isFinite(Number(budgetTurnRev)) ? Number(budgetTurnRev) : null
70
- const sameTurn = previous === current
71
- return {
72
- ...readTerminalBudgetDecision({
73
- targeted,
74
- targetedCount: sameTurn ? targetedCount : 0,
75
- rosterCount: sameTurn ? rosterCount : 0,
76
- }, limits),
77
- budgetTurnRev: current,
78
- }
79
- }
80
-
81
- // One shared lane-status classifier for every roster consumer: the bridge wire,
82
- // read_terminal, and ROOM NOW. Timestamps stay absolute on the wire so clients can
83
- // keep the displayed age current without a broadcast every second.
84
- export const LANE_STATUS = {
85
- stuckAfterMs: 90_000, // matches the structured-turn stall watchdog
86
- repeatedFailureCount: 2,
87
- failureWindow: 12,
88
- }
89
-
90
- const failureEvent = (e) => !!e && (
91
- e.kind === 'error'
92
- || (e.kind === 'tool_result' && e.isError)
93
- || (e.kind === 'result' && e.subtype && e.subtype !== 'success')
94
- )
95
-
96
- const recentFailureCount = (log, limits = LANE_STATUS) => {
97
- let failures = 0, seen = 0
98
- for (let i = (Array.isArray(log) ? log.length : 0) - 1; i >= 0 && seen < limits.failureWindow; i--) {
99
- const e = log[i]
100
- if (!e || typeof e !== 'object') continue
101
- seen++
102
- // A clean completion or a fresh human task closes the prior failure streak.
103
- if ((e.kind === 'result' && (!e.subtype || e.subtype === 'success')) || e.kind === 'you') break
104
- if (failureEvent(e)) failures++
105
- }
106
- return failures
107
- }
108
-
109
- // Structured runtimes expose a mutable `turnActive` getter, but the bridge owns
110
- // the roster edges. A terminal result can reach the bridge before a runtime has
111
- // finished clearing that getter; reading it back here used to re-open a finished
112
- // lane until the next WHO/refocus announcement. Once the bridge has observed an
113
- // edge, its latch is authoritative until beginStructuredTurn/syncStructuredTurn
114
- // records the next one. `busy` remains the explicit override for wire fixtures
115
- // and raw terminals.
116
- export const laneBusyOf = (entry = {}) => typeof entry?.busy === 'boolean'
117
- ? entry.busy
118
- : (typeof entry?._busyAnn === 'boolean' ? entry._busyAnn : (entry?.session?.turnActive ?? false))
119
-
120
- export const settleLaneBusy = (entry) => {
121
- if (!entry || entry._busyAnn === false) return false
122
- entry._busyAnn = false
123
- return true
124
- }
125
-
126
- // Structured controls such as native Codex compaction can borrow the runtime's
127
- // turnActive flag without emitting an ordinary result event. Close that borrowed
128
- // roster edge with the same durable revision proof as a normal terminal boundary.
129
- export const settleLaneControl = (entry, now = Date.now()) => {
130
- if (!entry || entry._busyAnn !== true) return false
131
- if (!settleLaneBusy(entry)) return false
132
- entry._settledTurnRev = Number(entry._turnRev) || null
133
- entry._turnSettledAt = Number(now) || Date.now()
134
- entry._turnStart = null
135
- return true
136
- }
137
-
138
- export const laneStatusOf = (entry = {}, now = Date.now(), limits = LANE_STATUS) => {
139
- const baseLog = Array.isArray(entry?.log) ? entry.log : []
140
- const log = baseLog.slice(-limits.failureWindow)
141
- if (entry?.lastEvent && entry.lastEvent !== baseLog[baseLog.length - 1]
142
- && (!entry.lastEvent.cid || entry.lastEvent.cid !== baseLog[baseLog.length - 1]?.cid)) log.push(entry.lastEvent)
143
- let lastLogAt = null
144
- for (let i = log.length - 1; i >= 0; i--) {
145
- if (Number.isFinite(log[i]?.ts)) { lastLogAt = log[i].ts; break }
146
- }
147
- const lastActionAt = Number.isFinite(entry?.lastActionAt) ? entry.lastActionAt : (lastLogAt ?? null)
148
- const lastActionAgeMs = lastActionAt == null ? null : Math.max(0, now - lastActionAt)
149
- const busy = laneBusyOf(entry)
150
- const pendingCount = entry?.pending instanceof Map
151
- ? entry.pending.size
152
- : Math.max(0, Number(entry?.pendingCount) || 0)
153
- const pendingReason = entry?.pending instanceof Map && [...entry.pending.values()]
154
- .some((pending) => pending?.payload?.risk === 'ask')
155
- ? BLOCKED_REASON.NEEDS_INPUT
156
- : BLOCKED_REASON.NEEDS_DECISION
157
- const failures = recentFailureCount(log, limits)
158
-
159
- let statusFlag = null, statusReason = null
160
- if (pendingCount > 0) {
161
- statusFlag = 'BLOCKED'; statusReason = 'waiting for input'
162
- } else if (entry?.stalled || (busy && !entry?.compacting && lastActionAgeMs != null && lastActionAgeMs >= limits.stuckAfterMs)) {
163
- statusFlag = 'STUCK'; statusReason = 'no events while busy'
164
- } else if (failures >= limits.repeatedFailureCount) {
165
- statusFlag = 'BLOCKED'; statusReason = 'repeated errors'
166
- }
167
-
168
- const lifecycle = projectLaneLifecycle({
169
- events: log,
170
- busy: !!busy,
171
- pendingCount,
172
- pendingReason,
173
- stalled: statusFlag === 'STUCK',
174
- failed: failures >= limits.repeatedFailureCount && !busy,
175
- rawStatus: statusFlag || (busy ? 'busy' : 'idle'),
176
- })
177
- return { busy: !!busy, lastActionAt, lastActionAgeMs, statusFlag, statusReason, lifecycle }
178
- }
179
-
180
- export const formatLaneAge = (ageMs) => {
181
- if (!Number.isFinite(ageMs)) return 'no activity'
182
- const s = Math.max(0, Math.floor(ageMs / 1000))
183
- if (s < 60) return `${s}s ago`
184
- const m = Math.floor(s / 60)
185
- if (m < 60) return `${m}m ago`
186
- const h = Math.floor(m / 60)
187
- if (h < 24) return `${h}h ago`
188
- return `${Math.floor(h / 24)}d ago`
189
- }
190
-
191
- const laneStatusText = (x) => `${x.lifecycle?.state || 'Blocked'}${x.lifecycle?.reason ? ` (${x.lifecycle.reason})` : ''} · ${formatLaneAge(x.lastActionAgeMs)}`
192
-
193
- const clip = (s, cap) => {
194
- const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
195
- return t.length > cap ? t.slice(0, cap - 1) + '…' : t
196
- }
197
-
198
- // Compact ONE structured log event into a single line, or '' to skip it.
199
- // Mirrors src/pages/code/code-constants.js digestLine — keep them in step.
200
- export const digestLogEvent = (e, cap = PEEK.lineCap) => {
201
- if (!e || typeof e !== 'object') return ''
202
- switch (e.kind) {
203
- case 'you': return clip(`${e.by || 'someone'}: ${e.text || ''}`, cap)
204
- case 'assistant': {
205
- const parts = []
206
- for (const b of (Array.isArray(e.blocks) ? e.blocks : [])) {
207
- if (b?.type === 'text' && String(b.text || '').trim()) parts.push(String(b.text).trim())
208
- else if (b?.type === 'tool_use' && b.name) parts.push(`→ ${b.name}`)
209
- }
210
- return parts.length ? clip(`assistant: ${parts.join(' ')}`, cap) : ''
211
- }
212
- case 'tool_result': return e.isError ? clip('⎿ error', cap) : ''
213
- case 'result': return (e.subtype && e.subtype !== 'success') ? clip(`turn ended — ${e.subtype}`, cap) : ''
214
- case 'error': return clip(`error: ${e.message || ''}`, cap)
215
- default: return '' // chat/whisper/pool/note/control/compaction/system → not lane work
216
- }
217
- }
218
-
219
- // Strip ANSI / control bytes from a PTY scrollback buffer, return last N real lines.
220
- export const cleanPtyTail = (buf, n) => {
221
- /* eslint-disable no-control-regex -- stripping terminal escape/control bytes is the whole point */
222
- const txt = String(buf || '')
223
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences
224
- .replace(/\x1b[@-_][0-?]*[ -/]*[@-~]/g, '') // CSI / escape sequences
225
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '') // remaining control chars
226
- /* eslint-enable no-control-regex */
227
- const lines = txt.split(/\r?\n/).map(l => l.replace(/\s+$/, '')).filter(l => l.trim())
228
- return lines.slice(-Math.max(1, n))
229
- }
230
-
231
- // Build the tool's text reply. `selfId` is excluded. `sessions` (structured) and
232
- // `terms` (PTY) are the bridge's module-scope Maps (id → entry). With no
233
- // `terminal`, returns the roster; with one, returns that lane's recent output.
234
- // ── shared sibling resolution (used by formatPeek + the cross-post handler) ──
235
- export const siblingRef = (id) => String(id).slice(0, 8)
236
-
237
- // Every terminal in the room except `selfId`, normalized. Structured sessions →
238
- // kind 'agent' (carry `log`); PTY terminals → kind 'shell' (carry `buf`). `names`
239
- // is the bridge's id→label map (termNames) — the HUMAN names people actually use
240
- // ("Pivot", "Grug"); without it the roster is just hashes and an agent can't map
241
- // "what is Pivot doing" to a lane.
242
- export const siblingsOf = ({ selfId, sessions, terms, names = {}, now = Date.now() } = {}) => {
243
- const nm = (id) => (names && names[id]) || ''
244
- const sib = []
245
- for (const [id, s] of (sessions || new Map())) {
246
- if (id === selfId) continue
247
- sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [], ...laneStatusOf(s, now) })
248
- }
249
- for (const [id, t] of (terms || new Map())) {
250
- if (id === selfId) continue
251
- sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '', ...laneStatusOf(t, now) })
252
- }
253
- return sib
254
- }
255
-
256
- // True for a POSITIONAL on-screen label ("Terminal 2", "t3", bare "2"). These
257
- // renumber when a sibling closes (Terminal 3 → Terminal 2 after a delete), so the
258
- // bridge — which only ever knows stable UUIDs — must NEVER resolve them, or a
259
- // delete could silently re-point a cross-post at the wrong lane. We refuse them
260
- // explicitly (safe miss) instead. The stable handles are the UUID/ref and the
261
- // custom name (both bound to the UUID, immune to renumbering).
262
- export const isOrdinalRef = (q) => /^(terminal\s*)?\d+$/i.test(q) || /^t\d+$/i.test(q)
263
-
264
- // Resolve an agent-supplied reference to one sibling, or null. Match priority:
265
- // exact id → exact name → exact short-ref → id-prefix (≥4 chars) → name substring
266
- // → command substring. Name matching is what makes "Pivot" / "Grug" resolvable.
267
- //
268
- // The ordinal guard runs AFTER the exact matches, not before (2026-07-10). A ref is
269
- // 8 hex characters, and ~2.3% of them contain no letters at all — "33729214" is a
270
- // perfectly good ref that `isOrdinalRef` reads as the ordinal "33,729,214". Refusing
271
- // it early made those lanes unresolvable by the very handle read_terminal prints:
272
- // unreadable, un-postable, and — the reason this surfaced — UNCLOSABLE, holding a
273
- // slot against the machine cap until the bridge restarted. Two of ~30 lanes in one
274
- // day, which is exactly the rate the arithmetic predicts.
275
- //
276
- // Checking identity first is safe: an ordinal like "3" or "terminal 2" can never
277
- // exactly equal an 8-char ref, a UUID, or a lane's name, so the guard still refuses
278
- // every reference it was written to refuse. It only stops shadowing real handles.
279
- export const resolveSibling = (siblings, terminal) => {
280
- const q = String(terminal || '').toLowerCase().trim()
281
- if (!q) return null
282
- const list = Array.isArray(siblings) ? siblings : []
283
- const exact = list.find(x => String(x.id).toLowerCase() === q)
284
- || list.find(x => String(x.name || '').toLowerCase() === q)
285
- || list.find(x => String(x.ref).toLowerCase() === q)
286
- if (exact) return exact
287
- if (isOrdinalRef(q)) return null
288
- return list.find(x => q.length >= 4 && String(x.id).toLowerCase().startsWith(q))
289
- || list.find(x => x.name && String(x.name).toLowerCase().includes(q))
290
- || list.find(x => String(x.cmd).toLowerCase().includes(q))
291
- || null
292
- }
293
-
294
- const label = (x) => `${x.ref}${x.name ? ` · "${x.name}"` : ''}`
295
-
296
- export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, lines, now = Date.now() } = {}) => {
297
- const n = Math.min(PEEK.maxLines, Math.max(1, Number(lines) || PEEK.defaultLines))
298
- const sib = siblingsOf({ selfId, sessions, terms, names, now })
299
- if (!sib.length) return 'No other terminals are open in this room right now.'
300
-
301
- if (!terminal) {
302
- const rows = sib.map(x => {
303
- let preview
304
- if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, PEEK.rosterPreview)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
305
- else { preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)' }
306
- return `- ${label(x)} · ${x.kind} · ${x.cmd} · ${laneStatusText(x)} — ${clip(preview, PEEK.rosterPreview)}`
307
- })
308
- return `Other terminals in this room (call read_terminal with a ref or name to read one):\n${rows.join('\n')}`
309
- }
310
-
311
- const hit = resolveSibling(sib, terminal)
312
- if (!hit) {
313
- const extra = isOrdinalRef(String(terminal || '').toLowerCase().trim())
314
- ? ` 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.`
315
- : ''
316
- 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}`
317
- }
318
-
319
- let body
320
- if (hit.kind === 'agent') {
321
- body = hit.log.map(e => digestLogEvent(e, PEEK.lineCap)).filter(Boolean).slice(-n).join('\n') || '(no activity yet)'
322
- } else {
323
- body = cleanPtyTail(hit.buf, n).join('\n') || '(no output yet)'
324
- }
325
- return `Terminal ${label(hit)} (${hit.kind} · ${hit.cmd}) · ${laneStatusText(hit)} — last ${n} lines:\n${body}`
326
- }
327
-
328
- // A native Claude Code account failure is provider-wide, not model-specific. Retrying
329
- // Sonnet → Opus → Haiku on the same built-in Anthropic path only repeats the same
330
- // failure and burns orchestration time. Keep this classifier deliberately narrow:
331
- // transient model/rate/overload errors must not disable the whole provider.
332
- export const nativeClaudeProviderAccessFailed = (events) => {
333
- let text = ''
334
- try { text = typeof events === 'string' ? events : JSON.stringify(events || '') }
335
- catch { text = String(events || '') }
336
- return /organization has disabled (?:claude(?: subscription access)?|subscription access)|(?:claude )?subscription access (?:has been |is )?disabled|invalid anthropic (?:api )?key|invalid x-api-key|not logged in to claude|(?:run|use) \/login[^\n]{0,80}claude|no valid anthropic (?:api )?key/i.test(text)
337
- }
338
-
339
- const HERMES_CLAUDE_REVIEW_PREFERENCE = Object.freeze([
340
- 'nous:anthropic/claude-sonnet-4.6',
341
- 'nous:anthropic/claude-sonnet-4.5',
342
- 'nous:anthropic/claude-sonnet-4',
343
- ])
344
-
345
- export const preferredHermesClaudeReviewModel = (models = []) => {
346
- const values = new Set((Array.isArray(models) ? models : []).map((model) => (
347
- typeof model === 'string' ? model : model?.value || model?.slug
348
- )).filter(Boolean))
349
- for (const model of HERMES_CLAUDE_REVIEW_PREFERENCE) if (values.has(model)) return model
350
- for (const model of values) if (/^nous:anthropic\/claude-sonnet-/i.test(model)) return model
351
- for (const model of values) if (/^nous:anthropic\/claude-haiku-/i.test(model)) return model
352
- for (const model of values) if (/^nous:anthropic\/claude-opus-/i.test(model)) return model
353
- return null
354
- }
355
-
356
- // Returned alongside read_terminal output so the orchestrator gets a structural
357
- // provider verdict at the exact moment it observes the failed worker. A Hermes
358
- // fallback is a visible ordinary worker, never hidden delegate_task and never Flow.
359
- // Hermes ACP 0.18.2 cannot enforce read-only review, so the note states the honest
360
- // permission-gated boundary instead of pretending mode="default" is structural plan.
361
- export const nativeClaudeFallbackHint = ({ parentRuntime, parentModels, targetRuntime, targetLog } = {}) => {
362
- if (targetRuntime !== 'claude' || !nativeClaudeProviderAccessFailed(targetLog)) return ''
363
- const stop = 'ThinkPool provider status: native Claude access failed at the account/provider level. Do not retry Sonnet, Opus, Haiku, or another model alias on the same native Claude provider during this task.'
364
- const fallback = parentRuntime === 'hermes' ? preferredHermesClaudeReviewModel(parentModels) : null
365
- if (!fallback) return `\n\n${stop} Use a different available runtime/provider once, or report the blocker.`
366
- return `\n\n${stop} For a visible manual review fallback, spawn exactly one worker with runtime="hermes", model=${JSON.stringify(fallback)}, mode="default", and sliceType="review". This Hermes lane is permission-gated, not structurally read-only: instruct it not to edit, deny/stop any write request, and do not treat its review as a Flow safety proof. Never use Hermes delegate_task.`
367
- }
368
-
369
- // ── Live per-turn room snapshot ("ROOM NOW") ─────────────────────────────────
370
- // The compact tail claude-session appends to EVERY user turn's <system-reminder>
371
- // (via the bridge's roomContext callback), so a lane sees the room's CURRENT
372
- // state — sibling lanes + the host's active git worktrees — adjacent to where its
373
- // attention actually is, instead of only knowing the peek tools exist. Same data
374
- // as the formatPeek roster, tighter: capped lanes, shorter previews. Returns null
375
- // when there is nothing to show (the reminder then carries no tail at all).
376
- // Spec ask (2026-07-02): "terminals need to be completely aware of the session."
377
- export const ROOMNOW = { laneCap: 8, previewLen: 70, wtCap: 10 }
378
- export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees = [], now = Date.now() } = {}, limits = ROOMNOW) => {
379
- const sib = siblingsOf({ selfId, sessions, terms, names, now })
380
- const out = []
381
- if (sib.length) {
382
- const shown = sib.slice(0, limits.laneCap)
383
- const rows = shown.map(x => {
384
- let preview
385
- if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, limits.previewLen)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
386
- else preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)'
387
- return `- ${label(x)} · ${x.kind} · ${laneStatusText(x)} — ${clip(preview, limits.previewLen)}`
388
- })
389
- if (sib.length > shown.length) rows.push(`- … +${sib.length - shown.length} more (read_terminal lists all)`)
390
- out.push(`Other lanes in this room right now (this is the roster; use targeted read_terminal only for needed detail):\n${rows.join('\n')}`)
391
- }
392
- const wtAll = Array.isArray(worktrees) ? worktrees : []
393
- const wt = wtAll.slice(0, limits.wtCap)
394
- if (wt.length) {
395
- const rows = wt.map(w => `- ${clip(w, limits.previewLen + 30)}`)
396
- if (wtAll.length > wt.length) rows.push(`- … +${wtAll.length - wt.length} more (\`git worktree list\` shows all)`)
397
- out.push(`Active git worktrees on this host (code edits go in your OWN worktree — never one another lane is on):\n${rows.join('\n')}`)
398
- }
399
- if (!out.length) return null
400
- return `ROOM NOW (live snapshot, this turn):\n${out.join('\n')}`
401
- }
402
-
403
- // ── Tier C: cross-terminal POST (directed, gated cross-lane action) ──────────
404
- // Pure gate for whether a session may post into a sibling lane on THIS call:
405
- // kill-switch, loop-breaker (hop), per-turn cap. The routing itself (sendTurn
406
- // into the target) lives in bridge.mjs — it needs the live session. Spec INV-4:
407
- // no text reaches a sibling without an approved code-perm; agent-originated turns
408
- // are hop-capped so two agents can't ping-pong forever.
409
- export const CROSSPOST = {
410
- maxHop: 1, // human turn = hop 0; an injected turn = hop 1, which CANNOT post onward
411
- perTurnCap: 3, // post_to_terminal calls allowed per turn
412
- }
413
-
414
- export const crossPostDecision = ({ hop = 0, postCount = 0, disabled = false } = {}, limits = CROSSPOST) => {
415
- const o = { ...CROSSPOST, ...(limits || {}) }
416
- if (disabled) return { ok: false, reason: 'Cross-terminal posting is disabled in this room.' }
417
- 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.' }
418
- if (postCount >= o.perTurnCap) return { ok: false, reason: `Cross-terminal post limit reached for this turn (${o.perTurnCap}).` }
419
- return { ok: true }
420
- }
421
-
422
- // ── Tier C consent SCOPE — does this post_to_terminal need a human card? ──────
423
- // Runs AFTER crossPostDecision's kill-switch/hop/cap precheck passes, and decides
424
- // only whether a person must TAP a card before the (already-permitted) post is
425
- // delivered. Two — and only two — cases auto-allow (no card):
426
- // (1) bypassPermissions mode — bypass means bypass, cross-lane posts included.
427
- // Without this, a conductor's post into its OWN lane queued behind a human
428
- // tap even in a bypass session (the 3-stacked-Dispatch-cards bug, 2026-07-03).
429
- // (2) the SENDER SPAWNED the target lane (parent→child orchestration) — a
430
- // conductor posting into a lane it Dispatched with spawn_terminal is not a
431
- // stranger writing into someone else's work, so no per-post tap is needed.
432
- // The CARD REMAINS for every other post: a non-owner posting into a lane it did
433
- // not spawn, in any non-bypass mode. That is the load-bearing invariant — a lane
434
- // can never be posted into by a non-owner without a human tap, except in bypass.
435
- // Cross-ROOM post_to_session is a SEPARATE path (dual-consent) and is untouched.
436
- // Spec: docs/specs/2026-07-03-xpost-consent-scope.md
437
- export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {}) =>
438
- !(mode === 'bypassPermissions' || spawnedByMe === true)
439
-
440
- // ── Tier C+: cross-terminal SPAWN (open a FRESH lane the agent owns) ──────────
441
- // Pure gate for whether a session may open a new terminal on THIS call. The
442
- // motivating bug: an agent dumped a fan-out into SIBLINGS that were already
443
- // working (a bridge release lane, a Q&A lane) because it had no way to make its
444
- // OWN lanes. spawn_terminal gives it fresh lanes; this gate keeps that from
445
- // becoming a fork bomb. Bounds, in order:
446
- // • kill-switch (TP_SPAWN_OFF)
447
- // • hop — a lane REACHED via cross-post/spawn (hop ≥ 1) cannot spawn onward.
448
- // Cascade conductors now use the independent main-terminal lifecycle; every
449
- // Ensemble-spawned lane is a worker (the loop breaker mirrors CROSSPOST).
450
- // • machineMax — overall hard cap on LIVE lanes (sessions+terms): the runaway /
451
- // don't-melt-the-laptop backstop, plan-INDEPENDENT.
452
- // • dispatch ceiling — a SEPARATE, plan-tiered budget counting ONLY spawned lanes
453
- // (Free 3 / Plus 6). Counts spawned lanes ONLY, so they never block the human's
454
- // own terminals, and the human's terminals never eat the dispatch budget. Under
455
- // BYOK-only the tokens are the user's, so this is feature-shaping + machine
456
- // safety, NOT a ThinkPool-COGS control.
457
- // • burst window — a SLIDING-window runaway breaker: ≤ perWindowCap spawns per
458
- // rolling windowMs. Replaces the old per-HUMAN-turn cap, which starved an
459
- // UNATTENDED conductor: agent turns woken by Monitor/task notifications never
460
- // traverse the 'code-turn' handler that reset the per-turn counter, so a cascade
461
- // that spent its 4 spawns could NEVER spawn again until a person typed. The
462
- // window replenishes on wall-clock alone — no turn boundary required — while a
463
- // real human turn still clears it instantly (the reset point is unchanged).
464
- // The caller measures: spawnedLive (live sessions with spawnedBy set), totalLive
465
- // (sessions.size + terms.size), resolves `plan` from the room owner, and passes the
466
- // lane's recent spawn timestamps (`spawnTimes`) plus the current clock (`now`) — so
467
- // this stays a PURE function (no Date.now inside; the clock is injected, like
468
- // spawnCount was before).
469
- export const SPAWN = {
470
- maxHop: 1, // main terminal/conductor = hop 0 and may dispatch workers;
471
- // Ensemble worker = hop 1 and can never spawn onward. Conductors
472
- // are created via open_main_terminal, not spawn_terminal.
473
- perWindowCap: 6, // spawn_terminal calls allowed per rolling window (burst breaker);
474
- // 6 matches plusMaxSpawned so one wave can open a full crew (was 4)
475
- windowMs: 10 * 60 * 1000, // sliding window over which perWindowCap applies (10 min)
476
- freeMaxSpawned: 3, // Free plan dispatch ceiling (concurrent spawned lanes)
477
- plusMaxSpawned: 6, // Plus plan dispatch ceiling
478
- machineMax: 12, // hard cap on TOTAL live lanes (the laptop/runaway backstop). 10 → 12
479
- // 2026-07-10: finished-but-open lanes were starving slots. NOT higher:
480
- // at ~10 live lanes CPU contention corrupted Lighthouse runs that day —
481
- // this cap protects the host and measurement quality, not just runaway.
482
- }
483
-
484
- // Plan → dispatch ceiling. Anything not 'plus' is treated as Free (the safe default).
485
- export const maxSpawnedFor = (plan, limits = SPAWN) => {
486
- const o = { ...SPAWN, ...(limits || {}) }
487
- return plan === 'plus' ? o.plusMaxSpawned : o.freeMaxSpawned
488
- }
489
-
490
- // Count spawn timestamps still inside the sliding window ending at `now`. Timestamps
491
- // strictly older than `now - windowMs` have replenished (fallen out of the window).
492
- // Non-finite entries are ignored defensively. Exported so the bridge can prune its
493
- // stored spawnTimes with the identical predicate (no unbounded growth).
494
- export const recentSpawnCount = (spawnTimes, now, windowMs = SPAWN.windowMs) =>
495
- (Array.isArray(spawnTimes) ? spawnTimes : []).filter((t) => Number.isFinite(t) && t > now - windowMs).length
496
-
497
- export const spawnDecision = ({ hop = 0, spawnTimes = [], now = 0, spawnedLive = 0, totalLive = 0, plan = 'free', disabled = false } = {}, limits = SPAWN) => {
498
- const o = { ...SPAWN, ...(limits || {}) }
499
- if (disabled) return { ok: false, reason: 'Spawning terminals is disabled in this room.' }
500
- 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.' }
501
- if (totalLive >= o.machineMax) return { ok: false, reason: `Room is at the machine cap (${o.machineMax} live lanes) — close one before opening another.` }
502
- const cap = maxSpawnedFor(plan, o)
503
- 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.` }
504
- if (recentSpawnCount(spawnTimes, now, o.windowMs) >= o.perWindowCap) {
505
- const mins = Math.round(o.windowMs / 60000)
506
- 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.` }
507
- }
508
- return { ok: true }
509
- }
510
-
511
- // ── Previewed Dispatch contract ────────────────────────────────────────────
512
- // The durable pair-control row remains the approval authority. This contract
513
- // supplies the immutable, secret-free object that row refers to and the bridge
514
- // rechecks immediately before it spends a lane. No token, provider credential,
515
- // host path, or mutable process object is copied into the preview.
516
- const DISPATCH_ARG_KEYS = Object.freeze(['mode', 'model', 'name', 'provider', 'runtime', 'sliceType', 'task'])
517
- // `review` is an internal Codex-only mode selected by structuredModeForSlice.
518
- // It is not user-selectable in the spawn schema, but it must survive the immutable
519
- // preview boundary or every Codex review lane fails before creation.
520
- const DISPATCH_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'review'])
521
- const DISPATCH_RUNTIMES = new Set(['claude', 'codex', 'hermes'])
522
- const DISPATCH_SLICES = new Set(['scaffold', 'feature', 'fix', 'review'])
523
- const secretValue = /((?<![a-z0-9])sk-[a-z0-9_-]{8,}|(?<![a-z0-9])gsk_[a-z0-9_-]{8,}|(?<![a-z0-9])AIza[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,})/ig
524
- const hostPath = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\)/ig
525
- const canonical = (value) => {
526
- if (Array.isArray(value)) return value.map(canonical)
527
- if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]))
528
- return value
529
- }
530
- const sha256 = (value) => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
531
- const bounded = (value, max) => value == null ? null : String(value).slice(0, max)
532
- const safeCopy = (value, max) => bounded(value, max)?.replace(secretValue, '[credential redacted]').replace(hostPath, '[host path redacted]') || ''
533
-
534
- export function normalizeDispatchArgs (args = {}, inherited = {}) {
535
- if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).some((key) => !DISPATCH_ARG_KEYS.includes(key))) {
536
- throw new TypeError('Invalid dispatch arguments')
537
- }
538
- const runtime = args.runtime || inherited.runtime || 'claude'
539
- const mode = args.mode || (inherited.mode === 'plan' ? 'default' : inherited.mode) || 'default'
540
- if (!DISPATCH_RUNTIMES.has(runtime) || !DISPATCH_MODES.has(mode) || (args.sliceType && !DISPATCH_SLICES.has(args.sliceType))) {
541
- throw new TypeError('Invalid dispatch arguments')
542
- }
543
- return Object.freeze({
544
- name: bounded(args.name, 80),
545
- task: bounded(args.task, 12000),
546
- model: bounded(args.model, 160),
547
- sliceType: bounded(args.sliceType, 24),
548
- runtime,
549
- provider: bounded(args.provider, 160),
550
- mode,
551
- })
552
- }
553
-
554
- export function dispatchCapSnapshot ({ hop = 0, spawnTimes = [], now = 0, spawnedLive = 0, totalLive = 0, plan = 'free', disabled = false } = {}, limits = SPAWN) {
555
- const o = { ...SPAWN, ...(limits || {}) }
556
- const planName = plan === 'plus' ? 'plus' : 'free'
557
- const planLimit = maxSpawnedFor(planName, o)
558
- return Object.freeze({
559
- killSwitch: disabled === true,
560
- hop: Object.freeze({ used: Math.max(0, Number(hop) || 0), limit: o.maxHop }),
561
- width: Object.freeze({ requested: 1, live: Math.max(0, Number(spawnedLive) || 0), limit: planLimit }),
562
- machine: Object.freeze({ live: Math.max(0, Number(totalLive) || 0), limit: o.machineMax }),
563
- plan: Object.freeze({ name: planName, price: planName === 'plus' ? '€5/mo' : '€0', used: Math.max(0, Number(spawnedLive) || 0), limit: planLimit }),
564
- burst: Object.freeze({ used: recentSpawnCount(spawnTimes, now, o.windowMs), limit: o.perWindowCap, windowMinutes: Math.round(o.windowMs / 60000) }),
565
- })
566
- }
567
-
568
- export function buildDispatchPreview ({ args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context = {}, limits = SPAWN } = {}) {
569
- if (!initiatorTerminalId || !roomCode || !bridgeAuthorityId || !localAuthorityId) throw new TypeError('Dispatch authority is incomplete')
570
- const exactArgs = normalizeDispatchArgs(args, { runtime: context.runtime, mode: context.mode })
571
- const caps = dispatchCapSnapshot(context, limits)
572
- // The terminal initiates the request; it is NOT the human approver. The human
573
- // actor is bound later by code_pair_control_items.resolved_by plus the matching
574
- // durable resolution/delivery-attempt events.
575
- const authority = Object.freeze({ initiatorTerminalId: String(initiatorTerminalId), roomCode: String(roomCode).toUpperCase(), bridgeAuthorityId: String(bridgeAuthorityId), localAuthorityId: String(localAuthorityId) })
576
- const fingerprint = sha256({ authority, exactArgs, caps })
577
- const provider = exactArgs.provider || (exactArgs.runtime === 'codex'
578
- ? 'OpenAI / Codex login'
579
- : exactArgs.runtime === 'hermes' ? 'Hermes ACP profile' : 'Anthropic / host default')
580
- const model = exactArgs.model || context.resolvedModel || 'runtime default'
581
- const purpose = safeCopy(exactArgs.name || exactArgs.task?.split(/\r?\n/)[0] || 'Open one worker lane', 120)
582
- return Object.freeze({
583
- version: 1,
584
- fingerprint,
585
- purpose,
586
- packages: Object.freeze([{ title: safeCopy(exactArgs.name || 'Worker package', 80), task: safeCopy(exactArgs.task || 'Open idle for a later hand-off.', 600) }]),
587
- runtime: Object.freeze({
588
- name: exactArgs.runtime,
589
- provider: safeCopy(context.providerName || provider, 120),
590
- model: safeCopy(model, 160),
591
- band: 'Start: now–1 min · completion: task-dependent',
592
- execution: 'Runs locally on the bridge machine; no cloud worker is created.',
593
- accountAndSpend: `Uses the host’s ${safeCopy(context.providerName || provider, 100)} account/BYOK. Provider usage is the spend source; ThinkPool has no credit meter.`,
594
- }),
595
- permissions: Object.freeze({ mode: exactArgs.mode, consequence: exactArgs.mode === 'bypassPermissions'
596
- ? 'Unattended local access within this worker’s sandbox and tool policy.'
597
- : exactArgs.mode === 'review' ? 'Read-only inspection of the pinned parent snapshot; writes and approval escapes are unavailable.'
598
- : exactArgs.mode === 'plan' ? 'Planning only; execution remains gated.' : 'Protected local actions keep their normal permission gates.' }),
599
- worktree: 'Automatically creates one visible lateral worker lane and an isolated git worktree on this machine.',
600
- caps,
601
- })
602
- }
603
-
604
- export function authorizeDispatchApproval ({ preview, approvedFingerprint, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context = {}, decision = 'allow', consumed = false, permissionId = null, consumedPermissionId = null, limits = SPAWN } = {}) {
605
- if (decision !== 'allow') return { ok: false, code: 'dispatch_canceled' }
606
- if (consumed) return { ok: false, code: permissionId && permissionId === consumedPermissionId ? 'idempotent_replay' : 'approval_replayed' }
607
- return authorizeBoundDispatch({ preview, approvedFingerprint, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context, limits })
608
- }
609
-
610
- // Spawned workers are autonomous by product contract: Dispatch never pauses on
611
- // a room approval card, and every worker runs in bypassPermissions. The immutable
612
- // preview still binds the exact package and caps, which are rechecked immediately
613
- // before opening the lane.
614
- export function authorizeDirectDispatch ({ preview, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context = {}, limits = SPAWN } = {}) {
615
- return authorizeBoundDispatch({ preview, approvedFingerprint: preview?.fingerprint, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context, limits })
616
- }
617
-
618
- function authorizeBoundDispatch ({ preview, approvedFingerprint, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context = {}, limits = SPAWN } = {}) {
619
- let current
620
- try { current = buildDispatchPreview({ args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context, limits }) } catch { return { ok: false, code: 'invalid_dispatch' } }
621
- if (!preview || preview.fingerprint !== approvedFingerprint) return { ok: false, code: 'approval_mismatch' }
622
- if (current.fingerprint !== preview.fingerprint) return { ok: false, code: 'stale_dispatch' }
623
- const gate = spawnDecision(context, limits)
624
- if (!gate.ok) return { ok: false, code: 'cap_denied', reason: gate.reason }
625
- return { ok: true, code: 'approved', preview: current }
626
- }
627
-
628
- // A public Realtime broadcast is only a wake-up hint. This verifier consumes the
629
- // rows the bridge re-read through its authenticated room-member token. Exactly one
630
- // durable winner (resolved_by) must own both the resolution and the single delivery
631
- // attempt, and every immutable authority/fingerprint field must match.
632
- export function verifyDurableDispatchAuthority ({ item, events = [], expected = {} } = {}) {
633
- const fail = (code) => ({ ok: false, code })
634
- const fp = String(expected.fingerprint || '')
635
- if (!item || item.id !== expected.controlItemId) return fail('control_item_mismatch')
636
- if (item.session_code !== String(expected.roomCode || '').toUpperCase()) return fail('wrong_room')
637
- if (item.action_kind !== 'dispatch' || item.item_kind !== 'approval') return fail('wrong_action')
638
- if (item.status !== 'resolved' || item.resolution_code !== 'approved' || !item.resolved_by) return fail('not_approved')
639
- if (item.resolution_policy !== 'either_member' || item.required_actor_id != null) return fail('wrong_resolution_policy')
640
- if (item.bridge_authority_id !== expected.bridgeAuthorityId) return fail('wrong_bridge_authority')
641
- if (item.local_authority_id !== expected.localAuthorityId) return fail('wrong_local_authority')
642
- if (!/^[0-9a-f]{64}$/.test(fp)) return fail('invalid_fingerprint')
643
- if (!String(item.request_context?.action || '').includes(`fp:${fp}`)) return fail('request_fingerprint_mismatch')
644
- if (item.resolution_payload?.dispatch_fingerprint !== fp) return fail('resolution_fingerprint_mismatch')
645
- const relevant = (Array.isArray(events) ? events : []).filter((event) => event?.item_id === item.id)
646
- const resolutions = relevant.filter((event) => event.event_type === 'resolved' && event.actor_id === item.resolved_by &&
647
- event.payload?.resolution_payload?.dispatch_fingerprint === fp && event.payload?.resolution_code === 'approved')
648
- if (resolutions.length !== 1) return fail('resolution_event_mismatch')
649
- const attempts = relevant.filter((event) => event.event_type === 'delivery_attempted' && event.actor_id === item.resolved_by &&
650
- event.payload?.data?.dispatch_fingerprint === fp)
651
- if (attempts.length !== 1) return fail('delivery_attempt_mismatch')
652
- if (relevant.some((event) => event.event_type === 'delivered' || event.event_type === 'delivery_failed')) return fail('delivery_already_final')
653
- return { ok: true, code: 'durable_dispatch_authorized', fingerprint: fp, humanActorId: item.resolved_by, attemptKey: attempts[0].correlation_key }
654
- }
655
-
656
- // When a spawn is refused, "close one before opening another" is useless advice if the
657
- // caller has to go hunting for WHICH one. Rooms fill up with finished lanes nobody
658
- // closed (Max, 2026-07-10: "clean up the finished ensemble terminals there's a lot"),
659
- // because the old preamble asked politely and nothing named the cost. So the refusal
660
- // now names the caller's OWN idle spawned lanes — the exact ones close_terminal will
661
- // accept — newest last, capped so a long list can't drown the reason above it.
662
- //
663
- // Pure: takes the roster, returns a string. `lanes` = [{ ref, name, spawnedBy, busy }].
664
- // Only lanes this caller spawned are suggested: close_terminal refuses the others, and
665
- // suggesting a sibling's lane is how you get an agent killing someone else's work.
666
- export const CLOSABLE_HINT_MAX = 6
667
- export const closeLaneDecision = ({ callerId, spawnedBy, busy } = {}) => {
668
- if (!callerId || spawnedBy !== callerId) return { ok: false, reason: 'not-owner' }
669
- if (busy) return { ok: false, reason: 'busy' }
670
- return { ok: true }
671
- }
672
-
673
- export const closableLanes = (callerId, lanes = []) =>
674
- (Array.isArray(lanes) ? lanes : []).filter((l) => l && closeLaneDecision({
675
- callerId,
676
- spawnedBy: l.spawnedBy,
677
- busy: l.busy,
678
- }).ok)
679
-
680
- export const formatClosableHint = (callerId, lanes = [], max = CLOSABLE_HINT_MAX) => {
681
- const idle = closableLanes(callerId, lanes)
682
- if (!idle.length) return ''
683
- const shown = idle.slice(0, max)
684
- const list = shown.map((l) => `${l.ref}${l.name ? ` ("${l.name}")` : ''}`).join(', ')
685
- const more = idle.length > shown.length ? ` (+${idle.length - shown.length} more)` : ''
686
- return `\n\nYou have ${idle.length} finished lane${idle.length === 1 ? '' : 's'} of your own still open — collect ${idle.length === 1 ? 'it' : 'them'} with read_terminal, then close_terminal: ${list}${more}`
687
- }
688
-
689
- // ── Tier 1 cross-ROOM peek (read-only, same-machine) ─────────────────────────
690
- // Thinkpool Ensemble expanded from cross-TERMINAL (sibling lanes in one room) to
691
- // cross-SESSION (the account's OTHER rooms served by the same account supervisor).
692
- // This module only formats the ROSTER of other rooms; the per-room terminal read
693
- // reuses formatPeek as-is on the REMOTE side (the target room's bridge runs it over
694
- // its own registries). Cross-room is rarer + costlier than in-room peek (an IPC /
695
- // future-bus round-trip per call), so the per-turn cap is tighter than PEEK's.
696
- // Spec: docs/specs/2026-06-30-cross-room-ensemble.md
697
- export const CROSSROOM = {
698
- peekPerTurnCap: 5, // list_sessions + read_session calls allowed per turn (vs PEEK.perTurnCap=10 in-room)
699
- }
700
-
701
- // Canonical ThinkPool peer capability names. Claude/Codex receive the SDK MCP
702
- // server directly; Hermes reconstructs a process-local schema and therefore
703
- // needs the same names explicitly. Keep the peer surface here beside its gates
704
- // so a runtime cannot silently lose cross-room reach when the server grows.
705
- export const PEER_READ_MCP_TOOLS = Object.freeze([
706
- 'read_terminal',
707
- 'list_sessions',
708
- 'read_session',
709
- ])
710
-
711
- export const PEER_POST_MCP_TOOLS = Object.freeze([
712
- 'post_to_terminal',
713
- 'post_to_session',
714
- ])
715
-
716
- export const PEER_MCP_TOOLS = Object.freeze([
717
- ...PEER_READ_MCP_TOOLS,
718
- ...PEER_POST_MCP_TOOLS,
719
- ])
720
-
721
- // Render the list_sessions roster from the supervisor's room list. `rooms` is
722
- // [{ code, name }]; `thisRoom` is excluded defensively (the supervisor already
723
- // drops it). Mirrors formatPeek's no-arg roster shape so the agent reads one voice.
724
- export const formatPairRoster = (rooms, { thisRoom, error } = {}) => {
725
- if (error) return String(error)
726
- const seen = new Set()
727
- const list = (Array.isArray(rooms) ? rooms : []).filter((r) => {
728
- if (!r || !r.code || r.code === thisRoom || seen.has(r.code)) return false
729
- seen.add(r.code); return true
730
- })
731
- if (!list.length) return 'No other sessions of yours are open right now. (Cross-session reach is limited to rooms this account is running here, plus your partner\'s rooms in the same pair.)'
732
- // `host` is set for a Tier-2 cross-MACHINE room (it lives on a partner's Mac,
733
- // reached over the pair bus); a same-machine Tier-1 room has none. Rendering the
734
- // host tells the agent (and the people) which rooms are "over there".
735
- const rows = list.map((r) => `- ${r.code}${r.name ? ` · "${r.name}"` : ''}${r.host ? ` · on ${r.host}` : ''}${r.online === false ? ' · partner bridge offline' : ''}`)
736
- return `Your other ThinkPool Code sessions (call read_session with a room code to look inside one):\n${rows.join('\n')}`
737
- }
738
-
739
- // ── Tier 2 cross-MACHINE peek — the pair bus ─────────────────────────────────
740
- // Tier 1 reached the account's OTHER rooms on the SAME machine over Node IPC.
741
- // Tier 2 reaches a PARTNER's rooms on ANOTHER machine over a per-pair Supabase
742
- // Broadcast channel, `tppair:<pairKey>`. The firewall IS the channel: only the
743
- // two people who actually share a 2-person room can join it (RLS on
744
- // realtime.messages, server-side) — so a supervisor never even hears a stranger's
745
- // room, and there's no client-side trust to get wrong. pairKeyFor builds the
746
- // IDENTICAL channel name on both Macs from the two participant UUIDs, so they meet
747
- // on the same bus without coordinating. Spec: docs/specs/2026-06-30-cross-room-pair-bus.md
748
- export const CROSSROOM_BUS = {
749
- busTimeoutMs: 4000, // a cross-MACHINE round-trip fails soft after this (never hangs a turn)
750
- }
751
-
752
- // Deterministic, order-INDEPENDENT channel key for the pair {a, b}: lowercased,
753
- // sorted, dot-joined. Both Macs derive the same string from the shared room's two
754
- // participants, so they subscribe to the same tppair channel. Returns null unless
755
- // given two DISTINCT uuids — a self-only "pair" (one participant) has no partner on
756
- // another machine to reach, so it gets no bus.
757
- export const pairKeyFor = (a, b) => {
758
- const x = String(a == null ? '' : a).toLowerCase().trim()
759
- const y = String(b == null ? '' : b).toLowerCase().trim()
760
- if (!x || !y || x === y) return null
761
- return x < y ? `${x}.${y}` : `${y}.${x}`
762
- }
763
-
764
- // The realtime topic both supervisors subscribe to for a pair. The two UUIDs in the
765
- // name are not secrets (they're already in tpacct:<uid>), and the RLS policy re-derives
766
- // + verifies membership server-side; the name is just the rendezvous point.
767
- export const pairTopic = (key) => key ? `tppair:${key}` : null
768
-
769
- // ── Tier 3 cross-ROOM POST — directed, dual-consent, possibly cross-machine ──
770
- // Posting work INTO another room (yours on this machine, or a partner's over the
771
- // pair bus). Deliberately TIGHTER than the in-room cross-TERMINAL post (CROSSPOST):
772
- // it spends real model tokens in another room — maybe on another person's machine —
773
- // so (a) the per-turn cap starts at ONE, (b) a room reached VIA a cross-room post
774
- // cannot post onward (roomHop loop-breaker), and (c) BOTH ends gate on a person:
775
- // the sender's room approves the send, the recipient's room approves the receipt.
776
- // The bridge enforces (a)+(b) here; the dual cards live in the gate + the web.
777
- // Kill-switch: TP_PAIRBUS_OFF (the same switch that disables the bus).
778
- export const CROSSROOM_POST = {
779
- maxRoomHop: 1, // human turn = roomHop 0; a turn injected via cross-room post = 1, which CANNOT post onward
780
- crossRoomPostCap: 1, // post_to_session calls allowed per human turn (tighter than in-room CROSSPOST.perTurnCap)
781
- }
782
-
783
- export const crossRoomPostDecision = ({ roomHop = 0, postCount = 0, disabled = false } = {}, limits = CROSSROOM_POST) => {
784
- const o = { ...CROSSROOM_POST, ...(limits || {}) }
785
- if (disabled) return { ok: false, reason: 'Cross-room posting is turned off on this bridge.' }
786
- if (roomHop >= o.maxRoomHop) return { ok: false, reason: 'Cross-room hop limit reached — a room reached via a cross-room post cannot itself post into another room; a person must direct the next cross-room action.' }
787
- if (postCount >= o.crossRoomPostCap) return { ok: false, reason: `Cross-room post limit reached for this turn (${o.crossRoomPostCap}).` }
788
- return { ok: true }
789
- }
1
+ import{createHash as e}from"node:crypto";import{BLOCKED_REASON as t,projectLaneLifecycle as r}from"./lane-lifecycle.mjs";export const PEEK={defaultLines:20,maxLines:200,perTurnCap:10,rosterPerTurnCap:1,lineCap:200,rosterPreview:100};export const readTerminalBudgetDecision=({targeted:e=!1,targetedCount:t=0,rosterCount:r=0}={},n=PEEK)=>{const o=Math.max(0,Number(t)||0),i=Math.max(0,Number(r)||0);return e?o>=n.perTurnCap?{ok:!1,targetedCount:o,rosterCount:i,reason:`Cross-terminal transcript read limit reached for this turn (${n.perTurnCap}). Stop polling and continue with the worker results already collected; a fresh accepted room turn resets the allowance.`}:{ok:!0,targetedCount:o+1,rosterCount:i}:i>=n.rosterPerTurnCap?{ok:!1,targetedCount:o,rosterCount:i,reason:`The full terminal roster was already read this turn (${n.rosterPerTurnCap}/${n.rosterPerTurnCap}). Use the current ROOM NOW snapshot or read one relevant terminal by ref or name.`}:{ok:!0,targetedCount:o,rosterCount:i+1}};export const readTerminalTurnBudgetDecision=({targeted:e=!1,targetedCount:t=0,rosterCount:r=0,budgetTurnRev:n=null,currentTurnRev:o=0}={},i=PEEK)=>{const s=Math.max(0,Number(o)||0),a=(null==n?null:Number.isFinite(Number(n))?Number(n):null)===s;return{...readTerminalBudgetDecision({targeted:e,targetedCount:a?t:0,rosterCount:a?r:0},i),budgetTurnRev:s}};export const LANE_STATUS={stuckAfterMs:9e4,repeatedFailureCount:2,failureWindow:12};const n=e=>!!e&&("error"===e.kind||"tool_result"===e.kind&&e.isError||"result"===e.kind&&e.subtype&&"success"!==e.subtype);export const laneBusyOf=(e={})=>"boolean"==typeof e?.busy?e.busy:"boolean"==typeof e?._busyAnn?e._busyAnn:e?.session?.turnActive??!1;export const settleLaneBusy=e=>!(!e||!1===e._busyAnn||(e._busyAnn=!1,0));export const settleLaneControl=(e,t=Date.now())=>!(!e||!0!==e._busyAnn||!settleLaneBusy(e)||(e._settledTurnRev=Number(e._turnRev)||null,e._turnSettledAt=Number(t)||Date.now(),e._turnStart=null,0));export const laneStatusOf=(e={},o=Date.now(),i=LANE_STATUS)=>{const s=Array.isArray(e?.log)?e.log:[],a=s.slice(-i.failureWindow);!e?.lastEvent||e.lastEvent===s[s.length-1]||e.lastEvent.cid&&e.lastEvent.cid===s[s.length-1]?.cid||a.push(e.lastEvent);let l=null;for(let e=a.length-1;e>=0;e--)if(Number.isFinite(a[e]?.ts)){l=a[e].ts;break}const c=Number.isFinite(e?.lastActionAt)?e.lastActionAt:l??null,d=null==c?null:Math.max(0,o-c),u=laneBusyOf(e),p=e?.pending instanceof Map?e.pending.size:Math.max(0,Number(e?.pendingCount)||0),m=e?.pending instanceof Map&&[...e.pending.values()].some(e=>"ask"===e?.payload?.risk)?t.NEEDS_INPUT:t.NEEDS_DECISION,h=((e,t=LANE_STATUS)=>{let r=0,o=0;for(let i=(Array.isArray(e)?e.length:0)-1;i>=0&&o<t.failureWindow;i--){const t=e[i];if(t&&"object"==typeof t){if(o++,"result"===t.kind&&(!t.subtype||"success"===t.subtype)||"you"===t.kind)break;n(t)&&r++}}return r})(a,i);let f=null,g=null;return p>0?(f="BLOCKED",g="waiting for input"):e?.stalled||u&&!e?.compacting&&null!=d&&d>=i.stuckAfterMs?(f="STUCK",g="no events while busy"):h>=i.repeatedFailureCount&&(f="BLOCKED",g="repeated errors"),{busy:!!u,lastActionAt:c,lastActionAgeMs:d,statusFlag:f,statusReason:g,lifecycle:r({events:a,busy:!!u,pendingCount:p,pendingReason:m,stalled:"STUCK"===f,failed:h>=i.repeatedFailureCount&&!u,rawStatus:f||(u?"busy":"idle")})}};export const formatLaneAge=e=>{if(!Number.isFinite(e))return"no activity";const t=Math.max(0,Math.floor(e/1e3));if(t<60)return`${t}s ago`;const r=Math.floor(t/60);if(r<60)return`${r}m ago`;const n=Math.floor(r/60);return n<24?`${n}h ago`:`${Math.floor(n/24)}d ago`};const o=e=>`${e.lifecycle?.state||"Blocked"}${e.lifecycle?.reason?` (${e.lifecycle.reason})`:""} · ${formatLaneAge(e.lastActionAgeMs)}`,i=(e,t)=>{const r=String(null==e?"":e).replace(/\s+/g," ").trim();return r.length>t?r.slice(0,t-1)+"…":r};export const digestLogEvent=(e,t=PEEK.lineCap)=>{if(!e||"object"!=typeof e)return"";switch(e.kind){case"you":return i(`${e.by||"someone"}: ${e.text||""}`,t);case"assistant":{const r=[];for(const t of Array.isArray(e.blocks)?e.blocks:[])"text"===t?.type&&String(t.text||"").trim()?r.push(String(t.text).trim()):"tool_use"===t?.type&&t.name&&r.push(`→ ${t.name}`);return r.length?i(`assistant: ${r.join(" ")}`,t):""}case"tool_result":return e.isError?i("⎿ error",t):"";case"result":return e.subtype&&"success"!==e.subtype?i(`turn ended — ${e.subtype}`,t):"";case"error":return i(`error: ${e.message||""}`,t);default:return""}};export const cleanPtyTail=(e,t)=>String(e||"").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g,"").replace(/\x1b[@-_][0-?]*[ -/]*[@-~]/g,"").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g,"").split(/\r?\n/).map(e=>e.replace(/\s+$/,"")).filter(e=>e.trim()).slice(-Math.max(1,t));export const siblingRef=e=>String(e).slice(0,8);export const siblingsOf=({selfId:e,sessions:t,terms:r,names:n={},now:o=Date.now()}={})=>{const i=e=>n&&n[e]||"",s=[];for(const[r,n]of t||new Map)r!==e&&s.push({id:r,ref:siblingRef(r),name:i(r),kind:"agent",cmd:n?.cmd||"claude",log:Array.isArray(n?.log)?n.log:[],...laneStatusOf(n,o)});for(const[t,n]of r||new Map)t!==e&&s.push({id:t,ref:siblingRef(t),name:i(t),kind:"shell",cmd:n?.cmd||"shell",buf:n?.buf||"",...laneStatusOf(n,o)});return s};export const isOrdinalRef=e=>/^(terminal\s*)?\d+$/i.test(e)||/^t\d+$/i.test(e);export const resolveSibling=(e,t)=>{const r=String(t||"").toLowerCase().trim();if(!r)return null;const n=Array.isArray(e)?e:[];return n.find(e=>String(e.id).toLowerCase()===r)||n.find(e=>String(e.name||"").toLowerCase()===r)||n.find(e=>String(e.ref).toLowerCase()===r)||(isOrdinalRef(r)?null:n.find(e=>r.length>=4&&String(e.id).toLowerCase().startsWith(r))||n.find(e=>e.name&&String(e.name).toLowerCase().includes(r))||n.find(e=>String(e.cmd).toLowerCase().includes(r))||null)};const s=e=>`${e.ref}${e.name?` · "${e.name}"`:""}`;export const formatPeek=({selfId:e,sessions:t,terms:r,names:n={},terminal:a,lines:l,now:c=Date.now()}={})=>{const d=Math.min(PEEK.maxLines,Math.max(1,Number(l)||PEEK.defaultLines)),u=siblingsOf({selfId:e,sessions:t,terms:r,names:n,now:c});if(!u.length)return"No other terminals are open in this room right now.";if(!a)return`Other terminals in this room (call read_terminal with a ref or name to read one):\n${u.map(e=>{let t;if("agent"===e.kind){const r=e.log.map(e=>digestLogEvent(e,PEEK.rosterPreview)).filter(Boolean);t=r[r.length-1]||"(no activity yet)"}else t=cleanPtyTail(e.buf,1)[0]||"(no output yet)";return`- ${s(e)} · ${e.kind} · ${e.cmd} · ${o(e)} — ${i(t,PEEK.rosterPreview)}`}).join("\n")}`;const p=resolveSibling(u,a);if(!p)return`No terminal matches "${a}". Call read_terminal with no argument to list the open terminals (refer to one by its name or ref).${isOrdinalRef(String(a||"").toLowerCase().trim())?" 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.":""}`;let m;return m="agent"===p.kind?p.log.map(e=>digestLogEvent(e,PEEK.lineCap)).filter(Boolean).slice(-d).join("\n")||"(no activity yet)":cleanPtyTail(p.buf,d).join("\n")||"(no output yet)",`Terminal ${s(p)} (${p.kind} · ${p.cmd}) · ${o(p)} — last ${d} lines:\n${m}`};export const nativeClaudeProviderAccessFailed=e=>{let t="";try{t="string"==typeof e?e:JSON.stringify(e||"")}catch{t=String(e||"")}return/organization has disabled (?:claude(?: subscription access)?|subscription access)|(?:claude )?subscription access (?:has been |is )?disabled|invalid anthropic (?:api )?key|invalid x-api-key|not logged in to claude|(?:run|use) \/login[^\n]{0,80}claude|no valid anthropic (?:api )?key/i.test(t)};const a=Object.freeze(["nous:anthropic/claude-sonnet-4.6","nous:anthropic/claude-sonnet-4.5","nous:anthropic/claude-sonnet-4"]);export const preferredHermesClaudeReviewModel=(e=[])=>{const t=new Set((Array.isArray(e)?e:[]).map(e=>"string"==typeof e?e:e?.value||e?.slug).filter(Boolean));for(const e of a)if(t.has(e))return e;for(const e of t)if(/^nous:anthropic\/claude-sonnet-/i.test(e))return e;for(const e of t)if(/^nous:anthropic\/claude-haiku-/i.test(e))return e;for(const e of t)if(/^nous:anthropic\/claude-opus-/i.test(e))return e;return null};export const nativeClaudeFallbackHint=({parentRuntime:e,parentModels:t,targetRuntime:r,targetLog:n}={})=>{if("claude"!==r||!nativeClaudeProviderAccessFailed(n))return"";const o="ThinkPool provider status: native Claude access failed at the account/provider level. Do not retry Sonnet, Opus, Haiku, or another model alias on the same native Claude provider during this task.",i="hermes"===e?preferredHermesClaudeReviewModel(t):null;return i?`\n\n${o} For a visible manual review fallback, spawn exactly one worker with runtime="hermes", model=${JSON.stringify(i)}, mode="default", and sliceType="review". This Hermes lane is permission-gated, not structurally read-only: instruct it not to edit, deny/stop any write request, and do not treat its review as a Flow safety proof. Never use Hermes delegate_task.`:`\n\n${o} Use a different available runtime/provider once, or report the blocker.`};export const ROOMNOW={laneCap:8,previewLen:70,wtCap:10};export const formatRoomNow=({selfId:e,sessions:t,terms:r,names:n={},worktrees:a=[],now:l=Date.now()}={},c=ROOMNOW)=>{const d=siblingsOf({selfId:e,sessions:t,terms:r,names:n,now:l}),u=[];if(d.length){const e=d.slice(0,c.laneCap),t=e.map(e=>{let t;if("agent"===e.kind){const r=e.log.map(e=>digestLogEvent(e,c.previewLen)).filter(Boolean);t=r[r.length-1]||"(no activity yet)"}else t=cleanPtyTail(e.buf,1)[0]||"(no output yet)";return`- ${s(e)} · ${e.kind} · ${o(e)} — ${i(t,c.previewLen)}`});d.length>e.length&&t.push(`- … +${d.length-e.length} more (read_terminal lists all)`),u.push(`Other lanes in this room right now (this is the roster; use targeted read_terminal only for needed detail):\n${t.join("\n")}`)}const p=Array.isArray(a)?a:[],m=p.slice(0,c.wtCap);if(m.length){const e=m.map(e=>`- ${i(e,c.previewLen+30)}`);p.length>m.length&&e.push(`- … +${p.length-m.length} more (\`git worktree list\` shows all)`),u.push(`Active git worktrees on this host (code edits go in your OWN worktree — never one another lane is on):\n${e.join("\n")}`)}return u.length?`ROOM NOW (live snapshot, this turn):\n${u.join("\n")}`:null};export const CROSSPOST={maxHop:1,perTurnCap:3};export const crossPostDecision=({hop:e=0,postCount:t=0,disabled:r=!1}={},n=CROSSPOST)=>{const o={...CROSSPOST,...n||{}};return r?{ok:!1,reason:"Cross-terminal posting is disabled in this room."}:e>=o.maxHop?{ok:!1,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."}:t>=o.perTurnCap?{ok:!1,reason:`Cross-terminal post limit reached for this turn (${o.perTurnCap}).`}:{ok:!0}};export const crossPostNeedsCard=({mode:e="default",spawnedByMe:t=!1}={})=>!("bypassPermissions"===e||!0===t);export const SPAWN={maxHop:1,perWindowCap:6,windowMs:6e5,freeMaxSpawned:3,plusMaxSpawned:6,machineMax:12};export const maxSpawnedFor=(e,t=SPAWN)=>{const r={...SPAWN,...t||{}};return"plus"===e?r.plusMaxSpawned:r.freeMaxSpawned};export const recentSpawnCount=(e,t,r=SPAWN.windowMs)=>(Array.isArray(e)?e:[]).filter(e=>Number.isFinite(e)&&e>t-r).length;export const spawnDecision=({hop:e=0,spawnTimes:t=[],now:r=0,spawnedLive:n=0,totalLive:o=0,plan:i="free",disabled:s=!1}={},a=SPAWN)=>{const l={...SPAWN,...a||{}};if(s)return{ok:!1,reason:"Spawning terminals is disabled in this room."};if(e>=l.maxHop)return{ok:!1,reason:"Spawn limit reached — a terminal reached via cross-terminal cannot itself open further terminals; a person must open the next lane."};if(o>=l.machineMax)return{ok:!1,reason:`Room is at the machine cap (${l.machineMax} live lanes) — close one before opening another.`};const c=maxSpawnedFor(i,l);if(n>=c)return{ok:!1,reason:`Dispatch ceiling reached (${c} lane${1===c?"":"s"} on the ${i} plan)${"plus"!==i?` — Plus raises it to ${l.plusMaxSpawned}.`:"."} Close a dispatched lane to open another.`};if(recentSpawnCount(t,r,l.windowMs)>=l.perWindowCap){const e=Math.round(l.windowMs/6e4);return{ok:!1,reason:`Spawn burst limit reached (${l.perWindowCap} in ${e} min) — this replenishes as the window slides forward, or instantly when a person types into this lane.`}}return{ok:!0}};const l=Object.freeze(["mode","model","name","provider","runtime","sliceType","task"]),c=new Set(["default","acceptEdits","bypassPermissions","plan","review"]),d=new Set(["claude","codex","hermes"]),u=new Set(["scaffold","feature","fix","review"]),p=/((?<![a-z0-9])sk-[a-z0-9_-]{8,}|(?<![a-z0-9])gsk_[a-z0-9_-]{8,}|(?<![a-z0-9])AIza[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,})/gi,m=/(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\)/gi,h=e=>Array.isArray(e)?e.map(h):e&&"object"==typeof e?Object.fromEntries(Object.keys(e).sort().map(t=>[t,h(e[t])])):e,f=(e,t)=>null==e?null:String(e).slice(0,t),g=(e,t)=>f(e,t)?.replace(p,"[credential redacted]").replace(m,"[host path redacted]")||"";export function normalizeDispatchArgs(e={},t={}){if(!e||"object"!=typeof e||Array.isArray(e)||Object.keys(e).some(e=>!l.includes(e)))throw new TypeError("Invalid dispatch arguments");const r=e.runtime||t.runtime||"claude",n=e.mode||("plan"===t.mode?"default":t.mode)||"default";if(!d.has(r)||!c.has(n)||e.sliceType&&!u.has(e.sliceType))throw new TypeError("Invalid dispatch arguments");return Object.freeze({name:f(e.name,80),task:f(e.task,12e3),model:f(e.model,160),sliceType:f(e.sliceType,24),runtime:r,provider:f(e.provider,160),mode:n})}export function dispatchCapSnapshot({hop:e=0,spawnTimes:t=[],now:r=0,spawnedLive:n=0,totalLive:o=0,plan:i="free",disabled:s=!1}={},a=SPAWN){const l={...SPAWN,...a||{}},c="plus"===i?"plus":"free",d=maxSpawnedFor(c,l);return Object.freeze({killSwitch:!0===s,hop:Object.freeze({used:Math.max(0,Number(e)||0),limit:l.maxHop}),width:Object.freeze({requested:1,live:Math.max(0,Number(n)||0),limit:d}),machine:Object.freeze({live:Math.max(0,Number(o)||0),limit:l.machineMax}),plan:Object.freeze({name:c,price:"plus"===c?"€5/mo":"€0",used:Math.max(0,Number(n)||0),limit:d}),burst:Object.freeze({used:recentSpawnCount(t,r,l.windowMs),limit:l.perWindowCap,windowMinutes:Math.round(l.windowMs/6e4)})})}export function buildDispatchPreview({args:t,initiatorTerminalId:r,roomCode:n,bridgeAuthorityId:o,localAuthorityId:i,context:s={},limits:a=SPAWN}={}){if(!(r&&n&&o&&i))throw new TypeError("Dispatch authority is incomplete");const l=normalizeDispatchArgs(t,{runtime:s.runtime,mode:s.mode}),c=dispatchCapSnapshot(s,a),d=(u={authority:Object.freeze({initiatorTerminalId:String(r),roomCode:String(n).toUpperCase(),bridgeAuthorityId:String(o),localAuthorityId:String(i)}),exactArgs:l,caps:c},e("sha256").update(JSON.stringify(h(u))).digest("hex"));var u;const p=l.provider||("codex"===l.runtime?"OpenAI / Codex login":"hermes"===l.runtime?"Hermes ACP profile":"Anthropic / host default"),m=l.model||s.resolvedModel||"runtime default",f=g(l.name||l.task?.split(/\r?\n/)[0]||"Open one worker lane",120);return Object.freeze({version:1,fingerprint:d,purpose:f,packages:Object.freeze([{title:g(l.name||"Worker package",80),task:g(l.task||"Open idle for a later hand-off.",600)}]),runtime:Object.freeze({name:l.runtime,provider:g(s.providerName||p,120),model:g(m,160),band:"Start: now–1 min · completion: task-dependent",execution:"Runs locally on the bridge machine; no cloud worker is created.",accountAndSpend:`Uses the host’s ${g(s.providerName||p,100)} account/BYOK. Provider usage is the spend source; ThinkPool has no credit meter.`}),permissions:Object.freeze({mode:l.mode,consequence:"bypassPermissions"===l.mode?"Unattended local access within this worker’s sandbox and tool policy.":"review"===l.mode?"Read-only inspection of the pinned parent snapshot; writes and approval escapes are unavailable.":"plan"===l.mode?"Planning only; execution remains gated.":"Protected local actions keep their normal permission gates."}),worktree:"Automatically creates one visible lateral worker lane and an isolated git worktree on this machine.",caps:c})}export function authorizeDispatchApproval({preview:e,approvedFingerprint:t,args:r,initiatorTerminalId:n,roomCode:o,bridgeAuthorityId:i,localAuthorityId:s,context:a={},decision:l="allow",consumed:c=!1,permissionId:d=null,consumedPermissionId:u=null,limits:p=SPAWN}={}){return"allow"!==l?{ok:!1,code:"dispatch_canceled"}:c?{ok:!1,code:d&&d===u?"idempotent_replay":"approval_replayed"}:y({preview:e,approvedFingerprint:t,args:r,initiatorTerminalId:n,roomCode:o,bridgeAuthorityId:i,localAuthorityId:s,context:a,limits:p})}export function authorizeDirectDispatch({preview:e,args:t,initiatorTerminalId:r,roomCode:n,bridgeAuthorityId:o,localAuthorityId:i,context:s={},limits:a=SPAWN}={}){return y({preview:e,approvedFingerprint:e?.fingerprint,args:t,initiatorTerminalId:r,roomCode:n,bridgeAuthorityId:o,localAuthorityId:i,context:s,limits:a})}function y({preview:e,approvedFingerprint:t,args:r,initiatorTerminalId:n,roomCode:o,bridgeAuthorityId:i,localAuthorityId:s,context:a={},limits:l=SPAWN}={}){let c;try{c=buildDispatchPreview({args:r,initiatorTerminalId:n,roomCode:o,bridgeAuthorityId:i,localAuthorityId:s,context:a,limits:l})}catch{return{ok:!1,code:"invalid_dispatch"}}if(!e||e.fingerprint!==t)return{ok:!1,code:"approval_mismatch"};if(c.fingerprint!==e.fingerprint)return{ok:!1,code:"stale_dispatch"};const d=spawnDecision(a,l);return d.ok?{ok:!0,code:"approved",preview:c}:{ok:!1,code:"cap_denied",reason:d.reason}}export function verifyDurableDispatchAuthority({item:e,events:t=[],expected:r={}}={}){const n=e=>({ok:!1,code:e}),o=String(r.fingerprint||"");if(!e||e.id!==r.controlItemId)return n("control_item_mismatch");if(e.session_code!==String(r.roomCode||"").toUpperCase())return n("wrong_room");if("dispatch"!==e.action_kind||"approval"!==e.item_kind)return n("wrong_action");if("resolved"!==e.status||"approved"!==e.resolution_code||!e.resolved_by)return n("not_approved");if("either_member"!==e.resolution_policy||null!=e.required_actor_id)return n("wrong_resolution_policy");if(e.bridge_authority_id!==r.bridgeAuthorityId)return n("wrong_bridge_authority");if(e.local_authority_id!==r.localAuthorityId)return n("wrong_local_authority");if(!/^[0-9a-f]{64}$/.test(o))return n("invalid_fingerprint");if(!String(e.request_context?.action||"").includes(`fp:${o}`))return n("request_fingerprint_mismatch");if(e.resolution_payload?.dispatch_fingerprint!==o)return n("resolution_fingerprint_mismatch");const i=(Array.isArray(t)?t:[]).filter(t=>t?.item_id===e.id);if(1!==i.filter(t=>"resolved"===t.event_type&&t.actor_id===e.resolved_by&&t.payload?.resolution_payload?.dispatch_fingerprint===o&&"approved"===t.payload?.resolution_code).length)return n("resolution_event_mismatch");const s=i.filter(t=>"delivery_attempted"===t.event_type&&t.actor_id===e.resolved_by&&t.payload?.data?.dispatch_fingerprint===o);return 1!==s.length?n("delivery_attempt_mismatch"):i.some(e=>"delivered"===e.event_type||"delivery_failed"===e.event_type)?n("delivery_already_final"):{ok:!0,code:"durable_dispatch_authorized",fingerprint:o,humanActorId:e.resolved_by,attemptKey:s[0].correlation_key}}export const CLOSABLE_HINT_MAX=6;export const closeLaneDecision=({callerId:e,spawnedBy:t,busy:r}={})=>e&&t===e?r?{ok:!1,reason:"busy"}:{ok:!0}:{ok:!1,reason:"not-owner"};export const closableLanes=(e,t=[])=>(Array.isArray(t)?t:[]).filter(t=>t&&closeLaneDecision({callerId:e,spawnedBy:t.spawnedBy,busy:t.busy}).ok);export const formatClosableHint=(e,t=[],r=6)=>{const n=closableLanes(e,t);if(!n.length)return"";const o=n.slice(0,r),i=o.map(e=>`${e.ref}${e.name?` ("${e.name}")`:""}`).join(", "),s=n.length>o.length?` (+${n.length-o.length} more)`:"";return`\n\nYou have ${n.length} finished lane${1===n.length?"":"s"} of your own still open — collect ${1===n.length?"it":"them"} with read_terminal, then close_terminal: ${i}${s}`};export const CROSSROOM={peekPerTurnCap:5};export const PEER_READ_MCP_TOOLS=Object.freeze(["read_terminal","list_sessions","read_session"]);export const PEER_POST_MCP_TOOLS=Object.freeze(["post_to_terminal","post_to_session"]);export const PEER_MCP_TOOLS=Object.freeze([...PEER_READ_MCP_TOOLS,...PEER_POST_MCP_TOOLS]);export const formatPairRoster=(e,{thisRoom:t,error:r}={})=>{if(r)return String(r);const n=new Set,o=(Array.isArray(e)?e:[]).filter(e=>!(!e||!e.code||e.code===t||n.has(e.code)||(n.add(e.code),0)));return o.length?`Your other ThinkPool Code sessions (call read_session with a room code to look inside one):\n${o.map(e=>`- ${e.code}${e.name?` · "${e.name}"`:""}${e.host?` · on ${e.host}`:""}${!1===e.online?" · partner bridge offline":""}`).join("\n")}`:"No other sessions of yours are open right now. (Cross-session reach is limited to rooms this account is running here, plus your partner's rooms in the same pair.)"};export const CROSSROOM_BUS={busTimeoutMs:4e3};export const pairKeyFor=(e,t)=>{const r=String(null==e?"":e).toLowerCase().trim(),n=String(null==t?"":t).toLowerCase().trim();return r&&n&&r!==n?r<n?`${r}.${n}`:`${n}.${r}`:null};export const pairTopic=e=>e?`tppair:${e}`:null;export const CROSSROOM_POST={maxRoomHop:1,crossRoomPostCap:1};export const crossRoomPostDecision=({roomHop:e=0,postCount:t=0,disabled:r=!1}={},n=CROSSROOM_POST)=>{const o={...CROSSROOM_POST,...n||{}};return r?{ok:!1,reason:"Cross-room posting is turned off on this bridge."}:e>=o.maxRoomHop?{ok:!1,reason:"Cross-room hop limit reached — a room reached via a cross-room post cannot itself post into another room; a person must direct the next cross-room action."}:t>=o.crossRoomPostCap?{ok:!1,reason:`Cross-room post limit reached for this turn (${o.crossRoomPostCap}).`}:{ok:!0}};