thinkpool-pair 0.7.357 → 0.7.359

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.
@@ -0,0 +1,268 @@
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 }
package/service.mjs CHANGED
@@ -26,6 +26,7 @@ import { hostMemoryAdmission } from './host-memory.mjs'
26
26
  import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
27
27
  import { pairCli } from './command-guidance.mjs'
28
28
  import { loadKeepAwakePreference } from './keep-awake.mjs'
29
+ import { scheduledRunsEnabled } from './scheduled-runs.mjs'
29
30
 
30
31
  // Service identity. Account mode has no room → a single stable id so there's
31
32
  // exactly one account service per machine (a second install replaces it).
@@ -131,7 +132,7 @@ export function provisionRuntime(version, { exec = execSync, root = path.join(os
131
132
 
132
133
  // Pure artifact builder — returned shape is testable without side effects.
133
134
  // room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
134
- export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false, runtimeEntry = null } = {}) {
135
+ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false, runtimeEntry = null, scheduledRuns = scheduledRunsEnabled() } = {}) {
135
136
  const npx = npxPath(platform)
136
137
  // Account mode discovers each room's own bound directory, so its supervisor must use
137
138
  // a stable home cwd. Persisting the caller's managed worktree here makes the service
@@ -161,6 +162,11 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
161
162
  // In-process self-update (account.mjs / bridge.mjs) is gated on this env — only on when
162
163
  // the user explicitly opted into auto-update.
163
164
  const autoUpdateEnv = autoUpdate ? { THINKPOOL_PAIR_AUTOUPDATE: '1' } : {}
165
+ // Persistent services rebuild a deliberately tiny environment. Preserve this
166
+ // activation only as the exact boolean true; arbitrary caller values are omitted.
167
+ const scheduledRunsEnv = scheduledRuns === true
168
+ ? { darwin: '<key>TP_SCHEDULED_RUNS_ENABLED</key><string>1</string>', linux: 'Environment=TP_SCHEDULED_RUNS_ENABLED=1\n', win32: 'set "TP_SCHEDULED_RUNS_ENABLED=1"\r\n' }
169
+ : { darwin: '', linux: '', win32: '' }
164
170
 
165
171
  if (platform === 'darwin') {
166
172
  // launchd KeepAlive supervises. Stable runtimes execute directly; the legacy npx
@@ -187,7 +193,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
187
193
  <key>WorkingDirectory</key><string>${xml(cwd)}</string>
188
194
  <key>StandardOutPath</key><string>${xml(log)}</string>
189
195
  <key>StandardErrorPath</key><string>${xml(log)}</string>
190
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${runtimeEntry ? '' : `<key>NPM_CONFIG_CACHE</key><string>${xml(npmCache)}</string>`}${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
196
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${runtimeEntry ? '' : `<key>NPM_CONFIG_CACHE</key><string>${xml(npmCache)}</string>`}${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}${scheduledRunsEnv.darwin}</dict>
191
197
  </dict></plist>\n`
192
198
  // The destructive reload is intentionally NOT represented as an inline `post`
193
199
  // command. installService stages this plist and hands the transaction to an
@@ -223,7 +229,7 @@ RestartSec=2
223
229
  RestartPreventExitStatus=0
224
230
  WorkingDirectory=${cwd}
225
231
  Environment=PATH=${servicePath}
226
- ${runtimeEntry ? '' : `Environment=NPM_CONFIG_CACHE=${npmCache}\n`}${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
232
+ ${runtimeEntry ? '' : `Environment=NPM_CONFIG_CACHE=${npmCache}\n`}${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}${scheduledRunsEnv.linux}StandardOutput=append:${log}
227
233
  StandardError=append:${log}
228
234
 
229
235
  [Install]
@@ -244,7 +250,7 @@ WantedBy=default.target
244
250
  : room
245
251
  ? ['npx', '-y', ...onlineFlag, verSpec, room, '--supervise', ...tail].join(' ')
246
252
  : ['npx', '-y', ...onlineFlag, verSpec].join(' ')
247
- const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${runtimeEntry ? '' : `set "NPM_CONFIG_CACHE=${npmCache}"\r\n`}${inner}\r\n`
253
+ const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${runtimeEntry ? '' : `set "NPM_CONFIG_CACHE=${npmCache}"\r\n`}${scheduledRunsEnv.win32}${inner}\r\n`
248
254
  return { file, content, logDir, post: [], note: 'Installed to the Startup folder — runs at login' + (room ? ' with --supervise (auto-restart on crash).' : ' (account mode).') + ' Start it now without rebooting by double-clicking the .cmd, or run it from a terminal.' }
249
255
  }
250
256
 
package/session-store.mjs CHANGED
@@ -292,6 +292,123 @@ export function deleteSession(room, id) {
292
292
  catch (error) { persistenceError('archive closed session snapshot', p, error); return false }
293
293
  }
294
294
 
295
+ // Scheduled outcomes use a separate host outbox from live session snapshots.
296
+ // A person may close the visible terminal while its idempotent database finish
297
+ // is retrying; keeping the intent here lets a restart complete that write without
298
+ // loadAll() resurrecting the deliberately closed terminal.
299
+ const scheduledOutcomesFile = (room) => path.join(dir(room), '.scheduled-outcomes')
300
+ const safeTerminalId = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/.test(value)
301
+ const safeRunId = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/.test(value)
302
+ const readScheduledOutcomeMap = (room) => {
303
+ try {
304
+ const value = JSON.parse(fs.readFileSync(scheduledOutcomesFile(room), 'utf8'))
305
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
306
+ } catch { return {} }
307
+ }
308
+ // Pending entries from the original format are raw intents. Once the database
309
+ // finish and recorded session snapshot are both durable, replace that raw entry
310
+ // with a versioned acknowledgement tombstone before attempting deletion. The
311
+ // tombstone is intentionally independent of the live/archived session record:
312
+ // restart recovery can therefore identify an acknowledged exact run and retry
313
+ // deletion only, without issuing another finish RPC or reclassifying its result.
314
+ const scheduledOutcomeRecord = (value) => {
315
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
316
+ if (value.v === 1 && value.state === 'acknowledged') {
317
+ const intent = value.intent
318
+ return intent && typeof intent === 'object' && !Array.isArray(intent) &&
319
+ safeRunId(intent.runId) && value.runId === intent.runId
320
+ ? { intent, acknowledged: true }
321
+ : null
322
+ }
323
+ return safeRunId(value.runId) ? { intent: value, acknowledged: false } : null
324
+ }
325
+ export function savePendingScheduledOutcome(room, terminalId, intent) {
326
+ if (!safeTerminalId(terminalId) || !intent || typeof intent !== 'object' || Array.isArray(intent) || !safeRunId(intent.runId)) return false
327
+ try {
328
+ ensureDir(room)
329
+ const pending = readScheduledOutcomeMap(room)
330
+ pending[terminalId] = intent
331
+ atomicCommit(scheduledOutcomesFile(room), JSON.stringify(pending))
332
+ return true
333
+ } catch (error) {
334
+ persistenceError('write scheduled outcome outbox', scheduledOutcomesFile(room), error)
335
+ return false
336
+ }
337
+ }
338
+ export function loadPendingScheduledOutcomes(room) {
339
+ return Object.entries(readScheduledOutcomeMap(room))
340
+ .map(([terminalId, value]) => ({ terminalId, record: scheduledOutcomeRecord(value) }))
341
+ .filter(({ terminalId, record }) => safeTerminalId(terminalId) && record)
342
+ .map(({ terminalId, record }) => ({
343
+ terminalId,
344
+ intent: record.intent,
345
+ ...(record.acknowledged ? { acknowledged: true } : {}),
346
+ }))
347
+ .slice(0, LIVE_SESSION_MAX)
348
+ }
349
+ export function loadPendingScheduledOutcome(room, terminalId) {
350
+ if (!safeTerminalId(terminalId)) return null
351
+ return scheduledOutcomeRecord(readScheduledOutcomeMap(room)[terminalId])?.intent || null
352
+ }
353
+ export function acknowledgePendingScheduledOutcome(room, terminalId, runId) {
354
+ if (!safeTerminalId(terminalId) || !safeRunId(runId)) return false
355
+ try {
356
+ const pending = readScheduledOutcomeMap(room)
357
+ const record = scheduledOutcomeRecord(pending[terminalId])
358
+ if (!record || record.intent.runId !== runId) return false
359
+ if (record.acknowledged) return true
360
+ pending[terminalId] = {
361
+ v: 1,
362
+ state: 'acknowledged',
363
+ runId,
364
+ intent: record.intent,
365
+ }
366
+ atomicCommit(scheduledOutcomesFile(room), JSON.stringify(pending))
367
+ return true
368
+ } catch (error) {
369
+ persistenceError('acknowledge scheduled outcome outbox', scheduledOutcomesFile(room), error)
370
+ return false
371
+ }
372
+ }
373
+ export function deletePendingScheduledOutcome(room, terminalId) {
374
+ if (!safeTerminalId(terminalId)) return false
375
+ try {
376
+ const pending = readScheduledOutcomeMap(room)
377
+ if (!(terminalId in pending)) return true
378
+ delete pending[terminalId]
379
+ if (Object.keys(pending).length) atomicCommit(scheduledOutcomesFile(room), JSON.stringify(pending))
380
+ else fs.rmSync(scheduledOutcomesFile(room), { force: true })
381
+ return true
382
+ } catch (error) {
383
+ persistenceError('delete scheduled outcome outbox', scheduledOutcomesFile(room), error)
384
+ return false
385
+ }
386
+ }
387
+
388
+ // A database acknowledgement is not locally committed until the corresponding
389
+ // live-session snapshot has synchronously recorded scheduleOutcomeRecorded=true
390
+ // AND the exact outbox is durably marked acknowledged. Keep deletion behind both
391
+ // barriers so a restart always sees one of:
392
+ // 1. pending outbox intent to replay idempotently;
393
+ // 2. an acknowledged outbox tombstone to delete only; or
394
+ // 3. a recorded session snapshot with no outbox.
395
+ // `flushRecordedSnapshot` is intentionally injected by bridge.mjs because only
396
+ // the live entry can produce its complete session payload.
397
+ export function commitRecordedScheduledOutcome(room, terminalId, flushRecordedSnapshot) {
398
+ let snapshotRecorded = false
399
+ try {
400
+ snapshotRecorded = typeof flushRecordedSnapshot === 'function' && flushRecordedSnapshot() === true
401
+ } catch (error) {
402
+ persistenceError('record scheduled outcome in session snapshot', path.join(dir(room), `${terminalId}.json`), error)
403
+ }
404
+ if (!snapshotRecorded) return { ok: false, snapshotRecorded: false, outboxAcknowledged: false, outboxDeleted: false }
405
+ const intent = loadPendingScheduledOutcome(room, terminalId)
406
+ const outboxAcknowledged = acknowledgePendingScheduledOutcome(room, terminalId, intent?.runId)
407
+ if (!outboxAcknowledged) return { ok: false, snapshotRecorded: true, outboxAcknowledged: false, outboxDeleted: false }
408
+ const outboxDeleted = deletePendingScheduledOutcome(room, terminalId)
409
+ return { ok: outboxDeleted, snapshotRecorded: true, outboxAcknowledged: true, outboxDeleted }
410
+ }
411
+
295
412
  // Most-recently-saved structured session for the room (drives attached restore).
296
413
  export function loadLatest(room) {
297
414
  const recs = listRecs(room).sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 18,
3
+ "bundleVersion": 19,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -188,9 +188,9 @@
188
188
  },
189
189
  {
190
190
  "id": "design-workspace",
191
- "version": 7,
192
- "interactionPrompt": "DESIGN EDITING MODEL: every complete bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For source-aware application previews, the bridge validates each opaque source identity against its contained host-only map and the current source-file hash before sharing an exact source location privately with the producing lane; uninstrumented elements keep the selector-based fallback, while stale or mismatched identities fail closed. For authored HTML, the producing lane edits the canonical authored HTML source directly. Edit in Design—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. When a person asks for many mockups or options that can share a surface, prefer one source-backed multi-option board or gallery in one file and one card so they can compare together; create separate files/cards only when the person explicitly requests independently editable artifacts or the options cannot be represented faithfully together.",
193
- "turnReminder": "DESIGN ROUTE: intentional application-preview deliverables use preview_capture with card=true; correlated Design recaptures are recognized automatically. Authored HTML uses the source-backed render helper. Both must produce editable Thinkpool Design cards with verified desktop and mobile renders. Ordinary verification captures stay inline evidence and must not create cards. Cards are delivered after the final agent response so they remain the newest transcript item. Edit in Design explicitly arms the persistent Design workspace and adds its virtual Design · Page beneath the producing terminal in the Ensemble row; Preview alone does not arm it. For large direction sets, consolidate compatible options into one source-backed comparison board/gallery and one card; use separate files/cards only when independent editing is explicitly requested or technically necessary. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.",
191
+ "version": 8,
192
+ "interactionPrompt": "DESIGN EDITING MODEL: every complete bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For source-aware application previews, the bridge validates each opaque source identity against its contained host-only map and the current source-file hash before sharing an exact source location privately with the producing lane; uninstrumented elements keep the selector-based fallback, while stale or mismatched identities fail closed. For authored HTML, the producing lane edits the canonical authored HTML source directly. Edit in Design—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. A remote discrete selection may show one bounded, normalized location pulse in the partner's color; it never grants authority, persists an event, or loops. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Compare is available only for the exact immutable verified parent/current revision pair and uses its captured desktop or mobile images; staged, stale, cross-page, or incomplete pairs fail closed. Design visual runtimes honor live reduced motion, suspend when hidden or off-screen, cap DPR, and tear down deterministically. Never call a staged draft saved or live. When a person asks for many mockups or options that can share a surface, prefer one source-backed multi-option board or gallery in one file and one card so they can compare together; create separate files/cards only when the person explicitly requests independently editable artifacts or the options cannot be represented faithfully together.",
193
+ "turnReminder": "DESIGN ROUTE: intentional application-preview deliverables use preview_capture with card=true; correlated Design recaptures are recognized automatically. Authored HTML uses the source-backed render helper. Both must produce editable Thinkpool Design cards with verified desktop and mobile renders. Ordinary verification captures stay inline evidence and must not create cards. Cards are delivered after the final agent response so they remain the newest transcript item. Edit in Design explicitly arms the persistent Design workspace and adds its virtual Design · Page beneath the producing terminal in the Ensemble row; Preview alone does not arm it. Compare may expose only the exact immutable verified parent/current revision pair; never label a staged or incomplete capture verified. Remote selection pulses are one-shot collaboration receipts, not authority or ambient decoration. For large direction sets, consolidate compatible options into one source-backed comparison board/gallery and one card; use separate files/cards only when independent editing is explicitly requested or technically necessary. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.",
194
194
  "impact": [
195
195
  {"path": "src/pages/code/design/"},
196
196
  {"path": "src/pages/code/structured.jsx", "diffPattern": "Edit in Design|openMockup|tp-mockup-view|sourceKnown"},
@@ -203,6 +203,8 @@
203
203
  {"path": "src/pages/code/room.jsx", "pattern": "designLaneSelected"},
204
204
  {"path": "src/pages/code/design/DesignViewer.jsx", "pattern": "createPortal\\(surface, laneHost\\)"},
205
205
  {"path": "src/pages/code/design/DesignViewer.jsx", "pattern": "Apply \\${pendingEditCount}"},
206
+ {"path": "src/pages/code/design/workspace.js", "pattern": "resolveVerifiedDesignComparison"},
207
+ {"path": "src/pages/code/design/visual-runtime.js", "pattern": "DESIGN_VISUAL_RUNTIME_CONTRACT"},
206
208
  {"path": "src/pages/code/design/queue.js", "pattern": "designBatchPayload"},
207
209
  {"path": "bridge/design-edit.mjs", "pattern": "validateDesignBatchRequest"}
208
210
  ]