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.
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,149 @@ 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
+ }
1909
+ // Realtime can carry the settled DOM for ordinary authored mockups. Including
1910
+ // that already-uploaded snapshot in the correlated verified-live event lets an
1911
+ // open Design lane advance revisions without a second auth/sign/download round
1912
+ // trip. Large artifacts keep the normal signed-storage path.
1913
+ const MAX_INLINE_DESIGN_REVISION_BYTES = 128 * 1024
1758
1914
  const designStatus = (payload) => designChannel.send({
1759
1915
  type: 'broadcast', event: 'design-status', payload: { ts: Date.now(), ...payload },
1760
1916
  })
@@ -1777,7 +1933,12 @@ const finishDesign = (term, state, extra = {}) => {
1777
1933
  const maybeVerifyDesign = (term) => {
1778
1934
  const active = designActive.get(term)
1779
1935
  if (!active?.resultOk || !active.proof) return
1780
- finishDesign(term, 'verified-live', { message: 'Verified at desktop and mobile.', artifact: active.proof, canRestore: active.record.sourceKind !== 'preview' })
1936
+ finishDesign(term, 'verified-live', {
1937
+ message: 'Verified at desktop and mobile.',
1938
+ artifact: active.proof.artifact,
1939
+ ...(active.proof.artifactHtml ? { artifactHtml: active.proof.artifactHtml } : {}),
1940
+ canRestore: active.record.sourceKind !== 'preview',
1941
+ })
1781
1942
  }
1782
1943
 
1783
1944
  function pumpDesign(term) {
@@ -1883,6 +2044,20 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
1883
2044
  const displaySource = designRecord
1884
2045
  ? resolveManifestDisplaySource(m, designRecord, { box })
1885
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
1886
2061
  // 2026-07-07: these used to swallow read errors silently — a transient
1887
2062
  // unreadable file (race with the render script, permissions, mid-write)
1888
2063
  // meant the manifest still POSTed with that field missing, and nothing
@@ -1919,6 +2094,7 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
1919
2094
  code: room, slug: m.slug, title: m.title, cid, ts: deliveryTs, term,
1920
2095
  desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? displaySource : readTxt(m.html),
1921
2096
  ...(designRecord ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}),
2097
+ ...(verifiedParentRevision ? { verified: true, parentRevision: verifiedParentRevision } : {}),
1922
2098
  }),
1923
2099
  })
1924
2100
  if (!res.ok) {
@@ -1928,8 +2104,17 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
1928
2104
  const uploaded = await res.json()
1929
2105
  const { paths } = uploaded
1930
2106
  const designAccepted = !!(designRecord && uploaded?.payload?.sourceKnown)
2107
+ const verifiedDesignRevision = !!(designAccepted
2108
+ && verifiedParentRevision
2109
+ && uploaded?.payload?.verified === true
2110
+ && uploaded.payload.parentRevision === verifiedParentRevision)
1931
2111
  const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: deliveryTs, paths,
1932
- ...(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
+ } : {}) }
1933
2118
  if (designAccepted) {
1934
2119
  Object.assign(designRecord, { term, slug: m.slug, title: m.title || m.slug, desktop: m.desktop, mobile: m.mobile })
1935
2120
  try {
@@ -1939,17 +2124,15 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
1939
2124
  if (!fs.existsSync(designRecord.backupPath)) fs.writeFileSync(designRecord.backupPath, designRecord.source)
1940
2125
  } catch { /* restore stays unavailable if the host backup cannot be written */ }
1941
2126
  designArtifacts.set(designRecord.previewId, designRecord)
1942
- const active = designActive.get(term)
1943
- const correlated = active && m.designRequestId === active.request.cid && m.parentRevision === active.record.revision
2127
+ const active = activeDesignRequest
2128
+ const correlated = verifiedDesignRevision
1944
2129
  const dual = !!(paths?.html && paths?.desktop && paths?.mobile && m.desktop && m.mobile)
1945
- const expectedRevision = active?.restoreRecord?.revision
1946
- const revisionProvesChange = expectedRevision ? designRecord.revision === expectedRevision : designRecord.revision !== active?.record?.revision
1947
- const sameTarget = active?.record?.sourceKind === 'preview'
1948
- ? designRecord.sourceKind === 'preview' && designRecord.captureKey === active.record.captureKey
1949
- : designRecord.sourcePath === active?.record?.sourcePath
1950
- if (correlated && dual && sameTarget && revisionProvesChange) {
2130
+ if (correlated && dual) {
1951
2131
  if (active.record.sourceKind !== 'preview') designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
1952
- active.proof = artifact
2132
+ const artifactHtml = Buffer.byteLength(displaySource, 'utf8') <= MAX_INLINE_DESIGN_REVISION_BYTES
2133
+ ? displaySource
2134
+ : null
2135
+ active.proof = { artifact, artifactHtml }
1953
2136
  maybeVerifyDesign(term)
1954
2137
  }
1955
2138
  }
@@ -2238,6 +2421,80 @@ function restoredTurnOpen(log) {
2238
2421
  return false
2239
2422
  }
2240
2423
 
2424
+ function emitCodexCompactionControl(entry, text, by = null) {
2425
+ const evt = { kind: 'control', text, by }
2426
+ pushLog(entry, evt)
2427
+ bcast('code-event', { term: entry.id, evt })
2428
+ }
2429
+
2430
+ async function runCodexCompaction(entry, request = {}) {
2431
+ const recap = buildRecapFromLog(entry.log, RECAP_CAP, { reason: 'compact' })
2432
+ if (!recap) {
2433
+ emitCodexCompactionControl(entry, 'nothing to compact — context unchanged', request.by)
2434
+ return
2435
+ }
2436
+ const preTokens = entry.lastUsage?.ctx?.used || null
2437
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
2438
+ let nativeCompacted = false
2439
+ try {
2440
+ nativeCompacted = await entry.session.compactContext?.()
2441
+ } catch {
2442
+ // compactContext classifies delivery certainty. A truly unexpected throw
2443
+ // remains non-replayable here; resetting after an unknown native request can
2444
+ // execute compaction twice against two different threads.
2445
+ nativeCompacted = null
2446
+ } finally {
2447
+ // Native compaction emits usage while compactActive=true, so onEvent
2448
+ // legitimately announces a busy edge. It has no ordinary result event;
2449
+ // close that borrowed edge after compactContext clears compactActive.
2450
+ if (!entry.session.turnActive && settleLaneControl(entry)) announce()
2451
+ }
2452
+ if (nativeCompacted === true) {
2453
+ const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by, native: true }
2454
+ pushLog(entry, evt)
2455
+ bcast('code-event', { term: entry.id, evt })
2456
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2457
+ entry.flush?.()
2458
+ return
2459
+ }
2460
+ if (nativeCompacted == null) {
2461
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2462
+ emitCodexCompactionControl(entry, 'Native compaction is still settling; existing context was preserved.', request.by)
2463
+ entry.flush?.()
2464
+ return
2465
+ }
2466
+ if (entry.session.clearContext?.() === false) {
2467
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2468
+ emitCodexCompactionControl(entry, 'Codex context compaction unavailable right now', request.by)
2469
+ return
2470
+ }
2471
+ entry.pendingRecap = recap
2472
+ const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by }
2473
+ pushLog(entry, evt)
2474
+ bcast('code-event', { term: entry.id, evt })
2475
+ // The new native thread has only the bounded recap queued for the next human
2476
+ // turn. Reset the visible meter to that conservative text estimate.
2477
+ if (entry.lastUsage?.ctx?.max) {
2478
+ const used = Math.ceil(recap.length / 4)
2479
+ const max = entry.lastUsage.ctx.max
2480
+ const usage = { kind: 'usage', ctx: { ...entry.lastUsage.ctx, used, max, pct: Math.min(100, Math.round((used / max) * 100)), over: used > max } }
2481
+ entry.lastUsage = usage
2482
+ bcast('code-event', { term: entry.id, evt: usage })
2483
+ }
2484
+ bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2485
+ entry.flush?.()
2486
+ }
2487
+
2488
+ function startCodexCompaction(entry, request = {}) {
2489
+ if (!entry || entry.runtime !== 'codex' || entry.codexCompactionPromise) return false
2490
+ const job = runCodexCompaction(entry, request)
2491
+ entry.codexCompactionPromise = job
2492
+ void job.finally(() => {
2493
+ if (entry.codexCompactionPromise === job) entry.codexCompactionPromise = null
2494
+ }).catch(() => { /* runCodexCompaction already surfaced classified failures */ })
2495
+ return true
2496
+ }
2497
+
2241
2498
  // Active-worktree snapshot for the per-turn ROOM NOW tail. Cached ~60s per cwd —
2242
2499
  // a subprocess on every turn of every lane would be waste, and worktrees change on
2243
2500
  // minutes-scale. Plain `git worktree list` output (path · sha · [branch]) is the
@@ -2266,7 +2523,7 @@ function worktreeSnapshot(cwd) {
2266
2523
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2267
2524
  // persist to the host file; tool calls round-trip through the perm card; the
2268
2525
  // rolling log replays to joiners and survives bridge restarts (session-store).
2269
- 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 }) {
2270
2527
  if (sessions.has(id)) return
2271
2528
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2272
2529
  // Fail closed before exposing a native lane if its bridge semantic contract
@@ -2313,6 +2570,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2313
2570
  const restoredRawLog = Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : []
2314
2571
  const restoredLog = boundStructuredLog(restoredRawLog)
2315
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
2316
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,
2317
2580
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
2318
2581
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
@@ -2326,7 +2589,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2326
2589
  : (!provider || provider === 'anthropic')
2327
2590
  ? (laneModel || null)
2328
2591
  : (laneModel || providerNameMap()[provider] || provider),
2329
- 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,
2330
2593
  sliceType: sliceType === 'review' ? 'review' : null,
2331
2594
  flowRole: flowRole || (flowSessionId ? (flowTaskKey ? ((flowReviewTargets?.length || reviewSliceRoots?.length) ? 'reviewer' : 'builder') : 'conductor') : null),
2332
2595
  flowReviewTarget: flowReviewTarget || null,
@@ -2563,6 +2826,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2563
2826
  // with no archive yet; pushLog stamps it from the first appended event.
2564
2827
  entry.archiveOldestSeq = readDurableOldestSeq(room, id)
2565
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)
2566
2833
  // Close an interrupted turn so a restored terminal comes back IDLE-with-history
2567
2834
  // instead of stuck "thinking" (see restoredTurnOpen). Idempotent: the closer is
2568
2835
  // persisted, so a later resume sees a closed turn and no-ops.
@@ -2603,13 +2870,18 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2603
2870
  // restart. Without this, sessionData omitted it → on restart the resumed session
2604
2871
  // re-launched on the host default (Opus) regardless of the last switch, and the
2605
2872
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2606
- 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) })
2607
2874
  const persist = () => saveSession(room, id, sessionData())
2608
2875
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2609
2876
  // has a file under its id BEFORE its first event — surviving a restart inside the
2610
2877
  // 1.5s saveSession debounce window) and on shutdown (so events since the last
2611
2878
  // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
2612
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
+ }
2613
2885
  // Main-terminal creation and Ensemble dispatch are different capabilities.
2614
2886
  // A main terminal (human-opened or agent-opened via open_main_terminal) may
2615
2887
  // dispatch workers. A spawned/Side/Flow lane can never elevate itself or a
@@ -3656,6 +3928,15 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3656
3928
  pushLog(entry, ce)
3657
3929
  bcast('code-event', { term: id, evt: ce })
3658
3930
  }
3931
+ // A busy Codex lane accepts one deferred /compact intent. Claim the
3932
+ // compaction synchronously at the terminal result boundary so a new human
3933
+ // turn cannot slip into the gap, and coalesce every repeated tap into this
3934
+ // one job.
3935
+ if ((evt.kind === 'result' || evt.kind === 'error') && entry.runtime === 'codex' && entry.pendingCodexCompaction) {
3936
+ const request = entry.pendingCodexCompaction
3937
+ entry.pendingCodexCompaction = null
3938
+ startCodexCompaction(entry, request)
3939
+ }
3659
3940
  // ── Slice 3: the turn settled. Two things happen, in this order.
3660
3941
  // 1. Every outstanding permission card on this lane is now dead (the SDK
3661
3942
  // won't ask again for a turn that ended) — clearAll retracts any
@@ -3773,6 +4054,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3773
4054
  if (mockupDeliveryBoundary) {
3774
4055
  queueMicrotask(() => { mockupDeliveries.flush(id).catch(() => {}) })
3775
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
+ }
3776
4065
  }
3777
4066
  // A tool_result carrying an inline base64 image can't ride a broadcast frame
3778
4067
  // (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
@@ -4009,6 +4298,8 @@ function endStructured(id) {
4009
4298
  if (!id) return
4010
4299
  const s = sessions.get(id)
4011
4300
  if (s) {
4301
+ recordScheduledRunOutcome(s, { subtype: 'canceled' }, { canceled: true })
4302
+ clearScheduledRunDeadline(s)
4012
4303
  s.imageQueue?.close()
4013
4304
  mockupDeliveries.clear(id)
4014
4305
  drainPending(s)
@@ -4678,6 +4969,7 @@ channel
4678
4969
  }
4679
4970
  if (/^\/clear\s*$/.test(text)) {
4680
4971
  s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
4972
+ s.pendingCodexCompaction = null // explicit clear supersedes a deferred compact
4681
4973
  if (s.runtime === 'codex' || s.runtime === 'hermes') s.session.clearContext?.()
4682
4974
  else s.session.sendTurn(text)
4683
4975
  // The seq reset is load-bearing on BOTH ends: the client's clear handler sets
@@ -4698,61 +4990,16 @@ channel
4698
4990
  // No persisted ctl line — the live indicator + the CompactionCard are the record.
4699
4991
  if (/^\/compact\s*$/.test(text)) {
4700
4992
  if (s.runtime === 'codex') {
4993
+ // One lifecycle owns every distinct press. During an ordinary turn the
4994
+ // first request is queued and truthfully runs at its result boundary;
4995
+ // later taps, including taps during native compaction, coalesce silently.
4996
+ if (s.codexCompactionPromise || s.pendingCodexCompaction) return
4701
4997
  if (s.session.turnActive) {
4702
- ctlLine('Compact after this turn ends.', { tone: 'caution' })
4703
- return
4704
- }
4705
- const recap = buildRecapFromLog(s.log, RECAP_CAP, { reason: 'compact' })
4706
- if (!recap) {
4707
- ctlLine('nothing to compact — context unchanged')
4708
- return
4709
- }
4710
- const preTokens = s.lastUsage?.ctx?.used || null
4711
- bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
4712
- let nativeCompacted = false
4713
- try {
4714
- nativeCompacted = await s.session.compactContext?.()
4715
- } catch {
4716
- // The bounded recap below is the safe fallback for a native transport
4717
- // failure. The roster edge is still settled in finally.
4718
- } finally {
4719
- // Native compaction emits usage while compactActive=true, so onEvent
4720
- // legitimately announces a busy edge. It has no ordinary result event;
4721
- // close that borrowed edge here after compactContext's finally clears
4722
- // compactActive, or "Compacting…" degrades into stale "Thinking…".
4723
- // A browser turn may have arrived while compactContext awaited App
4724
- // Server readiness. Never let control cleanup settle that ordinary
4725
- // turn's busy edge; the normal result boundary owns it.
4726
- if (!s.session.turnActive && settleLaneControl(s)) announce()
4727
- }
4728
- if (nativeCompacted) {
4729
- const ce = { kind: 'compaction', trigger: 'manual', preTokens, by: payload.by, native: true }
4730
- pushLog(s, ce)
4731
- bcast('code-event', { term: payload.term, evt: ce })
4732
- bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
4733
- s.flush?.()
4734
- return
4735
- }
4736
- if (s.session.clearContext?.() === false) {
4737
- bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
4738
- ctlLine('Codex context compaction unavailable right now')
4998
+ s.pendingCodexCompaction = { by: payload.by, cid: payload.cid }
4999
+ ctlLine('Compaction queued for after this turn.', { tone: 'caution' })
4739
5000
  return
4740
5001
  }
4741
- s.pendingRecap = recap
4742
- const ce = { kind: 'compaction', trigger: 'manual', preTokens, by: payload.by }
4743
- pushLog(s, ce)
4744
- bcast('code-event', { term: payload.term, evt: ce })
4745
- // The new native thread has only the bounded recap queued for the next
4746
- // human turn. Reset the visible meter to that conservative text estimate.
4747
- if (s.lastUsage?.ctx?.max) {
4748
- const used = Math.ceil(recap.length / 4)
4749
- const max = s.lastUsage.ctx.max
4750
- const usage = { kind: 'usage', ctx: { ...s.lastUsage.ctx, used, max, pct: Math.min(100, Math.round((used / max) * 100)), over: used > max } }
4751
- s.lastUsage = usage
4752
- bcast('code-event', { term: payload.term, evt: usage })
4753
- }
4754
- bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
4755
- s.flush?.()
5002
+ startCodexCompaction(s, { by: payload.by, cid: payload.cid })
4756
5003
  return
4757
5004
  }
4758
5005
  s.compacting = true
@@ -4989,7 +5236,7 @@ channel
4989
5236
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
4990
5237
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
4991
5238
  // lanes keep their worktree cwd + the ability to mark done.
4992
- 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,
4993
5240
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
4994
5241
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
4995
5242
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -5466,6 +5713,83 @@ designChannel
5466
5713
  if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ design realtime ${status} (tpdesign:${room}).\n`)
5467
5714
  })
5468
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
+
5469
5793
  // Watchdog — restart only for a Realtime-only wedge. A total network/Supabase
5470
5794
  // outage cannot be repaired by killing the room process; doing so destroys local
5471
5795
  // agent runtimes and leaves the supervisor unable to rediscover the room until HTTP