@gotcos/glasses-server 6.32.0 → 6.35.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,130 @@ unsaved capture, and makes batch status stop lying about finished work.
2219
2219
 
2220
2220
  # Changelog
2221
2221
 
2222
+ ## [6.35.0] - 2026-08-16
2223
+
2224
+ ### The newest assistant reply arrives whole instead of at 160 characters
2225
+
2226
+ - **The defect.** Miles opened a session on the glasses and the newest reply was cut
2227
+ off mid-sentence. The session detail payload had NO full-text field: it carried
2228
+ `discussion_summary` (180 chars) and `discussion_digest` (2000), and the newest
2229
+ reply appeared only as the `Latest:` line inside the digest, produced by
2230
+ `proseSnippet` at **160 characters** with a bare `slice` that stops mid-word and
2231
+ prints nothing to say it stopped. An 1821-character reply reached the wire as 160
2232
+ characters. The other 1661 never left the Mac. Polling was never the suspect: no
2233
+ poll can deliver bytes the server did not send.
2234
+ - **New field `latest_reply` on `GET /api/agent-sessions/:provider/:sessionId`,
2235
+ bounded at 4000 characters.** The number is measured, not guessed. Across 8,387
2236
+ assistant replies in the 60 most recent transcripts on this Mac: p50 153, p75 291,
2237
+ p90 1,627, p95 2,412, p99 3,405, max 21,757. The old 160-char cap delivered only
2238
+ **52.7%** of replies whole. 4000 delivers **99.58%** (35 of 8,387 cut). 6000 would
2239
+ buy 0.26 of a point for 50% more bytes on every fetch, and the reader paginates at
2240
+ roughly 200 chars, so 4000 is at most 20 swipes of deliberate reading.
2241
+ - **A dedicated field, not a bigger slice of the digest.** Three caps sit in series
2242
+ here (`proseSnippet` 160, `DIGEST_TURN_MAX` 220, `DISCUSSION_DIGEST_MAX` 2000) and
2243
+ the digest reserves its `Latest:` block FIRST, so a longer reply inside that budget
2244
+ starves the user turns it is supposed to sit beside. Two fields, two jobs.
2245
+ - **Truncation is now visible.** `latest_reply` ends in an ellipsis when it does have
2246
+ to cut. `proseSnippet` keeps its old bare 160-char slice, pinned by a test.
2247
+ - **ADDITIVE. An older app keeps working.** Nothing was removed or renamed:
2248
+ `discussion_summary` and `discussion_digest` still carry exactly what they carried,
2249
+ the digest still holds its own 160-char `Latest:` line, and a client that never
2250
+ learns the new key renders what it rendered before. Proven over real HTTP rather
2251
+ than by reading the route source.
2252
+
2253
+ ### Two latent hazards in the same path, both closed
2254
+
2255
+ - **"Latest" can no longer be a line from the session's opening.** An oversized
2256
+ transcript is read head-then-tail into one loop, and the newest reply was plain
2257
+ last-write-wins across both windows. A session whose tail window happened to hold
2258
+ no assistant prose, which is ordinary when the last 768 KiB are giant tool results,
2259
+ kept whichever assistant row the HEAD saw and published it labelled `Latest:`. A
2260
+ line from the start of the session, presented as its current state. Silently wrong,
2261
+ and worse than truncation because truncation is at least visible. Lines are now
2262
+ tagged with the window they came from and only the window ending at EOF can claim
2263
+ to be latest; with nothing there the honest answer is nothing, and the digest drops
2264
+ its `Latest:` block rather than filling it with the wrong turn. A whole-file read
2265
+ is all tail, so small sessions are unchanged.
2266
+ - **A record larger than the tail window no longer empties the tail.** The tail opens
2267
+ at `size - 768 KiB`, mid-record, and the leading fragment is discarded by design.
2268
+ When the FINAL record is bigger than the window, that fragment is the only thing in
2269
+ it: nothing parses, and the tail contributes no recent turns and no reply at all.
2270
+ Not hypothetical: 14 records over 768 KiB exist in a 60-transcript sample on this
2271
+ Mac, the largest 1,239,045 bytes, 1.58x the window. The tail now reaches back up to
2272
+ one further MiB to open at a real record boundary, and stops reaching past that so
2273
+ a pathological record cannot pull an unbounded read into memory.
2274
+ - **Measured, not asserted:** both hazards are LATENT today. Every transcript
2275
+ currently over the 32 MiB read ceiling has 8 to 19 assistant rows in its tail, so
2276
+ neither is firing right now. They are mechanisms, closed before they fire.
2277
+
2278
+ ### Notes
2279
+
2280
+ - One behaviour change beyond the additive field, and it is the point of the fix: on
2281
+ a truncated read whose tail holds no assistant prose, `discussion_summary` and the
2282
+ digest's `Latest:` block are now EMPTY where they used to carry a line from the
2283
+ session's opening. That removes wrong content, not content.
2284
+ - A mutation that survived is recorded rather than buried. An early draft clamped the
2285
+ tail start at `headBytes` to avoid double counting, and no test reached it. It was
2286
+ both untested and wrong: `lastRecordStart` returns the LAST record's start and that
2287
+ record always runs to EOF, so the head can only ever see a prefix it discards, and
2288
+ the clamp only threw away the record the reach exists to recover. The clamp is
2289
+ gone, a test now covers the case, and re-adding it fails.
2290
+
2291
+ ## [6.34.0] - 2026-08-16
2292
+
2293
+ ### A continued turn now runs with the session's own permissions
2294
+
2295
+ - **This widens what Continue can do.** A prompt spoken into the glasses can run
2296
+ tools on the Mac with nobody at the keyboard. Authorized explicitly by Miles
2297
+ after his first real continued turn came back reporting that every tool was
2298
+ disabled, which is not the point of the feature.
2299
+ - Claude drops `--permission-mode plan` and the empty `--tools`/`--allowedTools`
2300
+ pair. Codex now uses `getCodexTrustMode()`, the same posture ordinary Codex runs
2301
+ use on the host, rather than a stricter one invented at this call site. Absent
2302
+ `COS_CODEX_SANDBOX=workspace-write` that is still read-only, so this is never
2303
+ more permissive than the rest of the server.
2304
+ - **Unchanged, and load-bearing:** `COS_THREAD_ATTACH_ENABLED` still gates the
2305
+ surface and off still leaves the routes unregistered; `findBannedPermissionArg`
2306
+ still rejects every real bypass at the spawn boundary; delivery is still gated
2307
+ on a fresh occupancy probe, the epoch floor, the per-target claim and the head
2308
+ watermark. Dropping a lockdown is not adding a bypass.
2309
+ - The old read-only posture had NO test asserting it was present, so it could have
2310
+ been deleted silently. The new posture is pinned, and so is the bypass ban.
2311
+
2312
+ ## [6.33.0] - 2026-08-16
2313
+
2314
+ ### One switch turns Continue on
2315
+
2316
+ Continue was gated by two environment variables, and the second one was
2317
+ invisible. `COS_THREAD_ATTACH_ENABLED=1` registered the write routes;
2318
+ `COS_THREAD_ATTACH_IDLE_HOLDER=1` was additionally needed before COS would write
2319
+ into a thread whose Mac window is open but idle. COS Control's "Continue agent
2320
+ threads" checkbox sets only the first, so health reported the feature ON while
2321
+ every single attempt was refused, because a developer's editor windows stay
2322
+ open and that is precisely the case the second flag covered.
2323
+
2324
+ - **`COS_THREAD_ATTACH_ENABLED=1` is now the whole answer.** It registers the two
2325
+ write routes and wires the transcript clock that tells an idle holder from a
2326
+ working one. Nothing else to set.
2327
+ - **`COS_THREAD_ATTACH_IDLE_HOLDER` is REMOVED, not deprecated.** If you set it
2328
+ by hand, it is now ignored and can be deleted from your LaunchAgent plist or
2329
+ shell profile. Leaving it in place changes nothing either way.
2330
+ - **COS Control needs no update for this.** Its existing checkbox already writes
2331
+ the surviving key, so the toggle that reported ON while refusing now reports
2332
+ ON and works.
2333
+
2334
+ The two switches were never independently useful, and folding them is the honest
2335
+ description of the decision rather than a convenience. The relaxation is what
2336
+ makes a silent fork possible, so "Continue is on" and "a fork may happen" are one
2337
+ choice, and one switch states it.
2338
+
2339
+ Every safety behaviour is unchanged. A holder measured WRITING is still refused
2340
+ with `native_thread_working`; a holder COS cannot measure is still refused with
2341
+ `live_desktop_process`, because only a positive idle observation relaxes the gate
2342
+ and an unreadable transcript is not one. Off still means the write routes are not
2343
+ registered at all, so a disabled server answers 404 rather than 403 and holds no
2344
+ reachable write code.
2345
+
2222
2346
  ## [6.32.0] - 2026-08-16
2223
2347
 
2224
2348
  ### Continue now blocks on WORKING, not on merely open
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.32.0",
3
+ "version": "6.35.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
@@ -20,15 +20,13 @@ 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
22
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
23
- import { createAgentSessionBindingsRouter } from './routes/agent-session-bindings.js'
23
+ import {
24
+ createAgentSessionBindingsRouter,
25
+ threadAttachEnabled,
26
+ } from './routes/agent-session-bindings.js'
24
27
  import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
25
28
  import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
26
- import {
27
- idleHolderContinueEnabled,
28
- realOccupancyDirs,
29
- realOccupancyProbes,
30
- withTranscriptClock,
31
- } from './lib/occupancy-probes.js'
29
+ import { buildOccupancyProbes, realOccupancyDirs } from './lib/occupancy-probes.js'
32
30
  import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
33
31
  import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
34
32
  import { forkThread, realForkDeps } from './lib/fork-thread.js'
@@ -310,13 +308,20 @@ const occupancyDirs = realOccupancyDirs()
310
308
  const nativeHeadDeps = realNativeHeadDeps()
311
309
  const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
312
310
  /**
313
- * THE ONE PLACE the idle-holder relaxation is switched on (6.32.0).
311
+ * THE ONE PLACE the idle-holder relaxation is switched on, on the ONE flag
312
+ * that also registers the write routes (6.33.0).
314
313
  *
315
314
  * Without the clock, a foreign holder is terminal and Continue is refused for
316
315
  * any thread with a Claude Code window open on it — including the ones Miles
317
316
  * actually works in, because he leaves those windows open. With it, a holder
318
317
  * measured idle is continuable and only a holder measured WRITING is refused.
319
318
  *
319
+ * `threadAttachEnabled()` is the same call `createAgentSessionBindingsRouter`
320
+ * makes below to decide whether the two POST routes exist at all. Reading it
321
+ * here rather than a second env key is what makes "Continue is on" a single
322
+ * answer: the pair of flags this replaced could disagree, and did, reporting
323
+ * the feature enabled while every attempt was refused.
324
+ *
320
325
  * Both gates read this same object — the route's detector via `probes` below,
321
326
  * and the adapter's pre-spawn preflight via `occupancyProbes` at the closure
322
327
  * further down — so they cannot come to different conclusions. That is the whole
@@ -325,9 +330,7 @@ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
325
330
  * Read the canary evidence in `thread-occupancy.ts` under THE IDLE-HOLDER
326
331
  * RELAXATION before changing this line.
327
332
  */
328
- const occupancyProbes = idleHolderContinueEnabled()
329
- ? withTranscriptClock(realOccupancyProbes(cosSpawnedPids), nativeHeadDeps)
330
- : realOccupancyProbes(cosSpawnedPids)
333
+ const occupancyProbes = buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
331
334
 
332
335
  /**
333
336
  * The shim between the route's request shape and the adapter's.
@@ -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
- let body = text.replace(/```[\s\S]*?```/g, ' ')
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
- ): AsyncGenerator<string> {
1204
+ extraBytes = PARTIAL_TAIL_MAX_EXTRA_BYTES,
1205
+ ): AsyncGenerator<AgentSessionLine> {
1064
1206
  if (size <= maxBytes) {
1065
- yield* createInterface({ input: createReadStream(path), crlfDelay: Infinity })
1207
+ for await (const text of createInterface({ input: createReadStream(path), crlfDelay: Infinity })) {
1208
+ yield { text, tail: true }
1209
+ }
1066
1210
  return
1067
1211
  }
1068
- yield* createInterface({
1212
+ for await (const text of createInterface({
1069
1213
  input: createReadStream(path, { start: 0, end: headBytes - 1 }),
1070
1214
  crlfDelay: Infinity,
1071
- })
1072
- yield* createInterface({
1073
- input: createReadStream(path, { start: Math.max(headBytes, size - tailBytes) }),
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
- for await (const line of agentSessionLines(path, st.size, maxBytes, opts.headBytes, opts.tailBytes)) {
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
- const snippet = proseSnippet(text)
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
- const snippet = proseSnippet(text)
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
- const snippet = proseSnippet(text)
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,
@@ -93,6 +93,7 @@ import { isValidNativeThreadId } from './native-thread-id.js'
93
93
  import { isBindableProvider, type BindableProvider } from './agent-session-binding-store.js'
94
94
  import { recordCosSpawn, releaseCosSpawn } from './agent-session-ownership-store.js'
95
95
  import { processStartMs as realProcessStartMs } from './occupancy-probes.js'
96
+ import { getCodexTrustMode } from './codex-run-ledger.js'
96
97
 
97
98
  /** Providers with a certified attached path. Cursor is Fork-only (plan 2.5). */
98
99
  export type AttachedProvider = BindableProvider
@@ -529,17 +530,35 @@ const STRIPPED_ENV_KEYS: readonly string[] = ['CLAUDECODE', 'COS_API_TOKEN']
529
530
  // ---------------------------------------------------------------------------
530
531
 
531
532
  /**
532
- * Claude: `claude -p --resume <id>`, read-only, prompt on stdin.
533
+ * Claude: `claude -p --resume <id>`, prompt on stdin.
533
534
  *
534
535
  * The prompt is NOT an argv element. argv is world-readable through `ps`, and
535
536
  * the prompt is the user's private text; stdin also matches what both ordinary
536
537
  * bridges already do.
537
538
  *
538
- * Two independent read-only layers, because either alone is one assumption
539
- * deep: `--permission-mode plan` means the model cannot mutate regardless of
540
- * its tool list, and the empty `--tools`/`--allowedTools` pair is the exact
541
- * text-only posture `claude-permissions.ts` already ships for untrusted mode.
542
- * If an older CLI rejects `plan`, it exits non-zero and we fail closed.
539
+ * THE TURN INHERITS THE SESSION'S OWN PERMISSIONS (Miles, 2026-08-16, explicit).
540
+ *
541
+ * This used to force two independent read-only layers: `--permission-mode plan`
542
+ * plus an empty `--tools`/`--allowedTools` pair. The result was a Continue that
543
+ * could talk into a thread and do nothing in it -- his first real continued turn
544
+ * came back reporting that every tool was disabled, which is not "like being at
545
+ * the desk". Both layers are gone; a resumed turn now runs as that session
546
+ * ordinarily would.
547
+ *
548
+ * What that means plainly: a prompt spoken into a pair of glasses can run tools
549
+ * on this Mac with nobody at the keyboard. That is the intent, not an oversight.
550
+ * The protections that remain, and that must not be quietly removed with it:
551
+ *
552
+ * 1. `COS_THREAD_ATTACH_ENABLED` still gates the whole surface, and off means
553
+ * the write routes are never registered.
554
+ * 2. `findBannedPermissionArg` still runs at the spawn boundary and still
555
+ * rejects the actual bypasses -- `--dangerously-skip-permissions`, `--yolo`,
556
+ * `danger-full-access` and the rest. Dropping a lockdown is not the same as
557
+ * adding a bypass, and the bypass ban is unchanged.
558
+ * 3. The menu cursor rests on a row that cannot act, so a false-positive ring
559
+ * tap cannot reach Continue (`defaultSessionThreadActionIndex`).
560
+ * 4. Delivery is still gated on a fresh occupancy probe, the epoch floor, the
561
+ * per-target claim and the head watermark.
543
562
  */
544
563
  export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
545
564
  return [
@@ -549,9 +568,6 @@ export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
549
568
  // never observe the session id we are required to verify.
550
569
  '--verbose',
551
570
  '--resume', nativeThreadId,
552
- '--permission-mode', 'plan',
553
- '--tools', '',
554
- '--allowedTools', '',
555
571
  ]
556
572
  }
557
573
 
@@ -571,7 +587,10 @@ export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
571
587
  export function buildCodexAttachedArgs(nativeThreadId: string, cwd: string): string[] {
572
588
  return [
573
589
  'exec',
574
- '--sandbox', 'read-only',
590
+ // The posture ordinary Codex runs use on this host, not a stricter one
591
+ // invented here. `COS_CODEX_SANDBOX=workspace-write` opts in; absent stays
592
+ // read-only, so this is never MORE permissive than the rest of the server.
593
+ '--sandbox', getCodexTrustMode(),
575
594
  '--cd', cwd,
576
595
  'resume',
577
596
  '--json',
@@ -446,21 +446,39 @@ export function realOccupancyProbes(ledger: SpawnLedgerAccessor): OccupancyProbe
446
446
  }
447
447
 
448
448
  /**
449
- * Is the idle-holder relaxation switched on for this install?
449
+ * The probe set this install should run, clock and all.
450
450
  *
451
- * ONE READER, exported so every other module imports it rather than re-reading
452
- * `process.env` the same anti-drift rule `threadAttachEnabled` follows, and for
453
- * the same reason: a second copy is how a surface comes to advertise a write path
454
- * the gate will refuse.
451
+ * ONE DECISION, IN ONE PLACE (6.33.0). `attachEnabled` is the SAME answer that
452
+ * decides whether the two write routes get registered `threadAttachEnabled()`,
453
+ * passed in by the composition root rather than re-read here. There is exactly
454
+ * one reader of `COS_THREAD_ATTACH_ENABLED` in the server, and a second copy is
455
+ * how a surface comes to advertise a write path the gate will refuse.
455
456
  *
456
- * `=== '1'` opt-IN, not `!== '0'` opt-out. This decides whether COS may write
457
- * into a conversation a human still has open, so anything ambiguous unset,
458
- * empty, 'true', 'yes' reads as OFF. It composes with
459
- * `COS_THREAD_ATTACH_ENABLED`: with attach itself off, this flag does nothing at
460
- * all, because there is no write path to relax.
457
+ * WHY THE CLOCK RIDES THE SAME SWITCH. In 6.32.0 the relaxation had a second
458
+ * env key of its own (named in the 6.33.0 changelog entry; it is now ignored),
459
+ * and the two were never independently useful: without the clock, Continue is
460
+ * refused on every thread with an editor window open, which for a working
461
+ * developer is all of them. The pair also cost a real testing cycle, because
462
+ * COS Control's checkbox knew only the first key and reported the feature ON
463
+ * while the gate still refused every session. And the honest reading is that
464
+ * they were always ONE decision: the relaxation is what makes a silent fork
465
+ * possible, so "Continue is on" and "fork is possible" are the same choice, and
466
+ * one switch says so.
467
+ *
468
+ * Attach off is still byte-for-byte the pre-6.28 posture: no clock, so every
469
+ * foreign holder reads `unknown` and refuses, and the write routes do not exist
470
+ * to be reached anyway.
461
471
  */
462
- export function idleHolderContinueEnabled(): boolean {
463
- return process.env.COS_THREAD_ATTACH_IDLE_HOLDER === '1'
472
+ export function buildOccupancyProbes(
473
+ ledger: SpawnLedgerAccessor,
474
+ headDeps: NativeHeadDeps,
475
+ attachEnabled: boolean,
476
+ ): OccupancyProbes {
477
+ const base = realOccupancyProbes(ledger)
478
+ // Strictly `=== true`. A caller that hands over a truthy non-boolean has not
479
+ // answered the question, and the permissive branch is the one that must be
480
+ // earned by a real yes.
481
+ return attachEnabled === true ? withTranscriptClock(base, headDeps) : base
464
482
  }
465
483
 
466
484
  /**
@@ -142,8 +142,9 @@ export interface OccupancyProbes {
142
142
  * foreign holder stays `live_desktop_process` — byte-for-byte the behaviour
143
143
  * that shipped before the idle-holder relaxation existed. That is why it is
144
144
  * optional rather than required: the strict gate is what you get by doing
145
- * nothing, and the relaxation has to be wired on purpose (see `server/index.ts`,
146
- * where the wiring is itself behind `COS_THREAD_ATTACH_IDLE_HOLDER`).
145
+ * nothing, and the relaxation is wired on purpose by `buildOccupancyProbes`
146
+ * in `occupancy-probes.ts`, which attaches the clock only when thread attach
147
+ * itself is on.
147
148
  *
148
149
  * Null is NOT idle. See `holderActivity` for why that distinction is the whole
149
150
  * safety property here.
@@ -525,9 +526,12 @@ export interface OccupancyDirs {
525
526
  // `isActiveRecently` here; its `false` means "stale OR unmeasurable", and only
526
527
  // `holderActivity` separates those.
527
528
  //
528
- // TURNING IT OFF. Unwire `probes.transcriptMtimeMs` (in practice: unset
529
- // `COS_THREAD_ATTACH_IDLE_HOLDER`). Every foreign holder then reads `unknown`
530
- // and refuses, which is the pre-6.32.0 gate exactly. No rollback needed.
529
+ // TURNING IT OFF. Unwire `probes.transcriptMtimeMs`. Since 6.33.0 that happens
530
+ // by unsetting `COS_THREAD_ATTACH_ENABLED`, the one switch that also unregisters
531
+ // the write routes: the relaxation is what makes a silent fork possible, so it
532
+ // is the same decision as Continue itself and no longer has a flag of its own.
533
+ // Every foreign holder then reads `unknown` and refuses, which is the pre-6.32.0
534
+ // gate exactly, and with attach off there is no write route to reach anyway.
531
535
 
532
536
  /**
533
537
  * The Phase 0 attach precondition.
@@ -366,6 +366,12 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
366
366
  // summary — Miles: "it should be in the body not the title, the row should
367
367
  // be no more than the 180 characters."
368
368
  discussion_digest: parsed.discussion_digest || '',
369
+ // The newest assistant reply, whole. ADDITIVE: the digest above still carries
370
+ // its own 160-char `Latest:` line, so a client that never learns this field
371
+ // renders exactly what it rendered before. Measured at 4000 chars — see
372
+ // LATEST_REPLY_MAX. Before this existed the detail payload had NO full-text
373
+ // field at all, and an 1821-char reply left the Mac as 160 characters.
374
+ latest_reply: parsed.latest_reply || '',
369
375
  truncated: parsed.truncated,
370
376
  project: parsed.project,
371
377
  created: modified,