@gotcos/glasses-server 7.0.7 → 7.0.18
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 +170 -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 +126 -23
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +3 -3
- 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/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/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- 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-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- 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/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +11 -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/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +16 -9
- 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 +36 -0
- package/server/web-control/app.js +953 -57
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +14 -1
- 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/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +75 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +178 -35
- 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
|
@@ -2754,8 +2754,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2754
2754
|
// 6.99.29 (Codex audit of e4758a6): the record names the SOURCE session, never the fork child
|
|
2755
2755
|
// that actually receives the words, and the child has no binding yet. Until a destination
|
|
2756
2756
|
// binding exists, a forked turn is recorded UNVERIFIED for the child: no footer, ever.
|
|
2757
|
-
|
|
2757
|
+
// 6.99.31: a signed web fork is judged too, so its record says why (always fork_destination_unbound).
|
|
2758
2758
|
const forkTurnId = typeof clientForkId === 'string' ? clientForkId : `fork-${randomBytes(12).toString('hex')}`
|
|
2759
|
+
const forkVerdict = deps.provenance.acceptWebKey(unvouched(deps.provenance.originOf(req), 'fork_destination_unbound'), { provider: providerParam, sessionId: null, turnId: forkTurnId, words: prompt, attachments: [], fork: true })
|
|
2759
2760
|
forkPrompt = deps.provenance.footed(prompt, forkTurnId, forkVerdict, providerParam)
|
|
2760
2761
|
deps.provenance.record({ turnId: forkTurnId, prompt, verdict: forkVerdict, provider: providerParam, sessionId: threadIdParam, via: 'fork', fork: true })
|
|
2761
2762
|
}
|
|
@@ -3139,6 +3140,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3139
3140
|
return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
|
|
3140
3141
|
}
|
|
3141
3142
|
}
|
|
3143
|
+
// 6.99.31: a web turn carrying an enrolled phone key's envelope is judged once the binding names
|
|
3144
|
+
// the real target (below). Its files are read here (the only await): the envelope's ordered
|
|
3145
|
+
// manifest must equal them. Null: unreadable, so the turn is not vouched.
|
|
3146
|
+
let webKeyFiles: ReadonlyArray<{ id: string; sha256: string }> | null = []
|
|
3147
|
+
if (turnVerdict?.webKeyCandidate && attachIds.ids.length > 0) {
|
|
3148
|
+
const m = await turnAttachmentManifest(attachIds.ids, deps.turnAttachmentStore)
|
|
3149
|
+
webKeyFiles = m.ok ? m.manifest : null
|
|
3150
|
+
}
|
|
3142
3151
|
/** Ledgered once the text did, or may have, reached a session. Hash of the text WITHOUT the footer. */
|
|
3143
3152
|
const ledgerTurn = (via: string, provider: string, sessionId: string): void => {
|
|
3144
3153
|
if (provenance && turnVerdict && clientTurnId !== null) provenance.record({ turnId: clientTurnId, prompt, verdict: turnVerdict, provider, sessionId, via: turnCarried ? `drain-${via}` : via, continues: turnCarried })
|
|
@@ -3214,6 +3223,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
3214
3223
|
console.warn(`[agent-session-bindings] queued turn seal mismatch (target) turnId=${turnId} clientTurnId=${clientTurnId}`)
|
|
3215
3224
|
return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
|
|
3216
3225
|
}
|
|
3226
|
+
// 6.99.31: the envelope against the binding's provider and native thread (the actual receiving
|
|
3227
|
+
// session), the exact words (before the files block) and the files. Synchronous: the nonce is
|
|
3228
|
+
// spent here, in the same tick as the reuse check below. No envelope: the verdict is unchanged.
|
|
3229
|
+
if (provenance && turnVerdict && turnVerdict.webKeyCandidate) {
|
|
3230
|
+
turnVerdict = provenance.acceptWebKey(turnVerdict, { provider: binding.provider, sessionId: binding.nativeThreadId, bindingId, turnId: clientTurnId, words, attachments: webKeyFiles })
|
|
3231
|
+
}
|
|
3217
3232
|
if (provenance && turnVerdict && provenance.reusesTurnId(clientTurnId, { prompt, verdict: turnVerdict, provider: binding.provider, sessionId: binding.nativeThreadId }, { continues: turnCarried })) {
|
|
3218
3233
|
return refuseTurn('invalid_request', { retryable: false, error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
|
|
3219
3234
|
}
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
// never be mistaken for a user's explicit opt-out. Off answers 503 and the client
|
|
60
60
|
// polls, which is the same code path as a server too old to have this route at all.
|
|
61
61
|
|
|
62
|
+
import { streamSignInHolds } from '../lib/api-auth.js'
|
|
62
63
|
import { redactLocalPaths } from '../lib/local-path-redact.js'
|
|
63
64
|
import { Router, type Response } from 'express'
|
|
64
65
|
import { stat } from 'node:fs/promises'
|
|
@@ -366,6 +367,12 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
|
|
|
366
367
|
teardown()
|
|
367
368
|
return
|
|
368
369
|
}
|
|
370
|
+
// Final review S1: a stream opened by a browser sign-in ends once that sign-in no longer verifies (signed out
|
|
371
|
+
// everywhere, expired, past the 30-day cap). A token stream never re-checks.
|
|
372
|
+
if (!streamSignInHolds(res)) {
|
|
373
|
+
teardown()
|
|
374
|
+
return
|
|
375
|
+
}
|
|
369
376
|
write({ kind: 'heartbeat', at: Date.now() })
|
|
370
377
|
}, HEARTBEAT_INTERVAL_MS)
|
|
371
378
|
if (typeof (heartbeat as any).unref === 'function') (heartbeat as any).unref()
|
package/server/routes/drops.ts
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* POST /drops/:id/move { dest, target?: { id, label, domain? } } DropAnswer
|
|
19
19
|
* POST /drops/:id/ask { clientJobId, generation } DropAnswer
|
|
20
20
|
* POST /drops/:id/sent DropAnswer
|
|
21
|
-
* GET /inbox/summary { unread, waiting, lastLine, lastAt, version }
|
|
21
|
+
* GET /inbox/summary { unread, waiting, asked, yourMove, lastLine, lastAt, version }
|
|
22
22
|
* GET /inbox/feed ?after&limit { items, version, more }
|
|
23
|
+
* GET /inbox/archive { items, total, waitingExpiryDays, version } (7.0.9)
|
|
23
24
|
* POST /inbox/read { through } { unread, waiting, version }
|
|
24
25
|
* POST /inbox/action { itemId, action: 'choose:<id>' | 'undo' } DropAnswer
|
|
25
26
|
* POST /inbox/action { itemId, action: 'dismiss' } { unread, waiting, version }
|
|
@@ -211,9 +212,13 @@ export function createDropsRouter(deps: DropsRouterDependencies): Router {
|
|
|
211
212
|
try { await service.syncAsks() } catch { /* the chat still reads */ }
|
|
212
213
|
try { return res.json(service.feed({ after, limit })) } catch (e) { return send(res, e) }
|
|
213
214
|
}))
|
|
215
|
+
// 7.0.9: the older questions (asked WAITING_EXPIRY_DAYS or more ago and still unanswered), newest first.
|
|
216
|
+
router.get('/inbox/archive', (_req, res) => withService(res, async service => {
|
|
217
|
+
try { return res.json(service.archive()) } catch (e) { return send(res, e) }
|
|
218
|
+
}))
|
|
214
219
|
router.post('/inbox/read', (req, res) => withService(res, async service => {
|
|
215
220
|
if (!isRecord(req.body) || typeof req.body.through !== 'string') return bad(res, 'invalid_read')
|
|
216
|
-
let out: { unread: number; waiting: number; version: number }
|
|
221
|
+
let out: { unread: number; waiting: number; asked: number; version: number }
|
|
217
222
|
try { out = service.markRead(req.body.through) } catch (e) { return send(res, e) }
|
|
218
223
|
// The same `waiting` the summary gives, Work's Your move included.
|
|
219
224
|
const yourMove = await boundedYourMove(deps.yourMove)
|
|
@@ -42,5 +42,40 @@ export function createG2TurnsRouter(deps: {
|
|
|
42
42
|
res.status(500).json({ found: false, error: 'lookup_failed' })
|
|
43
43
|
}
|
|
44
44
|
})
|
|
45
|
+
// 6.99.31 (plan §4.5): POST /api/g2-turns/:turnId/consume {session, provider}. One server-enforced
|
|
46
|
+
// winner per web-key authorization (keys turn:<turnId> and web:<aud>:<authId>, one write), so two receiving sessions
|
|
47
|
+
// cannot both act on one signed message. X-Cos-Token only. A token holder can pre-spend: that is
|
|
48
|
+
// denial of service, not forgery. Not a route while COS_WEB_KEY_AUTHORITY is off.
|
|
49
|
+
router.post('/g2-turns/:turnId/consume', async (req, res, next) => {
|
|
50
|
+
const authority = deps.provenance.webKey
|
|
51
|
+
if (!authority || !authority.enabled()) return next()
|
|
52
|
+
res.set('Cache-Control', 'private, no-store')
|
|
53
|
+
let expected = ''
|
|
54
|
+
try { expected = deps.apiToken ? deps.apiToken() : '' } catch { expected = '' }
|
|
55
|
+
if (!expected || !timingSafeTokenEqual(req.headers['x-cos-token'], expected)) return void res.status(401).json({ consumed: false, error: 'token_required' })
|
|
56
|
+
const turnId = String(req.params.turnId ?? '')
|
|
57
|
+
if (!G2_TURN_ID_RE.test(turnId)) return void res.status(400).json({ consumed: false, error: 'invalid_turn_id' })
|
|
58
|
+
const b = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>
|
|
59
|
+
try {
|
|
60
|
+
const answer = await describeG2Turn(deps.provenance, turnId, now())
|
|
61
|
+
if (!answer) return void res.status(404).json({ consumed: false, turnId, error: 'not_found' })
|
|
62
|
+
if (answer.authority !== 'web-key' || answer.verifiedOrigin !== true || typeof answer.authId !== 'string') {
|
|
63
|
+
return void res.status(409).json({ consumed: false, turnId, error: 'not_web_key_verified', reason: answer.reason ?? null })
|
|
64
|
+
}
|
|
65
|
+
const target = answer.target as { provider?: string; sessionId?: string } | undefined
|
|
66
|
+
if (typeof b.session !== 'string' || typeof b.provider !== 'string' || target?.sessionId !== b.session || target?.provider !== b.provider) {
|
|
67
|
+
return void res.status(409).json({ consumed: false, turnId, error: 'destination_mismatch' })
|
|
68
|
+
}
|
|
69
|
+
// Review B3: one namespace. The turn id and the web authorization are spent in ONE write.
|
|
70
|
+
const keys = Array.isArray(answer.consumeKeys) ? (answer.consumeKeys as unknown[]).filter((k): k is string => typeof k === 'string') : []
|
|
71
|
+
if (keys.length !== 2) return void res.status(409).json({ consumed: false, turnId, error: 'not_web_key_verified' })
|
|
72
|
+
const spent = authority.nonces.spend(keys, b.session, b.provider)
|
|
73
|
+
if (!spent.ok) return void res.status(spent.reason === 'already_consumed' ? 409 : 503).json({ consumed: false, turnId, error: spent.reason })
|
|
74
|
+
res.json({ consumed: true, turnId, authId: answer.authId, keys })
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(`[g2-turns] consume failed: ${error instanceof Error ? error.message : error}`)
|
|
77
|
+
res.status(500).json({ consumed: false, error: 'consume_failed' })
|
|
78
|
+
}
|
|
79
|
+
})
|
|
45
80
|
return router
|
|
46
81
|
}
|
package/server/routes/health.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { API_AUTH_BY_LOCAL } from '../lib/api-auth.js'
|
|
1
2
|
import { MAX_ATTACHMENTS_PER_PROMPT } from '../../shared/media-attachment.js'
|
|
2
3
|
import { calendarStatus } from '../lib/calendar-source.js'
|
|
3
4
|
import { TURN_FILE_MAX_BYTES } from '../lib/turn-file-store.js'
|
|
@@ -10,6 +11,7 @@ import { customInstructionsCapability, learnedStyleCapability, polishOptionsCapa
|
|
|
10
11
|
import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
|
|
11
12
|
import { taskBridgeAvailable } from '../lib/task-bridge.js'
|
|
12
13
|
import { Router } from 'express'
|
|
14
|
+
import { webKeyAuthorityEnabled } from '../lib/web-key-authority.js'
|
|
13
15
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
14
16
|
import { claudeSessionsEnabled } from './claude-sessions.js'
|
|
15
17
|
import { statSync } from 'node:fs'
|
|
@@ -460,6 +462,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
460
462
|
// 6.99.27: a session turn also takes ANY file up to this size (lib/turn-file-store.ts), sent to /api/media/file with
|
|
461
463
|
// X-COS-Purpose: session-turn. 0 with attach off; absent on an older server, which takes only what the media store reads.
|
|
462
464
|
threadTurnFileBytes: threadAttach.enabled ? TURN_FILE_MAX_BYTES : 0,
|
|
465
|
+
// 6.99.31 (Phase 3): the phone key signer exists (COS_WEB_KEY_AUTHORITY on), so Control Web may offer
|
|
466
|
+
// "Approve on this phone". Absent with the flag off: the composer is exactly 6.99.29's.
|
|
467
|
+
...(webKeyAuthorityEnabled() ? { webKeySigner: 1 } : {}),
|
|
463
468
|
...sessionHooksHealthFields(),
|
|
464
469
|
// 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
|
|
465
470
|
// how many are held (that is on the authenticated questions route).
|
|
@@ -684,6 +689,11 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
684
689
|
// mintDisplayTicket refuses an empty key, because a ticket signed with '' can
|
|
685
690
|
// never verify. Omit the field rather than publish a dead capability.
|
|
686
691
|
const apiToken = process.env.COS_API_TOKEN ?? ''
|
|
692
|
+
// Final review S8 (Q1 and the lead, 2026-11-10 02:20): only a caller the API token let in (the glasses, the phone app,
|
|
693
|
+
// COS Control) gets a display-stream ticket. A browser signed in by cookie never does: Control Web never uses one, and
|
|
694
|
+
// a ticket's socket is authorized once at connect, so it would outlive "Sign out every browser". Fails closed: a
|
|
695
|
+
// request the gate did not mark as the token door gets none.
|
|
696
|
+
const ticketCaller = res.locals?.[API_AUTH_BY_LOCAL] === 'token'
|
|
687
697
|
res.json({
|
|
688
698
|
...catalog,
|
|
689
699
|
...threadAttachHealthFields(threadAttach),
|
|
@@ -751,7 +761,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
751
761
|
// 6.8.441 lens fifteen minutes into a meeting. The TTL therefore bounds how
|
|
752
762
|
// long a LEAKED URL can open a new socket, not how long an open socket lives.
|
|
753
763
|
// Changing that is a breaking change that needs a client version gate first.
|
|
754
|
-
...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
|
|
764
|
+
...(apiToken && ticketCaller ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
|
|
755
765
|
capabilities: {
|
|
756
766
|
meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
|
|
757
767
|
// 6.91.0: learnedStyle ("Your style", enabled = the server switch) and polishOptions ("Previous polish").
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// SWITCH: COS_WEB_LIVE_EVENTS. Absent means on. `0` registers no route at all (404), which is also what a client
|
|
16
16
|
// sees from a server older than 7.0.1, so both take the same polling path.
|
|
17
17
|
|
|
18
|
+
import { streamSignInHolds } from '../lib/api-auth.js'
|
|
18
19
|
import { Router, type Request, type Response } from 'express'
|
|
19
20
|
import { readSwitch } from '../lib/env-switch.js'
|
|
20
21
|
import {
|
|
@@ -323,7 +324,11 @@ export function liveEventsHandler(deps: LiveEventsRouterDeps): (req: Request, re
|
|
|
323
324
|
// or its interval would outlive the stream (review N1).
|
|
324
325
|
if (closed) return
|
|
325
326
|
|
|
326
|
-
heartbeat
|
|
327
|
+
// Final review S1: each heartbeat first asks whether the browser sign-in that opened the stream still verifies.
|
|
328
|
+
heartbeat = setInterval(() => {
|
|
329
|
+
if (!streamSignInHolds(res)) { teardown('signed_out'); return }
|
|
330
|
+
write(deps.hub.heartbeatFrame())
|
|
331
|
+
}, heartbeatMs)
|
|
327
332
|
;(heartbeat as { unref?: () => void }).unref?.()
|
|
328
333
|
}
|
|
329
334
|
}
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
|
|
51
51
|
import express, { Router, type NextFunction, type Request, type Response } from 'express'
|
|
52
52
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
53
|
+
import { turnEndAskStats } from '../lib/session-hooks-runtime.js'
|
|
53
54
|
import {
|
|
54
55
|
ANSWERING_CLIENTS,
|
|
55
56
|
CLIENT_LIVE_WINDOW_MS,
|
|
@@ -162,6 +163,8 @@ export interface SessionQuestionsRouterDeps {
|
|
|
162
163
|
* rules: `isWebSessionAuthorized` in production). Absent: only the token opens the list, as before.
|
|
163
164
|
*/
|
|
164
165
|
webSessionAuthorized?: (req: Request) => boolean
|
|
166
|
+
/** 7.0.18 (X1): the turn-end ask counters (lib/turn-end-ask.ts), null while COS_TURN_END_ASKS is off. Defaults to the runtime's. */
|
|
167
|
+
turnEndAskStats?: () => Record<string, number> | null
|
|
165
168
|
/** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
|
|
166
169
|
notePoll?: (at: number, poll: QuestionsPollFacts) => void
|
|
167
170
|
/** 6.99.26: where COS Control Web's presence is recorded; `noteWebPresence` in production. */
|
|
@@ -271,6 +274,12 @@ export function requestFromThisMac(
|
|
|
271
274
|
return own.some(a => normalizeRemoteIp(String(a)).toLowerCase() === ip)
|
|
272
275
|
}
|
|
273
276
|
|
|
277
|
+
function turnEndAskCounters(deps: SessionQuestionsRouterDeps): { turnEndAsks?: Record<string, number> } {
|
|
278
|
+
let counters: Record<string, number> | null = null
|
|
279
|
+
try { counters = (deps.turnEndAskStats ?? turnEndAskStats)() } catch { counters = null }
|
|
280
|
+
return counters ? { turnEndAsks: counters } : {}
|
|
281
|
+
}
|
|
282
|
+
|
|
274
283
|
export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
|
|
275
284
|
const router = Router()
|
|
276
285
|
const notePoll = deps.notePoll ?? noteQuestionsPoll
|
|
@@ -325,6 +334,13 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
325
334
|
// forgotten (the server started then, or dropped it while it still waited). Missing from
|
|
326
335
|
// `settled` and settled after it: the session moved past it.
|
|
327
336
|
waitingAtMacForgottenBefore: new Date(deps.broker.waitingAtMacForgottenBeforeMs()).toISOString(),
|
|
337
|
+
// X1, additive and only while a full-hold switch is on: every question a live session waits on (held, at the
|
|
338
|
+
// Mac, or just answered), with its words while it waits, and what a web page must send with an answer right now.
|
|
339
|
+
// With both off the response is 7.0.13's, key for key.
|
|
340
|
+
...(deps.broker.extendedQuestionsView() ? { waiting: deps.broker.waitingList(), webAnswer: deps.broker.webAnswerRules() } : {}),
|
|
341
|
+
// 7.0.18 (X1), additive and only while COS_TURN_END_ASKS is on: how many turn-end asks armed, were shown,
|
|
342
|
+
// were answered, were overtaken by a new turn, or aged out. Counts only, never words.
|
|
343
|
+
...turnEndAskCounters(deps),
|
|
328
344
|
})
|
|
329
345
|
})
|
|
330
346
|
router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
|
|
@@ -337,5 +353,15 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
337
353
|
const result = deps.broker.answer(String(req.params.id ?? ''), req.body, by)
|
|
338
354
|
res.status(result.status).json(result.body)
|
|
339
355
|
})
|
|
356
|
+
// X1: the web door's confirm step for an approval (lib/permission-broker.ts `confirm`). The /api gate let the
|
|
357
|
+
// request in; the door is labelled exactly as for an answer, and only the web door gets a confirm id.
|
|
358
|
+
router.post('/session-questions/:id/confirm', (req: Request, res: Response) => {
|
|
359
|
+
res.set('Cache-Control', 'private, no-store')
|
|
360
|
+
let expected = ''
|
|
361
|
+
try { expected = deps.apiToken() } catch { expected = '' }
|
|
362
|
+
const by = timingSafeTokenEqual(req.headers['x-cos-token'], expected) ? 'app' : 'web'
|
|
363
|
+
const result = deps.broker.confirm(String(req.params.id ?? ''), req.body, by)
|
|
364
|
+
res.status(result.status).json(result.body)
|
|
365
|
+
})
|
|
340
366
|
return router
|
|
341
367
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { streamSignInHolds } from '../lib/api-auth.js'
|
|
1
2
|
import { Router, type Request, type Response } from 'express'
|
|
2
3
|
import { durableQueryJobsEnabled, messagesSteerEnabled } from '../lib/query-job-feature.js'
|
|
3
4
|
import { ACTIVE_QUERY_JOBS_LIST_MAX, ACTIVE_QUERY_JOBS_PROTOCOL } from '../lib/query-job-active.js'
|
|
@@ -429,6 +430,8 @@ export function createQueryJobsRouter(
|
|
|
429
430
|
if (closed) return
|
|
430
431
|
|
|
431
432
|
heartbeat = setInterval(() => {
|
|
433
|
+
// Final review S1: a stream opened by a browser sign-in ends once that sign-in no longer verifies.
|
|
434
|
+
if (!streamSignInHolds(res)) { finish(); return }
|
|
432
435
|
if (!closed && !res.writableEnded) res.write(': keepalive\n\n')
|
|
433
436
|
}, heartbeatMs)
|
|
434
437
|
heartbeat.unref?.()
|
|
@@ -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,
|
|
@@ -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 {
|
|
@@ -879,7 +879,9 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
879
879
|
// with its content hash), so the drain can carry it (QUEUED_ORIGIN_TTL_MS at most).
|
|
880
880
|
if (deps.provenance && verdict) {
|
|
881
881
|
const sealed = await sealFiles(attach.ids, verdict, deps.turnAttachmentStore)
|
|
882
|
-
|
|
882
|
+
// 6.99.31: a signed web turn is judged here, once parking is decided, against this provider and
|
|
883
|
+
// thread (where it will drain), these exact words and these files. No envelope: unchanged.
|
|
884
|
+
verdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: clientTurnId, words: prompt, attachments: sealed.manifest })
|
|
883
885
|
deps.provenance.noteQueued(provider, threadId, clientTurnId, prompt, verdict, sealed.attachments)
|
|
884
886
|
}
|
|
885
887
|
|
|
@@ -990,8 +992,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
990
992
|
if (deps.provenance && resendVerdict) {
|
|
991
993
|
// After the write: the read-modify-write above stays one synchronous step. Sealed to the copy's files.
|
|
992
994
|
const sealed = await sealFiles(copy.attachmentIds, resendVerdict, deps.turnAttachmentStore)
|
|
993
|
-
|
|
994
|
-
|
|
995
|
+
// 6.99.31: Send again from the phone needs a fresh signature over the copy (its new id, the words,
|
|
996
|
+
// the files); without one the copy stays unvouched ('resend_not_reverified').
|
|
997
|
+
const copyVerdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: newId, words: copy.prompt, attachments: sealed.manifest })
|
|
998
|
+
deps.provenance.noteQueued(provider, threadId, newId, copy.prompt, copyVerdict, sealed.attachments)
|
|
999
|
+
deps.provenance.record({ turnId: newId, prompt: copy.prompt, verdict: copyVerdict, provider, sessionId: threadId, via: 'queue-resend', resendOf: original.clientTurnId })
|
|
995
1000
|
}
|
|
996
1001
|
console.log(`[thread-turn-queue] resend provider=${provider} from=${original.clientTurnId} as=${newId} unknown=${original.deliveryState === 'unknown'}`)
|
|
997
1002
|
return res.status(200).json(publicRow(copy, queuePosition(admitted.queue, newId)))
|
|
@@ -1061,9 +1066,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
1061
1066
|
if (deps.provenance && verdict) {
|
|
1062
1067
|
// Sealed to the row's files as they are now (an edit changes only the words).
|
|
1063
1068
|
const sealed = await sealFiles(row.attachmentIds, verdict, deps.turnAttachmentStore)
|
|
1064
|
-
|
|
1069
|
+
// 6.99.31: an edit needs a fresh signature over the new words (same id, same files), or it is unvouched.
|
|
1070
|
+
const editVerdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: id, words: prompt, attachments: sealed.manifest })
|
|
1071
|
+
deps.provenance.noteQueued(provider, threadId, id, prompt, editVerdict, sealed.attachments)
|
|
1065
1072
|
// The queue's own row, re-judged: it continues the record parked under this id.
|
|
1066
|
-
deps.provenance.record({ turnId: id, prompt, verdict:
|
|
1073
|
+
deps.provenance.record({ turnId: id, prompt, verdict: editVerdict, provider, sessionId: threadId, via: 'queue-edit', continues: true })
|
|
1067
1074
|
}
|
|
1068
1075
|
return res.json({ updated: true })
|
|
1069
1076
|
})
|
|
@@ -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.' } })
|