@gotcos/glasses-server 6.76.1 → 6.78.0

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 (52) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +17 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +98 -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/cos-bridge-notes.ts +73 -0
  13. package/server/lib/decision-provider.ts +9 -0
  14. package/server/lib/env-switch.ts +17 -0
  15. package/server/lib/fuzzy-correct.ts +77 -49
  16. package/server/lib/live-cos-ask.ts +41 -7
  17. package/server/lib/live-cues-v2-notes.ts +152 -0
  18. package/server/lib/live-cues-v2-settings.ts +63 -0
  19. package/server/lib/live-cues-v2.ts +198 -38
  20. package/server/lib/managed-restart.ts +8 -0
  21. package/server/lib/provider-actions.ts +18 -0
  22. package/server/lib/query-job-coordinator.ts +300 -0
  23. package/server/lib/query-job-feature.ts +39 -0
  24. package/server/lib/query-job-resume.ts +269 -0
  25. package/server/lib/query-job-runtime.ts +23 -3
  26. package/server/lib/query-job-store.ts +516 -7
  27. package/server/lib/query-job-types.ts +210 -1
  28. package/server/lib/queue-turn-evidence.ts +213 -0
  29. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  30. package/server/lib/thread-turn-queue-store.ts +13 -4
  31. package/server/lib/thread-turn-queue.ts +151 -1
  32. package/server/lib/transcript-cleanup-context.ts +234 -0
  33. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  34. package/server/lib/transcript-cleanup-model.ts +217 -0
  35. package/server/lib/transcript-cleanup-screen.ts +249 -0
  36. package/server/lib/transcript-cleanup-spans.ts +358 -0
  37. package/server/lib/transcript-cleanup-state.ts +201 -0
  38. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  39. package/server/lib/transcript-cleanup-text.ts +200 -0
  40. package/server/lib/transcript-cleanup.ts +427 -0
  41. package/server/routes/agent-session-bindings.ts +169 -11
  42. package/server/routes/glossary.ts +38 -19
  43. package/server/routes/health.ts +29 -1
  44. package/server/routes/live-cues.ts +32 -3
  45. package/server/routes/maintenance.ts +34 -0
  46. package/server/routes/meeting.ts +92 -9
  47. package/server/routes/query-jobs.ts +107 -13
  48. package/server/routes/recovery.ts +4 -0
  49. package/server/routes/thread-turn-queue.ts +371 -10
  50. package/server/routes/transcribe.ts +3 -0
  51. package/server/routes/transcript-cleanup.ts +237 -0
  52. package/shared/live-cues-v2.ts +9 -4
@@ -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
  }
@@ -198,7 +198,7 @@ function commitWords(words: GlossaryWords, nextLog: CorrectionLogEntry[], previo
198
198
  resetVocabEchoCache()
199
199
  }
200
200
 
201
- type Outcome = { status: number; body: unknown }
201
+ export type Outcome = { status: number; body: unknown }
202
202
 
203
203
  function serialized(work: () => Outcome): Promise<Outcome> {
204
204
  const run = putChain.catch(() => {}).then(work)
@@ -206,17 +206,27 @@ function serialized(work: () => Outcome): Promise<Outcome> {
206
206
  return run
207
207
  }
208
208
 
209
+ /**
210
+ * Learn one correction inside the glossary write chain, exactly as POST
211
+ * /transcription-glossary/correction does (logged, undoable). Exported for the transcript
212
+ * cleanup's dictionary suggestions (6.77.0), so an accepted suggestion uses this writer and
213
+ * never a second copy of it.
214
+ */
215
+ export function glossaryLearnCorrection(body: unknown): Promise<Outcome> {
216
+ return serialized(() => {
217
+ const previousLog = readCorrectionsLog()
218
+ const learned = learnCorrection(storedWords(), (body ?? {}) as { heard: unknown; written: unknown; source: unknown }, { id: randomUUID(), at: new Date().toISOString() })
219
+ if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
220
+ if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
221
+ commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
222
+ return { status: 200, body: { unchanged: false, entry: learned.entry, glossary: currentGlossary() } }
223
+ })
224
+ }
225
+
209
226
  glossaryRouter.post('/transcription-glossary/correction', async (req, res) => {
210
227
  const body = req.body ?? {}
211
228
  try {
212
- const outcome = await serialized(() => {
213
- const previousLog = readCorrectionsLog()
214
- const learned = learnCorrection(storedWords(), body, { id: randomUUID(), at: new Date().toISOString() })
215
- if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
216
- if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
217
- commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
218
- return { status: 200, body: { unchanged: false, entry: learned.entry, glossary: currentGlossary() } }
219
- })
229
+ const outcome = await glossaryLearnCorrection(body)
220
230
  return res.status(outcome.status).json(outcome.body)
221
231
  } catch (err) {
222
232
  return res.status(500).json({ error: errMsg(err) })
@@ -250,20 +260,29 @@ glossaryRouter.post('/transcription-glossary/correction/undo', async (req, res)
250
260
  // ── Single-item hand edits (6.73.12) ─────────────────────────────────────────
251
261
  // One word or one correction at a time, inside the same chain as PUT and learn,
252
262
  // so COS Control never has to read the whole map and write it back.
263
+ function runSingleEdit(edit: (words: GlossaryWords, raw: unknown) => ReturnType<typeof addVocabulary>, raw: unknown): Promise<Outcome> {
264
+ return serialized(() => {
265
+ const result = edit(storedWords(), raw)
266
+ if (!result.ok) return { status: result.status, body: { error: result.error } }
267
+ if (!result.unchanged) {
268
+ updateProfileFields({ vocabulary: result.words.vocabulary, whisper_corrections: JSON.stringify(result.words.corrections) })
269
+ resetDecoderCaches()
270
+ resetVocabEchoCache()
271
+ }
272
+ return { status: 200, body: { unchanged: result.unchanged, glossary: currentGlossary() } }
273
+ })
274
+ }
275
+
276
+ /** Add one vocabulary word inside the glossary write chain (same as POST /transcription-glossary/vocabulary/add). */
277
+ export function glossaryAddVocabulary(term: unknown): Promise<Outcome> {
278
+ return runSingleEdit(addVocabulary, term)
279
+ }
280
+
253
281
  function singleEdit(path: string, field: 'term' | 'heard', edit: (words: GlossaryWords, raw: unknown) => ReturnType<typeof addVocabulary>) {
254
282
  glossaryRouter.post(path, async (req, res) => {
255
283
  const raw = (req.body ?? {})[field]
256
284
  try {
257
- const outcome = await serialized(() => {
258
- const result = edit(storedWords(), raw)
259
- if (!result.ok) return { status: result.status, body: { error: result.error } }
260
- if (!result.unchanged) {
261
- updateProfileFields({ vocabulary: result.words.vocabulary, whisper_corrections: JSON.stringify(result.words.corrections) })
262
- resetDecoderCaches()
263
- resetVocabEchoCache()
264
- }
265
- return { status: 200, body: { unchanged: result.unchanged, glossary: currentGlossary() } }
266
- })
285
+ const outcome = await runSingleEdit(edit, raw)
267
286
  return res.status(outcome.status).json(outcome.body)
268
287
  } catch (err) {
269
288
  return res.status(500).json({ error: errMsg(err) })
@@ -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'
@@ -69,6 +70,7 @@ import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
69
70
  import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
70
71
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
71
72
  import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
73
+ import { transcriptCleanupAdvertised } from './transcript-cleanup.js'
72
74
  import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
73
75
  import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
74
76
  import { getStrandedCaptures } from './transcribe-stream.js'
@@ -99,6 +101,7 @@ import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib
99
101
  import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
100
102
  import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
101
103
  import { codexLiveQueueEnabled } from '../lib/codex-live-queue.js'
104
+ import { bootResumeGate } from '../lib/boot-resume.js'
102
105
 
103
106
  /**
104
107
  * 6.49.0: which last hop a Continue takes, and how often each fell back.
@@ -497,6 +500,8 @@ healthRouter.get('/health', async (_req, res) => {
497
500
  cliDebug: CLI_DEBUG_CAPABILITY,
498
501
  liveCues,
499
502
  ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
503
+ // 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
504
+ transcriptCleanup: transcriptCleanupAdvertised(),
500
505
  richMedia: {
501
506
  text: true,
502
507
  pdf: richMedia.pdf,
@@ -541,9 +546,23 @@ healthRouter.get('/health', async (_req, res) => {
541
546
  enabled: durableJobs.enabled && messagesTrailEnabled(),
542
547
  ...jobTrailStats(),
543
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(),
544
553
  })
545
554
  })
546
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
+
547
566
  // Stable app slots backed by Codex + Cursor live catalogs. Authenticated by
548
567
  // global /api middleware; ?refresh=1 forces discovery.
549
568
  healthRouter.get('/models', async (req, res) => {
@@ -644,6 +663,9 @@ healthRouter.get('/models', async (req, res) => {
644
663
  // THIS surface (not /api/health) and falls back to the activity log without it.
645
664
  // False whenever jobs themselves are off or COS_MESSAGES_TRAIL=0.
646
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),
647
669
  },
648
670
  // 6.52.0: the session questions API and its client contract. A client polls
649
671
  // `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
@@ -692,6 +714,12 @@ healthRouter.get('/models', async (req, res) => {
692
714
  },
693
715
  })
694
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
+
695
723
  // GET /api/cli-session — returns current CLI session ID for cross-device resume
696
724
  healthRouter.get('/cli-session', (req, res) => {
697
725
  const cosSessionId = req.query.sid as string | undefined
@@ -4,6 +4,8 @@
4
4
  // POST /live-cues/start { sessionId } -> { armed, sessionId, pipelinesUsed }
5
5
  // POST /live-cues/stop { sessionId } -> { nudgesGenerated, nudges }
6
6
  // GET /live-cues/status -> engine snapshot + capability
7
+ // GET /live-cues/v2/settings -> { enabled, source: env|setting|default, capability } (6.76.2)
8
+ // POST /live-cues/v2/settings { enabled } -> same shape; 409 { error: 'env_forced' } when env forces v2 on
7
9
 
8
10
  import { Router } from 'express'
9
11
  import { errMsg } from '../lib/utils.js'
@@ -14,7 +16,7 @@ import {
14
16
  LiveCuesArmError,
15
17
  } from '../lib/live-cues-engine.js'
16
18
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
17
- import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled } from '../lib/live-cues-v2.js'
19
+ import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
18
20
 
19
21
  const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
20
22
  const CUE_ID = /^[A-Za-z0-9-]{8,64}$/
@@ -22,6 +24,10 @@ function bodySessionId(body: unknown): string {
22
24
  const raw = (body as { sessionId?: unknown })?.sessionId
23
25
  return typeof raw === 'string' ? raw.trim() : ''
24
26
  }
27
+ /** v2 owns this meeting: v2 is on, or it armed the meeting before v2 was switched off at runtime. */
28
+ function v2Owns(sessionId: string): boolean {
29
+ return liveCuesV2Enabled() || (peekLiveCuesV2()?.knows(sessionId) ?? false)
30
+ }
25
31
 
26
32
  export const liveCuesRouter = Router()
27
33
 
@@ -59,14 +65,14 @@ liveCuesRouter.post('/live-cues/stop', (req, res) => {
59
65
  // Shape matches the existing client stop handler: it reads nudgesGenerated
60
66
  // for its log line and hydrates meetingNudgeHistory from nudges when SSE
61
67
  // missed events (the 200-event replay buffer is flooded by transcript_chunk).
62
- if (liveCuesV2Enabled()) return res.json(liveCuesV2().end(sessionId))
68
+ if (v2Owns(sessionId)) return res.json(liveCuesV2().end(sessionId))
63
69
  return res.json(disarmLiveCues(sessionId))
64
70
  })
65
71
 
66
72
  liveCuesRouter.get('/live-cues/status', (req, res) => {
67
73
  const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId.trim() : ''
68
74
  // v2 with a sessionId answers the per-meeting shape; without one the v1 shape is unchanged (plus v2 capability).
69
- if (sessionId && liveCuesV2Enabled()) {
75
+ if (sessionId && v2Owns(sessionId)) {
70
76
  if (!SESSION_ID.test(sessionId)) return res.status(400).json({ error: 'invalid sessionId', reason: 'invalid_session_id' })
71
77
  return res.json(liveCuesV2().status(sessionId))
72
78
  }
@@ -101,3 +107,26 @@ liveCuesRouter.post('/live-cues/feedback', (req, res) => {
101
107
  if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
102
108
  return res.json(liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined }))
103
109
  })
110
+
111
+ // COS Control's live cues v2 toggle (6.76.2). Token-authenticated like every /api route. The setting lives in this server's
112
+ // data dir (live-cues-v2-settings.json, atomic write) and applies without a restart: /api/health and /api/models advertise
113
+ // liveCuesV2 only while it is on. capability.memory and capability.decisionProvider say where notes and the gate come from.
114
+ liveCuesRouter.get('/live-cues/v2/settings', (_req, res) => {
115
+ res.set('Cache-Control', 'private, no-store')
116
+ try { return res.json(liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
117
+ })
118
+
119
+ liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
120
+ res.set('Cache-Control', 'private, no-store')
121
+ const body = req.body as { enabled?: unknown } | undefined
122
+ if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean') {
123
+ return res.status(400).json({ error: 'invalid_request' })
124
+ }
125
+ try {
126
+ const result = setLiveCuesV2Enabled(body.enabled)
127
+ if (!result.ok) return res.status(409).json({ error: result.error })
128
+ return res.json(result.view)
129
+ } catch {
130
+ return res.status(500).json({ error: 'settings_write_failed' })
131
+ }
132
+ })
@@ -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 {