@gotcos/glasses-server 6.51.0 → 6.52.3

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 (34) hide show
  1. package/.env.example +16 -0
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +56 -3
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +58 -9
  7. package/server/lib/activity-preview.ts +45 -2
  8. package/server/lib/agent-session-search.ts +3 -1
  9. package/server/lib/agent-session-store.ts +35 -5
  10. package/server/lib/agent-session-turns.ts +4 -1
  11. package/server/lib/claude-bridge.ts +9 -0
  12. package/server/lib/client-instance-claim.ts +22 -1
  13. package/server/lib/client-liveness.ts +50 -0
  14. package/server/lib/codex-bridge.ts +6 -0
  15. package/server/lib/cursor-bridge.ts +6 -0
  16. package/server/lib/job-trail.ts +758 -0
  17. package/server/lib/ollama-bridge.ts +17 -0
  18. package/server/lib/permission-broker.ts +1156 -0
  19. package/server/lib/query-job-coordinator.ts +17 -1
  20. package/server/lib/query-job-feature.ts +10 -0
  21. package/server/lib/query-job-runtime.ts +9 -1
  22. package/server/lib/query-job-store.ts +214 -5
  23. package/server/lib/query-job-types.ts +36 -0
  24. package/server/lib/session-signal-store.ts +42 -10
  25. package/server/lib/session-state-derive.ts +14 -4
  26. package/server/lib/session-stream-events.ts +629 -18
  27. package/server/lib/session-stream-producer.ts +25 -6
  28. package/server/lib/session-transcript-watcher.ts +120 -3
  29. package/server/routes/agent-sessions.ts +12 -1
  30. package/server/routes/client-instance.ts +12 -1
  31. package/server/routes/cursor-stop-followup.ts +14 -7
  32. package/server/routes/health.ts +24 -1
  33. package/server/routes/permission-broker.ts +176 -0
  34. package/server/routes/query-jobs.ts +84 -13
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,89 @@
1
+ ## 6.52.3
2
+
3
+ ### A quiet ring stays with its owner; a zombie never takes it on waking
4
+
5
+ 2026-09-20: a hidden copy of the glasses app from the night before took the ring after the live copy went quiet, and answered a hold-to-talk 27 minutes later with a stale session. Under 6.50.4 an older boot could take a ring whose owner had been quiet for 45 s, and a locked phone is quiet for longer than that.
6
+
7
+ - **An older boot takes a quiet ring back only after ten minutes** (`CLIENT_INSTANCE_RECLAIM_MS`), **and only while it has itself been claiming** (`CLIENT_INSTANCE_AWAKE_MS`, 45 s: its own previous claim must be that recent). Both copies sleep when the phone does, so a zombie that wakes beside the live copy has a gap of its own and is refused; the live copy's refresh then lands within a tick. A copy never seen before is not awake. A NEWER boot still takes at once, however quiet the owner: reopening COS remains the fix for a dead copy. The route remembers each copy's last claim time (`CLIENT_INSTANCE_MAX_SEEN` 64, least recent dropped).
8
+ - Everything else in the referee is 6.50.4: one owner per device, the recording holds (own tagged chunks, `recording` in the claim), `check: true` never takes, no protocol change. Glasses 6.9.510 and 6.9.516 see only the longer takeover window. No new client is required; glasses 6.9.518 adds the matching client-side check.
9
+ - **The 6.52.1 Codex fixtures now expect `turn_started_at`.** 6.52.1 stamped the prompt line with the user record's timestamp and left three `session-stream-codex` expectations on the old shape, so the suite was red. The behaviour is unchanged; the expectations were updated.
10
+ - Tests: the reclaim edge (10 min, awake), the overnight zombie (hours-old own claim, never seen), the awake edge to the millisecond, the newer boot still taking, and over HTTP: checks past the window say owner without taking, a first claim after sleep is refused and the next tick takes. Gate: `referee-zombie-reclaims-after-45s`, `referee-asleep-zombie-reclaims`, `referee-never-seen-counts-as-awake`, `referee-last-seen-unnoted`.
11
+
12
+ ## 6.52.1
13
+
14
+ ### Session turn clock
15
+
16
+ Sessions prompt events now carry additive `turn_started_at` (ISO), the timestamp on the user record that started the turn. Seed, reconnect, and live frames keep that value; transport `at` is still `Date.now()` at emit time, so a reconnect no longer resets the glasses elapsed clock. `/api/models` advertises `capabilities.runningTurnMeta.protocolVersion = 1`. A 6.52.0 client strips the unknown field and renders as before. Messages jobs already have durable `startedAt`; this is Sessions only.
17
+
18
+ - Tests: a Claude user row with `timestamp` stamps the prompt draft; a row without one is byte-identical to 6.52.0; the models capability is present beside `sessionQuestions`.
19
+
20
+ ## 6.52.0
21
+
22
+ ### Messages trail
23
+
24
+ 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).
25
+
26
+ - **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.
27
+ - **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.
28
+ - **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.
29
+ - **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).
30
+ - **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.
31
+ - **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.
32
+ - **Health.** `/api/health` gains `messages_trail`: `enabled`, and since boot `linesRead`, `entriesEmitted`, `readerErrors` and `lastErrorAt`. Counts and a timestamp only.
33
+ - 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.
34
+ - 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.
35
+
36
+ ### Session questions and approvals from the glasses
37
+
38
+ 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.
39
+ - **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`.
40
+ - **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.
41
+ - **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.
42
+ - **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.
43
+ - **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.
44
+ - **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.
45
+
46
+
47
+ - **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.
48
+ - **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.
49
+ - **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.
50
+ - **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.
51
+ - **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`.
52
+ - **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.
53
+ - **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.
54
+ - **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.
55
+ - **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).
56
+ - **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.
57
+ - **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.
58
+ - 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.
59
+
60
+ ### Codex threads show what Codex writes
61
+
62
+ 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.
63
+ - **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.
64
+
65
+
66
+ - **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.
67
+ - **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.
68
+ - **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.
69
+ - **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.
70
+ - **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.
71
+ - **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.
72
+ - **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.
73
+ - 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.
74
+
75
+ ### QA round 1 (before release)
76
+
77
+ - **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.
78
+ - **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.
79
+ - **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.
80
+ - **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.
81
+ - **Liveness contract, no reinstall, Codex exec output, `off` mode, the desk read, telemetry, capacity, privacy, "Other" text:** above.
82
+ - **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/**`.
83
+ - **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.
84
+ - 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.
85
+ - 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).
86
+
1
87
  ## 6.51.0
2
88
 
3
89
  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.
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
@@ -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.51.0",
3
+ "version": "6.52.3",
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,
@@ -209,6 +220,45 @@ app.use(cors({
209
220
  // reader to conclude the server had stopped sending Range headers.
210
221
  exposedHeaders: ['Content-Range', 'Accept-Ranges'],
211
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
+
212
262
  // Auth middleware — always active (token is auto-generated if not set).
213
263
  // Mounted before body parsers so rejected uploads cannot consume parse memory.
214
264
  // Two capability-URL exceptions: a canonical /tts/play/<UUID> GET/HEAD (minted by
@@ -574,6 +624,10 @@ app.use('/api', agentSessionStreamRouter)
574
624
  // COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
575
625
  app.use('/api', claudeSessionsRouter)
576
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 }))
577
631
  // Phase 0 of Continue Original Agent Thread: can COS write into a desktop thread
578
632
  // without colliding with a live writer? Read-only — it answers, it never attaches.
579
633
  // Registered AFTER agentSessionsRouter deliberately: its paths are 2 and 4 segments
@@ -707,14 +761,7 @@ if (threadAttachEnabled()) {
707
761
  now: () => Date.now(),
708
762
  }
709
763
  app.use('/api', createThreadTurnQueueRouter(queueDeps))
710
- // 6.51.0: a queued Cursor turn leaves ONLY through the composer's own Stop hook (the
711
- // drainer skips Cursor, see `drainThread`). Hook-token auth, outside /api.
712
- app.use(createCursorStopFollowupRouter({
713
- hookToken: readHookToken,
714
- readQueue,
715
- writeQueue,
716
- now: () => Date.now(),
717
- }))
764
+ // The Cursor Stop door is mounted before the /api gate and the body parser (see there).
718
765
 
719
766
  // Every 20s. Fast enough that a freed thread drains while the user is still looking
720
767
  // at the pending row, slow enough to be nothing: the sweep does no work at all when
@@ -874,6 +921,8 @@ async function gracefulShutdown(): Promise<void> {
874
921
  stopMeetingImportScheduler()
875
922
  stopMeetingMergeScheduler()
876
923
  sessionHooksRuntime.stop()
924
+ // Every held hook gets `{}` (the native dialog) before the process goes.
925
+ permissionBroker.stop()
877
926
  try {
878
927
  await shutdownQueryJobRuntime('server_shutdown')
879
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
@@ -11,7 +11,7 @@ import { homedir } from 'node:os'
11
11
  import { join } from 'node:path'
12
12
  import { createInterface } from 'node:readline'
13
13
  import { promisify } from 'node:util'
14
- import { unwrapPeerMessage } from './session-stream-events.js'
14
+ import { codexUserAsk, unwrapPeerMessage } from './session-stream-events.js'
15
15
 
16
16
  const execFileAsync = promisify(execFile)
17
17
 
@@ -484,6 +484,30 @@ export function payloadText(payload: Record<string, unknown>): string | null {
484
484
  return joined || null
485
485
  }
486
486
 
487
+ /**
488
+ * What the person typed, out of one Codex `response_item/message` user row, or null
489
+ * (6.52.0).
490
+ *
491
+ * `payloadText` joins every block, so the AGENTS.md block Codex writes as a user message
492
+ * ("# AGENTS.md instructions for /Users/…", the FIRST user row in 469 rollouts on this
493
+ * Mac) became the session's title, first prompt and opening DISCUSSION line, and typed
494
+ * words after an `<image>` block were dropped with the wrapper. `codexUserAsk` reads the
495
+ * row block by block; the stream's `prompt` line uses the same function, so the two
496
+ * cannot disagree about what was asked.
497
+ */
498
+ export function codexUserText(payload: Record<string, unknown>): string | null {
499
+ const content = payload.content
500
+ if (typeof content === 'string') return codexUserAsk([content])
501
+ if (!Array.isArray(content)) return null
502
+ return codexUserAsk(content.map(block => {
503
+ if (!block || typeof block !== 'object') return null
504
+ const type = String((block as { type?: unknown }).type ?? '')
505
+ if (type !== 'text' && type !== 'input_text') return null
506
+ const text = (block as { text?: unknown }).text
507
+ return typeof text === 'string' ? text : null
508
+ }))
509
+ }
510
+
487
511
  export function isoFromMtime(mtimeMs: number): string {
488
512
  return new Date(mtimeMs).toISOString()
489
513
  }
@@ -912,7 +936,7 @@ export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: st
912
936
  if (!title && obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
913
937
  const payload = obj.payload as Record<string, unknown>
914
938
  if (payload.type === 'message' && payload.role === 'user') {
915
- const textBody = payloadText(payload)
939
+ const textBody = codexUserText(payload)
916
940
  if (textBody && !isWrapperPrompt(textBody)) title = firstLineTitle(textBody)
917
941
  }
918
942
  }
@@ -1624,12 +1648,15 @@ export interface AgentSessionDetail {
1624
1648
  export async function parseAgentSession(
1625
1649
  provider: AgentProvider,
1626
1650
  path: string,
1627
- opts: { maxBytes?: number; headBytes?: number; tailBytes?: number } = {},
1651
+ opts: { maxBytes?: number; headBytes?: number; tailBytes?: number; codexThreadName?: string } = {},
1628
1652
  ): Promise<AgentSessionDetail> {
1629
1653
  const st = await stat(path)
1630
1654
  const maxBytes = opts.maxBytes ?? AGENT_SESSION_MAX_FILE_BYTES
1631
1655
  const truncated = st.size > maxBytes
1632
- let title = ''
1656
+ // 6.52.0: a Codex thread's own name (`session_index.jsonl`, what the Codex app shows)
1657
+ // titles the detail the way it already titles the list row. The first ask is still
1658
+ // read below and published as `first_prompt`.
1659
+ let title = provider === 'codex' ? (opts.codexThreadName ?? '').trim() : ''
1633
1660
  let project = ''
1634
1661
  let gitBranch = ''
1635
1662
  let sessionId = path.split('/').pop()?.replace(/\.jsonl$/, '') || ''
@@ -1771,7 +1798,10 @@ export async function parseAgentSession(
1771
1798
  continue
1772
1799
  }
1773
1800
  if (kind !== 'message' || payload.role === 'developer') continue
1774
- const text = payloadText(payload)
1801
+ // 6.52.0: a user row is read block by block (`codexUserText`), so the AGENTS.md
1802
+ // block Codex writes as a user message is not the title, the first prompt or a
1803
+ // DISCUSSION turn. The assistant path is unchanged.
1804
+ const text = payload.role === 'assistant' ? payloadText(payload) : codexUserText(payload)
1775
1805
  if (!text || isWrapperPrompt(text)) continue
1776
1806
  if (payload.role === 'assistant') {
1777
1807
  assistantCount += 1
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { open, stat } from 'node:fs/promises'
28
28
  import {
29
+ codexUserText,
29
30
  latestAssistantReply,
30
31
  isWrapperPrompt,
31
32
  parseJsonLine,
@@ -112,7 +113,9 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
112
113
  const payload = obj.payload as Record<string, unknown>
113
114
  if (String(payload.type ?? '') !== 'message') return null
114
115
  if (payload.role !== 'user' && payload.role !== 'assistant') return null
115
- const text = payloadText(payload)
116
+ // 6.52.0: a user row block by block, so typed words after an `<image>` block are kept
117
+ // and an attached-file message reads as the request that was typed.
118
+ const text = payload.role === 'user' ? codexUserText(payload) : payloadText(payload)
116
119
  if (!text) return null
117
120
  return shaped(payload.role, text, iso(obj.timestamp))
118
121
  }
@@ -59,6 +59,7 @@ import {
59
59
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
60
60
  import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
61
61
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
62
+ import { teeJobTrail, type JobTrailDraft } from './job-trail.js'
62
63
 
63
64
  // Inactivity = no stdout data for this long → kill (catches stalls)
64
65
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -336,6 +337,9 @@ export interface StreamCallbacks {
336
337
  onActivityLine?: (line: ActivityPreviewLine) => void
337
338
  onStart?: (model: ModelPreference, sessionId: string, cliSessionId?: string, metadata?: ModelRunMetadata) => void
338
339
  onProviderProcess?: (metadata: ProviderProcessMetadata) => boolean | void | Promise<boolean | void>
340
+ /** 6.52.0: readable steps of a durable Messages job (lib/job-trail.ts). Optional, and
341
+ * absent on every path that is not a durable job with the trail on. */
342
+ onTrail?: (draft: JobTrailDraft) => void
339
343
  }
340
344
 
341
345
  /** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
@@ -964,6 +968,11 @@ export async function callClaudeStreaming(
964
968
  }
965
969
  })
966
970
 
971
+ // 6.52.0: the Messages trail, a SECOND stdout reader registered after the one above, so
972
+ // that handler sees every chunk first and nothing it decides can change. Without an
973
+ // `onTrail` (legacy /api/query, openai-compat, COS_MESSAGES_TRAIL=0) this attaches nothing.
974
+ teeJobTrail(proc.stdout, 'claude', callbacks.onTrail)
975
+
967
976
  proc.stderr.on('data', (chunk: Buffer) => {
968
977
  // stderr activity also counts — Claude CLI logs progress there
969
978
  resetInactivity()