@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.
- package/CHANGELOG.md +112 -0
- package/package.json +1 -1
- package/server/index.ts +44 -15
- package/server/lib/agent-session-store.ts +196 -16
- package/server/lib/attached-provider-adapter.ts +39 -0
- package/server/lib/session-stream-bus.ts +150 -0
- package/server/lib/session-stream-events.ts +351 -0
- package/server/lib/session-stream-producer.ts +138 -0
- package/server/lib/session-transcript-watcher.ts +364 -0
- package/server/routes/agent-session-stream.ts +250 -0
- package/server/routes/agent-sessions.ts +6 -0
|
@@ -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
|
+
}
|