@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 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.35.0",
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
- 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)
@@ -122,12 +122,57 @@ export function firstLineTitle(text: string): string {
122
122
  return line.slice(0, 80)
123
123
  }
124
124
 
125
- /** Fenced code, tags and runs of whitespace out; one line of prose left. Shared so
126
- * `proseSnippet` and `latestAssistantReply` can never disagree about what the prose
127
- * of a record IS — only about how much of it they keep. */
128
- function proseBody(text: string): string {
129
- const body = text.replace(/```[\s\S]*?```/g, ' ')
130
- return body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
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
- const body = proseBody(text)
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
+ }