@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
package/CHANGELOG.md
CHANGED
|
@@ -2219,6 +2219,118 @@ 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
|
+
|
|
2265
|
+
## [6.35.0] - 2026-08-16
|
|
2266
|
+
|
|
2267
|
+
### The newest assistant reply arrives whole instead of at 160 characters
|
|
2268
|
+
|
|
2269
|
+
- **The defect.** Miles opened a session on the glasses and the newest reply was cut
|
|
2270
|
+
off mid-sentence. The session detail payload had NO full-text field: it carried
|
|
2271
|
+
`discussion_summary` (180 chars) and `discussion_digest` (2000), and the newest
|
|
2272
|
+
reply appeared only as the `Latest:` line inside the digest, produced by
|
|
2273
|
+
`proseSnippet` at **160 characters** with a bare `slice` that stops mid-word and
|
|
2274
|
+
prints nothing to say it stopped. An 1821-character reply reached the wire as 160
|
|
2275
|
+
characters. The other 1661 never left the Mac. Polling was never the suspect: no
|
|
2276
|
+
poll can deliver bytes the server did not send.
|
|
2277
|
+
- **New field `latest_reply` on `GET /api/agent-sessions/:provider/:sessionId`,
|
|
2278
|
+
bounded at 4000 characters.** The number is measured, not guessed. Across 8,387
|
|
2279
|
+
assistant replies in the 60 most recent transcripts on this Mac: p50 153, p75 291,
|
|
2280
|
+
p90 1,627, p95 2,412, p99 3,405, max 21,757. The old 160-char cap delivered only
|
|
2281
|
+
**52.7%** of replies whole. 4000 delivers **99.58%** (35 of 8,387 cut). 6000 would
|
|
2282
|
+
buy 0.26 of a point for 50% more bytes on every fetch, and the reader paginates at
|
|
2283
|
+
roughly 200 chars, so 4000 is at most 20 swipes of deliberate reading.
|
|
2284
|
+
- **A dedicated field, not a bigger slice of the digest.** Three caps sit in series
|
|
2285
|
+
here (`proseSnippet` 160, `DIGEST_TURN_MAX` 220, `DISCUSSION_DIGEST_MAX` 2000) and
|
|
2286
|
+
the digest reserves its `Latest:` block FIRST, so a longer reply inside that budget
|
|
2287
|
+
starves the user turns it is supposed to sit beside. Two fields, two jobs.
|
|
2288
|
+
- **Truncation is now visible.** `latest_reply` ends in an ellipsis when it does have
|
|
2289
|
+
to cut. `proseSnippet` keeps its old bare 160-char slice, pinned by a test.
|
|
2290
|
+
- **ADDITIVE. An older app keeps working.** Nothing was removed or renamed:
|
|
2291
|
+
`discussion_summary` and `discussion_digest` still carry exactly what they carried,
|
|
2292
|
+
the digest still holds its own 160-char `Latest:` line, and a client that never
|
|
2293
|
+
learns the new key renders what it rendered before. Proven over real HTTP rather
|
|
2294
|
+
than by reading the route source.
|
|
2295
|
+
|
|
2296
|
+
### Two latent hazards in the same path, both closed
|
|
2297
|
+
|
|
2298
|
+
- **"Latest" can no longer be a line from the session's opening.** An oversized
|
|
2299
|
+
transcript is read head-then-tail into one loop, and the newest reply was plain
|
|
2300
|
+
last-write-wins across both windows. A session whose tail window happened to hold
|
|
2301
|
+
no assistant prose, which is ordinary when the last 768 KiB are giant tool results,
|
|
2302
|
+
kept whichever assistant row the HEAD saw and published it labelled `Latest:`. A
|
|
2303
|
+
line from the start of the session, presented as its current state. Silently wrong,
|
|
2304
|
+
and worse than truncation because truncation is at least visible. Lines are now
|
|
2305
|
+
tagged with the window they came from and only the window ending at EOF can claim
|
|
2306
|
+
to be latest; with nothing there the honest answer is nothing, and the digest drops
|
|
2307
|
+
its `Latest:` block rather than filling it with the wrong turn. A whole-file read
|
|
2308
|
+
is all tail, so small sessions are unchanged.
|
|
2309
|
+
- **A record larger than the tail window no longer empties the tail.** The tail opens
|
|
2310
|
+
at `size - 768 KiB`, mid-record, and the leading fragment is discarded by design.
|
|
2311
|
+
When the FINAL record is bigger than the window, that fragment is the only thing in
|
|
2312
|
+
it: nothing parses, and the tail contributes no recent turns and no reply at all.
|
|
2313
|
+
Not hypothetical: 14 records over 768 KiB exist in a 60-transcript sample on this
|
|
2314
|
+
Mac, the largest 1,239,045 bytes, 1.58x the window. The tail now reaches back up to
|
|
2315
|
+
one further MiB to open at a real record boundary, and stops reaching past that so
|
|
2316
|
+
a pathological record cannot pull an unbounded read into memory.
|
|
2317
|
+
- **Measured, not asserted:** both hazards are LATENT today. Every transcript
|
|
2318
|
+
currently over the 32 MiB read ceiling has 8 to 19 assistant rows in its tail, so
|
|
2319
|
+
neither is firing right now. They are mechanisms, closed before they fire.
|
|
2320
|
+
|
|
2321
|
+
### Notes
|
|
2322
|
+
|
|
2323
|
+
- One behaviour change beyond the additive field, and it is the point of the fix: on
|
|
2324
|
+
a truncated read whose tail holds no assistant prose, `discussion_summary` and the
|
|
2325
|
+
digest's `Latest:` block are now EMPTY where they used to carry a line from the
|
|
2326
|
+
session's opening. That removes wrong content, not content.
|
|
2327
|
+
- A mutation that survived is recorded rather than buried. An early draft clamped the
|
|
2328
|
+
tail start at `headBytes` to avoid double counting, and no test reached it. It was
|
|
2329
|
+
both untested and wrong: `lastRecordStart` returns the LAST record's start and that
|
|
2330
|
+
record always runs to EOF, so the head can only ever see a prefix it discards, and
|
|
2331
|
+
the clamp only threw away the record the reach exists to recover. The clamp is
|
|
2332
|
+
gone, a test now covers the case, and re-adding it fails.
|
|
2333
|
+
|
|
2222
2334
|
## [6.34.0] - 2026-08-16
|
|
2223
2335
|
|
|
2224
2336
|
### A continued turn now runs with the session's own permissions
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
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
|
-
|
|
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,13 +122,55 @@ 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()
|
|
131
|
+
}
|
|
132
|
+
|
|
125
133
|
export function proseSnippet(text: string, max = 160): string {
|
|
126
|
-
|
|
127
|
-
body = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
134
|
+
const body = proseBody(text)
|
|
128
135
|
if (!body || isWrapperPrompt(body)) return ''
|
|
129
136
|
return body.slice(0, max)
|
|
130
137
|
}
|
|
131
138
|
|
|
139
|
+
/**
|
|
140
|
+
* How much of the newest assistant reply crosses the wire, whole.
|
|
141
|
+
*
|
|
142
|
+
* MEASURED, not guessed. Across 8,387 assistant replies in the 60 most recent
|
|
143
|
+
* transcripts on this Mac: p50 153, p75 291, p90 1,627, p95 2,412, p99 3,405,
|
|
144
|
+
* max 21,757. The 160-char snippet that used to be the ONLY assistant text on the
|
|
145
|
+
* detail payload delivered 52.7% of replies whole — it cut nearly half of them, and
|
|
146
|
+
* cut them mid-word with no ellipsis. 4,000 delivers 99.58% whole (35 of 8,387 cut).
|
|
147
|
+
*
|
|
148
|
+
* WHY NOT HIGHER. 6,000 buys 0.26 of a percentage point for 50% more bytes on every
|
|
149
|
+
* detail fetch, and the ceiling is what a pathological reply costs, not what a
|
|
150
|
+
* typical one costs. The reader paginates at ~200 chars, so 4,000 is at most 20
|
|
151
|
+
* swipes of deliberate reading; 21,757 would be 109. Bounded on purpose: this
|
|
152
|
+
* crosses a phone radio and renders on a 576x288 HUD.
|
|
153
|
+
*
|
|
154
|
+
* WHY NOT LOWER. 2,000 would still cut 7.5% of replies, and Miles's 1,821-char reply
|
|
155
|
+
* — the one that started this — sits inside the band that a 2,000 cap leaves with no
|
|
156
|
+
* headroom at all.
|
|
157
|
+
*/
|
|
158
|
+
export const LATEST_REPLY_MAX = 4000
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The newest assistant reply, whole.
|
|
162
|
+
*
|
|
163
|
+
* Same cleaning as `proseSnippet`, a far larger budget, and — when it does have to
|
|
164
|
+
* cut — an ellipsis, so a truncated reply is VISIBLY truncated. `proseSnippet` ends
|
|
165
|
+
* in a bare `slice`, which is how 1,821 characters became 160 with nothing on screen
|
|
166
|
+
* to say so.
|
|
167
|
+
*/
|
|
168
|
+
export function latestAssistantReply(text: string, max = LATEST_REPLY_MAX): string {
|
|
169
|
+
const body = proseBody(text)
|
|
170
|
+
if (!body || isWrapperPrompt(body)) return ''
|
|
171
|
+
return body.length <= max ? body : `${body.slice(0, max - 1)}…`
|
|
172
|
+
}
|
|
173
|
+
|
|
132
174
|
export function composeDiscussionSummary(input: {
|
|
133
175
|
title?: string
|
|
134
176
|
firstPrompt?: string
|
|
@@ -1034,6 +1076,92 @@ export const PARTIAL_HEAD_BYTES = 256 * 1024
|
|
|
1034
1076
|
* follow-up continues from. */
|
|
1035
1077
|
export const PARTIAL_TAIL_BYTES = 768 * 1024
|
|
1036
1078
|
|
|
1079
|
+
/**
|
|
1080
|
+
* Extra bytes the tail window may reach BACKWARDS to open at a record boundary.
|
|
1081
|
+
*
|
|
1082
|
+
* A single JSONL record can be larger than the whole tail window. Measured on this
|
|
1083
|
+
* Mac: 14 records over 768 KiB in a 60-transcript sample, the largest 1,239,045
|
|
1084
|
+
* bytes — 1.58x the window. When the record that straddles the window start is the
|
|
1085
|
+
* FINAL record, the window holds no complete record at all: `parseJsonLine` rejects
|
|
1086
|
+
* the one fragment it gets and the tail contributes NOTHING. No recent turns, and no
|
|
1087
|
+
* latest reply, at exactly the moment the user opened the session to see what just
|
|
1088
|
+
* happened. Reaching back one more MiB recovers the record instead.
|
|
1089
|
+
*
|
|
1090
|
+
* BOUNDED, because the point is to survive a big record and not to be dragged into an
|
|
1091
|
+
* unbounded read by a pathological one. Past this the tail keeps its ordinary start
|
|
1092
|
+
* and the loss stays honest rather than becoming a wrong "Latest".
|
|
1093
|
+
*/
|
|
1094
|
+
export const PARTIAL_TAIL_MAX_EXTRA_BYTES = 1024 * 1024
|
|
1095
|
+
|
|
1096
|
+
async function readRange(path: string, start: number, end: number): Promise<Buffer> {
|
|
1097
|
+
if (end < start) return Buffer.alloc(0)
|
|
1098
|
+
const chunks: Buffer[] = []
|
|
1099
|
+
for await (const chunk of createReadStream(path, { start, end })) {
|
|
1100
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
1101
|
+
}
|
|
1102
|
+
return Buffer.concat(chunks)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Where the LAST record in the file begins, found by walking back from EOF to the
|
|
1107
|
+
* newline that closed the record before it.
|
|
1108
|
+
*
|
|
1109
|
+
* Cheap in the ordinary case: the last record is small, so the first 64 KiB chunk
|
|
1110
|
+
* contains the boundary and the walk stops immediately. Returns -1 when no boundary
|
|
1111
|
+
* turns up within `limit` bytes, which is the signal to stop reaching.
|
|
1112
|
+
*/
|
|
1113
|
+
export async function lastRecordStart(path: string, size: number, limit: number): Promise<number> {
|
|
1114
|
+
if (size <= 0) return -1
|
|
1115
|
+
const CHUNK = 64 * 1024
|
|
1116
|
+
// A terminating newline CLOSES the last record; it does not open one. Counting it
|
|
1117
|
+
// would report the last record as starting at EOF and find every window empty.
|
|
1118
|
+
const tailByte = await readRange(path, size - 1, size - 1)
|
|
1119
|
+
const searchEnd = tailByte.length === 1 && tailByte[0] === 0x0a ? size - 1 : size
|
|
1120
|
+
if (searchEnd <= 0) return -1
|
|
1121
|
+
const floor = Math.max(0, size - limit)
|
|
1122
|
+
let pos = searchEnd
|
|
1123
|
+
while (pos > floor) {
|
|
1124
|
+
const start = Math.max(floor, pos - CHUNK)
|
|
1125
|
+
const buf = await readRange(path, start, pos - 1)
|
|
1126
|
+
const idx = buf.lastIndexOf(0x0a)
|
|
1127
|
+
if (idx >= 0) return start + idx + 1
|
|
1128
|
+
pos = start
|
|
1129
|
+
}
|
|
1130
|
+
// Reached the front of the file without a newline: the whole file is one record.
|
|
1131
|
+
return floor === 0 ? 0 : -1
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* The byte offset the tail window opens at.
|
|
1136
|
+
*
|
|
1137
|
+
* Normally `size - tailBytes`, mid-record, and the leading fragment is discarded by
|
|
1138
|
+
* design. The exception this exists for is a final record BIGGER than the window,
|
|
1139
|
+
* where discarding the fragment discards the entire tail.
|
|
1140
|
+
*/
|
|
1141
|
+
export async function tailWindowStart(
|
|
1142
|
+
path: string,
|
|
1143
|
+
size: number,
|
|
1144
|
+
headBytes: number,
|
|
1145
|
+
tailBytes: number,
|
|
1146
|
+
extraBytes: number,
|
|
1147
|
+
): Promise<number> {
|
|
1148
|
+
const preferred = Math.max(headBytes, size - tailBytes)
|
|
1149
|
+
const boundary = await lastRecordStart(path, size, tailBytes + extraBytes)
|
|
1150
|
+
// A boundary at or after the ordinary start means at least one complete record
|
|
1151
|
+
// already falls inside the window. Nothing to recover; keep the cheaper read.
|
|
1152
|
+
if (boundary < 0 || boundary >= preferred) return preferred
|
|
1153
|
+
// Reaching back BEHIND `headBytes` is allowed, and the instinct to clamp it here is
|
|
1154
|
+
// wrong — an earlier draft did clamp, which silently threw away the very record
|
|
1155
|
+
// this function exists to recover whenever the final record began inside the head
|
|
1156
|
+
// window. It cannot double-count: `boundary` is the start of the LAST record, and
|
|
1157
|
+
// the last record always runs to EOF, which is past `maxBytes` and therefore past
|
|
1158
|
+
// `headBytes`. The head pass can only ever see a prefix of it, arriving as a
|
|
1159
|
+
// trailing partial line that `parseJsonLine` rejects. Every record the head reads
|
|
1160
|
+
// WHOLE ends before `boundary`, so the tail never re-yields one. The read stays
|
|
1161
|
+
// bounded because `lastRecordStart` only looks back `tailBytes + extraBytes`.
|
|
1162
|
+
return boundary
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1037
1165
|
/**
|
|
1038
1166
|
* Lines of a transcript, reading the WHOLE file when it fits and head+tail when it
|
|
1039
1167
|
* does not.
|
|
@@ -1049,6 +1177,19 @@ export const PARTIAL_TAIL_BYTES = 768 * 1024
|
|
|
1049
1177
|
* `parseJsonLine` returns null for it and the caller's loop skips it, which is why
|
|
1050
1178
|
* this can slice at an arbitrary byte offset and stay correct.
|
|
1051
1179
|
*/
|
|
1180
|
+
/**
|
|
1181
|
+
* One transcript line, tagged with the window it came from.
|
|
1182
|
+
*
|
|
1183
|
+
* `tail` means "this line is in the window that ends at EOF". A whole-file read is
|
|
1184
|
+
* all tail, which is what makes the consumer's rule uniform: anything claiming to be
|
|
1185
|
+
* the LATEST state of a session must come from a tail line, no `truncated` special
|
|
1186
|
+
* case at the call site.
|
|
1187
|
+
*/
|
|
1188
|
+
export interface AgentSessionLine {
|
|
1189
|
+
text: string
|
|
1190
|
+
tail: boolean
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1052
1193
|
export async function* agentSessionLines(
|
|
1053
1194
|
path: string,
|
|
1054
1195
|
size: number,
|
|
@@ -1060,19 +1201,27 @@ export async function* agentSessionLines(
|
|
|
1060
1201
|
// windowing removed. It did exactly that until a mutation caught it.
|
|
1061
1202
|
headBytes = PARTIAL_HEAD_BYTES,
|
|
1062
1203
|
tailBytes = PARTIAL_TAIL_BYTES,
|
|
1063
|
-
|
|
1204
|
+
extraBytes = PARTIAL_TAIL_MAX_EXTRA_BYTES,
|
|
1205
|
+
): AsyncGenerator<AgentSessionLine> {
|
|
1064
1206
|
if (size <= maxBytes) {
|
|
1065
|
-
|
|
1207
|
+
for await (const text of createInterface({ input: createReadStream(path), crlfDelay: Infinity })) {
|
|
1208
|
+
yield { text, tail: true }
|
|
1209
|
+
}
|
|
1066
1210
|
return
|
|
1067
1211
|
}
|
|
1068
|
-
|
|
1212
|
+
for await (const text of createInterface({
|
|
1069
1213
|
input: createReadStream(path, { start: 0, end: headBytes - 1 }),
|
|
1070
1214
|
crlfDelay: Infinity,
|
|
1071
|
-
})
|
|
1072
|
-
|
|
1073
|
-
|
|
1215
|
+
})) {
|
|
1216
|
+
yield { text, tail: false }
|
|
1217
|
+
}
|
|
1218
|
+
const start = await tailWindowStart(path, size, headBytes, tailBytes, extraBytes)
|
|
1219
|
+
for await (const text of createInterface({
|
|
1220
|
+
input: createReadStream(path, { start }),
|
|
1074
1221
|
crlfDelay: Infinity,
|
|
1075
|
-
})
|
|
1222
|
+
})) {
|
|
1223
|
+
yield { text, tail: true }
|
|
1224
|
+
}
|
|
1076
1225
|
}
|
|
1077
1226
|
|
|
1078
1227
|
export interface AgentSessionDetail {
|
|
@@ -1086,6 +1235,12 @@ export interface AgentSessionDetail {
|
|
|
1086
1235
|
/** Deep, paginated body text for the detail page — up to 2000 chars. The list row
|
|
1087
1236
|
* keeps `discussion_summary` at 180. Older clients ignore this field. */
|
|
1088
1237
|
discussion_digest: string
|
|
1238
|
+
/** The newest assistant reply, whole, up to `LATEST_REPLY_MAX`. Its own field
|
|
1239
|
+
* rather than a bigger slice of the digest: the digest's job is to summarize a
|
|
1240
|
+
* whole session inside 2000 chars, and a long reply competing for that budget
|
|
1241
|
+
* starves the user turns — `composeDiscussionDigest` reserves the Latest block
|
|
1242
|
+
* FIRST. Two fields, two jobs. Older clients ignore this one. */
|
|
1243
|
+
latest_reply: string
|
|
1089
1244
|
user_message_count: number
|
|
1090
1245
|
assistant_message_count: number
|
|
1091
1246
|
omitted_tools: number
|
|
@@ -1110,6 +1265,7 @@ export async function parseAgentSession(
|
|
|
1110
1265
|
let sessionId = path.split('/').pop()?.replace(/\.jsonl$/, '') || ''
|
|
1111
1266
|
let firstPrompt = ''
|
|
1112
1267
|
let latestAssistant = ''
|
|
1268
|
+
let latestReply = ''
|
|
1113
1269
|
let userCount = 0
|
|
1114
1270
|
// Bounded sample for the deep digest: the opening turns plus a recent window.
|
|
1115
1271
|
// The composer only ever renders head + as many recent as fit 2000 chars, so
|
|
@@ -1142,7 +1298,33 @@ export async function parseAgentSession(
|
|
|
1142
1298
|
let assistantCount = 0
|
|
1143
1299
|
let omittedTools = 0
|
|
1144
1300
|
|
|
1145
|
-
|
|
1301
|
+
/**
|
|
1302
|
+
* Where the assistant left off — taken ONLY from the window that ends at EOF.
|
|
1303
|
+
*
|
|
1304
|
+
* An oversized transcript is read head-then-tail into one loop, and this used to be
|
|
1305
|
+
* plain last-write-wins across both. So a session whose tail window happens to hold
|
|
1306
|
+
* no assistant prose — entirely possible when the last 768 KiB are giant tool
|
|
1307
|
+
* results — kept whichever assistant row the HEAD saw and published it under the
|
|
1308
|
+
* label "Latest:". A line from the session's opening, presented as its current
|
|
1309
|
+
* state. Silently wrong, and worse than truncation: truncation is visible.
|
|
1310
|
+
*
|
|
1311
|
+
* With nothing in the tail the honest answer is nothing, and the digest drops its
|
|
1312
|
+
* Latest block rather than filling it with the wrong turn.
|
|
1313
|
+
*
|
|
1314
|
+
* The two fields move together, gated on the same snippet, so the summary line and
|
|
1315
|
+
* the full reply can never come from different records.
|
|
1316
|
+
*/
|
|
1317
|
+
const rememberAssistant = (text: string, fromTail: boolean): void => {
|
|
1318
|
+
if (!fromTail) return
|
|
1319
|
+
const snippet = proseSnippet(text)
|
|
1320
|
+
if (!snippet) return
|
|
1321
|
+
latestAssistant = snippet
|
|
1322
|
+
latestReply = latestAssistantReply(text)
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
for await (const { text: line, tail } of agentSessionLines(
|
|
1326
|
+
path, st.size, maxBytes, opts.headBytes, opts.tailBytes,
|
|
1327
|
+
)) {
|
|
1146
1328
|
const obj = parseJsonLine(line)
|
|
1147
1329
|
if (!obj) continue
|
|
1148
1330
|
if (provider === 'claude') {
|
|
@@ -1169,8 +1351,7 @@ export async function parseAgentSession(
|
|
|
1169
1351
|
if (!title) title = firstLineTitle(text)
|
|
1170
1352
|
} else {
|
|
1171
1353
|
assistantCount += 1
|
|
1172
|
-
|
|
1173
|
-
if (snippet) latestAssistant = snippet
|
|
1354
|
+
rememberAssistant(text, tail)
|
|
1174
1355
|
}
|
|
1175
1356
|
} else if (provider === 'codex') {
|
|
1176
1357
|
if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
|
|
@@ -1193,8 +1374,7 @@ export async function parseAgentSession(
|
|
|
1193
1374
|
if (!text || isWrapperPrompt(text)) continue
|
|
1194
1375
|
if (payload.role === 'assistant') {
|
|
1195
1376
|
assistantCount += 1
|
|
1196
|
-
|
|
1197
|
-
if (snippet) latestAssistant = snippet
|
|
1377
|
+
rememberAssistant(text, tail)
|
|
1198
1378
|
} else {
|
|
1199
1379
|
userCount += 1
|
|
1200
1380
|
collectTurn(text)
|
|
@@ -1219,8 +1399,7 @@ export async function parseAgentSession(
|
|
|
1219
1399
|
}
|
|
1220
1400
|
} else {
|
|
1221
1401
|
assistantCount += 1
|
|
1222
|
-
|
|
1223
|
-
if (snippet) latestAssistant = snippet
|
|
1402
|
+
rememberAssistant(text, tail)
|
|
1224
1403
|
}
|
|
1225
1404
|
}
|
|
1226
1405
|
}
|
|
@@ -1253,6 +1432,7 @@ export async function parseAgentSession(
|
|
|
1253
1432
|
totalTurns: truncated ? undefined : userCount,
|
|
1254
1433
|
truncated,
|
|
1255
1434
|
}),
|
|
1435
|
+
latest_reply: latestReply,
|
|
1256
1436
|
user_message_count: userCount,
|
|
1257
1437
|
assistant_message_count: assistantCount,
|
|
1258
1438
|
omitted_tools: omittedTools,
|
|
@@ -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
|