@gotcos/glasses-server 7.0.18 → 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.
@@ -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
+ }
@@ -21,7 +21,7 @@ import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-
21
21
  import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
22
22
  import { CLIENT_TURN_ID_RE, COS_SESSION_ID_RE, DEFAULT_MAX_PROMPT_CHARS } from './agent-session-bindings.js'
23
23
  import {
24
- admitToQueue, drainDecision, queueableRefusal, queuePosition,
24
+ admitToQueue, drainDecision, holdReasonFor, queueableRefusal, queuePosition,
25
25
  MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
26
26
  queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
27
27
  steerMidturnEnabled, type SessionSteerAvailability,
@@ -153,6 +153,14 @@ export function steerAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe
153
153
  return sessionSteerAvailability({ provider, gateReason, continueLive, peerInbox: probe.peerInbox, waitingOnPrompt: probe.waitingOnPrompt })
154
154
  }
155
155
 
156
+ /**
157
+ * When a sweep last decided each held row (epoch ms), in memory only: the row file is written when its hold
158
+ * reason CHANGES, not every 20 s. Bounded; a settled row is forgotten.
159
+ */
160
+ const holdCheckedAt = new Map<string, number>()
161
+ const HOLD_CHECKED_MAX = 1_000
162
+ export function __resetHoldCheckedForTests(): void { holdCheckedAt.clear() }
163
+
156
164
  /** Public shape of a queued turn. No prompt echo beyond a short preview. */
157
165
  export function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unknown> {
158
166
  const row: Record<string, unknown> = {
@@ -169,6 +177,12 @@ export function publicRow(turn: QueuedThreadTurn, position: number): Record<stri
169
177
  ...(turn.settledAt ? { settledAt: turn.settledAt } : {}),
170
178
  // 11/07: how many files wait with the words (never their ids or paths). Absent without files.
171
179
  ...(Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0 ? { attachments: turn.attachmentIds.length } : {}),
180
+ // 2026-11-11: why a waiting row is held (a code), since when, and when a sweep last decided it. Additive.
181
+ ...(turn.status === 'waiting' && typeof turn.holdReason === 'string' ? {
182
+ waiting_reason: turn.holdReason,
183
+ ...(typeof turn.holdSince === 'number' ? { waiting_since: turn.holdSince } : {}),
184
+ ...(holdCheckedAt.has(turn.clientTurnId) ? { waiting_checked_at: holdCheckedAt.get(turn.clientTurnId) } : {}),
185
+ } : {}),
172
186
  }
173
187
  // 6.78.0: additive, and only while a P3 flag is on, so the row is the 6.77 bytes otherwise.
174
188
  // `resendable` is the per-row capability: present only when Send again can act on it.
@@ -242,7 +256,7 @@ function localDrainVerdict(at: number): OriginVerdict {
242
256
  }
243
257
 
244
258
  /** What one mid-turn attempt did to its row: delivered, settled some other way, or left waiting. */
245
- type MidTurnOutcome = 'delivered' | 'settled' | 'held'
259
+ type MidTurnOutcome = 'delivered' | 'settled' | 'held' | 'held_files'
246
260
 
247
261
  /**
248
262
  * Steer mid-turn (COS_STEER_MIDTURN, Miles 2026-11-07): put ONE waiting row into the running turn
@@ -279,9 +293,11 @@ async function steerHeldTurn(
279
293
  deps: ThreadTurnQueueDeps,
280
294
  ): Promise<MidTurnOutcome> {
281
295
  if (turn.status !== 'waiting' || turn.attempts !== 0) return 'held'
282
- if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held'
283
296
  const availability = midTurnAvailabilityFor(deps, provider, threadId, gateReason)
284
297
  if (!availability || !availability.available) return 'held'
298
+ // Files never go into a running turn (the peer inbox carries words only). Checked AFTER availability, so
299
+ // `held_files` means exactly this: the turn could have gone in now but for its files (the row's hold reason).
300
+ if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held_files'
285
301
  const { clientTurnId, prompt } = turn
286
302
 
287
303
  const rewrite = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn | null => {
@@ -465,11 +481,19 @@ export async function drainThread(
465
481
  // which also means the hold is the busy GATE: the rule answers only for a refusal that
466
482
  // names a desk run (an attachable thread has no reason). Anything else holds as before.
467
483
  const cancelHeld = typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && deps.now() < seen.cancelHoldUntil
484
+ let reason = holdReasonFor(seen, deps.now())
468
485
  if (!midTurnOrderHeld && !cancelHeld) {
469
486
  const outcome = await steerHeldTurn(provider, threadId, turn, gate.reason, deps)
470
- if (outcome === 'delivered') { delivered += 1; continue }
471
- if (outcome === 'settled') { retired += 1; continue }
487
+ if (outcome === 'delivered') { holdCheckedAt.delete(turn.clientTurnId); delivered += 1; continue }
488
+ if (outcome === 'settled') { holdCheckedAt.delete(turn.clientTurnId); retired += 1; continue }
489
+ if (outcome === 'held_files') reason = 'files_wait_for_turn_end'
472
490
  }
491
+ // The hold reason: written only when it changes; the check time is kept in memory (2026-11-11).
492
+ const at = deps.now()
493
+ if (turn.holdReason !== reason) { turn.holdReason = reason; turn.holdSince = at; persist(turn) }
494
+ holdCheckedAt.delete(turn.clientTurnId)
495
+ holdCheckedAt.set(turn.clientTurnId, at)
496
+ while (holdCheckedAt.size > HOLD_CHECKED_MAX) holdCheckedAt.delete(holdCheckedAt.keys().next().value!)
473
497
  midTurnOrderHeld = true
474
498
  held += 1
475
499
  continue
@@ -480,6 +504,7 @@ export async function drainThread(
480
504
  turn.settledAt = deps.now()
481
505
  retired += 1; persist(turn)
482
506
  forgetIfSettled(turn)
507
+ holdCheckedAt.delete(turn.clientTurnId)
483
508
  continue
484
509
  }
485
510
 
@@ -488,6 +513,10 @@ export async function drainThread(
488
513
  // forever; the ceiling only bounds anything if it survives the crash it is bounding.
489
514
  turn.attempts += 1
490
515
  turn.status = 'delivering'
516
+ // No longer held: the reason belonged to the wait that just ended.
517
+ delete turn.holdReason
518
+ delete turn.holdSince
519
+ holdCheckedAt.delete(turn.clientTurnId)
491
520
  // 6.78.0: the stamp the restart reconcile reads. `journal: 1` says this build keeps an
492
521
  // attempt record for the delivery that follows; `deliveringBootId` says which boot owns
493
522
  // it, so a sweep never touches a row this process is delivering right now. Durable:
@@ -2,14 +2,14 @@
2
2
  "v": 1,
3
3
  "algorithm": "sha256",
4
4
  "files": {
5
- "/app.css": "0d3ebbf2ca167bc9ef6ab8efef41618f514cc4f505ae588642d36935249697af",
6
- "/app.js": "3c489b154588908178de5b70b18ee8184ec9256e99f774177b9e35ceb27eb362",
5
+ "/app.css": "a4dbdb42ba6c9de5dc02235985fde499854693c41fca54ad3f3395587ab9834a",
6
+ "/app.js": "9a26933a5bd72724b620c9c61d11508d895f63ddea620df684f62bb585d761de",
7
7
  "/apple-touch-icon.png": "04ec7eca0bf6f53d89900d146ac32d4826759d6258bcdbd1ccdcc517f26f532c",
8
8
  "/control.css": "e7b287aa4acc1b3895a9ce61512fe449c555b6335a7145684fd78108ae008c75",
9
9
  "/fonts/DMSans.ttf": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d",
10
10
  "/fonts/Fraunces.ttf": "177ff6c0f14e5550a3c624247cd1189611d4eb65d000b14944c63d967958abbb",
11
11
  "/fonts/JetBrainsMono.ttf": "48715a42ec242c21e9f02692891e147d022299a52e48d5e413e1a942193ffeda",
12
- "/index.html": "26ee9cd1f68829ae5f47dcc7a0c2e45a40d28c4ee65d0537f0677b13ea727267",
12
+ "/index.html": "736f56bbeec449f2204b815594d74042924622ef3004227202a0374df8bc82b5",
13
13
  "/mark.svg": "8e380d33d393027df192eb1afa08f15a380c2070c72d68f27311b49d9e94ca18",
14
14
  "/memories/d3.min.js": "f2094bbf6141b359722c4fe454eb6c4b0f0e42cc10cc7af921fc158fceb86539",
15
15
  "/memories/graph-explorer.css": "ec128de59811f45c71b6bededd737989309205e903951eb8211ade7664f87486",
@@ -28,17 +28,17 @@
28
28
  "/panes/questions.css": "5c5a9b32ce32386b508ce326b17d40ecacbb0f2e049df7f4fe2a0009bd3b7908",
29
29
  "/panes/questions.js": "228e8cd0a9651a290cbb380db487af677a2fc7e93a64b1e33c9411cd8b013478",
30
30
  "/panes/reader-media.js": "4cf892a855aa2d0dce6978e69296cd49b8036d8fc62f12773b3fcadc210b2ef0",
31
- "/panes/sessions.css": "fa2e714e7b03459e12407f7125ab0e1eb7d12a4cc9341a2442dc73900f5a3081",
31
+ "/panes/sessions.css": "654e0509ab2b699b9ea92b6d9e2f7522146df1f0b6355915bef03caaf63addc4",
32
32
  "/panes/settings.js": "5c46c951fdcc2fc942486ec4b468b7e0bb0350a0a56a38d429948fc5147ab2f4",
33
33
  "/panes/speakers.css": "49e70dae4266151b14b0057f2e43de169de225b8f3ac30343c81cf532735a487",
34
34
  "/panes/speakers.js": "8132c9244fb3965de802680840d2985978571988b9b4462b347d079330251bc5",
35
35
  "/panes/threads.js": "45fd7b56480be53225e91193cfa4c41a4ee7167890f10560e439bc7d2461747f",
36
36
  "/panes/today-actions.css": "c82f95b2aaf09802b15d47b24eccdb5c974afcab38ff5f861b3a8d8630b60869",
37
- "/panes/today-actions.js": "4fbc51dc624f9e4cdc85db89905f019a8a5961e4984e8733e0525c71cf0e8657",
37
+ "/panes/today-actions.js": "57ae8b378b1061060ae2a3f61d227d440d7806538085c1d5b310239c036e4bdc",
38
38
  "/panes/work.css": "da50355dac690f712d1f6331f70ee5eadbf5982d37508648ae7786afc99ac7b1",
39
- "/panes/work.js": "0425f53fba98ca34496486d914c3b03f95de3c2a651dc0c88a2a20bc4f6407cd",
39
+ "/panes/work.js": "1ef872b8c7e1a57bd7bd314e11eee7ac5fa9668871da7e644a1fbf5fd9c0d9e8",
40
40
  "/shell.js": "f1cc6a52c78e01ddba89ff27c6bd0e8f8bd8f29bdfcbb5150ee4ed66a8128d9d",
41
- "/theme.js": "700453683539c9efcde014ef1eb088c39478dad54f1ef53fa5e2dee9b36f0a3f",
41
+ "/theme.js": "360df6013cd638524543728f8fa3d35f5641d4d230da7c290de40c79216670ee",
42
42
  "/vendor/qrcode-encoder.js": "61d87cc7445cc66b862cff913de4571634746ea62bcd50d0f592be14f6d1ce46"
43
43
  }
44
44
  }
@@ -106,6 +106,15 @@ a { color: var(--accent); }
106
106
  svg.icon { width: 22px; height: 22px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
107
107
 
108
108
  /* ── Sign in ─────────────────────────────────────────────── */
109
+ /* 7.0.19 first paint: the mark on the page's background until the sign-in card or the app shows (index.html #first-paint).
110
+ Fixed, so it is out of the layout and nothing shifts when it goes. Only while theme.js has run (html.fp-wait), so it
111
+ never shows with JavaScript off. The failsafe hides it after 25 s, longer than the 20 s sign-in check, so it ends the
112
+ wait only when the scripts never ran; reduced motion shortens durations, not this delay. */
113
+ .first-paint { display: none; }
114
+ html.fp-wait .first-paint { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; background: var(--page); color: var(--ink); pointer-events: none; animation: fp-failsafe 1ms linear 25s forwards; }
115
+ .first-paint .mark { width: 44px; height: 44px; }
116
+ @keyframes fp-failsafe { to { visibility: hidden; } }
117
+ .noscript-note { margin: 0; min-height: 100dvh; display: grid; place-items: center; padding: 24px var(--gutter); font-size: 17px; color: var(--ink); text-align: center; }
109
118
  .auth-gate { min-height: 100dvh; display: grid; place-items: center; padding: calc(var(--safe-top) + 24px) var(--gutter) calc(var(--safe-bottom) + 24px); }
110
119
  .auth-card { width: 100%; max-width: 420px; }
111
120
  .auth-card .mark { width: 44px; height: 44px; margin-bottom: 22px; }