@gotcos/glasses-server 6.99.25 → 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.
- package/CHANGELOG.md +166 -0
- package/package.json +1 -1
- package/server/index.ts +46 -4
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/web-control/app.css +2 -0
- package/server/web-control/app.js +738 -102
- package/server/web-control/index.html +5 -1
- package/server/web-control/panes/cos-chat.css +18 -0
- package/server/web-control/panes/cos-chat.js +239 -45
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +368 -51
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/today-actions.css +62 -4
- package/server/web-control/panes/today-actions.js +384 -53
|
@@ -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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -246,6 +246,8 @@ select.field {
|
|
|
246
246
|
@keyframes rec { 50% { opacity: 0.35; } }
|
|
247
247
|
|
|
248
248
|
.story { font: 400 24px/1.28 var(--display); letter-spacing: -0.005em; margin: 26px 2px; text-wrap: pretty; }
|
|
249
|
+
/* The Today summary's quiet age line (app.js briefWhen). */
|
|
250
|
+
.story-when { display: block; margin-top: 10px; font: 400 14px/1.35 var(--body); letter-spacing: 0; color: var(--sub); }
|
|
249
251
|
.story-skel { display: grid; gap: 12px; margin: 30px 0; }
|
|
250
252
|
.story-skel .skeleton { height: 22px; }
|
|
251
253
|
|