@gotcos/glasses-server 6.99.33 → 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.
Files changed (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The preview contract's routes (preview engine phase 1, PLAN 4c), mounted inside agentSessionsRouter, so behind the
3
+ * shared /api gate. Neither a request nor a response carries a filesystem path: a card or file is named by an opaque
4
+ * id that the resolver re-authorizes on every call (agent-session-output-images.ts `previewFile`).
5
+ *
6
+ * GET /api/preview/agent-sessions/:provider/:sessionId/:id/meta
7
+ * GET /api/preview/agent-sessions/:provider/:sessionId/:id/content?part=raw|inline|text|table|html|html-run|thumb
8
+ * GET|POST /api/preview-folders/settings the Settings extra folders (lib/preview-folders-setting.ts)
9
+ *
10
+ * "Show session files, screenshots and images" (7.0.4 name) off answers 403 previews_off before any transcript or file is read.
11
+ * A denied secret name answers 403 secret_file_denied (as the 7.0.4 output routes do). A folder card has no content (phase 2 lists folders).
12
+ * Every refusal or failure but a missing id logs one line (`logPreviewRefusal`): reason code, provider, part, kind.
13
+ */
14
+ import { Router, type Request, type Response } from 'express'
15
+ import { agentSessionOutputImages } from '../lib/agent-session-output-images.js'
16
+ import { sessionFilesSetting } from '../lib/session-files-setting.js'
17
+ import { previewFoldersSetting, folderDisplay, type PreviewFoldersStore } from '../lib/preview-folders-setting.js'
18
+ import { previewThumbnailer } from '../lib/preview-thumbs.js'
19
+ import {
20
+ HTML_PREVIEW_MAX, looksBinary, PREVIEW_PARTS, previewHeaders, tablePage, TEXT_WINDOW_MAX, textWindow, withCspMeta, type PreviewPart,
21
+ } from '../lib/preview-content.js'
22
+ import type { PreviewKind } from '../lib/session-links.js'
23
+ import { previewErrorCode } from '../lib/preview-error-code.js'
24
+ import { warnPreviewThrottled } from '../lib/preview-log-throttle.js'
25
+ /** The 7.0.4 code (routes/agent-sessions.ts PREVIEWS_BUSY), repeated here so this router needs nothing from that one. */
26
+ const PREVIEWS_BUSY = 'previews_busy'
27
+
28
+ type Previews = Pick<typeof agentSessionOutputImages, 'previewFile' | 'previewImage'>
29
+ type Thumbs = Pick<typeof previewThumbnailer, 'thumbnail'>
30
+ export interface SessionPreviewDeps {
31
+ previews?: Previews
32
+ enabled?: () => boolean
33
+ thumbs?: Thumbs
34
+ folders?: PreviewFoldersStore
35
+ home?: () => string
36
+ }
37
+
38
+ const PROVIDERS = new Set(['claude', 'codex', 'cursor'])
39
+ const TEXT_KINDS = new Set<PreviewKind>(['markdown', 'text', 'code', 'html', 'table'])
40
+ const THUMB_KINDS = new Set<PreviewKind>(['pdf', 'document', 'slides', 'sheet', 'html'])
41
+ const NORMALIZED = /\.(png|jpe?g|webp|hei[cf]|avif)$/i
42
+
43
+ /** Which parts a kind has. The web reads this from `meta` and never asks for another. */
44
+ export function partsFor(kind: PreviewKind, name: string): PreviewPart[] {
45
+ const parts: PreviewPart[] = []
46
+ if (kind === 'folder') return parts
47
+ parts.push('raw')
48
+ if (kind === 'pdf') parts.push('inline')
49
+ if (kind === 'image' && NORMALIZED.test(name)) parts.push('inline')
50
+ if (TEXT_KINDS.has(kind)) parts.push('text')
51
+ if (kind === 'table') parts.push('table')
52
+ if (kind === 'html') parts.push('html', 'html-run')
53
+ if (THUMB_KINDS.has(kind)) parts.push('thumb')
54
+ if (kind === 'image' && NORMALIZED.test(name)) parts.push('thumb')
55
+ return parts
56
+ }
57
+
58
+ /** What a refusal or failure is logged with. Only fixed words: never a path, a file name, an id, a token or content. */
59
+ interface LogAt { route: 'meta' | 'content'; provider: string; part?: string; kind?: PreviewKind; detail?: string }
60
+ const SAFE_WORD = /^[a-z0-9_-]{1,40}$/i
61
+ /**
62
+ * QA 7.0.5 (the 7.0.4 promise, for the preview engine's routes): one line per refusal or failure, with a stable reason
63
+ * code and the provider, part and file kind. A missing id (404 preview_not_found) is ordinary and is not logged, as in
64
+ * the 7.0.4 output routes. 7.0.7 (final QA N1): a burst of the same line (a page of thumbnails while another client is
65
+ * busy) logs once per 10 seconds with the count it held back, through the 7.0.4 routes' own limiter.
66
+ */
67
+ export function logPreviewRefusal(at: LogAt, error: string, now = Date.now()): void {
68
+ if (error === 'preview_not_found') return
69
+ const words = [`reason=${error}`, `provider=${PROVIDERS.has(at.provider) ? at.provider : 'unknown'}`]
70
+ if (at.part && SAFE_WORD.test(at.part)) words.push(`part=${at.part}`)
71
+ if (at.kind && SAFE_WORD.test(at.kind)) words.push(`kind=${at.kind}`)
72
+ if (at.detail && SAFE_WORD.test(at.detail)) words.push(`detail=${at.detail}`)
73
+ const line = `[preview] ${at.route} refused ${words.join(' ')}`
74
+ warnPreviewThrottled(line, line, now)
75
+ }
76
+
77
+ function refuse(res: Response, status: string, at: LogAt) {
78
+ const map: Record<string, [number, string]> = {
79
+ not_found: [404, 'preview_not_found'], unavailable: [503, 'preview_unavailable'], too_large: [413, 'preview_too_large'],
80
+ secret: [403, 'secret_file_denied'], folder: [404, 'preview_folder'], busy: [503, PREVIEWS_BUSY],
81
+ }
82
+ const [code, error] = map[status] ?? [503, 'preview_unavailable']
83
+ logPreviewRefusal(at, error)
84
+ if (status === 'busy') res.set('Retry-After', '2')
85
+ res.status(code).json({ error })
86
+ }
87
+ /** A refusal the route answers itself (not through `refuse`), logged the same way. */
88
+ function answer(res: Response, code: number, error: string, at: LogAt) {
89
+ logPreviewRefusal(at, error)
90
+ res.status(code).json({ error })
91
+ }
92
+
93
+ export function createSessionPreviewRouter(deps: SessionPreviewDeps = {}): Router {
94
+ const router = Router()
95
+ const previews = deps.previews ?? agentSessionOutputImages
96
+ const enabled = deps.enabled ?? (() => sessionFilesSetting.read().enabled)
97
+ const thumbs = deps.thumbs ?? previewThumbnailer
98
+ const folders = deps.folders ?? previewFoldersSetting
99
+
100
+ const gate = (req: Request, res: Response, route: LogAt['route']): { provider: 'claude' | 'codex' | 'cursor'; sessionId: string; id: string } | null => {
101
+ res.set('Cache-Control', 'private, no-store')
102
+ res.set('X-Content-Type-Options', 'nosniff')
103
+ const provider = String(req.params.provider ?? '').toLowerCase()
104
+ // The switch decides before anything is read (no transcript, no file, no stat).
105
+ if (!enabled()) { answer(res, 403, 'previews_off', { route, provider }); return null }
106
+ if (!PROVIDERS.has(provider)) { res.status(404).json({ error: 'preview_not_found' }); return null }
107
+ return { provider: provider as 'claude' | 'codex' | 'cursor', sessionId: String(req.params.sessionId ?? ''), id: String(req.params.id ?? '') }
108
+ }
109
+
110
+ router.get('/preview/agent-sessions/:provider/:sessionId/:id/meta', async (req, res) => {
111
+ const g = gate(req, res, 'meta'); if (!g) return
112
+ const at: LogAt = { route: 'meta', provider: g.provider }
113
+ try {
114
+ const found = await previews.previewFile(g.provider, g.sessionId, g.id, true)
115
+ if (found.status === 'folder') { res.json({ id: g.id, name: '', kind: 'folder', type: 'Folder', state: 'available', parts: [] }); return }
116
+ if (found.status !== 'ok') { refuse(res, found.status, { ...at, detail: found.reason }); return }
117
+ res.json({ id: g.id, name: found.name, kind: found.kind, type: found.type, size: found.identity.size, ...(found.line ? { line: found.line } : {}), state: 'available', parts: partsFor(found.kind, found.name) })
118
+ } catch (error) { refuse(res, 'unavailable', { ...at, detail: 'error_' + previewErrorCode(error) }) }
119
+ })
120
+
121
+ router.get('/preview/agent-sessions/:provider/:sessionId/:id/content', async (req, res) => {
122
+ const g = gate(req, res, 'content'); if (!g) return
123
+ const part = String(req.query.part ?? 'raw') as PreviewPart
124
+ const at: LogAt = { route: 'content', provider: g.provider }
125
+ if (!PREVIEW_PARTS.includes(part)) { answer(res, 400, 'preview_part', at); return }
126
+ at.part = part
127
+ try {
128
+ const found = await previews.previewFile(g.provider, g.sessionId, g.id)
129
+ if (found.status !== 'ok') { refuse(res, found.status, { ...at, detail: found.reason }); return }
130
+ at.kind = found.kind
131
+ if (!partsFor(found.kind, found.name).includes(part)) { answer(res, 400, 'preview_part', at); return }
132
+ // A picture goes through the output-image normalizer, as a JPEG, never as the bytes a file claims to be.
133
+ if (found.kind === 'image' && part !== 'raw') {
134
+ const img = await previews.previewImage(g.provider, g.sessionId, g.id, part === 'thumb' ? 'thumb' : 'phone')
135
+ if (img.status !== 'ok') { refuse(res, img.status, { ...at, detail: 'reason' in img ? img.reason : undefined }); return }
136
+ if (!('path' in img)) { refuse(res, 'unavailable', at); return }
137
+ res.set({ 'Content-Type': 'image/jpeg', 'Content-Disposition': 'inline', 'Content-Security-Policy': "sandbox; default-src 'none'", 'Cross-Origin-Resource-Policy': 'same-origin' })
138
+ res.sendFile(img.path, { dotfiles: 'allow', headers: { 'Content-Type': 'image/jpeg' } }, err => {
139
+ if (!err) return
140
+ if (!res.headersSent) refuse(res, 'unavailable', { ...at, detail: 'send_' + previewErrorCode(err) }); else { logPreviewRefusal({ ...at, detail: 'send_' + previewErrorCode(err) }, 'preview_cut'); res.destroy() }
141
+ })
142
+ return
143
+ }
144
+ const bytes = found.bytes
145
+ if (part === 'raw' || part === 'inline') { res.set(previewHeaders(part, found.name)); res.status(200).end(bytes); return }
146
+ if (part === 'thumb') {
147
+ const t = await thumbs.thumbnail(bytes, found.name, found.identity)
148
+ if (t.status !== 'ok') {
149
+ if (t.status === 'busy') res.set('Retry-After', '2')
150
+ answer(res, 503, t.status === 'busy' ? PREVIEWS_BUSY : 'thumb_unavailable', { ...at, detail: t.status === 'unavailable' ? t.reason : undefined })
151
+ return
152
+ }
153
+ res.set(previewHeaders('thumb', found.name))
154
+ res.sendFile(t.path, { dotfiles: 'allow', headers: { 'Content-Type': 'image/png' } }, err => {
155
+ if (!err) return
156
+ if (!res.headersSent) answer(res, 503, 'thumb_unavailable', { ...at, detail: 'send_' + previewErrorCode(err) }); else { logPreviewRefusal({ ...at, detail: 'send_' + previewErrorCode(err) }, 'preview_cut'); res.destroy() }
157
+ })
158
+ return
159
+ }
160
+ if (looksBinary(bytes)) { answer(res, 415, 'preview_binary', at); return }
161
+ if (part === 'html' || part === 'html-run') {
162
+ if (bytes.length > HTML_PREVIEW_MAX) { refuse(res, 'too_large', at); return }
163
+ // PLAN 6: the policy is in the page itself too, before anything in it can load.
164
+ res.set(previewHeaders(part, found.name)); res.status(200).end(withCspMeta(bytes, part)); return
165
+ }
166
+ if (part === 'table') {
167
+ res.set(previewHeaders('table', found.name))
168
+ res.json(tablePage(bytes, found.name, Number(req.query.offset ?? 0)))
169
+ return
170
+ }
171
+ // Every text window, an HTML page's source included, is 512 KiB a call with "load more".
172
+ res.set(previewHeaders('text', found.name))
173
+ res.json({ ...textWindow(bytes, Number(req.query.offset ?? 0), Number(req.query.limit ?? TEXT_WINDOW_MAX), TEXT_WINDOW_MAX), ...(found.line ? { line: found.line } : {}) })
174
+ } catch (error) {
175
+ refuse(res, 'unavailable', { ...at, detail: 'error_' + previewErrorCode(error) })
176
+ }
177
+ })
178
+
179
+ // Settings, Privacy: the named extra folders. Shown as ~/x inside home. A refusal names each folder's problem.
180
+ router.get('/preview-folders/settings', (_req, res) => {
181
+ res.set('Cache-Control', 'private, no-store')
182
+ try {
183
+ const s = folders.read()
184
+ res.json({ folders: s.folders.map(f => folderDisplay(f, deps.home?.())), source: s.source })
185
+ } catch { res.status(500).json({ error: 'preview_folders_unavailable' }) }
186
+ })
187
+ router.post('/preview-folders/settings', (req, res) => {
188
+ res.set('Cache-Control', 'private, no-store')
189
+ const body = req.body
190
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'folders') || !Array.isArray(body.folders)) {
191
+ res.status(400).json({ error: { code: 'invalid_preview_folders', message: 'Send { folders: ["~/cos-worktrees"] }.' } }); return
192
+ }
193
+ try {
194
+ const r = folders.set(body.folders)
195
+ if (!r.ok) { res.status(400).json({ error: { code: 'preview_folder_refused', message: r.errors[0]?.message ?? 'That folder cannot be used.' }, errors: r.errors }); return }
196
+ res.json({ folders: r.setting.folders.map(f => folderDisplay(f, deps.home?.())), source: r.setting.source })
197
+ } catch { res.status(500).json({ error: 'preview_folders_unavailable' }) }
198
+ })
199
+ return router
200
+ }
@@ -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, writeSessionLog } from '../lib/session-log.js'
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 lines = readFileSync(logPath, 'utf-8').trim().split('\n')
161
- for (const line of lines) {
162
- const d = JSON.parse(line)
163
- if (d.session_id && d.created_at) {
164
- const start = new Date(d.created_at).getTime()
165
- const end = d.ended_at ? new Date(d.ended_at).getTime() : start + 7200_000
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
- const logged = writeSessionLog(entry)
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,
@@ -24,13 +24,13 @@ import {
24
24
  admitToQueue, drainDecision, queueableRefusal, queuePosition,
25
25
  MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
26
26
  queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
27
- type SessionSteerAvailability,
27
+ steerMidturnEnabled, type SessionSteerAvailability,
28
28
  } from '../lib/thread-turn-queue.js'
29
29
  import { readQueue, writeQueue, queuedThreadKeys } from '../lib/thread-turn-queue-store.js'
30
30
  import type { DeliveryEvidence } from '../lib/queue-turn-evidence.js'
31
31
  import { continueLiveEnabled } from '../lib/session-peer-inbox-deps.js'
32
32
  import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
33
- import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
33
+ import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, sealMatches, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
34
34
  import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
35
35
 
36
36
  /**
@@ -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 {
@@ -115,6 +115,23 @@ export interface ThreadTurnQueueDeps {
115
115
  begin: (provider: string, threadId: string, clientTurnId: string, mode: 'turn' | 'steer', now: number) => boolean
116
116
  settle: (provider: string, threadId: string, clientTurnId: string, exit: 'sent' | 'not_sent' | 'unknown', now: number) => boolean
117
117
  }
118
+ /**
119
+ * Steer mid-turn (COS_STEER_MIDTURN), optional: ask for a drain pass for this thread now, so a
120
+ * turn parked at a busy session that can take it mid-turn goes in within seconds instead of at
121
+ * the next 20 s sweep. Production: the shared drain kick (one pass in flight, ever). Absent: the
122
+ * sweep still delivers it, only later.
123
+ */
124
+ kickDrain?: (threadId: string) => void
125
+ }
126
+
127
+ /**
128
+ * Can this thread take a parked turn mid-turn right now (COS_STEER_MIDTURN)? The same shared rule
129
+ * as Session steer, plus the switch and the two members the drain's steer needs. Never throws.
130
+ */
131
+ export function midTurnAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe' | 'deliverLiveTurn' | 'continueLive' | 'attempts'>, provider: string, threadId: string, gateReason: string | null): SessionSteerAvailability | null {
132
+ if (!steerMidturnEnabled()) return null
133
+ if (typeof deps.deliverLiveTurn !== 'function' || !deps.attempts) return { available: false, reason: 'no_outside_channel' }
134
+ return steerAvailabilityFor(deps, provider, threadId, gateReason)
118
135
  }
119
136
 
120
137
  /**
@@ -210,6 +227,159 @@ function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boole
210
227
  return queueableRefusal(outcome.reason)
211
228
  }
212
229
 
230
+ /**
231
+ * The verdict a drain hop with no parked origin is judged by: the loopback call it is. The front
232
+ * door reaches the same verdict through `originOf` on the drainer's own request (local socket,
233
+ * token shown), so a steered turn and a front-door turn are ledgered alike. Never footed.
234
+ */
235
+ function localDrainVerdict(at: number): OriginVerdict {
236
+ return {
237
+ origin: { remote: '127.0.0.1', loopback: true, ownAddress: true, clientInstance: null, client: 'local', tokenPresented: true, webSession: false },
238
+ verifiedOrigin: false,
239
+ reason: 'local_socket',
240
+ judgedAt: at,
241
+ }
242
+ }
243
+
244
+ /** What one mid-turn attempt did to its row: delivered, settled some other way, or left waiting. */
245
+ type MidTurnOutcome = 'delivered' | 'settled' | 'held'
246
+
247
+ /**
248
+ * Steer mid-turn (COS_STEER_MIDTURN, Miles 2026-11-07): put ONE waiting row into the running turn
249
+ * of a busy Claude desk session through its peer inbox. Claude Code takes the frame at once (its
250
+ * `enqueue` row is the proof `deliverLiveTurn` waits for) and folds it in at its next tool step.
251
+ *
252
+ * Called only by `drainThread`, only for a row the drain would otherwise hold behind a busy gate,
253
+ * and only in FIFO order (the caller stops at the first row that holds).
254
+ *
255
+ * WHO MAY BE STEERED: a row never attempted (`attempts === 0`: no front-door attempt, no earlier
256
+ * steer, no restart requeue, so nothing about it is unknown), with no files (the peer inbox
257
+ * carries words only), on a thread the shared steer rule says can take it now.
258
+ *
259
+ * PROVENANCE IS CHECKED HERE AS THE TURNS ROUTE CHECKS IT, because this hop does not go through
260
+ * that front door: footer-shaped words are refused, the parked origin is carried (a single-use
261
+ * carry token, spent in this process), its seal must name this provider, thread and no files,
262
+ * a turn id held for another turn is refused, and only the server writes the footer. A refusal
263
+ * settles the row `refused` with its own reason, so it reads "Not sent" with the reason line.
264
+ *
265
+ * NEVER TWICE, in the same order as `steerTurn`: the intent row (`delivering`, `mode: 'steer'`,
266
+ * `journal: 1`, this boot) durably, then the attempt `pending`, then the frame, then the
267
+ * verdict, journal first. Unverified, a failed write or a throw: `refused`, `steer_unverified`,
268
+ * `deliveryState: 'unknown'`, never resent. Nothing reached the session: back to waiting, the
269
+ * attempt given back, so the turn-end front door sends it as before.
270
+ *
271
+ * NO AWAIT BEFORE THE INTENT ROW IS WRITTEN: the caller read this row as `waiting` in the same
272
+ * synchronous step, so a cancel cannot slip between that read and the `delivering` write.
273
+ */
274
+ async function steerHeldTurn(
275
+ provider: string,
276
+ threadId: string,
277
+ turn: QueuedThreadTurn,
278
+ gateReason: string | null,
279
+ deps: ThreadTurnQueueDeps,
280
+ ): Promise<MidTurnOutcome> {
281
+ if (turn.status !== 'waiting' || turn.attempts !== 0) return 'held'
282
+ if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held'
283
+ const availability = midTurnAvailabilityFor(deps, provider, threadId, gateReason)
284
+ if (!availability || !availability.available) return 'held'
285
+ const { clientTurnId, prompt } = turn
286
+
287
+ const rewrite = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn | null => {
288
+ const latest = readQueue(provider, threadId, deps.now())
289
+ const row = latest.find(t => t.clientTurnId === clientTurnId)
290
+ if (!row) return null
291
+ apply(row)
292
+ writeQueue(provider, threadId, latest, { durable: true })
293
+ return row
294
+ }
295
+ const refuse = (reason: string): MidTurnOutcome => {
296
+ const at = deps.now()
297
+ rewrite(row => { row.status = 'refused'; row.reason = reason; row.settledAt = at })
298
+ deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
299
+ console.warn(`[thread-turn-queue] mid-turn refused provider=${provider} clientTurnId=${clientTurnId} reason=${reason}`)
300
+ return 'settled'
301
+ }
302
+
303
+ // The front door's provenance gates, in its order.
304
+ let verdict: OriginVerdict | null = null
305
+ if (deps.provenance) {
306
+ if (hasFooterShapedLine(prompt)) return refuse('footer_line_refused')
307
+ const token = deps.provenance.issueCarry({ provider, threadId, clientTurnId, prompt })
308
+ const carried = token ? deps.provenance.takeCarry(token, clientTurnId, prompt) : null
309
+ // No carry could be made: leave it for the front door, which decides with its own carry.
310
+ if (carried === null) return 'held'
311
+ if (carried.seal !== null && !sealMatches(carried.seal, { provider, threadId, attachments: '' })) return refuse('queued_turn_tampered')
312
+ verdict = carried.verdict ?? localDrainVerdict(deps.now())
313
+ if (deps.provenance.reusesTurnId(clientTurnId, { prompt, verdict, provider, sessionId: threadId }, { continues: true })) return refuse('turn_id_in_use')
314
+ }
315
+ const sent = deps.provenance && verdict ? deps.provenance.footed(prompt, clientTurnId, verdict, provider) : prompt
316
+ const ledger = (via: string): void => {
317
+ if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via, continues: true })
318
+ }
319
+
320
+ // 1. The intent row, durably.
321
+ const intent = rewrite(row => {
322
+ row.status = 'delivering'
323
+ row.attempts = 1
324
+ row.mode = 'steer'
325
+ row.journal = 1
326
+ if (typeof deps.bootId === 'string' && deps.bootId.length > 0) row.deliveringBootId = deps.bootId
327
+ else delete row.deliveringBootId
328
+ })
329
+ if (!intent) return 'held'
330
+ const backToWaiting = (row: QueuedThreadTurn): void => {
331
+ row.status = 'waiting'
332
+ row.attempts = 0
333
+ delete row.mode
334
+ delete row.journal
335
+ delete row.deliveringBootId
336
+ }
337
+
338
+ // 2. The attempt `pending`, durably, before any frame.
339
+ let begun = false
340
+ try { begun = deps.attempts!.begin(provider, threadId, clientTurnId, 'steer', deps.now()) === true } catch { begun = false }
341
+ if (!begun) {
342
+ rewrite(backToWaiting)
343
+ console.warn(`[thread-turn-queue] mid-turn not attempted provider=${provider} clientTurnId=${clientTurnId}: the attempt journal could not record it`)
344
+ return 'held'
345
+ }
346
+
347
+ // 3. The frame.
348
+ const startedAt = deps.now()
349
+ let live: { ok: boolean; reason: string } | null = null
350
+ try {
351
+ live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt: sent, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
352
+ } catch (error) {
353
+ console.error(`[thread-turn-queue] mid-turn delivery threw: ${error instanceof Error ? error.message : error}`)
354
+ live = null
355
+ }
356
+ const at = deps.now()
357
+ const journal = (exit: 'sent' | 'not_sent' | 'unknown'): void => {
358
+ try { deps.attempts!.settle(provider, threadId, clientTurnId, exit, at) } catch { /* the reconcile reads `pending` as unknown */ }
359
+ }
360
+ console.log(`[thread-turn-queue] mid-turn provider=${provider} clientTurnId=${clientTurnId} hop=${live?.reason ?? 'threw'} ms=${Math.max(0, at - startedAt)} waitedMs=${Math.max(0, at - turn.queuedAt)}`)
361
+
362
+ // 4. The verdict, journal first, then the row.
363
+ if (live?.ok === true) {
364
+ journal('sent')
365
+ ledger('drain-steer')
366
+ rewrite(row => { row.status = 'delivered'; row.reason = 'steered'; row.settledAt = at })
367
+ deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
368
+ return 'delivered'
369
+ }
370
+ if (live === null || live.reason === 'unverified' || live.reason === 'write_failed') {
371
+ journal('unknown')
372
+ ledger('drain-steer-unverified')
373
+ rewrite(row => { row.status = 'refused'; row.reason = 'steer_unverified'; row.deliveryState = 'unknown'; row.settledAt = at })
374
+ deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
375
+ return 'settled'
376
+ }
377
+ // The transport's own word that nothing reached a session: the row waits for the turn end.
378
+ journal('not_sent')
379
+ rewrite(backToWaiting)
380
+ return 'held'
381
+ }
382
+
213
383
  /**
214
384
  * One drain pass over one thread.
215
385
  *
@@ -243,9 +413,14 @@ export async function drainThread(
243
413
  if (turn.status !== 'waiting') deps.provenance?.forgetQueued(provider, threadId, turn.clientTurnId)
244
414
  }
245
415
 
416
+ // Steer mid-turn (COS_STEER_MIDTURN): FIFO per thread. Once a row ahead cannot go in mid-turn
417
+ // (or is still being delivered), nothing behind it may jump it; it waits for the turn end too.
418
+ let midTurnOrderHeld = false
419
+
246
420
  for (const snapshot of queue) {
247
421
  const turn = readQueue(provider, threadId, deps.now()).find(t => t.clientTurnId === snapshot.clientTurnId)
248
422
  if (!turn) continue
423
+ if (turn.status === 'delivering') midTurnOrderHeld = true
249
424
  if (turn.status !== 'waiting') continue
250
425
 
251
426
  // Reuse only refusal within this synchronous pass. Rechecking a busy thread
@@ -284,7 +459,21 @@ export async function drainThread(
284
459
  }
285
460
  const decision = drainDecision(turn, seen, deps.now())
286
461
 
287
- if (decision === 'hold') { held += 1; continue }
462
+ if (decision === 'hold') {
463
+ // Steer mid-turn: never during a cancel hold (the person said stop), only for the first
464
+ // rows in order, and only where the shared steer rule says this session can take it now,
465
+ // which also means the hold is the busy GATE: the rule answers only for a refusal that
466
+ // names a desk run (an attachable thread has no reason). Anything else holds as before.
467
+ const cancelHeld = typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && deps.now() < seen.cancelHoldUntil
468
+ if (!midTurnOrderHeld && !cancelHeld) {
469
+ const outcome = await steerHeldTurn(provider, threadId, turn, gate.reason, deps)
470
+ if (outcome === 'delivered') { delivered += 1; continue }
471
+ if (outcome === 'settled') { retired += 1; continue }
472
+ }
473
+ midTurnOrderHeld = true
474
+ held += 1
475
+ continue
476
+ }
288
477
  if (decision === 'expire' || decision === 'give_up') {
289
478
  turn.status = decision === 'expire' ? 'expired' : 'refused'
290
479
  turn.reason = decision === 'expire' ? 'queued_turn_expired' : 'delivery_attempts_exhausted'
@@ -690,7 +879,9 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
690
879
  // with its content hash), so the drain can carry it (QUEUED_ORIGIN_TTL_MS at most).
691
880
  if (deps.provenance && verdict) {
692
881
  const sealed = await sealFiles(attach.ids, verdict, deps.turnAttachmentStore)
693
- verdict = sealed.verdict
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 })
694
885
  deps.provenance.noteQueued(provider, threadId, clientTurnId, prompt, verdict, sealed.attachments)
695
886
  }
696
887
 
@@ -710,12 +901,27 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
710
901
 
711
902
  writeQueue(provider, threadId, admitted.queue)
712
903
  if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via: 'queue' })
904
+ // Steer mid-turn (COS_STEER_MIDTURN): the park answer stays the 202 every client reads (a
905
+ // lens that sees anything else says "could not queue"); the drain is asked to run now, and it
906
+ // puts the words into the running turn. `landsAt` (additive, only with the switch on) tells a
907
+ // client which line to show: `next_step` only when this row is first in line, has no files,
908
+ // and the session can take it now; `turn_end` otherwise.
909
+ const midTurn = midTurnAvailabilityFor(deps, provider, threadId, gate.reason)
910
+ let landsAt: 'next_step' | 'turn_end' | null = null
911
+ if (midTurn) {
912
+ const firstInLine = queuePosition(admitted.queue, clientTurnId) === 0 && !admitted.queue.some(t => t.status === 'delivering')
913
+ landsAt = midTurn.available && firstInLine && attach.ids.length === 0 ? 'next_step' : 'turn_end'
914
+ if (landsAt === 'next_step') {
915
+ try { deps.kickDrain?.(threadId) } catch { /* the 20 s sweep still delivers it */ }
916
+ }
917
+ }
713
918
  return res.status(202).json({
714
919
  queued: true,
715
920
  clientTurnId,
716
921
  position: queuePosition(admitted.queue, clientTurnId),
717
922
  waitingOn: gate.reason,
718
923
  ...(steerFallback !== null ? { steerFallback } : {}),
924
+ ...(landsAt !== null ? { landsAt } : {}),
719
925
  })
720
926
  })
721
927
 
@@ -786,8 +992,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
786
992
  if (deps.provenance && resendVerdict) {
787
993
  // After the write: the read-modify-write above stays one synchronous step. Sealed to the copy's files.
788
994
  const sealed = await sealFiles(copy.attachmentIds, resendVerdict, deps.turnAttachmentStore)
789
- deps.provenance.noteQueued(provider, threadId, newId, copy.prompt, sealed.verdict, sealed.attachments)
790
- deps.provenance.record({ turnId: newId, prompt: copy.prompt, verdict: sealed.verdict, provider, sessionId: threadId, via: 'queue-resend', resendOf: original.clientTurnId })
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 })
791
1000
  }
792
1001
  console.log(`[thread-turn-queue] resend provider=${provider} from=${original.clientTurnId} as=${newId} unknown=${original.deliveryState === 'unknown'}`)
793
1002
  return res.status(200).json(publicRow(copy, queuePosition(admitted.queue, newId)))
@@ -857,9 +1066,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
857
1066
  if (deps.provenance && verdict) {
858
1067
  // Sealed to the row's files as they are now (an edit changes only the words).
859
1068
  const sealed = await sealFiles(row.attachmentIds, verdict, deps.turnAttachmentStore)
860
- deps.provenance.noteQueued(provider, threadId, id, prompt, sealed.verdict, sealed.attachments)
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)
861
1072
  // The queue's own row, re-judged: it continues the record parked under this id.
862
- deps.provenance.record({ turnId: id, prompt, verdict: sealed.verdict, provider, sessionId: threadId, via: 'queue-edit', continues: true })
1073
+ deps.provenance.record({ turnId: id, prompt, verdict: editVerdict, provider, sessionId: threadId, via: 'queue-edit', continues: true })
863
1074
  }
864
1075
  return res.json({ updated: true })
865
1076
  })