@gotcos/glasses-server 6.99.29 → 7.0.7

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 (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -1455,12 +1455,31 @@ export async function listAgentSessions(
1455
1455
  return rows.slice(0, limit)
1456
1456
  }
1457
1457
 
1458
+ /**
1459
+ * Every Claude transcript by its lowercase id (names only, no stat). `shouldStop` lets a bounded caller end the walk
1460
+ * between folders; a stopped walk answers what it has.
1461
+ */
1462
+ export async function loadClaudeTranscriptIndex(roots: AgentSessionRoots, shouldStop: () => boolean = () => false): Promise<Map<string, string[]>> {
1463
+ const files = new Map<string, string[]>()
1464
+ for (const folder of await dirents(roots.claudeProjects)) {
1465
+ if (shouldStop()) break
1466
+ const dir = join(roots.claudeProjects, folder)
1467
+ for (const name of await dirents(dir)) {
1468
+ if (!CLAUDE_UUID_JSONL.test(name)) continue
1469
+ const id = name.slice(0, -6).toLowerCase()
1470
+ files.set(id, [...(files.get(id) || []), join(dir, name)])
1471
+ }
1472
+ }
1473
+ return files
1474
+ }
1475
+
1458
1476
  export async function findAgentSessionFile(
1459
1477
  provider: AgentProvider,
1460
1478
  sessionId: string,
1461
1479
  roots: AgentSessionRoots,
1462
1480
  now = new Date(),
1463
1481
  preloadedAliases?: ClaudeAliasMap,
1482
+ preloaded?: { claudeFiles?: ReadonlyMap<string, string[]>; codexFiles?: readonly string[] },
1464
1483
  ): Promise<string | null> {
1465
1484
  if (!isSafeSessionId(sessionId)) return null
1466
1485
  const needle = sessionId.trim().toLowerCase()
@@ -1473,15 +1492,8 @@ export async function findAgentSessionFile(
1473
1492
  // Names only. This used to lstat every transcript on this Mac (1,697 of
1474
1493
  // them) to keep directories out of the candidate set, on every call. The
1475
1494
  // isFile check now runs on the one id that matched, below.
1476
- const files = new Map<string, string[]>()
1477
- for (const folder of await dirents(roots.claudeProjects)) {
1478
- const dir = join(roots.claudeProjects, folder)
1479
- for (const name of await dirents(dir)) {
1480
- if (!CLAUDE_UUID_JSONL.test(name)) continue
1481
- const id = name.slice(0, -6).toLowerCase()
1482
- files.set(id, [...(files.get(id) || []), join(dir, name)])
1483
- }
1484
- }
1495
+ // 7.0.1: a caller resolving several ids at once (work-session-activity.ts) builds this index once and hands it in.
1496
+ const files = preloaded?.claudeFiles ?? await loadClaudeTranscriptIndex(roots)
1485
1497
  const exact = aliases.has(needle) || files.has(needle)
1486
1498
  const ids = new Set<string>()
1487
1499
  for (const [id, head] of aliases) {
@@ -1498,8 +1510,9 @@ export async function findAgentSessionFile(
1498
1510
  return matches.length === 1 ? matches[0] : null
1499
1511
  }
1500
1512
  if (provider === 'codex') {
1501
- for (const root of codexSessionRootsOf(roots)) {
1502
- for (const file of await listCodexJsonlFiles(root)) {
1513
+ const lists = preloaded?.codexFiles ? [preloaded.codexFiles] : await Promise.all(codexSessionRootsOf(roots).map(root => listCodexJsonlFiles(root)))
1514
+ for (const list of lists) {
1515
+ for (const file of list) {
1503
1516
  const name = file.split('/').pop()?.toLowerCase() ?? ''
1504
1517
  if (name.includes(needle)) return file
1505
1518
  }
@@ -0,0 +1,224 @@
1
+ // Images a session LOOKED AT: the pictures that came back inside tool results (a Read of a PNG,
2
+ // a browser or computer-use screenshot) and the pictures a person attached to a prompt.
3
+ //
4
+ // WHY. Miles, 11/08: the Claude app shows a thumbnail under a step like "Ran 2 commands, read 2
5
+ // files", labelled "Read" and "1 of 2". Control Web showed only words for that turn. 6.99.28
6
+ // covered images written into reply text and 6.99.35 covered SendUserFile; neither covered tool
7
+ // results or prompt attachments.
8
+ //
9
+ // RECORD SHAPES (read from real transcripts on this Mac, 11/08, shapes only):
10
+ // - Claude, a Read of an image: a `user` row with `toolUseResult: { type: 'image', file: { base64,
11
+ // type, originalSize, dimensions } }` and `message.content: [{ type: 'tool_result', tool_use_id,
12
+ // content: [{ type: 'image', source: { type: 'base64', media_type, data } }] }]`.
13
+ // - Claude, a screenshot (Claude_Browser / claude-in-chrome / computer-use / Claude_Preview): the
14
+ // same tool_result block, its `content` a mix of text blocks and image blocks of base64 source.
15
+ // - Claude, a prompt attachment: a plain `user` row (no toolUseResult) whose `message.content`
16
+ // holds `{ type: 'image', source: { type: 'base64', ... } }` beside the text block. A few rows
17
+ // (sent through the SDK) carry the image and no words at all.
18
+ // - Codex: `response_item` rows. A tool output is `function_call_output` or
19
+ // `custom_tool_call_output` with `output: [{ type: 'input_text' }, { type: 'input_image',
20
+ // image_url: 'data:image/png;base64,...', detail }]`; a prompt attachment is a user `message`
21
+ // whose `content` holds `input_image` blocks. The calls were `exec`, `wait` and `js`.
22
+ // - Every image seen was embedded base64 (png, jpeg, webp). None referenced a path only. The
23
+ // path-only Read is still handled, under the workspace rules output images use.
24
+ //
25
+ // This module is pure: it finds the images and names them. Ids, the workspace check and the
26
+ // bytes live in agent-session-output-images.ts, beside the other previews.
27
+ import type { AgentProvider } from './agent-session-store.js'
28
+
29
+ export type ToolImageLabel = 'Read' | 'Screenshot' | 'Image' | 'Attached'
30
+ export interface SessionToolImage {
31
+ /** Opaque, process-scoped: `ti_` and 64 hex characters. */
32
+ id: string
33
+ /** What the step was, as the Claude app labels it: "Read", "Screenshot", or "Attached" for a prompt image. */
34
+ tool: ToolImageLabel
35
+ /** Its place in the step, 1-based: "1 of 2". */
36
+ index: number
37
+ total: number
38
+ state: 'available' | 'unavailable'
39
+ /**
40
+ * `too_large`: the embedded image is over MAX_TOOL_IMAGE_BYTES. `outside_workspace` and
41
+ * `unavailable`: a path-only Read, under the output-image rules. `missing`: no bytes and no path.
42
+ * `secret_file` (7.0.4): a Read (path-only, or with its bytes embedded) of a file whose name says it may hold secrets
43
+ * (lib/secret-path-guard.ts). Embedded bytes with no path (a screenshot, a prompt attachment) have no name to judge.
44
+ */
45
+ reason?: 'outside_workspace' | 'unavailable' | 'too_large' | 'missing' | 'secret_file'
46
+ /** 7.0.4: the words the card shows instead, set only with `secret_file`. */
47
+ notice?: string
48
+ /** The step came before the turn's words (a reply that opened with tool calls). */
49
+ before?: true
50
+ }
51
+
52
+ export const SESSION_TOOL_IMAGE_ID = /^ti_[a-f0-9]{64}$/
53
+ /** Thumbnails one turn may show. The rest are counted ("+N more") and never get an id. */
54
+ export const TOOL_IMAGES_PER_TURN = 6
55
+ /** The largest decoded image served. The biggest seen on this Mac was about 510 KB. */
56
+ export const MAX_TOOL_IMAGE_BYTES = 8 * 1024 * 1024
57
+ export const TOOL_IMAGE_TYPES: readonly string[] = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
58
+ const IMAGE_PATH = /\.(png|jpe?g|webp|gif)$/i
59
+
60
+ export type ToolImageRef =
61
+ /** `path`: the file a Read named, when its bytes are embedded (7.0.4 QA: the secret guard judges that name). */
62
+ | { kind: 'embedded'; mediaType: string; data: string; path?: string }
63
+ | { kind: 'path'; path: string }
64
+ | { kind: 'missing' }
65
+ export interface ToolUseMeta { name: string; path?: string }
66
+ export interface FoundToolImage { tool: ToolImageLabel; ref: ToolImageRef }
67
+
68
+ /** The Claude app's label for a tool. Pure. */
69
+ export function toolLabel(name: string | undefined): ToolImageLabel {
70
+ if (name === 'Read') return 'Read'
71
+ if (name && /screenshot|computer|zoom|browser_batch|app_batch|snapshot/i.test(name)) return 'Screenshot'
72
+ return 'Image'
73
+ }
74
+
75
+ /** Decoded size of a base64 string, without decoding it. */
76
+ export function estimateBytes(b64: string): number {
77
+ let pad = 0
78
+ if (b64.endsWith('==')) pad = 2; else if (b64.endsWith('=')) pad = 1
79
+ return Math.max(0, Math.floor(b64.length * 3 / 4) - pad)
80
+ }
81
+
82
+ /** The image type the bytes ARE, from their magic numbers, or null. Never the declared type. */
83
+ export function sniffImageType(bytes: Buffer): string | null {
84
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
85
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
86
+ if (bytes.length >= 6 && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('latin1'))) return 'image/gif'
87
+ if (bytes.length >= 12 && bytes.subarray(0, 4).toString('latin1') === 'RIFF' && bytes.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
88
+ return null
89
+ }
90
+
91
+ /** Decode an embedded image: refused over the cap (checked before decoding) or when the bytes are not a safe image type. */
92
+ export function decodeToolImage(ref: { mediaType: string; data: string }): { status: 'ok'; bytes: Buffer; type: string } | { status: 'too_large' | 'unavailable' } {
93
+ if (estimateBytes(ref.data) > MAX_TOOL_IMAGE_BYTES) return { status: 'too_large' }
94
+ const bytes = Buffer.from(ref.data, 'base64')
95
+ if (!bytes.length || bytes.length > MAX_TOOL_IMAGE_BYTES) return { status: bytes.length ? 'too_large' : 'unavailable' }
96
+ const type = sniffImageType(bytes)
97
+ return type ? { status: 'ok', bytes, type } : { status: 'unavailable' }
98
+ }
99
+
100
+ function obj(v: unknown): Record<string, unknown> | null { return v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : null }
101
+
102
+ /** An Anthropic image block of base64 source with a safe declared type, or null. */
103
+ function base64Block(block: unknown): ToolImageRef | null {
104
+ const b = obj(block); if (!b || b.type !== 'image') return null
105
+ const s = obj(b.source)
106
+ if (!s || s.type !== 'base64' || typeof s.data !== 'string' || !s.data || typeof s.media_type !== 'string') return null
107
+ if (!TOOL_IMAGE_TYPES.includes(s.media_type)) return null
108
+ return { kind: 'embedded', mediaType: s.media_type, data: s.data }
109
+ }
110
+ /** A Codex/OpenAI `input_image` block carrying a data URL of a safe type, or null. Remote URLs stay inert. */
111
+ function dataUrlBlock(block: unknown): ToolImageRef | null {
112
+ const b = obj(block); if (!b || b.type !== 'input_image' || typeof b.image_url !== 'string') return null
113
+ const m = /^data:(image\/(?:png|jpeg|webp|gif));base64,/.exec(b.image_url.slice(0, 64))
114
+ if (!m) return null
115
+ return { kind: 'embedded', mediaType: m[1], data: b.image_url.slice(m[0].length) }
116
+ }
117
+
118
+ /** Keep the source of a Codex view_image so embedded bytes pass the same filename guard as Read. */
119
+ function codexImagePath(args: unknown): string | undefined {
120
+ if (typeof args === 'string') {
121
+ if (args.length > 16_384) return undefined
122
+ try { args = JSON.parse(args) } catch { return undefined }
123
+ }
124
+ const path = obj(args)?.path
125
+ return typeof path === 'string' && path.trim().length > 0 && path.length <= 4096 && !path.includes('\0') ? path : undefined
126
+ }
127
+
128
+ /** The tool calls a row makes, by call id: their names, and a Read's file path. Pure. */
129
+ export function toolUsesOf(provider: AgentProvider, row: Record<string, unknown>): Array<[string, ToolUseMeta]> {
130
+ if (row.isSidechain === true) return []
131
+ if (provider === 'claude') {
132
+ if (row.type !== 'assistant') return []
133
+ const content = obj(row.message)?.content
134
+ if (!Array.isArray(content)) return []
135
+ const out: Array<[string, ToolUseMeta]> = []
136
+ for (const block of content) {
137
+ const b = obj(block)
138
+ if (!b || b.type !== 'tool_use' || typeof b.id !== 'string' || typeof b.name !== 'string') continue
139
+ const input = obj(b.input)
140
+ const path = typeof input?.file_path === 'string' ? input.file_path : undefined
141
+ out.push([b.id, path ? { name: b.name, path } : { name: b.name }])
142
+ }
143
+ return out
144
+ }
145
+ if (provider === 'codex') {
146
+ if (row.type !== 'response_item') return []
147
+ const p = obj(row.payload)
148
+ if (!p || (p.type !== 'function_call' && p.type !== 'custom_tool_call') || typeof p.call_id !== 'string' || typeof p.name !== 'string') return []
149
+ const path = p.name === 'view_image' ? codexImagePath(p.type === 'function_call' ? p.arguments : p.input) : undefined
150
+ return [[p.call_id, path ? { name: p.name, path } : { name: p.name }]]
151
+ }
152
+ return []
153
+ }
154
+
155
+ /**
156
+ * The images inside a row's tool results, in order, labelled by the tool that produced them.
157
+ * A sub-agent's results went to its parent, not to the person, and stay out. Pure.
158
+ */
159
+ export function toolResultImages(provider: AgentProvider, row: Record<string, unknown>, uses: ReadonlyMap<string, ToolUseMeta>): FoundToolImage[] {
160
+ if (row.isSidechain === true) return []
161
+ const out: FoundToolImage[] = []
162
+ if (provider === 'claude') {
163
+ if (row.type !== 'user') return []
164
+ const content = obj(row.message)?.content
165
+ if (!Array.isArray(content)) return []
166
+ const results = content.filter(b => obj(b)?.type === 'tool_result')
167
+ for (const block of results) {
168
+ const b = obj(block)!
169
+ const meta = typeof b.tool_use_id === 'string' ? uses.get(b.tool_use_id) : undefined
170
+ const tool = toolLabel(meta?.name)
171
+ // 7.0.4 QA: a Read's embedded bytes keep the path it read, so `~/Desktop/credentials.png` is refused by name.
172
+ const readPath = meta?.name === 'Read' && meta.path ? meta.path : undefined
173
+ const found = Array.isArray(b.content) ? b.content.map(base64Block).filter((x): x is ToolImageRef => x !== null) : []
174
+ if (found.length) { for (const ref of found) out.push({ tool, ref: readPath && ref.kind === 'embedded' ? { ...ref, path: readPath } : ref }); continue }
175
+ // A Read of an image whose message block was not written: the row's own record of the
176
+ // result may still carry the bytes; else only the path the Read named; else nothing to show.
177
+ const result = obj(row.toolUseResult)
178
+ if (results.length !== 1 || b.is_error === true || result?.type !== 'image') continue
179
+ const file = obj(result.file)
180
+ if (file && typeof file.base64 === 'string' && file.base64 && typeof file.type === 'string' && TOOL_IMAGE_TYPES.includes(file.type)) {
181
+ out.push({ tool, ref: { kind: 'embedded', mediaType: file.type, data: file.base64, ...(readPath ? { path: readPath } : {}) } }); continue
182
+ }
183
+ if (meta?.name === 'Read' && meta.path && IMAGE_PATH.test(meta.path)) out.push({ tool, ref: { kind: 'path', path: meta.path } })
184
+ else out.push({ tool, ref: { kind: 'missing' } })
185
+ }
186
+ return out
187
+ }
188
+ if (provider === 'codex') {
189
+ if (row.type !== 'response_item') return []
190
+ const p = obj(row.payload)
191
+ if (!p || (p.type !== 'function_call_output' && p.type !== 'custom_tool_call_output') || !Array.isArray(p.output)) return []
192
+ const meta = typeof p.call_id === 'string' ? uses.get(p.call_id) : undefined
193
+ const tool = meta?.name === 'view_image' ? 'Read' : toolLabel(meta?.name)
194
+ for (const block of p.output) {
195
+ const ref = dataUrlBlock(block)
196
+ if (!ref || ref.kind !== 'embedded') continue
197
+ // A known file-read without a trustworthy source cannot bypass secret filename checks.
198
+ // Other image tools (for example exec screenshots) do not name a source file.
199
+ out.push({ tool, ref: meta?.name === 'view_image' ? meta.path ? { ...ref, path: meta.path } : { kind: 'missing' } : ref })
200
+ }
201
+ }
202
+ return out
203
+ }
204
+
205
+ /**
206
+ * The images a person attached to a prompt row, or [] (a tool result, a sub-agent, an injected
207
+ * row, an assistant row). Whether the row is a turn at all is the turn reader's call. Pure.
208
+ */
209
+ export function promptImages(provider: AgentProvider, row: Record<string, unknown>): ToolImageRef[] {
210
+ if (row.isSidechain === true || row.isMeta === true || row.isCompactSummary === true) return []
211
+ if (provider === 'claude') {
212
+ if (row.type !== 'user' || row.toolUseResult) return []
213
+ const content = obj(row.message)?.content
214
+ if (!Array.isArray(content) || content.some(b => obj(b)?.type === 'tool_result')) return []
215
+ return content.map(base64Block).filter((x): x is ToolImageRef => x !== null)
216
+ }
217
+ if (provider === 'codex') {
218
+ if (row.type !== 'response_item') return []
219
+ const p = obj(row.payload)
220
+ if (!p || p.type !== 'message' || p.role !== 'user' || !Array.isArray(p.content)) return []
221
+ return p.content.map(dataUrlBlock).filter((x): x is ToolImageRef => x !== null)
222
+ }
223
+ return []
224
+ }
@@ -33,7 +33,9 @@ import {
33
33
  payloadText,
34
34
  type AgentProvider,
35
35
  } from './agent-session-store.js'
36
- import type { SessionOutputImage } from './agent-session-output-images.js'
36
+ import type { SessionOutputFile, SessionOutputImage } from './agent-session-output-images.js'
37
+ import type { SessionToolImage } from './agent-session-tool-images.js'
38
+ import type { SessionOutputLink } from './session-links.js'
37
39
  import { unwrapPeerMessage } from './session-stream-events.js'
38
40
  import { withoutTurnFooter } from './turn-footer.js'
39
41
 
@@ -44,9 +46,23 @@ export interface SessionTurn {
44
46
  at?: string
45
47
  /** Opt-in native-session output previews. No local paths or COS message identities. */
46
48
  outputImages?: SessionOutputImage[]
49
+ /** Files a Claude session sent with SendUserFile that are not normalized images (6.99.35). Names only, no paths. */
50
+ outputFiles?: SessionOutputFile[]
51
+ /** Preview engine phase 1: local Markdown links in this reply, as cards. The text carries `[words](cos-file:N)`; base names only, no paths. */
52
+ outputLinks?: SessionOutputLink[]
53
+ /** Images the session looked at in the step around this turn (tool results), or a prompt's attached images. Opaque ids, no bytes, no paths. */
54
+ toolImages?: SessionToolImage[]
55
+ /** Images in that step past TOOL_IMAGES_PER_TURN: counted, never given an id. */
56
+ toolImagesMore?: number
57
+ /** 6.99.35: this message had images or files and "Show files and images your sessions make" is off. No names travel. */
58
+ previewsOff?: true
47
59
  }
48
60
 
49
- export type SessionTurnProjection = (provider: AgentProvider, record: Record<string, unknown>, turn: SessionTurn | null) => SessionTurn | null
61
+ export type SessionTurnProjection = {
62
+ (provider: AgentProvider, record: Record<string, unknown>, turn: SessionTurn | null): SessionTurn | null
63
+ /** A growing read reparses its window from scratch; discard state from the previous pass. */
64
+ reset?: () => void
65
+ }
50
66
 
51
67
  /** What a client may ask for in one read. Forty messages is ~10 lens pages of words. */
52
68
  export const SESSION_TURNS_MAX = 40
@@ -137,6 +153,7 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
137
153
 
138
154
  /** The newest `limit` messages among these lines (file order in, file order out). */
139
155
  export function recentSessionTurns(provider: AgentProvider, lines: readonly string[], limit: number, project?: SessionTurnProjection): SessionTurn[] {
156
+ project?.reset?.()
140
157
  const out: SessionTurn[] = []
141
158
  for (const line of lines) {
142
159
  const obj = parseJsonLine(line)
@@ -44,6 +44,9 @@ const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64
44
44
  // Minting, status and decisions stay behind the token AND a loopback socket.
45
45
  const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
46
46
 
47
+ /** `res.locals` key the gate sets to `'token'` when the X-COS-Token header matched. */
48
+ export const API_AUTH_BY_LOCAL = 'cosApiAuthBy'
49
+
47
50
  export function isPublicApiRequest(method: string, path: string): boolean {
48
51
  if (PUBLIC_API_PATHS.has(path)) return true
49
52
  if (method === 'POST' && path === '/pairing/claim') return true
@@ -76,6 +79,9 @@ export function requireApiToken(apiToken: string, options: RequireApiTokenOption
76
79
  return (req, res, next) => {
77
80
  if (isPublicApiRequest(req.method, req.path)) return next()
78
81
  if (timingSafeTokenEqual(req.headers['x-cos-token'], apiToken)) {
82
+ // 7.0.4 W1: which door let this request in, for routes that answer the Mac's own helper differently from a
83
+ // browser (lib/native-mac-caller.ts). Set only here, after the header matched; a cookie session never sets it.
84
+ res.locals[API_AUTH_BY_LOCAL] = 'token'
79
85
  if (options.onAuthenticated) {
80
86
  try { options.onAuthenticated(req) } catch { /* observer only */ }
81
87
  }
@@ -82,7 +82,9 @@ export function prepareAskWorkPlan(request: AskWorkPlanRequest, task: TaskBoardR
82
82
  const evidenceStatus = { snapshotAgeSec: evidence.snapshotAgeSec, liveListAgeSec: evidence.liveListAgeSec,
83
83
  logScanComplete: evidence.logScanComplete, fresh, usageMode: 'cached',
84
84
  scope: 'Canonical task and server session evidence only; no local attachments, local link context, or chat history. Meeting references do not include transcripts.',
85
- disabledTargets: evidence.rows.filter((_, index) => !sessions[index]!.automatic || !sessions[index]!.forkable).map(row => ({ id: row.id,
85
+ // A row is wholly disabled only when neither Continue nor Fork is eligible. The
86
+ // individual automatic/forkable fields still constrain each assignment mode.
87
+ disabledTargets: evidence.rows.filter((_, index) => !sessions[index]!.automatic && !sessions[index]!.forkable).map(row => ({ id: row.id,
86
88
  reasons: [!fresh && 'stale_or_missing_evidence', !evidence.logScanComplete && 'incomplete_session_scan',
87
89
  !row.member && 'not_card_member', !['claude', 'codex'].includes(row.id.split(':')[0]!) && 'unsupported_provider',
88
90
  !providerAllowed(row.id.split(':')[0]!) && 'provider_usage_unavailable', row.busy && 'busy', !row.live && 'not_live',