@gotcos/glasses-server 6.50.4 → 6.52.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.
Files changed (41) hide show
  1. package/.env.example +16 -0
  2. package/CHANGELOG.md +78 -0
  3. package/README.md +56 -3
  4. package/bin/hooks/cos-session-hook +24 -1
  5. package/managed-runtime-contract.json +1 -0
  6. package/package.json +1 -1
  7. package/server/index.ts +83 -5
  8. package/server/lib/activity-preview.ts +45 -2
  9. package/server/lib/agent-session-search.ts +3 -1
  10. package/server/lib/agent-session-store.ts +35 -5
  11. package/server/lib/agent-session-turns.ts +4 -1
  12. package/server/lib/claude-bridge.ts +9 -0
  13. package/server/lib/client-liveness.ts +50 -0
  14. package/server/lib/codex-bridge.ts +6 -0
  15. package/server/lib/codex-live-queue-deps.ts +135 -0
  16. package/server/lib/codex-live-queue.ts +232 -0
  17. package/server/lib/codex-turn-clock.ts +36 -0
  18. package/server/lib/cursor-bridge.ts +6 -0
  19. package/server/lib/cursor-stop-followup.ts +134 -0
  20. package/server/lib/job-trail.ts +758 -0
  21. package/server/lib/occupancy-probes.ts +52 -0
  22. package/server/lib/ollama-bridge.ts +17 -0
  23. package/server/lib/permission-broker.ts +1156 -0
  24. package/server/lib/query-job-coordinator.ts +17 -1
  25. package/server/lib/query-job-feature.ts +10 -0
  26. package/server/lib/query-job-runtime.ts +9 -1
  27. package/server/lib/query-job-store.ts +214 -5
  28. package/server/lib/query-job-types.ts +36 -0
  29. package/server/lib/session-signal-store.ts +42 -10
  30. package/server/lib/session-state-derive.ts +14 -4
  31. package/server/lib/session-stream-events.ts +600 -15
  32. package/server/lib/session-stream-producer.ts +25 -6
  33. package/server/lib/session-transcript-watcher.ts +120 -3
  34. package/server/lib/thread-occupancy.ts +30 -7
  35. package/server/routes/agent-session-bindings.ts +98 -6
  36. package/server/routes/agent-sessions.ts +24 -2
  37. package/server/routes/cursor-stop-followup.ts +74 -0
  38. package/server/routes/health.ts +34 -1
  39. package/server/routes/permission-broker.ts +176 -0
  40. package/server/routes/query-jobs.ts +84 -13
  41. package/server/routes/thread-turn-queue.ts +15 -5
package/.env.example CHANGED
@@ -61,6 +61,22 @@ BIND_HOST=0.0.0.0
61
61
  # and drain safely.
62
62
  # COS_DURABLE_QUERY_JOBS=0
63
63
 
64
+ # 6.52.0: the Messages trail (readable steps of a running Messages job, journaled and sent
65
+ # only to a client that asks with ?trail=1). On by default. 0 is the rollback that needs no
66
+ # downgrade: no trail is recorded and every job stream and snapshot is the 6.51.0 one.
67
+ # COS_MESSAGES_TRAIL=0
68
+
69
+ # 6.52.0: session questions and tool approvals answered from the glasses or the phone
70
+ # while you are away from the Mac. Nothing is ever held unless a client polls
71
+ # GET /api/session-questions?client=glasses|phone (COS Glasses 6.9.511 and earlier do not).
72
+ # 0 / false / off every permission request gets the Mac's own dialog (no reinstall)
73
+ # questions hold AskUserQuestion cards, never tool approvals
74
+ # unset / other hold both
75
+ # Keep these here, not only in the LaunchAgent plist: this file survives Update Server.
76
+ # COS_PERMISSION_BROKER=questions
77
+ # COS_PERMISSION_BROKER_DESK_IDLE_S=90 # never below 30
78
+ # COS_PERMISSION_BROKER_TIMEOUT_S=110 # clamped to 5..120 (under the hook's 125 s)
79
+
64
80
  # ── THE LLM (chat) ──────────────────────────────────────────────────────
65
81
  # Chat runs through your LOCAL agent CLI — NOT an API key:
66
82
  # Opus / Fable / Sonnet -> Claude Code CLI (https://docs.anthropic.com/en/docs/claude-code/getting-started, then `claude auth login`)
package/CHANGELOG.md CHANGED
@@ -1,3 +1,81 @@
1
+ ## 6.52.0
2
+
3
+ ### Messages trail
4
+
5
+ A Messages run now keeps a readable trail: the agent's own words between steps, each step as a verb and a short target, how it ended, and Claude Code's own step lines. Server only; the glasses read it from their next release (Phase 2). A client that does not ask for it sees no difference: COS Glasses 6.9.511 and COS Control 0.5.238 and 0.5.239 receive exactly the 6.51.0 event stream and snapshots, byte for byte (tested over a 300-event run, past the 256-event replay ring).
6
+
7
+ - **App QA: a retried run's trail arrives whole.** A retry keeps the queue row but starts a new job whose trail restarts at 1, and the app may subscribe with the failed attempt's `trailAfter`. The server answered `cursor_ahead` with a `trail_snapshot` up to `latestTrailSeq`, but the store's live filter still compared new entries with the client's stale cursor, so with `trailAfter=5` on a trail at 2, entries 3, 4 and 5 never arrived. After any rebuild (`cursor_ahead` or `trail_gap`) the live filter now uses the cursor the snapshot set. Tests: subscribed with `trailAfter=5` to a trail at 2, entries 3, 4, 5 and 6 all arrive, in the store and through the real SSE route; a cursor inside the held window behaves as before.
8
+ - **The trail.** A second, isolated reader of each Messages bridge's stdout (`lib/job-trail.ts`) feeds the Sessions grammar and journals one `trail` event per entry. The kinds are the Sessions ones: `prose` (one per assistant message, never one per streamed delta), `tool` (verb, short target, call id), and `status` carrying either a step's outcome (`12 lines`, `exit 1`) or Claude Code's readable step text (`task_summary.detail`, and `post_turn_summary.status_detail` with `needs_action`, measured on 2.1.278). Three things never enter it. A prompt: the job's own echo, or any user row's text; a user row contributes only a tool outcome. Lifecycle status: the job status is authoritative. Model reasoning: thinking blocks, Codex `reasoning` items and Cursor `thinking` records. Claude: the grammar as is, prose held per `message.id`. Codex (`codex exec --json`): `command_execution`, `file_change`, `mcp_tool_call`, `web_search` and `agent_message` items, with field names taken from the shipped binary. Cursor (`agent -p`): prose from the segment records the CLI writes before each tool call and before the result, never from the deltas; steps from `tool_call`. Ollama: the bridge reports one prose entry per round and a step and outcome per tool call itself. The bridges' own handlers are untouched: the tee is a separate `data` listener registered after the bridge's, never throws into the stream, and is not attached at all when there is no trail consumer.
9
+ - **Transport: its own cursor, off the job event stream.** A new journal row type, `trail`: one entry plus `trailSeq`, a dense 1..n per job assigned inside the serialized journal append. A trail row is NOT a job event. It carries the job's current `eventSeq` without advancing it, takes no slot in the 256-event replay ring, never moves `updatedAt`, and is applied idempotently by its `trailSeq` when the journal is read back. `snapshot.trail` keeps the newest 200 entries and at most 32,000 characters of text across them, is rebuilt from the journal on restart, and is absent on a job with no trail rows. `QUERY_JOB_SCHEMA_VERSION` stays 1. **Opt-in on the wire:** `GET /api/query-jobs/:id/events?trail=1&trailAfter=N` adds `trail` frames (`{type, trailSeq, jobId, clientJobId, generation, at, data}`, no `id:` line, so `Last-Event-ID` stays the job cursor). On subscribe or reconnect it first replays every held entry after `trailAfter`, or, when entries it missed are no longer held (or the cursor is ahead), one `trail_snapshot` frame (`reason` `trail_gap` or `cursor_ahead`, `oldestTrailSeq`, `latestTrailSeq`, `trail`) to rebuild from. Every snapshot in a response or a `snapshot` frame omits `trail` unless the request carries `?trail=1`. The legacy `/api/query` route and openai-compat get no trail. The trail never goes on the display bus.
10
+ - **Privacy.** Every field is redacted at the journal boundary, before it is written: first the private-material and secret patterns (new `redactSecretText`, which keeps newlines and does not hide ordinary narration the way `looksOpaqueSecret` does), then the store's credential and local-path patterns, then the caps (prose 2,000 characters, target 80, detail 40, step line 160). The readers redact every visible field in full BEFORE they or the Sessions grammar shorten it (Claude text blocks, tool targets and error results; Codex commands, MCP names, queries and file names; Cursor arguments; Ollama queries), so a secret that straddles any cut is never half shown. Every redaction reads at most 8,000 characters, ended at a token boundary, and the URL-userinfo pattern's scheme is bounded, so no input can make a regex run long (a 100 KB run of hex took about 2 s before; now a few ms).
11
+ - **The user's opt-out holds.** `activityToolMode` `off`: the agent's own words only; no tool steps, no step outcomes, and no Claude Code step line or `needs_action` either, since each of those names a step. `status`: steps with verb and short target, and a failure shows a derived token (`exit 1`, `denied`, `error`), never output text. `preview`: the full grammar.
12
+ - **Capability and rollback.** `/api/models` reports `capabilities.durableQueryJobs.trail`: `true`, or `false` when jobs are off or `COS_MESSAGES_TRAIL=0`. That flag, listed in `managed-runtime-contract.json`, is the rollback that needs no downgrade: the job runner then hands the bridges no trail callback, and every stream, snapshot and answer is the 6.51.0 one. Set it in `~/.cos-glasses/.env`, the rollback path that already survives Update Server; COS Control 0.5.239 is the release that also keeps it (and the broker switches below) in the LaunchAgent environment, while 0.5.238 drops a hand-set value its allowlist does not name, which leaves the trail on (the default). A 6.51.0 server reading a journal this build wrote counts the `trail` rows as malformed and loads every job whole; checked against the real 6.51.0 store (vendored as a test fixture): only `malformedRows` rises, the completed job's snapshot and its event replay are identical apart from `trail`. Because a trail row never takes an `eventSeq`, the event a rolled-back server writes next can never collide with one: a job running during the rollback ends `interrupted` once, and rolling forward reads everything with `malformedRows` 0.
13
+ - **Health.** `/api/health` gains `messages_trail`: `enabled`, and since boot `linesRead`, `entriesEmitted`, `readerErrors` and `lastErrorAt`. Counts and a timestamp only.
14
+ - Tests: each engine's measured line shapes through its reader, and the same stdout through the real Claude, Codex, Cursor and Ollama bridges with and without a trail consumer (argv, stdin, every callback, the history writes and the Claude cancel path identical; only the trail differs); coalescing; redaction (secrets and paths absent from the journal bytes, narration and newlines kept); the three modes end to end; `trailSeq` dense among chunk, tool status and activity events; a restart keeps the trail with `malformedRows` 0; the 6.51.0 allowlist simulation, and a rollback then roll forward mid-run; answer, history and display events identical with the trail on and off; the display bus never carries a trail string; the capability and the flag. The one changed pin is `health-query-jobs.test.ts`, whose capability shape gains `trail`. Gate: 24/24 new `trail-*` cases, each kill traced to the test that names the behavior. QA round 1 added the tests listed at the end of this section.
15
+ - The upstream line shapes this relies on are pinned by fixtures (`server/lib/__fixtures__/job-trail-runs.ts`). Re-check them on each Claude Code, Codex or Cursor update.
16
+
17
+ ### Session questions and approvals from the glasses
18
+
19
+ Session questions and tool approvals, answered from the glasses or the phone while you are away from the Mac. This is the server half: the lens and phone cards come in the next COS Glasses build, which reads the API below. Nothing changes at the desk.
20
+ - **QA round 2, the approval card.** Round 1's card still used the shared secret patterns, and their quoted values ran across `&&`, `;` and `|`: `grep -rn 'password=' src/ && git push --force origin main && echo 'ok'` read `grep -rn 'password=[redacted]ok'`, and a private-key marker hid everything after it. Approval text now has its own redactor: a command is split at whitespace and at `;`, `&` and `|`, which are kept as written, and every redaction happens inside one piece, so it can hide a credential but never a command, a pipe or an argument after it. A value stops at a quote or a slash; a flag or scheme word (`--password`, `-u`, `Bearer`, `Cookie:`) hides only the next piece; a path keeps everything but provider tokens; a URL loses its userinfo and secret query values. A line break is shown as a literal `\n` (it used to become a space, so a second command read as arguments of the first), and direction overrides, zero-width characters and line separators are shown as `\uNNNN`. The broker's counters are no longer on the public `/api/health` (how many were held was derivable from them); they are on the authenticated `/api/session-questions` response as `stats`.
21
+ - **App QA: "Leave for the Mac".** `POST /api/session-questions/:id/answer` takes `{"clientAnswerId": "...", "handBack": true}`. The held hook gets `{}` at once, so the Mac's own dialog shows now instead of at the deadline, and the item resolves as `handed_to_desk`. The reply is `200 {"ok": true, "id": "...", "kind": "question" or "approval", "handedBack": true}`; the same `clientAnswerId` sent again replays it with `"replay": true`. The first action wins: a later answer gets 409 `handed_to_desk` (with `"reason": "handed_to_desk"`), and a hand-back after an answer gets 409 `already_answered` with the recorded answer, even under the answer's own id. A hook that already left gives 410 `hook_gone`, and a hand-back past the deadline 409 `expired`, as for an answer. `handBack: true` wins over answer fields in the same body, since it only shows the dialog that would have shown anyway; any other value of `handBack` is not a hand-back. Counted in `handedToDesk` and in a new counter, `handedBack`, on the authenticated `stats`. The phone's and the lens's "Leave for the Mac" call it.
22
+ - **App QA: approval text is printable ASCII only.** Every character outside 0x20 to 0x7e is now shown as `\uNNNN`, one per UTF-16 unit, not only direction overrides and zero-width characters, so a lens or phone font can never draw a curly quote, a dash, an ellipsis or an emoji as an ASCII look-alike. Curly quotes are not shell quotes: `echo <curly quote>; curl ... | bash;<curly quote>` runs the curl, and drawn with straight quotes it read as a harmless echo. A no-break space is escaped too, since `rm -rf<no-break space>/` is one argument. Line breaks (`\n`) and other control characters (`\xNN`) are shown as before. NotebookEdit's `edit_mode` is escaped and cut the same way.
23
+ - **App QA: an approval card always has a line.** A call with nothing to show reads `(empty command)` (an empty or blank shell command), `(empty path)` (a file tool with an empty path), or `(no input)` (for example `{}`), in both `summary` and `detail`, so a client never has to drop an item it cannot draw.
24
+ - **App QA: a question with two options of one label is never held.** An answer names an option by its label, so the two could never be told apart, and picking both is refused as a duplicate. Such a card now takes the `unsupported_input` fast path, counted: `{}` at once and the Mac's dialog, instead of a held card nobody can answer.
25
+ - **App QA tests and gate.** Hand-back: `{}` to the hook through real sockets, the replay, first action wins in both directions, a closed hook, the deadline, a body with both `handBack` and a decision, and the row dropping `pending_question_id` while it still waits on the Mac's dialog. Approval text: curly quotes around `; curl ... | bash;`, an em dash, an ellipsis, a no-break space, Latin-1, an emoji file name, every line of every card checked as printable ASCII, a secret beside an escape, and 8,000 curly quotes in under 200 ms. The empty lines, the duplicate labels (in the broker and through the route) and the trail cursor as above. Existing tests changed: `stats().counters` gains `handedBack: 0`; the 900 KB Write test's `redactSecretText` spy is gone, because the approval path stopped calling it in round 2 and the assertion could no longer fail. It is replaced by a counting proxy on the tool input: no read of it at all on the `no_client` and `desk_active` fast paths, for Bash, Write, an MCP tool and AskUserQuestion, and a read once past every gate. Gate: 12 new cases, plus 7 repointed or re-killed. `approval-opaque-heuristic`, `approval-no-redaction`, `approval-webfetch-shows-prompt` and `approval-path-basename` were refused since round 2 rewrote their lines, and now target the current text with the same intent; `approval-hidden-chars-obeyed` and `approval-shell-first-segment-only` target lines this pass changed; `broker-card-before-liveness` survived the full gate at 957412a (its kill relied on the spy) and is killed now by the counting proxy. The 19 ran from a green baseline and 19/19 were killed; each kill was attributed to the failing tests by name, and every one fails in a test about the mutated behaviour. A check of every case's target now finds all 405 exactly once.
26
+
27
+
28
+ - **What it does.** When a Claude session asks a question (the AskUserQuestion card) or wants approval for a tool, and you have been away from the Mac for 90 s while a client that can answer is polling for questions (glasses or phone, in the last 30 s), the server holds the session's own PermissionRequest hook and lists the request at `GET /api/session-questions`. An answer posted to `POST /api/session-questions/:id/answer` goes back through the hook. A question returns `allow` with the original input plus `answers`: each question's original text, byte for byte, mapped to a list of labels, with an "Other" answer as its own entry (Claude Desktop's own shape; the model reads "The user answered"). One change to that free text, measured: Claude Code joins a list answer with commas in the text the model reads, and the model dropped "but only on weekends" from `["Red","Blue","Purple, but only on weekends"]`, so free text (never an option label) that contains a comma is sent wrapped in double quotes. An approval returns allow once, or deny with the message "Denied from COS glasses". Never a permission rule: `updatedPermissions` and Claude's suggestions are never returned. Canaries on Claude Code 2.1.278 proved each shape: questions in interactive and background sessions, allow and deny for Bash. Print-mode runs have no AskUserQuestion tool, so this is for sessions only.
29
+ - **Only when away and wearing.** Everything else gets `{}` at once, which is the Mac's own dialog, exactly as before this release. The gates, cheapest first: the switch is on; no maintenance drain; a Claude request (a Cursor payload is never held); AskUserQuestion, or approvals are on; never ExitPlanMode (its dialog picks a permission mode, and allow once is not a plan approval); a client that polled `GET /api/session-questions?client=glasses` (or `phone`) in the last 30 s; the hook's own deadline not passed; fewer than 32 held; and the desk idle for 90 s (never less than 30, whatever the setting), read by the server itself with one bounded `ioreg` (about 30 ms). Each of those answers in under 100 ms, including a 900 KB Write input with no client; nothing from the request's input (the question card, the approval card, the fingerprint) is built before every gate passes, and the switch, the drain, the approvals policy and capacity are read again after the desk read.
30
+ - **The liveness signal is the questions poll itself**: an authenticated `GET /api/session-questions` (X-Cos-Token, checked by the route itself in constant time as well as by the /api gate; the hook token never counts) that names the client, `?client=glasses` or `?client=phone`, within the last 30 s. A poll without it, or with `client=control` or anything else, is answered in full but never counts, so a surface that only shows a count can poll without the Mac holding questions nobody can answer. The window is checked at admission AND every 1.5 s while anything is held: a client quiet for 30 s hands everything back (`no_client`). A poll stamp more than 5 s ahead of the clock is stale, and a clock that stepped back is followed. Only a client that asks for questions can answer one. The client-instance claim was rejected on review: every COS Glasses copy since 6.9.505 posts it every 15 s, including the ones with no question UI, so counting it would have held questions and approvals that nobody could see. The display stream (an open socket is not proof), client diagnostics (lossy) and meeting chunks (only while recording) were rejected too. A copy whose timers are frozen cannot poll, and could not show a question either.
31
+ - **A held request ends exactly once**: an answer; the desk becoming active again (checked every 1.5 s; two unreadable reads in a row count as active, one does not), which hands it straight back to the Mac's dialog; the answering client going quiet for 30 s; the deadline (110 s by default, counted from when the hook started, and never at or above the hook's own 125 s); the hook hanging up; the session moving on without us (its own tool ran or was denied because the dialog was answered at the desk, or the turn ended: Stop, StopFailure, SessionEnd, a new prompt, a fresh SessionStart); a drain, or the server shutting down. A late answer gets 409 `already_answered` (with what was chosen), 409 `handed_to_desk`, 409 `expired`, or 410 `hook_gone`. The first answer wins, and a retry with the same `clientAnswerId` gets the same result back. The server checks that the hook's connection can still take the reply before it accepts an answer. Answers are validated: every question answered, labels exactly one of that question's options, one entry for a single-select question, "Other" up to 2,000 characters. A settled item keeps its codes and what was chosen, never the questions or the approval card.
32
+ - **The client contract.** Poll `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs` (10,000); the response carries `enabled`, `mode`, `protocolVersion` (1), `pollIntervalMs`, `liveWindowMs` (30,000), `pending` and `items`. Answer with `POST /api/session-questions/:id/answer`. The items live in memory: after a server restart, or once a settled item is pruned (10 minutes), an answer gets 404 `not_found`; during a maintenance drain the answer route, like every other /api mutation, answers 503 and every held item has already gone back to the Mac. `/api/models` advertises `capabilities.sessionQuestions`: `{enabled, mode, protocolVersion: 1, pollIntervalMs, liveWindowMs}`, beside `durableQueryJobs.trail`.
33
+ - **The approval card says exactly what will run.** A shell command is shown verbatim, whitespace-normalized, every segment, pipe and `&&` part (`git status | tail -3 && git push --force origin main` reads that way; a control byte shows as `\x1b`); Write, Edit, MultiEdit and NotebookEdit show the full path plus the size or line count; WebFetch the URL; WebSearch the query; any other tool its input keys and values. `summary` is cut at 160 characters and `detail` at 4,000, each ending ` ...(+N chars)` when cut. The only thing taken out is a secret (`redactSecretText`); the opaque-output heuristic, which hid ordinary 40-character paths, is never applied.
34
+ - **No hook reinstall.** The broker answers at `POST /api/permission-requests/ask`, the URL the 6.51.0 hook script already calls, and the script is byte for byte the 6.51.0 one, so `--hooks status` keeps reading `installed` and Control shows no "hooks outdated" banner. That one method and path is registered ahead of the /api token gate and the global body parser, as a deliberate exemption: it checks the per-install hook token in constant time before it reads a byte of the body, then parses at most 2 MB itself. Any other method on the path still needs the API token. It is mounted whatever `COS_THREAD_ATTACH_ENABLED` says. The 6.51.0 Cursor `/hooks/cursor/stop-followup` route moved ahead of the global parser the same way, token check first (same condition, same answers; its own 2 MB limit now applies where the 10 MB global parser used to run first). Against a 6.51 server the same script gets 401 from the /api gate and prints nothing, so the dialog shows, as it always did there.
35
+ - **Rows.** An AskUserQuestion now stays `waiting_kind: question` through its PermissionRequest. It used to become `permission`, which clears only on an exact input match, and the answered tool runs with `answers` added to its input, so the row could stay waiting. A held item's id rides the agent-sessions and claude-sessions rows as `pending_question_id` (a question) or `pending_permission_id` (an approval); both are ids from `GET /api/session-questions`, and neither goes on the live session stream. A wait the broker is holding stands even when the registry moves meanwhile, and the id is dropped however the item ends. A late copy of a request that was already answered does not leave the row waiting; the same question asked again later does. So COS Control's row for a question reads "Waiting · Question", not "Waiting · Permission": a bug fix in the rows themselves, which applies whatever the broker switch says, `COS_PERMISSION_BROKER=0` included.
36
+ - **Switches** (the names reserved in 6.48.0 and listed in `managed-runtime-contract.json`). `COS_PERMISSION_BROKER`: `0`, `false` or `off` answers `{}` to everything, with no reinstall; `questions` holds questions only; unset or any other value holds both (Miles, 2026-09-19). It is also off whenever the session hooks are off (`COS_SESSION_HOOKS`). `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90; the script reads the same value) and `COS_PERMISSION_BROKER_TIMEOUT_S` (default 110, clamped to 5 through 120). Set them in `~/.cos-glasses/.env`, which already survives Update Server; COS Control 0.5.239 is the release that keeps them in the LaunchAgent environment too (0.5.238 drops a value set only in the plist, and the defaults apply).
37
+ - **Inert until a client that polls for questions exists.** COS Glasses 6.9.511 and earlier, and COS Control, never call `GET /api/session-questions`, so with them every permission request is answered `{}` at once and nothing changes, whatever the switches say. The broker starts holding requests only once a client with the question cards polls with `client=glasses` or `client=phone`. A client that only shows a count polls without `client` and never counts.
38
+ - **Health.** `/api/health` gains `permissionBroker`, every key camelCase: `enabled`, `mode`, the counters (`parked`, `answered`, `handedToDesk`, `expired`, `hookGone`, `retracted`, `drained`, `noClient`, `invalidAnswer`, `deskUnreadable`, and `fastPath` by reason), `lastFastPath` (a reason code), `lastFastPathAt` and `lastQuestionsPollAt`. Counts and timestamps only: how many are held right now is on the authenticated questions route, not this public one. The log carries ids and counts, never question or command text.
39
+ - Tests, against a real express server and real sockets, wired as `index.ts` wires them: every fast-path reason answers `{}` in under 100 ms (a Bash request with the desk active, including one with the real `ioreg`; no questions poll; a Cursor envelope; the kill switch; a drain; approvals off; a plan approval; an unreadable desk); a question answered from the API reaches the hook as the exact `updatedInput` shape; an approval allowed and denied; desk return, a drain, and the deadline each answer `{}`; a hook that hangs up is released at once and a late answer gets 410; two racing answers, one wins and the winner's retry replays; retraction when the session's own tool runs and not when a parallel one does; answer validation; the hook token goes through the constant-time comparison; the row reads `waiting`/`question` with `pending_question_id` while held and clears after the answer. Liveness, with the real module: a live 6.9.511 claim and no questions poll takes the fast path; a poll 29 s old parks; one 31 s old does not, and a fresh one parks again; a poll with the hook token (or none) is refused 401 and never counts, with or without the /api gate in front; exactly 30 s still counts and 30.001 s does not. The real hook script: the round trip asserts the URL and the unwrapped `tool_input`; the script prints exactly the question and deny decisions from a real broker; no questions poll (with and without a live 6.9.511 claim) or the kill switch prints nothing; the same script against a 6.51 server (401) prints nothing. Gate (first draft): 52 new cases (`broker-*`, `store-*` except `store-day-counts-domain`, `derive-*`, `rows-*`, `liveness-*`, `hook-script-posts-old-url`, the last retired in QA round 1 with the script change it guarded), run against a green baseline. The liveness cases: the poll not recorded, a hook-token poll counted, a claim counted, the live window at its boundary and doubled, and the poll clock moving backwards. `store-request-id-dropped-on-reannounce` survived the first draft of the tests (the broker's own listener re-attached the id and hid the reducer's loss of it), and a reducer-only test was added for it.
40
+
41
+ ### Codex threads show what Codex writes
42
+
43
+ Codex threads show what Codex writes. Nothing on a Claude or Cursor path changes: over this Mac's 120 newest Claude transcripts and 741 Cursor transcripts, every line maps to the same events as 6.51.0, byte for byte.
44
+ - **QA round 2: a Continue turn's last lines are shown once.** When a COS-driven Continue finished, the transcript watcher's hold-off was released the moment the child closed, so rollout bytes written after its last check (the final reply, written just before exit) were read again on the next check, and the lens also flipped from done back to working. Now `finish()` releases through `releaseAfterTranscriptCatchUp`: while still held, it waits for any in-flight check and moves the watcher's cursor to end of file without emitting, then releases; bounded at 500 ms, with a hang or failure released anyway and counted. This covers Claude, Codex and Cursor Continue alike; the race existed on the Claude path since 6.48.
45
+
46
+
47
+ - **Codex's own words reach the Sessions view.** Miles, 2026-09-19 11:45: a live Codex thread showed `WORKING 8 steps` and "Working. Nothing written yet." while the Codex app showed a paragraph between every command. The stream took prose only from `event_msg/agent_message`, and no rollout on this Mac has one. All 912 (codex-cli 0.128 to 0.155) are `history_mode: paginated`, and a paginated rollout writes each message as an `item_completed` AgentMessage instead (171,606 item events, 0 legacy messages). Those items, commentary and final answer, are now prose. A legacy rollout still reads from `agent_message`, as before. The two never appear in one rollout: Codex's rollout writer keeps the legacy event only in a legacy-mode thread and the item only in a paginated one, and the mode is set once per thread in its `session_meta`. The older `{id, msg}` exec envelope still reads the legacy event only, as 6.51.0 did. Tools still come only from the `response_item` calls, never from the `CommandExecution` and `FileChange` items that repeat them.
48
+ - **Code-mode steps read as the command.** Since codex-cli 0.144 almost every Codex tool call is a JavaScript cell (`exec`) that calls the real tool, and the lens printed the cell itself: `bash let{output,...rest}=awai...`. Each call in a cell is now its own step. `exec_command` is `bash` with its command, shortened as before. `apply_patch` is `edit`, or `write` when it only adds files, with the file names and `+added -removed`. Any other tool names itself, and a cell that calls no tool is `other exec`. At most three steps per cell; the call id rides the first. A plain `apply_patch` call outside code mode reads the same way (it was `other apply_patch`). An `exec` that is a shell command rather than a cell reads as before.
49
+ - **Waiting is not a step.** `wait` and a `write_stdin` that sends nothing only poll a running command, and each showed as a step ("other wait"). Both are dropped, as function calls and inside a cell. A `write_stdin` that types something is still a step.
50
+ - **The reasoning line.** A Reasoning item with a summary gives its newest headline, bold marks removed, as a new optional field `reasoning` on a `status: working` event. It is never prose and never a new kind, so a shipped lens ignores it and renders exactly as before; a client that learns the field can show it. It is not seeded, and it never changes the queue's end-of-turn rule. An encrypted reasoning item (empty summary) says nothing.
51
+ - **The prompt line for Codex.** The ask, from the `UserMessage` item, is a `prompt` event, as it is for Claude, so the view can pin what is being worked on.
52
+ - **A Codex session's title and DISCUSSION are the ask, not AGENTS.md.** Codex writes the repo's AGENTS.md as a user message (`# AGENTS.md instructions for ...`). It is the first user row in 469 rollouts here, so it became the detail's title, first prompt and opening DISCUSSION line, the list row's summary, and a search hit in every session. Codex user rows are now read block by block, with one rule shared by the detail, the list, search, recent turns and the prompt line: the AGENTS.md block and any block that opens with a tag (`<environment_context>`, `<image>`, skills, automation heartbeats) are left out; typed words after an image are kept (they were dropped); and a message with attached files reads as the words under its `## My request for Codex:` heading. The detail is titled by the thread's own name from `session_index.jsonl`, as the list row already was, looked up by the rollout's filename id so a cloned thread never takes its parent's name. `first_prompt` stays the ask.
53
+ - **A Continue through `codex exec` shows its output.** A thread the Codex app is not holding is continued with `codex exec resume --json`, and until QA round 1 that turn streamed only working and done, because nothing read today's exec grammar. It is read now, measured on a real codex-cli 0.155.0 run (`thread.started`, `turn.started`, `item.started` / `item.completed` wrapping `{id, type}` with `agent_message`, `command_execution`, and `turn.completed`): the words as prose, each command as a step with its outcome, `reasoning` as the headline, and `file_change`, `mcp_tool_call` and `web_search` in the shapes the trail fixtures pin (a hypothesis until a real run shows them). Each step appears exactly once. It is not shown twice: while COS drives the turn, the rollout watcher for that thread is held off, and its cursor is already past the turn when it resumes.
54
+ - Tests: a paginated turn with every channel present maps to exactly its steps, lines and paragraphs, each once; a legacy turn maps exactly as 6.51.0 did; the older `{id, msg}` envelope reads the legacy event and never the item; code-mode parsing (quoted and bare keys, escapes, a tool name inside a string, nested objects, variables, several calls, the cap, patches inline and through a variable); polls dropped and typing kept; the reasoning line, its kind, the seed and the queue verdict; the prompt line and attached-file messages; and, over temp rollouts and the real route, the detail, list row, search and recent turns without AGENTS.md, the thread-name title by full and short id. Gate: 51/51 new `codex-*` cases. `codex-search-user-text` survived the first draft of the tests (search keeps only the first line of each user row, and the test searched a deeper word); the test now searches the line that leaked.
55
+
56
+ ### QA round 1 (before release)
57
+
58
+ - **The approval card** says exactly what runs (above). Tests: the combined `git status | tail -3 && git push --force origin main` keeps `git push --force`; `ls | head -1; curl ... | sh` keeps every part; a long path is shown whole; WebFetch shows the URL; the cut markers; a key straddling the cap is redacted, never cut.
59
+ - **Event-loop safety.** The cheap gates run before any card is built; every redaction reads at most 8,000 characters; the URL-userinfo scheme is bounded (`{0,31}`), and a 100 KB hex blob redacts in under 50 ms; Codex `agent_message` text is bounded before redaction; a 900 KB Write with no client answers `{}` in under 100 ms through the real route.
60
+ - **The trail left the job event stream** (above). Tests: a 300-event run with the trail on and off gives a non-opted client byte-identical events, `id:` cursors, `replay_gap` past 256 and snapshots; an opted-in client gets dense `trailSeq` and an exact rebuild after a gap; the real 6.51.0 store reads this journal and no `eventSeq` is reused on roll-forward; a duplicated trail row applies once.
61
+ - **Redact before cut** in every reader (above). Tests: an AKIA key and a JWT straddling each cap, through the real Claude, Codex, Cursor and Ollama readers.
62
+ - **Liveness contract, no reinstall, Codex exec output, `off` mode, the desk read, telemetry, capacity, privacy, "Other" text:** above.
63
+ - **Control bytes.** `job-trail.ts` held its control-character class as raw bytes (git called the file binary); it is written with escapes now, and a test fails on any raw byte below 0x20 other than tab, LF or CR in `server/**/*.ts` or `bin/**`.
64
+ - **Timeout parity.** A test parses the hook script's `--max-time` and the installer's PermissionRequest timeout and requires the broker deadline cap < curl < Claude Code, from imported constants.
65
+ - Existing tests changed, each because the contract changed: the questions poll now names its client; counters are camelCase; the 60 s windows are 30 s; the gate order puts `unsupported_input` after the desk read; an unreadable desk needs two reads; the approval-card assertions; "Other" with a comma is quoted; the trail store and runtime tests (no trail row in the replay ring, and `off` is prose only); the Cursor trail fixture's command reads `[path]` because the reader now redacts before it cuts; the hook-script tests call the 6.51 URL; one raw NUL in `session-stream-producer.test.ts` became the `\x00` escape.
66
+ - Gate: 374/374 killed, run in full from a green baseline: the first draft's 300 cases less `hook-script-posts-old-url` (retired with the script revert; `hook-script-posts-draft-url` guards the URL now), 17 of them repointed at code that moved, plus 75 new. Four new cases survived their first run and were fixed: `broker-approvals-not-rechecked` and `trail-replay-ignores-cursor` (tests added), `trail-codex-mcp-cut-first` (the straddle ignored the `server.` prefix), and `approval-value-scan-unbounded` (that per-value bound was redundant with the line's own, so the code was removed and the case retired).
67
+
68
+ ## 6.51.0
69
+
70
+ Codex and Cursor sessions get the Continue and queue that Claude sessions have, each through the engine's own door, and Cursor composers get hook-grounded state. Nothing on a Claude path changes.
71
+
72
+ - **Continue into a Codex thread the Codex app has open.** Before this, a Continue into any thread open in ChatGPT.app (Codex Desktop) could not work: the app's app-server holds the thread's writer lock for as long as the thread is loaded, idle included, so the `codex exec resume` child died in about 0.2 s on "thread already has an active writer" AFTER the prompt was written to its stdin. That is an `ambiguous` turn and a fence (two on record from August, both released by hand). Now, when the gate's own verdict names a foreign writer on a Codex thread, the turn goes through `codex queue --thread=<id> --message=<text>`, which inserts a row into Codex's own queue (`$CODEX_HOME/queue_1.sqlite`). Every app-server watches that database for writes from other processes, so the Codex app runs the message 10 ms after the current turn's `task_complete`, or within about 7 s when the thread is idle, as an ordinary user turn that is visible and editable in the app's own queue. Canary 2026-09-19 against a scratch `CODEX_HOME` and codex-cli 0.155.0-alpha.9.2 (the detail is in `codex-live-queue.ts`). Delivered means exit 0 AND the CLI's confirmation line naming our thread: 200 `completed`, `via: live`, copy "Queued in the Codex app...". A timeout, a kill or an unreadable confirmation might have inserted a row, and nothing can re-check that before a resend, so the turn is `delivery_ambiguous`: ledgered (a replay answers the same) and the thread fenced until a person looks at the Codex app's queue and releases it, like every other unproven delivery. The CLI's own "failed to queue session message", or a clap usage error (a `codex` without `queue`), is `codex_live_unavailable`: nothing was queued, not retryable, and no child is spawned against the held thread. A Codex thread nobody holds takes the 6.50 path unchanged. `COS_CODEX_LIVE_QUEUE=0` restores 6.50 everywhere. The prompt rides in argv for about 50 ms (the CLI has no stdin form: `--message -` queues a literal dash); it is never logged, and the `--message=` form keeps a prompt that starts with a dash as text. The runner kills its whole process group at the 5 s ceiling and answers at the ceiling even if a daemonised grandchild keeps stdout open. `/api/health` gains `codexLiveQueue` next to `continueLive`.
73
+ - **A queued turn on a Codex thread drains at the engine's `task_complete`**, not 30 s later. The B6 clause gains a Codex branch: a Codex holder whose rollout's newest turn marker is `task_complete`, `turn_complete` or `turn_aborted` reads idle at once, with the drain's own tail rule (an aborted Codex turn now ends the turn on the stream and for the drain too; a follow-up used to wait out the 30 min open-turn ceiling). The clause is off with `COS_CODEX_LIVE_QUEUE=0`, so that switch restores 6.50 fully. That is safe for Codex in a way it never was for a Claude Stop, because every write into a held Codex thread now goes through the app's queue, which the engine serialises behind any turn it starts next.
74
+ - **Cursor: a turn can queue for a composer that is mid-turn, and leaves through that composer's own Stop hook.** Cursor 3.21 runs hooks from `~/.claude/settings.json` by default, so the COS session hook installed on 9/15 has been running for Cursor composers since (478 Cursor events in the ledger by 9/18). And Cursor submits a Stop hook's `{"followup_message": ...}` as that composer's next message. So: occupancy reads a composer its hooks show mid-turn as `native_thread_working` (still never attachable: Gate 0 stands); the queue admits a Cursor turn for that reason only (every other Cursor reason stays 423, including ones that are queueable for Claude); the drainer never delivers a Cursor turn; and at the composer's Stop the hook script asks `POST /hooks/cursor/stop-followup` (outside `/api`, hook-token auth) and prints the reply. One turn per Stop, oldest first, claimed before the text leaves, and never for a hook that already hung up (the claim happens only on an open connection); only a `completed` turn gets one (an `aborted` or `error` Stop gets nothing). The hook asks only when the queue folder holds a file for that composer (almost every Stop sends nothing), falls back to asking when the folder is absent, and reads the payload with any JSON spacing. The script's new branch runs only when the environment carries `CURSOR_VERSION` AND the payload carries `"cursor_version"`; a Claude Stop takes the old line and never touches the network. An idle composer fires no Stop, so a turn cannot be put into one; that stays refused. The new script reaches `~/.cos-glasses/bin` through `--hooks install` (the settings entries are unchanged, so `settings.json` is not rewritten).
75
+ - **Cursor rows carry `agent_state` from their own hooks.** A composer whose events reached the signal store gets the same one derivation Claude rows get (running while its turn is open, idle after its Stop), on the list and the detail. A Cursor row with no events keeps the 6.50 shape.
76
+ - Tests: the Codex hop over HTTP (queued via live, replay-safe, held on unverified or a throw, refused without spawning when nothing was queued, 6.50 when disabled, without a holder, or without the dep, never for Claude, the turn clock opening the gate); every verdict rule, the argv, the confirmation, and the runner against real children (argv without a shell, a hang, an escaped grandchild, a missing binary, end to end through a fake `codex`); the Codex turn clock against real rollout lines; the Cursor composer signal (open, closed, ended, stale, throwing, CLI chat untouched, Claude and Codex untouched); the queue route's Cursor rule and the drainer's skip including TTL retirement; the Stop claim over HTTP (one per Stop, aborted gets nothing, Claude Stop and junk get `{}`, token required, only the cursor queue); the hook script run as Cursor runs it (posts and prints, `{}` prints nothing, a Claude Stop mentioning cursor_version never posts, missing env, payload or event sends nothing, a missing token or a closed port is silent and fast); Cursor rows over HTTP. The refusal vocabulary table covers `codex_live_unavailable`. Gate: 30/30 new cases (`codex-live-*`, `route-codex-*`, `codex-clock-*`, `codex-abort-*`, `cursor-*`, `hook-script-*`); `codex-live-timeout-waits-for-close` survived the first draft of the tests and the escaped-grandchild case was added for it.
77
+ - **QA pass (one skeptic, before publish) and the live canary.** Fixed from QA: an unverified Codex hand-off was retryable, so the drainer could insert the same sentence at every turn end (now ambiguous and fenced); a Cursor claim could be marked delivered after the hook had hung up; the Codex turn clock stayed on with the switch off; `turn_aborted` was read differently by the server and COS Control. A second, narrow QA round found the hung-up check was not enough: when the server is busy past the hook's 3 s, curl gives up and closes, but Node reads the request before the close, so the socket still looked open and the turn was claimed for a hook that was gone. A real-curl test (the event loop blocked 2.6 s against a 1 s curl) reproduced it: the row read `delivered`. Now the route claims only within 2 s of the hook's own start stamp (the envelope's `ts`, same Mac clock), so the reply always leaves with a second to spare; a later or unstamped Stop leaves the turn waiting for the next one. The hook also reads `session_id` when a payload has no `conversation_id`, and asks the server rather than skipping when it cannot read an id at all. Live canary on a scratch server (scratch `CODEX_HOME`, throwaway sessions): Codex Continue into a held thread queued in the app and the holder itself ran it 2.6 s later; a working Codex thread refused at attach, the follow-up parked, and the drainer handed it to the app, which ran it 3.8 s after the hand-off; Cursor mid-turn admitted, idle refused, and a Claude-shaped or aborted Stop got nothing. Found upstream, not from this release: Claude Code 2.1.278 HOLDS a peer message (`crossSessionInbound`: "Held peer message, from an unidentified session") when the receiving session runs a permissive mode, so a live Continue into such a session reads `live_unverified` and waits for approval at the desk; the Claude path here is unchanged.
78
+
1
79
  ## 6.50.4
2
80
 
3
81
  - **The ring never moves in a way that stops the only recording.** A copy told to yield stops its recording (that is how a duplicate ends). QA of 6.50.3, with glasses 6.9.507 on the phone: reopening COS on a blank phone page mid-meeting (the 2026-09-18 08:15 symptom) gave the ring to the newer copy, so the meeting ended within 15 s and the new window was not recording. The referee now notes every meeting-chunk POST as it passes: a live chunk, or an iPhone offline chunk replayed; the preview does not count. The router is mounted before the transcribe-stream router and only notes, never answers. It records whose chunk it was (`?clientInstance=`, glasses 6.9.509+) or, for an untagged chunk, which device sent it. Two rules follow. (1) While the owner's OWN chunks arrive (30 s window, `CLIENT_INSTANCE_CAPTURE_LIVE_MS`), a newer copy is told to wait, however quiet the owner's claims. A hidden or locked WebView throttles its timers, but chunk uploads follow the audio. The ring moves on the first claim after the meeting stops. (2) A claimant whose own chunks arrive while the owner's do not takes the ring. That covers the case QA found in the first draft of this release, which held the ring on ANY chunk: COS reopened offline, a meeting started on the new copy, and on reconnect that copy's own chunks switched the hold on for the dead copy the Mac still remembered, so its claim was told to yield and the only recording stopped. (3) A claim now says whether its copy is recording (`recording`, glasses 6.9.510+). A recording claimant takes the ring from an owner that is not recording, and an owner's last `recording: true` holds the ring while it keeps claiming (45 s). Chunk arrival alone lags: after an outage the uploader backs off up to 60 s, and only a visible page resets it, which a hidden recording copy never gets. QA round 3 found that gap: a reopened copy that reclaimed offline took the ring back on reconnect, and the recording copy was then told to stop. Untagged chunks (glasses 6.9.507/6.9.508) count for the owner only while it still claims (45 s), so a dead owner is never pinned by them or by a stale flag. When both copies record, the owner keeps the ring and the claimant stops. The answer's `captureLive` means the owner is recording; glasses 6.9.510 refuse a yield while recording when it is `false`. `lastRecordingChunkAt` (first draft) is gone.
package/README.md CHANGED
@@ -179,9 +179,11 @@ npx --yes @gotcos/glasses-server@latest --hooks uninstall
179
179
 
180
180
  The install keeps every hook you already had, backs the file up, and copies a small
181
181
  POSIX sh script to `~/.cos-glasses/bin/cos-session-hook`. The script writes one file per
182
- event into `~/.cos-glasses/data/hook-spool` and never contacts the server (a permission
183
- request may, only after the desk has been idle for 90 s, for the approval feature that ships
184
- next). Sessions report `state_source: hook` on `/api/agent-sessions` and
182
+ event into `~/.cos-glasses/data/hook-spool`. It contacts the server in exactly two cases:
183
+ a permission request, only after the desk has been idle for 90 s (see the 6.52.0 note
184
+ below), and a Cursor composer's Stop, only when a turn is queued for it (6.51.0). Every
185
+ other event is a file write, so a server that is down or restarting never delays a
186
+ session. Sessions report `state_source: hook` on `/api/agent-sessions` and
185
187
  `/api/claude-sessions` from their next event on (Claude Code 2.1.272 reloads its hooks
186
188
  when the settings file changes, so open tabs need no restart; they may show Claude's
187
189
  "hooks modified externally" notice once, which is expected: the user-level file changed
@@ -229,6 +231,39 @@ own) replays rather than repeats.
229
231
  sub-agents omitted) and `recent_turns_more` to the detail payload, read backward from
230
232
  the end of the transcript; without `turns` the payload is unchanged. The glasses use it
231
233
  to scroll back through a running session's conversation.
234
+ Since 6.52.0 a session question (the AskUserQuestion card) or a tool approval can be
235
+ answered from the glasses or the phone while you are away from the Mac. There is nothing
236
+ to reinstall: the hook script is the 6.51.0 one, and the server now answers the URL it
237
+ already calls, `POST /api/permission-requests/ask` (hook-token auth, checked before the
238
+ body is read; that one method and path is exempt from the API token, nothing else is).
239
+ The hook posts only after the desk has been idle 90 s; the server holds the request only
240
+ while the desk stays idle and a client that can answer keeps polling, and answers `{}`
241
+ (the Mac's own dialog) to everything else at once. A client that never polls (COS
242
+ Glasses 6.9.511 and earlier, COS Control) changes nothing.
243
+
244
+ The client contract (also at `/api/models` `capabilities.sessionQuestions`):
245
+
246
+ - Poll `GET /api/session-questions?client=glasses` (or `client=phone`) with X-Cos-Token
247
+ every `pollIntervalMs` (10,000). Only those two `client` values count as a live
248
+ answerer; a poll without `client`, or with any other value, is answered but never
249
+ counts. A client quiet for `liveWindowMs` (30,000) hands every held request back.
250
+ - The response carries `enabled`, `mode`, `protocolVersion` (1), `pollIntervalMs`,
251
+ `liveWindowMs`, `pending` and `items` (a question's `questions`, or an approval's
252
+ `approval`: `{tool, summary, detail}`, the command, path or URL exactly as it will run,
253
+ cut at 160 and 4,000 characters with ` ...(+N chars)`, with only secrets redacted).
254
+ - Answer with `POST /api/session-questions/:id/answer`: `{clientAnswerId, answers}` for
255
+ a question (one `{labels, other}` per question; free text with a comma is sent quoted),
256
+ `{clientAnswerId, decision}` for an approval (`allow` or `deny`). The first answer
257
+ wins; a retry with the same `clientAnswerId` replays it. 409 `already_answered`,
258
+ `handed_to_desk` or `expired`, and 410 `hook_gone`, say why an answer came too late;
259
+ 404 `not_found` after a server restart or once a settled item is pruned (10 minutes);
260
+ 503 during a maintenance drain, when every held item has already gone back to the Mac.
261
+
262
+ Touching the Mac hands a held request back to its dialog at once; the deadline is 110 s.
263
+ Allow once or deny only: no permission rule is ever written. Rows carry
264
+ `pending_question_id` or `pending_permission_id` while a request is held, and a question
265
+ row reads `waiting_kind: question` whatever the switch says; `/api/health` reports
266
+ `permissionBroker` (counters and timestamps, no ids or text).
232
267
 
233
268
  ## Configuration
234
269
 
@@ -259,6 +294,11 @@ No inline `#` on the EXTRA_ARGS line.),
259
294
  `COS_WEATHER_DEFAULT_CITY` (optional home fallback when phone GPS is denied),
260
295
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=0` (optional
261
296
  machine-wide rollback for build 204+ server-owned query recovery),
297
+ `COS_MESSAGES_TRAIL=0` (6.52.0: no Messages trail; every job stream and snapshot is the
298
+ 6.51.0 one), `COS_PERMISSION_BROKER` (6.52.0: `0` answers every permission request with
299
+ the Mac's own dialog, `questions` holds questions but not tool approvals; unset holds
300
+ both), `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90, never below 30) and
301
+ `COS_PERMISSION_BROKER_TIMEOUT_S` (default 110, clamped to 5 through 120),
262
302
  `COS_MEDIA_ROOT` (optional image/video store location; default
263
303
  `~/.cos-glasses/data/media`), and `COS_VIDEO_UPLOAD_V2=1` (private 6.27.3+
264
304
  resumable-video canary, managed by COS Control 0.5.20). The V2 canary retains
@@ -272,6 +312,9 @@ instead of biasing Whisper toward placeholder text.
272
312
  Telegram activity export is disabled by default even when a private COS
273
313
  pipeline contains `.telegram_config.json`; enable it only with the explicit
274
314
  `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
315
+ Put rollback switches such as `COS_MESSAGES_TRAIL=0` and `COS_PERMISSION_BROKER=0` in
316
+ `~/.cos-glasses/.env`: that file survives Update Server. COS Control 0.5.239 also keeps
317
+ them in the LaunchAgent environment; 0.5.238 drops a value set only in the plist.
275
318
 
276
319
  ## Morning brief (6.43.0)
277
320
 
@@ -576,6 +619,16 @@ BIND_HOST=0.0.0.0 npm run start:server
576
619
  Restart once, then confirm `/api/health` reports
577
620
  `features.durableQueryJobs: true`, protocol `1`, and state `ready`. To roll
578
621
  back, set `COS_DURABLE_QUERY_JOBS=0`; accepted jobs still drain while new prompts use legacy streaming.
622
+ - *Session questions never reach the glasses?* The server holds one only when every
623
+ gate passes. `/api/health` `permissionBroker.lastFastPath` names the last reason a
624
+ request went straight to the Mac's dialog: `no_client` (no poll with
625
+ `client=glasses|phone` in the last 30 s: the app build has no question cards, or its
626
+ timers are frozen), `desk_active` (the Mac saw input), `approvals_off`
627
+ (`COS_PERMISSION_BROKER=questions`), `broker_off`, `cursor`, `unsupported_tool`
628
+ (ExitPlanMode is never held). `lastQuestionsPollAt` says when a client last counted.
629
+ `--hooks status` must read `installed`. To turn it off with no reinstall, set
630
+ `COS_PERMISSION_BROKER=0` in `~/.cos-glasses/.env` and restart. For the Messages
631
+ trail, `/api/health` `messages_trail.readerErrors` counts lines a reader could not map.
579
632
  - *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
580
633
  Restart once, then confirm `/api/health` reports
581
634
  `features.localFirstMeetings: true` and
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
2
- # COS session hook (glasses-server 6.48.0). Installed into ~/.claude/settings.json by
2
+ # COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0). Installed into ~/.claude/settings.json by
3
3
  # `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
4
4
  # payload on stdin. It spools one JSON file per event into the hook spool and exits 0
5
5
  # on every path. Only PermissionRequest ever talks to the server, and only when the
@@ -23,6 +23,29 @@ T="$SPOOL/.tmp.$$.$TS"; head -c 1048576 > "$T.in" # stdin to
23
23
  case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
24
24
  { printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
25
25
  rm -f "$T.in"
26
+ # 6.51.0, CURSOR ONLY. Cursor runs this hook from ~/.claude/settings.json for its own
27
+ # composers, and submits a Stop hook's {"followup_message":...} as that composer's next
28
+ # message. A turn COS queued for the composer is handed over here, at the end of its turn.
29
+ # Claude Code sets no CURSOR_VERSION and sends no cursor_version, so a Claude Stop takes
30
+ # the line below this block exactly as before and never touches the network.
31
+ if [ "$EVENT" = Stop ] && [ -n "${CURSOR_VERSION:-}" ] && grep -Eq '"cursor_version"[[:space:]]*:[[:space:]]*"' "$T" 2>/dev/null; then
32
+ cp "$T" "$T.req" 2>/dev/null; mv "$T" "$SPOOL/$TS-$$-$EVENT.json"
33
+ # Nothing queued for this composer (almost every Stop): no request at all. The queue
34
+ # folder sits beside the spool. If it is not there, or the id cannot be read here,
35
+ # ask anyway rather than guess; the server reads the id the same way (conversation_id,
36
+ # then session_id).
37
+ QDIR="$(dirname "$SPOOL")/thread-turn-queue"
38
+ CONV=$(grep -Eo '"conversation_id"[[:space:]]*:[[:space:]]*"[0-9A-Fa-f-]{36}"' "$T.req" 2>/dev/null | grep -Eo '[0-9A-Fa-f-]{36}' | head -1 | tr 'A-F' 'a-f')
39
+ [ -n "$CONV" ] || CONV=$(grep -Eo '"session_id"[[:space:]]*:[[:space:]]*"[0-9A-Fa-f-]{36}"' "$T.req" 2>/dev/null | grep -Eo '[0-9A-Fa-f-]{36}' | head -1 | tr 'A-F' 'a-f')
40
+ if [ -d "$QDIR" ] && [ -n "$CONV" ] && [ ! -f "$QDIR/cursor-$CONV.json" ]; then rm -f "$T.req"; exit 0; fi
41
+ TOK=$(cat "$GH/hook-token" 2>/dev/null); PORT=$(cat "$GH/hook-port" 2>/dev/null || echo 3141)
42
+ if [ -n "$TOK" ] && [ -f "$T.req" ]; then
43
+ OUT=$(curl -s --connect-timeout 1 --max-time 3 -H "X-Cos-Hook-Token: $TOK" -H 'content-type: application/json' \
44
+ --data-binary @"$T.req" "http://127.0.0.1:$PORT/hooks/cursor/stop-followup") || OUT=''
45
+ case "$OUT" in '{"followup_message"'*) printf '%s' "$OUT" ;; esac
46
+ fi
47
+ rm -f "$T.req"; exit 0
48
+ fi
26
49
  if [ "$EVENT" != PermissionRequest ]; then mv "$T" "$SPOOL/$TS-$$-$EVENT.json"; exit 0; fi # rename = publish
27
50
  # A permission request keeps its own copy: the server unlinks the published file within
28
51
  # milliseconds of the rename, and the request body below must outlive that.
@@ -14,6 +14,7 @@
14
14
  "HTTPS_PORT",
15
15
  "BIND_HOST",
16
16
  "COS_DURABLE_QUERY_JOBS",
17
+ "COS_MESSAGES_TRAIL",
17
18
  "COS_LIVE_CUES",
18
19
  "COS_LIVE_CUES_MODEL",
19
20
  "COS_LIVE_CUES_GRAPH",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.50.4",
3
+ "version": "6.52.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -24,7 +24,18 @@ import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
24
24
  import { createAttachedTurnStream } from './lib/session-stream-producer.js'
25
25
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
26
26
  import { createSessionHooksRouter } from './routes/session-hooks.js'
27
- import { registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
27
+ import { deskIdleSeconds, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
28
+ import {
29
+ PermissionBroker,
30
+ brokerSignalSink,
31
+ permissionBrokerMode,
32
+ permissionBrokerTimeoutMs,
33
+ readHidIdleSeconds,
34
+ registerPermissionBroker,
35
+ wirePermissionBrokerToSignals,
36
+ } from './lib/permission-broker.js'
37
+ import { createPermissionBrokerHookRouter, createSessionQuestionsRouter } from './routes/permission-broker.js'
38
+ import { lastQuestionsPollAt } from './lib/client-liveness.js'
28
39
  import {
29
40
  createAgentSessionBindingsRouter,
30
41
  TargetGuard,
@@ -34,10 +45,13 @@ import {
34
45
  import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
35
46
  import { targetKey } from './lib/agent-session-binding-store.js'
36
47
  import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
37
- import { buildOccupancyProbes, realOccupancyDirs, withHookTurnClock } from './lib/occupancy-probes.js'
48
+ import { buildOccupancyProbes, realOccupancyDirs, withCodexTurnClock, withCursorComposerTurn, withHookTurnClock } from './lib/occupancy-probes.js'
49
+ import { readCodexTurnEndedAtMs } from './lib/codex-turn-clock.js'
50
+ import { codexLiveQueueEnabled } from './lib/codex-live-queue.js'
38
51
  import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
39
52
  import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
40
53
  import { makeLiveTurnDeliverer } from './lib/session-peer-inbox-deps.js'
54
+ import { makeCodexLiveDeliverer } from './lib/codex-live-queue-deps.js'
41
55
  import { forkThread, realForkDeps } from './lib/fork-thread.js'
42
56
  import { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
43
57
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
@@ -131,7 +145,9 @@ import {
131
145
 
132
146
  const app = express()
133
147
  import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
134
- import { queuedThreadKeys, transcriptTurnEnded, transcriptTurnVerdict } from './lib/thread-turn-queue-store.js'
148
+ import { createCursorStopFollowupRouter } from './routes/cursor-stop-followup.js'
149
+ import { queuedThreadKeys, readQueue, transcriptTurnEnded, transcriptTurnVerdict, writeQueue } from './lib/thread-turn-queue-store.js'
150
+ import { readHookToken } from './lib/claude-hooks-installer.js'
135
151
  import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
136
152
  import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
137
153
  import { transcriptPathFor } from './lib/native-head.js'
@@ -204,6 +220,45 @@ app.use(cors({
204
220
  // reader to conclude the server had stopped sending Range headers.
205
221
  exposedHeaders: ['Content-Range', 'Accept-Ranges'],
206
222
  }))
223
+ // 6.52.0: the permission broker. A session question (AskUserQuestion) or a tool approval,
224
+ // answered from the lens or the phone while the desk is idle and a client that can answer
225
+ // has polled `GET /api/session-questions?client=glasses|phone` in the last 30 s
226
+ // (lib/permission-broker.ts). An app without the question UI never polls, so for it every
227
+ // request is `{}`. `COS_PERMISSION_BROKER=0` is the switch, read per request, and every
228
+ // request outside the gate is answered `{}` at once.
229
+ const permissionBroker = new PermissionBroker({
230
+ now: () => Date.now(),
231
+ mode: () => permissionBrokerMode(process.env, sessionHooksEnabled()),
232
+ admissionsOpen: maintenanceAdmissionsOpen,
233
+ lastQuestionsPollAt,
234
+ readDeskIdleSeconds: readHidIdleSeconds,
235
+ deskIdleSeconds,
236
+ timeoutMs: () => permissionBrokerTimeoutMs(process.env),
237
+ signals: brokerSignalSink(sessionSignalStore),
238
+ })
239
+ registerPermissionBroker(permissionBroker)
240
+ wirePermissionBrokerToSignals(permissionBroker, sessionSignalStore)
241
+
242
+ // THE TWO HOOK-TOKEN DOORS, deliberately BEFORE the /api gate and the global body parser.
243
+ // Each checks the per-install hook token (never the pairing token) in constant time before
244
+ // it reads a byte of the body, then parses at most 2 MB itself. The broker's door is
245
+ // `POST /api/permission-requests/ask`: the URL the 6.51.0 hook script already calls, so no
246
+ // Mac reinstalls the hook. Only that method and path are exempt from the /api gate; any
247
+ // other method there still needs the API token. The Cursor Stop door is outside /api and is
248
+ // registered exactly when it always was (`threadAttachEnabled()`), only earlier, so a
249
+ // request without the hook token no longer costs a 10 MB parse.
250
+ app.use(createPermissionBrokerHookRouter({ hookToken: readHookToken, broker: permissionBroker }))
251
+ if (threadAttachEnabled()) {
252
+ // 6.51.0: a queued Cursor turn leaves ONLY through the composer's own Stop hook (the
253
+ // drainer skips Cursor, see `drainThread`).
254
+ app.use(createCursorStopFollowupRouter({
255
+ hookToken: readHookToken,
256
+ readQueue,
257
+ writeQueue,
258
+ now: () => Date.now(),
259
+ }))
260
+ }
261
+
207
262
  // Auth middleware — always active (token is auto-generated if not set).
208
263
  // Mounted before body parsers so rejected uploads cannot consume parse memory.
209
264
  // Two capability-URL exceptions: a canonical /tts/play/<UUID> GET/HEAD (minted by
@@ -386,12 +441,24 @@ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
386
441
  * Read the canary evidence in `thread-occupancy.ts` under THE IDLE-HOLDER
387
442
  * RELAXATION before changing this line.
388
443
  */
389
- const occupancyProbes = sessionHooksEnabled()
444
+ const occupancyProbes = withCursorComposerTurn(withCodexTurnClock(sessionHooksEnabled()
390
445
  // 6.48.1, the B6 clause: with the hooks on, a foreign Desktop holder whose newest hook
391
446
  // event is a Stop reads idle at once instead of after the 30 s transcript window. The
392
447
  // wrapper only adds a probe; the gate's precedence is unchanged. Off is 6.48.0.
393
448
  ? withHookTurnClock(buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled()), signalFor, registryIdleAfterStop)
394
- : buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
449
+ : buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled()),
450
+ // 6.51.0: the Codex counterpart, from the rollout's own turn markers. A queued COS turn
451
+ // on a Codex thread then drains at the engine's `task_complete` instead of 30 s later,
452
+ // and goes out through the app's own queue (`codex-live-queue.ts`).
453
+ // Off with `COS_CODEX_LIVE_QUEUE=0`: the clock only opens the gate because every write
454
+ // into a held thread goes through the app's queue, so without that hop it must not.
455
+ (threadId: string) => (codexLiveQueueEnabled() ? readCodexTurnEndedAtMs(threadId, nativeHeadDeps) : null)),
456
+ // 6.51.0: a Cursor composer its own hooks show mid-turn reads `native_thread_working`,
457
+ // so a turn can queue for its Stop hook. Without the hooks feature there is no signal
458
+ // and every composer keeps the 6.50 verdict.
459
+ (sessionId: string) => (sessionHooksEnabled() ? signalFor(sessionId) : undefined),
460
+ () => Date.now(),
461
+ OPEN_TURN_CEILING_MS)
395
462
 
396
463
  // 6.48.0: the hook spool ingester and the one signal store every session row reads. Starts
397
464
  // before any router is registered so the first list request already sees the replayed
@@ -557,6 +624,10 @@ app.use('/api', agentSessionStreamRouter)
557
624
  // COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
558
625
  app.use('/api', claudeSessionsRouter)
559
626
  app.use('/api', createSessionHooksRouter({ port: PORT }))
627
+ // 6.52.0: the permission broker's client API (the hook's door is mounted before the /api
628
+ // gate, above). Behind the API token; a poll counts as a live answerer only with
629
+ // `?client=glasses|phone`.
630
+ app.use('/api', createSessionQuestionsRouter({ broker: permissionBroker, apiToken: () => API_TOKEN }))
560
631
  // Phase 0 of Continue Original Agent Thread: can COS write into a desktop thread
561
632
  // without colliding with a live writer? Read-only — it answers, it never attaches.
562
633
  // Registered AFTER agentSessionsRouter deliberately: its paths are 2 and 4 segments
@@ -690,6 +761,7 @@ if (threadAttachEnabled()) {
690
761
  now: () => Date.now(),
691
762
  }
692
763
  app.use('/api', createThreadTurnQueueRouter(queueDeps))
764
+ // The Cursor Stop door is mounted before the /api gate and the body parser (see there).
693
765
 
694
766
  // Every 20s. Fast enough that a freed thread drains while the user is still looking
695
767
  // at the pending row, slow enough to be nothing: the sweep does no work at all when
@@ -762,6 +834,10 @@ app.use('/api', createAgentSessionBindingsRouter({
762
834
  // the resume child second. Reads `COS_CONTINUE_LIVE` per call, so the flag reports
763
835
  // on /api/health without a restart and every refusal falls back to the 6.48.2 path.
764
836
  deliverLiveTurn: makeLiveTurnDeliverer(nativeHeadDeps),
837
+ // 6.51.0: a Codex thread the Codex app holds goes into that app's own queue
838
+ // (`codex queue`), never into a resume child that cannot open a held thread.
839
+ // `COS_CODEX_LIVE_QUEUE=0` restores the 6.50 path.
840
+ deliverCodexLiveTurn: makeCodexLiveDeliverer(),
765
841
  forkThread: forkThreadForRoute,
766
842
  // The fork's real spawn directory. Separate from `resolveTarget` above, which
767
843
  // deliberately yields only fingerprints because plan 3.3 keeps a filesystem path
@@ -845,6 +921,8 @@ async function gracefulShutdown(): Promise<void> {
845
921
  stopMeetingImportScheduler()
846
922
  stopMeetingMergeScheduler()
847
923
  sessionHooksRuntime.stop()
924
+ // Every held hook gets `{}` (the native dialog) before the process goes.
925
+ permissionBroker.stop()
848
926
  try {
849
927
  await shutdownQueryJobRuntime('server_shutdown')
850
928
  } catch (error) {
@@ -23,8 +23,10 @@ const SECRET_PATTERNS: Array<[RegExp, string]> = [
23
23
  // Command-line flags commonly used for credentials.
24
24
  [/(\s--?(?:api-key|token|access-token|refresh-token|password|passwd|secret|client-secret)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
25
25
  [/(\s(?:-u|--user)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
26
- // URL userinfo and secret-bearing query parameters.
27
- [/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]*:[^\s/@]+@/gi, '$1[redacted]@'],
26
+ // URL userinfo and secret-bearing query parameters. The scheme is BOUNDED (6.52.0 QA):
27
+ // unbounded, it was tried from every position of a long run of letters and digits and
28
+ // each try scanned to the end of the run, so a 100 KB hex blob took seconds.
29
+ [/([a-z][a-z0-9+.-]{0,31}:\/\/)[^\s/@:]*:[^\s/@]+@/gi, '$1[redacted]@'],
28
30
  [/([?&](?:api[_-]?key|access[_-]?token|token|auth|password|secret)=)[^&#\s]+/gi, '$1[redacted]'],
29
31
  // Common provider token formats.
30
32
  [/\b(?:sk-(?:proj-|live-|test-)?|sk_(?:live|test)_|sess-|pat-|gh[pousr]_|github_pat_|glpat-|npm_|pypi-|shpat_|xox[baprs]-)[A-Za-z0-9._-]{8,}\b/gi, '[redacted-token]'],
@@ -56,6 +58,47 @@ function looksOpaqueSecret(text: string): boolean {
56
58
  return classes >= 3
57
59
  }
58
60
 
61
+ /**
62
+ * How much free text any redaction here is handed (6.52.0 QA). Every caller that redacts
63
+ * text of unbounded length (the trail, the approval card) cuts it to this BEFORE a regex
64
+ * runs, so no input can make a pattern run long, whatever the patterns become.
65
+ */
66
+ export const REDACTION_SCAN_MAX_CHARS = 8_000
67
+
68
+ /**
69
+ * The part of `text` a redaction may scan: at most `max` characters, ending where a token
70
+ * ends, so a secret that straddles the cut is left out whole rather than kept as a prefix
71
+ * no pattern can recognise any more. `omitted` is how many characters were left out. A
72
+ * head with no whitespace at all is empty: one unbroken run that long is nothing to show.
73
+ */
74
+ export function boundForRedaction(text: string, max = REDACTION_SCAN_MAX_CHARS): { head: string; omitted: number } {
75
+ if (typeof text !== 'string') return { head: '', omitted: 0 }
76
+ if (text.length <= max) return { head: text, omitted: 0 }
77
+ let cut = max
78
+ // The first character left out is whitespace: nothing straddles the cut.
79
+ if (!/\s/.test(text[cut]!)) {
80
+ while (cut > 0 && !/\s/.test(text[cut - 1]!)) cut--
81
+ }
82
+ return { head: text.slice(0, cut), omitted: text.length - cut }
83
+ }
84
+
85
+ /**
86
+ * Credentials out of free text, and nothing else (6.52.0, the Messages trail).
87
+ *
88
+ * The trail carries the agent's own words between steps. `sanitizeActivityPreview`
89
+ * cannot be used for that: it flattens every newline and, through `looksOpaqueSecret`,
90
+ * hides ordinary narration outright ("I will read the config file and then run the
91
+ * tests." reads as an opaque secret to it). This applies ONLY the private-material
92
+ * block and the secret patterns, keeps newlines, and imposes no length: the caller
93
+ * caps after redacting, so a secret is never half-cut into a visible prefix.
94
+ */
95
+ export function redactSecretText(raw: string): string {
96
+ if (typeof raw !== 'string' || raw.length === 0) return ''
97
+ let text = raw.replace(PRIVATE_MATERIAL_BLOCK_RE, '[private material hidden]')
98
+ for (const [pattern, replacement] of SECRET_PATTERNS) text = text.replace(pattern, replacement)
99
+ return text
100
+ }
101
+
59
102
  export function sanitizeActivityPreview(raw: unknown, max = 180): string | null {
60
103
  if (typeof raw !== 'string') return null
61
104
  let text = raw.replace(ANSI_RE, '').replace(PRIVATE_MATERIAL_BLOCK_RE, '[private material hidden]')
@@ -37,6 +37,7 @@ import {
37
37
  isWrapperPrompt,
38
38
  isoFromMtime,
39
39
  lastCustomTitle,
40
+ codexUserText,
40
41
  listCodexJsonlFiles,
41
42
  loadClaudeStarredIds,
42
43
  loadCodexPinnedIds,
@@ -149,7 +150,8 @@ function extractUserText(provider: AgentProvider, raw: string): string {
149
150
  if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') continue
150
151
  const payload = obj.payload as Record<string, unknown>
151
152
  if (payload.type !== 'message' || payload.role === 'developer' || payload.role === 'assistant') continue
152
- const body = payloadText(payload)
153
+ // 6.52.0: without the AGENTS.md block, which every session shares and nobody typed.
154
+ const body = codexUserText(payload)
153
155
  if (body && !isWrapperPrompt(body)) parts.push(firstLineTitle(body) || body)
154
156
  } else {
155
157
  if (obj.role !== 'user') continue