@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,364 @@
|
|
|
1
|
+
// Phase 2: row-level push for a session COS did not spawn.
|
|
2
|
+
//
|
|
3
|
+
// There is no pipe. COS never started that desktop process, so the ONLY observable is
|
|
4
|
+
// the transcript file it appends to. This tails that file and emits each new JSONL
|
|
5
|
+
// record through the same grammar and the same envelope Phase 1 uses.
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS CAN AND CANNOT DO, stated plainly because the UI must not pretend
|
|
8
|
+
// otherwise: Claude Code writes a COMPLETE record per message, so a long reply appears
|
|
9
|
+
// ALL AT ONCE when it finishes. This is row-level push, not token streaming, and the
|
|
10
|
+
// asymmetry with a Continue turn cannot be engineered away from a file.
|
|
11
|
+
//
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// WHY A STAT POLL AND NOT `fs.watch`
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// `fs.watch` was considered and rejected. On macOS it coalesces rapid writes, so a
|
|
16
|
+
// burst of records can arrive as one event or, during a rename or an atomic replace,
|
|
17
|
+
// as none: the kFSEvents backend keeps watching the old inode and simply stops firing,
|
|
18
|
+
// with no error and no callback. A watcher that goes silent is indistinguishable from
|
|
19
|
+
// a session that went quiet, which is the exact failure class this repo has been
|
|
20
|
+
// burned by repeatedly -- absence of a signal read as absence of activity.
|
|
21
|
+
//
|
|
22
|
+
// A `stat` every second is one syscall per second per OPEN session detail view. It
|
|
23
|
+
// cannot miss a write, because it does not observe writes at all: it observes SIZE,
|
|
24
|
+
// and size is cumulative. If three records land between two ticks, the next read
|
|
25
|
+
// returns all three. Slower to first byte by up to a second, and incapable of the
|
|
26
|
+
// silent-death mode. That trade is correct here.
|
|
27
|
+
//
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// OFFSET, AND THE 587 KB RECORD
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Reading forward from a byte offset, never a tail window. The tail window in
|
|
32
|
+
// `agent-session-store.ts` exists for a one-shot digest of a huge file and it has a
|
|
33
|
+
// measured hazard: a single JSONL record can exceed 768 KiB. This Mac's own largest
|
|
34
|
+
// transcript holds four such records, the biggest 1,239,046 bytes. A tail read that
|
|
35
|
+
// opens mid-record drops it whole and silently.
|
|
36
|
+
//
|
|
37
|
+
// Forward reading has no window, so record size is simply not a variable: a record of
|
|
38
|
+
// any length up to `MAX_READ_BYTES` is delivered intact the moment it is complete. The
|
|
39
|
+
// only bound is per TICK, not per record, so a burst larger than the cap drains across
|
|
40
|
+
// several ticks instead of allocating it all at once. Re-reading the 81 MB file per
|
|
41
|
+
// change, the thing the plan forbids, never happens: the cursor only ever reads the
|
|
42
|
+
// bytes appended since the last tick.
|
|
43
|
+
//
|
|
44
|
+
// The pathological case is explicit rather than silent, and it does NOT try to be clever.
|
|
45
|
+
// A record LARGER than `MAX_READ_BYTES` cannot be assembled without unbounded memory, so
|
|
46
|
+
// the tailer emits a terminal `done` and DEGRADES TO THE POLL, which has no such bound.
|
|
47
|
+
// `MAX_READ_BYTES` is 4 MiB, 3.2x the largest record measured on this machine, so this is
|
|
48
|
+
// a safety valve rather than an expected path.
|
|
49
|
+
//
|
|
50
|
+
// Skipping the record instead was tried first and was wrong twice over. It wedged: after
|
|
51
|
+
// discarding the oversized bytes the leftover in the same chunk still looked capped, so
|
|
52
|
+
// the GOOD record behind it was skipped too, on every tick, forever -- zero events, not
|
|
53
|
+
// even a status. And even working it would have silently dropped a reply the user was
|
|
54
|
+
// waiting to read. Arriving 5 seconds later through the poll beats never arriving.
|
|
55
|
+
|
|
56
|
+
import { constants as fsConstants } from 'node:fs'
|
|
57
|
+
import { open, stat } from 'node:fs/promises'
|
|
58
|
+
import { draftsFromLine, type SessionStreamProvider } from './session-stream-events.js'
|
|
59
|
+
import { isAttachedTurnActive, publishSessionStream, type PublishedSessionEvent } from './session-stream-bus.js'
|
|
60
|
+
import type { SessionStreamDraft } from './session-stream-events.js'
|
|
61
|
+
|
|
62
|
+
/** Bytes read per tick. See the header for why this is a per-tick and not a per-record bound. */
|
|
63
|
+
export const MAX_READ_BYTES = 4 * 1024 * 1024
|
|
64
|
+
|
|
65
|
+
/** Stat cadence. Deliberately unremarkable; see the header. */
|
|
66
|
+
export const POLL_INTERVAL_MS = 1_000
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Quiet before a tailing session is called idle.
|
|
70
|
+
*
|
|
71
|
+
* Longer than a poll interval by a wide margin, because a model thinking between tool
|
|
72
|
+
* calls writes nothing for tens of seconds and is not idle. This flag exists so a
|
|
73
|
+
* client can tell a live-but-quiet stream from a finished one; it must not flicker.
|
|
74
|
+
*/
|
|
75
|
+
export const IDLE_AFTER_MS = 45_000
|
|
76
|
+
|
|
77
|
+
/** Cursor position in the file. Forward-only; see the header. */
|
|
78
|
+
export interface TranscriptCursor {
|
|
79
|
+
offset: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ConsumeResult {
|
|
83
|
+
cursor: TranscriptCursor
|
|
84
|
+
lines: string[]
|
|
85
|
+
/**
|
|
86
|
+
* One record exceeded a whole tick's budget, so this stream cannot carry it.
|
|
87
|
+
* The caller must stop and let the poll take over. See the header.
|
|
88
|
+
*/
|
|
89
|
+
tooLarge?: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Advance the cursor over one freshly read chunk.
|
|
94
|
+
*
|
|
95
|
+
* PURE. No fs, no clock. Everything subtle about the offset strategy lives here so it
|
|
96
|
+
* can be tested by execution rather than by reading it.
|
|
97
|
+
*
|
|
98
|
+
* `capped` means the read filled `MAX_READ_BYTES` and more bytes are pending, which is
|
|
99
|
+
* the ONLY condition under which "no newline in this chunk" proves an oversized record
|
|
100
|
+
* rather than a record still being written.
|
|
101
|
+
*/
|
|
102
|
+
export function consumeTranscriptChunk(
|
|
103
|
+
cursor: TranscriptCursor,
|
|
104
|
+
chunk: Buffer,
|
|
105
|
+
capped: boolean,
|
|
106
|
+
): ConsumeResult {
|
|
107
|
+
const last = chunk.lastIndexOf(0x0a)
|
|
108
|
+
|
|
109
|
+
if (last < 0) {
|
|
110
|
+
// Nothing completed in this chunk. Leave the cursor where it is so the partial
|
|
111
|
+
// record is re-read next tick; that is what makes a 587 KB record arrive whole.
|
|
112
|
+
// If the read was FULL, though, re-reading it will never terminate: hand it off.
|
|
113
|
+
return capped
|
|
114
|
+
? { cursor, lines: [], tooLarge: true }
|
|
115
|
+
: { cursor, lines: [] }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lines = chunk.subarray(0, last).toString('utf8').split('\n').filter(line => line.length > 0)
|
|
119
|
+
return { cursor: { offset: cursor.offset + last + 1 }, lines }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface TranscriptTailerOptions {
|
|
123
|
+
key: string
|
|
124
|
+
path: string
|
|
125
|
+
provider: SessionStreamProvider
|
|
126
|
+
/** Start position. Production passes the file's current size: no history replay. */
|
|
127
|
+
offset: number
|
|
128
|
+
now?: () => number
|
|
129
|
+
publish?: (key: string, draft: SessionStreamDraft) => void
|
|
130
|
+
/** True while a COS-spawned turn is the live writer. Suppresses emission, not advance. */
|
|
131
|
+
suppressed?: (key: string) => boolean
|
|
132
|
+
maxReadBytes?: number
|
|
133
|
+
idleAfterMs?: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface TranscriptTailer {
|
|
137
|
+
/** One stat-and-read pass. Resolves; never rejects. */
|
|
138
|
+
tick(): Promise<void>
|
|
139
|
+
cursor(): TranscriptCursor
|
|
140
|
+
/** Last state this tailer published, or null if it has published none. */
|
|
141
|
+
state(): 'working' | 'idle' | 'done' | null
|
|
142
|
+
/** True once this tailer has handed off to the poll. Terminal; never clears. */
|
|
143
|
+
degraded(): boolean
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read a byte range with the flags this repo requires everywhere.
|
|
148
|
+
*
|
|
149
|
+
* `O_NONBLOCK` because an `open` on a FIFO with no writer never returns, and a planted
|
|
150
|
+
* path has wedged this entire server three times. `O_NOFOLLOW` because a symlinked
|
|
151
|
+
* `<id>.jsonl` could point at any file on disk and be streamed as session records.
|
|
152
|
+
* `fsPromises.open` also keeps the syscall off the event loop thread, unlike the
|
|
153
|
+
* `openSync` sites those incidents involved.
|
|
154
|
+
*/
|
|
155
|
+
async function readRangeAt(path: string, offset: number, length: number): Promise<Buffer | null> {
|
|
156
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0
|
|
157
|
+
const nonBlock = typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0
|
|
158
|
+
let handle: Awaited<ReturnType<typeof open>> | null = null
|
|
159
|
+
try {
|
|
160
|
+
handle = await open(path, fsConstants.O_RDONLY | noFollow | nonBlock)
|
|
161
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
162
|
+
const { bytesRead } = await handle.read(buffer, 0, length, offset)
|
|
163
|
+
return buffer.subarray(0, bytesRead)
|
|
164
|
+
} catch {
|
|
165
|
+
// Deleted, replaced by a symlink, permissions changed mid-session. The next tick
|
|
166
|
+
// tries again; a read failure is never reported as session inactivity.
|
|
167
|
+
return null
|
|
168
|
+
} finally {
|
|
169
|
+
// Opened per tick rather than held: a transcript replaced by a new inode would
|
|
170
|
+
// otherwise be tailed forever at the wrong file, with no error.
|
|
171
|
+
await handle?.close().catch(() => {})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function createTranscriptTailer(options: TranscriptTailerOptions): TranscriptTailer {
|
|
176
|
+
const now = options.now ?? (() => Date.now())
|
|
177
|
+
const publish = options.publish ?? ((key, draft) => { publishSessionStream(key, draft) })
|
|
178
|
+
const suppressed = options.suppressed ?? isAttachedTurnActive
|
|
179
|
+
const maxRead = options.maxReadBytes ?? MAX_READ_BYTES
|
|
180
|
+
const idleAfter = options.idleAfterMs ?? IDLE_AFTER_MS
|
|
181
|
+
|
|
182
|
+
let cursor: TranscriptCursor = { offset: Math.max(0, options.offset) }
|
|
183
|
+
let state: 'working' | 'idle' | 'done' | null = null
|
|
184
|
+
let lastActivity = now()
|
|
185
|
+
let degraded = false
|
|
186
|
+
|
|
187
|
+
const emit = (draft: SessionStreamDraft) => {
|
|
188
|
+
try {
|
|
189
|
+
publish(options.key, draft)
|
|
190
|
+
} catch {
|
|
191
|
+
/* a failed publish must not stop the cursor from advancing */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
cursor: () => ({ ...cursor }),
|
|
197
|
+
state: () => state,
|
|
198
|
+
degraded: () => degraded,
|
|
199
|
+
async tick(): Promise<void> {
|
|
200
|
+
// Terminal. Once the poll owns this session, continuing to stat and re-read a 4 MiB
|
|
201
|
+
// range every second would burn syscalls to produce nothing, forever.
|
|
202
|
+
if (degraded) return
|
|
203
|
+
try {
|
|
204
|
+
let size: number
|
|
205
|
+
try {
|
|
206
|
+
const st = await stat(options.path)
|
|
207
|
+
if (!st.isFile()) return
|
|
208
|
+
size = st.size
|
|
209
|
+
} catch {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (size < cursor.offset) {
|
|
214
|
+
// Truncated or replaced. Re-reading an 81 MB file from zero is the one thing
|
|
215
|
+
// this design exists to avoid, so we rejoin at the new end and let the poll
|
|
216
|
+
// fallback carry whatever the rotation took with it.
|
|
217
|
+
cursor = { offset: size }
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (size > cursor.offset) {
|
|
222
|
+
const pending = size - cursor.offset
|
|
223
|
+
const length = Math.min(pending, maxRead)
|
|
224
|
+
const chunk = await readRangeAt(options.path, cursor.offset, length)
|
|
225
|
+
if (chunk !== null && chunk.length > 0) {
|
|
226
|
+
const result = consumeTranscriptChunk(cursor, chunk, chunk.length >= maxRead)
|
|
227
|
+
cursor = result.cursor
|
|
228
|
+
if (result.tooLarge) {
|
|
229
|
+
// HAND OFF, do not skip. `done` is the same terminal status a finished turn
|
|
230
|
+
// sends, so the client already knows to close the stream and resume its
|
|
231
|
+
// 5s/15s/60s poll -- no new client vocabulary, no frozen screen.
|
|
232
|
+
//
|
|
233
|
+
// Self-healing: the watcher is torn down when the last subscriber releases,
|
|
234
|
+
// and a fresh one starts at the file's CURRENT size, which is already past
|
|
235
|
+
// this record. A reconnect therefore streams normally again.
|
|
236
|
+
degraded = true
|
|
237
|
+
state = 'done'
|
|
238
|
+
emit({ kind: 'status', state: 'done' })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (result.lines.length > 0) {
|
|
242
|
+
lastActivity = now()
|
|
243
|
+
// SUPPRESSED, NOT SKIPPED. A COS-spawned turn is already streaming these
|
|
244
|
+
// same records from stdout; emitting them again would double every line.
|
|
245
|
+
// The cursor still advanced above, so when the turn ends the tailer is
|
|
246
|
+
// already past them and replays nothing.
|
|
247
|
+
if (!suppressed(options.key)) {
|
|
248
|
+
if (state !== 'working') {
|
|
249
|
+
state = 'working'
|
|
250
|
+
emit({ kind: 'status', state: 'working' })
|
|
251
|
+
}
|
|
252
|
+
for (const line of result.lines) {
|
|
253
|
+
for (const draft of draftsFromLine(options.provider, line)) emit(draft)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (state === 'working' && now() - lastActivity >= idleAfter) {
|
|
261
|
+
state = 'idle'
|
|
262
|
+
emit({ kind: 'status', state: 'idle' })
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
/* a tick that throws is a tick that produced nothing; the next one retries */
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Ref-counted lifetime
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
interface WatcherEntry {
|
|
276
|
+
refs: number
|
|
277
|
+
timer: ReturnType<typeof setInterval>
|
|
278
|
+
ticking: boolean
|
|
279
|
+
tailer: TranscriptTailer
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const watchers = new Map<string, WatcherEntry>()
|
|
283
|
+
|
|
284
|
+
export interface AcquireWatcherOptions {
|
|
285
|
+
key: string
|
|
286
|
+
path: string
|
|
287
|
+
provider: SessionStreamProvider
|
|
288
|
+
/** Where to start. Production passes the file's current size. */
|
|
289
|
+
offset: number
|
|
290
|
+
intervalMs?: number
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Start tailing, or join a tail already running for this session.
|
|
295
|
+
*
|
|
296
|
+
* ONE WATCHER PER SESSION, ref-counted, torn down on the LAST release. Two glasses on
|
|
297
|
+
* the same session must not mean two pollers on an 81 MB file, and a release that
|
|
298
|
+
* fires twice must not tear down a tail another subscriber still holds.
|
|
299
|
+
*/
|
|
300
|
+
export function acquireTranscriptWatcher(options: AcquireWatcherOptions): () => void {
|
|
301
|
+
const existing = watchers.get(options.key)
|
|
302
|
+
if (existing) {
|
|
303
|
+
existing.refs++
|
|
304
|
+
return releaseOnce(options.key)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const tailer = createTranscriptTailer(options)
|
|
308
|
+
const timer = setInterval(() => {
|
|
309
|
+
const entry = watchers.get(options.key)
|
|
310
|
+
if (!entry) return
|
|
311
|
+
// A tick still in flight when the next one fires would read the same range twice
|
|
312
|
+
// and emit every record twice. Skipping is correct: size is cumulative, so the
|
|
313
|
+
// next tick sees everything the skipped one would have.
|
|
314
|
+
if (entry.ticking) return
|
|
315
|
+
entry.ticking = true
|
|
316
|
+
void tailer.tick().finally(() => { entry.ticking = false })
|
|
317
|
+
}, options.intervalMs ?? POLL_INTERVAL_MS)
|
|
318
|
+
// Never hold the process open. A tail is a view, not work.
|
|
319
|
+
if (typeof (timer as any).unref === 'function') (timer as any).unref()
|
|
320
|
+
|
|
321
|
+
watchers.set(options.key, { refs: 1, timer, ticking: false, tailer })
|
|
322
|
+
return releaseOnce(options.key)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function releaseOnce(key: string): () => void {
|
|
326
|
+
let released = false
|
|
327
|
+
return () => {
|
|
328
|
+
if (released) return
|
|
329
|
+
released = true
|
|
330
|
+
const entry = watchers.get(key)
|
|
331
|
+
if (!entry) return
|
|
332
|
+
entry.refs--
|
|
333
|
+
if (entry.refs > 0) return
|
|
334
|
+
clearInterval(entry.timer)
|
|
335
|
+
watchers.delete(key)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function watchedTranscriptCount(): number {
|
|
340
|
+
return watchers.size
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Has this session's tail handed off to the poll?
|
|
345
|
+
*
|
|
346
|
+
* The SSE route asks this so it can END the response instead of holding a socket that
|
|
347
|
+
* heartbeats forever and will never carry another record. Without that, the client's
|
|
348
|
+
* liveness check keeps reading `live` off the heartbeats and the footer keeps saying
|
|
349
|
+
* `stream` while the poll is quietly doing all the work -- which is precisely the
|
|
350
|
+
* "absence of a signal read as health" failure this repo keeps paying for.
|
|
351
|
+
*
|
|
352
|
+
* False for a session with no watcher, which includes every COS-spawned turn: those
|
|
353
|
+
* stream from a pipe and never degrade this way.
|
|
354
|
+
*/
|
|
355
|
+
export function transcriptWatcherDegraded(key: string): boolean {
|
|
356
|
+
return watchers.get(key)?.tailer.degraded() ?? false
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function __resetTranscriptWatchersForTests(): void {
|
|
360
|
+
for (const entry of watchers.values()) clearInterval(entry.timer)
|
|
361
|
+
watchers.clear()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export type { PublishedSessionEvent }
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// GET /api/agent-sessions/:provider/:sessionId/stream
|
|
2
|
+
//
|
|
3
|
+
// Server-sent events for ONE agent session. Each `data:` line is one JSON object:
|
|
4
|
+
//
|
|
5
|
+
// {"seq":1,"at":1786890000000,"kind":"tool","verb":"read","target":"x.ts","detail":""}
|
|
6
|
+
// {"seq":2,"at":1786890001000,"kind":"prose","text":"..."}
|
|
7
|
+
// {"seq":3,"at":1786890002000,"kind":"status","state":"working"}
|
|
8
|
+
// {"seq":4,"at":1786890003000,"kind":"heartbeat"}
|
|
9
|
+
//
|
|
10
|
+
// `seq` is monotonic PER CONNECTION from 1, so a client detects loss from a gap. There
|
|
11
|
+
// are no named SSE events and no comment keepalives: one shape, so a client needs one
|
|
12
|
+
// handler and can never miss a keepalive it was not parsing.
|
|
13
|
+
//
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// A DEAD STREAM MUST DEGRADE TO THE POLL, NEVER TO A FROZEN SCREEN
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Everything here is arranged so failure is DETECTABLE rather than silent:
|
|
18
|
+
//
|
|
19
|
+
// - A `status` is written before anything else, so the live view is never empty.
|
|
20
|
+
// - A `heartbeat` every 15s, inside the contract's 20s ceiling. Quiet past that is
|
|
21
|
+
// a dead stream, and the client resumes its 5s/15s/60s tiers.
|
|
22
|
+
// - Every refusal happens BEFORE the SSE headers, as ordinary JSON with a status
|
|
23
|
+
// code, so the client sees a failed request rather than an open socket that never
|
|
24
|
+
// speaks.
|
|
25
|
+
// - A write that throws tears the connection down rather than being swallowed.
|
|
26
|
+
//
|
|
27
|
+
// This route NEVER writes the lens. It hands events to the client, which owns every
|
|
28
|
+
// paint. A second writer to the renderer is how `enqueue` deadlocked and blanked the
|
|
29
|
+
// HUD, and no server route is going to reintroduce that.
|
|
30
|
+
//
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// THE FLAG DECISION: this is NOT behind `COS_THREAD_ATTACH_ENABLED`
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// That flag gates WRITING into somebody's live conversation, and with it off the two
|
|
35
|
+
// write routes are not registered at all. This route writes nothing. It streams
|
|
36
|
+
// records from a transcript that `GET /api/agent-sessions/:provider/:sessionId`
|
|
37
|
+
// already returns in full, to the same authenticated caller, through the same token.
|
|
38
|
+
// Reusing the write flag would mean a desktop session -- Case B, which involves no
|
|
39
|
+
// attaching whatsoever -- could not stream unless the user had first enabled the
|
|
40
|
+
// ability to write into their threads. That is a worse posture, not a safer one: it
|
|
41
|
+
// pushes people toward turning the write flag on to get a read feature.
|
|
42
|
+
//
|
|
43
|
+
// Phase 1 remains gated exactly as before, and not by anything here: a Continue turn
|
|
44
|
+
// can only exist when `COS_THREAD_ATTACH_ENABLED=1`, so with the flag off this route
|
|
45
|
+
// carries desktop rows and nothing else.
|
|
46
|
+
//
|
|
47
|
+
// `COS_SESSION_STREAM_ENABLED=0` is a separate kill switch for the streaming surface
|
|
48
|
+
// alone. Absent means ON. That is safe here specifically because nothing writes this
|
|
49
|
+
// key -- no COS Control toggle, no installer, no plist generator -- so key-absence can
|
|
50
|
+
// never be mistaken for a user's explicit opt-out. Off answers 503 and the client
|
|
51
|
+
// polls, which is the same code path as a server too old to have this route at all.
|
|
52
|
+
|
|
53
|
+
import { Router, type Response } from 'express'
|
|
54
|
+
import { stat } from 'node:fs/promises'
|
|
55
|
+
import {
|
|
56
|
+
agentSessionRoots,
|
|
57
|
+
findAgentSessionFile,
|
|
58
|
+
isSafeSessionId,
|
|
59
|
+
type AgentProvider,
|
|
60
|
+
} from '../lib/agent-session-store.js'
|
|
61
|
+
import { ACTIVE_RECENTLY_WINDOW_MS } from '../lib/thread-occupancy.js'
|
|
62
|
+
import {
|
|
63
|
+
isAttachedTurnActive,
|
|
64
|
+
sessionStreamKey,
|
|
65
|
+
subscribeSessionStream,
|
|
66
|
+
type PublishedSessionEvent,
|
|
67
|
+
} from '../lib/session-stream-bus.js'
|
|
68
|
+
import { acquireTranscriptWatcher, transcriptWatcherDegraded } from '../lib/session-transcript-watcher.js'
|
|
69
|
+
import type { SessionStreamState } from '../lib/session-stream-events.js'
|
|
70
|
+
|
|
71
|
+
export const agentSessionStreamRouter = Router()
|
|
72
|
+
|
|
73
|
+
/** Inside the contract's 20s ceiling with room for one lost write. */
|
|
74
|
+
export const HEARTBEAT_INTERVAL_MS = 15_000
|
|
75
|
+
|
|
76
|
+
/** Events held between subscribing and the headers going out. Bounded; see below. */
|
|
77
|
+
export const PREHEADER_BUFFER_MAX = 200
|
|
78
|
+
|
|
79
|
+
export function sessionStreamEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
80
|
+
return env.COS_SESSION_STREAM_ENABLED !== '0'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function asProvider(value: string): AgentProvider | null {
|
|
84
|
+
if (value === 'claude' || value === 'codex' || value === 'cursor') return value
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The state to open with.
|
|
90
|
+
*
|
|
91
|
+
* `working` when a COS turn is writing right now, or when the transcript was touched
|
|
92
|
+
* inside the same 30s window the session list already uses to call a thread active.
|
|
93
|
+
* Otherwise `idle`. Never `done`: this route cannot observe the end of a turn it did
|
|
94
|
+
* not start, and claiming one would be an invention.
|
|
95
|
+
*/
|
|
96
|
+
export async function openingState(
|
|
97
|
+
key: string,
|
|
98
|
+
path: string | null,
|
|
99
|
+
nowMs: number,
|
|
100
|
+
): Promise<SessionStreamState> {
|
|
101
|
+
if (isAttachedTurnActive(key)) return 'working'
|
|
102
|
+
if (path === null) return 'idle'
|
|
103
|
+
try {
|
|
104
|
+
const st = await stat(path)
|
|
105
|
+
return nowMs - st.mtimeMs <= ACTIVE_RECENTLY_WINDOW_MS ? 'working' : 'idle'
|
|
106
|
+
} catch {
|
|
107
|
+
return 'idle'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', async (req, res) => {
|
|
112
|
+
res.set('Cache-Control', 'private, no-store')
|
|
113
|
+
|
|
114
|
+
const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
|
|
115
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
116
|
+
if (!provider) {
|
|
117
|
+
res.status(400).json({ error: 'provider must be claude, codex, or cursor', reason: 'bad_provider' })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (!isSafeSessionId(sessionId)) {
|
|
121
|
+
res.status(400).json({ error: 'invalid session id', reason: 'bad_session_id' })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (!sessionStreamEnabled()) {
|
|
125
|
+
res.status(503).json({ error: 'session streaming is disabled', reason: 'stream_disabled' })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const key = sessionStreamKey(provider, sessionId)
|
|
130
|
+
|
|
131
|
+
// Resolved BEFORE the headers so a missing transcript is a 404 the client can act on
|
|
132
|
+
// rather than an open socket that turns out to carry nothing. Null is not fatal for
|
|
133
|
+
// Phase 1: a Continue turn streams from stdout whether or not the file is locatable.
|
|
134
|
+
let path: string | null = null
|
|
135
|
+
try {
|
|
136
|
+
path = await findAgentSessionFile(provider, sessionId, agentSessionRoots())
|
|
137
|
+
} catch {
|
|
138
|
+
path = null
|
|
139
|
+
}
|
|
140
|
+
if (path === null && !isAttachedTurnActive(key)) {
|
|
141
|
+
res.status(404).json({ error: 'Session not found', reason: 'session_not_found' })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Capacity is checked before the headers for the same reason. A refused subscribe is
|
|
146
|
+
// a 503 the client falls back from, not a stream that silently receives nothing.
|
|
147
|
+
//
|
|
148
|
+
// Subscribing happens HERE, two awaits before the headers, so a turn that starts in
|
|
149
|
+
// that window is not lost. Until the headers go out the events are buffered, bounded
|
|
150
|
+
// by `PREHEADER_BUFFER_MAX` -- an unbounded buffer in front of a socket that may
|
|
151
|
+
// never open is a leak, and the poll fallback covers anything dropped.
|
|
152
|
+
const pending: PublishedSessionEvent[] = []
|
|
153
|
+
let deliver = (event: PublishedSessionEvent): void => {
|
|
154
|
+
pending.push(event)
|
|
155
|
+
if (pending.length > PREHEADER_BUFFER_MAX) pending.shift()
|
|
156
|
+
}
|
|
157
|
+
const unsubscribe = subscribeSessionStream(key, event => { deliver(event) })
|
|
158
|
+
if (unsubscribe === null) {
|
|
159
|
+
res.status(503).json({ error: 'too many live streams', reason: 'stream_capacity' })
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Where the tail joins: the file's CURRENT size. A subscriber wants what happens
|
|
164
|
+
// next, and the history it already has came from the polled detail payload. Starting
|
|
165
|
+
// at zero would replay an 81 MB transcript into a pair of glasses.
|
|
166
|
+
let startOffset = 0
|
|
167
|
+
try {
|
|
168
|
+
if (path !== null) startOffset = (await stat(path)).size
|
|
169
|
+
} catch {
|
|
170
|
+
startOffset = 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const state = await openingState(key, path, Date.now())
|
|
174
|
+
|
|
175
|
+
res.writeHead(200, {
|
|
176
|
+
'Content-Type': 'text/event-stream',
|
|
177
|
+
'Cache-Control': 'no-cache',
|
|
178
|
+
'Connection': 'keep-alive',
|
|
179
|
+
'X-Accel-Buffering': 'no',
|
|
180
|
+
})
|
|
181
|
+
res.flushHeaders()
|
|
182
|
+
res.write('retry: 3000\n\n')
|
|
183
|
+
|
|
184
|
+
// Declared before `teardown` reads them. Both are assigned below; a `const` here
|
|
185
|
+
// would put them in the temporal dead zone for a teardown triggered by the very
|
|
186
|
+
// first write, and that ReferenceError would leak the subscription it exists to
|
|
187
|
+
// release.
|
|
188
|
+
let releaseWatcher: (() => void) | null = null
|
|
189
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null
|
|
190
|
+
|
|
191
|
+
let seq = 0
|
|
192
|
+
let closed = false
|
|
193
|
+
|
|
194
|
+
const teardown = (): void => {
|
|
195
|
+
if (closed) return
|
|
196
|
+
closed = true
|
|
197
|
+
if (heartbeat !== null) clearInterval(heartbeat)
|
|
198
|
+
unsubscribe()
|
|
199
|
+
releaseWatcher?.()
|
|
200
|
+
try { res.end() } catch { /* already gone */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const write = (event: PublishedSessionEvent): void => {
|
|
204
|
+
if (closed) return
|
|
205
|
+
try {
|
|
206
|
+
res.write(`data: ${JSON.stringify({ seq: ++seq, ...event })}\n\n`)
|
|
207
|
+
} catch {
|
|
208
|
+
// A failed write means the socket is gone. Close rather than swallow, so the
|
|
209
|
+
// watcher and the subscription are released instead of leaking behind a dead
|
|
210
|
+
// client that will never send `close`.
|
|
211
|
+
teardown()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
heartbeat = setInterval(() => {
|
|
216
|
+
// The tail gave up on a record too large to stream (see session-transcript-watcher).
|
|
217
|
+
// END the response rather than heartbeat over a dead tail: the client's fallback to
|
|
218
|
+
// its own poll is already written and tested, and a socket that stays "live" while
|
|
219
|
+
// producing nothing is worse than no socket at all.
|
|
220
|
+
if (transcriptWatcherDegraded(key)) {
|
|
221
|
+
teardown()
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
write({ kind: 'heartbeat', at: Date.now() })
|
|
225
|
+
}, HEARTBEAT_INTERVAL_MS)
|
|
226
|
+
if (typeof (heartbeat as any).unref === 'function') (heartbeat as any).unref()
|
|
227
|
+
|
|
228
|
+
// The contract's "emit a status immediately" -- written before any queued event so
|
|
229
|
+
// the client's first frame is always a state, never a bare tool line.
|
|
230
|
+
write({ kind: 'status', state, at: Date.now() })
|
|
231
|
+
// Then anything published while the headers were being prepared, in order, before
|
|
232
|
+
// the listener starts writing straight through. All three steps are synchronous, so
|
|
233
|
+
// no event can interleave and arrive out of order.
|
|
234
|
+
for (const event of pending.splice(0)) write(event)
|
|
235
|
+
deliver = write
|
|
236
|
+
|
|
237
|
+
releaseWatcher = path === null
|
|
238
|
+
? null
|
|
239
|
+
: acquireTranscriptWatcher({ key, path, provider, offset: startOffset })
|
|
240
|
+
// The socket may already have died during the writes above. Acquiring a watcher for
|
|
241
|
+
// a closed connection would leave it polling with nobody listening.
|
|
242
|
+
if (closed) {
|
|
243
|
+
releaseWatcher?.()
|
|
244
|
+
releaseWatcher = null
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
req.on('close', teardown)
|
|
249
|
+
res.on('error', teardown)
|
|
250
|
+
})
|
|
@@ -366,6 +366,12 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
366
366
|
// summary — Miles: "it should be in the body not the title, the row should
|
|
367
367
|
// be no more than the 180 characters."
|
|
368
368
|
discussion_digest: parsed.discussion_digest || '',
|
|
369
|
+
// The newest assistant reply, whole. ADDITIVE: the digest above still carries
|
|
370
|
+
// its own 160-char `Latest:` line, so a client that never learns this field
|
|
371
|
+
// renders exactly what it rendered before. Measured at 4000 chars — see
|
|
372
|
+
// LATEST_REPLY_MAX. Before this existed the detail payload had NO full-text
|
|
373
|
+
// field at all, and an 1821-char reply left the Mac as 160 characters.
|
|
374
|
+
latest_reply: parsed.latest_reply || '',
|
|
369
375
|
truncated: parsed.truncated,
|
|
370
376
|
project: parsed.project,
|
|
371
377
|
created: modified,
|