@gotcos/glasses-server 6.76.1 → 6.78.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 (52) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +17 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +98 -10
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/cos-bridge-notes.ts +73 -0
  13. package/server/lib/decision-provider.ts +9 -0
  14. package/server/lib/env-switch.ts +17 -0
  15. package/server/lib/fuzzy-correct.ts +77 -49
  16. package/server/lib/live-cos-ask.ts +41 -7
  17. package/server/lib/live-cues-v2-notes.ts +152 -0
  18. package/server/lib/live-cues-v2-settings.ts +63 -0
  19. package/server/lib/live-cues-v2.ts +198 -38
  20. package/server/lib/managed-restart.ts +8 -0
  21. package/server/lib/provider-actions.ts +18 -0
  22. package/server/lib/query-job-coordinator.ts +300 -0
  23. package/server/lib/query-job-feature.ts +39 -0
  24. package/server/lib/query-job-resume.ts +269 -0
  25. package/server/lib/query-job-runtime.ts +23 -3
  26. package/server/lib/query-job-store.ts +516 -7
  27. package/server/lib/query-job-types.ts +210 -1
  28. package/server/lib/queue-turn-evidence.ts +213 -0
  29. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  30. package/server/lib/thread-turn-queue-store.ts +13 -4
  31. package/server/lib/thread-turn-queue.ts +151 -1
  32. package/server/lib/transcript-cleanup-context.ts +234 -0
  33. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  34. package/server/lib/transcript-cleanup-model.ts +217 -0
  35. package/server/lib/transcript-cleanup-screen.ts +249 -0
  36. package/server/lib/transcript-cleanup-spans.ts +358 -0
  37. package/server/lib/transcript-cleanup-state.ts +201 -0
  38. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  39. package/server/lib/transcript-cleanup-text.ts +200 -0
  40. package/server/lib/transcript-cleanup.ts +427 -0
  41. package/server/routes/agent-session-bindings.ts +169 -11
  42. package/server/routes/glossary.ts +38 -19
  43. package/server/routes/health.ts +29 -1
  44. package/server/routes/live-cues.ts +32 -3
  45. package/server/routes/maintenance.ts +34 -0
  46. package/server/routes/meeting.ts +92 -9
  47. package/server/routes/query-jobs.ts +107 -13
  48. package/server/routes/recovery.ts +4 -0
  49. package/server/routes/thread-turn-queue.ts +371 -10
  50. package/server/routes/transcribe.ts +3 -0
  51. package/server/routes/transcript-cleanup.ts +237 -0
  52. package/shared/live-cues-v2.ts +9 -4
package/.env.example CHANGED
@@ -61,6 +61,38 @@ BIND_HOST=0.0.0.0
61
61
  # and drain safely.
62
62
  # COS_DURABLE_QUERY_JOBS=0
63
63
 
64
+ # 6.78.0: crash-loop parking. Work the server resumes on boot (meeting finalization, a
65
+ # question that never started) is parked after it was resumed on 3 boots in a row that each
66
+ # followed a crash less than 30 minutes after the last, instead of restarting the server
67
+ # forever. Update Server and other clean restarts never count, and a clean exit starts the
68
+ # count over. Parked work stays saved and is listed under `parked` in
69
+ # GET /api/maintenance/status (the count is on /api/health). Retry it with
70
+ # POST /api/maintenance/parked/<kind>/<id>/retry with the API token (the phone and the lens
71
+ # can, not only this Mac), or Retry the question in the app. A parked meeting finalization
72
+ # also gets one automatic second chance on the first boot 30 minutes after it was parked.
73
+ # On by default; 0 / false / off turns it off (6.77.0 behaviour).
74
+ # COS_CRASH_LOOP_PARKING=0
75
+
76
+ # 6.78.0: restart truth. A question a restart cut off (a crash, or a graceful stop such as
77
+ # Update Server or a Mac restart) ends "Server restarted. Tap to retry." and its job record
78
+ # names the step it was cut off during and the steps that had already changed something
79
+ # (edits, commands), so you can choose Resume or Retry knowing what already happened. On by
80
+ # default; 0 / false / off turns it off (the 6.77.0 messages, and no `interruption` record).
81
+ # COS_RESTART_NOTICE=0
82
+
83
+ # 6.78.0: the compact question journal. Off by default in this release: this release only
84
+ # READS compact rows, so a rollback to it from the next one keeps every answer. 1 / true / on
85
+ # stores each streamed piece once instead of the whole answer so far (much smaller journals).
86
+ # Leave it off until the release after 6.78.0 turns it on.
87
+ # COS_QUERY_JOB_DELTA_JOURNAL=1
88
+
89
+ # 6.78.0: steer a running answer from the glasses (Claude only). Off by default in 6.78.0.
90
+ # 1 / true / on lets COS Glasses 6.10.645 or later add guidance to an Ask while Claude is still
91
+ # working on it: the guidance joins the running answer at its next step. Codex, Cursor and
92
+ # Ollama answers are never steered; the app sends the guidance as the next Ask instead.
93
+ # Needs a Claude Code CLI that lists --input-format and --replay-user-messages.
94
+ # COS_MESSAGES_STEER=1
95
+
64
96
  # 6.52.0: the Messages trail (readable steps of a running Messages job, journaled and sent
65
97
  # only to a client that asks with ?trail=1). On by default. 0 is the rollback that needs no
66
98
  # downgrade: no trail is recorded and every job stream and snapshot is the 6.51.0 one.
@@ -377,3 +409,11 @@ COS_CONTINUE_FULL_PERMISSIONS=1
377
409
  # Drop-in alternative: link a folder holding parakeet-ultra/ to
378
410
  # <data>/stt/parakeet-sources and an install started with {} uses it, no download.
379
411
 
412
+
413
+ # ── Sessions: restart safety, Send again and steer (6.78.0) ─────────────────
414
+ # Read per request, like every 6.78.0 switch: 1 / true / on is on, 0 / false / off is off,
415
+ # anything else the default.
416
+ # COS_QUEUE_RECONCILE=1 # attempt journal, restart reconcile, receipts, reason copy (default on)
417
+ # COS_PIN_BOOT_REAP=1 # free a thread a crashed run held, 31 min after the restart and once its child is gone (default on; 0 = the 24 h reap)
418
+ # COS_QUEUE_RESEND=1 # Send again on a refused or expired queued turn (default on)
419
+ # COS_SESSION_STEER=0 # steer a busy Claude session through its peer inbox (default off)
package/CHANGELOG.md CHANGED
@@ -1,3 +1,59 @@
1
+ ## 6.78.0 - Steer from the glasses, Retry and Send again, and crash safety for Asks and desk turns (Pi Durable parity)
2
+
3
+ Pairs with COS Glasses companion 6.10.645. Includes 6.77.0. No COS Control change is required (0.5.322 and 0.5.323 work; see the client matrix). Miles, 2026-10-18: steer from the glasses and retry, for Messages and Sessions; no regressions; better offline to online. Plan: Pi Durable parity build v3.1.
4
+
5
+ - **Messages steer (`COS_MESSAGES_STEER`, default off).** A running Claude Ask that was admitted `steerable: true` (outside the fingerprint) runs with `--input-format stream-json --replay-user-messages`: the prompt is one stream-json user line and stdin stays open. `POST /api/query-jobs/:jobId/steer {generation, steerId, text}` journals the steer (fsync) before it is written to stdin, then answers `{steerId, state, fallback, jobStatus}`; `GET /api/query-jobs/steers/:steerId?clientJobId=` answers the same from the newest generation. States: accepted, delivered (the CLI replayed the line), folded (a result covered it), included (carried into a Resume), too_late, unsupported, not_applied (computed when read). The run finishes only when the results cover every line written; a result that covers the prompt with a steer still open journals an answer checkpoint, and every terminal path but cancel completes with it. Same id and text replays; other text is 409 `steer_identity_conflict`; 8 steers a job (429 `steer_limit`). `fallback` is `queue_follow_up` for too_late, unsupported, and not_applied on a completed job only; a failed or interrupted job answers null because Retry or Resume carries the steer into generation + 1. A steer the CLI replayed (delivered) never falls back, even when a deadline, a restart or a second-turn error then completes the job from its checkpoint and the steer reads not_applied: it may already be in the answer or the session, so sending it again would apply it twice. A run that ends with a steer line written and no result covering it kills the CLI (instead of closing stdin, which let the CLI run the line unwatched) and drops its CLI session mapping, so the next Ask does not `--resume` a session that holds the line. The steer cut-off is measured on the monotonic clock the run's timers use, so a Mac that slept does not refuse every steer as too late. SSE `?steer=1` adds `event: steer` frames with no `id:` and no eventSeq. Capability `capabilities.durableQueryJobs.steer = {protocolVersion: 1, providers: ["claude"]}` only with the switch on and the CLI probe passed. Codex, Cursor and Ollama answer `unsupported`. With the switch off every argument, stdin byte and route body is 6.77.0's (goldens recorded from 6.77.0).
6
+ - **Session steer (`COS_SESSION_STEER`, default off).** `POST /api/agent-sessions/:provider/:threadId/queued-turns` takes `mode: "steer"` and `fallback: "queue"|"none"`. A busy Claude thread with a peer inbox, `COS_CONTINUE_LIVE` on and no pending permission prompt gets the steer through the peer inbox (200 `{state: delivered, reason: steered}`): the intent row is written durably, then the attempt, then the frame. Unverified, a write failure or a throw: 200 `refused`, `steer_unverified`, `deliveryState: unknown`, never resent. Otherwise it queues (202 with `steerFallback`). The attach refusal gains `steer: {available, mode | reason}` and `act.steer` per provider; both are absent with the switch off.
7
+ - **Restart truth (`COS_RESTART_NOTICE`, default on).** An Ask cut off by a crash (`server_restarted`) or a graceful shutdown (`server_shutdown`) ends with `error: {code: interrupted, cause, message: "Server restarted. Tap to retry.", retryable: true}` (31 characters: the whole message fits the lens's 32-column lead) and an `interruption` block built from the job's own trail: `cutOffDuring`, `alreadyDone` (side effects only, 5 entries of 60 characters at most), `incomplete`, `resumable`. A graceful shutdown first lets the steps a run already reported reach the journal (1 s for all runs). The raw text `server_shutdown` no longer reaches the lens.
8
+ - **Crash-loop parking (`COS_CRASH_LOOP_PARKING`, default on).** Boot-resumed work (a meeting finalization, a requeued Ask) that crashed the server on 3 consecutive boots within 30 minutes is parked instead of retried forever. Clean restarts never count; a missing or unreadable ledger reads as a clean boot; any I/O error fails open. A parked Ask ends `restart_limit`, `cause: crash_loop_parked`, "Stopped after 3 server crashes." `GET /api/maintenance/status` gains `parked[]` (contractVersion stays 2; a parked entry reads `attempts: 4`, the count past the limit); `POST /api/maintenance/parked/:kind/:id/retry` retries one (token auth, so the phone and the lens can, refused during a drain). `/api/health` adds `crash_loop_parking: {enabled, parked}`, a count only. A parked meeting finalization gets one automatic second chance on the first boot at least 30 minutes after it was parked (a kernel panic storm can park a meeting that was never the cause); a second park is permanent until Retry. A parked Ask never unparks by itself. The phone's Restart marks the exit clean before the kickstart, so a restart that ends in the 2 s `exit(1)` fallback is not counted as a crash.
9
+ - **Queued desk turns after a crash (`COS_QUEUE_RECONCILE`, default on).** A durable attempt journal (target-scoped, 48 h) is written before any frame or spawn. A row entering `delivering` is stamped with its boot. At the next boot a row another boot left `delivering` is settled on evidence, in order: a completed or queued ledger record or a sent attempt, an exact transcript match, a Codex queue row with the same text (delivered); an ambiguous record, an unstamped 6.77 row or a steer row without evidence (unknown); only a `not_sent` attempt or a stamped row with no attempt goes back to waiting. Unknown is settled `refused`, `delivery_unknown_after_restart`, kept 6 h, never resent. The turns POST consults receipts (delivered rows, unknown rows, the attempt journal) before the claim, so a phone retry after a restart replays instead of sending twice; the drainer's own `delivering` row is never a receipt. Rows gain `reasonCopy`, `deliveryState`, `mode`, `resendOf`, `resentAs`.
10
+ - **Orphan pins (`COS_PIN_BOOT_REAP`, default on).** A pin a dead boot left frees its thread 31 minutes after the later of the pin and the restart (the longest turn the attached adapter accepts, 30 minutes, plus one), not 24 h. A turn that spawned a resume child records the child's pid and measured start time on its pin, and that pin also waits until the child is gone (no such pid, or a reused one). Until then the attach refusal reads "An earlier run may still be running · frees h:mm", or, past the window with the child still running, "An earlier run is still running on this Mac".
11
+ - **Send again (`COS_QUEUE_RESEND`, default on).** `POST .../queued-turns/:clientTurnId/resend` copies a refused or expired row's words into one new row, once from any surface (a repeat answers the first copy); an unknown original needs `acknowledge: "may_duplicate"` (409 `acknowledge_required`); a pruned one is 404 `unknown_turn`. Rows the flag can act on carry `resendable: true`.
12
+ - **Ask journal size (MVP-D release 1).** The reader understands delta-only chunk rows and checks their length; `createQueryJobJournalStorage()` is exported. The writer stays behind `COS_QUERY_JOB_DELTA_JOURNAL` (default off) until the next release, so a rollback to 6.77.x never meets delta rows.
13
+ - **Flags.** On by default with kill switches: `COS_CRASH_LOOP_PARKING`, `COS_RESTART_NOTICE`, `COS_QUEUE_RECONCILE`, `COS_PIN_BOOT_REAP`, `COS_QUEUE_RESEND`. Off by default (on for Miles's candidate, flipped after a 2-week field window): `COS_MESSAGES_STEER`, `COS_SESSION_STEER`, `COS_QUERY_JOB_DELTA_JOURNAL`. All eight are in `managed-runtime-contract.json` `optionalEnvironment`, `.env.example` and the README. All eight read the same way (one parser): `1`, `true` or `on` is on, `0`, `false` or `off` is off, case and spaces ignored, anything else the default.
14
+ - **Client matrix.** Companion 6.10.645 shows the restart notice, Retry this ask, steer and Send again. Companion 6.10.644 shows an unknown row as "Refused" (its Edit parks the text as new and "Send as new" can duplicate), has no Send again, and still reads 409 `duplicate_turn` as parked. COS Control 0.5.322/0.5.323 shows "Refused · delivery unknown after restart", drops `reasonCopy` and has no Send again or parked-item row. A thread that had a stranded `delivering` row gets the busy-Codex hop back once the row is reconciled (intended).
15
+ - **Known limits.** A parked meeting finalization shows in the maintenance status, the `/api/health` count and the `boot_resume.parked` log line until Control's row ships; it has its one automatic second chance meanwhile. Pre-6.78 pins and stranded rows settle as unknown, with no ledger record, during the upgrade window. A loop already running at install parks after 4 crashes, not 3. A pin with no recorded child (a 6.77 pin, a live delivery, a spawn whose record failed) has only the 31-minute window, a bound and not a proof the orphan is gone. Session steer is Claude only (Codex has `turn/steer`, but COS does not hold its app-server channel); with hooks off a session held on a permission prompt is steered and lands when the prompt is answered. Live-verified on Claude Code 2.1.302: Messages steer in the bridge's exact argument shape (`--include-partial-messages`, `--effort`, `--system-prompt`, `--allowedTools`) on a fresh session and on a `--resume`d persisted one. Not live-verified: a steer during a permission prompt (approval) on a Messages run, and MCP tools with stream-json input. Dispatch auto-resume (the restart planner ships uncalled, marked internal), phone steer buttons and the delta writer are deferred.
16
+
17
+ ## 6.77.0 - Transcript cleanup: the passages the engine was unsure of, resolved with the meeting's text and screen context (default off)
18
+
19
+ Includes 6.76.2. Off until Miles turns it on. No COS Control or companion change is required; Control can add the toggle, the screen feed and the Dictionary suggestions later against the contracts below.
20
+
21
+ - Miles, 2026-10-17: after a transcription (Parakeet Ultra or Whisper), run Haiku or Sonnet over the passages where the engine was unsure, with screen and textual awareness, so word hallucinations and misheard names ("Queen Ukoma", "Yukoma", "eucoma", "U coma" for Queen Ukaoma) are resolved.
22
+ - Where it runs. Once per saved meeting, in the durable finalization job after the HQ batch text and the Nemotron relabel are written, and before the meeting is handed to COS operations or summarised, so both read the cleaned text. Orphan recovery runs it too. Never during live transcription: it is a separate module no transcription file imports (pinned by a test), and a meeting whose session is still live is skipped. It never fails a save.
23
+ - Selection, no model (`transcript-cleanup-spans.ts`): low confidence (Parakeet below 0.50, Whisper below 0.40, from the batch words in the sidecar, aligned to the saved transcript); a word near a known name when the engine was unsure of it (below 0.9: one edit past fuzzy-correct's limit for a capitalised word, or the same consonant skeleton and onset, so eucoma reads like Ukaoma); two or three short pieces that join into a name ("U coma"), never function words; the leading-y rule at any confidence ("Yukoma"); an out-of-vocabulary capital mid-sentence beside a known name; a phrase repeated three times or more. Spans stay inside one speaker turn and one clause, hold at most 6 words (12 for a repeat), carry 12 words of context each side, and are cut to 40 per meeting with names first.
24
+ - Deterministic fixes first, free (fuzzy-correct's own rule, exported as `fuzzyReplacementFor`): only capitalised words that are not already a known spelling; this meeting's own screen and file terms as a temporary per-meeting vocabulary at any confidence; the curated dictionary (vocabulary and corrections) and the owner only where the engine was unsure; never an inflection ("Setting" is never a "Settings" button). Measured read-only on 68 real meetings (38.5 hours): rewriting lowercase words, or using every enrolled name as a target, swapped everyday words and real names for other names (giant to grant, Kelsey to Kelley, Slack to Black), so neither is done; with these rules the same meetings got 27 fixes, all real (Manuj to Manoj, MarketPOS to MarktPOS).
25
+ - Context pack: meeting title, date and attendees; COS people (enrolled speaker names); the owner card (6.76.0); the profile vocabulary and heard-to-written corrections (the COS dictionary dictation uses); text of files dropped on the meeting (the Meeting Ask file list); up to 6 local notes hits for the names the spans are near (memories and earlier meetings by keyword, and the memory bridge listing when memory search finds nothing, as the Live COS Ask does; never this meeting; nothing leaves the Mac; 3 s budget); and up to 3 screen lines per span, nearest in time (45 s) or naming the span's name. Every text is bounded and passed through the secret redactor.
26
+ - The call: one per meeting, batched, through the shared Claude CLI runner (`--tools ""`, no MCP servers, no hooks, empty scratch workspace), pinned `claude-haiku-5-5` by default or `claude-sonnet-5-5`, low effort, 1,500 output tokens, 75 s timeout, 24,000-character prompt, token audit source `transcript-cleanup`. Never an SDK. The reply is strict JSON by span id (`keep` or `replace`); a fenced reply or one followed by prose still parses.
27
+ - Guards on every edit (`transcript-cleanup-model.ts`): unknown or duplicate span id, bad action, no-op, a bracket or line break (a forged speaker label), more than one word over the span or twice its length, a neighbouring context word (text outside the span), a changed number or unit, an added or dropped negation, and any word that is neither a misheard span word (close spelling or same sound) nor a term this meeting's context knows. A repeat may only shrink to a run of its own words. Edits are applied by the span's offsets, never by the model's text.
28
+ - Undoable ledger (`transcript-cleanup-ledger.ts`, modelled on `meeting-corrections.ts`): intent first, every edit with its original words, offset and surrounding text, then the files (sidecar first, markdown second, both re-read and compared first: a change while the model answered writes nothing). Only the `## Transcript` section, the sidecar's `batchTranscript` (kept equal to it), `transcriptCleanup` (counts only) and `lifecycleRevision` change. Any edit or a whole run can be restored; a line changed since refuses with 409 `stale`. A rerun on a cleaned meeting does nothing.
29
+ - Stops: its own switch (`COS_TRANSCRIPT_CLEANUP=1` forces on, `=0` forces off, else the saved setting, else off; off means no read, no write, no trace); `ANTHROPIC_API_KEY` without `COS_ALLOW_API_FALLBACK=1` never calls; no Claude CLI never calls; 30 calls a day (`COS_TRANSCRIPT_CLEANUP_DAILY`) charged before the call; 400k tokens a day (`COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS`); 40 spans a meeting (`COS_TRANSCRIPT_CLEANUP_MAX_SPANS`, at most 80); a breaker after 3 failures in a row (30 minutes, one trial call after) or one provider limit (60 minutes). Each stop is recorded as `modelSkipped` while the free fixes still apply. An unreadable or future-dated state file reads as a spent day. All six env names are forwarded by the managed runtime contract.
30
+ - Trace (`transcript-cleanup/trace.jsonl`) and one log line per meeting: spans found and sent by reason, accepted, kept, rejected by reason, fixes, suggestions, screen lines, files, notes, prompt characters, tokens, model and total milliseconds. No transcript, screen or name text.
31
+ - Dictionary suggestions: an accepted edit (fixed or model) whose written form came from this meeting's screen, files, owner card, COS people or notes, and that the dictionary lacks, becomes a suggestion with its evidence, never an automatic addition. Deduped by written form (case-insensitive), 200 kept, 5 heard forms and 5 evidence rows each; an accepted or dismissed form is never suggested again.
32
+ - Routes (token authenticated like every /api route):
33
+ - `GET /api/transcript-cleanup/settings` and `POST {enabled?, model?: "haiku"|"sonnet"}`: `{protocol: 1, enabled, source: env|setting|default, model, modelId, modelSource, limits: {dailyCalls, dailyTokens, maxSpansPerMeeting}, today: {calls, tokens, breakerOpenUntil}}`; 409 `env_forced` when env pins the value; 400 `invalid_request`.
34
+ - `POST /api/meeting/:sessionId/screen-context {lines: [{at: epoch ms or ISO, text, app?}]}`: at most 100 lines and 32 KB of text a request (413 `too_many_lines`, `request_too_large`), 400 characters a line (cut and counted), 2,000 lines and 400 KB a meeting (the rest `dropped`), duplicates within 30 s skipped, a line that reads like a password, key or token `held` and never stored, clocks more than 5 minutes outside the session refused. Answers `{ok, protocol, accepted, duplicates, held, truncated, invalid, dropped, stored}`. Accepted while the session is live, and after save until the cleanup has run (then 409 `cleanup_done`); 404 `unknown_session` otherwise. `GET` answers counts only (`lines, bytes, firstAt, lastAt`), `DELETE` answers `{deleted}`. Stored per meeting in `transcript-cleanup/screen/`, text only, and deleted with the meeting: a boot sweep removes screen and ledger files whose meeting, live session and finalization are all gone (after 72 hours), and screen text after 30 days regardless (`COS_TRANSCRIPT_SCREEN_RETENTION_DAYS`).
35
+ - `GET /api/meeting/:sessionId/transcript-cleanup`: `{protocol, runs, edits (with original and replacement words, reverted), unusable, cleanedCurrent}`. `POST .../transcript-cleanup/undo {editId}` or `{runId}`: `{reverted: [ids]}`; 404 `edit_not_found`, 409 `already_reverted` or `stale`.
36
+ - `GET /api/transcript-cleanup/suggestions?status=pending|all`: `{protocol: 1, suggestions: [{id: "sug_<16 hex>", written, heard: [], status: pending|accepted|dismissed, count, createdAt, updatedAt, inVocabulary, evidence: [{source: screen|file|owner|people|notes, sessionId, heard, at, detail}]}]}`. `POST .../:id/accept {correction?: boolean}` adds the word through the glossary's own vocabulary writer, or with `correction: true` learns heard to written through the logged, undoable glossary learn (source `meeting`); answers `{suggestion, unchanged, glossary, entry}`; 409 `suggestion_dismissed`. `POST .../:id/dismiss` answers `{suggestion}`.
37
+ - Capability: `/api/transcribe/capabilities` adds `transcriptCleanup: {protocol: 1, suggestions: 1, screenContext: 1}`; `/api/health` adds `capabilities.transcriptCleanup: {protocol: 1, enabled}` (codes only, it is public).
38
+ - Older path, for the record: the app's former server had a whole-transcript `claude -p sonnet` rewrite (`correctTranscript`, 40 KB cap, breaker after 2 failures for 30 minutes, `correction_skipped`), deleted with that fork on 2026-08-11. This server had no transcript LLM pass until now, and the COS repo's `sync_meetings.py` gates only title, summary and action extraction (`COS_MEETING_LLM`); it never rewrites transcript text. This pass is span-level, guarded and undoable instead.
39
+ - Evidence and limits: the 2026-10-13 silver eval of Haiku on doubtful Parakeet spans (no screen, owner or file context) was a WER tie with Parakeet alone. Real-model quality with this context is unmeasured. Read-only selection on 68 real meetings (38.5 h, 379,273 words, 100% aligned, no model call): 149 doubtful spans an hour found (low confidence 93%), 48.9 an hour sent, a median prompt of about 1,840 input tokens, 33 meetings at the 40-span cap, at most 333 ms to select.
40
+ - Tests: `transcript-cleanup.test.ts` (33: end to end with a fake Claude provider, including the Ukoma, Yukoma, eucoma and U coma case with the owner card and a screen line, every stop, undo, the notes lookup, the per-meeting vocabulary and suggestions), `transcript-cleanup-spans.test.ts` (26: selection, fixes, edit guards, prompt), `transcript-cleanup-provider.test.ts` (1: the real runner wiring), `routes/transcript-cleanup.test.ts` (13: auth on every route, screen bounds, settings, suggestions through the real glossary writer, undo, the sweep), `meeting.test.ts` (2 new: runs once after the relabel and before the job completes; a throwing cleanup never fails the save, the default writes nothing), `health.test.ts` (1 new) and `transcribe.test.ts` (capability). `server/scripts/transcript-cleanup-mutation-gate.mjs` (`--check` validates every target): 107 mutants over the switch, model pin, every stop, selection, the fixes, every edit guard, the ledger and undo, the screen store and route, the notes lookup, suggestions and the save wiring, each killed by a named test against a green baseline (115 tests in 7 files). Full suite in isolated TMPDIR, HOME and data dir with COS_SCRIPTS_DIR unset: 7316 passed, 13 skipped, 0 failed; typecheck clean.
41
+
42
+ ## 6.76.2 - Live cues v2: a switch COS Control can flip, the owner's own notes as memory, and SubQ or Jev for the gate
43
+
44
+ Pairs with COS Glasses companion 6.10.644 and COS Control 0.5.322. Includes 6.76.1.
45
+
46
+ - Runtime switch. `GET /api/live-cues/v2/settings` answers `{ enabled, source, capability }`: `source` is `env` (`COS_LIVE_CUES_V2=1` forces v2 on), `setting` (saved) or `default` (off). `POST /api/live-cues/v2/settings {"enabled": true|false}` saves the choice in this server's data dir (`live-cues-v2-settings.json`, atomic and durable) and answers the same shape; turning it off while the env flag forces it on is 409 `{"error": "env_forced"}` and changes nothing; a body without a boolean `enabled` is 400 `invalid_request`. Both are token authenticated like every /api route. Only the exact env value `1` forces; any other value defers to the setting, and an unreadable settings file reads as off.
47
+ - No restart needed. `capabilities.liveCuesV2` on `/api/health` and `/api/models` (and `v2` on the status route) follows the setting at once, and a v2-off server still advertises nothing, so a companion treats it as v1. Turning v2 off aborts every evaluation in flight (traced `cancelled` with `cancelReason: "v2_off"`; no cue is emitted, the lease is released) and no new evaluation starts; a cue that finishes after the switch is checked once more and withheld. Meetings v2 armed keep their v2 stop and status answers after the switch, and a saved meeting still ends them. Turning it on applies from each meeting's next evaluation.
48
+ - Notes as memory. When `cue_memory.py` is missing (any Mac but Miles's), fails or times out, the memory arm now searches the owner's own COS instead of nothing: the Live COS Ask's `searchMemories` and `searchMeetingLibrary` in their keyword-only mode, plus the memory bridge listing (`cos_api_bridge.py memory`, COS Control's Memory list). The bridge matters: the public memory runtime 0.1.0 serves the owner's notes only there (its `bot_memory.py` has no `search` command, so `searchMemories` answers `memory_parse_error` for meaning and reads only markdown memory folders for keywords; measured on a scratch install). A few salient words from the window are the queries, never the window; candidates are fused by rank (k=60, as `cue_memory.py`), dated by their local day, bounded like `cue_memory.py`, at most 4, and go to the same judge and writer rules unchanged. The whole arm is bounded by `cue_memory.py`'s 3 s dense budget. No model call and no query leaves the Mac. `cue_memory.py` stays first whenever it answers, including an empty answer, so Miles's path does not change.
49
+ - Each evaluation traces `cueMemorySource` (`cue_memory`, `notes` or `none`: whose candidates the judge saw), plus `notesMs` and `notesError` when the fallback ran. No note text or query terms are traced.
50
+ - Decision provider. The gate and the memory judge run on Jev, or on SubQ when `COS_DECISION_PROVIDER_CUES=subq` says so or SubQ is the only decision key on this Mac (`COS_DECISION_PROVIDER_CUES=jev` pins Jev; both are forwarded by the managed runtime contract). Cues ignore COS Control's saved decision provider and `COS_DECISION_PROVIDER`, so a Mac whose Work decisions run on SubQ keeps Jev cues. One provider per evaluation. Both take the same questions (three `choice` questions with keyed criteria for the gate, one for the judge), and SubQ validates its answers against them. SubQ for cues has its own daily cap (`COS_LIVE_CUES_SUBQ_DAILY_TOKENS`, default 1.5M, ledger `subq-cues-usage.json`) and its own breaker, which a newly saved SubQ key clears; Jev keeps `COS_LIVE_CUES_JEV_DAILY_TOKENS` and `jev-cues-usage.json`.
51
+ - The 0.8 gate and 0.8 judge thresholds were calibrated on Jev (jev-1.13.0) and have no SubQ replay yet.
52
+ - When SubQ serves cues, the live transcript window (up to 2,200 characters per call) and the candidate notes go to SubQ, which retains data by default.
53
+ - Capability additions (optional fields; `shared/live-cues-v2.ts` changed additively, new sha256 174728e7...): `memory` (`cue_memory`, `notes` or `none`) and `decisionProvider` (`jev` or `subq`). New reasons: `decision_not_configured` (no key for the chosen provider; `jev_not_configured` is kept for Jev chosen while only a SubQ key exists), `decision_cap_reached`, `decision_breaker_open` (SubQ) and `decision_invalid_provider`. The same decision codes stop a running meeting's cues quietly with that reason; other SubQ errors count toward the meeting breaker as Jev errors do.
54
+ - Live COS Ask finds the owner's notes too. When `searchMemories` does not answer (it fails, its meaning arm fails as on the public runtime, or it finds nothing), the Ask's COS search also reads the memory bridge listing and matches it with the same keyword scorer, the same 0.35 floor and the same confirmed-domain filter as other COS hits, fused by rank with any memory hits (at most 3 memory sources). Listed notes are cited as COS memory sources (H ids, `memoryId`), counted in the `cos_search` phase time and result count, and the notices add "COS also searched the notes in its memory list by keyword." whenever one is cited. The listing is bounded at 3 s and the Ask's own cancel; a failed listing changes nothing. A memory search that answers is used exactly as before, the same sources in the same order and key order, and the listing is never read. With nothing found in either, the notices are the existing empty wording, word for word. On the public runtime this lets the 6.76.0 person lookup ("Who is X?") answer from the owner's note. One helper serves both cues and the Ask: `server/lib/cos-bridge-notes.ts` (bridge listing, keyword scoring, local dates, rank fusion).
55
+ - Tests: `live-cues-v2.test.ts` (52, 13 new), `live-cues-v2-notes.test.ts` (11), `live-cues-v2-notes-bridge.test.ts` (2: the real searchers over a scratch bridge, a markdown note and a saved meeting), `live-cues-v2-settings.test.ts` (3), `live-cues-v2-settings-route.test.ts` (10: behind the real token gate, a module reload as the restart, and the real deps with faked network sending the gate to SubQ then Jev), `health.test.ts` (1 new: `/api/health` and `/api/models` flip with the setting), `live-cos-ask-memory-fallback.test.ts` (8), `live-cos-ask-queen-runtime.test.ts` (3: the shipped public memory runtime 0.1.0 installed in a scratch dir with one accepted note; `searchMemories` alone finds nothing, the Ask answers "Who is Delgado?" from the note). `server/scripts/live-cues-v2-notes-mutation-gate.mjs`: 85 mutants over the cue switch, the cue notes fallback, the shared helper, the decision provider selection (global setting ignored, only-key fallback, explicit override both ways) and the Ask fallback (13 Ask guards), each killed by a named test against a green baseline (160 tests in 12 files). Full suite 7240 passed, 13 skipped, 0 failed; typecheck clean.
56
+
1
57
  ## 6.76.1 - Stop a glasses meeting from COS Control
2
58
 
3
59
  Pairs with COS Glasses companion 6.10.644 and COS Control 0.5.321. Includes 6.76.0.
package/README.md CHANGED
@@ -464,7 +464,18 @@ No inline `#` on the EXTRA_ARGS line.),
464
464
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=0` (optional
465
465
  machine-wide rollback for build 204+ server-owned query recovery),
466
466
  `COS_MESSAGES_TRAIL=0` (6.52.0: no Messages trail; every job stream and snapshot is the
467
- 6.51.0 one), `COS_PERMISSION_BROKER` (6.52.0: `0` answers every permission request with
467
+ 6.51.0 one), the 6.78.0 switches (each reads `1`, `true` or `on` as on and `0`, `false` or
468
+ `off` as off; anything else is its default): `COS_CRASH_LOOP_PARKING` (default on: boot-resumed
469
+ work that crashed the server on 3 boots in a row is parked; see `GET /api/maintenance/status`),
470
+ `COS_RESTART_NOTICE` (default on: an Ask a restart cut off says so and names the step it was
471
+ cut off during), `COS_QUEUE_RECONCILE` (default on: queued desk turns a crash cut off are
472
+ settled on evidence, never sent twice), `COS_PIN_BOOT_REAP` (default on: a thread a crashed
473
+ run held frees 31 minutes after the restart, once its child is gone), `COS_QUEUE_RESEND`
474
+ (default on: Send again on a refused or expired queued turn), `COS_MESSAGES_STEER` (default
475
+ off: steer a running Claude Ask from the glasses), `COS_SESSION_STEER` (default off: steer a
476
+ busy Claude desk session through its peer inbox) and `COS_QUERY_JOB_DELTA_JOURNAL` (default
477
+ off until the release after 6.78.0: the compact Ask journal writer),
478
+ `COS_PERMISSION_BROKER` (6.52.0: `0` answers every permission request with
468
479
  the Mac's own dialog, `questions` holds questions but not tool approvals; unset holds
469
480
  both), `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90, never below 30),
470
481
  `COS_PERMISSION_BROKER_TIMEOUT_S` (default 110, clamped to 5 through 120),
@@ -14,7 +14,11 @@
14
14
  "HTTPS_PORT",
15
15
  "BIND_HOST",
16
16
  "COS_DURABLE_QUERY_JOBS",
17
+ "COS_CRASH_LOOP_PARKING",
17
18
  "COS_MESSAGES_TRAIL",
19
+ "COS_RESTART_NOTICE",
20
+ "COS_QUERY_JOB_DELTA_JOURNAL",
21
+ "COS_MESSAGES_STEER",
18
22
  "COS_LIVE_CUES",
19
23
  "COS_LIVE_CUES_MODEL",
20
24
  "COS_LIVE_CUES_GRAPH",
@@ -27,6 +31,14 @@
27
31
  "COS_LIVE_CUES_V2_WEEKLY",
28
32
  "COS_LIVE_CUES_V2_NUMBER_GUARD",
29
33
  "COS_LIVE_CUES_JEV_DAILY_TOKENS",
34
+ "COS_LIVE_CUES_SUBQ_DAILY_TOKENS",
35
+ "COS_DECISION_PROVIDER_CUES",
36
+ "COS_TRANSCRIPT_CLEANUP",
37
+ "COS_TRANSCRIPT_CLEANUP_MODEL",
38
+ "COS_TRANSCRIPT_CLEANUP_DAILY",
39
+ "COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS",
40
+ "COS_TRANSCRIPT_CLEANUP_MAX_SPANS",
41
+ "COS_TRANSCRIPT_SCREEN_RETENTION_DAYS",
30
42
  "COS_BATCH_HQ_METAL",
31
43
  "COS_BATCH_HQ_FORCE_CPU",
32
44
  "COS_BATCH_HQ_TIMESTAMPS",
@@ -52,7 +64,11 @@
52
64
  "COS_LENS_GIST_DAILY_CAP",
53
65
  "COS_DIARIZER",
54
66
  "COS_NEMOTRON_CLI",
55
- "COS_NEMOTRON_MODELS"
67
+ "COS_NEMOTRON_MODELS",
68
+ "COS_QUEUE_RECONCILE",
69
+ "COS_PIN_BOOT_REAP",
70
+ "COS_QUEUE_RESEND",
71
+ "COS_SESSION_STEER"
56
72
  ],
57
73
  "maintenance": {
58
74
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.76.1",
3
+ "version": "6.78.0",
4
4
  "description": "COS Glasses: 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": {
@@ -62,7 +62,16 @@
62
62
  "!server/scripts/merged-scribe-mutation-gate.mjs",
63
63
  "!server/scripts/nemotron-replay.ts",
64
64
  "!server/scripts/turn-request-mutation-gate.mjs",
65
+ "!server/scripts/crash-loop-mutation-gate.mjs",
66
+ "!server/scripts/restart-notice-mutation-gate.mjs",
67
+ "!server/scripts/gate-anchor-check.mjs",
68
+ "!server/scripts/messages-steer-mutation-gate.mjs",
65
69
  "!server/scripts/mutation-gate.mjs",
70
+ "!server/scripts/turn-queue-durable-mutation-gate.mjs",
71
+ "!server/scripts/session-steer-mutation-gate.mjs",
72
+ "!server/scripts/check-gate-anchors.mjs",
73
+ "!server/scripts/transcript-cleanup-mutation-gate.mjs",
74
+ "!server/scripts/live-cues-v2-notes-mutation-gate.mjs",
66
75
  "!server/lib/meeting-engine/__fixtures__/**",
67
76
  "!server/lib/__fixtures__/**",
68
77
  "!shared/**/*.test.ts",
package/server/index.ts CHANGED
@@ -28,7 +28,7 @@ import { cachedHookStatus, isCosSpawnedPid, registryEntrypointSync, claudeDeskRu
28
28
  import { clearingHaltOnSpawn, writeHaltMarker } from './lib/session-halt.js'
29
29
  import { startProviderObserveRefresh } from './lib/provider-observe.js'
30
30
  import { appendSessionCancelLedger, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, setDeskCancelReaders, threadCancel } from './lib/session-cancel.js'
31
- import { makeQueueTurnEvidence } from './lib/queue-turn-evidence.js'
31
+ import { makeDeliveryEvidence, makeQueueTurnEvidence, makeTurnReceiptLookup } from './lib/queue-turn-evidence.js'
32
32
  import {
33
33
  PermissionBroker,
34
34
  brokerSignalSink,
@@ -48,10 +48,10 @@ import {
48
48
 
49
49
  threadAttachEnabled,
50
50
  } from './routes/agent-session-bindings.js'
51
- import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
51
+ import { AgentSessionBindingRegistry, makePinChildProbe } from './lib/agent-session-binding-registry.js'
52
52
  import { targetKey } from './lib/agent-session-binding-store.js'
53
53
  import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
54
- import { buildOccupancyProbes, realOccupancyDirs, withCodexTurnClock, withCursorComposerTurn, withHookTurnClock } from './lib/occupancy-probes.js'
54
+ import { buildOccupancyProbes, processStartMs, realOccupancyDirs, withCodexTurnClock, withCursorComposerTurn, withHookTurnClock } from './lib/occupancy-probes.js'
55
55
  import { readCodexTurnEndedAtMs } from './lib/codex-turn-clock.js'
56
56
  import { codexLiveQueueEnabled } from './lib/codex-live-queue.js'
57
57
  import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
@@ -64,7 +64,9 @@ import { readComposerFacts } from './lib/cursor-session-model.js'
64
64
  import { MAX_PROMPT_CHARS } from './lib/attached-provider-adapter.js'
65
65
  import { readRecentSessionTurns } from './lib/agent-session-turns.js'
66
66
  import { parseAgentSession } from './lib/agent-session-store.js'
67
- import { makeLiveTurnDeliverer } from './lib/session-peer-inbox-deps.js'
67
+ import { makeLiveTurnDeliverer, readPeerInboxRecords } from './lib/session-peer-inbox-deps.js'
68
+ import { selectPeerInbox } from './lib/session-peer-inbox.js'
69
+ import { nativeQueueControl, plainQueueText } from './lib/codex-queue-control.js'
68
70
  import { makeCodexLiveDeliverer } from './lib/codex-live-queue-deps.js'
69
71
  import { forkThread, realForkDeps } from './lib/fork-thread.js'
70
72
  import { ForkJobLedger, fileForkJobPersistence } from './lib/fork-job-ledger.js'
@@ -72,7 +74,8 @@ import { nativeHead, realNativeHeadDeps } from './lib/native-head.js'
72
74
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
73
75
  import { displayRouter } from './routes/display.js'
74
76
  import { transcribeStreamRouter, getActiveTranscriptionSessionCount } from './routes/transcribe-stream.js'
75
- import { meetingRouter, resumeMeetingFinalizationJobs } from './routes/meeting.js'
77
+ import { meetingRouter, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
78
+ import { createTranscriptCleanupRouter } from './routes/transcript-cleanup.js'
76
79
  import { meetingsRouter } from './routes/meetings.js'
77
80
  import { openaiCompatRouter } from './routes/openai-compat.js'
78
81
  import { openaiKeyRouter } from './routes/openai-key.js'
@@ -157,6 +160,7 @@ import { createWorkSearchRouter } from './routes/work-search.js'
157
160
  import { isReadOnlyApiPost } from './lib/work-search.js'
158
161
  import { dataPath } from './lib/data-dir.js'
159
162
  import { loadJsonOrQuarantine, durableAtomicWriteFileSync } from './lib/atomic-fs.js'
163
+ import { BOOT_RESUME_LEDGER_FILE, createBootResumeGate, installBootResumeGate } from './lib/boot-resume.js'
160
164
  import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
161
165
  import { WorkHandoffRequestStore, createOptionalWorkHandoffRequestStore, handoffRequestCapabilities } from './lib/work-handoff-requests.js'
162
166
  import { createWorkBoardReader } from './lib/work-board-reader.js'
@@ -191,16 +195,16 @@ import {
191
195
  } from './lib/maintenance-lifecycle.js'
192
196
 
193
197
  const app = express()
194
- import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
198
+ import { createThreadTurnQueueRouter, drainAllThreads, steerAvailabilityFor } from './routes/thread-turn-queue.js'
195
199
  import { createCursorStopFollowupRouter } from './routes/cursor-stop-followup.js'
196
- import { queuedThreadKeys, readQueue, transcriptTurnVerdict, writeQueue } from './lib/thread-turn-queue-store.js'
200
+ import { queuedThreadKeys, readQueue, readTranscriptTailLines, transcriptTurnVerdict, writeQueue } from './lib/thread-turn-queue-store.js'
197
201
  import { cursorDeskHaltReady, hookHaltReady, readHookToken } from './lib/claude-hooks-installer.js'
198
202
  import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
199
203
  import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
200
204
  import { transcriptPathFor } from './lib/native-head.js'
201
205
  import { deliverQueuedTurnOverLoopback } from './lib/thread-turn-queue-deliver.js'
202
206
  import { readFences, writeFences } from './lib/thread-fence-store.js'
203
- import type { QueuedThreadTurn } from './lib/thread-turn-queue.js'
207
+ import { sessionSteerEnabled, type QueuedThreadTurn } from './lib/thread-turn-queue.js'
204
208
 
205
209
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
206
210
 
@@ -488,7 +492,9 @@ app.use((_req, _res, next) => {
488
492
 
489
493
  // Hydrated once at boot, before any route can read it. Never throws; a corrupt
490
494
  // or unreadable store yields a degraded registry rather than a silent empty one.
491
- const agentSessionBindingRegistry = AgentSessionBindingRegistry.open()
495
+ // 6.78.0 (P7 S6): a crash's orphan pin that names its resume child waits for that pid, with
496
+ // its MEASURED start time, so a reused pid reads as gone.
497
+ const agentSessionBindingRegistry = AgentSessionBindingRegistry.open({ pinChildProbe: makePinChildProbe(processStartMs) })
492
498
  // Boot reap, and it is load-bearing rather than housekeeping. A turn pins its
493
499
  // binding and unpins in a `finally` that never runs if the process dies during the
494
500
  // provider run — a window of up to the 21-minute attached timeout, and exactly what
@@ -506,10 +512,23 @@ try {
506
512
  } catch (error) {
507
513
  console.warn('[binding-registry] boot reap failed', error)
508
514
  }
515
+ // 6.78.0 (COS_PIN_BOOT_REAP): a pin a dead boot left frees its thread 21 minutes after the
516
+ // later of its pin and this restart (the query jobs' orphan fence), not 24 h. Checked on the
517
+ // tick below and lazily on every attach and occupancy check (`attachBlocked`, `create`).
518
+ // Not at boot: nothing can be due until 21 minutes after this registry opened.
519
+ const releaseOrphanPins = (when: string): void => {
520
+ try {
521
+ const orphans = agentSessionBindingRegistry.reapOrphanedPins(Date.now())
522
+ if (orphans.released > 0) console.log(`[binding-registry] ${when}: released ${orphans.released} pin(s) a dead boot left, ${orphans.recorded} recorded as delivery unknown`)
523
+ } catch (error) {
524
+ console.warn('[binding-registry] orphan pin release failed', error)
525
+ }
526
+ }
509
527
  // And periodically, so a strand created mid-run clears without waiting for the next
510
528
  // restart. Unref'd so it never holds the process open during shutdown.
511
529
  const bindingReapTimer = setInterval(() => {
512
530
  try { agentSessionBindingRegistry.reap(Date.now()) } catch { /* next tick retries */ }
531
+ releaseOrphanPins('tick')
513
532
  }, 10 * 60_000)
514
533
  bindingReapTimer.unref()
515
534
 
@@ -864,6 +883,26 @@ const sharedTargetGuard = new TargetGuard(
864
883
  : null,
865
884
  )
866
885
 
886
+ // 6.78.0 (P3): one live-turn transport for the turns route's live hop and Sessions steer,
887
+ // so the health counters see both. And what a steer needs to know about a Claude session:
888
+ // a peer inbox it can be reached through, and whether it is held on a dialog (hooks).
889
+ const liveTurnDeliverer = makeLiveTurnDeliverer(nativeHeadDeps)
890
+ const steerDeps = {
891
+ deliverLiveTurn: liveTurnDeliverer,
892
+ steerProbe: (_provider: string, threadId: string) => ({
893
+ peerInbox: selectPeerInbox(threadId, readPeerInboxRecords(), pid => cosSpawnedPids().has(pid)) !== null,
894
+ waitingOnPrompt: sessionHooksEnabled() ? (signalFor(threadId)?.waiting ?? null) !== null : false,
895
+ }),
896
+ }
897
+ /** The bounded transcript tail the reconcile and the receipts read evidence from. */
898
+ const QUEUE_EVIDENCE_TAIL_BYTES = 4 * 1024 * 1024
899
+ const queueEvidenceLines = (provider: string, threadId: string): readonly string[] | null =>
900
+ provider === 'claude' || provider === 'codex'
901
+ ? readTranscriptTailLines(transcriptPathFor(provider, threadId, nativeHeadDeps), QUEUE_EVIDENCE_TAIL_BYTES)
902
+ : null
903
+ const queueAttempt = (provider: string, threadId: string, clientTurnId: string) =>
904
+ agentSessionBindingRegistry.attemptFor(provider, threadId, clientTurnId)
905
+
867
906
  const queueTurnEvidence = makeQueueTurnEvidence({
868
907
  signalFor: threadId => signalFor(threadId),
869
908
  registryIdleAfterStop: (threadId, stopAt) => registryIdleAfterStop(threadId, stopAt),
@@ -941,6 +980,28 @@ if (threadAttachEnabled()) {
941
980
  }
942
981
  },
943
982
  deliver: (turn: QueuedThreadTurn) => deliverQueuedTurnOverLoopback(turn, PORT, API_TOKEN),
983
+ // 6.78.0 (P3): the restart reconcile, the attempt journal and Sessions steer.
984
+ bootId: serverMetrics.bootId,
985
+ deliveryEvidence: makeDeliveryEvidence({
986
+ ledgerOutcome: (provider, threadId, clientTurnId) => {
987
+ const result = agentSessionBindingRegistry.findTurnForTarget(provider, threadId, clientTurnId)?.result
988
+ return result && typeof result === 'object' && typeof (result as { outcome?: unknown }).outcome === 'string'
989
+ ? (result as { outcome: string }).outcome
990
+ : null
991
+ },
992
+ attempt: queueAttempt,
993
+ transcriptLines: queueEvidenceLines,
994
+ codexQueueTexts: async threadId => (await nativeQueueControl.list(threadId))
995
+ .map(row => plainQueueText(row))
996
+ .filter((text): text is string => typeof text === 'string'),
997
+ }),
998
+ ...steerDeps,
999
+ attempts: {
1000
+ begin: (provider: string, threadId: string, clientTurnId: string, mode: 'turn' | 'steer', now: number) =>
1001
+ agentSessionBindingRegistry.beginAttempt(provider, threadId, clientTurnId, mode, now),
1002
+ settle: (provider: string, threadId: string, clientTurnId: string, exit: 'sent' | 'not_sent' | 'unknown', now: number) =>
1003
+ agentSessionBindingRegistry.settleAttempt(provider, threadId, clientTurnId, exit, now),
1004
+ },
944
1005
  // 6.53.0: parked turns hold for two minutes after a cancel on their thread.
945
1006
  cancelHoldUntil: (provider: string, threadId: string) => cancelHoldUntilFor(provider, threadId),
946
1007
  // 6.62.0 (W14): a busy Agent CLI chat's parked turn drains here (no Stop hook fires for `-p`).
@@ -1020,7 +1081,7 @@ app.use('/api', createAgentSessionBindingsRouter({
1020
1081
  // 6.49.0: the running session first (Desktop tab or iTerm, same registry record),
1021
1082
  // the resume child second. Reads `COS_CONTINUE_LIVE` per call, so the flag reports
1022
1083
  // on /api/health without a restart and every refusal falls back to the 6.48.2 path.
1023
- deliverLiveTurn: makeLiveTurnDeliverer(nativeHeadDeps),
1084
+ deliverLiveTurn: liveTurnDeliverer,
1024
1085
  // 6.51.0: a Codex thread the Codex app holds goes into that app's own queue
1025
1086
  // (`codex queue`), never into a resume child that cannot open a held thread.
1026
1087
  // `COS_CODEX_LIVE_QUEUE=0` restores the 6.50 path.
@@ -1066,6 +1127,14 @@ app.use('/api', createAgentSessionBindingsRouter({
1066
1127
  // empty list — "nothing is bound" and "the store could not be read" must not
1067
1128
  // look the same.
1068
1129
  bindings: agentSessionBindingRegistry,
1130
+ // 6.78.0 (P3): receipts before the claim, and the `steer` field on an attach refusal.
1131
+ turnReceipt: makeTurnReceiptLookup({
1132
+ queueRows: (provider, threadId) => readQueue(provider, threadId, Date.now()),
1133
+ attempt: queueAttempt,
1134
+ transcriptLines: queueEvidenceLines,
1135
+ bootId: serverMetrics.bootId,
1136
+ }),
1137
+ sessionSteer: (provider, threadId, gateReason) => (sessionSteerEnabled() ? steerAvailabilityFor(steerDeps, provider, threadId, gateReason) : null),
1069
1138
  // 6.53.0: cancel a session run from the lens. The route's own in-flight map answers for
1070
1139
  // a COS turn; these answer for a run at the desk. Read per request, so Install hooks in
1071
1140
  // Control flips `hooks_outdated` to `desk_run` within the status cache's five seconds.
@@ -1086,6 +1155,8 @@ app.use('/api', createAgentSessionBindingsRouter({
1086
1155
  app.use('/api', displayRouter)
1087
1156
  app.use('/api', transcribeStreamRouter)
1088
1157
  app.use('/api', meetingRouter)
1158
+ // 6.77.0: transcript cleanup settings, screen context, the cleanup ledger and dictionary suggestions.
1159
+ app.use('/api', createTranscriptCleanupRouter(transcriptCleanupRouteDeps()))
1089
1160
  app.use('/api', meetingsRouter)
1090
1161
  app.use('/api', memoryRouter)
1091
1162
  app.use('/api', threadsRouter)
@@ -1148,6 +1219,15 @@ app.get('/', (_req, res) => {
1148
1219
  )
1149
1220
  })
1150
1221
 
1222
+ // 6.78.0: crash-loop parking (lib/boot-resume.ts). One gate per process. It opens its boot
1223
+ // in startAdmittedRuntime (after the listeners bind, so exits 74 and 75 never count), before
1224
+ // any boot resume, and gracefulShutdown marks a clean exit first thing, so only boots that
1225
+ // follow a crash count. A Retry of a parked meeting resumes that one job.
1226
+ // COS_CRASH_LOOP_PARKING=0 turns it off (default on).
1227
+ const bootResume = installBootResumeGate(createBootResumeGate({ path: dataPath(BOOT_RESUME_LEDGER_FILE) }), {
1228
+ resume: { meeting_finalization: sessionId => resumeMeetingFinalizationJobs({ sessionId }) },
1229
+ })
1230
+
1151
1231
  // Graceful shutdown persists an interrupted terminal before provider abort.
1152
1232
  // The bounded force-exit keeps service managers from hanging forever on a
1153
1233
  // broken disk while retaining the previous session/catalog/Whisper cleanup.
@@ -1155,6 +1235,10 @@ let gracefulShutdownStarted = false
1155
1235
  async function gracefulShutdown(): Promise<void> {
1156
1236
  if (gracefulShutdownStarted) return
1157
1237
  gracefulShutdownStarted = true
1238
+ // 6.78.0: first, before the 8 s force exit can fire. A SIGTERM/SIGINT shutdown (Update
1239
+ // Server, kickstart -k, bootout) is not a crash loop even if a later step hangs into exit(1),
1240
+ // so the next boot does not count toward crash-loop parking.
1241
+ bootResume.markCleanShutdown()
1158
1242
  const forceExit = setTimeout(() => process.exit(1), 8_000)
1159
1243
  forceExit.unref?.()
1160
1244
  await meetingAskRuntime.shutdown()
@@ -1370,9 +1454,13 @@ listenRequiredServers(listeners).then(() => {
1370
1454
  if (admittedRuntimeStarted) return
1371
1455
  admittedRuntimeStarted = true
1372
1456
 
1457
+ // 6.78.0: before any boot resume. Was the last exit clean? Only boots after a crash count.
1458
+ bootResume.openBoot()
1373
1459
  // Resume already-saved meetings whose post-response HQ/operations work
1374
1460
  // was interrupted by a prior server update or process exit.
1375
1461
  resumeMeetingFinalizationJobs()
1462
+ // 6.77.0: transcript cleanup data whose meeting is gone, and screen text past retention.
1463
+ scheduleTranscriptCleanupSweep()
1376
1464
 
1377
1465
  // Debris from an import killed mid-write: a temp file, or a sidecar whose
1378
1466
  // markdown never landed. Both are invisible to every reader, so nothing