@gotcos/glasses-server 6.59.0 → 6.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -159,6 +159,14 @@ BIND_HOST=0.0.0.0
159
159
  # changing committed transcription or meeting audio.
160
160
  # COS_WHISPER_PREVIEW_MODEL=auto # auto | small.en | turbo | off
161
161
 
162
+ # Who spoke when (6.61.0). Nemotron 3 Diarization separates the voices in each
163
+ # chunk and at save; the voiceprint names them. It needs FluidAudio's
164
+ # fluidaudiocli and its models ON THIS MAC: the server always passes --models
165
+ # and never lets the CLI download. Missing either, labels stay on the voiceprint.
166
+ # COS_DIARIZER=nemotron # nemotron | embedding (voiceprint only, the 6.60 labels)
167
+ # COS_NEMOTRON_CLI=~/.cos-glasses/bin/fluidaudiocli
168
+ # COS_NEMOTRON_MODELS=~/.cos-glasses/models/nemotron-3-diarization # holds Nemotron3Diarizer_fast32.mlmodelc + learnable_sil_emb.bin
169
+
162
170
  # Spoken reply playback defaults to local Kokoro on Apple silicon Macs. The
163
171
  # first run creates a private venv and downloads the model. Local mode fails
164
172
  # closed: it never sends text to OpenAI. Set openai_primary only when cloud TTS
package/CHANGELOG.md CHANGED
@@ -1,3 +1,69 @@
1
+ ## 6.61.0
2
+
3
+ ### Nemotron is the default diarizer, for real: it splits two-voice chunks on the HUD during the call and in the final transcript, and the voiceprint names each voice
4
+
5
+ - Miles, 2026-10-02 07:10: "I want nemotron to become the default as it's more accurate. We should see it labeling in the HUD (During the call) and in the final transcript." This folds in the unpublished 6.60.1, which made `COS_DIARIZER` default to `nemotron` but never ran Nemotron: every live chunk was still labelled by the whole-chunk voiceprint, and its `[diarizer] tracks=nemotron` log line was false. That line is gone; every `[diarizer]` line now says what ran.
6
+ - **What it does, and what it does not claim.** Nemotron separates the voices inside a chunk and across a meeting; it names nobody. The voiceprint still decides every name, with its thresholds unchanged. What changes is that a chunk holding two voices is no longer given one label, and a stretch the voiceprint could not name can take the name of the voice Nemotron heard there. This release does not claim the labels are more accurate: the replay below measures how rarely Nemotron disagrees with labels the voiceprint was already sure of, which is stability, not accuracy (see the disclosure there).
7
+ - The evidence it builds on is the read-only canary of 2026-10-01 (`operations/personal/wk40_2026/nemotron3_canary/`): FluidAudio v0.17.4, preset `fast32`, about 155x realtime warm; on chunks the voiceprint is sure of, Nemotron's dominant channel maps back to the same name in 29 of 29 conclusive meetings (mean 0.959); 45.6% of chunks hold two voices the voiceprint labels as one. The canary's alignment rules are reused as written: chunkEntries joined to `chunk_NNNN.wav` by chunkIndex with `audioSha256` checked, each WAV at its real `elapsed` with only the overlapping tail trimmed, the raw 10 ms probabilities scored at 0.5, 8 channels.
8
+ - **Live, on the HUD.** Each chunk runs `fluidaudiocli nemotron3-diarize` on the chunk WAV the server has just written, started before the voiceprint and Whisper so it runs in their shadow, on the Neural Engine (`--compute-units ane`, so the GPU stays Whisper's; its probabilities matched `all` frame for frame at 0.5 on real chunks) at a lower CPU priority. A channel active for at least 0.5 s is a track. Whisper's word clocks place the words on the tracks when the ASR gives them (the iPhone path); the live whisper-server path returns compact JSON without clocks (its verbose JSON can crash the native server), so there the text is laid over the frames where Nemotron hears speech in proportion to word length, a turn change moves to the nearest phrase end within two words, and a one-word flicker inside another voice's turn is absorbed. The voiceprint names each track: one track takes the chunk's voiceprint; with two or more, a track with at least 2 s of its own audio (the same floor as a whole chunk) is named by `identifySpeaker` on that audio, its calibration rows tagged `nemotron-track`; a shorter one takes the chunk's voiceprint when it is the dominant voice, otherwise the client's label when that is a real name different from the dominant one, otherwise `Ext`. A name is never invented.
9
+ - **The chunk response (the contract with COS Glasses 6.9.567).** `speaker` stays and is now the dominant voice's name (by voiced time among the tracks that carry the words; two channels can carry one person, and a louder voice with no words in the chunk is not who said it), `text` is unchanged, and an optional `segments: [{ speaker, text, startSec, endSec, similarity }]` lists the turns in time order; their texts joined with one space equal `text`. A replayed upload of a canonical chunk returns the same segments, and the `transcript_chunk` display event carries them. The segments are stored on the chunk, with `diarizer: { engine, tracks, timing, ms }` and, when the dominant name differs from the whole-chunk voiceprint, `voiceprintSpeaker` and `voiceprintSimilarity`. The saved sidecar's `speakers` counts segment speakers too. The whole-chunk voiceprint still runs first and still banks the chunk embedding, auto-enrols and saves training and Ext audio exactly as before. When boot recovery strips a hallucination from a stored chunk, its turns are refitted to the new text, or dropped when the words no longer line up, so they always join to the text.
10
+ - **The fallback-experience rule.** Live never stalls or loses a chunk; every fallback keeps today's voiceprint label and gives no `segments`.
11
+ - The model is warmed in the background at server start (a silent second; the first Neural Engine compile took 15 to 35 s); until then chunks are `warming`. A warm-up tries three times (5 s and 30 s apart); a temp directory it cannot make is a failed attempt with a reason, never a warm-up stuck on `warming`.
12
+ - Each run has a 1.5 s budget and is killed with SIGKILL at the deadline. At most two run at once; a chunk that finds both busy is `busy`, never queued.
13
+ - **The breaker.** Three failed runs in a row (budget kills or crashes) stop spawning: every chunk takes the voiceprint at once, with no 1.5 s wait, and the server logs `[diarizer] breaker open: ...`. After a rest of 2 minutes one fresh warm-up decides; each further trip doubles the rest (2, 4, 8, 16, 32, then 60 minutes at most), and a clean chunk resets it. A warm-up that gives up rests on the same schedule; a missing CLI or missing models are re-checked every 2 minutes without spawning anything.
14
+ - A missing CLI or missing models (any install without FluidAudio) is logged once at warm-up, and chunks carry no per-chunk `diarizer` stamp. Run failures (timeout, crash, unreadable output, busy) are logged per chunk with their reason; state reasons (`warming`, `cooling`, `warmup_failed`) are counted per chunk and logged once per change of state.
15
+ - **The final transcript.** When a meeting is saved, the finalization job runs one Nemotron pass over the whole meeting after the HQ batch is persisted, while the raw chunk audio still exists, and before the job reaches the ops handoff. One run over the timeline keeps the channels consistent for the whole call. A channel takes a name only from chunks the voiceprint was sure of (similarity at least 0.65, the review panel's own floor, a real name, one-sided by Even Hub, or by Nemotron itself when a chunk has no Even Hub histogram), with at least 3 such chunks and 80% of them agreeing; several channels may map to one person, and a channel two people share stays unmapped. Following the canary's retro plan, a room with more than four identities or all eight channels in use (a channel counts with half a second of speech, not a blip) gets no meeting-wide map. Every word then takes its channel's name, or keeps its per-chunk name (the live Nemotron turn it sat in, else its existing label). The timeline is built asynchronously in steps, so a long meeting does not freeze live uploads; a timeline far longer than the audio it holds (more than 1.5 times, plus 10 minutes) is a bad clock and the pass is skipped as `too_long` before anything is allocated.
16
+ - **Only labels change.** The saved transcript is relabelled in place, word by word, against the relabelled speaker words (a 12-word window, then a three-word anchor past anything cleaning removed). A line splits where its speaker changes and is never merged with its neighbour, so a pass that changes no label reproduces the transcript byte for byte, writes nothing and moves no revision (`no_change`). Nothing is written unless the result has exactly the same words in the same order. Each changed batch word keeps `originalSpeaker`, a changed streaming chunk keeps `originalSpeaker` and gains `finalSegments`, and the sidecar keeps the whole pre-relabel transcript under `diarization.originalTranscript` with the channel map and its evidence. If the markdown write fails the sidecar is put back byte for byte; if even that fails the pass reports `sidecar_restore_failed` and says so in the log.
17
+ - **A person's edit always wins.** A meeting with a human correction (`correctionRevision` above 0) is never relabelled. The pass re-reads both files just before writing; if a relabel, a deattribution or a summary landed during its CLI run, it stands down (`changed_during_pass`) and writes nothing. Relabel and deattribute answer 409 `meeting_finalizing` (retryable) while a finalization job exists for the meeting, as held naming already did. A second pass over an applied meeting is a no-op. If the pass fails in any way the meeting stays exactly as saved, and the job goes on.
18
+ - **Voice profiles stay safe.** Since a chunk's label can now be its dominant turn's name, naming a voice no longer enrols a chunk whose own whole-chunk voiceprint named a third person, or a chunk Nemotron split into more than one turn; the response says how many it refused (`otherVoiceSkipped`, present only when it happened). A wrong existing label is still how a new person first appears (the Milo case).
19
+ - **When COS sync sees it.** The relabel completes before the meeting is visible as final: the job is still in `batch_pending` when it runs, and `sync_meetings.py --g2-only --g2-import-file` runs from the ops handoff only after it returns. Sync reads speakers from the markdown transcript and, for a batch meeting, from `batchSegments[].speakerWords`, both already relabelled; `batchTranscript` is kept equal to the canonical text. No sync change is needed. With early sync on, the claim made at save is marked HQ pending and the final import replaces its transcript, as it already did for HQ.
20
+ - **Offline by construction.** The server always passes `--models <dir>`; that path in FluidAudio has no network call, while the CLI run without it downloads from Hugging Face and deletes a cache whose weights marker it does not recognise. The directory is `COS_NEMOTRON_MODELS`, else `~/.cos-glasses/models/nemotron-3-diarization`, which the warm-up links to FluidAudio's own cache (`~/Library/Application Support/FluidAudio/Models/nemotron-3-diarization`, bundle `monolithic/v2`). An incomplete explicit directory is never replaced by another copy. With no complete local copy the CLI is never run. Probed 2026-10-02 under `sandbox-exec` with all network denied: the CLI ran from the linked directory and from the cache. The CLI has no version command, so the warm-up logs its sha256 once and whether it is the verified v0.17.4 build (`readiness.diarizer.cliKnown`).
21
+ - **The CLI gets nothing it does not need.** It runs with PATH, HOME, TMPDIR, USER, LOGNAME and the locale only: no API token, provider key or OpenAI key reaches it (verified warm with only those on 2026-10-02).
22
+ - **Nothing outlives the server.** Runs and temp directories in flight are tracked. `gracefulShutdown` and the process exit hook kill every CLI run and delete every temp directory (the final pass's holds the whole meeting's audio). The next start sweeps `cos-nemotron-*` directories older than a minute and kills CLI runs of ours that were orphaned.
23
+ - **Honest health, codes only.** `/api/health` is public (no token) and the server listens on the LAN, so `readiness.diarizer` carries codes and counts only: never a speaker name, a path, a meeting title or raw error text (QA blocker; the channel map and raw errors now stay in `server.log`). It reports `requested`; `active`, which is `nemotron` only while the model is warm, the breaker is closed and most of the last five chunks really were labelled by Nemotron (otherwise `warming` or `embedding`), with the `fallback` reason; `recent`; `breaker` (open, trips, next retry); the preset, budget and lane; the warm-up; `cliKnown`; the per-chunk outcome counts by reason and the last chunk; and the final passes as `{ status, reasonCode, mode, crowded, channels, mappedChannels, wordsRelabeled, wordsTotal, excludedChunks, ms, at }` with counts keyed by status and code from a fixed set. A Nemotron fallback never degrades `readiness.status`. Every final pass logs what it applied or why it left the meeting alone, with any chunks left off the timeline (missing, hash mismatch, unreadable); a pass the save path declines because the batch sidecar was not written is counted and logged too (`metadata_not_persisted`).
24
+ - **Settings.** `COS_DIARIZER` (default `nemotron`; `embedding`, `eres2net` or `voiceprint` turns Nemotron off entirely: no warm-up, no run, the 6.60 labels), `COS_NEMOTRON_CLI` (default `~/.cos-glasses/bin/fluidaudiocli`) and `COS_NEMOTRON_MODELS`, all in `managed-runtime-contract.json` and `.env.example`. To turn it off, put `COS_DIARIZER=embedding` in `~/.cos-glasses/.env`.
25
+ - **After updating.** No Install hooks and no COS Control change. The first start compiles the model for the Neural Engine in the background (about 30 s on the M3 Ultra); chunks use the voiceprint until `readiness.diarizer.active` reads `nemotron`. No LLM is called anywhere in transcription.
26
+ - **Real-data replay (read-only, 2026-10-02).** Clones of three meetings from the canary list, `COS_DATA_DIR` pointed at a scratch copy so not even the calibration log touched the store, the live path chunk by chunk exactly as the route runs it (Nemotron started first, the whole-chunk voiceprint, Whisper large-v3 through a private whisper-server with the live server's arguments, then the track names), then the final pass on a sidecar carrying those live turns. The live replay ran on `dacd8be`-era code, with the mutation gate alongside part of it, so the times are a busy-Mac upper bound.
27
+ - **Disclosure: what the agreement numbers measure.** They measure how rarely Nemotron disagrees with a label the voiceprint was already sure of, not whether a label is right. The live path gives a one-track chunk the stored voiceprint label by construction, so its agreement is high by design on those chunks; the final pass maps channels from the very chunks it is then scored on. Nobody listened to the changed turns. The independent check available here is the last number in each final line: final word labels against the per-track voiceprint names of the live turns.
28
+ - Silas and Miles, Thu 10:01 (505 chunks with text): Nemotron labelled 502 (3 `no_speech`); 114 chunks (22.7%) gained a second speaker, 1.31 segments per chunk; on 270 high-confidence chunks the dominant name matched the stored label 270 times. Final: channels {0: Silas, 1: MU}, 2,201 of 8,484 words relabelled (988 Ext to Silas, 455 Ext to MU, 609 turn splits); majority agreement 1.000 on 132 one-sided and 241 high-confidence chunks; final words against the voiceprint-named live tracks 0.983 (4,589 words).
29
+ - Ryan and Miles, Thu 16:03 (853): 852 labelled (1 `no_speech`); 289 (33.9%) gained a second speaker, 1.49 segments per chunk; dominant agreement 0.995 on 659. Final: {0: Ryan, 1: MU, 2: Ryan}, 4,432 of 19,140 words relabelled; majority agreement 0.993 one-sided (274), 0.974 high-confidence (650); against live tracks 0.974 (12,969 words).
30
+ - Five people, Wed 07:31 (753): 747 labelled (6 `no_speech`); 175 (23.4%) gained a second speaker, 1.32 per chunk; dominant agreement 0.995 on 365. Final: crowded (5 identities), so no meeting-wide map; the live turns relabelled 2,977 of 12,226 words; majority agreement 0.995 one-sided (183), 0.980 high-confidence (346); against live tracks 0.999.
31
+ - Latency with Whisper load, all 2,111 chunks: Nemotron's own run p50 140 to 141 ms, p95 149 to 157 ms, max 196, 302 and 680 ms; what the chunk waited beyond Whisper (the end of Nemotron plus naming the tracks) p50 0 ms, p95 89 to 114 ms, max 129, 142 and 213 ms; no chunk timed out or found the lane busy. The final pass took 19 s, 30 s and 28 s for 52, 86 and 80 minutes of audio.
32
+ - The final pass re-run on the QA-fixed code, on the stored sidecars without live turns: 10:01 applied ({0: Silas, 1: MU}, 1,935 of 8,484 words, one-sided 1.000, high-confidence 0.996); 16:03 applied ({0: Ryan, 1: MU, 2: Ryan}, 4,234 of 19,140, 0.993 and 0.974); 07:31 crowded with no live turns to apply, so `no_change`: nothing written.
33
+ - The rule that the chunk `speaker` is the voice carrying the words came after the replay; it changes none of the 2,101 replayed chunks' names (every replayed `speaker` carried words).
34
+ - The replay found a real bug before release: the first final pass regenerated the transcript from the relabelled words and cleaned it again, which changed which URL-only lines cleaning dropped, so the word check refused every batch meeting. Fixed by relabelling in place.
35
+ - **QA round 1** (the Skeptic and the Ghost Hunter, on `dacd8be`). Fixed: the public health leak (blocker); the final pass overwriting a correction or summary made during its run; rename enrolment able to take a chunk the voiceprint gave to someone else; the CLI inheriting the server's tokens; health reading `nemotron` while every chunk fell back; no breaker on repeated budget kills; `no_change` that almost never fired because lines were merged; no cap on a timeline stretched by a bad `elapsed`; temp audio and CLI runs left behind by an exit; a warm-up that could stick on `warming`; recovery leaving turns that no longer joined to the text; untestable guards (a seam for the writer and the relabeller now makes the word check reachable); missing telemetry; constants defined twice. 28 of the new regression tests fail on `dacd8be` and pass here.
36
+ - Tests: the backend choice and the models directory (offline, linking, an incomplete override); the CLI run (the arguments, the minimal environment, the frame check both ways, exit codes, SIGKILL at the budget, a missing binary, children and temp dirs killed and removed, the startup sweep); word-to-track mapping with clocks and estimated, phrase snapping, flickers, tracks, exclusive audio; naming (one track, two long tracks, a short dominant and a short other with the client label, a repeated or unknown client label, an unnamed track, word clocks over estimates, the voice that carries the words, punctuation-only tokens and merged turns joining to the text exactly, turns refitted after recovery); the runtime (a cold model, busy under a 300-chunk backlog, the breaker and its backoff, the real defaults pinned, health while chunks fall back, a warm-up whose temp dir cannot be made, the warm-up failure and identity lines, outcomes counted and logged, a missing CLI never stamped); the route (segments in the response and the HUD event with every legacy field intact, stored on the chunk, replayed, a fallback answering like 6.60, the voiceprint by choice leaving the record as 6.60 wrote it; recovery refitting turns); the final pass (consistent tracks with original labels and line breaks kept, a crashed or timed-out CLI changing no byte, words cleaning changed or removed kept exactly with labels in step, a relabel or summary during the run surviving, `no_change` writing nothing, `too_long`, a failed markdown write restoring the sidecar and a failed restore reported, the word check reached through the seam, the log lines, the timeout formula pinned, blips not crowding a room, an exit mid-pass leaving no temp audio, a human correction, the voiceprint by choice, a streaming meeting, a crowded room, the timeline's hash check, trim and refusal of a non-regular file, the map's floors, a declined pass counted); the save path (the pass runs on the saved files while the audio exists and before the job leaves `batch_pending`; a failed or throwing pass leaves the meeting byte-identical; relabel and deattribute refused while finalizing and taken after); rename enrolment refusing another voice or a two-voice chunk; unauthenticated `/api/health` after a final pass carrying no name, path or raw error; `readiness.diarizer`; the runtime contract keys. The suite runs with `COS_DIARIZER=embedding` so no test can spawn the real CLI.
37
+ - `node server/scripts/nemotron-mutation-gate.mjs` (repository only, not in the package) runs from a green baseline, matches each target exactly once, and each mutant must fail the test named for it: baseline 160 tests in 11 files, 96 of 96 killed. In QA round 1 it caught a mutant that dropped unmatched words and hid behind a line the pass left whole; the test now inserts a word into a line the pass splits. `server/scripts/nemotron-replay.ts` is the replay above (repository only).
38
+
39
+ ## 6.60.0
40
+
41
+ ### A question that starts while you are away waits up to 10 minutes for the glasses you are wearing
42
+
43
+ - 2026-09-30: a Claude question reached the glasses at 07:28:34 with 109 s left; the ring double tap did not work (a glasses bug, fixed in 6.9.563), so Miles answered on the phone at 07:29:30. More generally a question that starts while he is away was answerable from the glasses only for the broker's 110 s, and when no glasses or phone was polling at that moment it went straight to the Mac's dialog (`no_client`) and the glasses never saw it. His words: questions that begin while the user is away should have a second option to be reclaimed from the glasses if possible; else the user does not know the Mac is waiting on answers. He approved this at 18:40 CDT ("Yes, up to 10 min"), set the rule at 19:54 ("Hold only if the glasses or phone were connected in the last 30 min. A Claude Remote Control session with no glasses gets its question right away, as before."), and approved the QA round 1 fix pass at 21:04.
44
+ - **The away hold.** While the desk is idle past the existing threshold (`COS_PERMISSION_BROKER_DESK_IDLE_S`, default 90 s, the one the gate already uses), a question or approval is held for up to 10 minutes from when its hook started, but only when all of these hold: the hold is on; the installed hook waits longer than 6.59.0 could ever hold (after Install hooks); and the glasses or phone that show held questions reported you present within 30 minutes. Held that way, it stays answerable while nothing is polling, so glasses or a phone that come back can still answer it.
45
+ - **The contract shared with COS Glasses 6.9.563.** The questions poll becomes `GET /api/session-questions?client=glasses|phone&hold=1&presenceAgeMs=<int>`. `hold=1` says this client can show held questions (6.9.563 and later). `presenceAgeMs` is the ms since the latest real wearer evidence (glasses: a ring or temple input, or a device status with wearing true; phone: a foreground touch), a whole number from 0 to 24 h, left out when there is none since the app started. Only a poll with `hold=1` and a valid `presenceAgeMs` counts toward the hold. The server keeps the newest such presence in memory, forward-only, on the monotonic and the wall clock, and reads its age as the larger of the two, so a Mac that slept never makes it look newer. Recent means within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (default 1800, clamped to 0 through 86400; under 30 it means the 30 s live window). After a restart nothing is recent until such a poll.
46
+ - **Older clients keep 6.59.0.** A poll without `hold=1` (6.9.538, 6.9.561, 6.9.562, any phone build before 6.9.563) keeps the 6.59.0 liveness exactly (live for 30 s, a 110 s hold, handed back when it goes quiet) and never enables the away hold, even with `presenceAgeMs`. A `hold=1` poll without presence (a connected lens nobody wears, QA round 1 Skeptic W1) makes no client live and holds nothing: `{}` at once. A `hold=1` client counts as live only while its wearer's presence is recent.
47
+ - **Re-checked every desk poll (Skeptic W2).** Once no client is live and the wearer's latest presence is older than the window, a held question goes to the Mac's dialog (`no_client`) within 1.5 s, so one glance never holds a question 10 minutes. Touching the Mac still hands it back within 1.5 s, and a request that arrives while you are at the desk still goes straight to the dialog.
48
+ - **Before Install hooks the away hold is off (User W6).** While the hook waits no longer than 6.59.0 could hold (its 120 s clamp), away semantics are off entirely: no presence hold, quiet clients hand back, 110 s. One 6.60.0 rule still applies: a `hold=1` client (6.9.563 and later) counts as live only while its wearer's presence is 30 minutes old or less (next item).
49
+ - **A lens nobody has touched for 30 minutes gets no question card (QA round 2, W-A).** With 6.9.563 or later, the glasses count as live only while a ring or temple input, a worn report or a phone touch is 30 minutes old or less. That holds in every setting: before Install hooks, and with the away hold off. The wearing flag reads false almost all the time, so in a long meeting or a drive with no ring input a question goes straight to the Mac's dialog (`no_client`) instead of the lens. On 6.59.0 the same lens showed it for 110 s first. This is the QA round 1 Skeptic W1 decision: a connected lens nobody wears no longer delays the Mac's dialog.
50
+ - `COS_PERMISSION_BROKER_AWAY_HOLD_S` sets the hold: default 600, clamped to 30 through 600, never shorter than the base 110 s; `0`, `off` or `false` turns the away hold off (the 6.59.0 broker, except that a `hold=1` lens still needs recent presence to count as live). Junk is the default. Both settings are in `managed-runtime-contract.json`; COS Control's environment allowlist needs them in a Control build, until then set them in `~/.cos-glasses/.env`.
51
+ - **What it changes for a session driven from elsewhere.** While the desk is idle and you wore the glasses or touched the phone (with 6.9.563 or later) in the last 30 minutes, a permission prompt from a session you drive from another surface (Claude Remote Control, say) waits up to 10 minutes for the glasses before the Mac's dialog shows, unless you answer it from the glasses or phone, leave it for the Mac, touch the Mac, or your presence lapses. With no recent presence it shows at once, as before. `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` narrows the window; `COS_PERMISSION_BROKER_AWAY_HOLD_S=0` turns the hold off.
52
+ - **Never past the hook's own wait.** The hold is clamped by the hook that is actually installed: the PermissionRequest `timeout` in `~/.claude/settings.json` (read through the 5 s hook-status cache) and the wait the hook stamped on this request, the lesser, minus 10 s (5 s for the script's curl to give up before Claude Code stops the hook, 5 s more so the server's `{}` lands first: the 130, 125, 120 chain every release since 6.52.0 kept). If the settings file cannot be read, the stamp alone bounds it (Skeptic N4). A request with no stamp (the earlier script, or an earlier hook block) waited 130 s with a 125 s curl, so it is never held past 120 s.
53
+ - **The hook.** `--hooks install` writes the PermissionRequest block with `timeout: 630` and passes 630 to the script as its second argument (an argument, never an environment variable, so it can only come from that block). The script gives up its curl 5 s before that wait (it was a fixed 125 s) and stamps it on the request as `hookWaitS`; an invocation with no usable argument keeps the earlier 130 and 125 and sends no stamp. Only a whole number from 20 to 3,600 with no leading zero counts. The curl now runs as a tracked child, and the script stops it when the script itself is stopped (TERM, INT, HUP), so the server sees the hook leave (`hook_gone`) at once and never accepts an answer the session can no longer receive (Skeptic W7).
54
+ - **After updating.** Neither Update Server (`cos-control-helper update`) nor a server boot rewrites the Claude settings file or replaces an installed hook script, so until Install hooks runs once the broker is 6.59.0. `--hooks status` and `/api/session-hooks/status` read `drift` with `priorWaitOnly: true` and `permissionHookTimeoutS: 130`; COS Control shows its "Reinstall hooks" banner. Cancel from the lens keeps working meanwhile: `hookHaltReady` accepts that one drift on this package's script or a halt-capable earlier one, and the script 6.53.3 through 6.59.0 shipped (`df54677f...`) joins the halt-capable list, pinned against its bytes. Any other drift is still not halt-ready. Canary C below found that a running Claude session picked up a changed hook timeout.
55
+ - **Rolling back to 6.59.0 (Completionist W5, Skeptic N7).** After Install hooks, 6.59.0 reads the 630 block as `drift` and is not halt-ready, so cancel from the lens stays off for desk runs until Install hooks is run again under 6.59.0 (it writes the 130 block back). Questions stay safe meanwhile: the 6.60.0 hook script under a 6.59.0 server stamps a wait the old server ignores, and 6.59.0 holds at most 120 s, well inside the 630 s hook.
56
+ - **What the clients see.** Additive, every earlier field and meaning unchanged. Each pending item gains `answerable` (true while listed), `awayHold` and `waitingAtMac` (false while listed); `deadlineAt` is the latest it stays answerable, sooner if the wearer's presence lapses with no client polling. `GET /api/session-questions` gains `awayHoldMs` (how long a request that starts now is held at most with recent presence; 0 when off or before Install hooks), `awayRecentClientMs` (the window; 0 when off), `settled` (recently settled items, newest first, codes and times only: every one the Mac still waits on, and the newest others up to 32), `waitingAtMac` (how many), `serverStartedAt` (this process's start) and `waitingAtMacForgottenBefore`. `/api/models` `capabilities.sessionQuestions.awayHoldMs` and `awayRecentClientMs` carry the same numbers. `/api/health` `sessionHooks` gains `permissionHookTimeoutS` and `priorWaitOnly`.
57
+ - **Waiting at the Mac.** An item is `waitingAtMac` when the server wrote `{}` to its listening hook (back at the desk, expired, no client, drained), so the Mac's own dialog is up, and that request has not been finished since: its own tool has not finished or been denied (matched by its own fingerprint only, never by a denial of another dialog of the same tool, Skeptic N3), its turn has not ended, and its session row does not read ended (cleared at once, Skeptic N2). A `{}` that could not be written, an answer, a hook that left, or a retraction is never waiting. No hook marks the moment a dialog is approved, so an approved tool that is still running reads as waiting: the safe direction, never "not waiting" while a dialog may be up. Kept an hour, or up to a day while the session's own row still waits on that very request.
58
+ - **Forgot vs moved past (Ghost Hunter W1).** `waitingAtMacForgottenBefore` starts at the server's start and moves only when the server drops an item the Mac may still wait on. An entry a client saw waiting that is missing from `settled` moved past if it settled after that time; at or before it, or from a server with another `serverStartedAt`, the server forgot it and the Mac may still be waiting.
59
+ - **Telemetry (Completionist N9, Ghost Hunter W5).** Every request answered `{}` logs one line: `fast path <reason>`, and for `no_client` and `desk_active` `not_away=hold_off|ceiling_at_base|no_hold_client|stale_presence|desk_active`, with `presenceAgeMs`, `windowMs` and `clientLive`. Each held request logs once the numbers its deadline came from: `hold={"installedHookTimeoutS","hookWaitS","awayHoldS","ceilingS","effectiveDeadlineS","deskIdleMs","clientLive","presenceAgeMs","recentWindowS","notAwayReason"}`. Never text. The broker's authenticated counters gain `awayHeld` and `parkedWithoutClient`.
60
+ - **Canaries (Claude Code 2.1.286, 2026-09-30, throwaway `claude -p` sessions in temporary projects with `--setting-sources project`, an empty `--strict-mcp-config`, `--no-session-persistence` and haiku, so no user hook and no COS spool saw them, cleaned up after).** The claim the hold rests on: Claude Code waits on a PermissionRequest command hook for its configured timeout when that is above 130 s, and applies its decision. A630, timeout 630 and a hook that slept 610 s then allowed: the hook ran 21:06:28 to 21:16:38 and the tool ran the same second; not killed. A, timeout 300 and a 200 s sleep: the same, 19:12:11 to 19:15:31. B, the control, timeout 150: stopped at 150 s, the tool denied. C, a session started at 150 whose first request rewrote its own setting to 300: the second request's hook ran the full 200 s.
61
+ - Smoke, the real 6.60.0 script with `630` against a scratch server (the real routes and liveness module, wired as `index.ts`): a 6.9.563 poll with `presenceAgeMs=0`, then 35 s quiet (presence recent, no client live): held with a 600 s deadline, answered at 140 s, past the earlier curl's 125 s, and the script printed the allow and exited 0. No poll at all: `{}` in 120 ms, `no_client`, `not_away=no_hold_client`. A poll with `hold=1` and no `presenceAgeMs` (a lens nobody wears): `{}` in 118 ms, the same.
62
+ - The existing mutation gate's cases on the code this release reshaped (the no-client gate, the desk gate, the retraction rule, the quiet-client tick, the poll stamps, `hookHaltReady`) are retargeted to the new lines with the same mutations.
63
+ - **Exactly once, against the real registry (Ghost Hunter W7).** The glasses' saved-turn drain re-sends a turn under one `clientTurnId`; new tests run it against the real `AgentSessionBindingRegistry`: sequential (replays), two concurrent POSTs while the first is in flight (one delivery; the other `native_turn_in_progress`, not delivered; a later retry replays), across a binding retired and re-created on the same thread (replays), and a thread busy with the first send (a new attach `native_target_busy`, the same id `native_turn_in_progress`, then a replay through a new binding). No gap was found.
64
+ - Docs: the README's session questions section (the contract, the rule, the clamp, the fields, forgot vs moved past), the settings list and the `no_client` troubleshooting line.
65
+ - Tests: the contract parse and the liveness module (hold and legacy polls apart, presence only with `hold=1`, out-of-range ages, forward-only on both clocks, the sleep case, a reset); a lens polling with no presence, an older client, presence 10 minutes, exactly 30 minutes, 30 minutes and 1 ms, 31 minutes, a window of 0; the tick re-check at 30:00 and a wearer who came back; a worn lens as a live client until its presence lapses; the ceiling at base; never shorter than the base; unreadable settings with and without a stamp; no client ever (also over the real route under 100 ms) and a restart; every fast path logged; the real defaults wired as `index.ts`; desk return within one 1.5 s production poll; `no_client` and `drained` leave the dialog up; fingerprint-only and ended-row clearing; the listing keeps every waiting item past the cap; forgot vs moved past and the day-long keep; the hook script's stamp, its curl limit and a TERM that leaves no curl and a `hook_gone`; the installer's 630 block, the prior-wait drift and halt readiness; the runtime contract keys. `node server/scripts/away-hold-mutation-gate.mjs` (repository only, not in the package) runs its mutants from a green baseline, each target matched exactly once, and each must fail the test named for it: 68 of 68 killed, including the Ghost Hunter's two survivors (`no_client` and `drained` dropped from the dialog-up set, the never-below-base guard removed). The existing gate's 13 retargeted cases: 13 of 13 killed.
66
+
1
67
  ## 6.59.0
2
68
 
3
69
  ### Work on the glasses: progress, everything open, and a request inbox COS Control works through
package/README.md CHANGED
@@ -246,7 +246,8 @@ The client contract (also at `/api/models` `capabilities.sessionQuestions`):
246
246
  - Poll `GET /api/session-questions?client=glasses` (or `client=phone`) with X-Cos-Token
247
247
  every `pollIntervalMs` (10,000). Only those two `client` values count as a live
248
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.
249
+ counts. A client quiet for `liveWindowMs` (30,000) hands every held request back,
250
+ except one the away hold holds (6.60.0, below).
250
251
  - The response carries `enabled`, `mode`, `protocolVersion` (1), `pollIntervalMs`,
251
252
  `liveWindowMs`, `pending` and `items` (a question's `questions`, or an approval's
252
253
  `approval`: `{tool, summary, detail}`, the command, path or URL exactly as it will run,
@@ -259,7 +260,69 @@ The client contract (also at `/api/models` `capabilities.sessionQuestions`):
259
260
  404 `not_found` after a server restart or once a settled item is pruned (10 minutes);
260
261
  503 during a maintenance drain, when every held item has already gone back to the Mac.
261
262
 
262
- Touching the Mac hands a held request back to its dialog at once; the deadline is 110 s.
263
+ Touching the Mac hands a held request back to its dialog at once; the deadline is 110 s,
264
+ or the away hold's.
265
+
266
+ **The away hold (6.60.0).** A request that starts while you are away (the desk idle 90 s,
267
+ the same threshold as above) is held for up to 10 minutes, but only when the glasses or the
268
+ phone that show held questions report that you wore or touched them in the last 30 minutes.
269
+ The client contract (COS Glasses 6.9.563 and later):
270
+
271
+ - Poll `GET /api/session-questions?client=glasses|phone&hold=1&presenceAgeMs=<int>`.
272
+ `hold=1` says this client shows held questions. `presenceAgeMs` is the milliseconds since
273
+ the latest real wearer evidence (glasses: a ring or temple input, or a device status with
274
+ wearing true; phone: a foreground touch), a whole number from 0 to 86,400,000, and is left
275
+ out when there is none since the app started.
276
+ - Only a poll with `hold=1` AND a valid `presenceAgeMs` counts toward the hold. The server
277
+ keeps the newest such presence in memory, on the monotonic clock and the wall clock, and
278
+ reads its age as the larger of the two (so a Mac that slept never makes it look newer).
279
+ Recent means within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (default 1800, clamped to 0
280
+ through 86400; under 30 it means the 30 s live window). After a server restart nothing is
281
+ recent until such a poll.
282
+ - A `hold=1` poll without presence (a connected lens nobody wears) makes no client live and
283
+ holds nothing. A poll without `hold=1` (COS Glasses 6.9.562 and earlier, every older phone
284
+ build) keeps the 6.59.0 meaning exactly: live for 30 s, a 110 s hold, and never the away
285
+ hold.
286
+
287
+ Held that way, a request stays answerable while no client is polling, so glasses or a phone
288
+ that come back can still answer it. It is re-checked every 1.5 s: once no client is live and
289
+ your latest presence is older than the window, it goes to the Mac's dialog. Touching the Mac
290
+ still hands it back within 1.5 s, and a request that arrives while you are at the desk still
291
+ goes straight to the dialog. With no recent presence (a Claude Remote Control session with
292
+ no glasses, say) nothing changes from 6.59.0: `{}` at once and the Mac's dialog.
293
+
294
+ It is never held past the hook's own wait: the PermissionRequest `timeout` in
295
+ `~/.claude/settings.json` (630 s after Install hooks; 130 s on an install from before 6.60.0)
296
+ and the wait the hook stamps on the request, the lesser, minus 10 s. Until Install hooks has
297
+ run, the hook waits no longer than 6.59.0 could hold, so the away hold is off entirely and the
298
+ broker is 6.59.0. After updating, `--hooks status` reads `drift` with `priorWaitOnly: true`
299
+ until Install hooks (COS Control) or `npx --yes @gotcos/glasses-server@latest --hooks install`
300
+ is run once; cancel from the lens keeps working meanwhile. `COS_PERMISSION_BROKER_AWAY_HOLD_S`
301
+ sets the hold (default 600, clamped to 30 through 600; `0` turns the away hold off; a `hold=1` lens still needs recent presence to count as live).
302
+ Additive fields, every earlier one unchanged:
303
+
304
+ - each item: `answerable` (true while listed), `awayHold` (held by the away hold) and
305
+ `waitingAtMac` (false while listed). `deadlineAt` is the latest it stays answerable; it goes
306
+ sooner when your presence lapses with no client polling.
307
+ - the response: `awayHoldMs` (how long a request that starts now is held at most with recent
308
+ presence; 0 when off or before Install hooks), `awayRecentClientMs` (the presence window,
309
+ 0 when the hold is off), `settled` (recently settled items, newest first: every one the Mac
310
+ still waits on, and the newest others up to 32; `id`, `sessionId`, `provider`, `kind`,
311
+ `tool`, `createdAt`, `deadlineAt`, `settledAt`, `resolution`, `answerable: false`,
312
+ `awayHold`, `waitingAtMac`; never the questions, the card or the answer), `waitingAtMac`
313
+ (how many of them the Mac's own dialog still waits on), `serverStartedAt` (when this server
314
+ process started) and `waitingAtMacForgottenBefore`.
315
+ - `waitingAtMac` is true when the server handed the request to the Mac's dialog (back at the
316
+ desk, expired, no client, drained) and that request has not been finished since: its own
317
+ tool has not finished or been denied, its turn has not ended, and its session row does not
318
+ read ended. No hook marks the moment a dialog is approved, so an approved tool that is
319
+ still running reads as waiting. It is kept an hour, or up to a day while the session's own
320
+ row still waits on that request.
321
+ - Forgot vs moved past: an entry you saw waiting that is no longer in `settled` was moved past
322
+ if its `settledAt` is after `waitingAtMacForgottenBefore`; at or before it (or from a server
323
+ with another `serverStartedAt`), the server forgot it, and the Mac may still be waiting.
324
+ - `/api/models` `capabilities.sessionQuestions.awayHoldMs` and `awayRecentClientMs`, the same numbers.
325
+
263
326
  Allow once or deny only: no permission rule is ever written. Rows carry
264
327
  `pending_question_id` or `pending_permission_id` while a request is held, and a question
265
328
  row reads `waiting_kind: question` whatever the switch says; `/api/health` reports
@@ -402,8 +465,16 @@ machine-wide rollback for build 204+ server-owned query recovery),
402
465
  `COS_MESSAGES_TRAIL=0` (6.52.0: no Messages trail; every job stream and snapshot is the
403
466
  6.51.0 one), `COS_PERMISSION_BROKER` (6.52.0: `0` answers every permission request with
404
467
  the Mac's own dialog, `questions` holds questions but not tool approvals; unset holds
405
- both), `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90, never below 30) and
468
+ both), `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90, never below 30),
406
469
  `COS_PERMISSION_BROKER_TIMEOUT_S` (default 110, clamped to 5 through 120),
470
+ `COS_PERMISSION_BROKER_AWAY_HOLD_S` (6.60.0: default 600, clamped to 30 through 600, never
471
+ past the installed hook's wait; `0` turns the away hold off),
472
+ `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (6.60.0: default 1800; the away hold applies
473
+ only when a `hold=1` client reported its wearer present within it; under 30 means the 30 s
474
+ live window),
475
+ `COS_DIARIZER` (6.61.0: default `nemotron`; `embedding` keeps the voiceprint only),
476
+ `COS_NEMOTRON_CLI` and `COS_NEMOTRON_MODELS` (6.61.0: the FluidAudio CLI and its
477
+ local model directory; see Speaker diarization),
407
478
  `COS_MEDIA_ROOT` (optional image/video store location; default
408
479
  `~/.cos-glasses/data/media`), and `COS_VIDEO_UPLOAD_V2=1` (private 6.27.3+
409
480
  resumable-video canary, managed by COS Control 0.5.20). The V2 canary retains
@@ -510,6 +581,66 @@ report `enrolled: false`. Set `owner_speaker_label` to `MU` to keep the existing
510
581
  voiceprints rather than re-enrolling, which would split the same voice across two
511
582
  profiles.
512
583
 
584
+ ### Who spoke when: Nemotron (6.61.0, the default)
585
+
586
+ The voiceprint names a person but gives a chunk one label, and about 45% of
587
+ meeting chunks hold two voices. Since 6.61.0 the server separates the voices
588
+ with [Nemotron 3 Diarization](https://huggingface.co/FluidInference/nemotron-3-diarization-coreml)
589
+ (FluidAudio's `fluidaudiocli`, preset `fast32`, Neural Engine) and lets the
590
+ voiceprint name each one. Nemotron never names anyone itself, and the
591
+ voiceprint's thresholds are unchanged: what changes is that a two-voice chunk
592
+ is split into turns instead of carrying one label.
593
+
594
+ - **Live, on the HUD.** Each chunk runs Nemotron in parallel with Whisper, inside
595
+ a 1.5 s budget, at most two at once. The chunk response keeps `speaker` (now
596
+ the dominant voice's name) and `text`, and adds
597
+ `segments: [{ speaker, text, startSec, endSec, similarity }]` in time order;
598
+ their texts joined with one space equal `text`. A track with at least 2 s of
599
+ its own audio is named by the voiceprint on that audio; a shorter one takes the
600
+ chunk's voiceprint when it is the dominant voice, otherwise the client's label
601
+ (when that is a real name different from the dominant one) or `Ext`. The
602
+ segments are stored on the chunk, so the saved meeting keeps them.
603
+ - **The final transcript.** When a meeting is saved, one Nemotron pass over the
604
+ whole meeting maps each channel to a name from the chunks the voiceprint was
605
+ sure of (similarity 0.65 or more, one-sided), and relabels the transcript word
606
+ by word before the meeting reaches COS sync. A channel two people share stays
607
+ unmapped and keeps the per-chunk names. Only labels change: lines split where
608
+ the speaker changes and are never merged, nothing is written unless every
609
+ word survives in order, changed words keep `originalSpeaker`, and the sidecar
610
+ keeps the pre-relabel transcript under `diarization.originalTranscript`. A
611
+ meeting with a human speaker correction is never relabelled; an edit made
612
+ while the pass runs makes it stand down, and relabel or deattribute answer
613
+ 409 `meeting_finalizing` until the meeting has finished finalizing.
614
+ - **Never in the way.** Until the model is warm (the first Neural Engine compile
615
+ takes 15 to 35 s, started in the background at server start), and on any
616
+ timeout, crash, busy lane, missing CLI or missing model, a chunk keeps the
617
+ voiceprint label exactly as before, and the server logs the reason. Three
618
+ failed runs in a row open a breaker: no CLI is spawned and every chunk takes
619
+ the voiceprint at once, for 2 minutes, doubling per trip up to an hour, then
620
+ one fresh warm-up decides. A failed final pass leaves the meeting exactly as
621
+ saved. The CLI runs with a minimal environment (no tokens or keys), and no run
622
+ or temp file outlives the server. No model call is ever made.
623
+
624
+ **Offline.** The server always passes `--models <dir>` to the CLI, a directory
625
+ holding `Nemotron3Diarizer_fast32.mlmodelc` and `learnable_sil_emb.bin`, and that
626
+ path in FluidAudio never downloads. Without `--models` the CLI fetches from
627
+ Hugging Face, so the server never runs it that way. The directory is
628
+ `COS_NEMOTRON_MODELS`, else `~/.cos-glasses/models/nemotron-3-diarization`, which
629
+ the server links to FluidAudio's own cache
630
+ (`~/Library/Application Support/FluidAudio/Models/nemotron-3-diarization`, left
631
+ there by running the CLI once by hand). The CLI is `COS_NEMOTRON_CLI`, else
632
+ `~/.cos-glasses/bin/fluidaudiocli`. Missing either, the server stays on the
633
+ voiceprint and says so.
634
+
635
+ `COS_DIARIZER=embedding` (or `voiceprint`, `eres2net`) turns Nemotron off: no
636
+ warm-up, no run, the 6.60 labels. `/api/health` `readiness.diarizer` reports
637
+ `active` (`nemotron` only while warm, the breaker is closed and most recent
638
+ chunks really were labelled by Nemotron; otherwise `warming` or `embedding`),
639
+ the `fallback` reason, the recent outcomes, the breaker, the per-chunk counts,
640
+ the warm-up and the final passes. `/api/health` is public, so this carries codes
641
+ and counts only, never a speaker name, path or error text; those stay in
642
+ `server.log`. A Nemotron fallback never degrades `readiness.status`.
643
+
513
644
  ## HQ dictation
514
645
 
515
646
  Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
@@ -726,13 +857,17 @@ BIND_HOST=0.0.0.0 npm run start:server
726
857
  back, set `COS_DURABLE_QUERY_JOBS=0`; accepted jobs still drain while new prompts use legacy streaming.
727
858
  - *Session questions never reach the glasses?* The server holds one only when every
728
859
  gate passes. `/api/health` `permissionBroker.lastFastPath` names the last reason a
729
- request went straight to the Mac's dialog: `no_client` (no poll with
730
- `client=glasses|phone` in the last 30 s: the app build has no question cards, or its
731
- timers are frozen), `desk_active` (the Mac saw input), `approvals_off`
860
+ request went straight to the Mac's dialog: `no_client` (no client could answer: no poll
861
+ with `client=glasses|phone` and without `hold=1` in the last 30 s, no `hold=1` poll in the
862
+ last 30 s whose wearer was present within the window, and (6.60.0) no away hold: the app
863
+ build has no question cards, its timers are frozen, nobody wore the glasses or touched the
864
+ phone recently, or Install hooks has not run; the server log line `fast path no_client
865
+ not_away=...` names which), `desk_active` (the Mac saw input), `approvals_off`
732
866
  (`COS_PERMISSION_BROKER=questions`), `broker_off`, `cursor`, `unsupported_tool`
733
867
  (ExitPlanMode is never held). `lastQuestionsPollAt` says when a client last counted.
734
868
  `--hooks status` must read `installed` (right after updating to 6.53.4 it reads
735
- `script_outdated` until Install hooks is run once; its `advice` line says so). To turn it off with no reinstall, set
869
+ `script_outdated` until Install hooks is run once, and right after updating to 6.60.0
870
+ `drift` with `priorWaitOnly: true`; its `advice` line says so). To turn it off with no reinstall, set
736
871
  `COS_PERMISSION_BROKER=0` in `~/.cos-glasses/.env` and restart. For the Messages
737
872
  trail, `/api/health` `messages_trail.readerErrors` counts lines a reader could not map.
738
873
  - *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
2
- # COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0; halt check 6.53.0; single entry 6.53.3). Installed into ~/.claude/settings.json by
2
+ # COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0; halt check 6.53.0; single entry 6.53.3; away hold wait 6.60.0). Installed into ~/.claude/settings.json by
3
3
  # `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
4
4
  # payload on stdin. It spools one JSON file per event into the hook spool and exits 0
5
5
  # on every path. Only PermissionRequest ever talks to the server, and only when the
@@ -115,7 +115,17 @@ else
115
115
  { head -c 1048576 > "$T.in"; cat >/dev/null; }
116
116
  fi
117
117
  case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
118
- { printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
118
+ # 6.60.0: the installer passes the PermissionRequest block's own timeout as $2. Claude Code
119
+ # stops this hook at that many seconds, so curl below gives up 5 s before it, and the
120
+ # request carries it (`hookWaitS`) so the server never holds it longer. No usable $2 (an
121
+ # earlier block) is the earlier wait, 130, with no stamp.
122
+ WAIT=130; STAMP_WAIT=''
123
+ if [ "$EVENT" = PermissionRequest ]; then
124
+ case "${2:-}" in ''|0*|*[!0-9]*) ;; *)
125
+ if [ ${#2} -le 4 ] && [ "$2" -ge 20 ] && [ "$2" -le 3600 ]; then WAIT=$2; STAMP_WAIT=",\"hookWaitS\":$2"; fi ;;
126
+ esac
127
+ fi
128
+ { printf '{"ts":%s,"ppid":%s,"event":"%s"%s,"payload":' "$TS" "$PPID" "$EVENT" "$STAMP_WAIT"; cat "$T.in"; printf '}\n'; } > "$T"
119
129
  rm -f "$T.in"
120
130
  # 6.51.0, CURSOR ONLY. Cursor runs this hook from ~/.claude/settings.json for its own
121
131
  # composers, and submits a Stop hook's {"followup_message":...} as that composer's next
@@ -153,8 +163,18 @@ DESK_S=$(cat "$GH/hook-desk-idle-s" 2>/dev/null || echo 90)
153
163
  { [ -n "$IDLE_NS" ] && [ "$(( ${IDLE_NS%.*} / 1000000000 ))" -ge "$DESK_S" ]; } || { cleanup; exit 0; }
154
164
  TOK=$(cat "$GH/hook-token" 2>/dev/null); [ -n "$TOK" ] || { cleanup; exit 0; } # no token, no request
155
165
  PORT=$(cat "$GH/hook-port" 2>/dev/null || echo 3141)
156
- OUT=$(curl -s --connect-timeout 2 --max-time 125 -H "X-Cos-Hook-Token: $TOK" -H 'content-type: application/json' \
157
- --data-binary @"$T.req" "http://127.0.0.1:$PORT/api/permission-requests/ask") || { cleanup; exit 0; }
166
+ # 6.60.0 (QA round 1, W7): curl runs as a tracked child, not only inside $(...), and this
167
+ # script stops it when it is stopped itself (Claude Code's timeout, an interrupt). The
168
+ # broker then sees the socket close at once (`hook_gone`) and never accepts an answer the
169
+ # session can no longer receive.
170
+ rm -f "$T.out"
171
+ curl -s --connect-timeout 2 --max-time $((WAIT - 5)) -H "X-Cos-Hook-Token: $TOK" -H 'content-type: application/json' \
172
+ -o "$T.out" --data-binary @"$T.req" "http://127.0.0.1:$PORT/api/permission-requests/ask" &
173
+ CURL=$!
174
+ trap 'kill "$CURL" 2>/dev/null; rm -f "$T.req" "$T.out"; exit 0' TERM INT HUP
175
+ wait "$CURL" || { trap - TERM INT HUP; cleanup; rm -f "$T.out"; exit 0; }
176
+ trap - TERM INT HUP
177
+ OUT=$(cat "$T.out" 2>/dev/null); rm -f "$T.out"
158
178
  cleanup
159
179
  case "$OUT" in '{"hookSpecificOutput"'*) printf '%s' "$OUT" ;; esac
160
180
  exit 0
@@ -35,9 +35,14 @@
35
35
  "COS_PERMISSION_BROKER",
36
36
  "COS_PERMISSION_BROKER_DESK_IDLE_S",
37
37
  "COS_PERMISSION_BROKER_TIMEOUT_S",
38
+ "COS_PERMISSION_BROKER_AWAY_HOLD_S",
39
+ "COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S",
38
40
  "COS_LENS_GIST_ENGINE",
39
41
  "COS_LENS_GIST_MODEL",
40
- "COS_LENS_GIST_DAILY_CAP"
42
+ "COS_LENS_GIST_DAILY_CAP",
43
+ "COS_DIARIZER",
44
+ "COS_NEMOTRON_CLI",
45
+ "COS_NEMOTRON_MODELS"
41
46
  ],
42
47
  "maintenance": {
43
48
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.59.0",
3
+ "version": "6.61.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": {
@@ -45,6 +45,9 @@
45
45
  "!server/scripts/work-review-mutation-gate.mjs",
46
46
  "!server/scripts/work-intake-mutation-gate.mjs",
47
47
  "!server/scripts/work-progress-mutation-gate.mjs",
48
+ "!server/scripts/away-hold-mutation-gate.mjs",
49
+ "!server/scripts/nemotron-mutation-gate.mjs",
50
+ "!server/scripts/nemotron-replay.ts",
48
51
  "!server/scripts/mutation-gate.mjs",
49
52
  "!server/lib/meeting-engine/__fixtures__/**",
50
53
  "!server/lib/__fixtures__/**",
package/server/index.ts CHANGED
@@ -24,13 +24,15 @@ 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 { cachedHookStatus, claudeDeskRunning, deskIdleSeconds, deskTurnEndedAt, haltHandedOffTurn, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
27
+ import { cachedHookStatus, claudeDeskRunning, onSessionRowEnded, deskIdleSeconds, deskTurnEndedAt, haltHandedOffTurn, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
28
28
  import { writeHaltMarker } from './lib/session-halt.js'
29
29
  import { appendSessionCancelLedger, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, threadCancel } from './lib/session-cancel.js'
30
30
  import { makeQueueTurnEvidence } from './lib/queue-turn-evidence.js'
31
31
  import {
32
32
  PermissionBroker,
33
33
  brokerSignalSink,
34
+ permissionBrokerAwayHoldMs,
35
+ permissionBrokerAwayRecentClientMs,
34
36
  permissionBrokerMode,
35
37
  permissionBrokerTimeoutMs,
36
38
  readHidIdleSeconds,
@@ -38,7 +40,7 @@ import {
38
40
  wirePermissionBrokerToSignals,
39
41
  } from './lib/permission-broker.js'
40
42
  import { createPermissionBrokerHookRouter, createSessionQuestionsRouter } from './routes/permission-broker.js'
41
- import { lastQuestionsPollAt } from './lib/client-liveness.js'
43
+ import { lastHoldQuestionsPollAt, lastLegacyQuestionsPollAt, lastQuestionsPollAt, presenceAgeMs } from './lib/client-liveness.js'
42
44
  import {
43
45
  createAgentSessionBindingsRouter,
44
46
  TargetGuard,
@@ -114,6 +116,8 @@ import { startLocalTtsServer, stopLocalTtsServer } from './lib/tts-local.js'
114
116
  import { initSileroVAD } from './lib/vad-silero.js'
115
117
  import { initSessionCache } from './lib/session-cache-writer.js'
116
118
  import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
119
+ import { startNemotronWarmup } from './lib/nemotron-live.js'
120
+ import { killNemotronChildren } from './lib/nemotron-cli.js'
117
121
  import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
118
122
  import { getMediaStore } from './lib/media-store.js'
119
123
  import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
@@ -241,6 +245,13 @@ app.use(cors({
241
245
  // (lib/permission-broker.ts). An app without the question UI never polls, so for it every
242
246
  // request is `{}`. `COS_PERMISSION_BROKER=0` is the switch, read per request, and every
243
247
  // request outside the gate is answered `{}` at once.
248
+ // 6.60.0: the away hold. While the desk is idle, and only when glasses or a phone that send
249
+ // `hold=1` reported wearer presence within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S`
250
+ // (default 1800; in memory, the larger of the monotonic and wall-clock age), a request is held
251
+ // up to `COS_PERMISSION_BROKER_AWAY_HOLD_S` (default 600, `0` is the 6.59.0 broker) whether or
252
+ // not a client is polling right now, never past the PermissionRequest timeout the Claude
253
+ // settings file carries (read through the 5 s hook-status cache) and the hook's own stamp.
254
+ // Older clients (no `hold=1`) keep the 6.59.0 liveness and never enable it.
244
255
  const permissionBroker = new PermissionBroker({
245
256
  now: () => Date.now(),
246
257
  mode: () => permissionBrokerMode(process.env, sessionHooksEnabled()),
@@ -249,10 +260,22 @@ const permissionBroker = new PermissionBroker({
249
260
  readDeskIdleSeconds: readHidIdleSeconds,
250
261
  deskIdleSeconds,
251
262
  timeoutMs: () => permissionBrokerTimeoutMs(process.env),
263
+ awayHoldMs: () => permissionBrokerAwayHoldMs(process.env),
264
+ installedHookTimeoutS: () => cachedHookStatus().permissionHookTimeoutS,
265
+ lastLegacyPollAt: lastLegacyQuestionsPollAt,
266
+ lastHoldPollAt: lastHoldQuestionsPollAt,
267
+ presenceAgeMs: () => presenceAgeMs(),
268
+ awayRecentClientMs: () => permissionBrokerAwayRecentClientMs(process.env),
269
+ stillWaitingAtMac: (sessionId, fingerprint) => {
270
+ const signal = sessionSignalStore.get(sessionId)
271
+ return !!signal && !signal.ended && signal.waiting?.fingerprint === fingerprint
272
+ },
252
273
  signals: brokerSignalSink(sessionSignalStore),
253
274
  })
254
275
  registerPermissionBroker(permissionBroker)
255
276
  wirePermissionBrokerToSignals(permissionBroker, sessionSignalStore)
277
+ // 6.60.0 (QA round 1, N2): a row that reads ended clears "waiting at the Mac" at once.
278
+ onSessionRowEnded(sessionId => { permissionBroker.sessionRowEnded(sessionId) })
256
279
 
257
280
  // THE TWO HOOK-TOKEN DOORS, deliberately BEFORE the /api gate and the global body parser.
258
281
  // Each checks the per-install hook token (never the pairing token) in constant time before
@@ -979,6 +1002,12 @@ async function gracefulShutdown(): Promise<void> {
979
1002
  console.error('[live-cues] shutdown termination failed:', error)
980
1003
  }
981
1004
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
1005
+ // 6.61.0: no Nemotron run may outlive the server, and no temp directory may
1006
+ // keep a meeting's audio (the module's exit hook repeats this for any exit).
1007
+ try {
1008
+ const nemotron = killNemotronChildren()
1009
+ if (nemotron.killed || nemotron.removed) console.log(`[diarizer] shutdown: killed ${nemotron.killed} Nemotron run(s), removed ${nemotron.removed} temp dir(s)`)
1010
+ } catch { /* best effort */ }
982
1011
  stopCodexModelCatalogRefresh()
983
1012
  stopWhisperServer()
984
1013
  await stopWhisperPreviewServer({ final: true })
@@ -1141,6 +1170,9 @@ listenRequiredServers(listeners).then(() => {
1141
1170
  // Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
1142
1171
  const vadOk = initSileroVAD()
1143
1172
  console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
1173
+ // 6.61.0: warm Nemotron in the background (the first Neural Engine compile
1174
+ // takes 15-30 s). Chunks use the voiceprint until it is warm; never awaited.
1175
+ startNemotronWarmup()
1144
1176
  }
1145
1177
 
1146
1178
  // Durable recovery starts with the admitted runtime by default. A literal