@gotcos/glasses-server 6.35.0 → 6.36.0

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,351 @@
1
+ // The event grammar for a live agent session.
2
+ //
3
+ // ONE grammar, TWO sources. Phase 1 feeds it the NDJSON a COS-spawned `claude -p
4
+ // --output-format stream-json` writes to stdout; Phase 2 feeds it the JSONL records
5
+ // a desktop session appends to its transcript. For Claude those are the SAME record
6
+ // shape — `{type:'assistant', message:{content:[...]}}` — which is why the two phases
7
+ // can share one mapper instead of two that drift.
8
+ //
9
+ // PURE ON PURPOSE. No clock, no filesystem, no bus, no I/O. `seq` and `at` are
10
+ // stamped by the transport, so every function here is a total function of its input
11
+ // and can be tested by execution rather than by reading the source. That is the whole
12
+ // reason the grammar is a separate module from the wiring: source-shape tests cannot
13
+ // observe a mapping being wrong, and this is the part that CAN be wrong.
14
+ //
15
+ // THE CLOSED SETS ARE THE CONTRACT. `kind`, `state` and `verb` are fixed vocabularies
16
+ // the client renders against. An unrecognised tool is NEVER a new verb: it is `other`
17
+ // carrying its real name in `target`, so a provider adding a tool tomorrow degrades to
18
+ // a readable line instead of an unrenderable one.
19
+
20
+ /** Closed set. A provider tool name that is not in the table maps to `other`. */
21
+ export type SessionStreamVerb = 'read' | 'edit' | 'write' | 'bash' | 'search' | 'task' | 'other'
22
+
23
+ /** Closed set. */
24
+ export type SessionStreamState = 'working' | 'idle' | 'done'
25
+
26
+ export type SessionStreamDraft =
27
+ | { kind: 'tool'; verb: SessionStreamVerb; target: string; detail: string }
28
+ | { kind: 'prose'; text: string }
29
+ | { kind: 'status'; state: SessionStreamState }
30
+ | { kind: 'heartbeat' }
31
+
32
+ /** A draft plus the transport's stamps. This is the JSON on the wire. */
33
+ export type SessionStreamEvent = SessionStreamDraft & { seq: number; at: number }
34
+
35
+ /** Providers whose records this grammar understands. */
36
+ export type SessionStreamProvider = 'claude' | 'codex' | 'cursor'
37
+
38
+ /**
39
+ * Prose ceiling.
40
+ *
41
+ * Matches `LATEST_REPLY_MAX` in agent-session-store.ts, which is what the polled
42
+ * detail payload already carries, so the streamed view and the polled view agree on
43
+ * how much of a reply a client ever sees. It also stops a single 587 KB transcript
44
+ * record — four of them exist in this Mac's largest transcript — from being written
45
+ * down an SSE pipe in one frame.
46
+ */
47
+ export const PROSE_MAX_CHARS = 4_000
48
+
49
+ /** A tool target is one glanceable line on a 576x288 lens, never a paragraph. */
50
+ export const TARGET_MAX_CHARS = 80
51
+
52
+ /** `+14 -2`, `120 lines`. Anything longer is not a detail. */
53
+ export const DETAIL_MAX_CHARS = 40
54
+
55
+ /**
56
+ * Marker appended when a value was cut.
57
+ *
58
+ * Three ASCII periods, not the single-character ellipsis: the G2 font has a limited
59
+ * glyph table and an unmapped character renders as tofu, which is worse than the
60
+ * truncation it is announcing.
61
+ */
62
+ export const TRUNCATION_MARK = '...'
63
+
64
+ /**
65
+ * Tool name to verb.
66
+ *
67
+ * Exact names, not prefixes or fuzzy matching. `BashOutput` is not `bash`: it is a
68
+ * different action, and collapsing it would make the HUD claim a command ran when it
69
+ * was only being read. Anything absent here is deliberately `other`.
70
+ */
71
+ const VERB_BY_TOOL: Readonly<Record<string, SessionStreamVerb>> = {
72
+ read: 'read',
73
+ notebookread: 'read',
74
+ strreplace: 'edit',
75
+ edit: 'edit',
76
+ multiedit: 'edit',
77
+ notebookedit: 'edit',
78
+ write: 'write',
79
+ bash: 'bash',
80
+ shell: 'bash',
81
+ exec: 'bash',
82
+ exec_command: 'bash',
83
+ local_shell_call: 'bash',
84
+ grep: 'search',
85
+ glob: 'search',
86
+ search: 'search',
87
+ websearch: 'search',
88
+ webfetch: 'search',
89
+ toolsearch: 'search',
90
+ task: 'task',
91
+ agent: 'task',
92
+ skill: 'task',
93
+ }
94
+
95
+ export function verbForToolName(name: unknown): SessionStreamVerb {
96
+ if (typeof name !== 'string') return 'other'
97
+ return VERB_BY_TOOL[name.trim().toLowerCase()] ?? 'other'
98
+ }
99
+
100
+ /** Collapse to one line and cap. Every client-visible string passes through here. */
101
+ export function oneLine(value: unknown, max: number): string {
102
+ if (typeof value !== 'string') return ''
103
+ const flat = value.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim()
104
+ if (flat.length <= max) return flat
105
+ return flat.slice(0, Math.max(0, max - TRUNCATION_MARK.length)) + TRUNCATION_MARK
106
+ }
107
+
108
+ /** Last path segment. A full path is unreadable on the lens and leaks the tree. */
109
+ export function basename(path: unknown): string {
110
+ if (typeof path !== 'string' || path.length === 0) return ''
111
+ const trimmed = path.replace(/\/+$/, '')
112
+ const cut = trimmed.lastIndexOf('/')
113
+ return cut < 0 ? trimmed : trimmed.slice(cut + 1)
114
+ }
115
+
116
+ function asRecord(value: unknown): Record<string, unknown> | null {
117
+ return value && typeof value === 'object' && !Array.isArray(value)
118
+ ? value as Record<string, unknown>
119
+ : null
120
+ }
121
+
122
+ function countLines(value: unknown): number {
123
+ if (typeof value !== 'string' || value.length === 0) return 0
124
+ return value.split('\n').length
125
+ }
126
+
127
+ /**
128
+ * What this tool acted ON.
129
+ *
130
+ * A basename for file tools, the command for a shell, the pattern for a search, the
131
+ * description for a delegated task. For `other` the caller substitutes the real tool
132
+ * name, because a verb of `other` with an empty target says nothing at all.
133
+ */
134
+ export function targetForTool(name: unknown, input: unknown): string {
135
+ const toolName = typeof name === 'string' ? name.trim() : ''
136
+ const args = asRecord(input)
137
+ if (!args) return ''
138
+ const lower = toolName.toLowerCase()
139
+
140
+ const filePath = args.file_path ?? args.path ?? args.notebook_path
141
+ if (typeof filePath === 'string' && filePath.length > 0) return oneLine(basename(filePath), TARGET_MAX_CHARS)
142
+
143
+ if (lower === 'bash' || lower === 'shell' || lower === 'exec' || lower === 'exec_command') {
144
+ const command = args.command ?? args.cmd
145
+ if (typeof command === 'string' && command.length > 0) return oneLine(command, TARGET_MAX_CHARS)
146
+ }
147
+
148
+ for (const key of ['pattern', 'query', 'skill', 'description', 'subject', 'prompt']) {
149
+ const value = args[key]
150
+ if (typeof value === 'string' && value.length > 0) return oneLine(value, TARGET_MAX_CHARS)
151
+ }
152
+ return ''
153
+ }
154
+
155
+ /**
156
+ * The small quantitative aside, or nothing.
157
+ *
158
+ * Only where it is derivable from the call itself. An Edit carries both strings, so
159
+ * the line delta is arithmetic rather than a guess; a Read does not carry the file, so
160
+ * it gets nothing rather than an invented number.
161
+ */
162
+ export function detailForTool(name: unknown, input: unknown): string {
163
+ const args = asRecord(input)
164
+ if (!args) return ''
165
+ const lower = typeof name === 'string' ? name.trim().toLowerCase() : ''
166
+ if (lower === 'edit' || lower === 'strreplace' || lower === 'multiedit') {
167
+ const removed = countLines(args.old_string)
168
+ const added = countLines(args.new_string)
169
+ if (removed === 0 && added === 0) return ''
170
+ return oneLine(`+${added} -${removed}`, DETAIL_MAX_CHARS)
171
+ }
172
+ if (lower === 'write') {
173
+ const lines = countLines(args.content)
174
+ return lines === 0 ? '' : oneLine(`${lines} lines`, DETAIL_MAX_CHARS)
175
+ }
176
+ return ''
177
+ }
178
+
179
+ function toolDraft(name: unknown, input: unknown): SessionStreamDraft {
180
+ const verb = verbForToolName(name)
181
+ const target = targetForTool(name, input)
182
+ const readable = typeof name === 'string' ? oneLine(name, TARGET_MAX_CHARS) : ''
183
+ return {
184
+ kind: 'tool',
185
+ verb,
186
+ // An `other` verb names the tool, because the verb no longer does. A known verb
187
+ // that could not resolve a target also falls back to the name rather than to an
188
+ // empty line the reader cannot interpret.
189
+ target: verb === 'other' || target === '' ? (readable || target) : target,
190
+ detail: detailForTool(name, input),
191
+ }
192
+ }
193
+
194
+ function proseDraft(text: unknown): SessionStreamDraft | null {
195
+ if (typeof text !== 'string') return null
196
+ const trimmed = text.trim()
197
+ if (trimmed.length === 0) return null
198
+ const capped = trimmed.length <= PROSE_MAX_CHARS
199
+ ? trimmed
200
+ : trimmed.slice(0, PROSE_MAX_CHARS - TRUNCATION_MARK.length) + TRUNCATION_MARK
201
+ return { kind: 'prose', text: capped }
202
+ }
203
+
204
+ /**
205
+ * Anthropic-shaped content blocks, used by BOTH Claude and Cursor.
206
+ *
207
+ * Cursor writes `{role:'assistant', message:{content:[...]}}` with no top-level
208
+ * `type`; Claude writes `{type:'assistant', message:{content:[...]}}`. The blocks
209
+ * inside are identical, so they share this.
210
+ *
211
+ * `thinking` blocks are dropped. They are the model's private reasoning, they are
212
+ * long, and putting them on a six-line lens buries the tool trail the reader is
213
+ * actually following.
214
+ */
215
+ function draftsFromContentBlocks(message: Record<string, unknown>): SessionStreamDraft[] {
216
+ const content = message.content
217
+ if (typeof content === 'string') {
218
+ const prose = proseDraft(content)
219
+ return prose ? [prose] : []
220
+ }
221
+ if (!Array.isArray(content)) return []
222
+ const out: SessionStreamDraft[] = []
223
+ for (const raw of content) {
224
+ const block = asRecord(raw)
225
+ if (!block) continue
226
+ if (block.type === 'text') {
227
+ const prose = proseDraft(block.text)
228
+ if (prose) out.push(prose)
229
+ } else if (block.type === 'tool_use') {
230
+ out.push(toolDraft(block.name, block.input))
231
+ }
232
+ }
233
+ return out
234
+ }
235
+
236
+ function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamDraft[] {
237
+ const type = typeof record.type === 'string' ? record.type : ''
238
+
239
+ // The stream-json envelope's own lifecycle rows. `result` is the last line of a
240
+ // `claude -p` run and is the only place the turn's END is stated outright.
241
+ if (type === 'system' && record.subtype === 'init') return [{ kind: 'status', state: 'working' }]
242
+ if (type === 'result') return [{ kind: 'status', state: 'done' }]
243
+
244
+ // A user row is a tool RESULT or the prompt we just sent. Neither is news: the tool
245
+ // call was already announced, and the prompt came from this device.
246
+ if (type === 'user') return []
247
+
248
+ const role = typeof record.role === 'string' ? record.role : ''
249
+ if (type !== 'assistant' && role !== 'assistant') return []
250
+ const message = asRecord(record.message)
251
+ if (!message) return []
252
+ return draftsFromContentBlocks(message)
253
+ }
254
+
255
+ /**
256
+ * Codex, with ONE CHANNEL PER KIND, which is the point.
257
+ *
258
+ * Codex writes the same assistant text twice, as `event_msg/agent_message` AND as
259
+ * `response_item/message` with `role:'assistant'` (measured on this Mac: 6 of each in
260
+ * one rollout, plus 8 `response_item/agent_message`). Mapping both would double every
261
+ * reply on the lens. So prose comes from the event channel only and tools from the
262
+ * response-item channel only, and the duplication is unrepresentable rather than
263
+ * deduplicated after the fact.
264
+ *
265
+ * The honest cost: if a Codex build stops emitting `event_msg/agent_message`, prose
266
+ * goes quiet and the poll fallback carries the text. Quiet is the safe direction;
267
+ * doubled text is not.
268
+ *
269
+ * `codex exec --json` has also historically wrapped events as `{id, msg:{type,...}}`
270
+ * rather than `{type:'event_msg', payload:{...}}`. Both are accepted.
271
+ */
272
+ function draftsFromCodexRecord(record: Record<string, unknown>): SessionStreamDraft[] {
273
+ const msg = asRecord(record.msg)
274
+ if (msg && typeof msg.type === 'string') return draftsFromCodexEvent(msg)
275
+
276
+ const type = typeof record.type === 'string' ? record.type : ''
277
+ const payload = asRecord(record.payload)
278
+ if (!payload) return []
279
+ if (type === 'event_msg') return draftsFromCodexEvent(payload)
280
+ if (type !== 'response_item') return []
281
+
282
+ const kind = typeof payload.type === 'string' ? payload.type : ''
283
+ if (kind === 'function_call' || kind === 'custom_tool_call' || kind === 'local_shell_call') {
284
+ // `arguments` is a JSON STRING on function_call; `input` is a raw string on
285
+ // custom_tool_call. Only the parseable one can yield a structured target.
286
+ let input: unknown = payload.input
287
+ if (typeof payload.arguments === 'string') {
288
+ try {
289
+ input = JSON.parse(payload.arguments)
290
+ } catch {
291
+ input = { command: payload.arguments }
292
+ }
293
+ } else if (typeof input === 'string') {
294
+ input = { command: input }
295
+ }
296
+ return [toolDraft(payload.name, input)]
297
+ }
298
+ return []
299
+ }
300
+
301
+ function draftsFromCodexEvent(payload: Record<string, unknown>): SessionStreamDraft[] {
302
+ const kind = typeof payload.type === 'string' ? payload.type : ''
303
+ if (kind === 'task_started') return [{ kind: 'status', state: 'working' }]
304
+ if (kind === 'task_complete' || kind === 'turn_complete') return [{ kind: 'status', state: 'done' }]
305
+ if (kind === 'agent_message') {
306
+ const prose = proseDraft(payload.message ?? payload.text)
307
+ return prose ? [prose] : []
308
+ }
309
+ return []
310
+ }
311
+
312
+ /**
313
+ * One parsed provider record to zero or more events.
314
+ *
315
+ * Zero is a normal answer and the common one: token counts, reasoning, world state,
316
+ * tool results, mode rows and attachments all map to nothing. A record this grammar
317
+ * does not recognise is silently dropped rather than rendered as a mystery line.
318
+ */
319
+ export function draftsFromRecord(
320
+ provider: SessionStreamProvider,
321
+ record: unknown,
322
+ ): SessionStreamDraft[] {
323
+ const obj = asRecord(record)
324
+ if (!obj) return []
325
+ try {
326
+ if (provider === 'codex') return draftsFromCodexRecord(obj)
327
+ // Cursor shares Claude's content-block shape, keyed off `role` instead of `type`.
328
+ return draftsFromClaudeRecord(obj)
329
+ } catch {
330
+ // A malformed record costs one line of the trail. It must never cost the stream.
331
+ return []
332
+ }
333
+ }
334
+
335
+ /** One raw NDJSON line to events. Garbage in yields an empty array, never a throw. */
336
+ export function draftsFromLine(provider: SessionStreamProvider, line: string): SessionStreamDraft[] {
337
+ const trimmed = typeof line === 'string' ? line.trim() : ''
338
+ if (trimmed.length === 0 || trimmed[0] !== '{') return []
339
+ let parsed: unknown
340
+ try {
341
+ parsed = JSON.parse(trimmed)
342
+ } catch {
343
+ return []
344
+ }
345
+ return draftsFromRecord(provider, parsed)
346
+ }
347
+
348
+ /** Draft plus transport stamps, in the field order the contract shows. */
349
+ export function stampSessionEvent(draft: SessionStreamDraft, seq: number, at: number): SessionStreamEvent {
350
+ return { seq, at, ...draft }
351
+ }
@@ -0,0 +1,138 @@
1
+ // Phase 1: a Continue turn's own stdout, teed onto the session stream.
2
+ //
3
+ // `buildClaudeAttachedArgs` already spawns with `--output-format stream-json
4
+ // --verbose`, and `attached-provider-adapter.ts` already holds that stdout. It reads
5
+ // it for one purpose — proving the returned session id matches the target — and
6
+ // discards everything else. This module is the second reader.
7
+ //
8
+ // THE ID SCAN IS UNTOUCHABLE. It is what aborts a turn whose child cannot be
9
+ // identified, and it is mutation-tested. So the tee is wired as a SEPARATE `data`
10
+ // listener, registered AFTER the scanner, holding no state the scanner can see, and
11
+ // wrapped so it cannot throw into `emit()`. The scanner therefore runs first on every
12
+ // chunk and its behaviour is unchanged whether or not this module exists. When no
13
+ // observer is supplied the adapter registers no second listener at all, which is the
14
+ // path every existing test already exercises.
15
+ //
16
+ // This module owns line assembly and publication. It does NOT own the grammar (that
17
+ // is `session-stream-events.ts`, pure) or the transport (that is the SSE route).
18
+
19
+ import {
20
+ draftsFromLine,
21
+ type SessionStreamDraft,
22
+ type SessionStreamProvider,
23
+ } from './session-stream-events.js'
24
+ import { beginAttachedTurn, publishSessionStream, sessionStreamKey } from './session-stream-bus.js'
25
+
26
+ /**
27
+ * Ceiling on the incomplete trailing line held between chunks.
28
+ *
29
+ * A provider emitting one enormous line must not grow this without bound. Mirrors the
30
+ * 1 MB carry cap the adapter's own scanner already applies to the same stream, so the
31
+ * two readers cannot disagree about what is pathological.
32
+ */
33
+ export const MAX_LINE_CARRY_CHARS = 1_000_000
34
+
35
+ export interface LineAssembler {
36
+ /** Complete lines contained in everything pushed so far. */
37
+ push(chunk: string): string[]
38
+ /** The trailing partial line, if a caller wants it at end of stream. */
39
+ flush(): string[]
40
+ }
41
+
42
+ /**
43
+ * Split a byte stream into lines across chunk boundaries.
44
+ *
45
+ * Separate from the adapter's identical-looking logic on purpose: sharing it would
46
+ * mean the tee and the id scanner touch one piece of mutable state, which is exactly
47
+ * the coupling that would let a tee bug reach the scan.
48
+ */
49
+ export function createLineAssembler(maxCarry: number = MAX_LINE_CARRY_CHARS): LineAssembler {
50
+ let carry = ''
51
+ return {
52
+ push(chunk: string): string[] {
53
+ if (typeof chunk !== 'string' || chunk.length === 0) return []
54
+ carry += chunk
55
+ const parts = carry.split('\n')
56
+ carry = parts.pop() ?? ''
57
+ if (carry.length > maxCarry) carry = ''
58
+ return parts
59
+ },
60
+ flush(): string[] {
61
+ const rest = carry
62
+ carry = ''
63
+ return rest.trim().length > 0 ? [rest] : []
64
+ },
65
+ }
66
+ }
67
+
68
+ export interface AttachedTurnStream {
69
+ /** Wire this as the adapter's `observeStdout`. Never throws. */
70
+ observeStdout(chunk: string): void
71
+ /** Call exactly once when the turn settles, whatever its outcome. Never throws. */
72
+ finish(outcome: 'done' | 'idle'): void
73
+ }
74
+
75
+ export interface AttachedTurnStreamOptions {
76
+ provider: SessionStreamProvider
77
+ sessionId: string
78
+ /** Injected for tests. Production uses the module bus. */
79
+ publish?: (key: string, draft: SessionStreamDraft) => void
80
+ }
81
+
82
+ /**
83
+ * Open a stream for one attached turn.
84
+ *
85
+ * Publishes `status: working` immediately, so a client that subscribes mid-turn is not
86
+ * left guessing, then one event per grammar-recognised stdout record, then exactly one
87
+ * terminal `status` on `finish`.
88
+ *
89
+ * `finish` is idempotent and MUST be called on every exit path including failure. It
90
+ * is what lifts the duplicate-suppression gate; leaving it held would make the session
91
+ * permanently silent for Phase 2, which is a far worse failure than a duplicated line.
92
+ */
93
+ export function createAttachedTurnStream(options: AttachedTurnStreamOptions): AttachedTurnStream {
94
+ const key = sessionStreamKey(options.provider, options.sessionId)
95
+ const publish = options.publish ?? ((k, draft) => { publishSessionStream(k, draft) })
96
+ const assembler = createLineAssembler()
97
+ const endTurn = beginAttachedTurn(key)
98
+ let finished = false
99
+
100
+ const emit = (draft: SessionStreamDraft) => {
101
+ try {
102
+ publish(key, draft)
103
+ } catch {
104
+ /* publication is observation; it never affects the turn that produced it */
105
+ }
106
+ }
107
+
108
+ emit({ kind: 'status', state: 'working' })
109
+
110
+ return {
111
+ observeStdout(chunk: string): void {
112
+ try {
113
+ for (const line of assembler.push(chunk)) {
114
+ for (const draft of draftsFromLine(options.provider, line)) emit(draft)
115
+ }
116
+ } catch {
117
+ /* a malformed chunk costs its own events and nothing else */
118
+ }
119
+ },
120
+ finish(outcome: 'done' | 'idle'): void {
121
+ if (finished) return
122
+ finished = true
123
+ try {
124
+ for (const line of assembler.flush()) {
125
+ for (const draft of draftsFromLine(options.provider, line)) emit(draft)
126
+ }
127
+ } catch {
128
+ /* fall through: the terminal status and the gate release matter more */
129
+ }
130
+ emit({ kind: 'status', state: outcome })
131
+ try {
132
+ endTurn()
133
+ } catch {
134
+ /* the gate is a Map delete; there is no failure mode to report */
135
+ }
136
+ },
137
+ }
138
+ }