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,268 +1 @@
1
- // Durable, bridge-local execution for the deliberately small unattended-run V1.
2
- // This module owns no credentials and never executes a provider itself: it admits a
3
- // pre-bound schedule, asks the database to fence one occurrence, then lets bridge.mjs
4
- // open an ordinary visible top-level terminal.
5
-
6
- import { randomUUID } from 'node:crypto'
7
- import { SPAWN, spawnDecision } from './cross-terminal.mjs'
8
-
9
- const RUN_KINDS = new Set(['once', 'daily', 'weekly'])
10
- const RUNTIMES = new Set(['claude', 'codex', 'hermes'])
11
- const OUTCOMES = new Set(['delivered', 'needs_decision', 'failed', 'canceled'])
12
- const REF_TYPES = new Set(['artifact', 'preview', 'commit', 'test', 'flow_task', 'control_item'])
13
- const SECRET = /(?:sk-[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{10,}|AKIA[0-9A-Z]{16}|sbp_[a-z0-9]{20,}|eyJ[a-z0-9_-]{16,}\.[a-z0-9_-]{16,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|(?:token|secret|api[_-]?key|authorization)\s*=)/i
14
- const HOST_PATH = /(?:^|\s)(?:~\/|\/Users\/|\/home\/|\/private\/|\/tmp\/|[A-Za-z]:\\|\.\.[\\/])/i
15
- const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
16
- const OUTCOME_CODE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
17
- const formatterCache = new Map()
18
-
19
- // This is deliberately opt-in. A packaged bridge with schedules in its database
20
- // remains inert unless its owner explicitly enables unattended execution.
21
- export const scheduledRunsEnabled = (value = process.env.TP_SCHEDULED_RUNS_ENABLED) => value === '1'
22
- // Secret-free room projection. Clients need only the host owner's opt-in state,
23
- // never the scheduler's authentication, lease/fencing, or execution internals.
24
- export const scheduledRunsCapability = (value = process.env.TP_SCHEDULED_RUNS_ENABLED) => Object.freeze({
25
- enabled: scheduledRunsEnabled(value),
26
- })
27
-
28
- const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max && !SECRET.test(value) && !HOST_PATH.test(value)
29
- const date = (value) => value instanceof Date ? value : new Date(value)
30
- const validDate = (value) => !Number.isNaN(date(value).getTime())
31
- const timeParts = (zone, at) => {
32
- let formatter = formatterCache.get(zone)
33
- if (!formatter) {
34
- formatter = new Intl.DateTimeFormat('en-CA', { timeZone: zone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
35
- formatterCache.set(zone, formatter)
36
- }
37
- const parts = Object.fromEntries(formatter.formatToParts(at).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]))
38
- return { weekday: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(parts.weekday), time: `${parts.hour}:${parts.minute}` }
39
- }
40
-
41
- export function scheduleValidationError (input = {}) {
42
- if (!input || typeof input !== 'object' || !RUN_KINDS.has(input.kind)) return 'invalid_kind'
43
- if (!text(input.title, 120) || !text(input.prompt, 12_000)) return 'unsafe_or_invalid_text'
44
- if (!RUNTIMES.has(input.runtime)) return 'invalid_runtime'
45
- if (input.provider != null && (!text(input.provider, 80) || !SAFE_ID.test(input.provider))) return 'invalid_provider'
46
- if (input.model != null && (!text(input.model, 160) || !SAFE_ID.test(input.model))) return 'invalid_model'
47
- if (!Number.isInteger(input.maxRuntimeMinutes) || input.maxRuntimeMinutes < 1 || input.maxRuntimeMinutes > 30) return 'invalid_max_runtime'
48
- try { new Intl.DateTimeFormat('en', { timeZone: input.timezone }) } catch { return 'invalid_timezone' }
49
- if (input.kind === 'once' && !validDate(input.at)) return 'invalid_once_time'
50
- if (input.kind !== 'once' && !/^\d{2}:\d{2}$/.test(input.time) || input.kind !== 'once' && (Number(input.time.slice(0, 2)) > 23 || Number(input.time.slice(3)) > 59)) return 'invalid_local_time'
51
- if (input.kind === 'weekly' && (!Number.isInteger(input.weekday) || input.weekday < 0 || input.weekday > 6)) return 'invalid_weekday'
52
- return null
53
- }
54
-
55
- export function normalizeSchedule (input = {}, now = new Date()) {
56
- const error = scheduleValidationError(input)
57
- if (error) return Object.freeze({ ok: false, code: error })
58
- const normalized = {
59
- kind: input.kind, title: input.title.trim(), prompt: input.prompt.trim(), runtime: input.runtime,
60
- provider: input.provider || null, model: input.model || null, timezone: input.timezone,
61
- maxRuntimeMinutes: input.maxRuntimeMinutes, ...(input.kind === 'once' ? { at: date(input.at).toISOString() } : { time: input.time, ...(input.kind === 'weekly' ? { weekday: input.weekday } : {}) }),
62
- }
63
- const nextRunAt = nextScheduledAt(normalized, now)
64
- if (!nextRunAt) return Object.freeze({ ok: false, code: 'schedule_exhausted' })
65
- return Object.freeze({ ok: true, value: Object.freeze(normalized), nextRunAt: nextRunAt.toISOString() })
66
- }
67
-
68
- // Search minute boundaries in local calendar time. This is intentionally a small,
69
- // deterministic preset scheduler: nonexistent DST minutes are skipped, repeated minutes
70
- // choose the first future instant, and raw cron is not accepted anywhere.
71
- export function nextScheduledAt (schedule, after = new Date()) {
72
- if (schedule?.kind === 'once') {
73
- const once = date(schedule.at)
74
- return validDate(once) && once.getTime() > date(after).getTime() ? once : null
75
- }
76
- const start = Math.floor(date(after).getTime() / 60_000) * 60_000 + 60_000
77
- for (let ms = start, end = start + 400 * 86_400_000; ms < end; ms += 60_000) {
78
- const local = timeParts(schedule.timezone, new Date(ms))
79
- if (local.time === schedule.time && (schedule.kind === 'daily' || local.weekday === schedule.weekday)) return new Date(ms)
80
- }
81
- return null
82
- }
83
-
84
- export function classifyRunOutcome ({ result = null, evidenceRefs = [], pendingControls = [], runId = null, canceled = false } = {}) {
85
- // A hard runtime ceiling is the terminal outcome even if the aborted runtime
86
- // still has a pending permission or produced earlier evidence.
87
- if (result?.subtype === 'runtime_limit') return Object.freeze({ outcome: 'failed', code: 'runtime_limit' })
88
- if (canceled || ['aborted', 'canceled', 'cancelled', 'interrupted'].includes(result?.subtype)) return Object.freeze({ outcome: 'canceled', code: 'canceled' })
89
- const evidence = (Array.isArray(evidenceRefs) ? evidenceRefs : []).filter((ref) => ref && REF_TYPES.has(ref.type) && typeof ref.id === 'string' && SAFE_ID.test(ref.id)).slice(0, 16)
90
- if (evidence.length) return Object.freeze({ outcome: 'delivered', code: 'evidence_delivered', evidence })
91
- const pending = (Array.isArray(pendingControls) ? pendingControls : []).find((item) =>
92
- item?.status === 'pending' && item?.id && item?.request_context?.run_id &&
93
- (!runId || item.request_context.run_id === runId) &&
94
- Number.isSafeInteger(Number(item.source_event_from_seq)))
95
- if (pending) return Object.freeze({ outcome: 'needs_decision', code: 'pending_pair_control', controlItemId: pending.id })
96
- if (result?.subtype === 'success') return Object.freeze({ outcome: 'failed', code: 'missing_deliverable' })
97
- return Object.freeze({ outcome: 'failed', code: result?.subtype || 'runtime_failed' })
98
- }
99
-
100
- const sanitizedEvidence = (refs) => (Array.isArray(refs) ? refs : [])
101
- .filter((ref) => ref && REF_TYPES.has(ref.type) && typeof ref.id === 'string' && SAFE_ID.test(ref.id))
102
- .slice(0, 16)
103
- .map((ref) => Object.freeze({ type: ref.type, id: ref.id }))
104
-
105
- // The host outbox persists only the minimum replayable intent. Never persist
106
- // result text, permission input, prompts, credentials, endpoints, or host paths.
107
- export function sanitizeScheduledOutcomeIntent ({ runId, result = null, evidenceRefs = [], canceled = false } = {}) {
108
- if (typeof runId !== 'string' || !SAFE_ID.test(runId)) return null
109
- const subtype = typeof result?.subtype === 'string' && OUTCOME_CODE.test(result.subtype) ? result.subtype : 'runtime_failed'
110
- return Object.freeze({
111
- runId,
112
- result: Object.freeze({ subtype }),
113
- evidenceRefs: Object.freeze(sanitizedEvidence(evidenceRefs)),
114
- canceled: canceled === true,
115
- })
116
- }
117
-
118
- // A restored result/decision outbox owns the occurrence until its idempotent
119
- // database finish is acknowledged. An elapsed runtime deadline must not replace
120
- // that already-chosen outcome, and the scheduler must not expire occurrences
121
- // while any such durable intent is still awaiting reconciliation.
122
- export function scheduledRunDeadlineRequired ({ runId, deadlineAt, outcomeRecorded = false, outcomePending = null } = {}) {
123
- return Boolean(runId && Number.isFinite(deadlineAt) && !outcomeRecorded && !outcomePending)
124
- }
125
- export function scheduledOutcomeReconciliationComplete (pendingOutcomes = []) {
126
- return Array.isArray(pendingOutcomes) && pendingOutcomes.length === 0
127
- }
128
-
129
- // Scheduled runs share the ordinary spawn kill-switch, machine cap, and burst
130
- // window, but are not Ensemble dispatches and therefore never consume a plan
131
- // quota. The returned window is pruned and bounded; callers persist it only when
132
- // ok=true.
133
- export function scheduledRunSpawnAdmission ({ spawnTimes = [], now = Date.now(), totalLive = 0, disabled = false } = {}, limits = SPAWN) {
134
- const o = { ...SPAWN, ...(limits || {}) }
135
- const recent = (Array.isArray(spawnTimes) ? spawnTimes : [])
136
- .filter((value) => Number.isFinite(value) && value > now - o.windowMs)
137
- .slice(-(o.perWindowCap || 1))
138
- const gate = spawnDecision({
139
- hop: 0,
140
- spawnTimes: recent,
141
- now,
142
- spawnedLive: 0,
143
- totalLive,
144
- plan: 'free',
145
- disabled,
146
- }, o)
147
- return Object.freeze({
148
- ...gate,
149
- spawnTimes: Object.freeze(gate.ok ? [...recent, now].slice(-o.perWindowCap) : recent),
150
- })
151
- }
152
-
153
- export function preflightScheduledRun ({ schedule, occurrence, ownerId, bridgeId, bridgeFencing, providerAvailable, capsOk = true, now = new Date() } = {}) {
154
- if (!schedule?.enabled) return Object.freeze({ ok: false, code: 'schedule_disabled' })
155
- if (!occurrence?.id || occurrence.schedule_revision !== schedule.revision) return Object.freeze({ ok: false, code: 'stale_schedule_revision' })
156
- if (!ownerId || !bridgeId || !Number.isSafeInteger(bridgeFencing) || bridgeFencing < 1 || occurrence.owner_id !== ownerId || occurrence.bridge_id !== bridgeId || occurrence.bridge_fencing !== bridgeFencing) return Object.freeze({ ok: false, code: 'owner_bridge_mismatch' })
157
- if (!capsOk) return Object.freeze({ ok: false, code: 'runtime_cap_exceeded' })
158
- if (typeof providerAvailable !== 'function' || !providerAvailable(schedule)) return Object.freeze({ ok: false, code: 'provider_unavailable' })
159
- if (!occurrence.lease_expires_at || date(occurrence.lease_expires_at).getTime() <= date(now).getTime()) return Object.freeze({ ok: false, code: 'lease_expired' })
160
- return Object.freeze({ ok: true, code: 'admitted' })
161
- }
162
-
163
- const rpc = async ({ fetchImpl, supabaseUrl, headers, name, body }) => {
164
- const response = await fetchImpl(`${String(supabaseUrl).replace(/\/$/, '')}/rest/v1/rpc/${name}`, { method: 'POST', headers, body: JSON.stringify(body) })
165
- if (!response?.ok) throw new Error(`${name}_failed`)
166
- return response.json()
167
- }
168
-
169
- export async function pollDueScheduledRuns ({ enabled = false, fetchImpl = globalThis.fetch, supabaseUrl, anonKey, token, roomCode, bridgeId, bridgeFencing, ownerId, providerAvailable, openTerminal, admitRun, releaseRun, makeTerminalId = randomUUID, capsOk = () => true, now = new Date() } = {}) {
170
- if (!enabled) return Object.freeze({ ok: false, code: 'feature_disabled', runs: [] })
171
- if (typeof fetchImpl !== 'function' || !supabaseUrl || !anonKey || !token || !roomCode || !bridgeId || !Number.isSafeInteger(bridgeFencing) || bridgeFencing < 1 || !ownerId || typeof openTerminal !== 'function' || typeof admitRun !== 'function' || typeof releaseRun !== 'function' || typeof makeTerminalId !== 'function') return Object.freeze({ ok: false, code: 'runner_unavailable', runs: [] })
172
- const headers = { apikey: anonKey, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
173
- const normalizedRoomCode = String(roomCode).trim().toUpperCase()
174
- try {
175
- // An opened row can outlive its local terminal if the room process dies in
176
- // the durable-open/local-open gap. Once its lease (runtime ceiling + grace)
177
- // expires, turn that ambiguity into one explicit failure instead of leaving
178
- // the occurrence permanently "running".
179
- const expired = await rpc({ fetchImpl, supabaseUrl, headers, name: 'expire_code_scheduled_runs', body: {
180
- p_room_code: normalizedRoomCode, p_bridge_id: bridgeId, p_bridge_fencing: bridgeFencing, p_limit: 24,
181
- } })
182
- if (!expired?.ok) throw new Error('expire_code_scheduled_runs_failed')
183
- const due = await rpc({ fetchImpl, supabaseUrl, headers, name: 'list_due_code_schedules', body: {
184
- p_room_code: normalizedRoomCode, p_limit: 12,
185
- } })
186
- const runs = []
187
- for (const schedule of Array.isArray(due) ? due : []) {
188
- if (String(schedule?.session_code || '').trim().toUpperCase() !== normalizedRoomCode) {
189
- runs.push({ scheduleId: schedule?.id, code: 'room_scope_mismatch' })
190
- continue
191
- }
192
- if (!capsOk(schedule)) { runs.push({ scheduleId: schedule.id, code: 'runtime_cap_exceeded' }); continue }
193
- if (typeof providerAvailable !== 'function' || !providerAvailable(schedule)) { runs.push({ scheduleId: schedule.id, code: 'provider_unavailable' }); continue }
194
- const terminalId = makeTerminalId()
195
- if (typeof terminalId !== 'string' || !SAFE_ID.test(terminalId)) { runs.push({ scheduleId: schedule.id, code: 'terminal_id_unavailable' }); continue }
196
- const definition = { ...(schedule.schedule_spec || {}), kind: schedule.schedule_kind || schedule.kind, timezone: schedule.timezone }
197
- const next = definition.kind === 'once' ? new Date('9999-12-31T23:59:00.000Z') : nextScheduledAt(definition, new Date(schedule.next_run_at))
198
- if (!next) { runs.push({ scheduleId: schedule.id, code: 'next_run_invalid' }); continue }
199
- const occurrenceKey = `${schedule.id}:${schedule.next_run_at}`
200
- const leaseSeconds = Math.min(1_860, Math.max(60, schedule.max_runtime_minutes * 60 + 60))
201
- const deadlineAt = date(now).getTime() + leaseSeconds * 1000
202
- let admission = null
203
- try { admission = await admitRun({ schedule, runId: occurrenceKey, terminalId, deadlineAt }) } catch { admission = null }
204
- if (!admission?.ok) { runs.push({ scheduleId: schedule.id, code: admission?.code || 'spawn_not_admitted' }); continue }
205
- let leaseTransferred = false
206
- try {
207
- const claimed = await rpc({ fetchImpl, supabaseUrl, headers, name: 'claim_due_code_schedule', body: {
208
- p_schedule_id: schedule.id, p_expected_revision: schedule.revision, p_bridge_id: bridgeId,
209
- p_bridge_fencing: bridgeFencing, p_occurrence_key: occurrenceKey, p_lease_seconds: leaseSeconds,
210
- } })
211
- if (!claimed?.ok || !claimed?.run) { runs.push({ scheduleId: schedule.id, code: claimed?.code || 'claim_failed' }); continue }
212
- const admitted = preflightScheduledRun({ schedule, occurrence: claimed.run, ownerId, bridgeId, bridgeFencing, providerAvailable, capsOk: true, now })
213
- if (!admitted.ok) { runs.push({ scheduleId: schedule.id, runId: claimed.run.id, code: admitted.code }); continue }
214
- const opened = await rpc({ fetchImpl, supabaseUrl, headers, name: 'open_code_scheduled_run', body: {
215
- p_run_id: claimed.run.id, p_bridge_id: bridgeId, p_bridge_fencing: bridgeFencing, p_terminal_id: terminalId, p_next_run_at: next.toISOString(),
216
- } })
217
- if (!opened?.ok) { runs.push({ scheduleId: schedule.id, runId: claimed.run.id, code: opened?.code || 'open_not_persisted' }); continue }
218
- const openedRun = opened.run || claimed.run
219
- let started = null
220
- try { started = await openTerminal({ schedule, run: openedRun, terminalId, admission: admission.lease }) }
221
- catch { started = null }
222
- if (started) {
223
- leaseTransferred = true
224
- runs.push({ scheduleId: schedule.id, runId: claimed.run.id, code: 'opened' })
225
- continue
226
- }
227
- const failed = await rpc({ fetchImpl, supabaseUrl, headers, name: 'finish_code_scheduled_run', body: {
228
- p_run_id: claimed.run.id, p_bridge_id: bridgeId, p_bridge_fencing: bridgeFencing,
229
- p_outcome: 'failed', p_outcome_code: 'terminal_open_failed', p_evidence_refs: [], p_decision_item_id: null,
230
- } })
231
- runs.push({ scheduleId: schedule.id, runId: claimed.run.id, code: failed?.ok ? 'open_failed' : (failed?.code || 'open_failed_unrecorded') })
232
- } finally {
233
- if (!leaseTransferred) {
234
- try { await releaseRun(admission.lease) } catch { /* expiry remains the fail-closed release */ }
235
- }
236
- }
237
- }
238
- return Object.freeze({ ok: true, code: 'polled', runs: Object.freeze(runs) })
239
- } catch { return Object.freeze({ ok: false, code: 'runner_read_failed', runs: [] }) }
240
- }
241
-
242
- export async function finishScheduledRun ({ fetchImpl = globalThis.fetch, supabaseUrl, anonKey, token, roomCode, runId, bridgeId, bridgeFencing, result, evidenceRefs, canceled = false } = {}) {
243
- if (!supabaseUrl || !anonKey || !token || !roomCode || !runId || !bridgeId || !Number.isSafeInteger(bridgeFencing) || bridgeFencing < 1) return Object.freeze({ ok: false, code: 'outcome_unavailable' })
244
- const headers = { apikey: anonKey, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
245
- try {
246
- const pendingControls = await rpc({ fetchImpl, supabaseUrl, headers, name: 'list_code_pair_controls', body: { p_session_code: String(roomCode).toUpperCase(), p_status: 'pending', p_limit: 100 } })
247
- const outcome = classifyRunOutcome({ result, evidenceRefs, pendingControls, runId, canceled })
248
- const written = await rpc({ fetchImpl, supabaseUrl, headers, name: 'finish_code_scheduled_run', body: {
249
- p_run_id: runId, p_outcome: outcome.outcome, p_outcome_code: outcome.code,
250
- p_bridge_id: bridgeId, p_bridge_fencing: bridgeFencing,
251
- p_evidence_refs: outcome.evidence || [], p_decision_item_id: outcome.controlItemId || null,
252
- } })
253
- return Object.freeze({ ok: Boolean(written?.ok), code: written?.code || 'outcome_write_failed', outcome })
254
- } catch { return Object.freeze({ ok: false, code: 'outcome_write_failed' }) }
255
- }
256
-
257
- export async function finishScheduledRunWithRetry (args = {}, { attempts = 3, delays = [100, 400], sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
258
- const count = Math.max(1, Math.min(5, Number.isInteger(attempts) ? attempts : 3))
259
- let last = Object.freeze({ ok: false, code: 'outcome_write_failed' })
260
- for (let attempt = 0; attempt < count; attempt++) {
261
- last = await finishScheduledRun(args)
262
- if (last.ok) return last
263
- if (attempt + 1 < count) await sleep(Math.max(0, Math.min(5000, Number(delays[attempt] ?? delays.at(-1) ?? 0) || 0)))
264
- }
265
- return last
266
- }
267
-
268
- export { OUTCOMES }
1
+ import{randomUUID as e}from"node:crypto";import{SPAWN as t,spawnDecision as n}from"./cross-terminal.mjs";const i=new Set(["once","daily","weekly"]),r=new Set(["claude","codex","hermes"]),d=new Set(["delivered","needs_decision","failed","canceled"]),o=new Set(["artifact","preview","commit","test","flow_task","control_item"]),c=/(?:sk-[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{10,}|AKIA[0-9A-Z]{16}|sbp_[a-z0-9]{20,}|eyJ[a-z0-9_-]{16,}\.[a-z0-9_-]{16,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|(?:token|secret|api[_-]?key|authorization)\s*=)/i,a=/(?:^|\s)(?:~\/|\/Users\/|\/home\/|\/private\/|\/tmp\/|[A-Za-z]:\\|\.\.[\\/])/i,u=/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/,s=/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/,l=new Map;export const scheduledRunsEnabled=(e=process.env.TP_SCHEDULED_RUNS_ENABLED)=>"1"===e;export const scheduledRunsCapability=(e=process.env.TP_SCHEDULED_RUNS_ENABLED)=>Object.freeze({enabled:scheduledRunsEnabled(e)});const p=(e,t)=>"string"==typeof e&&e.trim().length>0&&e.length<=t&&!c.test(e)&&!a.test(e),_=e=>e instanceof Date?e:new Date(e),m=e=>!Number.isNaN(_(e).getTime()),f=(e,t)=>{let n=l.get(e);n||(n=new Intl.DateTimeFormat("en-CA",{timeZone:e,weekday:"short",hour:"2-digit",minute:"2-digit",hourCycle:"h23"}),l.set(e,n));const i=Object.fromEntries(n.formatToParts(t).filter(e=>"literal"!==e.type).map(e=>[e.type,e.value]));return{weekday:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"].indexOf(i.weekday),time:`${i.hour}:${i.minute}`}};export function scheduleValidationError(e={}){if(!e||"object"!=typeof e||!i.has(e.kind))return"invalid_kind";if(!p(e.title,120)||!p(e.prompt,12e3))return"unsafe_or_invalid_text";if(!r.has(e.runtime))return"invalid_runtime";if(!(null==e.provider||p(e.provider,80)&&u.test(e.provider)))return"invalid_provider";if(!(null==e.model||p(e.model,160)&&u.test(e.model)))return"invalid_model";if(!Number.isInteger(e.maxRuntimeMinutes)||e.maxRuntimeMinutes<1||e.maxRuntimeMinutes>30)return"invalid_max_runtime";try{new Intl.DateTimeFormat("en",{timeZone:e.timezone})}catch{return"invalid_timezone"}return"once"!==e.kind||m(e.at)?"once"!==e.kind&&!/^\d{2}:\d{2}$/.test(e.time)||"once"!==e.kind&&(Number(e.time.slice(0,2))>23||Number(e.time.slice(3))>59)?"invalid_local_time":"weekly"===e.kind&&(!Number.isInteger(e.weekday)||e.weekday<0||e.weekday>6)?"invalid_weekday":null:"invalid_once_time"}export function normalizeSchedule(e={},t=new Date){const n=scheduleValidationError(e);if(n)return Object.freeze({ok:!1,code:n});const i={kind:e.kind,title:e.title.trim(),prompt:e.prompt.trim(),runtime:e.runtime,provider:e.provider||null,model:e.model||null,timezone:e.timezone,maxRuntimeMinutes:e.maxRuntimeMinutes,..."once"===e.kind?{at:_(e.at).toISOString()}:{time:e.time,..."weekly"===e.kind?{weekday:e.weekday}:{}}},r=nextScheduledAt(i,t);return r?Object.freeze({ok:!0,value:Object.freeze(i),nextRunAt:r.toISOString()}):Object.freeze({ok:!1,code:"schedule_exhausted"})}export function nextScheduledAt(e,t=new Date){if("once"===e?.kind){const n=_(e.at);return m(n)&&n.getTime()>_(t).getTime()?n:null}const n=6e4*Math.floor(_(t).getTime()/6e4)+6e4;for(let t=n,i=n+3456e7;t<i;t+=6e4){const n=f(e.timezone,new Date(t));if(n.time===e.time&&("daily"===e.kind||n.weekday===e.weekday))return new Date(t)}return null}export function classifyRunOutcome({result:e=null,evidenceRefs:t=[],pendingControls:n=[],runId:i=null,canceled:r=!1}={}){if("runtime_limit"===e?.subtype)return Object.freeze({outcome:"failed",code:"runtime_limit"});if(r||["aborted","canceled","cancelled","interrupted"].includes(e?.subtype))return Object.freeze({outcome:"canceled",code:"canceled"});const d=(Array.isArray(t)?t:[]).filter(e=>e&&o.has(e.type)&&"string"==typeof e.id&&u.test(e.id)).slice(0,16);if(d.length)return Object.freeze({outcome:"delivered",code:"evidence_delivered",evidence:d});const c=(Array.isArray(n)?n:[]).find(e=>"pending"===e?.status&&e?.id&&e?.request_context?.run_id&&(!i||e.request_context.run_id===i)&&Number.isSafeInteger(Number(e.source_event_from_seq)));return c?Object.freeze({outcome:"needs_decision",code:"pending_pair_control",controlItemId:c.id}):"success"===e?.subtype?Object.freeze({outcome:"failed",code:"missing_deliverable"}):Object.freeze({outcome:"failed",code:e?.subtype||"runtime_failed"})}export function sanitizeScheduledOutcomeIntent({runId:e,result:t=null,evidenceRefs:n=[],canceled:i=!1}={}){if("string"!=typeof e||!u.test(e))return null;const r="string"==typeof t?.subtype&&s.test(t.subtype)?t.subtype:"runtime_failed";return Object.freeze({runId:e,result:Object.freeze({subtype:r}),evidenceRefs:Object.freeze((d=n,(Array.isArray(d)?d:[]).filter(e=>e&&o.has(e.type)&&"string"==typeof e.id&&u.test(e.id)).slice(0,16).map(e=>Object.freeze({type:e.type,id:e.id})))),canceled:!0===i});var d}export function scheduledRunDeadlineRequired({runId:e,deadlineAt:t,outcomeRecorded:n=!1,outcomePending:i=null}={}){return Boolean(e&&Number.isFinite(t)&&!n&&!i)}export function scheduledOutcomeReconciliationComplete(e=[]){return Array.isArray(e)&&0===e.length}export function scheduledRunSpawnAdmission({spawnTimes:e=[],now:i=Date.now(),totalLive:r=0,disabled:d=!1}={},o=t){const c={...t,...o||{}},a=(Array.isArray(e)?e:[]).filter(e=>Number.isFinite(e)&&e>i-c.windowMs).slice(-(c.perWindowCap||1)),u=n({hop:0,spawnTimes:a,now:i,spawnedLive:0,totalLive:r,plan:"free",disabled:d},c);return Object.freeze({...u,spawnTimes:Object.freeze(u.ok?[...a,i].slice(-c.perWindowCap):a)})}export function preflightScheduledRun({schedule:e,occurrence:t,ownerId:n,bridgeId:i,bridgeFencing:r,providerAvailable:d,capsOk:o=!0,now:c=new Date}={}){return e?.enabled?t?.id&&t.schedule_revision===e.revision?!n||!i||!Number.isSafeInteger(r)||r<1||t.owner_id!==n||t.bridge_id!==i||t.bridge_fencing!==r?Object.freeze({ok:!1,code:"owner_bridge_mismatch"}):o?"function"==typeof d&&d(e)?!t.lease_expires_at||_(t.lease_expires_at).getTime()<=_(c).getTime()?Object.freeze({ok:!1,code:"lease_expired"}):Object.freeze({ok:!0,code:"admitted"}):Object.freeze({ok:!1,code:"provider_unavailable"}):Object.freeze({ok:!1,code:"runtime_cap_exceeded"}):Object.freeze({ok:!1,code:"stale_schedule_revision"}):Object.freeze({ok:!1,code:"schedule_disabled"})}const h=async({fetchImpl:e,supabaseUrl:t,headers:n,name:i,body:r})=>{const d=await e(`${String(t).replace(/\/$/,"")}/rest/v1/rpc/${i}`,{method:"POST",headers:n,body:JSON.stringify(r)});if(!d?.ok)throw new Error(`${i}_failed`);return d.json()};export async function pollDueScheduledRuns({enabled:t=!1,fetchImpl:n=globalThis.fetch,supabaseUrl:i,anonKey:r,token:d,roomCode:o,bridgeId:c,bridgeFencing:a,ownerId:s,providerAvailable:l,openTerminal:p,admitRun:m,releaseRun:f,makeTerminalId:b=e,capsOk:y=()=>!0,now:g=new Date}={}){if(!t)return Object.freeze({ok:!1,code:"feature_disabled",runs:[]});if(!("function"==typeof n&&i&&r&&d&&o&&c&&Number.isSafeInteger(a))||a<1||!s||"function"!=typeof p||"function"!=typeof m||"function"!=typeof f||"function"!=typeof b)return Object.freeze({ok:!1,code:"runner_unavailable",runs:[]});const w={apikey:r,Authorization:`Bearer ${d}`,"Content-Type":"application/json"},k=String(o).trim().toUpperCase();try{const e=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"expire_code_scheduled_runs",body:{p_room_code:k,p_bridge_id:c,p_bridge_fencing:a,p_limit:24}});if(!e?.ok)throw new Error("expire_code_scheduled_runs_failed");const t=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"list_due_code_schedules",body:{p_room_code:k,p_limit:12}}),r=[];for(const e of Array.isArray(t)?t:[]){if(String(e?.session_code||"").trim().toUpperCase()!==k){r.push({scheduleId:e?.id,code:"room_scope_mismatch"});continue}if(!y(e)){r.push({scheduleId:e.id,code:"runtime_cap_exceeded"});continue}if("function"!=typeof l||!l(e)){r.push({scheduleId:e.id,code:"provider_unavailable"});continue}const t=b();if("string"!=typeof t||!u.test(t)){r.push({scheduleId:e.id,code:"terminal_id_unavailable"});continue}const d={...e.schedule_spec||{},kind:e.schedule_kind||e.kind,timezone:e.timezone},o="once"===d.kind?new Date("9999-12-31T23:59:00.000Z"):nextScheduledAt(d,new Date(e.next_run_at));if(!o){r.push({scheduleId:e.id,code:"next_run_invalid"});continue}const z=`${e.id}:${e.next_run_at}`,v=Math.min(1860,Math.max(60,60*e.max_runtime_minutes+60)),I=_(g).getTime()+1e3*v;let x=null;try{x=await m({schedule:e,runId:z,terminalId:t,deadlineAt:I})}catch{x=null}if(!x?.ok){r.push({scheduleId:e.id,code:x?.code||"spawn_not_admitted"});continue}let O=!1;try{const d=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"claim_due_code_schedule",body:{p_schedule_id:e.id,p_expected_revision:e.revision,p_bridge_id:c,p_bridge_fencing:a,p_occurrence_key:z,p_lease_seconds:v}});if(!d?.ok||!d?.run){r.push({scheduleId:e.id,code:d?.code||"claim_failed"});continue}const u=preflightScheduledRun({schedule:e,occurrence:d.run,ownerId:s,bridgeId:c,bridgeFencing:a,providerAvailable:l,capsOk:!0,now:g});if(!u.ok){r.push({scheduleId:e.id,runId:d.run.id,code:u.code});continue}const _=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"open_code_scheduled_run",body:{p_run_id:d.run.id,p_bridge_id:c,p_bridge_fencing:a,p_terminal_id:t,p_next_run_at:o.toISOString()}});if(!_?.ok){r.push({scheduleId:e.id,runId:d.run.id,code:_?.code||"open_not_persisted"});continue}const m=_.run||d.run;let f=null;try{f=await p({schedule:e,run:m,terminalId:t,admission:x.lease})}catch{f=null}if(f){O=!0,r.push({scheduleId:e.id,runId:d.run.id,code:"opened"});continue}const b=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"finish_code_scheduled_run",body:{p_run_id:d.run.id,p_bridge_id:c,p_bridge_fencing:a,p_outcome:"failed",p_outcome_code:"terminal_open_failed",p_evidence_refs:[],p_decision_item_id:null}});r.push({scheduleId:e.id,runId:d.run.id,code:b?.ok?"open_failed":b?.code||"open_failed_unrecorded"})}finally{if(!O)try{await f(x.lease)}catch{}}}return Object.freeze({ok:!0,code:"polled",runs:Object.freeze(r)})}catch{return Object.freeze({ok:!1,code:"runner_read_failed",runs:[]})}}export async function finishScheduledRun({fetchImpl:e=globalThis.fetch,supabaseUrl:t,anonKey:n,token:i,roomCode:r,runId:d,bridgeId:o,bridgeFencing:c,result:a,evidenceRefs:u,canceled:s=!1}={}){if(!(t&&n&&i&&r&&d&&o&&Number.isSafeInteger(c))||c<1)return Object.freeze({ok:!1,code:"outcome_unavailable"});const l={apikey:n,Authorization:`Bearer ${i}`,"Content-Type":"application/json"};try{const n=classifyRunOutcome({result:a,evidenceRefs:u,pendingControls:await h({fetchImpl:e,supabaseUrl:t,headers:l,name:"list_code_pair_controls",body:{p_session_code:String(r).toUpperCase(),p_status:"pending",p_limit:100}}),runId:d,canceled:s}),i=await h({fetchImpl:e,supabaseUrl:t,headers:l,name:"finish_code_scheduled_run",body:{p_run_id:d,p_outcome:n.outcome,p_outcome_code:n.code,p_bridge_id:o,p_bridge_fencing:c,p_evidence_refs:n.evidence||[],p_decision_item_id:n.controlItemId||null}});return Object.freeze({ok:Boolean(i?.ok),code:i?.code||"outcome_write_failed",outcome:n})}catch{return Object.freeze({ok:!1,code:"outcome_write_failed"})}}export async function finishScheduledRunWithRetry(e={},{attempts:t=3,delays:n=[100,400],sleep:i=e=>new Promise(t=>setTimeout(t,e))}={}){const r=Math.max(1,Math.min(5,Number.isInteger(t)?t:3));let d=Object.freeze({ok:!1,code:"outcome_write_failed"});for(let t=0;t<r;t++){if(d=await finishScheduledRun(e),d.ok)return d;t+1<r&&await i(Math.max(0,Math.min(5e3,Number(n[t]??n.at(-1)??0)||0)))}return d}export{d as OUTCOMES};
package/sdk-admission.mjs CHANGED
@@ -1,9 +1 @@
1
- // SDK checks protect agent-serving room processes only. Account/service commands
2
- // remain available so an operator can inspect or repair a bad SDK installation.
3
- export function requiresSdkAdmission(argv) {
4
- return Boolean(argv?.[0]) && argv[0] !== 'verify-sdk'
5
- }
6
-
7
- export function sdkSmokePassed(output) {
8
- return /^SMOKE:PASS:[^:\n]+(?:\n|$)/.test(String(output || ''))
9
- }
1
+ export function requiresSdkAdmission(e){return Boolean(e?.[0])&&"verify-sdk"!==e[0]}export function sdkSmokePassed(e){return/^SMOKE:PASS:[^:\n]+(?:\n|$)/.test(String(e||""))}
package/sdk-smoke.mjs CHANGED
@@ -1,61 +1 @@
1
- // sdk-smoke.mjs — agent-SDK compatibility smoke test.
2
- //
3
- // The bridge tracks @anthropic-ai/claude-agent-sdk on a caret (^0.3.x), so a
4
- // restart can auto-pull a NEW SDK. Our own history (feedback_bridge_sdk_pin_no_bundle)
5
- // is that the SDK's interrupt/turn API has broken between 0.3.x minors. This verifies
6
- // the API SURFACE the bridge depends on — BEFORE the bridge commits to serving with a
7
- // freshly-pulled SDK — so a bad one is caught + surfaced instead of silently breaking
8
- // every turn.
9
- //
10
- // Deliberately CHEAP + OFFLINE: no Claude key, no network, no token spend. It only
11
- // checks that the SDK imports, query() constructs, and the control methods the bridge
12
- // calls (interrupt / setModel / setPermissionMode / supportedModels) exist as
13
- // functions. It does NOT run a real turn (that would cost tokens + need auth). A
14
- // surface break — a removed/renamed method — is the failure mode this guards; a subtle
15
- // behavioural break still needs a live turn (out of scope for a boot gate).
16
- //
17
- // Runs as a SHORT-LIVED SUBPROCESS with a hard timeout, so a hung SDK import/construct
18
- // = FAIL, never a hung bridge. One line to stdout: `SMOKE:PASS:<version>` /
19
- // `SMOKE:FAIL:<version>:<reason>`. Exit 0 = pass, 1 = incompatible, 2 = timeout.
20
-
21
- import { createRequire } from 'node:module'
22
- import { readFileSync } from 'node:fs'
23
- import { dirname, join } from 'node:path'
24
-
25
- const HARD_TIMEOUT_MS = Math.max(4000, parseInt(process.env.TP_SDK_SMOKE_TIMEOUT, 10) || 12000)
26
- const guard = setTimeout(() => { emit('FAIL', ver(), 'timeout importing/constructing the SDK'); process.exit(2) }, HARD_TIMEOUT_MS)
27
-
28
- function emit(status, version, reason) {
29
- try { process.stdout.write(`SMOKE:${status}:${version || 'unknown'}${reason ? ':' + reason : ''}\n`) } catch { /* noop */ }
30
- }
31
- // Resolve the SDK's real version from wherever node just resolved the package (the
32
- // caret pick), by walking up from its entry to the package.json with the right name.
33
- function ver() {
34
- try {
35
- const require = createRequire(import.meta.url)
36
- let d = dirname(require.resolve('@anthropic-ai/claude-agent-sdk'))
37
- for (let i = 0; i < 8; i++) {
38
- try { const p = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (p.name === '@anthropic-ai/claude-agent-sdk') return p.version } catch { /* keep walking */ }
39
- const up = dirname(d); if (up === d) break; d = up
40
- }
41
- } catch { /* unresolved */ }
42
- return 'unknown'
43
- }
44
-
45
- const version = ver()
46
- try {
47
- const sdk = await import('@anthropic-ai/claude-agent-sdk')
48
- if (typeof sdk.query !== 'function') { emit('FAIL', version, 'query is not a function'); clearTimeout(guard); process.exit(1) }
49
- // eslint-disable-next-line require-yield -- deliberately empty: an async generator that ends immediately (the SDK's "no input" stream), never yields.
50
- async function* noInput() { return } // ends immediately — never blocks the construct
51
- const q = sdk.query({ prompt: noInput(), options: { permissionMode: 'default' } })
52
- const need = ['interrupt', 'setModel', 'setPermissionMode', 'supportedModels']
53
- const missing = need.filter((m) => typeof q?.[m] !== 'function')
54
- try { q?.interrupt?.() } catch { /* don't leave it running */ }
55
- if (missing.length) { emit('FAIL', version, `missing methods: ${missing.join('/')}`); clearTimeout(guard); process.exit(1) }
56
- emit('PASS', version)
57
- clearTimeout(guard); process.exit(0)
58
- } catch (e) {
59
- emit('FAIL', version, `import/construct threw: ${(e?.message || e).toString().slice(0, 120)}`)
60
- clearTimeout(guard); process.exit(1)
61
- }
1
+ import{createRequire as t}from"node:module";import{readFileSync as e}from"node:fs";import{dirname as o,join as n}from"node:path";const r=Math.max(4e3,parseInt(process.env.TP_SDK_SMOKE_TIMEOUT,10)||12e3),i=setTimeout(()=>{s("FAIL",c(),"timeout importing/constructing the SDK"),process.exit(2)},r);function s(t,e,o){try{process.stdout.write(`SMOKE:${t}:${e||"unknown"}${o?":"+o:""}\n`)}catch{}}function c(){try{const r=t(import.meta.url);let i=o(r.resolve("@anthropic-ai/claude-agent-sdk"));for(let t=0;t<8;t++){try{const t=JSON.parse(e(n(i,"package.json"),"utf8"));if("@anthropic-ai/claude-agent-sdk"===t.name)return t.version}catch{}const t=o(i);if(t===i)break;i=t}}catch{}return"unknown"}const a=c();try{const u=await import("@anthropic-ai/claude-agent-sdk");async function*p(){}"function"!=typeof u.query&&(s("FAIL",a,"query is not a function"),clearTimeout(i),process.exit(1));const m=u.query({prompt:p(),options:{permissionMode:"default"}}),d=["interrupt","setModel","setPermissionMode","supportedModels"].filter(t=>"function"!=typeof m?.[t]);try{m?.interrupt?.()}catch{}d.length&&(s("FAIL",a,`missing methods: ${d.join("/")}`),clearTimeout(i),process.exit(1)),s("PASS",a),clearTimeout(i),process.exit(0)}catch(f){s("FAIL",a,`import/construct threw: ${(f?.message||f).toString().slice(0,120)}`),clearTimeout(i),process.exit(1)}
package/serve-consent.mjs CHANGED
@@ -1,118 +1 @@
1
- // Room-serve owner consent — decision core (pure) + row fetch.
2
- //
3
- // Spec: docs/specs/2026-07-06-remove-cross-person-serve.md (Contract C-CODE-3, evolved).
4
- // A room is served by its OWNER's bridge ONLY. No cross-person serving, no grantee
5
- // path (removed 2026-07-06 — the cross-person grant was a recipe for the wrong-session
6
- // fuckery). The owner's own bridge on a second machine is handled by the per-account
7
- // claim_bridge, not here. Pairing stays read-only (peer peek across the pair).
8
- //
9
- // This module holds ONLY pure, order-independent decisions (unit-tested in
10
- // bridge/test-serve-consent.mjs) plus a single network helper. The wiring — where the
11
- // gate runs — lives in bridge.mjs. Keeping the logic pure is the
12
- // feedback_code_seed_order_independent discipline: no decision depends on live
13
- // channel/session state, so every branch is testable off-wire.
14
- //
15
- // LIMITATION (documented): enforcement is in-bridge. A bridge OLDER than this version
16
- // may still honor a grant — there is no server-side wire-level lock. The set_serve_grant
17
- // RPC + serve_grant_uid column were dropped server-side in the same 2026-07-06 migration,
18
- // so even an old bridge can no longer READ a grant (the column is gone) and will refuse
19
- // as a non-owner. That closes the bypass.
20
-
21
- import { pairCli } from './command-guidance.mjs'
22
-
23
- // serveDecision — may THIS bridge serve THIS room?
24
- // ownerId : code_sessions.owner_id (uuid | null)
25
- // myUid : this bridge's authed user id (uuid | null) — null when the bridge is anon
26
- // Returns { serve, reason }:
27
- // reason 'owner' — myUid owns the room (the only serve path)
28
- // reason 'no-identity' — bridge has no authed uid → refuse (no anon serve path)
29
- // reason 'not-owner' — authed, but not the owner → refuse
30
- // reason 'unknown-room'— no owner_id resolved (row missing / RLS-hidden) → refuse
31
- export function serveDecision ({ ownerId, myUid }) {
32
- if (!myUid) return { serve: false, reason: 'no-identity' }
33
- if (!ownerId) return { serve: false, reason: 'unknown-room' }
34
- if (myUid === ownerId) return { serve: true, reason: 'owner' }
35
- return { serve: false, reason: 'not-owner' }
36
- }
37
-
38
- // supervisorServes — the account supervisor's per-row discovery decision: may this
39
- // login auto-serve THIS discovered room? True iff the login OWNS the room. This is the
40
- // defense-in-depth per-row check behind the supervisor's owner-only `.eq(owner_id, me)`
41
- // query — a broad RLS row (owned OR merely participant) must still resolve to OWNED
42
- // before a child is spawned. Contract C-CODE-2 (owner-only).
43
- export function supervisorServes ({ ownerId, myUid, closedAt = null }) {
44
- if (!myUid) return false
45
- return ownerId === myUid && !closedAt
46
- }
47
-
48
- // supervisorRoomsToStop — reconcile the long-lived child map after a SUCCESSFUL
49
- // discovery read. Closing, deleting, or transferring a room removes it from the
50
- // active-owned result set; any existing child for that room must be retired even
51
- // when the best-effort `session-deleted` realtime broadcast was missed.
52
- //
53
- // This stays pure so a transient discovery failure can be handled by the caller:
54
- // account.mjs invokes it only after Supabase returned without an error. On a failed
55
- // fetch the existing children stay alive and the next tick retries.
56
- export function supervisorRoomsToStop ({ rooms, childRooms, myUid }) {
57
- const activeOwned = new Set((rooms || [])
58
- .filter((room) => supervisorServes({
59
- ownerId: room?.owner_id,
60
- myUid,
61
- closedAt: room?.closed_at,
62
- }))
63
- .map((room) => room.code)
64
- .filter(Boolean))
65
- return [...(childRooms || [])].filter((code) => !activeOwned.has(code))
66
- }
67
-
68
- // fetchServeRow — read the room's owner with whatever identity the bridge holds
69
- // (authed token → RLS returns the row if the user is a member; anon → empty). THROWS on
70
- // a non-OK response or a transport error. The thrown error carries `.authHard` so the
71
- // caller can tell the two apart (Contract C-CODE-3 invariant 2, hardened 2026-07-05):
72
- // - 401/403 (authHard === true) → auth is DEFINITIVELY bad (dead/expired token). The
73
- // caller REFUSES: a stale access token must never fail OPEN into serving, or the gate
74
- // is bypassed in the common case (stored tokens expire ~1h). The caller refreshes the
75
- // token BEFORE the gate, so a 401 here means the refresh also failed → genuinely unauthed.
76
- // - 5xx / genuine fetch() reject (authHard === false) → availability failure. The caller
77
- // fails OPEN (never strand a user on a flaky network / a Supabase blip).
78
- // A clean HTTP response that simply yields no row is NOT a throw — it returns
79
- // { ownerId: null } and the caller refuses (unknown room).
80
- export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
81
- const headers = { apikey: anonKey }
82
- if (token) headers.Authorization = `Bearer ${token}`
83
- const r = await fetch(
84
- `${supabaseUrl}/rest/v1/code_sessions?select=owner_id,name&code=eq.${encodeURIComponent(room)}`,
85
- { headers },
86
- )
87
- if (!r.ok) {
88
- const err = new Error(`code_sessions fetch ${r.status}`)
89
- err.status = r.status
90
- err.authHard = r.status === 401 || r.status === 403
91
- throw err
92
- }
93
- const rows = await r.json()
94
- const row = Array.isArray(rows) ? rows[0] : null
95
- return {
96
- ownerId: row?.owner_id ?? null,
97
- name: row?.name ?? null,
98
- }
99
- }
100
-
101
- // gateFailAction — pure classifier for a THROWN fetchServeRow error at the boot gate.
102
- // 'refuse' — auth is definitively bad (401/403 after a token refresh); do NOT fail open.
103
- // 'fail-open' — an availability error (5xx, offline, transport reject); serve rather than
104
- // strand a user on a flaky fetch. This is the ONLY fail-open path.
105
- export function gateFailAction (err) {
106
- return err?.authHard ? 'refuse' : 'fail-open'
107
- }
108
-
109
- // refusalMessage — the human-facing stderr line when a bridge refuses to serve.
110
- export function refusalMessage ({ room, name, reason }) {
111
- const label = name ? `"${name}"` : '(unnamed)'
112
- if (reason === 'no-identity') {
113
- return `\n ◇ Refusing to serve room ${room} — this bridge is not signed in.\n` +
114
- ` A room is served only by its owner's bridge. Sign in first: ${pairCli('login')}\n`
115
- }
116
- return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; only their bridge serves it.\n` +
117
- ` Pairing gives you a read-only view across the pair, not hosting rights. There is no override.\n`
118
- }
1
+ import{pairCli as e}from"./command-guidance.mjs";export function serveDecision({ownerId:e,myUid:o}){return o?e?o===e?{serve:!0,reason:"owner"}:{serve:!1,reason:"not-owner"}:{serve:!1,reason:"unknown-room"}:{serve:!1,reason:"no-identity"}}export function supervisorServes({ownerId:e,myUid:o,closedAt:n=null}){return!!o&&e===o&&!n}export function supervisorRoomsToStop({rooms:e,childRooms:o,myUid:n}){const r=new Set((e||[]).filter(e=>supervisorServes({ownerId:e?.owner_id,myUid:n,closedAt:e?.closed_at})).map(e=>e.code).filter(Boolean));return[...o||[]].filter(e=>!r.has(e))}export async function fetchServeRow({supabaseUrl:e,anonKey:o,token:n,room:r}){const s={apikey:o};n&&(s.Authorization=`Bearer ${n}`);const t=await fetch(`${e}/rest/v1/code_sessions?select=owner_id,name&code=eq.${encodeURIComponent(r)}`,{headers:s});if(!t.ok){const e=new Error(`code_sessions fetch ${t.status}`);throw e.status=t.status,e.authHard=401===t.status||403===t.status,e}const i=await t.json(),a=Array.isArray(i)?i[0]:null;return{ownerId:a?.owner_id??null,name:a?.name??null}}export function gateFailAction(e){return e?.authHard?"refuse":"fail-open"}export function refusalMessage({room:o,name:n,reason:r}){const s=n?`"${n}"`:"(unnamed)";return"no-identity"===r?`\n ◇ Refusing to serve room ${o} — this bridge is not signed in.\n A room is served only by its owner's bridge. Sign in first: ${e("login")}\n`:`\n ◇ Refusing to serve room ${o} ${s} — it is owned by another person; only their bridge serves it.\n Pairing gives you a read-only view across the pair, not hosting rights. There is no override.\n`}
package/serve-dir.mjs CHANGED
@@ -1,40 +1 @@
1
- /* serve-dir.mjs — decide which working directory the account supervisor serves a
2
- room from, and REFUSE the home-directory trap.
3
-
4
- Why this exists (FVIHV1DE, 2026-06-30): the supervisor auto-serves an unbound room
5
- from its own launch dir (DEFAULT_DIR = process.cwd()). When the supervisor is
6
- relaunched from $HOME instead of the repo, every unbound room is served from $HOME.
7
- That silently breaks two things:
8
- 1. The agent's cwd becomes ~, so the Claude SDK (which keys a session's transcript
9
- by project directory) can't find the session that was created under the real
10
- repo → resume fails → the agent is BLIND to its own history (the UI still replays
11
- the persisted ThinkPool log, so "I see the chat but the agent can't").
12
- 2. The room's repo label is basename(cwd) = your home folder name ("max"/"conrad")
13
- instead of "thinkpool" / a branch.
14
- Loud refusal beats silent context corruption. Precedence:
15
- explicit bind > last-served memory > the supervisor's launch dir
16
- and an UNBOUND, never-served room is refused when the launch dir IS $HOME.
17
- Pure (no fs/process) so it unit-tests without spawning bridges. */
18
- import path from 'node:path'
19
-
20
- const samePath = (a, b) => {
21
- try { return !!a && !!b && path.resolve(a) === path.resolve(b) } catch { return false }
22
- }
23
-
24
- // Returns { dir, refuse, reason, fromDefault }.
25
- // - dir: the directory to serve from (even when refusing, for the message)
26
- // - refuse: true → do NOT serve; warn the user to bind instead
27
- // - reason: 'home' | 'none' when refusing
28
- // - fromDefault:true → resolved to the supervisor's launch dir with no bind/memory
29
- // (the caller should remember it after a successful serve, so a later
30
- // relaunch-from-home resumes the room from this same repo)
31
- export function resolveServeDir({ bound, remembered, defaultDir, home } = {}) {
32
- const pick = bound || remembered || defaultDir || null
33
- if (!pick) return { dir: null, refuse: true, reason: 'none' }
34
- // Only the AUTO path (no explicit bind, no remembered dir) is guarded — if a person
35
- // deliberately bound a room to their home dir, that's their call, honour it.
36
- if (!bound && !remembered && samePath(pick, home)) {
37
- return { dir: pick, refuse: true, reason: 'home' }
38
- }
39
- return { dir: pick, refuse: false, fromDefault: !bound && !remembered }
40
- }
1
+ import e from"node:path";export function resolveServeDir({bound:r,remembered:o,defaultDir:n,home:t}={}){const u=r||o||n||null;return u?r||o||!((r,o)=>{try{return!!r&&!!o&&e.resolve(r)===e.resolve(o)}catch{return!1}})(u,t)?{dir:u,refuse:!1,fromDefault:!r&&!o}:{dir:u,refuse:!0,reason:"home"}:{dir:null,refuse:!0,reason:"none"}}