@gotcos/glasses-server 6.77.0 → 6.78.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +96 -10
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/env-switch.ts +17 -0
  13. package/server/lib/managed-restart.ts +8 -0
  14. package/server/lib/native-head.ts +65 -6
  15. package/server/lib/provider-actions.ts +18 -0
  16. package/server/lib/query-job-coordinator.ts +300 -0
  17. package/server/lib/query-job-feature.ts +39 -0
  18. package/server/lib/query-job-resume.ts +269 -0
  19. package/server/lib/query-job-runtime.ts +23 -3
  20. package/server/lib/query-job-store.ts +516 -7
  21. package/server/lib/query-job-types.ts +210 -1
  22. package/server/lib/queue-turn-evidence.ts +213 -0
  23. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  24. package/server/lib/thread-turn-queue-store.ts +13 -4
  25. package/server/lib/thread-turn-queue.ts +151 -1
  26. package/server/routes/agent-session-bindings.ts +169 -11
  27. package/server/routes/health.ts +26 -1
  28. package/server/routes/maintenance.ts +34 -0
  29. package/server/routes/meeting.ts +27 -9
  30. package/server/routes/query-jobs.ts +107 -13
  31. package/server/routes/recovery.ts +4 -0
  32. package/server/routes/thread-turn-queue.ts +371 -10
  33. package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +0 -170
  34. package/server/scripts/transcript-cleanup-mutation-gate.mjs +0 -219
@@ -63,6 +63,8 @@
63
63
  // event, and positive evidence of an OPEN turn outranks it. Same reasoning as the
64
64
  // session-trail handoff, one layer down.
65
65
 
66
+ import { readSwitch } from './env-switch.js'
67
+
66
68
  /** Terminal-ish states a queued turn can reach. `waiting` is the only live one. */
67
69
  export type QueuedTurnStatus =
68
70
  | 'waiting'
@@ -93,6 +95,107 @@ export interface QueuedThreadTurn {
93
95
  * only for a client that said it would show it. Absent: the 6.61 Ask posture.
94
96
  */
95
97
  continueNoteAck?: true
98
+ // ---------------------------------------------------------------- 6.78.0 (P3)
99
+ // Every field below is OPTIONAL and written only while its flag is on, so with
100
+ // COS_QUEUE_RECONCILE, COS_QUEUE_RESEND and COS_SESSION_STEER all off a queue file is
101
+ // byte-for-byte what 6.77.0 writes. A 6.77 server reading a 6.78 file ignores them.
102
+ /**
103
+ * Stamped (`1`) when this row entered `delivering` under the attempt journal (6.78.0).
104
+ * Its ABSENCE on a `delivering` row is the whole point: a row with no stamp was written by
105
+ * a build that kept no attempt record, so "no attempt on file" proves nothing about it and
106
+ * the reconcile reads it as unknown, never as unsent.
107
+ */
108
+ journal?: 1
109
+ /** The server boot that moved this row to `delivering`. A different boot means it was cut off. */
110
+ deliveringBootId?: string
111
+ /** `unknown`: COS cannot establish whether the words reached the session. Never resent. */
112
+ deliveryState?: 'unknown'
113
+ /** `steer`: sent into the running turn through the session's peer inbox. Absent: `queue`. */
114
+ mode?: 'steer'
115
+ /** Send again: the row this one was copied from. */
116
+ resendOf?: string
117
+ /** Send again: the row that was copied from this one. Set once, and only once. */
118
+ resentAs?: string
119
+ }
120
+
121
+ // ------------------------------------------------------------------- 6.78.0 flags
122
+ //
123
+ // Read at CALL time through the one 6.78.0 switch reader (lib/env-switch.ts): `1`, `true`
124
+ // or `on` is on, `0`, `false` or `off` is off, anything else is the default (P7 S3: an exact
125
+ // `0`/`1` reader here made `COS_QUEUE_RECONCILE=false` read ON). One reader per flag, so
126
+ // every caller (routes, registry, drain, health) decides with the same answer in the same tick.
127
+ function triStateFlag(env: NodeJS.ProcessEnv, name: string, fallback: boolean): boolean {
128
+ return readSwitch(env[name], fallback)
129
+ }
130
+
131
+ /** `COS_QUEUE_RECONCILE`: the attempt journal, the restart reconcile, receipts, unknown-row retention and reason copy. Default ON. */
132
+ export function queueReconcileEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
133
+ return triStateFlag(env, 'COS_QUEUE_RECONCILE', true)
134
+ }
135
+
136
+ /** `COS_PIN_BOOT_REAP`: release a pin a dead boot left, once its orphan child must be gone (21 min). Default ON. */
137
+ export function pinBootReapEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
138
+ return triStateFlag(env, 'COS_PIN_BOOT_REAP', true)
139
+ }
140
+
141
+ /** `COS_QUEUE_RESEND`: Send again on a refused or expired row. Default ON. */
142
+ export function queueResendEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
143
+ return triStateFlag(env, 'COS_QUEUE_RESEND', true)
144
+ }
145
+
146
+ /** `COS_SESSION_STEER`: steer a busy Claude session through its peer inbox. Default OFF. */
147
+ export function sessionSteerEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
148
+ return triStateFlag(env, 'COS_SESSION_STEER', false)
149
+ }
150
+
151
+ /**
152
+ * Reasons whose outcome COS cannot establish. Such a row is never resent and is kept for
153
+ * UNKNOWN_DELIVERY_RETENTION_MS rather than 30 minutes, because the phone has to be able to
154
+ * read "may not have been sent" for as long as the person might come back to it.
155
+ */
156
+ export const UNKNOWN_DELIVERY_REASONS: ReadonlySet<string> = new Set([
157
+ 'delivery_unknown_after_restart',
158
+ 'steer_unverified',
159
+ 'delivery_ambiguous',
160
+ ])
161
+
162
+ /** How long a settled row with an unknown outcome stays readable. */
163
+ export const UNKNOWN_DELIVERY_RETENTION_MS = 6 * 60 * 60 * 1000
164
+
165
+ /** The lens body is 62 columns; every reason line fits one. */
166
+ export const QUEUED_TURN_REASON_COPY_MAX = 62
167
+
168
+ /** One line per reason, server-authored. Keys are the reasons a row can carry. */
169
+ const QUEUED_TURN_REASON_COPY: Readonly<Record<string, string>> = {
170
+ delivery_unknown_after_restart: 'May not have been sent. Read it, then say it again.',
171
+ steer_unverified: 'May not have been sent. Read it, then say it again.',
172
+ delivery_ambiguous: 'May not have been sent. Read it, then say it again.',
173
+ steered: 'Sent into the running turn.',
174
+ reconciled_after_restart: 'Sent before the server restarted.',
175
+ requeued_after_restart: 'Not sent before the restart. Waiting to send.',
176
+ queued_turn_expired: 'Not sent: the thread stayed busy too long.',
177
+ delivery_attempts_exhausted: 'Not sent: the session refused it five times.',
178
+ turn_cancelled: 'Cancelled before it was sent.',
179
+ steer_not_sent: 'Not sent: the session could not take it mid-turn.',
180
+ native_thread_working: 'Waiting for the current turn to end.',
181
+ live_desktop_process: 'Waiting for the current turn to end.',
182
+ native_target_busy: 'Waiting: another COS turn holds this thread.',
183
+ native_turn_in_progress: 'Waiting: a COS turn is running on this thread.',
184
+ native_target_fenced: 'Waiting: an earlier turn needs a check on your Mac.',
185
+ }
186
+
187
+ /**
188
+ * The reason line for a queue row, or null when there is nothing to say (no reason, or one
189
+ * this build has no words for: a raw code is never shown as if it were a sentence).
190
+ */
191
+ export function queuedTurnReasonCopy(reason: string | null | undefined): string | null {
192
+ if (typeof reason !== 'string' || reason.length === 0) return null
193
+ return Object.prototype.hasOwnProperty.call(QUEUED_TURN_REASON_COPY, reason) ? QUEUED_TURN_REASON_COPY[reason] : null
194
+ }
195
+
196
+ /** Every reason with copy, for the column test. */
197
+ export function queuedTurnReasonCopyKeys(): string[] {
198
+ return Object.keys(QUEUED_TURN_REASON_COPY)
96
199
  }
97
200
 
98
201
  /**
@@ -286,10 +389,14 @@ export function pruneQueue(
286
389
  now: number,
287
390
  settledRetentionMs: number = 30 * 60 * 1000,
288
391
  ): QueuedThreadTurn[] {
392
+ // 6.78.0: a row whose outcome is unknown stays for six hours (COS_QUEUE_RECONCILE). Read
393
+ // once per prune, so one pass cannot keep some rows by the new rule and drop others by the old.
394
+ const keepUnknown = queueReconcileEnabled()
289
395
  return queue.filter(t => {
290
396
  if (t.status === 'waiting' || t.status === 'delivering') return true
291
397
  const settled = typeof t.settledAt === 'number' ? t.settledAt : t.queuedAt
292
- return now - settled < settledRetentionMs
398
+ const unknown = keepUnknown && (t.deliveryState === 'unknown' || (typeof t.reason === 'string' && UNKNOWN_DELIVERY_REASONS.has(t.reason)))
399
+ return now - settled < (unknown ? Math.max(settledRetentionMs, UNKNOWN_DELIVERY_RETENTION_MS) : settledRetentionMs)
293
400
  })
294
401
  }
295
402
 
@@ -297,3 +404,46 @@ export function pruneQueue(
297
404
  export function queuePosition(queue: readonly QueuedThreadTurn[], clientTurnId: string): number {
298
405
  return queue.filter(t => t.status === 'waiting').findIndex(t => t.clientTurnId === clientTurnId)
299
406
  }
407
+
408
+ // ------------------------------------------------------------- 6.78.0 session steer
409
+
410
+ /** Why a busy session cannot take a steer right now (the wire enum, plan v3.1 section 5.3). */
411
+ export type SessionSteerUnavailable = 'no_outside_channel' | 'engine_limit' | 'permission_pending'
412
+
413
+ export type SessionSteerAvailability =
414
+ | { available: true; mode: 'peer_inbox' }
415
+ | { available: false; reason: SessionSteerUnavailable }
416
+
417
+ /** Occupancy reasons that mean "a session outside COS is running this thread". Only those can be steered. */
418
+ const STEERABLE_GATE_REASONS: ReadonlySet<string> = new Set(['native_thread_working', 'live_desktop_process'])
419
+
420
+ /**
421
+ * Can a turn go INTO the running turn of this thread now (6.78.0, COS_SESSION_STEER)?
422
+ *
423
+ * PURE, one rule for the attach refusal's `steer` field and the queued-turns POST, so the
424
+ * row the lens offers and the answer to the tap agree.
425
+ * cursor engine_limit: the Stop hook is its only door
426
+ * codex no_outside_channel: `turn/steer` lives in an app-server COS does not hold
427
+ * claude, live hop off no_outside_channel (`COS_CONTINUE_LIVE=0`)
428
+ * claude, not a desk run no_outside_channel: COS's own binding or turn, a fence, or a
429
+ * thread nobody could measure; nothing outside COS to fold into
430
+ * claude, no peer inbox no_outside_channel
431
+ * claude, held on a prompt permission_pending: a session waiting on a dialog reaches no
432
+ * tool boundary, and the steer verify window (4 s) is far shorter
433
+ * than the hook's wait (630 s); it is queued for the turn's end
434
+ */
435
+ export function sessionSteerAvailability(input: {
436
+ provider: string
437
+ gateReason: string | null
438
+ continueLive: boolean
439
+ peerInbox: boolean
440
+ waitingOnPrompt: boolean
441
+ }): SessionSteerAvailability {
442
+ if (input.provider === 'cursor') return { available: false, reason: 'engine_limit' }
443
+ if (input.provider !== 'claude') return { available: false, reason: 'no_outside_channel' }
444
+ if (!input.continueLive) return { available: false, reason: 'no_outside_channel' }
445
+ if (typeof input.gateReason !== 'string' || !STEERABLE_GATE_REASONS.has(input.gateReason)) return { available: false, reason: 'no_outside_channel' }
446
+ if (!input.peerInbox) return { available: false, reason: 'no_outside_channel' }
447
+ if (input.waitingOnPrompt) return { available: false, reason: 'permission_pending' }
448
+ return { available: true, mode: 'peer_inbox' }
449
+ }
@@ -122,7 +122,10 @@ import {
122
122
  type NativeBinding,
123
123
  } from '../lib/agent-session-binding-store.js'
124
124
  import { boundedContinueNote, CONTINUE_NOTE_HEADER, continueNoteAcknowledged, fallbackContinueNote } from '../lib/continue-plan.js'
125
- import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
125
+ import type { PinExtras, RegistryCheck, RegistryRejection, RegistryResult, UnpinExtras } from '../lib/agent-session-binding-registry.js'
126
+ import { ORPHAN_CHILD_RUNNING_COPY } from '../lib/agent-session-binding-registry.js'
127
+ import { pinBootReapEnabled, queueableRefusal, queueReconcileEnabled, sessionSteerEnabled, type SessionSteerAvailability } from '../lib/thread-turn-queue.js'
128
+ import type { TurnReceipt } from '../lib/queue-turn-evidence.js'
126
129
  import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
127
130
  import { isValidNativeThreadId } from '../lib/native-thread-id.js'
128
131
  import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
@@ -212,9 +215,24 @@ export interface BindingRegistry {
212
215
  claim: { bindingId: string; epoch: number; targetKey: string },
213
216
  now: number,
214
217
  ) => RegistryCheck
215
- /** A rejection here is FATAL to the turn, per the registry's own caller contract. */
216
- pin?: (bindingId: string, jobId: string, now: number) => RegistryResult
217
- unpin?: (bindingId: string, jobId: string, now: number) => RegistryResult
218
+ /**
219
+ * A rejection here is FATAL to the turn, per the registry's own caller contract.
220
+ * 6.78.0: `extras` carries the pin's client turn and opens its attempt in the same commit;
221
+ * passed only while COS_PIN_BOOT_REAP or COS_QUEUE_RECONCILE is on.
222
+ */
223
+ pin?: (bindingId: string, jobId: string, now: number, extras?: PinExtras) => RegistryResult
224
+ /** 6.78.0: `extras` records how the turn's delivery ended, in the same commit as the release. */
225
+ unpin?: (bindingId: string, jobId: string, now: number, extras?: UnpinExtras) => RegistryResult
226
+ /**
227
+ * 6.78.0: is this target held only by pins a dead boot left, and until when? Optional; the
228
+ * refusal copy says when the thread frees, or keeps its 6.77 words without it.
229
+ */
230
+ orphanPinHold?: (provider: string, nativeThreadId: string, now: number) => { releasesAt: number; childRunning?: true } | null
231
+ /**
232
+ * 6.78.0 (P7 S6): record the resume child a pinned turn spawned (pid and measured start),
233
+ * so a crash's orphan pin waits for that process. Optional; a failure changes nothing.
234
+ */
235
+ notePinChild?: (bindingId: string, jobId: string, child: { pid: number; startMs: number }) => RegistryResult
218
236
  }
219
237
 
220
238
  /** What attach resolves server-side because plan 4.2 forbids the client sending it. */
@@ -411,6 +429,21 @@ export interface AgentSessionBindingsDeps {
411
429
  /** Overrides the env gate. Tests only; production reads threadAttachEnabled(). */
412
430
  attachEnabled?: boolean
413
431
 
432
+ /**
433
+ * 6.78.0 (COS_QUEUE_RECONCILE): was this client turn already sent, or may it have been?
434
+ * `makeTurnReceiptLookup`: the queue's SETTLED rows (never waiting or delivering), the
435
+ * attempt journal, and the transcript for an unknown. Consulted after the ledger replay
436
+ * and before the claim; a hit is replayed, never sent again. Absent: 6.77 behaviour.
437
+ */
438
+ turnReceipt?: (provider: string, nativeThreadId: string, clientTurnId: string, prompt: string) => TurnReceipt | null
439
+
440
+ /**
441
+ * 6.78.0 (COS_SESSION_STEER): could a turn go INTO this thread's running turn? Added to an
442
+ * attach refusal as `steer`. The router also checks the flag itself, so with it off the
443
+ * field is absent whatever is wired.
444
+ */
445
+ sessionSteer?: (provider: string, nativeThreadId: string, gateReason: string) => SessionSteerAvailability | null
446
+
414
447
  ownership?: {
415
448
  record: (pid: number, startMs: number) => string
416
449
  release: (pid: number) => boolean
@@ -1607,6 +1640,17 @@ export const CONTINUE_NOTE_BUDGET_MS = 1_500
1607
1640
  */
1608
1641
  export const TURN_QUEUED_COPY = 'Queued to the original thread. It keeps running if you put your phone away.'
1609
1642
 
1643
+ /**
1644
+ * 6.78.0: the refusal copy while a pin a dead boot left holds the thread. The time is the
1645
+ * Mac's local clock, rounded UP to the minute, so the thread is never shown free early.
1646
+ * 62 columns or fewer, no em dash, no arrow.
1647
+ */
1648
+ export function orphanPinWaitCopy(releasesAt: number): string {
1649
+ const at = new Date(Math.ceil(releasesAt / 60_000) * 60_000)
1650
+ const hour = at.getHours() % 12 || 12
1651
+ return `An earlier run may still be running · frees ${hour}:${String(at.getMinutes()).padStart(2, '0')}`
1652
+ }
1653
+
1610
1654
  /** Status of a turn that is admitted and still running. */
1611
1655
  export const TURN_PENDING_COPY = 'Still working on your Mac.'
1612
1656
 
@@ -1878,14 +1922,39 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1878
1922
  const runOccupancy = (provider: string, threadId: string): AttachabilityBody =>
1879
1923
  projectAttachability(routeOccupancy(provider, threadId))
1880
1924
 
1881
- const refuseAttach = (res: Response, reason: WriteRefusal): void => {
1925
+ const refuseAttach = (res: Response, reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
1882
1926
  res.status(refusalStatus(reason)).json({
1883
1927
  attached: false,
1884
1928
  reason,
1885
1929
  reasonCopy: writeReasonCopy(reason),
1930
+ ...extra,
1886
1931
  })
1887
1932
  }
1888
1933
 
1934
+ /**
1935
+ * 6.78.0: what an attach refusal on THIS thread adds. `steer` (COS_SESSION_STEER) on a
1936
+ * refusal a turn may queue behind, from the shared rule. And when the holder is a pin a
1937
+ * dead boot left (COS_PIN_BOOT_REAP), the copy says when it frees instead of telling the
1938
+ * person to detach a chat that no longer exists. Empty with the flags off.
1939
+ */
1940
+ const attachRefusalExtras = (provider: string, threadId: string, reason: WriteRefusal, now: number | null): Record<string, unknown> => {
1941
+ const extra: Record<string, unknown> = {}
1942
+ if (reason === 'native_target_busy' && now !== null && typeof deps.bindings?.orphanPinHold === 'function') {
1943
+ let hold: { releasesAt: number; childRunning?: true } | null = null
1944
+ try { hold = deps.bindings.orphanPinHold(provider, threadId, now) } catch { hold = null }
1945
+ if (hold && Number.isFinite(hold.releasesAt)) extra.reasonCopy = orphanPinWaitCopy(hold.releasesAt)
1946
+ // P7 S6: past the window, the dead boot's child itself still runs: no time to promise
1947
+ // (such a hold carries no finite releasesAt, so the line above never wrote).
1948
+ if (hold?.childRunning === true) extra.reasonCopy = ORPHAN_CHILD_RUNNING_COPY
1949
+ }
1950
+ if (sessionSteerEnabled() && typeof deps.sessionSteer === 'function' && queueableRefusal(reason)) {
1951
+ let steer: SessionSteerAvailability | null = null
1952
+ try { steer = deps.sessionSteer(provider, threadId, reason) } catch { steer = null }
1953
+ if (steer) extra.steer = steer
1954
+ }
1955
+ return extra
1956
+ }
1957
+
1889
1958
  /** Read a finite clock, or null. A route that cannot tell the time refuses. */
1890
1959
  const readNow = (): number | null => {
1891
1960
  try {
@@ -2335,7 +2404,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2335
2404
  // or any doubt about whether it has one — is Fork-only, and attach is the
2336
2405
  // gate that makes that true.
2337
2406
  const verdict = runOccupancy(providerParam, threadIdParam)
2338
- if (!verdict.attachable) return refuseAttach(res, verdict.reason ?? 'probe_failed')
2407
+ if (!verdict.attachable) {
2408
+ const refused = verdict.reason ?? 'probe_failed'
2409
+ return refuseAttach(res, refused, attachRefusalExtras(providerParam, threadIdParam, refused, now))
2410
+ }
2339
2411
 
2340
2412
  // `runOccupancy` already proved both, provider first. These narrow the types
2341
2413
  // rather than re-deciding the rule — a second copy of the id rule is how the
@@ -2352,7 +2424,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2352
2424
  // a per-binding fence.
2353
2425
  if (fenced !== null) {
2354
2426
  console.warn(`[agent-session-bindings] fence hit route=attach provider=${providerParam} target=${opaqueRevision(key)}`)
2355
- return refuseAttach(res, fenced)
2427
+ return refuseAttach(res, fenced, attachRefusalExtras(providerParam, threadIdParam, fenced, now))
2356
2428
  }
2357
2429
 
2358
2430
  const resolve = deps.resolveTarget
@@ -2391,7 +2463,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2391
2463
  ttlMs: attachTtlMs,
2392
2464
  now,
2393
2465
  })
2394
- if (!created?.binding) return refuseAttach(res, registryRefusal(created?.reason))
2466
+ if (!created?.binding) {
2467
+ const refused = registryRefusal(created?.reason)
2468
+ return refuseAttach(res, refused, attachRefusalExtras(providerParam, threadIdParam, refused, now))
2469
+ }
2395
2470
 
2396
2471
  const activated = deps.bindings.activate!(bindingId, now)
2397
2472
  if (!activated?.binding) {
@@ -2775,6 +2850,17 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2775
2850
  * alone is backgrounded once the last gate passes.
2776
2851
  */
2777
2852
  let queued = false
2853
+ /**
2854
+ * 6.78.0 (COS_QUEUE_RECONCILE): the attempt journal. Opened with the pin (same commit),
2855
+ * settled with the unpin. Where the turn MAY have gone: `proven` once a hop showed it
2856
+ * arrived; `liveMay` / `spawnMay` once a hop that could have written was entered and did
2857
+ * not prove nothing reached. `not_sent` only when every hop entered said nothing did.
2858
+ */
2859
+ let attemptOpened = false
2860
+ let proven = false
2861
+ let liveMay = false
2862
+ let spawnMay = false
2863
+ const attemptExit = (): 'sent' | 'not_sent' | 'unknown' => (proven ? 'sent' : liveMay || spawnMay ? 'unknown' : 'not_sent')
2778
2864
 
2779
2865
  const respond = (status: number, payload: Record<string, unknown>): void => {
2780
2866
  if (!res.headersSent) {
@@ -2919,6 +3005,46 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2919
3005
  return
2920
3006
  }
2921
3007
 
3008
+ // 6.78.0: RECEIPTS, before any gate, claim, pin or hop. The ledger above is dropped
3009
+ // with its binding and records nothing for a pre-202 `live_unverified`; a crash after
3010
+ // the pin leaves no record at all. The receipt covers those (target-scoped attempt
3011
+ // journal, the queue's settled rows), so a phone that retries a key after a reconnect
3012
+ // or a restart can never put the same words in twice. A hit is replayed, never sent:
3013
+ // `delivered` as a completed turn, `unknown` as the ambiguous outcome every client
3014
+ // already treats as "do not send again".
3015
+ if (queueReconcileEnabled() && typeof deps.turnReceipt === 'function') {
3016
+ const owner = deps.bindings.get?.(bindingId) ?? null
3017
+ if (owner && isBindableProvider(owner.provider) && isValidNativeThreadId(owner.nativeThreadId)) {
3018
+ let receipt: TurnReceipt | null
3019
+ try {
3020
+ receipt = deps.turnReceipt(owner.provider, owner.nativeThreadId, clientTurnId, prompt)
3021
+ } catch (error) {
3022
+ // A receipt we could not read is not "never sent". Fail closed: unknown.
3023
+ console.error(`[agent-session-bindings] turn receipt read failed: ${error instanceof Error ? error.message : error}`)
3024
+ receipt = { kind: 'unknown', source: 'attempt' }
3025
+ }
3026
+ if (receipt !== null) {
3027
+ const delivered = receipt.kind === 'delivered'
3028
+ if (!res.headersSent) {
3029
+ res.status(delivered ? 200 : refusalStatus('delivery_ambiguous')).json({
3030
+ turnId,
3031
+ outcome: delivered ? 'completed' : 'ambiguous',
3032
+ deliveryState: delivered ? 'delivered' : 'unknown',
3033
+ retryable: false,
3034
+ changed: false,
3035
+ revision: null,
3036
+ reason: delivered ? null : 'delivery_ambiguous',
3037
+ reasonCopy: delivered ? TURN_SENT_COPY : writeReasonCopy('delivery_ambiguous'),
3038
+ replayed: true,
3039
+ receipt: receipt.source,
3040
+ })
3041
+ console.log(turnTiming(`receipt/${receipt.kind}/${receipt.source}`))
3042
+ }
3043
+ return
3044
+ }
3045
+ }
3046
+ }
3047
+
2922
3048
  // The client-queued-prompt gate, used rather than reimplemented: it is the
2923
3049
  // one place that orders state before epoch before target, and a second
2924
3050
  // opinion here is how the store's own header says the two drifted apart.
@@ -3004,10 +3130,19 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3004
3130
 
3005
3131
  // A failed pin is FATAL, per the registry's own caller contract: an unpinned
3006
3132
  // binding can expire or be detached mid-turn, which defeats the lease.
3007
- const pinned = deps.bindings.pin!(bindingId, turnId, now)
3133
+ // 6.78.0: the pin names its client turn (COS_PIN_BOOT_REAP) and opens the attempt
3134
+ // (COS_QUEUE_RECONCILE) in its own commit: on disk before any frame or spawn below.
3135
+ const pinExtras: PinExtras = {
3136
+ ...(pinBootReapEnabled() ? { pinMeta: { clientTurnId } } : {}),
3137
+ ...(queueReconcileEnabled() ? { attempt: { clientTurnId, mode: 'turn' as const } } : {}),
3138
+ }
3139
+ const pinned = Object.keys(pinExtras).length > 0
3140
+ ? deps.bindings.pin!(bindingId, turnId, now, pinExtras)
3141
+ : deps.bindings.pin!(bindingId, turnId, now)
3008
3142
  stage('pin')
3009
3143
  if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
3010
3144
  pinnedBindingId = bindingId
3145
+ attemptOpened = pinExtras.attempt !== undefined
3011
3146
 
3012
3147
  // 6.53.0: a cancel that landed during the gate or the head read. Before the live hop,
3013
3148
  // which cannot be recalled once it writes into the open session. Nothing was sent;
@@ -3051,7 +3186,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3051
3186
  live = null
3052
3187
  }
3053
3188
  stage(`live(${live?.reason ?? 'threw'})`)
3189
+ // 6.78.0: a throw is a transport bug, not proof the frame never left.
3190
+ if (live === null) liveMay = true
3054
3191
  if (live?.ok) {
3192
+ proven = true
3055
3193
  // 6.53.3: delivered, so the turn runs in the open session now, and what stops it
3056
3194
  // there is the desk halt marker. The turn itself WAS delivered and says so.
3057
3195
  const latched = latchedHere()
@@ -3071,6 +3209,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3071
3209
  return
3072
3210
  }
3073
3211
  if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
3212
+ liveMay = true
3074
3213
  // 6.53.3: something MAY be in the session and the person asked for it to stop: arm
3075
3214
  // the marker as for a landed turn, and settle this turn as cancelled, never as a
3076
3215
  // retryable hold that the queue would send again after the cancel's two minutes.
@@ -3123,7 +3262,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3123
3262
  live = null
3124
3263
  }
3125
3264
  stage(`codex-live(${live?.reason ?? 'threw'})`)
3265
+ // 6.78.0: a row MAY be in the Codex app's queue after a throw or an unverified write.
3266
+ if (live === null || live.reason === 'unverified') liveMay = true
3126
3267
  if (live?.ok) {
3268
+ proven = true
3127
3269
  // 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
3128
3270
  const latched = latchedHere()
3129
3271
  if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt, busy: busyCodexHop })
@@ -3221,6 +3363,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3221
3363
 
3222
3364
  let delivery: Delivery
3223
3365
  deliveryAttempted = true
3366
+ spawnMay = true
3224
3367
  try {
3225
3368
  const result = await deliver({
3226
3369
  turnId,
@@ -3261,6 +3404,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3261
3404
  // occupancy check.
3262
3405
  if (outcome !== 'recorded') return false
3263
3406
  recordedPids.push({ pid, startMs })
3407
+ // 6.78.0 (P7 S6): the pin names this child, so after a crash the thread waits for
3408
+ // THIS process to be gone, not only for the window. Never fails the turn.
3409
+ if (pinBootReapEnabled() && typeof deps.bindings.notePinChild === 'function') {
3410
+ try { deps.bindings.notePinChild(bindingId, turnId, { pid, startMs }) } catch { /* the window still bounds the pin */ }
3411
+ }
3264
3412
  return true
3265
3413
  },
3266
3414
  })
@@ -3310,7 +3458,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3310
3458
  return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'cancelled' })
3311
3459
  }
3312
3460
 
3313
- if (delivery.kind === 'aborted') return refuseTurn('provider_never_opened')
3461
+ if (delivery.kind === 'aborted') {
3462
+ // The adapter's own proof that the child never opened the session.
3463
+ spawnMay = false
3464
+ return refuseTurn('provider_never_opened')
3465
+ }
3314
3466
 
3315
3467
  if (delivery.kind === 'ambiguous') {
3316
3468
  // Plan 4.6: the reservation is HELD, not released. A hand-crafted next
@@ -3356,6 +3508,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3356
3508
  after = reread === null ? null : reread.digest
3357
3509
  }
3358
3510
  if (after !== null) guard.acknowledgeHead(bindingId, after)
3511
+ proven = true
3359
3512
 
3360
3513
  // Phase 4 owns Message persistence. Nothing about the prompt or the reply is
3361
3514
  // written, logged or echoed here; the terminal outcome is the whole result.
@@ -3411,7 +3564,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3411
3564
  }
3412
3565
  if (pinnedBindingId !== null) {
3413
3566
  try {
3414
- deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow)
3567
+ // 6.78.0: the attempt's exit rides the release, in one commit.
3568
+ if (attemptOpened && clientTurnId !== null) {
3569
+ deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow, { attemptExit: { clientTurnId, exit: attemptExit() } })
3570
+ } else {
3571
+ deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow)
3572
+ }
3415
3573
  } catch (error) {
3416
3574
  console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
3417
3575
  }
@@ -57,7 +57,8 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
57
57
  // The flag lives beside the endpoints it describes, so it cannot drift from
58
58
  // whether they are actually registered.
59
59
  import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
60
- import { durableQueryJobsCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
60
+ import { durableQueryJobsCapability, messagesSteerCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
61
+ import { claudeSteerSupportKnown } from '../lib/claude-cli-support.js'
61
62
  import { jobTrailStats } from '../lib/job-trail.js'
62
63
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
63
64
  import { getMorningBriefScheduler } from '../lib/morning-brief-runtime.js'
@@ -100,6 +101,7 @@ import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib
100
101
  import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
101
102
  import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
102
103
  import { codexLiveQueueEnabled } from '../lib/codex-live-queue.js'
104
+ import { bootResumeGate } from '../lib/boot-resume.js'
103
105
 
104
106
  /**
105
107
  * 6.49.0: which last hop a Continue takes, and how often each fell back.
@@ -544,9 +546,23 @@ healthRouter.get('/health', async (_req, res) => {
544
546
  enabled: durableJobs.enabled && messagesTrailEnabled(),
545
547
  ...jobTrailStats(),
546
548
  },
549
+ // 6.78.0 (P7 S5): how much boot-resumed work is parked for crash-looping the server. A
550
+ // count only (health is public); the items and their Retry are behind the token at
551
+ // GET /api/maintenance/status.
552
+ crash_loop_parking: crashLoopParkingHealth(),
547
553
  })
548
554
  })
549
555
 
556
+ /** Never throws: health must not 500 because of the ledger. */
557
+ function crashLoopParkingHealth(): { enabled: boolean; parked: number } {
558
+ try {
559
+ const status = bootResumeGate().status()
560
+ return { enabled: status.enabled, parked: status.parked.length }
561
+ } catch {
562
+ return { enabled: false, parked: 0 }
563
+ }
564
+ }
565
+
550
566
  // Stable app slots backed by Codex + Cursor live catalogs. Authenticated by
551
567
  // global /api middleware; ?refresh=1 forces discovery.
552
568
  healthRouter.get('/models', async (req, res) => {
@@ -647,6 +663,9 @@ healthRouter.get('/models', async (req, res) => {
647
663
  // THIS surface (not /api/health) and falls back to the activity log without it.
648
664
  // False whenever jobs themselves are off or COS_MESSAGES_TRAIL=0.
649
665
  trail: durableJobs.enabled && messagesTrailEnabled(),
666
+ // 6.78.0 Messages steer: present only with COS_MESSAGES_STEER on and a Claude CLI that
667
+ // takes stream-json input (probed once). Absent, the app shows today's UI.
668
+ ...steerCapabilityField(durableJobs.enabled),
650
669
  },
651
670
  // 6.52.0: the session questions API and its client contract. A client polls
652
671
  // `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
@@ -695,6 +714,12 @@ healthRouter.get('/models', async (req, res) => {
695
714
  },
696
715
  })
697
716
  })
717
+ /** 6.78.0: `{ steer }` for /api/models `durableQueryJobs`, or nothing at all. */
718
+ export function steerCapabilityField(jobsEnabled: boolean): { steer?: { protocolVersion: 1; providers: ['claude'] } } {
719
+ const steer = messagesSteerCapability({ jobsEnabled, cliSupported: claudeSteerSupportKnown() })
720
+ return steer ? { steer } : {}
721
+ }
722
+
698
723
  // GET /api/cli-session — returns current CLI session ID for cross-device resume
699
724
  healthRouter.get('/cli-session', (req, res) => {
700
725
  const cosSessionId = req.query.sid as string | undefined
@@ -17,6 +17,7 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
17
17
  import { getWhisperHealth } from '../lib/whisper-local.js'
18
18
  import { getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './transcribe-stream.js'
19
19
  import { getVideoUploadRegistry } from '../lib/video-upload-v2.js'
20
+ import { bootResumeGate, isBootResumeKind, resumeRetriedItem, type ParkedBootResume } from '../lib/boot-resume.js'
20
21
 
21
22
  export const maintenanceRouter = Router()
22
23
 
@@ -69,6 +70,15 @@ function operationCredentials(req: Request): MaintenanceOperationCredentials {
69
70
  }
70
71
  }
71
72
 
73
+ /** 6.78.0: boot-resumed work parked for crash-looping the server. Never throws. */
74
+ function parkedSnapshot(): ParkedBootResume[] {
75
+ try {
76
+ return bootResumeGate().status().parked
77
+ } catch {
78
+ return []
79
+ }
80
+ }
81
+
72
82
  function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
73
83
  const jobs = getQueryJobRuntimeHealth()
74
84
  const activeTranscriptionSessions = getActiveTranscriptionSessionCount()
@@ -133,6 +143,9 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
133
143
  // block a binary downgrade, where an old server could erase V2 evidence.
134
144
  safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown && !videoUploads.blocksRestart,
135
145
  whisper: getWhisperHealth(),
146
+ // 6.78.0, additive (contractVersion unchanged): work a boot no longer resumes because
147
+ // it crashed the server on RESTART_LIMIT boots in a row. Retry with the route below.
148
+ parked: parkedSnapshot(),
136
149
  }
137
150
  }
138
151
 
@@ -155,6 +168,27 @@ maintenanceRouter.get('/maintenance/status', (req, res) => {
155
168
  res.json(statusSnapshot(operationCredentials(req)))
156
169
  })
157
170
 
171
+ // 6.78.0: the person's Retry of parked boot-resumed work (lib/boot-resume.ts). The API
172
+ // token, like GET /maintenance/status, and not loopback-only, so the phone and the lens can
173
+ // offer it; the /api mutation lease still refuses it during a drain. The count starts over;
174
+ // a meeting finalization is resumed again now, and a query job (id = its clientJobId) runs
175
+ // again when the app Retries it (a parked Ask is never resumable: it never started).
176
+ maintenanceRouter.post('/maintenance/parked/:kind/:id/retry', (req, res) => {
177
+ const { kind, id } = req.params
178
+ if (!isBootResumeKind(kind)) {
179
+ res.status(400).json({ error: 'unknown_parked_kind', retryable: false })
180
+ return
181
+ }
182
+ const gate = bootResumeGate()
183
+ if (!gate.isParked(kind, id)) {
184
+ res.status(404).json({ error: 'not_parked', retryable: false })
185
+ return
186
+ }
187
+ const entry = gate.retry(kind, id)
188
+ const resumed = resumeRetriedItem(kind, id)
189
+ res.json({ retried: true, resumed, entry })
190
+ })
191
+
158
192
  maintenanceRouter.post('/maintenance/drain', (req, res) => {
159
193
  if (!requireLoopback(req, res)) return
160
194
  try {