@gotcos/glasses-server 6.99.25 → 6.99.33

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 (53) hide show
  1. package/CHANGELOG.md +224 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +46 -4
  4. package/server/lib/agent-session-output-images.ts +280 -0
  5. package/server/lib/agent-session-store.ts +3 -1
  6. package/server/lib/agent-session-turns.ts +18 -7
  7. package/server/lib/client-liveness.ts +38 -3
  8. package/server/lib/codex-subagent-activity.ts +36 -0
  9. package/server/lib/g2-turn-provenance.ts +679 -0
  10. package/server/lib/media-store.ts +41 -2
  11. package/server/lib/permission-broker.ts +94 -17
  12. package/server/lib/queue-turn-evidence.ts +19 -2
  13. package/server/lib/session-halt.ts +15 -1
  14. package/server/lib/session-stream-events.ts +4 -0
  15. package/server/lib/session-subagents.ts +155 -0
  16. package/server/lib/session-turn-attachments.ts +245 -0
  17. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  18. package/server/lib/thread-turn-queue.ts +10 -0
  19. package/server/lib/today-brief.ts +731 -0
  20. package/server/lib/turn-file-store.ts +202 -0
  21. package/server/lib/turn-footer.ts +81 -0
  22. package/server/routes/agent-session-bindings.ts +164 -16
  23. package/server/routes/agent-sessions.ts +77 -8
  24. package/server/routes/client-instance.ts +12 -1
  25. package/server/routes/cursor-stop-followup.ts +30 -1
  26. package/server/routes/g2-turns.ts +46 -0
  27. package/server/routes/health.ts +9 -0
  28. package/server/routes/media.ts +32 -8
  29. package/server/routes/permission-broker.ts +47 -4
  30. package/server/routes/thread-turn-queue.ts +148 -8
  31. package/server/routes/today-brief.ts +148 -0
  32. package/server/web-control/app.css +42 -9
  33. package/server/web-control/app.js +975 -114
  34. package/server/web-control/control.css +12 -6
  35. package/server/web-control/index.html +6 -1
  36. package/server/web-control/panes/cos-chat.css +27 -3
  37. package/server/web-control/panes/cos-chat.js +240 -46
  38. package/server/web-control/panes/meetings.css +5 -2
  39. package/server/web-control/panes/meetings.js +21 -10
  40. package/server/web-control/panes/memories.css +1 -1
  41. package/server/web-control/panes/memories.js +3 -8
  42. package/server/web-control/panes/questions.css +56 -2
  43. package/server/web-control/panes/questions.js +454 -56
  44. package/server/web-control/panes/reader-media.js +154 -0
  45. package/server/web-control/panes/sessions.css +20 -7
  46. package/server/web-control/panes/settings.js +34 -2
  47. package/server/web-control/panes/speakers.css +3 -5
  48. package/server/web-control/panes/speakers.js +7 -4
  49. package/server/web-control/panes/threads.js +3 -2
  50. package/server/web-control/panes/today-actions.css +81 -3
  51. package/server/web-control/panes/today-actions.js +527 -62
  52. package/server/web-control/panes/work.css +1 -1
  53. package/server/web-control/shell.js +3 -0
@@ -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
  })
@@ -0,0 +1,148 @@
1
+ /**
2
+ * 6.99.26 candidate: the Today summary for COS Control Web (lib/today-brief.ts says how it stops: switch, caps, breaker).
3
+ *
4
+ * - GET /api/today/brief the cached summary; never generates.
5
+ * - POST /api/today/brief an open Today's ask, with the rows it read (titles, states, how long, last lines,
6
+ * what needs you). Generates only when the switch is on, the inputs changed, 10 minutes
7
+ * passed and the day's 60 are not spent. The Work cards in progress come from the
8
+ * server's own shared board read, never the body.
9
+ * - GET /api/today/brief/settings the switch and what set it.
10
+ * - POST /api/today/brief/settings { enabled: boolean }, the Settings toggle. COS_TODAY_BRIEF_LLM, when set, still wins.
11
+ * Auth is the parent /api gate (token or web session cookie). Every answer is private, no-store.
12
+ */
13
+ import { Router } from 'express'
14
+ import type { Response } from 'express'
15
+ import { dataPath } from '../lib/data-dir.js'
16
+ import { runClaudeText } from '../lib/claude-text-runner.js'
17
+ import { logTokenAudit } from '../lib/token-audit.js'
18
+ import type { TaskBoardRow } from '../lib/task-store.js'
19
+ import { agentSessionRoots, findAgentSessionFile } from '../lib/agent-session-store.js'
20
+ import { readClaudeSubagents, summarize, type SessionSubagents } from '../lib/session-subagents.js'
21
+ import { readCodexSubagentDetails } from '../lib/codex-subagent-activity.js'
22
+ import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
23
+ import { isRecordingLive } from '../lib/stt-runtime.js'
24
+ import {
25
+ TODAY_BRIEF_BODY_MAX_BYTES, TODAY_BRIEF_EFFORT, TODAY_BRIEF_SETTINGS_FILE, TODAY_BRIEF_STATE_FILE, TODAY_BRIEF_TIMEOUT_MS,
26
+ TodayBriefInputError, createTodayBrief, normalizeTodayBriefInputs, subagentLookups, todayBriefFingerprint, withSubagents, type BriefWork, type SubagentReading, type TodayBrief,
27
+ } from '../lib/today-brief.js'
28
+
29
+ export interface TodayBriefRouterDeps {
30
+ brief: TodayBrief
31
+ /** The Work board as the server already reads it (a shared, bounded read), or null when it cannot be read. */
32
+ board: () => Promise<TaskBoardRow[] | null>
33
+ /** Sub-agents for up to 8 sessions, by "provider|id". Missing keys mean "not read"; the page's counts stay. */
34
+ subagents?: (keys: string[]) => Promise<Map<string, SubagentReading>>
35
+ now?: () => number
36
+ }
37
+
38
+ /** How long the sub-agent reads may hold the ask before it goes on without them. */
39
+ export const TODAY_BRIEF_SUBAGENT_WAIT_MS = 2_500
40
+
41
+ /**
42
+ * The server's own sub-agent reads: Claude from the session's transcript tail (lib/session-subagents.ts), Codex from
43
+ * the spawn-edge snapshot (readCodexSubagentDetails, server home only). Cursor has none. Sequential, bounded, read-only.
44
+ */
45
+ export async function readSubagentsFor(keys: string[], now = Date.now()): Promise<Map<string, SubagentReading>> {
46
+ const out = new Map<string, SubagentReading>()
47
+ const roots = agentSessionRoots()
48
+ const codex = keys.filter(k => k.startsWith('codex|')).map(k => k.slice(6))
49
+ for (const key of keys.filter(k => k.startsWith('claude|'))) {
50
+ const file = await findAgentSessionFile('claude', key.slice(7), roots).catch(() => null)
51
+ const read: SessionSubagents | null = file ? await readClaudeSubagents(file) : null
52
+ if (read && read.total) out.set(key, read)
53
+ }
54
+ if (codex.length) {
55
+ const details = await readCodexSubagentDetails(codex, roots.codexSessions, now)
56
+ for (const [id, list] of details) {
57
+ const rows = list.map(d => ({ name: d.name, task: d.task, status: d.state === 'active' ? 'running' as const : d.state === 'completed' ? 'done' as const : 'unknown' as const, at: d.at }))
58
+ if (rows.length) out.set('codex|' + id, summarize(rows, false))
59
+ }
60
+ }
61
+ return out
62
+ }
63
+
64
+ /** The server's own brief: the data folder, Haiku through the isolated text runner, one audit row per start. */
65
+ export function defaultTodayBrief(): TodayBrief {
66
+ return createTodayBrief({
67
+ stateFile: dataPath(TODAY_BRIEF_STATE_FILE),
68
+ settingsFile: dataPath(TODAY_BRIEF_SETTINGS_FILE),
69
+ generate: (prompt, systemPrompt, model) => runClaudeText(prompt, {
70
+ model, effort: TODAY_BRIEF_EFFORT, timeoutMs: TODAY_BRIEF_TIMEOUT_MS, systemPrompt, label: 'Today brief',
71
+ }),
72
+ audit: row => logTokenAudit({
73
+ source: 'web-today-brief', model: row.model, inputChars: row.inputChars, outputChars: row.outputChars,
74
+ durationMs: row.durationMs, caller: row.outcome === 'ok' ? 'today_brief' : `today_brief_${row.guard ?? row.error ?? row.outcome}`,
75
+ }),
76
+ // No call while a meeting records (it keeps the Mac's CPU and the CLI for the meeting) or while the server drains.
77
+ blocked: () => (!maintenanceAdmissionsOpen() ? 'maintenance' : isRecordingLive() ? 'recording' : null),
78
+ // The call holds api_mutation, as the generic mutation lease would have; this route is lifecycle-owned in index.ts.
79
+ lease: () => acquireMaintenanceWork('api_mutation'),
80
+ })
81
+ }
82
+
83
+ const IN_PROGRESS = new Set(['draft', 'built', 'qa'])
84
+ /** Work cards in progress, by title and stage: open, not delegated, drafting to QA, or with an agent on it. */
85
+ export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
86
+ if (!rows) return []
87
+ const out: BriefWork[] = []
88
+ for (const row of rows) {
89
+ if (row.checked || row.delegated) continue
90
+ const stage = row.workStage ?? (row.stage === 'active' ? 'draft' : row.stage === 'review' ? 'qa' : 'planned')
91
+ const running = row.agentState === 'running'
92
+ if (!IN_PROGRESS.has(stage) && !running) continue
93
+ out.push({ title: row.title || row.text || '', stage, running })
94
+ }
95
+ return out.sort((a, b) => Number(b.running) - Number(a.running)).slice(0, 12)
96
+ }
97
+
98
+ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
99
+ const router = Router()
100
+ router.use('/today/brief', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
101
+ const failed = (res: Response) => res.status(503).json({ error: { code: 'today_brief_unavailable', message: 'The Today summary is unavailable right now.' } })
102
+
103
+ router.get('/today/brief', (_req, res) => {
104
+ try { res.json(deps.brief.peek()) } catch { failed(res) }
105
+ })
106
+ router.post('/today/brief', async (req, res) => {
107
+ try {
108
+ if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > TODAY_BRIEF_BODY_MAX_BYTES) {
109
+ return res.status(413).json({ error: { code: 'today_brief_too_large', message: 'Too much for the Today summary.' } })
110
+ }
111
+ // The switch first: off reads nothing, not even the board.
112
+ if (!deps.brief.settings().enabled) {
113
+ normalizeTodayBriefInputs(req.body)
114
+ return res.json(deps.brief.peek())
115
+ }
116
+ // Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval): a gated ask
117
+ // answers from the page's rows alone and reads neither the board nor any transcript.
118
+ const pageInputs = normalizeTodayBriefInputs(req.body)
119
+ const closed = deps.brief.gate()
120
+ if (closed) return res.json(deps.brief.askGated(pageInputs, closed))
121
+ const work = workInProgress(await deps.board().catch(() => null))
122
+ let inputs = normalizeTodayBriefInputs(req.body, work)
123
+ const keys = subagentLookups(inputs)
124
+ if (keys.length && deps.subagents) {
125
+ let timer: ReturnType<typeof setTimeout> | undefined
126
+ const late = new Promise<Map<string, SubagentReading>>(resolve => { timer = setTimeout(() => resolve(new Map()), TODAY_BRIEF_SUBAGENT_WAIT_MS) })
127
+ const readings = await Promise.race([deps.subagents(keys).catch(() => new Map<string, SubagentReading>()), late]).finally(() => clearTimeout(timer))
128
+ inputs = withSubagents(inputs, readings, (deps.now ?? Date.now)())
129
+ }
130
+ res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs)))
131
+ } catch (error) {
132
+ if (error instanceof TodayBriefInputError) return res.status(400).json({ error: { code: error.code, message: 'That is not a Today summary request.' } })
133
+ console.warn(`[today-brief] ask failed (${error instanceof Error ? error.name : 'error'})`)
134
+ failed(res)
135
+ }
136
+ })
137
+ router.get('/today/brief/settings', (_req, res) => {
138
+ try { res.json(deps.brief.settings()) } catch { failed(res) }
139
+ })
140
+ router.post('/today/brief/settings', (req, res) => {
141
+ const body = req.body
142
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'enabled') || typeof body.enabled !== 'boolean') {
143
+ return res.status(400).json({ error: { code: 'invalid_today_brief_settings', message: 'Send { enabled: true } or { enabled: false }.' } })
144
+ }
145
+ try { res.json(deps.brief.setEnabled(body.enabled)) } catch { failed(res) }
146
+ })
147
+ return router
148
+ }
@@ -190,16 +190,17 @@ select.field {
190
190
  .pane-title { font: 500 40px/1.02 var(--display); letter-spacing: -0.015em; margin: 0; }
191
191
  .pane-sub { color: var(--sub); margin: 6px 0 0; font-size: 15px; }
192
192
 
193
- /* No bottom bar, on purpose. On the iPhone Duo, Safari owns the bottom edge on both screens
194
- (a floating address pill on the closed screen), and iOS 26 WebKit stops drawing the page under
193
+ /* No bottom bar, on purpose. On a phone the browser owns the bottom edge (Safari's floating
194
+ address pill, Comet's and Chrome's own bars), and iOS 26 WebKit stops drawing the page under
195
195
  its bar while any fixed element sits within ~60px of it. Navigation lives in the top bar
196
- (closed screen) or the left rail (open screen and up). */
196
+ (folded screen) or the left rail (unfolded screen and up). */
197
197
  .badge-dot { position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--hot); }
198
198
 
199
199
  .rail { display: none; }
200
200
 
201
201
  /* ── Today ───────────────────────────────────────────────── */
202
202
  .today-date { color: var(--sub); font-size: 15px; margin: 6px 0 0; }
203
+ .today-weather { color: var(--sub); font-size: 15px; margin: 2px 0 0; }
203
204
  /* The session pet on Today: the pet COS Control shows, in the pose the Mac would pick. */
204
205
  .today-pet { display: flex; flex-direction: column; align-items: center; margin: 6px 0 14px; min-height: 166px; }
205
206
  .today-pet.hidden { display: none; }
@@ -246,6 +247,8 @@ select.field {
246
247
  @keyframes rec { 50% { opacity: 0.35; } }
247
248
 
248
249
  .story { font: 400 24px/1.28 var(--display); letter-spacing: -0.005em; margin: 26px 2px; text-wrap: pretty; }
250
+ /* The Today summary's quiet age line (app.js briefWhen). */
251
+ .story-when { display: block; margin-top: 10px; font: 400 14px/1.35 var(--body); letter-spacing: 0; color: var(--sub); }
249
252
  .story-skel { display: grid; gap: 12px; margin: 30px 0; }
250
253
  .story-skel .skeleton { height: 22px; }
251
254
 
@@ -419,11 +422,13 @@ select.field {
419
422
  .pane:not(#pane-today) .pane-head .pane-sub { margin-top: 0; }
420
423
  }
421
424
 
422
- /* ── Short screens: the closed Duo is ~678px tall with ~76px under Safari's pill, the open
423
- Duo ~626px. Height is the scarce axis, so the type steps down here, not by width. */
425
+ /* ── Short screens: the folded Duo is about 382x678 with the browser's own bar under that, and
426
+ landscape tablets and laptop windows are often under 720px tall. Height is the scarce axis, so
427
+ the type steps down here, not by width. */
424
428
  @media (max-height: 720px) {
425
429
  .pane-title { font-size: 34px; }
426
430
  .today-date { font-size: 14px; margin-top: 4px; }
431
+ .today-weather { font-size: 14px; }
427
432
  .ask-bar { min-height: 46px; margin-top: 12px; }
428
433
  .story { font-size: 21px; line-height: 1.3; margin: 18px 2px; }
429
434
  .run-panel { padding-top: 8px; }
@@ -434,8 +439,8 @@ select.field {
434
439
  .rec-banner { margin-top: 10px; padding: 8px 8px 8px 14px; }
435
440
  }
436
441
 
437
- /* ── Open Duo (~890x626), tablets, desktop: a labelled left rail costs width, which is the
438
- axis there is plenty of. */
442
+ /* ── Unfolded Duo (about 1430x1000 landscape, 1000x1430 portrait), tablets, desktop: a labelled
443
+ left rail costs width, which is the axis there is plenty of. */
439
444
  @media (min-width: 600px) {
440
445
  :root { --gutter: 24px; }
441
446
  .topbar { display: none; }
@@ -466,11 +471,11 @@ select.field {
466
471
  .run-title, .run-meta { grid-column: 2; }
467
472
  .composer { position: sticky; bottom: calc(var(--safe-bottom) + 12px); }
468
473
  .toast { top: auto; bottom: 24px; }
469
- .sheet-backdrop { padding-top: 8vh; }
474
+ .sheet-backdrop { padding-top: 8vh; padding-top: 8dvh; }
470
475
  .sheet { max-width: 620px; border-radius: 18px; padding: 16px 18px 18px; max-height: 80dvh; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25); }
471
476
  }
472
477
 
473
- /* Open Duo short screen: keep the rail's nine sections inside ~626px. */
478
+ /* Wide but short (a landscape tablet, a short window): keep the rail's nine sections inside 720px. */
474
479
  @media (min-width: 600px) and (max-height: 720px) {
475
480
  .pane:not(#pane-today) .pane-head .pane-title { display: none; }
476
481
  .pane:not(#pane-today) .pane-head { margin: 0 0 12px; }
@@ -536,3 +541,31 @@ select.field {
536
541
  .live-prose { margin-top: 8px; font-size: 15px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 14em; overflow: auto; color: var(--run-title); }
537
542
  #session-status .link-btn { min-height: 0; padding: 0; }
538
543
  .turn.user.pending { opacity: 0.72; }
544
+
545
+ /* ── Touch screens (6.99.30) ────────────────────────────── */
546
+ /* Every field that types is 16 px on a touch screen, at every width: under 16 px a phone browser zooms the page in on
547
+ focus (the unfolded Duo did it on Sessions' selects, Search, Find and the model picker, 600 px and up). A floor, not
548
+ a size: no field anywhere is set above 16 px. !important because each pane sets its own compact size with more
549
+ specific selectors; this one rule beats them all only on touch. */
550
+ @media (any-pointer: coarse) {
551
+ input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='color']):not([type='file']):not([type='button']):not([type='submit']):not([type='reset']):not([type='image']):not([type='hidden']),
552
+ select, textarea { font-size: 16px !important; }
553
+ }
554
+ /* Folded phones: every button and field is at least 44 px tall (design QA: .cx-btn came in six heights from 30 px). A
555
+ minimum only, so it grows the compact ones and leaves the taller ones; the per-pane phone heights that fought it are
556
+ gone. */
557
+ @media (max-width: 599px) {
558
+ .cx-btn, .field { min-height: 44px !important; }
559
+ }
560
+ /* Touch screens: the small controls the canary measured under 44 px (nav 30, switches 30, a chip's remove and Retry 36,
561
+ camera and clip 38, Send 38, the question strip's steps 36, the calendar's days 34) reach 44. Floors only (min-*),
562
+ !important because each pane sizes its own with more specific rules. A text switch keeps its look and grows only its
563
+ hit area, so its gold rule stays under the word. */
564
+ @media (any-pointer: coarse) {
565
+ .nav-btn, .btn.small, .cc-chip-retry, .sq-step, .mt-day { min-height: 44px !important; }
566
+ .cc-chip-x, .cc-attach, .cc-camera { min-width: 44px !important; min-height: 44px !important; }
567
+ .cx-switch button::before { content: ''; position: absolute; inset: -7px -4px; }
568
+ }
569
+ /* 6.99.30 (QA): an element with the hidden attribute is hidden, whatever display its classes give it ("Retry image"
570
+ showed under loaded images: .link-btn's inline-flex beat [hidden]). */
571
+ [hidden] { display: none !important; }