@gotcos/glasses-server 6.35.0 → 6.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2219,6 +2219,49 @@ unsaved capture, and makes batch status stop lying about finished work.
2219
2219
 
2220
2220
  # Changelog
2221
2221
 
2222
+ ## [6.36.0] - 2026-08-17
2223
+
2224
+ ### Sessions push instead of being polled
2225
+
2226
+ A new SSE route streams what an agent session is doing, so the glasses stop
2227
+ re-asking every five seconds. Two cases, and the difference between them is real
2228
+ rather than something the UI papers over.
2229
+
2230
+ - **`GET /api/agent-sessions/:provider/:sessionId/stream`** — one event per step
2231
+ (`tool` / `prose` / `status` / `heartbeat`), a monotonic `seq` so a client can
2232
+ see loss, and a heartbeat at least every 20s so silence is evidence rather than
2233
+ ambiguity. Authenticated like every other route; `COS_SESSION_STREAM_ENABLED=0`
2234
+ turns it off.
2235
+ - **A turn COS started streams from the pipe.** The child already ran with
2236
+ `--output-format stream-json --verbose`; its stdout was read only to confirm a
2237
+ session id and then discarded. It is now teed to the bus as well, and the id
2238
+ scan is untouched.
2239
+ - **A session in a desktop window gets row-level push.** COS never spawned that
2240
+ process and has no pipe to it, so the transcript file is the only observable.
2241
+ It is tailed forward from a byte offset, one `stat` per second per OPEN view,
2242
+ and each new record is emitted through the same grammar and envelope. Claude
2243
+ writes a complete record per message, so a long reply lands all at once when it
2244
+ finishes. **That asymmetry cannot be engineered away from a file** and the
2245
+ contract does not claim otherwise.
2246
+ - **`fs.watch` was rejected deliberately.** On macOS it coalesces bursts and, on
2247
+ an atomic replace, keeps watching the old inode and simply stops firing — a
2248
+ watcher that goes silent is indistinguishable from a session that went quiet,
2249
+ which is the failure class this repo keeps paying for. A `stat` poll cannot
2250
+ miss a write because it does not observe writes; it observes size, and size is
2251
+ cumulative.
2252
+ - **A record too large to stream hands the session back to the poll.** One record
2253
+ in a real transcript on this machine is 1,239,046 bytes. The tail reads up to 4
2254
+ MiB per TICK, so any record up to that arrives intact; a bigger one ends the
2255
+ response with a terminal `done` instead of stalling. Skipping it was tried
2256
+ first and was wrong twice: it wedged (the good record behind the oversized one
2257
+ was skipped too, on every tick, forever — zero events, not even a status) and,
2258
+ even working, it would have silently dropped a reply the user was waiting for.
2259
+ - **One watcher per session, ref-counted.** Two glasses on one session is not two
2260
+ pollers on an 81 MB file, and a double release does not tear down a tail
2261
+ another subscriber still holds.
2262
+
2263
+ Needs COS Glasses 6.8.372 to be visible. An older app never calls the route.
2264
+
2222
2265
  ## [6.35.0] - 2026-08-16
2223
2266
 
2224
2267
  ### 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.35.0",
3
+ "version": "6.36.0",
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
- return deliverAttachedTurn({
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
- nativeThreadId: request.nativeThreadId,
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)
@@ -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
+ }