thinkpool-pair 0.7.358 → 0.7.360

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/account.mjs CHANGED
@@ -798,6 +798,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
798
798
  // reap that lost room XE3IQ's history.
799
799
  // Spec: docs/specs/2026-06-25-single-bridge-durable-history.md (INV-2)
800
800
  let claimHeld = false
801
+ let claimFencing = 0
801
802
  let standbyHost = null
802
803
  // Wall-clock of the last SUCCESSFUL claim RPC. The heartbeat watchdog (below) uses it to
803
804
  // detect a wedged loop and self-heal — the 2026-07-08 outage stood by against its OWN
@@ -822,6 +823,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
822
823
  else if (!held && claimHeld) process.stderr.write(`\n ◇ another device (${host}) took over this account — standing by.\n`)
823
824
  else if (!held && standbyHost !== host) process.stderr.write(`\n ◇ another device (${host}) is serving this account — standing by.\n (this Mac takes over automatically if that device goes offline.)\n`)
824
825
  claimHeld = held
826
+ claimFencing = held && Number.isSafeInteger(Number(c?.fencing)) && Number(c.fencing) > 0 ? Number(c.fencing) : 0
825
827
  standbyHost = held ? null : host
826
828
 
827
829
  // ── supervisor claim_tick logging (cascade brg-instrument) ─────────────────
@@ -1026,7 +1028,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1026
1028
  // @latest); they report idle to us over IPC so we can restart at a moment
1027
1029
  // when every session is quiet. Strip AUTOUPDATE so an inherited service env
1028
1030
  // can't make a child self-exit; flag it as an account child instead.
1029
- const env = { ...process.env, THINKPOOL_PAIR_ACCOUNT_CHILD: '1' }
1031
+ const env = { ...process.env, THINKPOOL_PAIR_ACCOUNT_CHILD: '1', TP_ACCOUNT_BRIDGE_ID: BRIDGE_ID, TP_ACCOUNT_BRIDGE_FENCING: String(claimFencing) }
1030
1032
  delete env.THINKPOOL_PAIR_AUTOUPDATE
1031
1033
  // Hand the child the owner JWT for authed web writes (code-mockup, L16).
1032
1034
  if (currentAccessToken) env.TP_ACCESS_TOKEN = currentAccessToken
package/bridge.mjs CHANGED
@@ -129,11 +129,13 @@ import { createStandalonePairResponder, standalonePairIdentity } from './direct-
129
129
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
130
130
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
131
131
  import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
132
- import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
132
+ import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage, acknowledgePendingScheduledOutcome, commitRecordedScheduledOutcome, deletePendingScheduledOutcome, loadPendingScheduledOutcome, loadPendingScheduledOutcomes, savePendingScheduledOutcome } from './session-store.mjs'
133
133
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, appendCurrentPersonRequest, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
134
134
  import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
135
135
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
136
136
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
137
+ import { finishScheduledRunWithRetry, pollDueScheduledRuns, sanitizeScheduledOutcomeIntent, scheduledOutcomeReconciliationComplete, scheduledRunDeadlineRequired, scheduledRunSpawnAdmission, scheduledRunsCapability, scheduledRunsEnabled } from './scheduled-runs.mjs'
138
+ import { acquireScheduledRunAdmission, releaseScheduledRunAdmission } from './scheduled-run-admission.mjs'
137
139
  import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
138
140
  import { acknowledgeWorkerCompletions, enqueueWorkerCompletion, workerCompletionPrompt, workerCompletionRecord } from './worker-completion.mjs'
139
141
  import { planMeterLine } from './plan-meters.mjs'
@@ -664,6 +666,11 @@ if (process.stdin.isTTY && !headless && process.env.THINKPOOL_PAIR_AUTOUPDATE !=
664
666
  }
665
667
  const name = process.env.TP_NAME || os.userInfo().username || 'host'
666
668
  const BRIDGE_ID = (randomUUID?.().slice(0,8)) || 'bridgexx'
669
+ // The room child has its own short local id for terminal/pair-control authority.
670
+ // Scheduled execution additionally needs the supervisor's UUID claim, inherited only
671
+ // by account-owned children; standalone/anon bridges deliberately cannot run schedules.
672
+ const ACCOUNT_BRIDGE_ID = process.env.TP_ACCOUNT_BRIDGE_ID || null
673
+ const ACCOUNT_BRIDGE_FENCING = Number.parseInt(process.env.TP_ACCOUNT_BRIDGE_FENCING || '', 10)
667
674
  const BRIDGE_STARTED_AT = Date.now()
668
675
  // host: this machine's short label — os.hostname() with any DNS/domain suffix
669
676
  // stripped and capped ~24 chars. A /code room can be served by different bridges
@@ -1392,6 +1399,9 @@ const announce = () => {
1392
1399
  // updir: where room file-drops land (forward-slash normalised — the web
1393
1400
  // client string-joins host paths onto it; Node accepts `/` on Windows).
1394
1401
  updir: UPDIR.split(path.sep).join('/'),
1402
+ // Secret-free host opt-in projection for Automations. Never announce auth,
1403
+ // fencing, credentials, endpoints, prompts, or schedule contents.
1404
+ scheduledRuns: scheduledRunsCapability(),
1395
1405
  // canResume: this agent can continue a prior session in THIS cwd —
1396
1406
  // re-probed per announce (a fresh run creates a session, so the flag
1397
1407
  // can flip true while the bridge is up). Functions don't survive
@@ -1414,7 +1424,7 @@ const announce = () => {
1414
1424
  // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1415
1425
  // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1416
1426
  // every roster consumer reads one status instead of reconstructing it.
1417
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(laneBusyOf(s) && s._turnStart ? { turnStartedAt: s._turnStart } : {}), ...(!laneBusyOf(s) && Number(s._settledTurnRev) > 0 && Number(s._settledTurnRev) === Number(s._turnRev) ? { settledTurnRev: Number(s._settledTurnRev), turnSettledAt: Number(s._turnSettledAt) || undefined } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1427
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(laneBusyOf(s) && s._turnStart ? { turnStartedAt: s._turnStart } : {}), ...(!laneBusyOf(s) && Number(s._settledTurnRev) > 0 && Number(s._settledTurnRev) === Number(s._turnRev) ? { settledTurnRev: Number(s._settledTurnRev), turnSettledAt: Number(s._turnSettledAt) || undefined } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}), ...(s.scheduleRunId ? { scheduleRunId: s.scheduleRunId } : {}),
1418
1428
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1419
1429
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1420
1430
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1568,7 +1578,10 @@ function attachDurablePermissionSource(entry, payload) {
1568
1578
  }
1569
1579
  pushLog(entry, event)
1570
1580
  bcast('code-event', { term: entry.id, evt: event })
1571
- return { ...payload, source: { epoch: 0, cid: event.cid, seq: event.seq } }
1581
+ // Schedule provenance is opaque and bounded; the browser's existing durable
1582
+ // pair-control projection carries it into request_context. It is never prompt,
1583
+ // path, credential, or tool argument data.
1584
+ return { ...payload, ...(entry.scheduleRunId ? { runId: entry.scheduleRunId } : {}), source: { epoch: 0, cid: event.cid, seq: event.seq } }
1572
1585
  }
1573
1586
  // ── Tier 3 — raise a standalone permission card on a target session ──────────
1574
1587
  // The cross-ROOM post lands here on the RECEIVING side: a person in THIS room must
@@ -1755,6 +1768,144 @@ const designArtifacts = new Map() // previewId → trusted source record
1755
1768
  const designQueues = new Map() // producer term → validated requests
1756
1769
  const designActive = new Map() // producer term → request awaiting result + proof
1757
1770
  const designRestore = new Map() // current previewId → immediately previous verified record/edit
1771
+ function scheduledEvidenceForTerm(terminalId) {
1772
+ return [...designArtifacts.values()]
1773
+ .filter((record) => record?.term === terminalId && typeof record.previewId === 'string')
1774
+ .slice(0, 16).map((record) => ({ type: 'preview', id: record.previewId }))
1775
+ }
1776
+ function clearScheduledRunDeadline(entry) {
1777
+ if (entry?.scheduleDeadlineTimer) clearTimeout(entry.scheduleDeadlineTimer)
1778
+ if (entry) entry.scheduleDeadlineTimer = null
1779
+ }
1780
+ const scheduledOutcomeWrites = new Set()
1781
+ async function releaseScheduledAdmissionForEntry(entry) {
1782
+ const lease = entry?.scheduleAdmissionLease
1783
+ if (!lease) return true
1784
+ const released = await releaseScheduledRunAdmission(lease)
1785
+ if (!released.ok) return false
1786
+ entry.scheduleAdmissionLease = null
1787
+ return true
1788
+ }
1789
+ async function persistScheduledRunOutcome(entry) {
1790
+ const intent = sanitizeScheduledOutcomeIntent(entry?.scheduleOutcomePending)
1791
+ if (!entry?.id || !intent || entry.scheduleOutcomeRecorded) return false
1792
+ const key = `${entry.id}:${intent.runId}`
1793
+ if (scheduledOutcomeWrites.has(key)) return false
1794
+ scheduledOutcomeWrites.add(key)
1795
+ try {
1796
+ const written = await finishScheduledRunWithRetry({
1797
+ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room,
1798
+ runId: intent.runId, bridgeId: ACCOUNT_BRIDGE_ID, bridgeFencing: ACCOUNT_BRIDGE_FENCING,
1799
+ result: intent.result, canceled: intent.canceled, evidenceRefs: intent.evidenceRefs,
1800
+ })
1801
+ if (!written.ok) return false
1802
+ // The durable occurrence is now terminal, so it no longer consumes the
1803
+ // host-wide scheduled-run capacity. A failed local release remains leased
1804
+ // until its exact deadline and is retried from the restored session.
1805
+ await releaseScheduledAdmissionForEntry(entry)
1806
+ const liveEntry = sessions.get(entry.id) === entry
1807
+ if (!liveEntry) {
1808
+ // Closed terminals have no live snapshot to preserve. Their independent
1809
+ // outbox is the only local replay boundary. Tombstone the acknowledged
1810
+ // exact run before deletion so a failed delete can never re-finish it.
1811
+ if (!acknowledgePendingScheduledOutcome(room, entry.id, intent.runId)) return false
1812
+ return deletePendingScheduledOutcome(room, entry.id)
1813
+ }
1814
+ const priorPending = entry.scheduleOutcomePending
1815
+ entry.scheduleOutcomeRecorded = true
1816
+ entry.scheduleOutcomePending = null
1817
+ const committed = commitRecordedScheduledOutcome(room, entry.id, () => entry.flush?.())
1818
+ if (!committed.snapshotRecorded) {
1819
+ // The remote finish is idempotent. Retain the durable outbox and restore
1820
+ // in-memory pending state so reconciliation retries the whole commit.
1821
+ entry.scheduleOutcomeRecorded = false
1822
+ entry.scheduleOutcomePending = priorPending
1823
+ return false
1824
+ }
1825
+ // If deleting the outbox failed, keep the durably recorded live state. The
1826
+ // next reconciliation pass recognizes this exact run and retries deletion
1827
+ // without issuing another finish RPC.
1828
+ return committed.ok
1829
+ } finally {
1830
+ scheduledOutcomeWrites.delete(key)
1831
+ }
1832
+ }
1833
+ function recordScheduledRunOutcome(entry, result, { canceled = false } = {}) {
1834
+ if (!entry?.scheduleRunId || entry.scheduleOutcomeRecorded || entry.scheduleOutcomePending) return false
1835
+ const intent = sanitizeScheduledOutcomeIntent({
1836
+ runId: entry.scheduleRunId,
1837
+ result,
1838
+ canceled,
1839
+ evidenceRefs: scheduledEvidenceForTerm(entry.id),
1840
+ })
1841
+ if (!intent) return false
1842
+ if (!savePendingScheduledOutcome(room, entry.id, intent)) return false
1843
+ entry.scheduleOutcomePending = intent
1844
+ clearScheduledRunDeadline(entry)
1845
+ entry.flush?.()
1846
+ void persistScheduledRunOutcome(entry)
1847
+ return true
1848
+ }
1849
+ async function recoverPendingScheduledRunOutcomes() {
1850
+ if (!codeAuthToken || !ACCOUNT_BRIDGE_ID || !Number.isSafeInteger(ACCOUNT_BRIDGE_FENCING) || ACCOUNT_BRIDGE_FENCING < 1) return false
1851
+ const savedRecords = new Map(loadAll(room).map((record) => [record?.id, record]).filter(([id]) => id))
1852
+ for (const { terminalId, intent: stored, acknowledged } of loadPendingScheduledOutcomes(room)) {
1853
+ // A durable acknowledgement tombstone is delete-only. It remains
1854
+ // authoritative even after the terminal snapshot has been archived.
1855
+ if (acknowledged) {
1856
+ deletePendingScheduledOutcome(room, terminalId)
1857
+ continue
1858
+ }
1859
+ const intent = sanitizeScheduledOutcomeIntent(stored)
1860
+ if (!intent) { deletePendingScheduledOutcome(room, terminalId); continue }
1861
+ let entry = sessions.get(terminalId)
1862
+ const saved = savedRecords.get(terminalId)
1863
+ // An acknowledged finish can leave its exact outbox behind if local
1864
+ // deletion failed after the recorded snapshot commit. Clean only that
1865
+ // exact run; never re-finish or reinterpret it.
1866
+ const recordedRunId = entry?.scheduleOutcomeRecorded === true ? entry.scheduleRunId
1867
+ : saved?.scheduleOutcomeRecorded === true ? saved.scheduleRunId
1868
+ : null
1869
+ if (recordedRunId === intent.runId) {
1870
+ deletePendingScheduledOutcome(room, terminalId)
1871
+ continue
1872
+ }
1873
+ // A live snapshot that has not restored yet is not a closed terminal. Wait
1874
+ // for the restore loop instead of racing it with a synthetic holder.
1875
+ if (!entry && saved) continue
1876
+ if (entry && entry.scheduleRunId && entry.scheduleRunId !== intent.runId) continue
1877
+ if (!entry) entry = { id: terminalId, scheduleRunId: intent.runId, scheduleOutcomeRecorded: false }
1878
+ entry.scheduleOutcomePending = intent
1879
+ await persistScheduledRunOutcome(entry)
1880
+ }
1881
+ return scheduledOutcomeReconciliationComplete(loadPendingScheduledOutcomes(room))
1882
+ }
1883
+ function armScheduledRunDeadline(entry) {
1884
+ clearScheduledRunDeadline(entry)
1885
+ if (!scheduledRunDeadlineRequired({
1886
+ runId: entry?.scheduleRunId,
1887
+ deadlineAt: entry?.scheduleDeadlineAt,
1888
+ outcomeRecorded: entry?.scheduleOutcomeRecorded,
1889
+ outcomePending: entry?.scheduleOutcomePending,
1890
+ })) return
1891
+ const remaining = Math.max(0, entry.scheduleDeadlineAt - Date.now())
1892
+ entry.scheduleDeadlineTimer = setTimeout(async () => {
1893
+ entry.scheduleDeadlineTimer = null
1894
+ if (entry.scheduleOutcomeRecorded) return
1895
+ entry.scheduleTimedOut = true
1896
+ // Settle every local permission promise before aborting the runtime. Otherwise
1897
+ // a permission card can keep the deadline path hung or later win classification.
1898
+ drainPending(entry)
1899
+ await queueAbortBarrier(entry)
1900
+ const evt = { kind: 'error', subtype: 'runtime_limit', message: 'Scheduled run reached its configured runtime ceiling.' }
1901
+ pushLog(entry, evt)
1902
+ bcast('code-event', { term: entry.id, evt })
1903
+ recordScheduledRunOutcome(entry, evt)
1904
+ entry.flush?.()
1905
+ announce()
1906
+ }, remaining)
1907
+ entry.scheduleDeadlineTimer.unref?.()
1908
+ }
1758
1909
  // Realtime can carry the settled DOM for ordinary authored mockups. Including
1759
1910
  // that already-uploaded snapshot in the correlated verified-live event lets an
1760
1911
  // open Design lane advance revisions without a second auth/sign/download round
@@ -1893,6 +2044,20 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
1893
2044
  const displaySource = designRecord
1894
2045
  ? resolveManifestDisplaySource(m, designRecord, { box })
1895
2046
  : null
2047
+ const activeDesignRequest = designRecord ? designActive.get(term) : null
2048
+ const correlatedDesignRequest = !!(activeDesignRequest
2049
+ && m.designRequestId === activeDesignRequest.request.cid
2050
+ && m.parentRevision === activeDesignRequest.record.revision)
2051
+ const expectedDesignRevision = activeDesignRequest?.restoreRecord?.revision
2052
+ const designRevisionProvesChange = expectedDesignRevision
2053
+ ? designRecord?.revision === expectedDesignRevision
2054
+ : designRecord?.revision !== activeDesignRequest?.record?.revision
2055
+ const sameDesignTarget = activeDesignRequest?.record?.sourceKind === 'preview'
2056
+ ? designRecord?.sourceKind === 'preview' && designRecord?.captureKey === activeDesignRequest.record.captureKey
2057
+ : designRecord?.sourcePath === activeDesignRequest?.record?.sourcePath
2058
+ const verifiedParentRevision = correlatedDesignRequest && sameDesignTarget && designRevisionProvesChange
2059
+ ? activeDesignRequest.record.revision
2060
+ : null
1896
2061
  // 2026-07-07: these used to swallow read errors silently — a transient
1897
2062
  // unreadable file (race with the render script, permissions, mid-write)
1898
2063
  // meant the manifest still POSTed with that field missing, and nothing
@@ -1929,6 +2094,7 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
1929
2094
  code: room, slug: m.slug, title: m.title, cid, ts: deliveryTs, term,
1930
2095
  desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? displaySource : readTxt(m.html),
1931
2096
  ...(designRecord ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}),
2097
+ ...(verifiedParentRevision ? { verified: true, parentRevision: verifiedParentRevision } : {}),
1932
2098
  }),
1933
2099
  })
1934
2100
  if (!res.ok) {
@@ -1938,8 +2104,17 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
1938
2104
  const uploaded = await res.json()
1939
2105
  const { paths } = uploaded
1940
2106
  const designAccepted = !!(designRecord && uploaded?.payload?.sourceKnown)
2107
+ const verifiedDesignRevision = !!(designAccepted
2108
+ && verifiedParentRevision
2109
+ && uploaded?.payload?.verified === true
2110
+ && uploaded.payload.parentRevision === verifiedParentRevision)
1941
2111
  const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: deliveryTs, paths,
1942
- ...(designAccepted ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}) }
2112
+ ...(designAccepted ? {
2113
+ previewId: designRecord.previewId,
2114
+ revision: designRecord.revision,
2115
+ sourceKnown: true,
2116
+ ...(verifiedDesignRevision ? { verified: true, parentRevision: verifiedParentRevision } : {}),
2117
+ } : {}) }
1943
2118
  if (designAccepted) {
1944
2119
  Object.assign(designRecord, { term, slug: m.slug, title: m.title || m.slug, desktop: m.desktop, mobile: m.mobile })
1945
2120
  try {
@@ -1949,15 +2124,10 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
1949
2124
  if (!fs.existsSync(designRecord.backupPath)) fs.writeFileSync(designRecord.backupPath, designRecord.source)
1950
2125
  } catch { /* restore stays unavailable if the host backup cannot be written */ }
1951
2126
  designArtifacts.set(designRecord.previewId, designRecord)
1952
- const active = designActive.get(term)
1953
- const correlated = active && m.designRequestId === active.request.cid && m.parentRevision === active.record.revision
2127
+ const active = activeDesignRequest
2128
+ const correlated = verifiedDesignRevision
1954
2129
  const dual = !!(paths?.html && paths?.desktop && paths?.mobile && m.desktop && m.mobile)
1955
- const expectedRevision = active?.restoreRecord?.revision
1956
- const revisionProvesChange = expectedRevision ? designRecord.revision === expectedRevision : designRecord.revision !== active?.record?.revision
1957
- const sameTarget = active?.record?.sourceKind === 'preview'
1958
- ? designRecord.sourceKind === 'preview' && designRecord.captureKey === active.record.captureKey
1959
- : designRecord.sourcePath === active?.record?.sourcePath
1960
- if (correlated && dual && sameTarget && revisionProvesChange) {
2130
+ if (correlated && dual) {
1961
2131
  if (active.record.sourceKind !== 'preview') designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
1962
2132
  const artifactHtml = Buffer.byteLength(displaySource, 'utf8') <= MAX_INLINE_DESIGN_REVISION_BYTES
1963
2133
  ? displaySource
@@ -2353,7 +2523,7 @@ function worktreeSnapshot(cwd) {
2353
2523
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2354
2524
  // persist to the host file; tool calls round-trip through the perm card; the
2355
2525
  // rolling log replays to joiners and survives bridge restarts (session-store).
2356
- function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage, receivedTurnCids }) {
2526
+ function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage, receivedTurnCids, scheduleRunId, scheduleDeadlineAt, scheduleOutcomeRecorded, scheduleAdmissionLease }) {
2357
2527
  if (sessions.has(id)) return
2358
2528
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2359
2529
  // Fail closed before exposing a native lane if its bridge semantic contract
@@ -2400,6 +2570,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2400
2570
  const restoredRawLog = Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : []
2401
2571
  const restoredLog = boundStructuredLog(restoredRawLog)
2402
2572
  const restoredOffset = restoredRawLog.length - restoredLog.length
2573
+ const restoredScheduleOutcomeCandidate = scheduleRunId && scheduleOutcomeRecorded !== true
2574
+ ? sanitizeScheduledOutcomeIntent(loadPendingScheduledOutcome(room, id))
2575
+ : null
2576
+ const restoredScheduleOutcome = restoredScheduleOutcomeCandidate?.runId === scheduleRunId
2577
+ ? restoredScheduleOutcomeCandidate
2578
+ : null
2403
2579
  const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), receivedTurnCids: new Set(Array.isArray(receivedTurnCids) ? receivedTurnCids.filter(Boolean).slice(-1000) : []), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2404
2580
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
2405
2581
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
@@ -2413,7 +2589,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2413
2589
  : (!provider || provider === 'anthropic')
2414
2590
  ? (laneModel || null)
2415
2591
  : (laneModel || providerNameMap()[provider] || provider),
2416
- provider: provider || null, spawnedBy: spawnedBy || undefined, spawnDepth: structuralDepth, cascadeRole: cascadeRole === 'conductor' || cascadeRole === 'worker' ? cascadeRole : null, hop: initialHop, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], pendingWorkerCompletions: Array.isArray(pendingWorkerCompletions) ? pendingWorkerCompletions.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], workerCompletionsInFlight: Array.isArray(workerCompletionsInFlight) ? workerCompletionsInFlight.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
2592
+ provider: provider || null, spawnedBy: spawnedBy || undefined, spawnDepth: structuralDepth, cascadeRole: cascadeRole === 'conductor' || cascadeRole === 'worker' ? cascadeRole : null, hop: initialHop, sideParent: sideParent || undefined, sideTask: sideTask || undefined, scheduleRunId: scheduleRunId || null, scheduleDeadlineAt: Number.isFinite(Number(scheduleDeadlineAt)) ? Number(scheduleDeadlineAt) : null, scheduleOutcomeRecorded: scheduleOutcomeRecorded === true, scheduleOutcomePending: restoredScheduleOutcome, scheduleAdmissionLease: scheduleAdmissionLease || null, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], pendingWorkerCompletions: Array.isArray(pendingWorkerCompletions) ? pendingWorkerCompletions.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], workerCompletionsInFlight: Array.isArray(workerCompletionsInFlight) ? workerCompletionsInFlight.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
2417
2593
  sliceType: sliceType === 'review' ? 'review' : null,
2418
2594
  flowRole: flowRole || (flowSessionId ? (flowTaskKey ? ((flowReviewTargets?.length || reviewSliceRoots?.length) ? 'reviewer' : 'builder') : 'conductor') : null),
2419
2595
  flowReviewTarget: flowReviewTarget || null,
@@ -2650,6 +2826,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2650
2826
  // with no archive yet; pushLog stamps it from the first appended event.
2651
2827
  entry.archiveOldestSeq = readDurableOldestSeq(room, id)
2652
2828
  sessions.set(id, entry)
2829
+ // Bind and replay an already-chosen durable outcome before any elapsed
2830
+ // restored deadline can arm and overwrite it.
2831
+ if (entry.scheduleOutcomePending) void persistScheduledRunOutcome(entry)
2832
+ else armScheduledRunDeadline(entry)
2653
2833
  // Close an interrupted turn so a restored terminal comes back IDLE-with-history
2654
2834
  // instead of stuck "thinking" (see restoredTurnOpen). Idempotent: the closer is
2655
2835
  // persisted, so a later resume sees a closed turn and no-ops.
@@ -2670,7 +2850,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2670
2850
  // Visual QA runs in the bridge process, outside either agent runtime's sandbox.
2671
2851
  // It remains scoped to this lane's cwd and private mockup outbox.
2672
2852
  entry.viewport = new ViewportManager({
2673
- workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox,
2853
+ workspaceRoot: cwd || process.cwd(), ownerId: id, roomCode: room, outbox: mockupOutbox,
2674
2854
  authContext: () => ({ accessToken: codeAuthToken, supabaseUrl: SUPABASE_URL }),
2675
2855
  designContext: () => {
2676
2856
  const active = designActive.get(id)
@@ -2690,13 +2870,18 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2690
2870
  // restart. Without this, sessionData omitted it → on restart the resumed session
2691
2871
  // re-launched on the host default (Opus) regardless of the last switch, and the
2692
2872
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2693
- const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
2873
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, scheduleRunId: entry.scheduleRunId || null, scheduleDeadlineAt: entry.scheduleDeadlineAt || null, scheduleOutcomeRecorded: entry.scheduleOutcomeRecorded === true, scheduleAdmissionLease: entry.scheduleAdmissionLease || null, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
2694
2874
  const persist = () => saveSession(room, id, sessionData())
2695
2875
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2696
2876
  // has a file under its id BEFORE its first event — surviving a restart inside the
2697
2877
  // 1.5s saveSession debounce window) and on shutdown (so events since the last
2698
2878
  // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
2699
2879
  entry.flush = () => flushSession(room, id, sessionData())
2880
+ if (entry.scheduleOutcomeRecorded && entry.scheduleAdmissionLease) {
2881
+ void releaseScheduledAdmissionForEntry(entry).then((released) => {
2882
+ if (released) entry.flush?.()
2883
+ })
2884
+ }
2700
2885
  // Main-terminal creation and Ensemble dispatch are different capabilities.
2701
2886
  // A main terminal (human-opened or agent-opened via open_main_terminal) may
2702
2887
  // dispatch workers. A spawned/Side/Flow lane can never elevate itself or a
@@ -3869,6 +4054,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3869
4054
  if (mockupDeliveryBoundary) {
3870
4055
  queueMicrotask(() => { mockupDeliveries.flush(id).catch(() => {}) })
3871
4056
  }
4057
+ // A scheduled run has one durable terminal outcome. Deliberately do this
4058
+ // after its terminal boundary is emitted, so a preview created by that turn
4059
+ // is visible before it can count as a deliverable. Plain prose success is
4060
+ // classified as missing_deliverable by the pure runner.
4061
+ if (terminalBoundary && entry.scheduleRunId && (e.kind === 'result' || e.kind === 'error')) {
4062
+ const scheduledResult = entry.scheduleTimedOut ? { ...e, subtype: 'runtime_limit' } : e
4063
+ recordScheduledRunOutcome(entry, scheduledResult, { canceled: !entry.scheduleTimedOut && e.subtype === 'aborted' })
4064
+ }
3872
4065
  }
3873
4066
  // A tool_result carrying an inline base64 image can't ride a broadcast frame
3874
4067
  // (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
@@ -4105,6 +4298,8 @@ function endStructured(id) {
4105
4298
  if (!id) return
4106
4299
  const s = sessions.get(id)
4107
4300
  if (s) {
4301
+ recordScheduledRunOutcome(s, { subtype: 'canceled' }, { canceled: true })
4302
+ clearScheduledRunDeadline(s)
4108
4303
  s.imageQueue?.close()
4109
4304
  mockupDeliveries.clear(id)
4110
4305
  drainPending(s)
@@ -5041,7 +5236,7 @@ channel
5041
5236
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
5042
5237
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
5043
5238
  // lanes keep their worktree cwd + the ability to mark done.
5044
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
5239
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, scheduleRunId: rec.scheduleRunId, scheduleDeadlineAt: rec.scheduleDeadlineAt, scheduleOutcomeRecorded: rec.scheduleOutcomeRecorded, scheduleAdmissionLease: rec.scheduleAdmissionLease, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
5045
5240
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
5046
5241
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
5047
5242
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -5518,6 +5713,83 @@ designChannel
5518
5713
  if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ design realtime ${status} (tpdesign:${room}).\n`)
5519
5714
  })
5520
5715
 
5716
+ // Owner-bridge-only, local polling for V1 unattended schedules. The database claim
5717
+ // is the cross-process fence; this guard only prevents overlapping HTTP ticks in one
5718
+ // bridge. A due run always becomes an ordinary top-level terminal with run provenance.
5719
+ let scheduledRunPollBusy = false
5720
+ const scheduledProviderAvailable = (schedule) => {
5721
+ const runtime = schedule?.runtime
5722
+ const provider = schedule?.provider_id || 'anthropic'
5723
+ if (runtime === 'claude') {
5724
+ if (!listProviders().some((item) => item.id === provider)) return false
5725
+ return provider === 'anthropic' || !schedule?.model || providerModel(provider) === schedule.model
5726
+ }
5727
+ if (schedule?.provider_id) return false // V1 never silently maps non-Claude providers.
5728
+ if (runtime === 'codex') return !schedule?.model || modelCatalogValues(readCodexModels()).has(schedule.model)
5729
+ if (runtime === 'hermes') return !schedule?.model || hostHermesModels.some((item) => (item?.id || item?.model) === schedule.model)
5730
+ return false
5731
+ }
5732
+ const pollScheduledRuns = async () => {
5733
+ if (scheduledRunPollBusy || shuttingDown || !codeAuthToken || !myServeUid || !ACCOUNT_BRIDGE_ID || !Number.isSafeInteger(ACCOUNT_BRIDGE_FENCING) || ACCOUNT_BRIDGE_FENCING < 1) return
5734
+ scheduledRunPollBusy = true
5735
+ try {
5736
+ // Finishing a previously-opened occurrence is cleanup, not a new scheduled
5737
+ // execution. Recover it even when the owner has since disabled new runs.
5738
+ const outcomesReconciled = await recoverPendingScheduledRunOutcomes()
5739
+ // expire_code_scheduled_runs is intentionally downstream of host-outbox
5740
+ // reconciliation. Otherwise a restart can expire a delivered/decision run
5741
+ // while its idempotent finish is still waiting for a database ack.
5742
+ if (!outcomesReconciled) return
5743
+ if (!scheduledRunsEnabled()) return
5744
+ const result = await pollDueScheduledRuns({
5745
+ enabled: true, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room, bridgeId: ACCOUNT_BRIDGE_ID, bridgeFencing: ACCOUNT_BRIDGE_FENCING, ownerId: myServeUid,
5746
+ providerAvailable: scheduledProviderAvailable,
5747
+ admitRun: async ({ runId, terminalId, deadlineAt }) => {
5748
+ const localAdmission = scheduledRunSpawnAdmission({
5749
+ // The rolling burst authority is the host-wide ledger below. This
5750
+ // local gate retains the room process kill-switch and live-lane cap.
5751
+ spawnTimes: [],
5752
+ totalLive: sessions.size + terms.size,
5753
+ disabled: process.env.TP_SPAWN_OFF === '1',
5754
+ })
5755
+ if (!localAdmission.ok) return localAdmission
5756
+ return acquireScheduledRunAdmission({ runId, terminalId, deadlineAt })
5757
+ },
5758
+ releaseRun: (lease) => releaseScheduledRunAdmission(lease),
5759
+ openTerminal: async ({ schedule, run, terminalId: id, admission }) => {
5760
+ // The durable occurrence is opened before this local terminal. Never reuse
5761
+ // an existing session or worker lane for unattended work.
5762
+ const leaseExpiry = Date.parse(run.lease_expires_at)
5763
+ const scheduleDeadlineAt = Number.isFinite(leaseExpiry) ? leaseExpiry - 60_000 : NaN
5764
+ if (!Number.isFinite(scheduleDeadlineAt) || scheduleDeadlineAt <= Date.now()) return null
5765
+ const entry = openStructured({ id, runtime: schedule.runtime, provider: schedule.provider_id || undefined, model: schedule.model || undefined, mode: 'default', scheduleRunId: run.id, scheduleDeadlineAt, scheduleAdmissionLease: admission })
5766
+ if (!entry) return null
5767
+ termNames[id] = String(schedule.title || 'Scheduled run').slice(0, 80)
5768
+ saveNames(room, termNames)
5769
+ const accepted = dispatchStructuredTurn(entry, String(schedule.prompt || ''), {
5770
+ visibleEvent: { kind: 'you', text: String(schedule.prompt || ''), by: 'Scheduled run' },
5771
+ rejectionMessage: 'The scheduled run could not start; no hidden fallback was used.',
5772
+ })
5773
+ if (!accepted?.accepted) {
5774
+ // pollDueScheduledRuns owns the explicit terminal_open_failed outcome.
5775
+ // Detach this process-local entry before closing so endStructured cannot
5776
+ // race it with a synthetic canceled outcome.
5777
+ entry.scheduleRunId = null
5778
+ clearScheduledRunDeadline(entry)
5779
+ endStructured(id)
5780
+ return null
5781
+ }
5782
+ armScheduledRunDeadline(entry)
5783
+ entry.flush?.(); announce()
5784
+ return id
5785
+ },
5786
+ })
5787
+ if (!result.ok && process.env.TP_DEBUG && result.code !== 'runner_read_failed') process.stderr.write(`\n ◇ scheduled-run poll: ${result.code}\n`)
5788
+ } finally { scheduledRunPollBusy = false }
5789
+ }
5790
+ void pollScheduledRuns()
5791
+ setInterval(() => { void pollScheduledRuns() }, 15_000).unref()
5792
+
5521
5793
  // Watchdog — restart only for a Realtime-only wedge. A total network/Supabase
5522
5794
  // outage cannot be repaired by killing the room process; doing so destroys local
5523
5795
  // agent runtimes and leaves the supervisor unable to rediscover the room until HTTP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.358",
3
+ "version": "0.7.360",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,6 +63,8 @@
63
63
  "evidence-citations.mjs",
64
64
  "error-recovery.mjs",
65
65
  "pair-control-authority.mjs",
66
+ "scheduled-runs.mjs",
67
+ "scheduled-run-admission.mjs",
66
68
  "event-id.mjs",
67
69
  "event-bounds.mjs",
68
70
  "replay-transport.mjs",