@gotcos/glasses-server 7.0.7 → 7.0.27
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/.env.example +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// Session endpoints — recent list, full history, existence check, client-format messages, context breaks, end
|
|
2
2
|
import { Router } from 'express'
|
|
3
|
-
import { readFileSync } from 'fs'
|
|
4
|
-
import { join } from 'path'
|
|
5
3
|
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions, resolveExchangePairModel, ensureArchiveMirrorForDay } from '../lib/conversation.js'
|
|
6
|
-
import { buildSessionLogEntry,
|
|
4
|
+
import { buildSessionLogEntry, lookupRangesFromHistory, readSessionLogRanges, writeSessionLogIfChanged } from '../lib/session-log.js'
|
|
7
5
|
import { getArchiveDayMessages, listArchiveDateStrings } from '../lib/archive.js'
|
|
8
6
|
import { recentMessagesLimit, selectRecentMessages } from '../lib/recent-messages.js'
|
|
9
7
|
import { localDay } from '../lib/local-day.js'
|
|
@@ -135,7 +133,7 @@ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
|
|
|
135
133
|
// instead of waiting for the 2hr TTL expiry.
|
|
136
134
|
// POST /api/sessions/lookup — batch resolve timestamps to session IDs
|
|
137
135
|
// Used to retroactively stamp messages that predate the sessionId feature
|
|
138
|
-
sessionsRouter.post('/sessions/lookup', (req, res) => {
|
|
136
|
+
sessionsRouter.post('/sessions/lookup', async (req, res) => {
|
|
139
137
|
const { timestamps } = req.body as { timestamps: number[] }
|
|
140
138
|
if (!timestamps || !Array.isArray(timestamps)) {
|
|
141
139
|
res.status(400).json({ error: 'timestamps[] required' })
|
|
@@ -143,7 +141,6 @@ sessionsRouter.post('/sessions/lookup', (req, res) => {
|
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
// Build time ranges from BOTH JSONL history AND live server sessions
|
|
146
|
-
const logPath = join(process.env.COS_SCRIPTS_DIR || '', '.glasses_sessions.jsonl')
|
|
147
144
|
const sessionRanges: Array<{ sid: string; start: number; end: number }> = []
|
|
148
145
|
|
|
149
146
|
// 1. Live server sessions (always current — no snapshot lag)
|
|
@@ -155,25 +152,17 @@ sessionsRouter.post('/sessions/lookup', (req, res) => {
|
|
|
155
152
|
}
|
|
156
153
|
}
|
|
157
154
|
|
|
158
|
-
// 2. JSONL history (ended sessions + snapshots)
|
|
155
|
+
// 2. JSONL history (ended sessions + snapshots). S1 (2026-11-09): streamed and incremental
|
|
156
|
+
// (session-log.ts readSessionLogRanges); the old readFileSync().toString() threw on the 1.94 GB
|
|
157
|
+
// ledger and the catch read that as "no file". The range rule is the old one, unchanged: a
|
|
158
|
+
// session's end is widened by later lines only when it is NOT in memory (lookupRangesFromHistory).
|
|
159
159
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// Live sessions take priority. For JSONL, keep the widest (latest) time range per session.
|
|
167
|
-
const existing = sessionRanges.find(s => s.sid === d.session_id)
|
|
168
|
-
if (!existing) {
|
|
169
|
-
sessionRanges.push({ sid: d.session_id, start, end })
|
|
170
|
-
} else if (end > existing.end && !getSessionRaw(d.session_id)) {
|
|
171
|
-
// Widen the JSONL range (but don't overwrite live sessions which extend to NOW)
|
|
172
|
-
existing.end = end
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
} catch { /* no log file */ }
|
|
160
|
+
const live = new Set(sessionRanges.map(s => s.sid))
|
|
161
|
+
const history = await readSessionLogRanges()
|
|
162
|
+
sessionRanges.push(...lookupRangesFromHistory(history, sid => Boolean(getSessionRaw(sid)), live))
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.warn('[sessions] lookup could not read the session ledger:', err instanceof Error ? err.message : err)
|
|
165
|
+
}
|
|
177
166
|
|
|
178
167
|
// Match each timestamp to a session (live sessions checked first, then JSONL)
|
|
179
168
|
const results: Record<number, string | null> = {}
|
|
@@ -242,7 +231,9 @@ sessionsRouter.post('/sessions/:id/snapshot', (req, res) => {
|
|
|
242
231
|
slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
|
|
243
232
|
})
|
|
244
233
|
|
|
245
|
-
|
|
234
|
+
// Logged means the ledger holds this content: written now, or already there.
|
|
235
|
+
const result = writeSessionLogIfChanged(entry, 'snapshot')
|
|
236
|
+
const logged = result === 'written' || result === 'unchanged'
|
|
246
237
|
res.json({
|
|
247
238
|
ok: true,
|
|
248
239
|
logged,
|
|
@@ -21,7 +21,7 @@ import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-
|
|
|
21
21
|
import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
|
|
22
22
|
import { CLIENT_TURN_ID_RE, COS_SESSION_ID_RE, DEFAULT_MAX_PROMPT_CHARS } from './agent-session-bindings.js'
|
|
23
23
|
import {
|
|
24
|
-
admitToQueue, drainDecision, queueableRefusal, queuePosition,
|
|
24
|
+
admitToQueue, drainDecision, holdReasonFor, queueableRefusal, queuePosition,
|
|
25
25
|
MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
|
|
26
26
|
queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
|
|
27
27
|
steerMidturnEnabled, type SessionSteerAvailability,
|
|
@@ -38,11 +38,11 @@ import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds
|
|
|
38
38
|
* the attachment digest its origin is sealed to. Files whose content cannot be read leave the turn
|
|
39
39
|
* unvouched (it still parks); no files is the empty digest and reads nothing.
|
|
40
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: '' }
|
|
41
|
+
async function sealFiles(ids: readonly string[] | undefined, verdict: OriginVerdict, store: TurnAttachmentStore | undefined): Promise<{ verdict: OriginVerdict; attachments: string; manifest: ReadonlyArray<{ id: string; sha256: string }> | null }> {
|
|
42
|
+
if (!Array.isArray(ids) || ids.length === 0) return { verdict, attachments: '', manifest: [] }
|
|
43
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) }
|
|
44
|
+
if (!manifest.ok) return { verdict: unvouched(verdict, 'attachments_unsealable'), attachments: '', manifest: null }
|
|
45
|
+
return { verdict, attachments: attachmentsDigest(manifest.manifest), manifest: manifest.manifest }
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export interface ThreadTurnQueueDeps {
|
|
@@ -153,6 +153,14 @@ export function steerAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe
|
|
|
153
153
|
return sessionSteerAvailability({ provider, gateReason, continueLive, peerInbox: probe.peerInbox, waitingOnPrompt: probe.waitingOnPrompt })
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* When a sweep last decided each held row (epoch ms), in memory only: the row file is written when its hold
|
|
158
|
+
* reason CHANGES, not every 20 s. Bounded; a settled row is forgotten.
|
|
159
|
+
*/
|
|
160
|
+
const holdCheckedAt = new Map<string, number>()
|
|
161
|
+
const HOLD_CHECKED_MAX = 1_000
|
|
162
|
+
export function __resetHoldCheckedForTests(): void { holdCheckedAt.clear() }
|
|
163
|
+
|
|
156
164
|
/** Public shape of a queued turn. No prompt echo beyond a short preview. */
|
|
157
165
|
export function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unknown> {
|
|
158
166
|
const row: Record<string, unknown> = {
|
|
@@ -169,6 +177,12 @@ export function publicRow(turn: QueuedThreadTurn, position: number): Record<stri
|
|
|
169
177
|
...(turn.settledAt ? { settledAt: turn.settledAt } : {}),
|
|
170
178
|
// 11/07: how many files wait with the words (never their ids or paths). Absent without files.
|
|
171
179
|
...(Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0 ? { attachments: turn.attachmentIds.length } : {}),
|
|
180
|
+
// 2026-11-11: why a waiting row is held (a code), since when, and when a sweep last decided it. Additive.
|
|
181
|
+
...(turn.status === 'waiting' && typeof turn.holdReason === 'string' ? {
|
|
182
|
+
waiting_reason: turn.holdReason,
|
|
183
|
+
...(typeof turn.holdSince === 'number' ? { waiting_since: turn.holdSince } : {}),
|
|
184
|
+
...(holdCheckedAt.has(turn.clientTurnId) ? { waiting_checked_at: holdCheckedAt.get(turn.clientTurnId) } : {}),
|
|
185
|
+
} : {}),
|
|
172
186
|
}
|
|
173
187
|
// 6.78.0: additive, and only while a P3 flag is on, so the row is the 6.77 bytes otherwise.
|
|
174
188
|
// `resendable` is the per-row capability: present only when Send again can act on it.
|
|
@@ -242,7 +256,7 @@ function localDrainVerdict(at: number): OriginVerdict {
|
|
|
242
256
|
}
|
|
243
257
|
|
|
244
258
|
/** What one mid-turn attempt did to its row: delivered, settled some other way, or left waiting. */
|
|
245
|
-
type MidTurnOutcome = 'delivered' | 'settled' | 'held'
|
|
259
|
+
type MidTurnOutcome = 'delivered' | 'settled' | 'held' | 'held_files'
|
|
246
260
|
|
|
247
261
|
/**
|
|
248
262
|
* Steer mid-turn (COS_STEER_MIDTURN, Miles 2026-11-07): put ONE waiting row into the running turn
|
|
@@ -279,9 +293,11 @@ async function steerHeldTurn(
|
|
|
279
293
|
deps: ThreadTurnQueueDeps,
|
|
280
294
|
): Promise<MidTurnOutcome> {
|
|
281
295
|
if (turn.status !== 'waiting' || turn.attempts !== 0) return 'held'
|
|
282
|
-
if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held'
|
|
283
296
|
const availability = midTurnAvailabilityFor(deps, provider, threadId, gateReason)
|
|
284
297
|
if (!availability || !availability.available) return 'held'
|
|
298
|
+
// Files never go into a running turn (the peer inbox carries words only). Checked AFTER availability, so
|
|
299
|
+
// `held_files` means exactly this: the turn could have gone in now but for its files (the row's hold reason).
|
|
300
|
+
if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held_files'
|
|
285
301
|
const { clientTurnId, prompt } = turn
|
|
286
302
|
|
|
287
303
|
const rewrite = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn | null => {
|
|
@@ -465,11 +481,19 @@ export async function drainThread(
|
|
|
465
481
|
// which also means the hold is the busy GATE: the rule answers only for a refusal that
|
|
466
482
|
// names a desk run (an attachable thread has no reason). Anything else holds as before.
|
|
467
483
|
const cancelHeld = typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && deps.now() < seen.cancelHoldUntil
|
|
484
|
+
let reason = holdReasonFor(seen, deps.now())
|
|
468
485
|
if (!midTurnOrderHeld && !cancelHeld) {
|
|
469
486
|
const outcome = await steerHeldTurn(provider, threadId, turn, gate.reason, deps)
|
|
470
|
-
if (outcome === 'delivered') { delivered += 1; continue }
|
|
471
|
-
if (outcome === 'settled') { retired += 1; continue }
|
|
487
|
+
if (outcome === 'delivered') { holdCheckedAt.delete(turn.clientTurnId); delivered += 1; continue }
|
|
488
|
+
if (outcome === 'settled') { holdCheckedAt.delete(turn.clientTurnId); retired += 1; continue }
|
|
489
|
+
if (outcome === 'held_files') reason = 'files_wait_for_turn_end'
|
|
472
490
|
}
|
|
491
|
+
// The hold reason: written only when it changes; the check time is kept in memory (2026-11-11).
|
|
492
|
+
const at = deps.now()
|
|
493
|
+
if (turn.holdReason !== reason) { turn.holdReason = reason; turn.holdSince = at; persist(turn) }
|
|
494
|
+
holdCheckedAt.delete(turn.clientTurnId)
|
|
495
|
+
holdCheckedAt.set(turn.clientTurnId, at)
|
|
496
|
+
while (holdCheckedAt.size > HOLD_CHECKED_MAX) holdCheckedAt.delete(holdCheckedAt.keys().next().value!)
|
|
473
497
|
midTurnOrderHeld = true
|
|
474
498
|
held += 1
|
|
475
499
|
continue
|
|
@@ -480,6 +504,7 @@ export async function drainThread(
|
|
|
480
504
|
turn.settledAt = deps.now()
|
|
481
505
|
retired += 1; persist(turn)
|
|
482
506
|
forgetIfSettled(turn)
|
|
507
|
+
holdCheckedAt.delete(turn.clientTurnId)
|
|
483
508
|
continue
|
|
484
509
|
}
|
|
485
510
|
|
|
@@ -488,6 +513,10 @@ export async function drainThread(
|
|
|
488
513
|
// forever; the ceiling only bounds anything if it survives the crash it is bounding.
|
|
489
514
|
turn.attempts += 1
|
|
490
515
|
turn.status = 'delivering'
|
|
516
|
+
// No longer held: the reason belonged to the wait that just ended.
|
|
517
|
+
delete turn.holdReason
|
|
518
|
+
delete turn.holdSince
|
|
519
|
+
holdCheckedAt.delete(turn.clientTurnId)
|
|
491
520
|
// 6.78.0: the stamp the restart reconcile reads. `journal: 1` says this build keeps an
|
|
492
521
|
// attempt record for the delivery that follows; `deliveringBootId` says which boot owns
|
|
493
522
|
// it, so a sweep never touches a row this process is delivering right now. Durable:
|
|
@@ -879,7 +908,9 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
879
908
|
// with its content hash), so the drain can carry it (QUEUED_ORIGIN_TTL_MS at most).
|
|
880
909
|
if (deps.provenance && verdict) {
|
|
881
910
|
const sealed = await sealFiles(attach.ids, verdict, deps.turnAttachmentStore)
|
|
882
|
-
|
|
911
|
+
// 6.99.31: a signed web turn is judged here, once parking is decided, against this provider and
|
|
912
|
+
// thread (where it will drain), these exact words and these files. No envelope: unchanged.
|
|
913
|
+
verdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: clientTurnId, words: prompt, attachments: sealed.manifest })
|
|
883
914
|
deps.provenance.noteQueued(provider, threadId, clientTurnId, prompt, verdict, sealed.attachments)
|
|
884
915
|
}
|
|
885
916
|
|
|
@@ -990,8 +1021,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
990
1021
|
if (deps.provenance && resendVerdict) {
|
|
991
1022
|
// After the write: the read-modify-write above stays one synchronous step. Sealed to the copy's files.
|
|
992
1023
|
const sealed = await sealFiles(copy.attachmentIds, resendVerdict, deps.turnAttachmentStore)
|
|
993
|
-
|
|
994
|
-
|
|
1024
|
+
// 6.99.31: Send again from the phone needs a fresh signature over the copy (its new id, the words,
|
|
1025
|
+
// the files); without one the copy stays unvouched ('resend_not_reverified').
|
|
1026
|
+
const copyVerdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: newId, words: copy.prompt, attachments: sealed.manifest })
|
|
1027
|
+
deps.provenance.noteQueued(provider, threadId, newId, copy.prompt, copyVerdict, sealed.attachments)
|
|
1028
|
+
deps.provenance.record({ turnId: newId, prompt: copy.prompt, verdict: copyVerdict, provider, sessionId: threadId, via: 'queue-resend', resendOf: original.clientTurnId })
|
|
995
1029
|
}
|
|
996
1030
|
console.log(`[thread-turn-queue] resend provider=${provider} from=${original.clientTurnId} as=${newId} unknown=${original.deliveryState === 'unknown'}`)
|
|
997
1031
|
return res.status(200).json(publicRow(copy, queuePosition(admitted.queue, newId)))
|
|
@@ -1061,9 +1095,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
1061
1095
|
if (deps.provenance && verdict) {
|
|
1062
1096
|
// Sealed to the row's files as they are now (an edit changes only the words).
|
|
1063
1097
|
const sealed = await sealFiles(row.attachmentIds, verdict, deps.turnAttachmentStore)
|
|
1064
|
-
|
|
1098
|
+
// 6.99.31: an edit needs a fresh signature over the new words (same id, same files), or it is unvouched.
|
|
1099
|
+
const editVerdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: id, words: prompt, attachments: sealed.manifest })
|
|
1100
|
+
deps.provenance.noteQueued(provider, threadId, id, prompt, editVerdict, sealed.attachments)
|
|
1065
1101
|
// The queue's own row, re-judged: it continues the record parked under this id.
|
|
1066
|
-
deps.provenance.record({ turnId: id, prompt, verdict:
|
|
1102
|
+
deps.provenance.record({ turnId: id, prompt, verdict: editVerdict, provider, sessionId: threadId, via: 'queue-edit', continues: true })
|
|
1067
1103
|
}
|
|
1068
1104
|
return res.json({ updated: true })
|
|
1069
1105
|
})
|
|
@@ -25,49 +25,87 @@ import { dataPath } from '../lib/data-dir.js'
|
|
|
25
25
|
import { runClaudeText } from '../lib/claude-text-runner.js'
|
|
26
26
|
import { logTokenAudit } from '../lib/token-audit.js'
|
|
27
27
|
import type { TaskBoardRow } from '../lib/task-store.js'
|
|
28
|
-
import {
|
|
28
|
+
import type { TaskDateFields } from '../lib/task-dates.js'
|
|
29
|
+
import { agentSessionRoots, codexSessionRootsOf, findAgentSessionFile, listCodexJsonlFiles, loadClaudeDesktopAliases, loadClaudeTranscriptIndex, type AgentProvider } from '../lib/agent-session-store.js'
|
|
29
30
|
import { readClaudeSubagents, summarize, type SessionSubagents } from '../lib/session-subagents.js'
|
|
30
31
|
import { readCodexSubagentDetails } from '../lib/codex-subagent-activity.js'
|
|
32
|
+
import { readRecentActivity, type RecentActivity } from '../lib/agent-session-recent.js'
|
|
31
33
|
import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
32
34
|
import { isRecordingLive } from '../lib/stt-runtime.js'
|
|
33
35
|
import {
|
|
34
36
|
TODAY_BRIEF_BODY_MAX_BYTES, TODAY_BRIEF_EFFORT, TODAY_BRIEF_SETTINGS_FILE, TODAY_BRIEF_STATE_FILE, TODAY_BRIEF_TIMEOUT_MS,
|
|
35
|
-
TodayBriefInputError, createTodayBrief, normalizeTodayBriefInputs,
|
|
37
|
+
TodayBriefInputError, createTodayBrief, normalizeTodayBriefInputs, sessionContextLookups, todayBriefFingerprint, withCalendar, withRecentActivity, withSubagents, type BriefWork, type CalendarReading, type SubagentReading, type TodayBrief,
|
|
36
38
|
} from '../lib/today-brief.js'
|
|
37
39
|
|
|
38
40
|
export interface TodayBriefRouterDeps {
|
|
39
41
|
brief: TodayBrief
|
|
40
|
-
/** The Work board
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
/** The Work board, dated when it can be (2026-11-09: listBoardWithDates, so a card's last move is known), or null
|
|
43
|
+
* when it cannot be read. */
|
|
44
|
+
board: () => Promise<Array<TaskBoardRow & Partial<TaskDateFields>> | null>
|
|
45
|
+
/** Sub-agents and recent activity for up to 12 sessions, by "provider|id", written into `into` as each session is
|
|
46
|
+
* read, so an ask that stops waiting keeps what was read by then. Missing keys mean "not read": the page's counts
|
|
47
|
+
* and `line` stay, the line marked "may be old". */
|
|
48
|
+
context?: (keys: string[], into: SessionContext) => Promise<unknown>
|
|
49
|
+
/** Today round 2: today's calendar (lib/calendar-source.ts calendarToday). Read with the session reads, inside the
|
|
50
|
+
* same wait; a failed, slow or unconnected read means no calendar in the prompt. */
|
|
51
|
+
calendar?: () => Promise<CalendarReading | null>
|
|
44
52
|
now?: () => number
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
/** How long the
|
|
55
|
+
/** How long the session reads may hold the ask before it goes on with what was read by then. */
|
|
48
56
|
export const TODAY_BRIEF_SUBAGENT_WAIT_MS = 2_500
|
|
49
57
|
|
|
58
|
+
/** What the server read per session: its sub-agents and its recent activity, by "provider|id". */
|
|
59
|
+
export interface SessionContext { agents: Map<string, SubagentReading>; recent: Map<string, RecentActivity> }
|
|
60
|
+
export const emptySessionContext = (): SessionContext => ({ agents: new Map(), recent: new Map() })
|
|
61
|
+
|
|
50
62
|
/**
|
|
51
|
-
*
|
|
52
|
-
* the spawn-edge snapshot
|
|
63
|
+
* 2026-11-09: readSubagentsFor became this. For each key, most important first: the sub-agents (Claude from the
|
|
64
|
+
* transcript tail, Codex from the spawn-edge snapshot) and the recent activity (lib/agent-session-recent.ts). Claude
|
|
65
|
+
* and Cursor read one after another; Codex reads in parallel with them, because a Codex rollout's first scan is the
|
|
66
|
+
* slow one (32 GB files) and must not starve the rest. Results land in `into` as they arrive. Read-only.
|
|
53
67
|
*/
|
|
54
|
-
export async function
|
|
55
|
-
const out = new Map<string, SubagentReading>()
|
|
68
|
+
export async function readSessionContext(keys: string[], into: SessionContext = emptySessionContext(), now = Date.now()): Promise<SessionContext> {
|
|
56
69
|
const roots = agentSessionRoots()
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
70
|
+
const split = (k: string) => { const i = k.indexOf('|'); return { provider: k.slice(0, i) as AgentProvider, id: k.slice(i + 1) } }
|
|
71
|
+
const codexKeys = keys.filter(k => k.startsWith('codex|'))
|
|
72
|
+
const localKeys = keys.filter(k => k.startsWith('claude|') || k.startsWith('cursor|'))
|
|
73
|
+
const local = (async () => {
|
|
74
|
+
// One index for every Claude id in this ask, not one per call.
|
|
75
|
+
const claudeFiles = localKeys.some(k => k.startsWith('claude|')) ? await loadClaudeTranscriptIndex(roots).catch(() => undefined) : undefined
|
|
76
|
+
const aliases = claudeFiles ? await loadClaudeDesktopAliases(roots.claudeCodeSessions).catch(() => undefined) : undefined
|
|
77
|
+
for (const key of localKeys) {
|
|
78
|
+
const { provider, id } = split(key)
|
|
79
|
+
const file = await findAgentSessionFile(provider, id, roots, new Date(now), aliases, claudeFiles ? { claudeFiles } : undefined).catch(() => null)
|
|
80
|
+
if (!file) continue
|
|
81
|
+
if (provider === 'claude') {
|
|
82
|
+
const read: SessionSubagents | null = await readClaudeSubagents(file)
|
|
83
|
+
if (read && read.total) into.agents.set(key, read)
|
|
84
|
+
}
|
|
85
|
+
const recent = await readRecentActivity(provider, file, now)
|
|
86
|
+
if (recent) into.recent.set(key, recent)
|
|
87
|
+
}
|
|
88
|
+
})()
|
|
89
|
+
const codex = (async () => {
|
|
90
|
+
if (!codexKeys.length) return
|
|
91
|
+
const ids = codexKeys.map(k => k.slice(6))
|
|
92
|
+
const details = await readCodexSubagentDetails(ids, roots.codexSessions, now).catch(() => new Map())
|
|
65
93
|
for (const [id, list] of details) {
|
|
66
|
-
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 }))
|
|
67
|
-
|
|
94
|
+
const rows = (list as Array<{ name: string; task: string; state: string; at: number }>).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 }))
|
|
95
|
+
// Every row, not six: the brief keeps the live and recent ones and counts the rest as history.
|
|
96
|
+
if (rows.length) into.agents.set('codex|' + id, summarize(rows, false, rows.length))
|
|
68
97
|
}
|
|
69
|
-
|
|
70
|
-
|
|
98
|
+
const lists = await Promise.all(codexSessionRootsOf(roots).map(root => listCodexJsonlFiles(root).catch(() => [] as string[])))
|
|
99
|
+
const codexFiles = lists.flat()
|
|
100
|
+
for (const key of codexKeys) {
|
|
101
|
+
const file = await findAgentSessionFile('codex', key.slice(6), roots, new Date(now), undefined, { codexFiles }).catch(() => null)
|
|
102
|
+
if (!file) continue
|
|
103
|
+
const recent = await readRecentActivity('codex', file, now)
|
|
104
|
+
if (recent) into.recent.set(key, recent)
|
|
105
|
+
}
|
|
106
|
+
})()
|
|
107
|
+
await Promise.all([local, codex])
|
|
108
|
+
return into
|
|
71
109
|
}
|
|
72
110
|
|
|
73
111
|
/** The server's own brief: the data folder, Sonnet (7.0.1) through the isolated text runner, one audit row per start. */
|
|
@@ -90,8 +128,12 @@ export function defaultTodayBrief(): TodayBrief {
|
|
|
90
128
|
}
|
|
91
129
|
|
|
92
130
|
const IN_PROGRESS = new Set(['draft', 'built', 'qa'])
|
|
93
|
-
/**
|
|
94
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Work cards in progress, by title and stage: open, not delegated, drafting to QA, or with an agent on it. 2026-11-09:
|
|
133
|
+
* with when each last moved (the dated board read: the line's git change time, else the day it was created; null when
|
|
134
|
+
* neither is known), running first, then the most recently moved, so the twelve kept are the live ones.
|
|
135
|
+
*/
|
|
136
|
+
export function workInProgress(rows: Array<TaskBoardRow & Partial<TaskDateFields>> | null, now = Date.now()): BriefWork[] {
|
|
95
137
|
if (!rows) return []
|
|
96
138
|
const out: BriefWork[] = []
|
|
97
139
|
for (const row of rows) {
|
|
@@ -99,9 +141,11 @@ export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
|
|
|
99
141
|
const stage = row.workStage ?? (row.stage === 'active' ? 'draft' : row.stage === 'review' ? 'qa' : 'planned')
|
|
100
142
|
const running = row.agentState === 'running'
|
|
101
143
|
if (!IN_PROGRESS.has(stage) && !running) continue
|
|
102
|
-
|
|
144
|
+
const at = Date.parse(row.lineChangedAt ?? '') || Date.parse(row.createdOn ?? '') || 0
|
|
145
|
+
out.push({ title: row.title || row.text || '', stage, running, movedMinutes: at > 0 ? Math.max(0, Math.round((now - at) / 60000)) : null })
|
|
103
146
|
}
|
|
104
|
-
|
|
147
|
+
const age = (w: BriefWork) => (w.movedMinutes == null ? Number.MAX_SAFE_INTEGER : w.movedMinutes)
|
|
148
|
+
return out.sort((a, b) => Number(b.running) - Number(a.running) || age(a) - age(b)).slice(0, 12)
|
|
105
149
|
}
|
|
106
150
|
|
|
107
151
|
/**
|
|
@@ -142,15 +186,26 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
|
|
|
142
186
|
const pageInputs = normalizeTodayBriefInputs(body)
|
|
143
187
|
const closed = deps.brief.gate({ force, reconnect })
|
|
144
188
|
if (closed) return res.json(deps.brief.askGated(pageInputs, closed))
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
189
|
+
const now = (deps.now ?? Date.now)()
|
|
190
|
+
// The board and the session reads run together; the reads stop being waited for after 2.5 s and the ask goes on
|
|
191
|
+
// with whatever they wrote by then (a Codex first scan that runs on still fills its memo for the next ask).
|
|
192
|
+
const keys = sessionContextLookups(normalizeTodayBriefInputs(body))
|
|
193
|
+
const context = emptySessionContext()
|
|
194
|
+
// Today round 2: the calendar reads alongside the sessions, inside the same wait; whatever has not answered by
|
|
195
|
+
// then is left out (a slow bridge never holds the ask).
|
|
196
|
+
const cal: { value: CalendarReading | null } = { value: null }
|
|
197
|
+
const work: Array<Promise<unknown>> = []
|
|
198
|
+
if (keys.length && deps.context) work.push(Promise.resolve().then(() => deps.context!(keys, context)).catch(() => undefined))
|
|
199
|
+
if (deps.calendar) work.push(Promise.resolve().then(() => deps.calendar!()).then(c => { cal.value = c }, () => undefined))
|
|
200
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
201
|
+
const reads = work.length
|
|
202
|
+
? Promise.race([Promise.all(work), new Promise<void>(resolve => { timer = setTimeout(resolve, TODAY_BRIEF_SUBAGENT_WAIT_MS) })]).finally(() => clearTimeout(timer))
|
|
203
|
+
: Promise.resolve()
|
|
204
|
+
const [rows] = await Promise.all([deps.board().catch(() => null), reads])
|
|
205
|
+
// A snapshot: a read still running after the wait cannot change the inputs under the call.
|
|
206
|
+
const agents = new Map(context.agents), recent = new Map(context.recent), calendar = cal.value
|
|
207
|
+
let inputs = normalizeTodayBriefInputs(body, workInProgress(rows, now))
|
|
208
|
+
inputs = withCalendar(withRecentActivity(withSubagents(inputs, agents, now), recent, now), calendar, now)
|
|
154
209
|
res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs), { force, reconnect }))
|
|
155
210
|
} catch (error) {
|
|
156
211
|
if (error instanceof TodayBriefInputError) return res.status(400).json({ error: { code: error.code, message: 'That is not a Today summary request.' } })
|
|
@@ -24,6 +24,10 @@ import {
|
|
|
24
24
|
type WebLoginFailure,
|
|
25
25
|
WebGlassesCodeProbeBudget,
|
|
26
26
|
WEB_SESSION_TTL_MS,
|
|
27
|
+
WEB_SESSION_COOKIE_NAME,
|
|
28
|
+
WEB_SESSION_MAX_LIFE_MS,
|
|
29
|
+
readWebSessionCookie,
|
|
30
|
+
webSessionCookieFrom,
|
|
27
31
|
WebHandoffStore,
|
|
28
32
|
WebLoginLimiter,
|
|
29
33
|
WebDeviceCodeStore,
|
|
@@ -199,6 +203,18 @@ export function webControlCsp(_requestPath: string): string {
|
|
|
199
203
|
].join('; ')
|
|
200
204
|
}
|
|
201
205
|
|
|
206
|
+
/**
|
|
207
|
+
* The expiry (ISO) of the cos_web_session cookie on a request, read from the SAME value the verifier reads (Q1 S1:
|
|
208
|
+
* `parseCookies` keeps the last of several; another app on this host name can plant an earlier one). Read only after
|
|
209
|
+
* verification. Shapes: `<exp>.<epoch>.<sig>`, or a renewed `<exp>.<epoch>.<orig>.<sig>`.
|
|
210
|
+
*/
|
|
211
|
+
export function webSessionCookieExpiry(cookieHeader: unknown): string | null {
|
|
212
|
+
const value = webSessionCookieFrom(cookieHeader)
|
|
213
|
+
const exp = typeof value === 'string' ? /^(\d{1,15})\./.exec(value) : null
|
|
214
|
+
const ms = exp ? Number(exp[1]) * 1000 : NaN
|
|
215
|
+
return Number.isSafeInteger(ms) && ms > 0 ? new Date(ms).toISOString() : null
|
|
216
|
+
}
|
|
217
|
+
|
|
202
218
|
export function createWebControlRouter(deps: WebControlRouterDeps): Router {
|
|
203
219
|
const router = Router()
|
|
204
220
|
const handoffStore = deps.handoffStore ?? new WebHandoffStore()
|
|
@@ -287,11 +303,20 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
|
|
|
287
303
|
? { authenticated: false, mode: 'none' }
|
|
288
304
|
: { authenticated: false, mode: 'none', network: 'tailscale_only' })
|
|
289
305
|
}
|
|
306
|
+
// W1 (audit 02 F2): a signed-in browser learns when ITS OWN cookie ends, so the page can say so before it does.
|
|
307
|
+
// Only after the cookie verified (its signature covers the expiry), only for cookie mode, and nothing a caller without
|
|
308
|
+
// that cookie can read. The 7-day lifetime itself is unchanged.
|
|
309
|
+
const expiresAt = cookieOk && !headerOk ? webSessionCookieExpiry(req.headers.cookie) : null
|
|
310
|
+
// Renewal (Miles 2026-11-09): the latest this sign-in can last while in use, 30 days from its first sign-in.
|
|
311
|
+
const read = cookieOk && !headerOk ? readWebSessionCookie(webSessionCookieFrom(req.headers.cookie), token) : null
|
|
312
|
+
const renewUntil = read ? new Date((read.origUnixSec + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)) * 1000).toISOString() : null
|
|
290
313
|
res.json({
|
|
291
314
|
authenticated: true,
|
|
292
315
|
mode: headerOk ? 'header' : 'cookie',
|
|
293
316
|
serverName: hostname().replace(/\.local$/i, ''),
|
|
294
317
|
ownerName: getOwnerName(),
|
|
318
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
319
|
+
...(renewUntil ? { renewUntil } : {}),
|
|
295
320
|
})
|
|
296
321
|
})
|
|
297
322
|
|
|
@@ -322,10 +347,13 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
|
|
|
322
347
|
const token = deps.apiToken()
|
|
323
348
|
const result = classifyWebLogin(body, token, handoffStore, deviceCodes, glassesCheck)
|
|
324
349
|
if (!result.ok) {
|
|
325
|
-
// Every failure counts, the glasses-code one included, so that check cannot be used to
|
|
326
|
-
// probe codes faster than the limiter allows.
|
|
327
|
-
|
|
328
|
-
|
|
350
|
+
// Every failure counts toward the per-IP limit, the glasses-code one included, so that check cannot be used to
|
|
351
|
+
// probe codes faster than the limiter allows. Except a failed sign-in LINK (Miles 2026-11-09 20:00, "Yes, stop
|
|
352
|
+
// counting stale links"): a ticket is 192 random bits, so guessing one is not a risk, and a person reopening an old
|
|
353
|
+
// QR or link should not lock themselves out of typing a code. It still fails with the same message.
|
|
354
|
+
if (result.reason !== 'handoff_expired') loginLimiter.fail(ip, result.reason)
|
|
355
|
+
// S1 item 7: why a handoff link failed (expired, used, evicted, unknown). The log only; the message is unchanged.
|
|
356
|
+
log(`[web-login] fail kind=${result.reason}${result.handoff ? ` sub=${result.handoff}` : ''} len=${result.length} ip=${ip}`)
|
|
329
357
|
return res.status(401).json({
|
|
330
358
|
ok: false,
|
|
331
359
|
error: 'invalid_credentials',
|
|
@@ -340,6 +368,8 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
|
|
|
340
368
|
return res.json({
|
|
341
369
|
ok: true,
|
|
342
370
|
expiresAt: new Date(minted.expiresAtMs).toISOString(),
|
|
371
|
+
// Renewal (Miles 2026-11-09): while in use this sign-in renews up to 30 days from now (lib/web-session-auth.ts).
|
|
372
|
+
renewUntil: new Date((Math.floor(minted.expiresAtMs / 1000) - Math.floor(WEB_SESSION_TTL_MS / 1000) + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)) * 1000).toISOString(),
|
|
343
373
|
serverName: hostname().replace(/\.local$/i, ''),
|
|
344
374
|
ownerName: getOwnerName(),
|
|
345
375
|
})
|
|
@@ -357,6 +387,9 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
|
|
|
357
387
|
router.post('/web-session/revoke-all', (req: Request, res: Response) => {
|
|
358
388
|
res.set('Cache-Control', 'private, no-store')
|
|
359
389
|
const epoch = bumpWebSessionEpoch()
|
|
390
|
+
// Final review V7: a sign-in link or code made before the sign-out no longer signs a browser in after it.
|
|
391
|
+
handoffStore.revokeAll()
|
|
392
|
+
deviceCodes.revokeAll()
|
|
360
393
|
res.setHeader('Set-Cookie', formatClearWebSessionCookie(req.secure))
|
|
361
394
|
res.json({ ok: true, epoch })
|
|
362
395
|
})
|