@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.
- package/CHANGELOG.md +224 -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 +42 -9
- package/server/web-control/app.js +975 -114
- package/server/web-control/control.css +12 -6
- package/server/web-control/index.html +6 -1
- package/server/web-control/panes/cos-chat.css +27 -3
- package/server/web-control/panes/cos-chat.js +240 -46
- package/server/web-control/panes/meetings.css +5 -2
- package/server/web-control/panes/meetings.js +21 -10
- package/server/web-control/panes/memories.css +1 -1
- package/server/web-control/panes/memories.js +3 -8
- package/server/web-control/panes/questions.css +56 -2
- package/server/web-control/panes/questions.js +454 -56
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +20 -7
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +81 -3
- package/server/web-control/panes/today-actions.js +527 -62
- package/server/web-control/panes/work.css +1 -1
- 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)
|
|
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
|
+
}
|
|
@@ -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
|
|
194
|
-
|
|
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
|
-
(
|
|
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
|
|
423
|
-
|
|
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
|
-
/* ──
|
|
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
|
-
/*
|
|
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; }
|