thinkpool-pair 0.7.363 → 0.7.365
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/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6240
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -0
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -91
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -0
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +15 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-capabilities.json +11 -2
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/cross-terminal.mjs
CHANGED
|
@@ -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}};
|