@gotcos/glasses-server 6.99.20 → 6.99.29

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 (65) hide show
  1. package/CHANGELOG.md +257 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +48 -5
  5. package/server/lib/agent-session-output-images.ts +280 -0
  6. package/server/lib/agent-session-store.ts +3 -1
  7. package/server/lib/agent-session-turns.ts +18 -7
  8. package/server/lib/client-liveness.ts +38 -3
  9. package/server/lib/codex-subagent-activity.ts +36 -0
  10. package/server/lib/g2-turn-provenance.ts +679 -0
  11. package/server/lib/media-store.ts +41 -2
  12. package/server/lib/permission-broker.ts +94 -17
  13. package/server/lib/queue-turn-evidence.ts +19 -2
  14. package/server/lib/session-halt.ts +15 -1
  15. package/server/lib/session-stream-events.ts +4 -0
  16. package/server/lib/session-subagents.ts +155 -0
  17. package/server/lib/session-turn-attachments.ts +245 -0
  18. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  19. package/server/lib/thread-turn-queue.ts +10 -0
  20. package/server/lib/today-brief.ts +731 -0
  21. package/server/lib/turn-file-store.ts +202 -0
  22. package/server/lib/turn-footer.ts +81 -0
  23. package/server/lib/web-control-static.ts +226 -0
  24. package/server/lib/web-memories-bridge.ts +9 -8
  25. package/server/lib/web-pet-choice.ts +225 -0
  26. package/server/lib/web-pet.ts +226 -0
  27. package/server/lib/work-lens-rounds.ts +29 -4
  28. package/server/routes/agent-session-bindings.ts +164 -16
  29. package/server/routes/agent-sessions.ts +77 -8
  30. package/server/routes/client-instance.ts +12 -1
  31. package/server/routes/cursor-stop-followup.ts +30 -1
  32. package/server/routes/g2-turns.ts +46 -0
  33. package/server/routes/health.ts +9 -0
  34. package/server/routes/media.ts +32 -8
  35. package/server/routes/permission-broker.ts +47 -4
  36. package/server/routes/thread-turn-queue.ts +148 -8
  37. package/server/routes/today-brief.ts +148 -0
  38. package/server/routes/web-control.ts +110 -7
  39. package/server/routes/work-lens-rounds.ts +19 -5
  40. package/server/web-control/app.css +26 -5
  41. package/server/web-control/app.js +1351 -177
  42. package/server/web-control/control.css +1 -1
  43. package/server/web-control/index.html +31 -13
  44. package/server/web-control/memories/memory-workspace.js +1 -1
  45. package/server/web-control/panes/cos-chat.css +22 -0
  46. package/server/web-control/panes/cos-chat.js +264 -48
  47. package/server/web-control/panes/meetings.css +15 -2
  48. package/server/web-control/panes/meetings.js +20 -9
  49. package/server/web-control/panes/memories.css +291 -0
  50. package/server/web-control/panes/memories.js +2208 -0
  51. package/server/web-control/panes/questions.css +39 -1
  52. package/server/web-control/panes/questions.js +419 -64
  53. package/server/web-control/panes/reader-media.js +154 -0
  54. package/server/web-control/panes/sessions.css +2 -0
  55. package/server/web-control/panes/settings.js +136 -6
  56. package/server/web-control/panes/today-actions.css +167 -0
  57. package/server/web-control/panes/today-actions.js +1303 -0
  58. package/server/web-control/panes/work.css +54 -0
  59. package/server/web-control/panes/work.js +1037 -18
  60. package/server/web-control/shell.js +6 -2
  61. package/server/web-control/theme.js +0 -2
  62. package/server/web-control/memories/memories-app.js +0 -1561
  63. package/server/web-control/memories/memories-theme.css +0 -73
  64. package/server/web-control/memories/memories.html +0 -72
  65. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -98,7 +98,7 @@
98
98
  import type { FenceRecord } from '../lib/thread-fence-store.js'
99
99
  import { defaultPidStartProbe, fenceLiveness, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
100
100
  import { Router, type Request, type Response } from 'express'
101
- import { createHash, randomUUID } from 'node:crypto'
101
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
102
102
  import {
103
103
  threadOccupancy,
104
104
  type Occupancy,
@@ -131,7 +131,9 @@ import { isValidNativeThreadId } from '../lib/native-thread-id.js'
131
131
  import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
132
132
  import { CLIENT_FORK_ID_RE, ForkJobLedger, type ForkJobRow } from '../lib/fork-job-ledger.js'
133
133
  import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
134
+ import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, QUEUED_TURN_TAMPERED_COPY, sealMatches, TURN_CARRY_HEADER, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict, type QueueSeal } from '../lib/g2-turn-provenance.js'
134
135
  import { KEYED_SESSION_REFUSAL } from '../lib/keyed-sessions.js'
136
+ import { associateTurnAttachments, composeTurnPrompt, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
135
137
  import {
136
138
  CANCEL_QUEUE_HOLD_MS,
137
139
  CLIENT_CANCEL_ID_RE,
@@ -318,6 +320,13 @@ export type AttachedTurnResult =
318
320
  | { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' | 'cancelled' }
319
321
 
320
322
  export interface AgentSessionBindingsDeps {
323
+ /**
324
+ * G2 authority Tier 1, with web origin (6.99.29): where each turn came from. With it, every turn
325
+ * and fork this router hands to a session is ledgered, and a VERIFIED glasses or phone turn
326
+ * carries the one verify footer line (a Control Web turn is recorded, never vouched or footed).
327
+ * Footer-shaped words and reused turn ids are refused. Absent: the 6.99.28 behaviour, byte for byte.
328
+ */
329
+ provenance?: G2TurnProvenance
321
330
  /** Shared fence state. Omit and the router owns a private one, which is correct
322
331
  * for tests and wrong for the server -- see the wiring note at its use site. */
323
332
  guard?: TargetGuard
@@ -437,6 +446,11 @@ export interface AgentSessionBindingsDeps {
437
446
  * and before the claim; a hit is replayed, never sent again. Absent: 6.77 behaviour.
438
447
  */
439
448
  turnReceipt?: (provider: string, nativeThreadId: string, clientTurnId: string, prompt: string) => TurnReceipt | null
449
+ /**
450
+ * 11/07 (Control Web attach): the media store a turn's `attachmentIds` resolve against. Absent: the real store
451
+ * (lib/session-turn-attachments.ts). Tests pass a fake. Never read for a turn without files.
452
+ */
453
+ turnAttachmentStore?: TurnAttachmentStore
440
454
 
441
455
  /**
442
456
  * 6.78.0 (COS_SESSION_STEER): could a turn go INTO this thread's running turn? Added to an
@@ -2601,10 +2615,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2601
2615
  if (typeof cosSessionId !== 'string' || !COS_SESSION_ID_RE.test(cosSessionId)) {
2602
2616
  return refuseFork('invalid_request')
2603
2617
  }
2604
- const prompt = body.prompt
2605
- if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
2618
+ const words = body.prompt
2619
+ if (typeof words !== 'string' || words.trim().length === 0 || words.length > maxPromptChars) {
2606
2620
  return refuseFork('invalid_request')
2607
2621
  }
2622
+ // 6.99.27: a fork's first turn takes files as a turn does (the message written with them, sent to the copy).
2623
+ const forkFiles = parseTurnAttachmentIds(body.attachmentIds)
2624
+ if (!forkFiles.ok) return refuseFork('invalid_request')
2608
2625
  // 6.63.0: optional. With it, the fork answers 202 once every gate has passed and runs
2609
2626
  // in the background; without it, the route behaves exactly as before.
2610
2627
  const clientForkId = body.clientForkId
@@ -2628,6 +2645,28 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2628
2645
  if (known) return known.source === forkSource ? replayForkJob(known) : refuseFork('invalid_request')
2629
2646
  }
2630
2647
 
2648
+ // After the replay (a known fork sends nothing), before the workspace, the claim and the spawn: where each file is
2649
+ // on this Mac goes under the words. Without files the prompt is the words, untouched. Nothing is kept yet: the
2650
+ // files are associated only once this fork holds its claim (below, QA 6.99.27).
2651
+ const forkComposed = await composeTurnPrompt(words, forkFiles.ids, {
2652
+ maxChars: maxPromptChars,
2653
+ ...(deps.turnAttachmentStore ? { store: deps.turnAttachmentStore } : {}),
2654
+ })
2655
+ if (!forkComposed.ok) {
2656
+ return refuseFork('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[forkComposed.code], attachmentError: forkComposed.code })
2657
+ }
2658
+ const prompt = forkComposed.prompt
2659
+ // G2 authority Tier 1 (QA 6.99.29): only the server writes a verify footer. Words (or a file
2660
+ // name) carrying anything footer-shaped are refused before anything is spawned.
2661
+ if (deps.provenance && hasFooterShapedLine(prompt)) {
2662
+ return refuseFork('invalid_request', { retryable: false, error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
2663
+ }
2664
+ // A client fork id this server already holds a turn record for is never reused (a new id
2665
+ // would overwrite that record). A repeat of this same fork was replayed above.
2666
+ if (deps.provenance && typeof clientForkId === 'string' && deps.provenance.holds(clientForkId)) {
2667
+ return refuseFork('invalid_request', { retryable: false, error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
2668
+ }
2669
+
2631
2670
  const now = readNow()
2632
2671
  if (now === null) return refuseFork('fork_failed')
2633
2672
 
@@ -2636,7 +2675,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2636
2675
  let cwd: string | null = null
2637
2676
  try {
2638
2677
  // 6.62.0: may answer later (a Cursor IDE composer's folder is read from Cursor's own
2639
- // store). Still before the claim below, which stays the last synchronous step.
2678
+ // store). Before the claim below, whose check and set stay one synchronous call.
2640
2679
  cwd = await resolveWorkspace(providerParam, threadIdParam)
2641
2680
  } catch (error) {
2642
2681
  console.error(`[agent-session-bindings] fork workspace resolve threw: ${error instanceof Error ? error.message : error}`)
@@ -2649,9 +2688,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2649
2688
  return refuseFork('fork_workspace_unresolvable')
2650
2689
  }
2651
2690
 
2652
- // LAST SYNCHRONOUS STATEMENT BEFORE THE FIRST AWAIT. One fork per source
2653
- // thread at a time: this route spawns a provider CLI, and without a claim a
2654
- // client retry loop spawns one child per request with nothing bounding it.
2691
+ // Check and set in ONE synchronous call (awaits above, the workspace read and the
2692
+ // files, come before it; nothing may sit between the check and the set). One fork
2693
+ // per source thread at a time: this route spawns a provider CLI, and without a claim
2694
+ // a client retry loop spawns one child per request with nothing bounding it.
2655
2695
  // A DISTINCT key namespace from the turn claim, so a fork can never block a
2656
2696
  // continuation or be blocked by one — `targetKey` is length-prefixed and
2657
2697
  // therefore unambiguous, and this prefix cannot collide with one.
@@ -2665,6 +2705,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2665
2705
  }
2666
2706
  claimedForkKey = forkKey
2667
2707
 
2708
+ // QA 6.99.27 (Skeptic W2): the files are kept with this fork only now that it holds the claim and every gate
2709
+ // has passed, before the job is recorded or anything is spawned. A file that went in the meantime refuses here.
2710
+ if (forkFiles.ids.length > 0) {
2711
+ const kept = await associateTurnAttachments(forkFiles.ids, `session-fork:${typeof clientForkId === 'string' ? clientForkId : mintId()}`, deps.turnAttachmentStore)
2712
+ if (!kept.ok) return refuseFork('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[kept.code], attachmentError: kept.code })
2713
+ }
2714
+
2668
2715
  if (typeof clientForkId === 'string') {
2669
2716
  // Recorded BEFORE the spawn and before the 202: a job the ledger cannot hold would
2670
2717
  // have an outcome nobody can read, so it does not start.
@@ -2686,12 +2733,26 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2686
2733
  console.log(`[agent-session-bindings] fork accepted provider=${providerParam} background=true`)
2687
2734
  }
2688
2735
 
2736
+ // G2 authority Tier 1: a fork is a turn too, ledgered. Its id is the client's fork id, or one
2737
+ // minted here; the target is the SOURCE thread (the copy's id is not known until it ran). The
2738
+ // hash covers the prompt as composed (words plus any files block). Never vouched (below).
2739
+ let forkPrompt = prompt
2740
+ if (deps.provenance) {
2741
+ // 6.99.29 (Codex audit of e4758a6): the record names the SOURCE session, never the fork child
2742
+ // that actually receives the words, and the child has no binding yet. Until a destination
2743
+ // binding exists, a forked turn is recorded UNVERIFIED for the child: no footer, ever.
2744
+ const forkVerdict = unvouched(deps.provenance.originOf(req), 'fork_destination_unbound')
2745
+ const forkTurnId = typeof clientForkId === 'string' ? clientForkId : `fork-${randomBytes(12).toString('hex')}`
2746
+ forkPrompt = deps.provenance.footed(prompt, forkTurnId, forkVerdict, providerParam)
2747
+ deps.provenance.record({ turnId: forkTurnId, prompt, verdict: forkVerdict, provider: providerParam, sessionId: threadIdParam, via: 'fork', fork: true })
2748
+ }
2749
+
2689
2750
  let raw: unknown
2690
2751
  try {
2691
2752
  raw = await fork({
2692
2753
  provider: providerParam,
2693
2754
  nativeThreadId: threadIdParam,
2694
- prompt,
2755
+ prompt: forkPrompt,
2695
2756
  cwd,
2696
2757
  // Text-only, same as the attached path. A fork runs a real model turn
2697
2758
  // against a workspace the user did not hand us explicitly.
@@ -2973,10 +3034,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2973
3034
  // an empty one, and must not fall through as a turn with no claims to check.
2974
3035
  if (body === null) return refuseTurn('invalid_request')
2975
3036
 
2976
- const prompt = body.prompt
2977
- if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
3037
+ const words = body.prompt
3038
+ if (typeof words !== 'string' || words.trim().length === 0 || words.length > maxPromptChars) {
2978
3039
  return refuseTurn('invalid_request')
2979
3040
  }
3041
+ // 11/07: files with the turn, as media-store ids. Absent is the old turn; a bad list refuses it whole.
3042
+ const attachIds = parseTurnAttachmentIds(body.attachmentIds)
3043
+ if (!attachIds.ok) return refuseTurn('invalid_request')
2980
3044
  const epoch = body.epoch
2981
3045
  if (!Number.isInteger(epoch) || (epoch as number) < 1) return refuseTurn('invalid_request')
2982
3046
  const claimedTargetKey = body.targetKey
@@ -3014,6 +3078,59 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3014
3078
  return
3015
3079
  }
3016
3080
 
3081
+ // 11/07: the files' paths on this Mac go under the words HERE, after the ledger replay (a replayed turn sends
3082
+ // nothing) and before the receipt (which matches the transcript against what was actually sent). Without files
3083
+ // the prompt is the words, untouched. A file that cannot be read refuses the turn before anything is sent. The
3084
+ // files are kept with the turn only after the claim and the gates (before the pin, below; QA 6.99.27).
3085
+ const composed = await composeTurnPrompt(words, attachIds.ids, {
3086
+ maxChars: maxPromptChars,
3087
+ ...(deps.turnAttachmentStore ? { store: deps.turnAttachmentStore } : {}),
3088
+ })
3089
+ if (!composed.ok) {
3090
+ return refuseTurn('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[composed.code], attachmentError: composed.code })
3091
+ }
3092
+ const prompt = composed.prompt
3093
+ // G2 authority Tier 1 (QA 6.99.29): only the server writes a verify footer. Words (or a file
3094
+ // name) carrying anything footer-shaped are refused here, before any gate, claim or hop.
3095
+ if (deps.provenance && hasFooterShapedLine(prompt)) {
3096
+ return refuseTurn('invalid_request', { retryable: false, error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
3097
+ }
3098
+
3099
+ // G2 authority Tier 1 (6.99.29 port, with web origin): who said this, and the exact text the
3100
+ // session will see. A turn the queue drains re-enters over loopback with a single-use carry
3101
+ // token naming the origin captured when it was parked (bound to the WORDS it parked, before
3102
+ // any files block); anything else is judged by its own socket, its token and its cookie.
3103
+ // `prompt` stays the unfooted text: the receipts, the turn ledger and the queue evidence
3104
+ // compare against it, and the transcript readers take the footer off before they compare.
3105
+ const provenance = deps.provenance
3106
+ let turnVerdict: OriginVerdict | null = null
3107
+ let turnCarried = false
3108
+ /** The seal a vouched queued turn carries; checked against the files here and the binding below. */
3109
+ let turnSeal: QueueSeal | null = null
3110
+ if (provenance) {
3111
+ // A carry marks the queue's own drain of a parked turn: it carries the parked origin while
3112
+ // one is held (else none, and the hop is judged as the local call it is) and is the one
3113
+ // caller allowed to add to that turn's record.
3114
+ const carried = provenance.takeCarry(req.headers[TURN_CARRY_HEADER], clientTurnId, words)
3115
+ turnCarried = carried !== null
3116
+ turnVerdict = carried?.verdict ?? provenance.originOf(req)
3117
+ turnSeal = carried?.seal ?? null
3118
+ }
3119
+ // 6.99.29 (Codex audit of e4758a6): the queue file holds MUTABLE attachment ids, and the drain
3120
+ // composes them into the text this turn vouches for. A carried seal names the manifest (each id
3121
+ // with its content hash) the origin was given for; files that differ now refuse the turn.
3122
+ if (turnSeal !== null) {
3123
+ const files = attachIds.ids.length === 0 ? { ok: true as const, manifest: [] } : await turnAttachmentManifest(attachIds.ids, deps.turnAttachmentStore)
3124
+ if (!files.ok || attachmentsDigest(files.manifest) !== turnSeal.attachments) {
3125
+ console.warn(`[agent-session-bindings] queued turn seal mismatch (files) turnId=${turnId} clientTurnId=${clientTurnId}`)
3126
+ return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
3127
+ }
3128
+ }
3129
+ /** Ledgered once the text did, or may have, reached a session. Hash of the text WITHOUT the footer. */
3130
+ const ledgerTurn = (via: string, provider: string, sessionId: string): void => {
3131
+ if (provenance && turnVerdict && clientTurnId !== null) provenance.record({ turnId: clientTurnId, prompt, verdict: turnVerdict, provider, sessionId, via: turnCarried ? `drain-${via}` : via, continues: turnCarried })
3132
+ }
3133
+
3017
3134
  // 6.78.0: RECEIPTS, before any gate, claim, pin or hop. The ledger above is dropped
3018
3135
  // with its binding and records nothing for a pre-202 `live_unverified`; a crash after
3019
3136
  // the pin leaves no record at all. The receipt covers those (target-scoped attempt
@@ -3074,6 +3191,22 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3074
3191
  if (!isBindableProvider(binding.provider) || !isValidNativeThreadId(binding.nativeThreadId)) {
3075
3192
  return refuseTurn('binding_unusable')
3076
3193
  }
3194
+ // G2 authority Tier 1 (QA 6.99.29): a turn id this server already holds a record for is never
3195
+ // reused for a different turn (it would overwrite that record: a denial of service on a
3196
+ // vouched turn). An identical retry is not reuse, and the queue's own drain (a carry) continues
3197
+ // its turn while that turn is still parked; never over a delivered record.
3198
+ // ...and the seal names the provider and thread it was parked for: a drain into any other
3199
+ // binding is refused, never delivered under the parked origin.
3200
+ if (turnSeal !== null && !sealMatches(turnSeal, { provider: binding.provider, threadId: binding.nativeThreadId, attachments: turnSeal.attachments })) {
3201
+ console.warn(`[agent-session-bindings] queued turn seal mismatch (target) turnId=${turnId} clientTurnId=${clientTurnId}`)
3202
+ return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
3203
+ }
3204
+ if (provenance && turnVerdict && provenance.reusesTurnId(clientTurnId, { prompt, verdict: turnVerdict, provider: binding.provider, sessionId: binding.nativeThreadId }, { continues: turnCarried })) {
3205
+ return refuseTurn('invalid_request', { retryable: false, error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
3206
+ }
3207
+ // Footed HERE, once the binding names the harness the text goes into: the footer's verify
3208
+ // command carries that provider (Codex re-audit of 910eb90). Every delivery below uses this text.
3209
+ const deliveredPrompt = provenance && turnVerdict ? provenance.footed(prompt, clientTurnId, turnVerdict, binding.provider) : prompt
3077
3210
 
3078
3211
  const key = binding.targetKey
3079
3212
  const fenced = guard.fencedReason(key)
@@ -3082,9 +3215,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3082
3215
  return refuseTurn(fenced, { retryable: false, deliveryState: 'unknown' })
3083
3216
  }
3084
3217
 
3085
- // LAST SYNCHRONOUS STATEMENT BEFORE THE FIRST AWAIT. Check-and-set in one
3086
- // call: with an await between them, two turns admitted in the same tick both
3087
- // see a free target and both deliver.
3218
+ // Check-and-set in ONE synchronous call. Awaits come before it (the files above,
3219
+ // QA 6.99.27) and after it, never between the check and the set: with an await
3220
+ // between them, two turns admitted in the same tick both see a free target and
3221
+ // both deliver.
3088
3222
  if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
3089
3223
  claimedKey = key
3090
3224
  // 6.53.0: cancellable from here, with the claim. Same key, same lifetime.
@@ -3137,6 +3271,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3137
3271
  guard.acknowledgeHead(bindingId, head.digest)
3138
3272
  }
3139
3273
 
3274
+ // QA 6.99.27 (Skeptic W2): the files are kept with this turn only now, with the claim held and every gate
3275
+ // passed, and before the pin and any hop. A turn refused above keeps nothing past the 4 hour staged TTL.
3276
+ if (attachIds.ids.length > 0) {
3277
+ const kept = await associateTurnAttachments(attachIds.ids, `session-turn:${clientTurnId}`, deps.turnAttachmentStore)
3278
+ stage('files')
3279
+ if (!kept.ok) return refuseTurn('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[kept.code], attachmentError: kept.code })
3280
+ }
3281
+
3140
3282
  // A failed pin is FATAL, per the registry's own caller contract: an unpinned
3141
3283
  // binding can expire or be detached mid-turn, which defeats the lease.
3142
3284
  // 6.78.0: the pin names its client turn (COS_PIN_BOOT_REAP) and opens the attempt
@@ -3184,7 +3326,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3184
3326
  live = await deps.deliverLiveTurn({
3185
3327
  provider: binding.provider,
3186
3328
  sessionId: binding.nativeThreadId,
3187
- prompt,
3329
+ prompt: deliveredPrompt,
3188
3330
  verifyTimeoutMs: LIVE_VERIFY_BUDGET_MS,
3189
3331
  clientTurnId,
3190
3332
  })
@@ -3204,6 +3346,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3204
3346
  const latched = latchedHere()
3205
3347
  if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
3206
3348
  console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
3349
+ ledgerTurn('live', binding.provider, binding.nativeThreadId)
3207
3350
  respond(200, {
3208
3351
  turnId,
3209
3352
  outcome: 'completed',
@@ -3222,6 +3365,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3222
3365
  // 6.53.3: something MAY be in the session and the person asked for it to stop: arm
3223
3366
  // the marker as for a landed turn, and settle this turn as cancelled, never as a
3224
3367
  // retryable hold that the queue would send again after the cancel's two minutes.
3368
+ // The text MAY be in the session, footer and all: a record, so a verify finds it.
3369
+ ledgerTurn('live-unverified', binding.provider, binding.nativeThreadId)
3225
3370
  const latched = latchedHere()
3226
3371
  if (latched) {
3227
3372
  settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
@@ -3263,7 +3408,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3263
3408
  live = await deps.deliverCodexLiveTurn({
3264
3409
  provider: binding.provider,
3265
3410
  sessionId: binding.nativeThreadId,
3266
- prompt,
3411
+ prompt: deliveredPrompt,
3267
3412
  foreignHolder: true,
3268
3413
  })
3269
3414
  } catch (error) {
@@ -3278,6 +3423,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3278
3423
  // 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
3279
3424
  const latched = latchedHere()
3280
3425
  if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt, busy: busyCodexHop })
3426
+ ledgerTurn('codex-live', binding.provider, binding.nativeThreadId)
3281
3427
  console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
3282
3428
  respond(200, {
3283
3429
  turnId,
@@ -3320,6 +3466,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3320
3466
  // delivery is: ambiguous, ledgered (a replay answers the same), and fenced until
3321
3467
  // a person looks at the Codex app's queue and releases it.
3322
3468
  if (live === null || live.reason === 'unverified') {
3469
+ ledgerTurn('codex-live-unverified', binding.provider, binding.nativeThreadId)
3323
3470
  guard.fence(key, 'native_target_fenced', {
3324
3471
  provider: binding.provider,
3325
3472
  headBefore: head.digest,
@@ -3373,6 +3520,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3373
3520
  let delivery: Delivery
3374
3521
  deliveryAttempted = true
3375
3522
  spawnMay = true
3523
+ ledgerTurn('spawn', binding.provider, binding.nativeThreadId)
3376
3524
  try {
3377
3525
  const result = await deliver({
3378
3526
  turnId,
@@ -3383,7 +3531,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3383
3531
  workspaceFingerprint: binding.workspaceFingerprint,
3384
3532
  sourceFingerprint: binding.sourceFingerprint,
3385
3533
  expectedNativeHead: head.raw,
3386
- prompt,
3534
+ prompt: deliveredPrompt,
3387
3535
  abortSignal: cancelController.signal,
3388
3536
  // 6.62.0 /qa (Q3): only a client that shows `continue_note` gets Cursor Run Everything.
3389
3537
  continueNoteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),
@@ -83,6 +83,8 @@ import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
83
83
  import { sessionActFactsFor, sessionActFields } from '../lib/session-act-fields.js'
84
84
  import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
85
85
 
86
+ import { agentSessionOutputImages } from '../lib/agent-session-output-images.js'
87
+
86
88
  export const agentSessionsRouter = Router()
87
89
 
88
90
  function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
@@ -236,7 +238,7 @@ export function withRunning<T extends { session_id: string }>(entry: T, scan: Oc
236
238
  // clears on its own when the work stops. Still a display hint, never a gate.
237
239
  // 6.62.0: or the engine's own hooks (or a Codex rollout) say a turn is open and the
238
240
  // last event is inside HOOK_ACTIVE_CAP_MS (plan 1.1-1.3).
239
- running_active: decides ? hint!.active : occ?.activeRecently === true || hint?.active === true,
241
+ running_active: decides || hint?.activeDecides === true ? hint!.active : occ?.activeRecently === true || hint?.active === true,
240
242
  }
241
243
  }
242
244
 
@@ -254,14 +256,22 @@ export function withRunning<T extends { session_id: string }>(entry: T, scan: Oc
254
256
  * and an Esc with no Stop looks the same, QA W8);
255
257
  * Claude and Codex: no hint (occupancy answers)
256
258
  * hook waiting running, not active
259
+ * hook or registry idle (Claude, Codex) not active even if the file was just written
260
+ * (a flush after Stop), unless the transcript's
261
+ * newest record is more than
262
+ * TRANSCRIPT_AFTER_IDLE_GRACE_MS past the idle
263
+ * signal: then no hint (occupancy freshness
264
+ * answers, a hookless restarted turn)
265
+ * hook or registry waiting, ended, failed not active, ownership kept (Cursor: decided)
257
266
  * Codex rollout turn open inside the cap running, active (no Codex hooks yet)
258
267
  * Cursor, any other hook state not running, not active (`decides`)
259
268
  *
260
269
  * A hook-derived `running` only exists inside the deriver's 30-minute open-turn ceiling, so an
261
270
  * open-quiet Cursor row ends there, or at its Stop or SessionEnd.
262
271
  *
263
- * For Claude and Codex the hint only ADDS to occupancy (a held, freshly written thread still
264
- * reads as it did). For Cursor, whose list has no occupancy at all, a hook signal decides, and
272
+ * For Claude and Codex, authoritative idle/waiting states suppress recent-write activity
273
+ * for display while leaving ownership intact. Other hints add to occupancy. For Cursor,
274
+ * whose list has no occupancy at all, a hook signal decides, and
265
275
  * a running Cursor turn is the user's own (an IDE composer or their terminal; COS's own child
266
276
  * is kept out of the phase), so it is foreign.
267
277
  */
@@ -271,15 +281,42 @@ export interface RunningHint {
271
281
  foreign: boolean
272
282
  /** Cursor with hook evidence: this hint IS the answer, occupancy and file time are not read. */
273
283
  decides: boolean
284
+ /** Authoritative non-working state suppresses file-write activity without changing occupancy. */
285
+ activeDecides?: boolean
274
286
  }
275
287
 
276
- export function runningHintFor(provider: AgentProvider, derived: DerivedSessionState | undefined, lastEventAt: number | null, now: number): RunningHint | null {
288
+ /**
289
+ * A transcript write this long after the idle signal is new work, not the flush that
290
+ * trails a Stop. Claude writes its last records around the Stop hook; a turn that wakes
291
+ * later (a background-task notification restarts a text-only turn, with no prompt and no
292
+ * tool call, so no hook fires until its Stop) writes well past this.
293
+ */
294
+ export const TRANSCRIPT_AFTER_IDLE_GRACE_MS = 5_000
295
+
296
+ export function runningHintFor(provider: AgentProvider, derived: DerivedSessionState | undefined, lastEventAt: number | null, now: number, transcriptAt: number | null = null): RunningHint | null {
277
297
  const cursor = provider === 'cursor'
278
298
  const quiet: RunningHint | null = cursor ? { running: false, active: false, foreign: false, decides: true } : null
279
299
  if (!derived) return null
300
+ // The state deriver already arbitrates fresh work, waits and completed turns.
301
+ // A transcript flush after Stop must not turn an idle/waiting badge back to
302
+ // working. Keep ownership/open-window evidence intact: this is display only.
303
+ if ((derived.state_source === 'hook' || derived.state_source === 'registry') && derived.agent_state !== 'running') {
304
+ // An idle signal OLDER than the transcript's newest real record is not the latest
305
+ // word: a turn restarted with no hook (see TRANSCRIPT_AFTER_IDLE_GRACE_MS) is writing.
306
+ // Fall back to occupancy freshness. Idle only: waiting keeps `running: true` and its
307
+ // question; ended and failed stay settled. Cursor's transcript has no clock.
308
+ if (!cursor && derived.agent_state === 'idle' && transcriptAt !== null) {
309
+ const since = Date.parse(derived.state_since)
310
+ const signalAt = Math.max(Number.isFinite(since) ? since : 0, derived.state_source === 'hook' ? lastEventAt ?? 0 : 0)
311
+ if (transcriptAt > signalAt + TRANSCRIPT_AFTER_IDLE_GRACE_MS) return null
312
+ }
313
+ if (cursor) return derived.agent_state === 'waiting'
314
+ ? { running: true, active: false, foreign: true, decides: true }
315
+ : quiet
316
+ return { running: derived.agent_state === 'waiting', active: false, foreign: false, decides: false, activeDecides: true }
317
+ }
280
318
  const inCap = lastEventAt !== null && now - lastEventAt <= HOOK_ACTIVE_CAP_MS && lastEventAt <= now + 5_000
281
319
  if (derived.state_source === 'hook') {
282
- if (derived.agent_state === 'waiting') return { running: true, active: false, foreign: cursor, decides: cursor }
283
320
  if (derived.agent_state === 'running' && inCap) return { running: true, active: true, foreign: cursor, decides: cursor }
284
321
  if (derived.agent_state === 'running' && cursor) return { running: true, active: false, foreign: true, decides: true }
285
322
  return quiet
@@ -535,7 +572,7 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
535
572
  const derived = derivedById.get(row.session_id)
536
573
  const signal = signalFor(row.session_id)
537
574
  const facts = rolloutById.get(row.session_id) ?? null
538
- const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now)
575
+ const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now, clocks.get(row.session_id) ?? null)
539
576
  return {
540
577
  ...withRunning(toEntry(row, activity[index], derived, queuedOf(row.provider, row.session_id)), running, hint),
541
578
  ...cursorActivityFields(row.provider, signal),
@@ -604,6 +641,37 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
604
641
  }
605
642
  })
606
643
 
644
+ // Authenticated by index.ts's shared /api gate. Neither the request nor the response
645
+ // carries a filesystem path; the resolver reauthorizes exact transcript membership.
646
+ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/output-images/:imageId/content', async (req, res) => {
647
+ res.set('Cache-Control', 'private, no-store')
648
+ res.set('X-Content-Type-Options', 'nosniff')
649
+ const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
650
+ if (!provider || (req.query.variant !== undefined && req.query.variant !== 'thumb' && req.query.variant !== 'phone')) {
651
+ res.status(404).json({ error: 'image_not_found' }); return
652
+ }
653
+ try {
654
+ const content = await agentSessionOutputImages.content(provider, String(req.params.sessionId), String(req.params.imageId), req.query.variant === 'thumb' ? 'thumb' : 'phone')
655
+ if (content.status !== 'ok') {
656
+ res.status(content.status === 'not_found' ? 404 : 503).json({ error: content.status === 'not_found' ? 'image_not_found' : 'image_unavailable' }); return
657
+ }
658
+ res.type('image/jpeg')
659
+ // `send` defaults to `dotfiles: 'ignore'` and applies it to the WHOLE absolute path
660
+ // when no `root` is given, so a media root under `~/.cos-glasses/data` 404'd on
661
+ // every default install (see the header of lib/send-audio.ts). 'allow' is safe: the
662
+ // dot component is our data home, and the resolver above returned an exact,
663
+ // containment-checked path with no caller-supplied segments.
664
+ res.sendFile(content.path, { dotfiles: 'allow' }, err => {
665
+ if (!err) return
666
+ if (!res.headersSent) res.status(503).json({ error: 'image_unavailable' })
667
+ else res.destroy()
668
+ })
669
+ } catch {
670
+ console.warn('[agent-sessions] output image unavailable')
671
+ res.status(503).json({ error: 'image_unavailable' })
672
+ }
673
+ })
674
+
607
675
  agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res) => {
608
676
  res.set('Cache-Control', 'private, no-store')
609
677
  const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
@@ -647,7 +715,8 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
647
715
  let recentTurns: RecentTurnsRead | null = null
648
716
  if (turnsWanted !== null) {
649
717
  try {
650
- recentTurns = await readRecentSessionTurns(provider, found, turnsWanted)
718
+ const images = await agentSessionOutputImages.context(provider, sessionId, found)
719
+ recentTurns = await readRecentSessionTurns(provider, found, turnsWanted, undefined, images.project)
651
720
  } catch (error) {
652
721
  console.warn(`[agent-sessions] recent turns failed provider=${provider}: ${error instanceof Error ? error.message : error}`)
653
722
  }
@@ -684,7 +753,7 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
684
753
  derived = deriveCodexRow(parsed.session_id, running.occupied.has(parsed.session_id), rollout, now, false)
685
754
  }
686
755
  const signal = signalFor(parsed.session_id)
687
- const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now)
756
+ const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now, activityClockMs(st.mtimeMs, activity))
688
757
  res.json({
689
758
  ...withRunning({ session_id: parsed.session_id }, running, hint),
690
759
  ...derivedRowFields(derived),
@@ -32,6 +32,17 @@ export const CLIENT_INSTANCE_MAX_SEEN = 64
32
32
 
33
33
  export interface ClientInstanceRouterDeps {
34
34
  now?: () => number
35
+ /**
36
+ * G2 authority Tier 1 (6.99.29): the owner-per-device map, shared so the turn provenance
37
+ * can ask which app copy holds the ring for a request's address. Absent: private, as before.
38
+ */
39
+ owners?: Map<string, ClientInstanceOwner>
40
+ }
41
+
42
+ /** The ring owner's id for this device address, when it claimed within `freshMs`; else null. */
43
+ export function pairedInstanceFrom(owners: ReadonlyMap<string, ClientInstanceOwner>, device: string, now: number, freshMs: number): string | null {
44
+ const owner = owners.get(deviceKey(device))
45
+ return owner && now - owner.seenAt <= freshMs ? owner.id : null
35
46
  }
36
47
 
37
48
  /** A POST that carries a meeting chunk: a live one, or an iPhone offline chunk replayed. The preview is not one. */
@@ -49,7 +60,7 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
49
60
  const opts: ClientInstanceRouterDeps = typeof deps === 'function' ? { now: deps } : deps
50
61
  const now = opts.now ?? Date.now
51
62
  const router = Router()
52
- const owners = new Map<string, ClientInstanceOwner>()
63
+ const owners = opts.owners ?? new Map<string, ClientInstanceOwner>()
53
64
  const chunks = new ClientChunkLedger()
54
65
  // 6.52.3: when each copy last claimed, so an older boot proves it was awake before it
55
66
  // may take a quiet ring back. Bounded like the chunk ledger.
@@ -14,12 +14,15 @@ import express, { Router, type NextFunction, type Request, type Response } from
14
14
  import { timingSafeTokenEqual } from '../lib/token-auth.js'
15
15
  import { claimNextCursorTurn, cursorStopAcceptsFollowup, cursorStopIsFresh, parseCursorStopEnvelope } from '../lib/cursor-stop-followup.js'
16
16
  import { MAX_QUEUED_PER_THREAD, type QueuedThreadTurn } from '../lib/thread-turn-queue.js'
17
+ import { hasFooterShapedLine, type G2TurnProvenance } from '../lib/g2-turn-provenance.js'
17
18
 
18
19
  export interface CursorStopFollowupDeps {
19
20
  /** The per-install hook token, or null when none has been minted (then nothing is answered). */
20
21
  hookToken: () => string | null
21
22
  readQueue: (provider: string, threadId: string, now: number) => QueuedThreadTurn[]
22
23
  writeQueue: (provider: string, threadId: string, queue: QueuedThreadTurn[]) => void
24
+ /** G2 authority Tier 1 (6.99.29), optional: ledger the hand-off and foot a glasses or phone turn. */
25
+ provenance?: G2TurnProvenance
23
26
  now: () => number
24
27
  }
25
28
 
@@ -60,6 +63,20 @@ export function createCursorStopFollowupRouter(deps: CursorStopFollowupDeps): Ro
60
63
  }
61
64
  claimed = claimNextCursorTurn(deps.readQueue('cursor', facts.conversationId, now), now)
62
65
  if (!claimed) return res.json({})
66
+ // G2 authority Tier 1 (Codex re-audit of 910eb90): the queue file is mutable, and this is the
67
+ // last hand-off before the words reach the composer. Words carrying a footer-shaped line are
68
+ // refused here exactly as at every other route that hands words to a session: the row is
69
+ // settled refused (never handed over, never retried) and its parked origin forgotten.
70
+ if (deps.provenance && hasFooterShapedLine(claimed.turn.prompt)) {
71
+ const refusedTurn = claimed.turn
72
+ const settled = claimed.queue.map(t => t.clientTurnId === refusedTurn.clientTurnId
73
+ ? { ...t, status: 'refused' as const, reason: 'footer_line_refused', settledAt: now }
74
+ : t)
75
+ deps.writeQueue('cursor', facts.conversationId, settled)
76
+ deps.provenance.forgetQueued('cursor', facts.conversationId, refusedTurn.clientTurnId)
77
+ console.warn(`[cursor-stop-followup] refused a queued turn with a footer-shaped line clientTurnId=${refusedTurn.clientTurnId}`)
78
+ return res.json({})
79
+ }
63
80
  // Written BEFORE the text leaves: the row must never read `waiting` after the
64
81
  // composer has been handed it, or a later Stop would hand it over again.
65
82
  deps.writeQueue('cursor', facts.conversationId, claimed.queue)
@@ -68,7 +85,19 @@ export function createCursorStopFollowupRouter(deps: CursorStopFollowupDeps): Ro
68
85
  return res.json({})
69
86
  }
70
87
  console.log(`[cursor-stop-followup] handed a queued turn to Cursor clientTurnId=${claimed.turn.clientTurnId} chars=${claimed.turn.prompt.length} loop=${facts.loopCount ?? 'null'} cursor=${facts.cursorVersion}`)
71
- return res.json({ followup_message: claimed.turn.prompt })
88
+ let followup = claimed.turn.prompt
89
+ if (deps.provenance) {
90
+ // The hook's own socket is local; the origin is the one captured when the turn was
91
+ // parked, and only while the queued text is still the text it vouched for.
92
+ const turn = claimed.turn
93
+ const verdict = deps.provenance.queuedVerdict('cursor', facts.conversationId, turn.clientTurnId, turn.prompt) ?? deps.provenance.originOf(req)
94
+ followup = deps.provenance.footed(turn.prompt, turn.clientTurnId, verdict, 'cursor')
95
+ // The queue's own hand-off of its parked turn: it continues the record parked under this id.
96
+ deps.provenance.record({ turnId: turn.clientTurnId, prompt: turn.prompt, verdict, provider: 'cursor', sessionId: facts.conversationId, via: 'cursor-stop-followup', continues: true })
97
+ // Settled (handed over): the parked origin is forgotten (QA 6.99.29).
98
+ deps.provenance.forgetQueued('cursor', facts.conversationId, turn.clientTurnId)
99
+ }
100
+ return res.json({ followup_message: followup })
72
101
  })
73
102
  return router
74
103
  }
@@ -0,0 +1,46 @@
1
+ // GET /api/g2-turns/:turnId (and its 6.99.29 alias GET /api/turn-provenance/:turnId): read-only provenance for one turn this server delivered or queued
2
+ // into an agent session (G2 authority Tier 1, see lib/g2-turn-provenance.ts). Behind the global
3
+ // X-Cos-Token gate like every /api route. 400 on a malformed id, 404 `{ found: false }` when no
4
+ // record names it. Answers what was recorded, and vouches (`verifiedOrigin: true`) only for a
5
+ // glasses or phone record this process wrote and still holds, written within 24 hours. A web record
6
+ // is reported with `reason: web_cookie_derivable_from_token` and never vouched (6.99.29).
7
+
8
+ import { Router } from 'express'
9
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
10
+ import { describeG2Turn, G2_TURN_ID_RE, type G2TurnProvenance } from '../lib/g2-turn-provenance.js'
11
+
12
+ export function createG2TurnsRouter(deps: {
13
+ provenance: G2TurnProvenance
14
+ now?: () => number
15
+ /**
16
+ * 6.99.29: the pairing token. With it, only a request that presents X-Cos-Token is answered: the
17
+ * /api gate also admits a COS Control Web browser by its cookie, and the verifier runs on this Mac
18
+ * with the token. Absent: whatever the gate admitted (the 6.66 tests mount the gate alone).
19
+ */
20
+ apiToken?: () => string
21
+ }): Router {
22
+ const router = Router()
23
+ const now = deps.now ?? Date.now
24
+ // 6.99.29: one handler, two paths. `/turn-provenance` names what it is now that web turns are
25
+ // recorded too (reported, never vouched: lib/g2-turn-provenance.ts); `/g2-turns` stays for
26
+ // verify_g2_turn.py and every caller of the 6.66 design.
27
+ router.get(['/g2-turns/:turnId', '/turn-provenance/:turnId'], async (req, res) => {
28
+ res.set('Cache-Control', 'private, no-store')
29
+ if (deps.apiToken) {
30
+ let expected = ''
31
+ try { expected = deps.apiToken() } catch { expected = '' }
32
+ if (!expected || !timingSafeTokenEqual(req.headers['x-cos-token'], expected)) return void res.status(401).json({ found: false, error: 'token_required' })
33
+ }
34
+ const turnId = String(req.params.turnId ?? '')
35
+ if (!G2_TURN_ID_RE.test(turnId)) return void res.status(400).json({ found: false, error: 'invalid_turn_id' })
36
+ try {
37
+ const answer = await describeG2Turn(deps.provenance, turnId, now())
38
+ if (!answer) return void res.status(404).json({ found: false, turnId })
39
+ res.json(answer)
40
+ } catch (error) {
41
+ console.error(`[g2-turns] lookup failed: ${error instanceof Error ? error.message : error}`)
42
+ res.status(500).json({ found: false, error: 'lookup_failed' })
43
+ }
44
+ })
45
+ return router
46
+ }