@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
@@ -1,3 +1,5 @@
1
+ import { MAX_ATTACHMENTS_PER_PROMPT } from '../../shared/media-attachment.js'
2
+ import { TURN_FILE_MAX_BYTES } from '../lib/turn-file-store.js'
1
3
  import { dropItCapability } from '../lib/drop-settings.js'
2
4
  import { PREP_CAPABILITY } from '../../shared/prep.js'
3
5
  import { phoneMicrophoneMeetingsCapability } from '../lib/phone-recording.js'
@@ -446,6 +448,13 @@ healthRouter.get('/health', async (_req, res) => {
446
448
  ...checks,
447
449
  server_version: managedServerVersion(),
448
450
  ...threadAttachHealthFields(threadAttach),
451
+ // 11/07 (Control Web attach): a session turn takes up to this many media-store ids as `attachmentIds`, and the turns
452
+ // route writes each file's path on this Mac under the words. 0 with attach off; absent on an older server. Either
453
+ // way a client sends words only.
454
+ threadTurnAttachments: threadAttach.enabled ? MAX_ATTACHMENTS_PER_PROMPT : 0,
455
+ // 6.99.27: a session turn also takes ANY file up to this size (lib/turn-file-store.ts), sent to /api/media/file with
456
+ // X-COS-Purpose: session-turn. 0 with attach off; absent on an older server, which takes only what the media store reads.
457
+ threadTurnFileBytes: threadAttach.enabled ? TURN_FILE_MAX_BYTES : 0,
449
458
  ...sessionHooksHealthFields(),
450
459
  // 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
451
460
  // how many are held (that is on the authenticated questions route).
@@ -22,6 +22,7 @@
22
22
  // /api/media/file STREAMS to disk rather than buffering: see
23
23
  // createMediaBinaryBodyParser below.
24
24
 
25
+ import { getTurnFileStore } from '../lib/turn-file-store.js'
25
26
  import { Router, json, type NextFunction, type Request, type Response } from 'express'
26
27
  import { createReadStream, createWriteStream, openSync, statSync } from 'node:fs'
27
28
  import {
@@ -554,6 +555,15 @@ mediaRouter.post('/media', async (req: Request, res: Response) => {
554
555
  * bounded requests, but composer admission still owns the shared five-item
555
556
  * prompt cap. Filename and MIME are hints only; the store sniffs and validates
556
557
  * the actual staged bytes. */
558
+ /**
559
+ * 6.99.27: does a refused upload become a turn file? Only for a session turn, and only when the media store refused the
560
+ * FORMAT (it cannot read the type, or the text is not UTF-8). Too large, too long, or the Mac unable to process media
561
+ * right now are said as they always were.
562
+ */
563
+ export function keepsAsTurnFile(purpose: string | undefined, code: unknown): boolean {
564
+ return purpose === 'session-turn' && (code === 'unsupported_attachment_format' || code === 'corrupt_attachment')
565
+ }
566
+
557
567
  mediaRouter.post('/media/file', async (req: Request, res: Response) => {
558
568
  const staged = takeStagedUploadBody(req)
559
569
  try {
@@ -566,14 +576,28 @@ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
566
576
  if (rawLabel) {
567
577
  try { label = decodeURIComponent(rawLabel).slice(0, 120) } catch { label = rawLabel.slice(0, 120) }
568
578
  }
569
- const attachment = await getMediaStore().ingestRichMediaFromFile({
570
- sourcePath: staged.path,
571
- byteLength: staged.bytes,
572
- label,
573
- declaredMime: safeString(req.header('content-type'), 120),
574
- capturedAt: safeString(req.header('x-cos-captured-at'), 40),
575
- sessionId: safeString(req.header('x-cos-session-id'), 64),
576
- })
579
+ let attachment: unknown
580
+ try {
581
+ attachment = await getMediaStore().ingestRichMediaFromFile({
582
+ sourcePath: staged.path,
583
+ byteLength: staged.bytes,
584
+ label,
585
+ declaredMime: safeString(req.header('content-type'), 120),
586
+ capturedAt: safeString(req.header('x-cos-captured-at'), 40),
587
+ sessionId: safeString(req.header('x-cos-session-id'), 64),
588
+ })
589
+ } catch (err) {
590
+ // 6.99.27: a file for a Claude or Codex session turn, in a format the media store does not read (a .zip, a .docx,
591
+ // a log in another encoding). The session gets its path and opens it itself, so it is kept as a turn file, which
592
+ // only the turn and fork routes can resolve (lib/turn-file-store.ts). Anything else refuses as before.
593
+ if (!keepsAsTurnFile(req.header('x-cos-purpose'), (err as { code?: unknown } | null)?.code)) throw err
594
+ attachment = getTurnFileStore().ingestFromFile({
595
+ sourcePath: staged.path,
596
+ byteLength: staged.bytes,
597
+ label,
598
+ declaredMime: safeString(req.header('content-type'), 120),
599
+ })
600
+ }
577
601
  res.json({ attachment })
578
602
  } catch (err) {
579
603
  sendMediaError(res, err)
@@ -35,8 +35,14 @@
35
35
  // The hook token still opens nothing here. A browser poll counts as a live answering client
36
36
  // only with `?client=web`, which the page sends only while it shows the cards (a visible tab);
37
37
  // the same 30 s window as the phone. A cookie poll naming `glasses` or `phone`, or no client,
38
- // never counts, and a token poll naming `web` never counts either. A browser never reports
39
- // wearer presence, so it never enables the away hold.
38
+ // never counts, and a token poll naming `web` never counts either.
39
+ // 6.99.26 (Miles 11/07, "Away rule, like phone"): a browser poll `?client=web&hold=1&presenceAgeMs=<int>`
40
+ // (the ms since a touch or a key while the page was in front) records COS Control Web's presence for the
41
+ // away hold ONLY (lib/client-liveness noteWebPresence): never a live client, so at the desk the Mac's own
42
+ // pop-up still comes at once (6.99.13). Without `hold=1` or a valid `presenceAgeMs` it records nothing. A
43
+ // token poll naming `web`, and a browser poll naming anything else, still record nothing. QA round 1: neither
44
+ // does a poll from the Mac itself (loopback, or one of its own addresses, with no proxy header): Control Web
45
+ // open on the Mac is the desk, never away.
40
46
  // 6.60.0: a poll with `&hold=1&presenceAgeMs=<int>` (COS Glasses 6.9.563 and later) records
41
47
  // wearer presence for the away hold instead of the 6.59.0 liveness; a `hold=1` poll without
42
48
  // a valid `presenceAgeMs` makes no client live and enables no hold. The response adds
@@ -53,7 +59,9 @@ import {
53
59
  type HookReplyChannel,
54
60
  type PermissionBroker,
55
61
  } from '../lib/permission-broker.js'
56
- import { noteQuestionsPoll, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
62
+ import { noteQuestionsPoll, noteWebPresence, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
63
+ import { hasProxyForwardedHeaders, isLoopbackAddress, normalizeRemoteIp, reachableIpv4Addresses } from '../lib/network-policy.js'
64
+ import { networkInterfaces } from 'node:os'
57
65
 
58
66
  export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
59
67
  /** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
@@ -155,6 +163,10 @@ export interface SessionQuestionsRouterDeps {
155
163
  webSessionAuthorized?: (req: Request) => boolean
156
164
  /** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
157
165
  notePoll?: (at: number, poll: QuestionsPollFacts) => void
166
+ /** 6.99.26: where COS Control Web's presence is recorded; `noteWebPresence` in production. */
167
+ noteWebPresence?: (at: number, presenceAgeMs: number) => void
168
+ /** 6.99.26 (QA round 1): whether a request comes from this Mac itself; `requestFromThisMac` in production. */
169
+ fromThisMac?: (req: Request) => boolean
158
170
  now?: () => number
159
171
  /** 6.60.0: when this server process started (ISO); defaults to the process's own start. */
160
172
  serverStartedAt?: string
@@ -176,9 +188,30 @@ export function questionsPollFacts(query: Record<string, unknown>): QuestionsPol
176
188
  return { hold, presenceAgeMs: hold && validPresenceAgeMs(parsed) ? parsed : null }
177
189
  }
178
190
 
191
+ /**
192
+ * 6.99.26 (QA round 1): a request from this Mac itself: its socket is loopback or one of the Mac's own addresses (its
193
+ * tailnet address first), and no proxy header says it was forwarded for someone else (a `tailscale serve` hop arrives on
194
+ * loopback for a remote peer). Control Web open on the Mac is the desk: its touch never makes the Mac away.
195
+ */
196
+ export function requestFromThisMac(
197
+ req: { socket?: { remoteAddress?: string | undefined } | null; headers?: Record<string, unknown> | null },
198
+ selfAddresses: () => readonly string[] = () => reachableIpv4Addresses(networkInterfaces()).map(a => a.address),
199
+ ): boolean {
200
+ if (hasProxyForwardedHeaders(req.headers)) return false
201
+ const remote = req.socket?.remoteAddress
202
+ if (isLoopbackAddress(remote)) return true
203
+ const ip = normalizeRemoteIp(remote ?? '')
204
+ if (!ip) return false
205
+ let own: readonly string[] = []
206
+ try { own = selfAddresses() } catch { own = [] }
207
+ return own.includes(ip)
208
+ }
209
+
179
210
  export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
180
211
  const router = Router()
181
212
  const notePoll = deps.notePoll ?? noteQuestionsPoll
213
+ const noteWeb = deps.noteWebPresence ?? ((at: number, ageMs: number) => noteWebPresence(at, ageMs))
214
+ const fromThisMac = deps.fromThisMac ?? ((req: Request) => requestFromThisMac(req))
182
215
  const now = deps.now ?? Date.now
183
216
  router.get('/session-questions', (req: Request, res: Response) => {
184
217
  res.set('Cache-Control', 'private, no-store')
@@ -199,6 +232,11 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
199
232
  if (byToken && ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
200
233
  // 6.99.13 (Miles, option B): a browser poll never counts as live, so an open web tab never holds a question
201
234
  // back from the Mac's own pop-up. The web still lists and answers what the glasses or phone are holding.
235
+ // 6.99.26: its touch counts for the away hold only, like the phone's (Miles 11/07, "Away rule, like phone").
236
+ if (byWebSession && client === WEB_ANSWERING_CLIENT) {
237
+ const facts = questionsPollFacts(req.query as Record<string, unknown>)
238
+ if (facts.presenceAgeMs !== null && facts.presenceAgeMs !== undefined && !fromThisMac(req)) noteWeb(now(), facts.presenceAgeMs)
239
+ }
202
240
  const health = deps.broker.health()
203
241
  // Listed first: a deadline the timer has not caught yet is settled by the listing.
204
242
  const items = deps.broker.list()
@@ -227,7 +265,12 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
227
265
  })
228
266
  router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
229
267
  res.set('Cache-Control', 'private, no-store')
230
- const result = deps.broker.answer(String(req.params.id ?? ''), req.body)
268
+ // 6.99.27: label the answer by its door (the /api gate already let it in): the token is the Even app, anything else
269
+ // is COS Control Web's browser session.
270
+ let expected = ''
271
+ try { expected = deps.apiToken() } catch { expected = '' }
272
+ const by = timingSafeTokenEqual(req.headers['x-cos-token'], expected) ? 'app' : 'web'
273
+ const result = deps.broker.answer(String(req.params.id ?? ''), req.body, by)
231
274
  res.status(result.status).json(result.body)
232
275
  })
233
276
  return router
@@ -15,6 +15,7 @@
15
15
  //
16
16
  // EVERY DEPENDENCY IS INJECTED so the whole path is testable without a live server.
17
17
 
18
+ import { randomBytes } from 'node:crypto'
18
19
  import { Router, type Request, type Response } from 'express'
19
20
  import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
20
21
  import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
@@ -29,8 +30,24 @@ import { readQueue, writeQueue, queuedThreadKeys } from '../lib/thread-turn-queu
29
30
  import type { DeliveryEvidence } from '../lib/queue-turn-evidence.js'
30
31
  import { continueLiveEnabled } from '../lib/session-peer-inbox-deps.js'
31
32
  import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
33
+ import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
34
+ import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
35
+
36
+ /**
37
+ * G2 authority Tier 1 (6.99.29, Codex audit of e4758a6): the verdict a parked turn's files allow, and
38
+ * the attachment digest its origin is sealed to. Files whose content cannot be read leave the turn
39
+ * unvouched (it still parks); no files is the empty digest and reads nothing.
40
+ */
41
+ async function sealFiles(ids: readonly string[] | undefined, verdict: OriginVerdict, store: TurnAttachmentStore | undefined): Promise<{ verdict: OriginVerdict; attachments: string }> {
42
+ if (!Array.isArray(ids) || ids.length === 0) return { verdict, attachments: '' }
43
+ const manifest = await turnAttachmentManifest(ids, store)
44
+ if (!manifest.ok) return { verdict: unvouched(verdict, 'attachments_unsealable'), attachments: '' }
45
+ return { verdict, attachments: attachmentsDigest(manifest.manifest) }
46
+ }
32
47
 
33
48
  export interface ThreadTurnQueueDeps {
49
+ /** 11/07: the media store a parked turn's `attachmentIds` are checked against. Absent: the real store. Tests pass a fake. */
50
+ turnAttachmentStore?: TurnAttachmentStore
34
51
  /** Re-runs the FULL occupancy gate. The drainer never decides attachability itself. */
35
52
  occupancy: (provider: string, threadId: string) => { attachable: boolean; reason: string | null }
36
53
  /** Did the holder's last transcript record end a turn? */
@@ -66,6 +83,13 @@ export interface ThreadTurnQueueDeps {
66
83
  * Stop hook. Absent: no Cursor turn is ever drained here, exactly as in 6.61.
67
84
  */
68
85
  cursorCliChat?: (threadId: string) => boolean
86
+ /**
87
+ * G2 authority Tier 1, with web origin (6.99.29), optional: the origin of each parked turn is
88
+ * captured here (and again on an edit or a Send again, each judged by its own request) and carried to
89
+ * its drain, forgotten on any settle; a steer and a native Codex queue edit are ledgered and
90
+ * footed like a turn. Absent: the 6.99.28 behaviour.
91
+ */
92
+ provenance?: G2TurnProvenance
69
93
  now: () => number
70
94
 
71
95
  // ------------------------------------------------------------- 6.78.0 (P3)
@@ -126,6 +150,8 @@ export function publicRow(turn: QueuedThreadTurn, position: number): Record<stri
126
150
  preview: turn.prompt.length > 80 ? `${turn.prompt.slice(0, 79)}…` : turn.prompt,
127
151
  ...(turn.reason ? { reason: turn.reason } : {}),
128
152
  ...(turn.settledAt ? { settledAt: turn.settledAt } : {}),
153
+ // 11/07: how many files wait with the words (never their ids or paths). Absent without files.
154
+ ...(Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0 ? { attachments: turn.attachmentIds.length } : {}),
129
155
  }
130
156
  // 6.78.0: additive, and only while a P3 flag is on, so the row is the 6.77 bytes otherwise.
131
157
  // `resendable` is the per-row capability: present only when Send again can act on it.
@@ -211,6 +237,11 @@ export async function drainThread(
211
237
  if (index >= 0) { latest[index] = turn; writeQueue(provider, threadId, latest, durable ? { durable: true } : {}) }
212
238
  }
213
239
  let refusedGate: ReturnType<ThreadTurnQueueDeps['occupancy']> | null = null
240
+ // G2 authority Tier 1 (QA 6.99.29): a parked origin is forgotten on ANY settle (delivered,
241
+ // cancelled, refused, expired, given up). Only a row still `waiting` keeps it for its next attempt.
242
+ const forgetIfSettled = (turn: QueuedThreadTurn): void => {
243
+ if (turn.status !== 'waiting') deps.provenance?.forgetQueued(provider, threadId, turn.clientTurnId)
244
+ }
214
245
 
215
246
  for (const snapshot of queue) {
216
247
  const turn = readQueue(provider, threadId, deps.now()).find(t => t.clientTurnId === snapshot.clientTurnId)
@@ -259,6 +290,7 @@ export async function drainThread(
259
290
  turn.reason = decision === 'expire' ? 'queued_turn_expired' : 'delivery_attempts_exhausted'
260
291
  turn.settledAt = deps.now()
261
292
  retired += 1; persist(turn)
293
+ forgetIfSettled(turn)
262
294
  continue
263
295
  }
264
296
 
@@ -335,6 +367,7 @@ export async function drainThread(
335
367
  held += 1
336
368
  }
337
369
  persist(turn)
370
+ forgetIfSettled(turn)
338
371
  }
339
372
  return { delivered, held, retired }
340
373
  }
@@ -451,8 +484,15 @@ async function steerTurn(
451
484
  queue: QueuedThreadTurn[],
452
485
  gateReason: string | null,
453
486
  fallbackNone: boolean,
487
+ verdict: OriginVerdict | null = null,
454
488
  ): Promise<unknown> {
455
489
  const { provider, threadId, clientTurnId, prompt } = turn
490
+ // G2 authority Tier 1 (6.99.29): a steer goes straight into the running session through its peer
491
+ // inbox, so it is a delivery of its own and carries the footer like a turn.
492
+ const steerPrompt = deps.provenance && verdict ? deps.provenance.footed(prompt, clientTurnId, verdict, provider) : prompt
493
+ const ledgerSteer = (via: string): void => {
494
+ if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via })
495
+ }
456
496
  const intent: QueuedThreadTurn = {
457
497
  ...turn,
458
498
  status: 'delivering',
@@ -462,7 +502,10 @@ async function steerTurn(
462
502
  ...(typeof deps.bootId === 'string' && deps.bootId.length > 0 ? { deliveringBootId: deps.bootId } : {}),
463
503
  }
464
504
  const admitted = admitToQueue(queue, intent)
465
- if (!admitted.ok) return res.status(409).json({ error: admitted.reason })
505
+ if (!admitted.ok) {
506
+ deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
507
+ return res.status(409).json({ error: admitted.reason })
508
+ }
466
509
  writeQueue(provider, threadId, admitted.queue, { durable: true })
467
510
 
468
511
  /** Rewrite this row on the CURRENT file: other requests may have changed others meanwhile. */
@@ -470,6 +513,9 @@ async function steerTurn(
470
513
  const latest = readQueue(provider, threadId, deps.now())
471
514
  const row = latest.find(t => t.clientTurnId === clientTurnId)
472
515
  if (row) { apply(row); writeQueue(provider, threadId, latest, { durable: true }) }
516
+ // G2 authority Tier 1 (QA 6.99.29): a steer that settled (delivered or refused) forgets its
517
+ // parked origin; one put back to wait keeps it for the drain.
518
+ if (!row || row.status !== 'waiting') deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
473
519
  return latest
474
520
  }
475
521
  const backToWaiting = (row: QueuedThreadTurn): void => {
@@ -502,7 +548,7 @@ async function steerTurn(
502
548
 
503
549
  let live: { ok: boolean; reason: string } | null = null
504
550
  try {
505
- live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
551
+ live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt: steerPrompt, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
506
552
  } catch (error) {
507
553
  console.error(`[thread-turn-queue] steer delivery threw: ${error instanceof Error ? error.message : error}`)
508
554
  live = null
@@ -515,11 +561,13 @@ async function steerTurn(
515
561
 
516
562
  if (live?.ok === true) {
517
563
  journal('sent')
564
+ ledgerSteer('steer')
518
565
  settleRow(row => { row.status = 'delivered'; row.reason = 'steered'; row.settledAt = at })
519
566
  return res.status(200).json({ state: 'delivered', reason: 'steered', clientTurnId })
520
567
  }
521
568
  if (live === null || live.reason === 'unverified' || live.reason === 'write_failed') {
522
569
  journal('unknown')
570
+ ledgerSteer('steer-unverified')
523
571
  settleRow(row => { row.status = 'refused'; row.reason = 'steer_unverified'; row.deliveryState = 'unknown'; row.settledAt = at })
524
572
  return res.status(200).json({
525
573
  state: 'refused',
@@ -560,10 +608,21 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
560
608
  return res.status(400).json({ error: 'invalid_request', reason: 'invalid_cos_session_id' })
561
609
  }
562
610
 
611
+ // G2 authority Tier 1 (QA 6.99.29): only the server writes a verify footer. Words carrying
612
+ // anything footer-shaped are refused where they were typed (this is also the steer's door).
613
+ if (deps.provenance && hasFooterShapedLine(prompt)) {
614
+ return res.status(400).json({ error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
615
+ }
616
+
617
+ // 11/07 (Control Web attach): files with the words, as media-store ids. Absent is the old row, byte for byte.
618
+ const attach = parseTurnAttachmentIds(body.attachmentIds)
619
+ if (!attach.ok) return res.status(400).json({ error: 'invalid_request', reason: 'invalid_attachments' })
620
+
563
621
  // 6.78.0: `mode: 'steer'` asks for the running turn itself (COS_SESSION_STEER). Off, the
564
622
  // field is ignored exactly as a 6.77 server ignores it.
565
623
  const steerOn = sessionSteerEnabled()
566
- const wantSteer = steerOn && body.mode === 'steer'
624
+ // A steer goes through the session's peer inbox, which carries words only: a turn with files always parks.
625
+ const wantSteer = steerOn && body.mode === 'steer' && attach.ids.length === 0
567
626
  const fallbackNone = wantSteer && body.fallback === 'none'
568
627
 
569
628
  // 6.78.0: REPLAY BEFORE THE GATE. A key this queue already holds answers with what that
@@ -584,6 +643,14 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
584
643
  }
585
644
  }
586
645
 
646
+ // G2 authority Tier 1 (6.99.29): who parked these words, judged on this request's own socket,
647
+ // token and cookie. A turn id this server already holds a record for, for a different turn, is
648
+ // never reused (QA 6.99.29: it would overwrite that record).
649
+ let verdict = deps.provenance ? deps.provenance.originOf(req) : null
650
+ if (deps.provenance && verdict && deps.provenance.reusesTurnId(clientTurnId, { prompt, verdict, provider, sessionId: threadId })) {
651
+ return res.status(409).json({ error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
652
+ }
653
+
587
654
  // THE GATE DECIDES WHETHER PARKING IS EVEN HONEST. A structural refusal can never
588
655
  // clear, and telling someone their turn is queued when it can never run is worse
589
656
  // than refusing it. An attachable thread is not queued either -- it is sent now,
@@ -601,6 +668,14 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
601
668
  return res.status(423).json({ error: gate.reason ?? 'probe_failed', queueable: false })
602
669
  }
603
670
 
671
+ // 11/07: every file must be readable now, and is kept past the 4 hour staged TTL while the turn waits. A file
672
+ // that is gone refuses the park here, where the words are still in the box, never later at the drain.
673
+ if (attach.ids.length > 0) {
674
+ const store = deps.turnAttachmentStore ?? defaultTurnAttachmentStore()
675
+ const kept = await keepTurnAttachments(attach.ids, `session-turn:${clientTurnId}`, store)
676
+ if (!kept.ok) return res.status(409).json({ error: kept.code, attachmentError: kept.code, reasonCopy: TURN_ATTACHMENT_COPY[kept.code] })
677
+ }
678
+
604
679
  const now = deps.now()
605
680
  const queue = readQueue(provider, threadId, now)
606
681
  const turn: QueuedThreadTurn = {
@@ -608,20 +683,33 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
608
683
  queuedAt: now, status: 'waiting', attempts: 0,
609
684
  // 6.62.0 /qa (Q3): the acknowledgement travels with the row to its drain.
610
685
  ...(continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]) ? { continueNoteAck: true as const } : {}),
686
+ ...(attach.ids.length > 0 ? { attachmentIds: attach.ids } : {}),
687
+ }
688
+
689
+ // Held in memory with the words' hash, sealed to this provider, thread and these files (each id
690
+ // with its content hash), so the drain can carry it (QUEUED_ORIGIN_TTL_MS at most).
691
+ if (deps.provenance && verdict) {
692
+ const sealed = await sealFiles(attach.ids, verdict, deps.turnAttachmentStore)
693
+ verdict = sealed.verdict
694
+ deps.provenance.noteQueued(provider, threadId, clientTurnId, prompt, verdict, sealed.attachments)
611
695
  }
612
696
 
613
697
  let steerFallback: string | null = null
614
698
  if (wantSteer) {
615
699
  const availability = steerAvailabilityFor(deps, provider, threadId, gate.reason)
616
- if (availability.available) return steerTurn(req, res, deps, turn, queue, gate.reason, fallbackNone)
700
+ if (availability.available) return steerTurn(req, res, deps, turn, queue, gate.reason, fallbackNone, verdict)
617
701
  if (fallbackNone) return res.status(409).json({ error: 'steer_unavailable', reason: availability.reason })
618
702
  steerFallback = availability.reason
619
703
  }
620
704
 
621
705
  const admitted = admitToQueue(queue, turn)
622
- if (!admitted.ok) return res.status(409).json({ error: admitted.reason })
706
+ if (!admitted.ok) {
707
+ deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
708
+ return res.status(409).json({ error: admitted.reason })
709
+ }
623
710
 
624
711
  writeQueue(provider, threadId, admitted.queue)
712
+ if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via: 'queue' })
625
713
  return res.status(202).json({
626
714
  queued: true,
627
715
  clientTurnId,
@@ -639,7 +727,7 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
639
727
  // writing it, so two taps (lens and phone) in the same tick cannot both pass the check.
640
728
  // The new row is admitted even when the thread is free: the drain sends it through the
641
729
  // ordinary front door, with every gate.
642
- router.post('/agent-sessions/:provider/:threadId/queued-turns/:clientTurnId/resend', (req: Request, res: Response, next) => {
730
+ router.post('/agent-sessions/:provider/:threadId/queued-turns/:clientTurnId/resend', async (req: Request, res: Response, next) => {
643
731
  // Off: not a route at all, exactly as on 6.77 (a client reads the 404 as "update the server").
644
732
  if (!queueResendEnabled()) return next()
645
733
  res.set('Cache-Control', 'private, no-store')
@@ -664,6 +752,14 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
664
752
  : { clientTurnId: original.resentAs, resendOf: original.clientTurnId, replayed: true })
665
753
  }
666
754
  if (original.status !== 'refused' && original.status !== 'expired') return res.status(409).json({ error: 'not_resendable' })
755
+ // G2 authority Tier 1 (QA 6.99.29): the copy's words go to a session, so they meet the footer
756
+ // rule like any typed words, and its id may not be one this server already holds a record for.
757
+ if (deps.provenance && hasFooterShapedLine(original.prompt)) {
758
+ return res.status(400).json({ error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
759
+ }
760
+ if (deps.provenance && deps.provenance.holds(newId)) return res.status(409).json({ error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
761
+ // Judged now, from THIS request (Codex re-audit of 910eb90, P1): never inherited from the row.
762
+ const resendVerdict = deps.provenance ? deps.provenance.resendVerdict(req) : null
667
763
  // "May not have been sent": a second copy is possible, so the person has to have been told.
668
764
  if (original.deliveryState === 'unknown' && body.acknowledge !== 'may_duplicate') {
669
765
  return res.status(409).json({ error: 'acknowledge_required' })
@@ -673,12 +769,26 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
673
769
  prompt: original.prompt,
674
770
  queuedAt: now, status: 'waiting', attempts: 0,
675
771
  ...(original.continueNoteAck === true ? { continueNoteAck: true as const } : {}),
772
+ // 11/07: Send again sends the same files; the turns route refuses it there if one has gone.
773
+ ...(Array.isArray(original.attachmentIds) && original.attachmentIds.length > 0 ? { attachmentIds: [...original.attachmentIds] } : {}),
676
774
  resendOf: original.clientTurnId,
677
775
  }
678
776
  const admitted = admitToQueue(queue, copy)
679
777
  if (!admitted.ok) return res.status(409).json({ error: admitted.reason })
680
778
  original.resentAs = newId
681
779
  writeQueue(provider, threadId, admitted.queue)
780
+ // G2 authority Tier 1 (Codex re-audit of 910eb90, P1): the row's words and files are mutable, so
781
+ // the copy NEVER inherits the original row's authority (that let a local caller change a refused
782
+ // G2 row and mint a new vouched turn from it). It carries the origin of the request that sent it
783
+ // again: a verified glasses or phone client is vouched on its own socket and instance, like any
784
+ // new turn; anyone else is recorded unvouched ('resend_not_reverified'), with no footer. The
785
+ // original id is kept only as the record's `resendOf` annotation.
786
+ if (deps.provenance && resendVerdict) {
787
+ // After the write: the read-modify-write above stays one synchronous step. Sealed to the copy's files.
788
+ const sealed = await sealFiles(copy.attachmentIds, resendVerdict, deps.turnAttachmentStore)
789
+ deps.provenance.noteQueued(provider, threadId, newId, copy.prompt, sealed.verdict, sealed.attachments)
790
+ deps.provenance.record({ turnId: newId, prompt: copy.prompt, verdict: sealed.verdict, provider, sessionId: threadId, via: 'queue-resend', resendOf: original.clientTurnId })
791
+ }
682
792
  console.log(`[thread-turn-queue] resend provider=${provider} from=${original.clientTurnId} as=${newId} unknown=${original.deliveryState === 'unknown'}`)
683
793
  return res.status(200).json(publicRow(copy, queuePosition(admitted.queue, newId)))
684
794
  })
@@ -693,7 +803,9 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
693
803
  if (provider === 'codex') {
694
804
  try {
695
805
  native = (await (deps.nativeQueue ?? nativeQueueControl).list(threadId)).map((row, position) => {
696
- const text = plainQueueText(row)
806
+ // G2 authority Tier 1: the lens and the web show (and edit) the words, never the verify footer.
807
+ const plain = plainQueueText(row)
808
+ const text = plain === null ? null : stripG2TurnFooter(plain).text
697
809
  return { clientTurnId: `codex-native:${row.id}`, status: 'waiting', position, queuedAt: 0,
698
810
  attempts: 0, preview: text?.slice(0, 80) ?? 'Message with attachments · manage in Codex',
699
811
  ...(text !== null ? { prompt: text } : {}), editable: text !== null, owner: 'codex' }
@@ -711,17 +823,44 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
711
823
  const provider = String(req.params.provider), threadId = String(req.params.threadId), id = String(req.params.clientTurnId)
712
824
  const prompt = req.body?.prompt
713
825
  if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > DEFAULT_MAX_PROMPT_CHARS) return res.status(400).json({ error: 'invalid_prompt' })
826
+ // G2 authority Tier 1 (QA 6.99.29): an edit is typed words like any other.
827
+ if (deps.provenance && hasFooterShapedLine(prompt)) {
828
+ return res.status(400).json({ error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
829
+ }
714
830
  if (provider === 'codex' && id.startsWith('codex-native:')) {
715
831
  try {
716
- await (deps.nativeQueue ?? nativeQueueControl).update(threadId, id.slice(13), prompt)
832
+ // G2 authority Tier 1: the edit replaces the text in the Codex app's own queue, so it is a
833
+ // delivery of its own, under an id minted here (the native row id is not ours).
834
+ let sent = prompt
835
+ if (deps.provenance) {
836
+ const verdict = deps.provenance.originOf(req)
837
+ const editId = `cnedit-${randomBytes(12).toString('hex')}`
838
+ sent = deps.provenance.footed(prompt, editId, verdict, provider)
839
+ deps.provenance.record({ turnId: editId, prompt, verdict, provider, sessionId: threadId, via: 'codex-native-edit' })
840
+ }
841
+ await (deps.nativeQueue ?? nativeQueueControl).update(threadId, id.slice(13), sent)
717
842
  return res.json({ updated: true })
718
843
  } catch { return res.status(409).json({ error: 'queue_edit_unconfirmed' }) }
719
844
  }
720
845
  const queue = readQueue(provider, threadId, deps.now())
721
846
  const row = queue.find(t => t.clientTurnId === id)
722
847
  if (!row || row.status !== 'waiting') return res.status(409).json({ error: 'already_delivering' })
848
+ // The words are now whoever edited them: their origin replaces the parked one. Only over a record
849
+ // that is still parked (QA 6.99.29): a row written into the queue file under a delivered turn's id
850
+ // cannot be edited over that turn's record.
851
+ const verdict = deps.provenance ? deps.provenance.originOf(req) : null
852
+ if (deps.provenance && verdict && deps.provenance.reusesTurnId(id, { prompt, verdict, provider, sessionId: threadId }, { continues: true })) {
853
+ return res.status(409).json({ error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
854
+ }
723
855
  row.prompt = prompt
724
856
  writeQueue(provider, threadId, queue)
857
+ if (deps.provenance && verdict) {
858
+ // Sealed to the row's files as they are now (an edit changes only the words).
859
+ const sealed = await sealFiles(row.attachmentIds, verdict, deps.turnAttachmentStore)
860
+ deps.provenance.noteQueued(provider, threadId, id, prompt, sealed.verdict, sealed.attachments)
861
+ // The queue's own row, re-judged: it continues the record parked under this id.
862
+ deps.provenance.record({ turnId: id, prompt, verdict: sealed.verdict, provider, sessionId: threadId, via: 'queue-edit', continues: true })
863
+ }
725
864
  return res.json({ updated: true })
726
865
  })
727
866
 
@@ -748,6 +887,7 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
748
887
  row.status = 'cancelled'
749
888
  row.settledAt = now
750
889
  writeQueue(provider, threadId, queue)
890
+ deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
751
891
  }
752
892
  return res.json({ cancelled: true, clientTurnId, status: row.status })
753
893
  })