@gotcos/glasses-server 6.34.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,150 @@
1
+ // Per-session pub/sub for the live session stream.
2
+ //
3
+ // WHY THIS IS NOT `display-bus.ts`, given the instruction to reuse the SSE machinery.
4
+ // The SSE machinery being reused is the TRANSPORT: `text/event-stream`, the header
5
+ // block, `retry:`, the close-on-`req.close` teardown. That pattern is copied from
6
+ // `routes/display.ts` verbatim and no second transport is introduced.
7
+ //
8
+ // The display BUS is a different object with two properties that are wrong here, and
9
+ // both would be regressions rather than style disagreements:
10
+ //
11
+ // 1. It is a GLOBAL BROADCAST. Every `/display-stream` subscriber, which is to say
12
+ // every connected pair of glasses, receives every event published to it. Session
13
+ // events belong to one session detail view; broadcasting them would put another
14
+ // thread's tool trail on the lens, and the plan is explicit that the stream must
15
+ // never write the lens directly.
16
+ // 2. It has a 200-EVENT SHARED REPLAY BUFFER. A tool-heavy turn emits hundreds of
17
+ // events in seconds. Pushing those through `emitDisplay` would evict the real
18
+ // display events a reconnecting client replays from, and that client would then
19
+ // be told `buffer_overflow` — a silent loss of query results caused entirely by
20
+ // a feature that only wanted to show a file being read.
21
+ //
22
+ // So: same transport, own keyspace. This module is deliberately small.
23
+ //
24
+ // NO REPLAY BUFFER HERE EITHER, and that is a decision rather than an omission. A
25
+ // reconnecting client resumes LIVE and its 5s/15s/60s poll is what fills the gap it
26
+ // missed; the contract gives it `seq` precisely so it can SEE the gap. Retaining a
27
+ // per-session ring would add memory that grows with the number of sessions ever
28
+ // opened, to duplicate a fallback that already exists and already works.
29
+
30
+ import type { SessionStreamDraft } from './session-stream-events.js'
31
+
32
+ /** A draft with the publish instant stamped. `seq` stays per connection. */
33
+ export type PublishedSessionEvent = SessionStreamDraft & { at: number }
34
+
35
+ export type SessionStreamListener = (event: PublishedSessionEvent) => void
36
+
37
+ /**
38
+ * Concurrent subscribers to ONE session.
39
+ *
40
+ * Small on purpose. The realistic count is one pair of glasses plus, briefly, a
41
+ * reconnecting duplicate of it. A ceiling means a client stuck in a reconnect loop
42
+ * costs a bounded number of file watchers rather than an unbounded one.
43
+ */
44
+ export const MAX_SUBSCRIBERS_PER_SESSION = 8
45
+
46
+ /** Sessions streamed at once. Each carries at most one poller. */
47
+ export const MAX_STREAMED_SESSIONS = 16
48
+
49
+ const listeners = new Map<string, Set<SessionStreamListener>>()
50
+
51
+ /**
52
+ * Sessions with a COS-spawned turn writing to them right now.
53
+ *
54
+ * A counter, not a boolean: it is set and cleared by the turn's own lifecycle and a
55
+ * counter cannot be left stuck true by an unbalanced pair the way a boolean can, since
56
+ * an extra clear floors at zero instead of silently disabling the gate.
57
+ */
58
+ const attachedTurns = new Map<string, number>()
59
+
60
+ export function sessionStreamKey(provider: string, sessionId: string): string {
61
+ return `${String(provider).trim().toLowerCase()}:${String(sessionId).trim().toLowerCase()}`
62
+ }
63
+
64
+ export function subscriberCount(key: string): number {
65
+ return listeners.get(key)?.size ?? 0
66
+ }
67
+
68
+ export function streamedSessionCount(): number {
69
+ return listeners.size
70
+ }
71
+
72
+ /** Null when a ceiling is reached; the caller answers 503 and the client polls. */
73
+ export function subscribeSessionStream(key: string, listener: SessionStreamListener): (() => void) | null {
74
+ const existing = listeners.get(key)
75
+ if (existing && existing.size >= MAX_SUBSCRIBERS_PER_SESSION) return null
76
+ if (!existing && listeners.size >= MAX_STREAMED_SESSIONS) return null
77
+
78
+ const set = existing ?? new Set<SessionStreamListener>()
79
+ set.add(listener)
80
+ listeners.set(key, set)
81
+
82
+ let released = false
83
+ return () => {
84
+ // Idempotent. A double release from a close handler that fires twice must not
85
+ // delete a key another subscriber still holds.
86
+ if (released) return
87
+ released = true
88
+ const current = listeners.get(key)
89
+ if (!current) return
90
+ current.delete(listener)
91
+ if (current.size === 0) listeners.delete(key)
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Fan out one draft.
97
+ *
98
+ * A throwing listener is isolated: one dead socket must not stop the others from being
99
+ * written, and must not propagate back into the provider stdout handler that called
100
+ * this. Iterating a COPY additionally means a listener that unsubscribes itself while
101
+ * being notified cannot corrupt the iteration.
102
+ */
103
+ export function publishSessionStream(key: string, draft: SessionStreamDraft, at: number = Date.now()): number {
104
+ const set = listeners.get(key)
105
+ if (!set || set.size === 0) return 0
106
+ const event: PublishedSessionEvent = { ...draft, at }
107
+ let delivered = 0
108
+ for (const listener of [...set]) {
109
+ try {
110
+ listener(event)
111
+ delivered++
112
+ } catch {
113
+ /* a broken subscriber costs itself, never the publisher or its peers */
114
+ }
115
+ }
116
+ return delivered
117
+ }
118
+
119
+ /**
120
+ * Mark a COS-spawned turn as the live writer for this session.
121
+ *
122
+ * THE DUPLICATE-SUPPRESSION GATE. A Continue turn appears TWICE: once as the stdout
123
+ * this server tees in Phase 1, and again as the transcript records the provider writes
124
+ * to the very file Phase 2 is tailing. Without a gate the reader sees every tool call
125
+ * and every reply twice.
126
+ *
127
+ * stdout wins while it exists, because it is live rather than post-hoc. The watcher
128
+ * keeps advancing its offset through the suppressed region, so when the turn ends the
129
+ * cursor is already past those records and nothing is replayed.
130
+ */
131
+ export function beginAttachedTurn(key: string): () => void {
132
+ attachedTurns.set(key, (attachedTurns.get(key) ?? 0) + 1)
133
+ let ended = false
134
+ return () => {
135
+ if (ended) return
136
+ ended = true
137
+ const next = (attachedTurns.get(key) ?? 0) - 1
138
+ if (next > 0) attachedTurns.set(key, next)
139
+ else attachedTurns.delete(key)
140
+ }
141
+ }
142
+
143
+ export function isAttachedTurnActive(key: string): boolean {
144
+ return (attachedTurns.get(key) ?? 0) > 0
145
+ }
146
+
147
+ export function __resetSessionStreamBusForTests(): void {
148
+ listeners.clear()
149
+ attachedTurns.clear()
150
+ }
@@ -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
+ }