@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
|
@@ -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()
|
|
@@ -91,6 +91,35 @@ import { resetPreviewLogThrottle, warnPreviewThrottled } from '../lib/preview-lo
|
|
|
91
91
|
import { sessionFilesSetting, withoutPreviews } from '../lib/session-files-setting.js'
|
|
92
92
|
import { withoutLinkMarkers } from '../lib/session-links.js'
|
|
93
93
|
import { createSessionPreviewRouter } from './session-preview.js'
|
|
94
|
+
import { organizerKey, sessionOrganizer, type OrganizerRowFields } from '../lib/session-organizer.js'
|
|
95
|
+
import { listOlderSessions, shortRowFor, OLDER_DEFAULT_DAYS, OLDER_DEFAULT_LIMIT, OLDER_MAX_DAYS, OLDER_MAX_LIMIT, OLDER_MIN_DAYS } from '../lib/agent-session-older.js'
|
|
96
|
+
import { cachedListModel, warmListModels } from '../lib/list-model-memo.js'
|
|
97
|
+
import { cosStartedSessionIds } from '../lib/cos-started-sessions.js'
|
|
98
|
+
|
|
99
|
+
/** Step 2a: the organizer, or null when it is unavailable or empty (the list is then exactly today's). */
|
|
100
|
+
function organizerForList(): ReturnType<typeof sessionOrganizer> | null {
|
|
101
|
+
try {
|
|
102
|
+
const o = sessionOrganizer()
|
|
103
|
+
return o.isAvailable() && !o.isEmpty() ? o : null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Claude Desktop ids per CLI id, from the request's alias table (an entry written under a Desktop id still matches). */
|
|
109
|
+
function desktopIdsByCli(aliases: ClaudeAliasMap | undefined): Map<string, string[]> {
|
|
110
|
+
const out = new Map<string, string[]>()
|
|
111
|
+
for (const [desktopId, head] of aliases ?? new Map()) {
|
|
112
|
+
const cli = head?.cliSessionId
|
|
113
|
+
if (!cli || cli.startsWith('!')) continue
|
|
114
|
+
out.set(cli, [...(out.get(cli) ?? []), desktopId])
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
function organizerFieldsOf(o: ReturnType<typeof sessionOrganizer> | null, provider: string, sessionId: string, desktopIds?: Map<string, string[]>): OrganizerRowFields {
|
|
119
|
+
if (!o) return {}
|
|
120
|
+
const id = sessionId.toLowerCase()
|
|
121
|
+
return o.fieldsForAny([organizerKey(provider, id)], provider === 'claude' ? desktopIds?.get(id) ?? [] : [])
|
|
122
|
+
}
|
|
94
123
|
|
|
95
124
|
export const agentSessionsRouter = Router()
|
|
96
125
|
// Preview engine phase 1: GET /api/preview/agent-sessions/... and /api/preview-folders/settings (routes/session-preview.ts).
|
|
@@ -511,7 +540,17 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
511
540
|
const peers = await liveClaudePeerRecords()
|
|
512
541
|
const live = liveClaudeRows(peers)
|
|
513
542
|
const dropped = emptySessionListDropped()
|
|
514
|
-
|
|
543
|
+
// Step 2a: COS pins and folder members are listed at any age, outside the cap and `limit`.
|
|
544
|
+
const organizer = organizerForList()
|
|
545
|
+
const desktopIds = organizer ? desktopIdsByCli(aliases) : undefined
|
|
546
|
+
// 7.0.25 (lead 2026-11-11 10:29): kept rows only for a client that ASKS (`kept=1`, Control Web). The public lens
|
|
547
|
+
// (6.11.681) and Control Mac (0.5.389) do not clamp to their own limit, so a server-only update would have added
|
|
548
|
+
// up to KEPT_LIST_MAX rows to their lists unasked. Without the parameter the walk gets no keep set at all: exactly
|
|
549
|
+
// the 7.0.18 selection (same rows, same limit). The organizer fields on rows stay (additive, ignored by them).
|
|
550
|
+
const wantsKept = req.query.kept === '1'
|
|
551
|
+
// Miles, 2026-11-10: sessions a COS job started in the last 7 days stay listed (memoized journal read).
|
|
552
|
+
const started = wantsKept ? await cosStartedSessionIds() : {}
|
|
553
|
+
const sessions = await listAgentSessions(roots, new Date(), live, limit, sort, dropped, aliases, wantsKept && organizer ? organizer.keptIds() : {}, started)
|
|
515
554
|
// 6.45.5: each row's last real activity, read from its transcript records. Memoized on
|
|
516
555
|
// mtime and size, so a steady poll re-reads nothing; bounded so a cold list of 80 rows
|
|
517
556
|
// never holds 80 descriptors at once.
|
|
@@ -583,8 +622,12 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
583
622
|
const signal = signalFor(row.session_id)
|
|
584
623
|
const facts = rolloutById.get(row.session_id) ?? null
|
|
585
624
|
const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now, clocks.get(row.session_id) ?? null)
|
|
625
|
+
// Step 2a: the model only from the memo (never a read on the list path, 6.62.0 W11).
|
|
626
|
+
const model = cachedListModel(row.provider, row.file, Number.isFinite(Date.parse(row.modified)) ? Date.parse(row.modified) : null)
|
|
586
627
|
return {
|
|
587
628
|
...withRunning(toEntry(row, activity[index], derived, queuedOf(row.provider, row.session_id)), running, hint),
|
|
629
|
+
...organizerFieldsOf(organizer, row.provider, row.session_id, desktopIds),
|
|
630
|
+
...(model ? { reported_model: model } : {}),
|
|
588
631
|
...cursorActivityFields(row.provider, signal),
|
|
589
632
|
...(row.provider === 'codex'
|
|
590
633
|
? { ...(subagents.has(row.session_id) ? { subagent_activity: subagents.get(row.session_id) } : {}), ...codexCompactionFields(workObservationFor(row.session_id), facts, now) }
|
|
@@ -602,13 +645,57 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
602
645
|
// client ignores this key. Zero means the walk found nothing to hide, not
|
|
603
646
|
// that the caps are off.
|
|
604
647
|
dropped,
|
|
648
|
+
...(organizer ? { organizer: { rev: organizer.rev() } } : {}),
|
|
605
649
|
}))
|
|
650
|
+
// Step 2a: read the models this answer lacked, in the background, for a later poll. Never awaited.
|
|
651
|
+
warmListModels(sessions)
|
|
606
652
|
} catch (error) {
|
|
607
653
|
console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
|
|
608
654
|
res.status(500).json({ error: 'Failed to read agent sessions', reason: 'agent_sessions_read_failed' })
|
|
609
655
|
}
|
|
610
656
|
})
|
|
611
657
|
|
|
658
|
+
// Step 2a (S1_CONTRACT_step2a.md B2): the last `days` days in a short form, paged, for the compact groups. Not on the
|
|
659
|
+
// list poll: fetched when the list opens and as a group unfolds. Live rows, COS-kept rows and each agent's own pins are
|
|
660
|
+
// left out (the main list has them in full); a row may still also be in the main list, and the client keeps that one.
|
|
661
|
+
agentSessionsRouter.get('/agent-sessions/older', async (req, res) => {
|
|
662
|
+
res.set('Cache-Control', 'private, no-store')
|
|
663
|
+
try {
|
|
664
|
+
const days = boundedInteger(req.query.days, OLDER_DEFAULT_DAYS, OLDER_MIN_DAYS, OLDER_MAX_DAYS)
|
|
665
|
+
const limit = boundedInteger(req.query.limit, OLDER_DEFAULT_LIMIT, 1, OLDER_MAX_LIMIT)
|
|
666
|
+
const beforeRaw = typeof req.query.before === 'string' ? Date.parse(req.query.before) : NaN
|
|
667
|
+
if (typeof req.query.before === 'string' && !Number.isFinite(beforeRaw)) {
|
|
668
|
+
res.status(400).json({ error: 'before must be an ISO time', reason: 'invalid_before' })
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
const roots = agentSessionRoots()
|
|
672
|
+
const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
673
|
+
const organizer = organizerForList()
|
|
674
|
+
const kept = organizer ? organizer.keptIds() : { claude: new Set<string>(), codex: new Set<string>(), cursor: new Set<string>() }
|
|
675
|
+
const started = await cosStartedSessionIds()
|
|
676
|
+
for (const provider of ['claude', 'codex', 'cursor'] as const) for (const id of started[provider]) kept[provider].add(id)
|
|
677
|
+
// Kept Claude ids may be Desktop ids; the walk names transcripts by CLI id.
|
|
678
|
+
for (const id of [...kept.claude]) { const cli = aliases.get(id)?.cliSessionId; if (cli && !cli.startsWith('!')) kept.claude.add(cli) }
|
|
679
|
+
const liveIds = new Set(liveClaudeRows(await liveClaudePeerRecords()).map(row => row.session_id.toLowerCase()))
|
|
680
|
+
const result = await listOlderSessions(roots, new Date(), {
|
|
681
|
+
days, limit, aliases,
|
|
682
|
+
...(Number.isFinite(beforeRaw) ? { before: beforeRaw } : {}),
|
|
683
|
+
// Kept rows come back here at any age: the main list holds at most KEPT_LIST_MAX of them (Q1 FIX 1).
|
|
684
|
+
exclude: { claude: liveIds },
|
|
685
|
+
keep: kept,
|
|
686
|
+
})
|
|
687
|
+
res.json(redactLocalPathsDeep({
|
|
688
|
+
sessions: result.rows.map(row => ({ ...row, ...organizerFieldsOf(organizer, row.provider, row.session_id, desktopIdsByCli(aliases)) })),
|
|
689
|
+
windowDays: days,
|
|
690
|
+
next_before: result.nextBefore,
|
|
691
|
+
...(Number.isFinite(beforeRaw) ? {} : { older_than_window: result.olderThanWindow }),
|
|
692
|
+
}))
|
|
693
|
+
} catch (error) {
|
|
694
|
+
console.error(`[agent-sessions] older list failed: ${error instanceof Error ? error.message : error}`)
|
|
695
|
+
res.status(500).json({ error: 'Failed to read older agent sessions', reason: 'agent_sessions_read_failed' })
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
|
|
612
699
|
agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
613
700
|
res.set('Cache-Control', 'private, no-store')
|
|
614
701
|
const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
|
@@ -619,6 +706,23 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
619
706
|
try {
|
|
620
707
|
const limit = boundedInteger(req.query.limit, 20, 1, 50)
|
|
621
708
|
const result = await searchAgentSessions({ query, limit })
|
|
709
|
+
// Step 2a: a session renamed in COS is found by its new title too (the agent's own title still matches as before).
|
|
710
|
+
const organizer = organizerForList()
|
|
711
|
+
if (organizer) {
|
|
712
|
+
const seen = new Set(result.hits.map(hit => organizerKey(hit.provider, hit.session_id)))
|
|
713
|
+
const roots = agentSessionRoots()
|
|
714
|
+
const extra: AgentSessionSearchHit[] = []
|
|
715
|
+
for (const match of organizer.titleMatches(query)) {
|
|
716
|
+
if (seen.has(organizerKey(match.provider, match.session_id)) || extra.length + result.hits.length >= limit) continue
|
|
717
|
+
const file = await findAgentSessionFile(match.provider, match.session_id, roots).catch(() => null)
|
|
718
|
+
const short = file ? await shortRowFor(match.provider, file, undefined, roots) : null
|
|
719
|
+
if (!short) continue
|
|
720
|
+
extra.push({ session_id: short.session_id, provider: short.provider, display_label: short.display_label, project: short.project,
|
|
721
|
+
modified: short.modified, created: short.created, alive: false, state: 'recent', pinned: false,
|
|
722
|
+
snippet: '', keywordScore: 1, semanticScore: 0, match: 'keyword' })
|
|
723
|
+
}
|
|
724
|
+
result.hits = [...extra, ...result.hits]
|
|
725
|
+
}
|
|
622
726
|
const queuedOf = await allQueuedWaitingLookup(Date.now())
|
|
623
727
|
// 6.48.1: search hits carry the same derived state as list rows (no list/search shape
|
|
624
728
|
// drift), from the registry and the hook signal; a hit has no transcript walk.
|
|
@@ -644,7 +748,7 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
644
748
|
// 7.0.4 S1: search hits carry snippets of the same words.
|
|
645
749
|
res.json(redactLocalPathsDeep({
|
|
646
750
|
...result,
|
|
647
|
-
hits: result.hits.map(hit => toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit))),
|
|
751
|
+
hits: result.hits.map(hit => ({ ...toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit)), ...organizerFieldsOf(organizer, hit.provider, hit.session_id) })),
|
|
648
752
|
}))
|
|
649
753
|
} catch (error) {
|
|
650
754
|
console.error(`[agent-sessions] search failed: ${error instanceof Error ? error.message : error}`)
|
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'
|
|
@@ -9,7 +10,9 @@ import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
|
|
|
9
10
|
import { customInstructionsCapability, learnedStyleCapability, polishOptionsCapability } from './dictation-polish-settings.js'
|
|
10
11
|
import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
|
|
11
12
|
import { taskBridgeAvailable } from '../lib/task-bridge.js'
|
|
13
|
+
import { modelEfforts } from '../lib/model-efforts.js'
|
|
12
14
|
import { Router } from 'express'
|
|
15
|
+
import { webKeyAuthorityEnabled } from '../lib/web-key-authority.js'
|
|
13
16
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
14
17
|
import { claudeSessionsEnabled } from './claude-sessions.js'
|
|
15
18
|
import { statSync } from 'node:fs'
|
|
@@ -460,6 +463,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
460
463
|
// 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
464
|
// X-COS-Purpose: session-turn. 0 with attach off; absent on an older server, which takes only what the media store reads.
|
|
462
465
|
threadTurnFileBytes: threadAttach.enabled ? TURN_FILE_MAX_BYTES : 0,
|
|
466
|
+
// 6.99.31 (Phase 3): the phone key signer exists (COS_WEB_KEY_AUTHORITY on), so Control Web may offer
|
|
467
|
+
// "Approve on this phone". Absent with the flag off: the composer is exactly 6.99.29's.
|
|
468
|
+
...(webKeyAuthorityEnabled() ? { webKeySigner: 1 } : {}),
|
|
463
469
|
...sessionHooksHealthFields(),
|
|
464
470
|
// 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
|
|
465
471
|
// how many are held (that is on the authenticated questions route).
|
|
@@ -684,6 +690,11 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
684
690
|
// mintDisplayTicket refuses an empty key, because a ticket signed with '' can
|
|
685
691
|
// never verify. Omit the field rather than publish a dead capability.
|
|
686
692
|
const apiToken = process.env.COS_API_TOKEN ?? ''
|
|
693
|
+
// 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,
|
|
694
|
+
// COS Control) gets a display-stream ticket. A browser signed in by cookie never does: Control Web never uses one, and
|
|
695
|
+
// a ticket's socket is authorized once at connect, so it would outlive "Sign out every browser". Fails closed: a
|
|
696
|
+
// request the gate did not mark as the token door gets none.
|
|
697
|
+
const ticketCaller = res.locals?.[API_AUTH_BY_LOCAL] === 'token'
|
|
687
698
|
res.json({
|
|
688
699
|
...catalog,
|
|
689
700
|
...threadAttachHealthFields(threadAttach),
|
|
@@ -702,6 +713,8 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
702
713
|
...cursorOptions,
|
|
703
714
|
...ollamaOptions,
|
|
704
715
|
],
|
|
716
|
+
// Step 2a (Sessions chat-style layout): each agent's efforts, the values POST /api/query-jobs accepts. Additive.
|
|
717
|
+
efforts: modelEfforts(),
|
|
705
718
|
cursor: cursorCatalog,
|
|
706
719
|
cursorReady: isCursorProviderReady(),
|
|
707
720
|
ollama: {
|
|
@@ -751,7 +764,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
751
764
|
// 6.8.441 lens fifteen minutes into a meeting. The TTL therefore bounds how
|
|
752
765
|
// long a LEAKED URL can open a new socket, not how long an open socket lives.
|
|
753
766
|
// Changing that is a breaking change that needs a client version gate first.
|
|
754
|
-
...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
|
|
767
|
+
...(apiToken && ticketCaller ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
|
|
755
768
|
capabilities: {
|
|
756
769
|
meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
|
|
757
770
|
// 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?.()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session organizer routes (Sessions chat-style layout, step 2a; S1_CONTRACT_step2a.md, A1).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/session-folders
|
|
5
|
+
* POST /api/session-folders { name }
|
|
6
|
+
* PATCH /api/session-folders/:id { name }
|
|
7
|
+
* DELETE /api/session-folders/:id (its sessions leave it; no session is deleted)
|
|
8
|
+
* PATCH /api/agent-sessions/:provider/:sessionId/organizer { folder_id?, cos_pinned?, title? }
|
|
9
|
+
*
|
|
10
|
+
* Behind index.ts's /api gate (API token, or a browser cookie with `x-cos-web: 1` and a same-origin Origin).
|
|
11
|
+
* The session route canonicalizes the id from the transcript it finds, so a Claude Desktop id and its CLI id are
|
|
12
|
+
* one key, and a session the server cannot find is refused (404) instead of stored.
|
|
13
|
+
* Logs carry reason codes only: never a folder name or a title.
|
|
14
|
+
*/
|
|
15
|
+
import { Router, type Request, type Response } from 'express'
|
|
16
|
+
import {
|
|
17
|
+
agentSessionRoots,
|
|
18
|
+
findAgentSessionFile,
|
|
19
|
+
idFromCodexFilename,
|
|
20
|
+
isSafeSessionId,
|
|
21
|
+
loadClaudeDesktopAliases,
|
|
22
|
+
normalizeClaudeSessionId,
|
|
23
|
+
type AgentProvider,
|
|
24
|
+
type AgentSessionRoots,
|
|
25
|
+
} from '../lib/agent-session-store.js'
|
|
26
|
+
import { OrganizerError, organizerKey, sessionOrganizer, type SessionOrganizer } from '../lib/session-organizer.js'
|
|
27
|
+
|
|
28
|
+
const PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
|
|
29
|
+
|
|
30
|
+
export interface SessionOrganizerRouteDeps {
|
|
31
|
+
organizer: () => SessionOrganizer
|
|
32
|
+
roots: () => AgentSessionRoots
|
|
33
|
+
/** The canonical id of a session the server can find on disk, or null. */
|
|
34
|
+
canonicalId: (provider: AgentProvider, sessionId: string, roots: AgentSessionRoots) => Promise<SessionIdentity | null>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Where a session's organizer entry lives: its canonical id, and for Claude the Desktop id it was named by when one is
|
|
38
|
+
* known. `null`: the server cannot find the session. Throws 409 alias_conflict while Claude Desktop's records disagree
|
|
39
|
+
* about the session's CLI id (Q1 FIX 2): an entry written then could be orphaned when they settle. */
|
|
40
|
+
export interface SessionIdentity { id: string; desktopId?: string }
|
|
41
|
+
|
|
42
|
+
/** The id a found transcript names: Claude's CLI uuid (a Desktop id resolves to it), Codex's rollout id, Cursor's composer. */
|
|
43
|
+
export async function canonicalSessionId(provider: AgentProvider, sessionId: string, roots: AgentSessionRoots): Promise<SessionIdentity | null> {
|
|
44
|
+
if (!isSafeSessionId(sessionId)) return null
|
|
45
|
+
if (provider === 'claude') {
|
|
46
|
+
// A Desktop id may arrive as `local_<uuid>`; the alias table and transcripts use the bare uuid.
|
|
47
|
+
sessionId = normalizeClaudeSessionId(sessionId)
|
|
48
|
+
const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
49
|
+
if (aliases.has(sessionId) && aliases.get(sessionId) === null) throw new OrganizerError(409, 'alias_conflict')
|
|
50
|
+
const head = aliases.get(sessionId)
|
|
51
|
+
const file = await findAgentSessionFile('claude', sessionId, roots, new Date(), aliases)
|
|
52
|
+
if (file) {
|
|
53
|
+
const id = (file.split('/').pop() || '').replace(/\.jsonl$/, '').toLowerCase()
|
|
54
|
+
if (!id) return null
|
|
55
|
+
// The Desktop id: the one asked for when it is an alias, else the single Desktop record that names this CLI id.
|
|
56
|
+
let desktopId: string | undefined = head ? sessionId : undefined
|
|
57
|
+
if (!desktopId) {
|
|
58
|
+
const named = [...aliases].filter(([, h]) => h?.cliSessionId === id).map(([d]) => d)
|
|
59
|
+
if (named.length === 1) desktopId = named[0]
|
|
60
|
+
}
|
|
61
|
+
return { id, ...(desktopId && desktopId !== id ? { desktopId } : {}) }
|
|
62
|
+
}
|
|
63
|
+
// A Desktop-only session (no CLI transcript yet): keyed by its Desktop id, which the list also shows.
|
|
64
|
+
if (head) return { id: sessionId, desktopId: sessionId }
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
const file = await findAgentSessionFile(provider, sessionId, roots)
|
|
68
|
+
if (!file) return null
|
|
69
|
+
const parts = file.split('/')
|
|
70
|
+
const id = provider === 'codex'
|
|
71
|
+
? idFromCodexFilename(parts[parts.length - 1] || '')
|
|
72
|
+
: (parts[parts.length - 2] || '').toLowerCase() || null
|
|
73
|
+
return id ? { id } : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fail(res: Response, error: unknown): void {
|
|
77
|
+
if (error instanceof OrganizerError) {
|
|
78
|
+
res.status(error.status).json({ error: error.reason, reason: error.reason })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
console.error(`[session-organizer] write failed (reason=${(error as NodeJS.ErrnoException)?.code ?? 'error'})`)
|
|
82
|
+
res.status(500).json({ error: 'organizer_write_failed', reason: 'organizer_write_failed' })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createSessionOrganizerRouter(deps: SessionOrganizerRouteDeps = {
|
|
86
|
+
organizer: sessionOrganizer,
|
|
87
|
+
roots: () => agentSessionRoots(),
|
|
88
|
+
canonicalId: canonicalSessionId,
|
|
89
|
+
}): Router {
|
|
90
|
+
const router = Router()
|
|
91
|
+
const noStore = (res: Response) => res.set('Cache-Control', 'private, no-store')
|
|
92
|
+
|
|
93
|
+
router.get('/session-folders', (_req: Request, res: Response) => {
|
|
94
|
+
noStore(res)
|
|
95
|
+
const o = deps.organizer()
|
|
96
|
+
if (!o.isAvailable()) return void res.status(503).json({ error: 'organizer_unavailable', reason: 'organizer_unavailable' })
|
|
97
|
+
res.json({ rev: o.rev(), folders: o.listFolders() })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
router.post('/session-folders', (req: Request, res: Response) => {
|
|
101
|
+
noStore(res)
|
|
102
|
+
try {
|
|
103
|
+
const o = deps.organizer()
|
|
104
|
+
const folder = o.createFolder((req.body as { name?: unknown } | undefined)?.name)
|
|
105
|
+
res.status(201).json({ rev: o.rev(), folder })
|
|
106
|
+
} catch (error) { fail(res, error) }
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
router.patch('/session-folders/:id', (req: Request, res: Response) => {
|
|
110
|
+
noStore(res)
|
|
111
|
+
try {
|
|
112
|
+
const o = deps.organizer()
|
|
113
|
+
const folder = o.renameFolder(String(req.params.id), (req.body as { name?: unknown } | undefined)?.name)
|
|
114
|
+
res.json({ rev: o.rev(), folder })
|
|
115
|
+
} catch (error) { fail(res, error) }
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
router.delete('/session-folders/:id', (req: Request, res: Response) => {
|
|
119
|
+
noStore(res)
|
|
120
|
+
try {
|
|
121
|
+
const o = deps.organizer()
|
|
122
|
+
const released = o.deleteFolder(String(req.params.id))
|
|
123
|
+
res.json({ rev: o.rev(), released })
|
|
124
|
+
} catch (error) { fail(res, error) }
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
router.patch('/agent-sessions/:provider/:sessionId/organizer', async (req: Request, res: Response) => {
|
|
128
|
+
noStore(res)
|
|
129
|
+
const provider = String(req.params.provider)
|
|
130
|
+
if (!PROVIDERS.has(provider)) return void res.status(400).json({ error: 'invalid_provider', reason: 'invalid_provider' })
|
|
131
|
+
// A Claude Desktop id arrives as `local_<uuid>`; the bare uuid is the id everywhere else.
|
|
132
|
+
const sessionId = provider === 'claude' ? normalizeClaudeSessionId(String(req.params.sessionId)) : String(req.params.sessionId)
|
|
133
|
+
if (!isSafeSessionId(sessionId)) return void res.status(400).json({ error: 'invalid_session_id', reason: 'invalid_session_id' })
|
|
134
|
+
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body as Record<string, unknown> : {}
|
|
135
|
+
const change: { folder_id?: unknown; cos_pinned?: unknown; title?: unknown } = {}
|
|
136
|
+
for (const key of ['folder_id', 'cos_pinned', 'title'] as const) if (Object.prototype.hasOwnProperty.call(body, key)) change[key] = body[key]
|
|
137
|
+
try {
|
|
138
|
+
const o = deps.organizer()
|
|
139
|
+
if (!o.isAvailable()) throw new OrganizerError(503, 'organizer_unavailable')
|
|
140
|
+
if (Object.keys(change).length === 0) throw new OrganizerError(400, 'empty_change')
|
|
141
|
+
const identity = await deps.canonicalId(provider as AgentProvider, sessionId, deps.roots())
|
|
142
|
+
if (!identity) throw new OrganizerError(404, 'session_not_found')
|
|
143
|
+
const fields = o.updateSession(organizerKey(provider, identity.id), change, { desktopId: identity.desktopId })
|
|
144
|
+
res.json({ rev: o.rev(), session: { provider, session_id: identity.id, ...fields } })
|
|
145
|
+
} catch (error) { fail(res, error) }
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return router
|
|
149
|
+
}
|