@gotcos/glasses-server 6.35.0 → 6.36.1
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 +68 -0
- package/package.json +1 -1
- package/server/index.ts +44 -15
- package/server/lib/agent-session-store.ts +55 -7
- 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/CHANGELOG.md
CHANGED
|
@@ -2219,6 +2219,74 @@ unsaved capture, and makes batch status stop lying about finished work.
|
|
|
2219
2219
|
|
|
2220
2220
|
# Changelog
|
|
2221
2221
|
|
|
2222
|
+
## [6.36.1] - 2026-08-17
|
|
2223
|
+
|
|
2224
|
+
### A reply keeps its line structure
|
|
2225
|
+
|
|
2226
|
+
Miles, from a G2 screenshot: a session reply arrived on the lens as one unbroken
|
|
2227
|
+
paragraph carrying three headings and six bullets, none of them visible.
|
|
2228
|
+
|
|
2229
|
+
`proseBody` collapsed ALL whitespace, and both the one-line list gist and the
|
|
2230
|
+
`latest_reply` BODY went through it. Collapsing is right for a row and destroys a
|
|
2231
|
+
body: the client cannot restore structure the server already flattened. Two
|
|
2232
|
+
fields, two jobs, and now two paths — `latestAssistantReply` preserves newlines
|
|
2233
|
+
while `proseSnippet` still returns exactly one line.
|
|
2234
|
+
|
|
2235
|
+
### The tag strip ate prose
|
|
2236
|
+
|
|
2237
|
+
`/<[^>]+>/` deleted anything between angle brackets, so `read <file>` reached the
|
|
2238
|
+
lens as `read ,`. Every `<path>`, `<PORT>` and `<name>` a technical reply uses
|
|
2239
|
+
died the same way, mid-sentence and unreportably. Replaced with an allowlist built
|
|
2240
|
+
from the tags actually present in transcripts on this machine (measured over 3,001
|
|
2241
|
+
records: HTML from rendered output, plus the COS wrapper blocks). A name not on
|
|
2242
|
+
the list is treated as the prose it almost always is; adding one later is a
|
|
2243
|
+
one-line change, whereas a placeholder eaten out of a sentence is invisible.
|
|
2244
|
+
|
|
2245
|
+
9 execution tests on the shared prose path.
|
|
2246
|
+
|
|
2247
|
+
## [6.36.0] - 2026-08-17
|
|
2248
|
+
|
|
2249
|
+
### Sessions push instead of being polled
|
|
2250
|
+
|
|
2251
|
+
A new SSE route streams what an agent session is doing, so the glasses stop
|
|
2252
|
+
re-asking every five seconds. Two cases, and the difference between them is real
|
|
2253
|
+
rather than something the UI papers over.
|
|
2254
|
+
|
|
2255
|
+
- **`GET /api/agent-sessions/:provider/:sessionId/stream`** — one event per step
|
|
2256
|
+
(`tool` / `prose` / `status` / `heartbeat`), a monotonic `seq` so a client can
|
|
2257
|
+
see loss, and a heartbeat at least every 20s so silence is evidence rather than
|
|
2258
|
+
ambiguity. Authenticated like every other route; `COS_SESSION_STREAM_ENABLED=0`
|
|
2259
|
+
turns it off.
|
|
2260
|
+
- **A turn COS started streams from the pipe.** The child already ran with
|
|
2261
|
+
`--output-format stream-json --verbose`; its stdout was read only to confirm a
|
|
2262
|
+
session id and then discarded. It is now teed to the bus as well, and the id
|
|
2263
|
+
scan is untouched.
|
|
2264
|
+
- **A session in a desktop window gets row-level push.** COS never spawned that
|
|
2265
|
+
process and has no pipe to it, so the transcript file is the only observable.
|
|
2266
|
+
It is tailed forward from a byte offset, one `stat` per second per OPEN view,
|
|
2267
|
+
and each new record is emitted through the same grammar and envelope. Claude
|
|
2268
|
+
writes a complete record per message, so a long reply lands all at once when it
|
|
2269
|
+
finishes. **That asymmetry cannot be engineered away from a file** and the
|
|
2270
|
+
contract does not claim otherwise.
|
|
2271
|
+
- **`fs.watch` was rejected deliberately.** On macOS it coalesces bursts and, on
|
|
2272
|
+
an atomic replace, keeps watching the old inode and simply stops firing — a
|
|
2273
|
+
watcher that goes silent is indistinguishable from a session that went quiet,
|
|
2274
|
+
which is the failure class this repo keeps paying for. A `stat` poll cannot
|
|
2275
|
+
miss a write because it does not observe writes; it observes size, and size is
|
|
2276
|
+
cumulative.
|
|
2277
|
+
- **A record too large to stream hands the session back to the poll.** One record
|
|
2278
|
+
in a real transcript on this machine is 1,239,046 bytes. The tail reads up to 4
|
|
2279
|
+
MiB per TICK, so any record up to that arrives intact; a bigger one ends the
|
|
2280
|
+
response with a terminal `done` instead of stalling. Skipping it was tried
|
|
2281
|
+
first and was wrong twice: it wedged (the good record behind the oversized one
|
|
2282
|
+
was skipped too, on every tick, forever — zero events, not even a status) and,
|
|
2283
|
+
even working, it would have silently dropped a reply the user was waiting for.
|
|
2284
|
+
- **One watcher per session, ref-counted.** Two glasses on one session is not two
|
|
2285
|
+
pollers on an 81 MB file, and a double release does not tear down a tail
|
|
2286
|
+
another subscriber still holds.
|
|
2287
|
+
|
|
2288
|
+
Needs COS Glasses 6.8.372 to be visible. An older app never calls the route.
|
|
2289
|
+
|
|
2222
2290
|
## [6.35.0] - 2026-08-16
|
|
2223
2291
|
|
|
2224
2292
|
### The newest assistant reply arrives whole instead of at 160 characters
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.36.1",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { providerProofRouter } from './routes/provider-proof.js'
|
|
|
19
19
|
import { transcribeRouter } from './routes/transcribe.js'
|
|
20
20
|
import { sessionIndexRouter } from './routes/session-index.js'
|
|
21
21
|
import { agentSessionsRouter } from './routes/agent-sessions.js'
|
|
22
|
+
import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
|
|
23
|
+
import { createAttachedTurnStream } from './lib/session-stream-producer.js'
|
|
22
24
|
import { claudeSessionsRouter } from './routes/claude-sessions.js'
|
|
23
25
|
import {
|
|
24
26
|
createAgentSessionBindingsRouter,
|
|
@@ -384,23 +386,45 @@ const deliverAttachedTurnForRoute = async (request: {
|
|
|
384
386
|
return { attachable: verdict.attachable, reason: verdict.reason }
|
|
385
387
|
})
|
|
386
388
|
|
|
387
|
-
|
|
389
|
+
// PHASE 1 OF SESSION STREAMING. The child already writes `--output-format
|
|
390
|
+
// stream-json`; this is the only place that stream reaches anything other than the
|
|
391
|
+
// adapter's id scanner. Opened before the spawn so a subscriber that connects mid
|
|
392
|
+
// turn sees `working` rather than silence, and closed in a `finally` because the
|
|
393
|
+
// duplicate-suppression gate it holds must be released on EVERY exit path -- a stuck
|
|
394
|
+
// gate would silence Phase 2 for that session permanently.
|
|
395
|
+
const live = createAttachedTurnStream({
|
|
388
396
|
provider: request.provider,
|
|
389
|
-
|
|
390
|
-
prompt: request.prompt,
|
|
391
|
-
cwd: workspace.path,
|
|
392
|
-
// The only policy this build accepts. The adapter refuses anything else and
|
|
393
|
-
// asserts no bypass/always-approve flag reaches the argv (plan 4.7).
|
|
394
|
-
policy: 'read_only',
|
|
395
|
-
deps: {
|
|
396
|
-
...base,
|
|
397
|
-
// `startMs` is deliberately unused: the adapter already probed it as a GATE
|
|
398
|
-
// (a null there aborts before this is reached), and the route probes again
|
|
399
|
-
// as the recorder. One record, one authority.
|
|
400
|
-
recordSpawn: (pid: number, _startMs: number) =>
|
|
401
|
-
request.onSpawn(pid) ? 'recorded' : 'route_refused_ownership',
|
|
402
|
-
},
|
|
397
|
+
sessionId: request.nativeThreadId,
|
|
403
398
|
})
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const result = await deliverAttachedTurn({
|
|
402
|
+
provider: request.provider,
|
|
403
|
+
nativeThreadId: request.nativeThreadId,
|
|
404
|
+
prompt: request.prompt,
|
|
405
|
+
cwd: workspace.path,
|
|
406
|
+
// The only policy this build accepts. The adapter refuses anything else and
|
|
407
|
+
// asserts no bypass/always-approve flag reaches the argv (plan 4.7).
|
|
408
|
+
policy: 'read_only',
|
|
409
|
+
deps: {
|
|
410
|
+
...base,
|
|
411
|
+
// `startMs` is deliberately unused: the adapter already probed it as a GATE
|
|
412
|
+
// (a null there aborts before this is reached), and the route probes again
|
|
413
|
+
// as the recorder. One record, one authority.
|
|
414
|
+
recordSpawn: (pid: number, _startMs: number) =>
|
|
415
|
+
request.onSpawn(pid) ? 'recorded' : 'route_refused_ownership',
|
|
416
|
+
observeStdout: chunk => live.observeStdout(chunk),
|
|
417
|
+
},
|
|
418
|
+
})
|
|
419
|
+
// `done` only for a turn the adapter proved landed. Anything else is `idle`: the
|
|
420
|
+
// session stopped working, and claiming a completion we could not verify is the
|
|
421
|
+
// kind of invention the rest of this feature refuses to make.
|
|
422
|
+
live.finish(result && typeof result === 'object' && (result as { ok?: unknown }).ok === true ? 'done' : 'idle')
|
|
423
|
+
return result
|
|
424
|
+
} catch (error) {
|
|
425
|
+
live.finish('idle')
|
|
426
|
+
throw error
|
|
427
|
+
}
|
|
404
428
|
}
|
|
405
429
|
|
|
406
430
|
/**
|
|
@@ -454,6 +478,11 @@ app.use('/api', transcribeRouter)
|
|
|
454
478
|
app.use('/api', sessionIndexRouter)
|
|
455
479
|
// Claude + Codex + Cursor transcripts from this Mac. Same 7-day window as Control.
|
|
456
480
|
app.use('/api', agentSessionsRouter)
|
|
481
|
+
// The live view of one of those sessions. Four segments ending in a literal `stream`,
|
|
482
|
+
// so it cannot shadow the three-segment transcript route above or the bindings
|
|
483
|
+
// router's `/attachability` below. READ-ONLY, and deliberately NOT behind
|
|
484
|
+
// COS_THREAD_ATTACH_ENABLED -- see the header of the route for why.
|
|
485
|
+
app.use('/api', agentSessionStreamRouter)
|
|
457
486
|
// Presence view of Claude Code sessions on this Mac. Dark unless
|
|
458
487
|
// COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
|
|
459
488
|
app.use('/api', claudeSessionsRouter)
|
|
@@ -122,12 +122,57 @@ export function firstLineTitle(text: string): string {
|
|
|
122
122
|
return line.slice(0, 80)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Tags worth deleting, as opposed to any pair of angle brackets.
|
|
127
|
+
*
|
|
128
|
+
* WHY A LIST AND NOT `/<[^>]+>/`. That pattern deleted PROSE. `read <file>` in an
|
|
129
|
+
* assistant reply rendered on the lens as `read ,` because `<file>` looks exactly like
|
|
130
|
+
* a tag; `<path>`, `<PORT>`, `<name>` and every other placeholder a technical answer
|
|
131
|
+
* uses died the same way, silently, mid-sentence.
|
|
132
|
+
*
|
|
133
|
+
* These are the names actually present in transcripts on this machine (measured over
|
|
134
|
+
* 3,001 records: HTML from rendered output, plus the COS wrapper blocks the harness
|
|
135
|
+
* injects). Anything not named here is treated as the prose it almost always is.
|
|
136
|
+
* A name that shows up later and should be stripped is a one-line addition; a
|
|
137
|
+
* placeholder eaten out of a sentence is invisible and unreportable.
|
|
138
|
+
*/
|
|
139
|
+
const STRIPPABLE_TAGS = [
|
|
140
|
+
// HTML that reaches a transcript through rendered or pasted output
|
|
141
|
+
'div', 'p', 'span', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'br', 'hr',
|
|
142
|
+
'code', 'pre', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
|
|
143
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'img', 'summary', 'details',
|
|
144
|
+
// COS / harness wrapper blocks
|
|
145
|
+
'now', 'relevant-memories', 'cache-health', 'daily-bulletin', 'cos-alarms',
|
|
146
|
+
'device-handoff', 'system-reminder', 'memory-stored', 'user_query',
|
|
147
|
+
'task-notification', 'task-id', 'tool-use-id', 'output-file', 'status',
|
|
148
|
+
'result', 'usage', 'subagent_tokens', 'tool_uses', 'duration_ms',
|
|
149
|
+
'example', 'commentary', 'string', 'functions', 'function', 'command-name',
|
|
150
|
+
'local-command-stdout', 'local-command-stderr', 'thinking',
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fenced code and known tags out. Shared so `proseSnippet` and `latestAssistantReply`
|
|
157
|
+
* can never disagree about what the prose of a record IS.
|
|
158
|
+
*
|
|
159
|
+
* `keepLines` is the whole difference between them, and it was the bug. A LIST ROW is
|
|
160
|
+
* one line and must collapse; a BODY is what the reader paginates and its line
|
|
161
|
+
* structure IS the formatting. Collapsing both through one path turned every heading,
|
|
162
|
+
* bullet and paragraph break in a reply into a space, and delivered a wall of prose to
|
|
163
|
+
* a 576x288 lens with no structure left for the client to lay out.
|
|
164
|
+
*/
|
|
165
|
+
function proseBody(text: string, keepLines = false): string {
|
|
166
|
+
const body = text.replace(/```[\s\S]*?```/g, ' ').replace(STRIPPABLE_TAG_RE, ' ')
|
|
167
|
+
if (!keepLines) return body.replace(/\s+/g, ' ').trim()
|
|
168
|
+
return body
|
|
169
|
+
// Spaces and tabs collapse; newlines do not.
|
|
170
|
+
.replace(/[^\S\n]+/g, ' ')
|
|
171
|
+
// Trailing space before a break would render as a hanging indent on the lens.
|
|
172
|
+
.replace(/ *\n/g, '\n')
|
|
173
|
+
// Three or more breaks is never meaningful and costs a reader page.
|
|
174
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
175
|
+
.trim()
|
|
131
176
|
}
|
|
132
177
|
|
|
133
178
|
export function proseSnippet(text: string, max = 160): string {
|
|
@@ -166,7 +211,10 @@ export const LATEST_REPLY_MAX = 4000
|
|
|
166
211
|
* to say so.
|
|
167
212
|
*/
|
|
168
213
|
export function latestAssistantReply(text: string, max = LATEST_REPLY_MAX): string {
|
|
169
|
-
|
|
214
|
+
// LINE STRUCTURE PRESERVED. This is the field the reader renders, so its headings,
|
|
215
|
+
// bullets and paragraph breaks have to survive the trip; the client cannot restore
|
|
216
|
+
// structure the server already flattened.
|
|
217
|
+
const body = proseBody(text, true)
|
|
170
218
|
if (!body || isWrapperPrompt(body)) return ''
|
|
171
219
|
return body.length <= max ? body : `${body.slice(0, max - 1)}…`
|
|
172
220
|
}
|
|
@@ -462,6 +462,25 @@ export interface AttachedTurnDeps {
|
|
|
462
462
|
* and prove the turn is refused with zero spawns. Production never sets it.
|
|
463
463
|
*/
|
|
464
464
|
buildArgs?: (provider: AttachedProvider, nativeThreadId: string, cwd: string) => string[]
|
|
465
|
+
/**
|
|
466
|
+
* A PASSIVE reader of the child's stdout. Optional; omitted changes nothing.
|
|
467
|
+
*
|
|
468
|
+
* The turn already spawns with `--output-format stream-json --verbose`, and this
|
|
469
|
+
* module reads that stream for exactly one thing: proving the returned session id
|
|
470
|
+
* matches the target. Everything else is discarded. This hook is how the live view
|
|
471
|
+
* gets a copy without that scan being touched.
|
|
472
|
+
*
|
|
473
|
+
* IT IS AN OBSERVER AND IS TREATED AS ONE. Registered as a SECOND `data` listener,
|
|
474
|
+
* AFTER the scanner, so the scanner runs first on every chunk. It shares no state
|
|
475
|
+
* with the scanner, it cannot influence any outcome, and a throw from it is caught
|
|
476
|
+
* and dropped rather than reaching `emit()` where it would starve the listener that
|
|
477
|
+
* matters. When absent, no second listener is registered at all, which is byte for
|
|
478
|
+
* byte the behaviour every existing caller and test already has.
|
|
479
|
+
*
|
|
480
|
+
* DELIBERATELY NOT A TRANSFORM. It returns nothing, so there is no shape in which a
|
|
481
|
+
* future edit can let it modify what the scan sees.
|
|
482
|
+
*/
|
|
483
|
+
observeStdout?: (chunk: string) => void
|
|
465
484
|
}
|
|
466
485
|
|
|
467
486
|
export interface AttachedTurnRequest {
|
|
@@ -1144,6 +1163,26 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1144
1163
|
return settleFailure('adapter_internal_error', { detail: 'wire_failed' })
|
|
1145
1164
|
}
|
|
1146
1165
|
|
|
1166
|
+
// --- the passive tee, wired LAST and in its own try ------------------------
|
|
1167
|
+
// Its own try/catch, outside the block above, is the point: a stdout that refuses
|
|
1168
|
+
// a second listener must cost the OBSERVER, not the turn. Inside that block the
|
|
1169
|
+
// same throw would hit `wire_failed` and refuse a delivery for the sake of a view.
|
|
1170
|
+
try {
|
|
1171
|
+
const observe = deps.observeStdout
|
|
1172
|
+
if (typeof observe === 'function' && child.stdout) {
|
|
1173
|
+
child.stdout.on('data', (chunk: any) => {
|
|
1174
|
+
try {
|
|
1175
|
+
observe(typeof chunk === 'string' ? chunk : String(chunk))
|
|
1176
|
+
} catch {
|
|
1177
|
+
// Never rethrown. A throw here propagates out of `emit()` and would stop
|
|
1178
|
+
// every later listener on this stream.
|
|
1179
|
+
}
|
|
1180
|
+
})
|
|
1181
|
+
}
|
|
1182
|
+
} catch {
|
|
1183
|
+
/* the turn proceeds unobserved, which is the correct direction */
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1147
1186
|
// --- bounded budget ------------------------------------------------------
|
|
1148
1187
|
deadline = setTimeout(() => {
|
|
1149
1188
|
timedOut = true
|
|
@@ -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
|
+
}
|