@gotcos/glasses-server 6.59.0 → 6.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## 6.60.0
2
+
3
+ ### A question that starts while you are away waits up to 10 minutes for the glasses you are wearing
4
+
5
+ - 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.
6
+ - **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.
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **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).
11
+ - **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.
12
+ - `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`.
13
+ - **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.
14
+ - **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.
15
+ - **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).
16
+ - **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.
17
+ - **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.
18
+ - **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`.
19
+ - **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.
20
+ - **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.
21
+ - **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`.
22
+ - **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.
23
+ - 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.
24
+ - 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.
25
+ - **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.
26
+ - 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.
27
+ - 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.
28
+
1
29
  ## 6.59.0
2
30
 
3
31
  ### 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,13 @@ 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),
407
475
  `COS_MEDIA_ROOT` (optional image/video store location; default
408
476
  `~/.cos-glasses/data/media`), and `COS_VIDEO_UPLOAD_V2=1` (private 6.27.3+
409
477
  resumable-video canary, managed by COS Control 0.5.20). The V2 canary retains
@@ -726,13 +794,17 @@ BIND_HOST=0.0.0.0 npm run start:server
726
794
  back, set `COS_DURABLE_QUERY_JOBS=0`; accepted jobs still drain while new prompts use legacy streaming.
727
795
  - *Session questions never reach the glasses?* The server holds one only when every
728
796
  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`
797
+ request went straight to the Mac's dialog: `no_client` (no client could answer: no poll
798
+ with `client=glasses|phone` and without `hold=1` in the last 30 s, no `hold=1` poll in the
799
+ last 30 s whose wearer was present within the window, and (6.60.0) no away hold: the app
800
+ build has no question cards, its timers are frozen, nobody wore the glasses or touched the
801
+ phone recently, or Install hooks has not run; the server log line `fast path no_client
802
+ not_away=...` names which), `desk_active` (the Mac saw input), `approvals_off`
732
803
  (`COS_PERMISSION_BROKER=questions`), `broker_off`, `cursor`, `unsupported_tool`
733
804
  (ExitPlanMode is never held). `lastQuestionsPollAt` says when a client last counted.
734
805
  `--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
806
+ `script_outdated` until Install hooks is run once, and right after updating to 6.60.0
807
+ `drift` with `priorWaitOnly: true`; its `advice` line says so). To turn it off with no reinstall, set
736
808
  `COS_PERMISSION_BROKER=0` in `~/.cos-glasses/.env` and restart. For the Messages
737
809
  trail, `/api/health` `messages_trail.readerErrors` counts lines a reader could not map.
738
810
  - *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,6 +35,8 @@
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
42
  "COS_LENS_GIST_DAILY_CAP"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.59.0",
3
+ "version": "6.60.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,7 @@
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",
48
49
  "!server/scripts/mutation-gate.mjs",
49
50
  "!server/lib/meeting-engine/__fixtures__/**",
50
51
  "!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,
@@ -241,6 +243,13 @@ app.use(cors({
241
243
  // (lib/permission-broker.ts). An app without the question UI never polls, so for it every
242
244
  // request is `{}`. `COS_PERMISSION_BROKER=0` is the switch, read per request, and every
243
245
  // request outside the gate is answered `{}` at once.
246
+ // 6.60.0: the away hold. While the desk is idle, and only when glasses or a phone that send
247
+ // `hold=1` reported wearer presence within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S`
248
+ // (default 1800; in memory, the larger of the monotonic and wall-clock age), a request is held
249
+ // up to `COS_PERMISSION_BROKER_AWAY_HOLD_S` (default 600, `0` is the 6.59.0 broker) whether or
250
+ // not a client is polling right now, never past the PermissionRequest timeout the Claude
251
+ // settings file carries (read through the 5 s hook-status cache) and the hook's own stamp.
252
+ // Older clients (no `hold=1`) keep the 6.59.0 liveness and never enable it.
244
253
  const permissionBroker = new PermissionBroker({
245
254
  now: () => Date.now(),
246
255
  mode: () => permissionBrokerMode(process.env, sessionHooksEnabled()),
@@ -249,10 +258,22 @@ const permissionBroker = new PermissionBroker({
249
258
  readDeskIdleSeconds: readHidIdleSeconds,
250
259
  deskIdleSeconds,
251
260
  timeoutMs: () => permissionBrokerTimeoutMs(process.env),
261
+ awayHoldMs: () => permissionBrokerAwayHoldMs(process.env),
262
+ installedHookTimeoutS: () => cachedHookStatus().permissionHookTimeoutS,
263
+ lastLegacyPollAt: lastLegacyQuestionsPollAt,
264
+ lastHoldPollAt: lastHoldQuestionsPollAt,
265
+ presenceAgeMs: () => presenceAgeMs(),
266
+ awayRecentClientMs: () => permissionBrokerAwayRecentClientMs(process.env),
267
+ stillWaitingAtMac: (sessionId, fingerprint) => {
268
+ const signal = sessionSignalStore.get(sessionId)
269
+ return !!signal && !signal.ended && signal.waiting?.fingerprint === fingerprint
270
+ },
252
271
  signals: brokerSignalSink(sessionSignalStore),
253
272
  })
254
273
  registerPermissionBroker(permissionBroker)
255
274
  wirePermissionBrokerToSignals(permissionBroker, sessionSignalStore)
275
+ // 6.60.0 (QA round 1, N2): a row that reads ended clears "waiting at the Mac" at once.
276
+ onSessionRowEnded(sessionId => { permissionBroker.sessionRowEnded(sessionId) })
256
277
 
257
278
  // THE TWO HOOK-TOKEN DOORS, deliberately BEFORE the /api gate and the global body parser.
258
279
  // Each checks the per-install hook token (never the pairing token) in constant time before
@@ -36,8 +36,24 @@ export interface HookSubscription {
36
36
  matcher?: string
37
37
  async: boolean
38
38
  timeout: number
39
+ /**
40
+ * 6.60.0: the command passes the block's own `timeout` to the script as its second
41
+ * argument. The script stops waiting 5 s before it and stamps it on the request it posts
42
+ * (`hookWaitS`), so the broker knows the wait of THIS invocation. The command and the
43
+ * timeout sit in one block, so whether or not a running Claude session re-reads its hook
44
+ * settings, the stamp it sends and the timeout it enforces come from the same block.
45
+ */
46
+ passTimeout?: boolean
39
47
  }
40
48
 
49
+ /**
50
+ * 6.60.0: Claude Code's wait on the PermissionRequest hook: the away hold (default 600 s)
51
+ * plus slack. The script's curl stops at 625 and the broker answers by 620 at the latest.
52
+ */
53
+ export const PERMISSION_HOOK_TIMEOUT_S = 630
54
+ /** What every install before 6.60.0 carries for PermissionRequest, with no wait argument. */
55
+ export const LEGACY_PERMISSION_HOOK_TIMEOUT_S = 130
56
+
41
57
  /**
42
58
  * Every event the reducer understands, with the timing each one needs. SessionStart,
43
59
  * UserPromptSubmit, Stop and SessionEnd are synchronous: measured 2026-09-15, an async
@@ -55,6 +71,10 @@ export interface HookSubscription {
55
71
  * because the merge keeps exactly one of ours per event and status requires it: the script
56
72
  * spools only AskUserQuestion and ExitPlanMode, which is what the old async matcher block
57
73
  * did. Changing this row makes every existing install read `drift` until Install hooks.
74
+ *
75
+ * 6.60.0: PermissionRequest waits 630 s (was 130) and passes that number to the script, for
76
+ * the away hold. An install that differs ONLY by still carrying the earlier block (130 s, no
77
+ * argument) reads `drift` with `priorWaitOnly`, and stays halt-ready (`hookHaltReady`).
58
78
  */
59
79
  export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
60
80
  { event: 'SessionStart', async: false, timeout: 5 },
@@ -62,7 +82,7 @@ export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
62
82
  { event: 'UserPromptSubmit', async: false, timeout: 5 },
63
83
  { event: 'Stop', async: false, timeout: 5 },
64
84
  { event: 'StopFailure', async: true, timeout: 10 },
65
- { event: 'PermissionRequest', async: false, timeout: 130 },
85
+ { event: 'PermissionRequest', async: false, timeout: PERMISSION_HOOK_TIMEOUT_S, passTimeout: true },
66
86
  { event: 'PermissionDenied', async: true, timeout: 10 },
67
87
  { event: 'PreToolUse', async: false, timeout: 5 },
68
88
  { event: 'PostToolUse', async: true, timeout: 10 },
@@ -117,9 +137,13 @@ export function shellQuote(value: string): string {
117
137
  /**
118
138
  * The installed command carries the two paths the script needs as environment, so the
119
139
  * server and the script can never disagree about where the spool or the token is.
140
+ * 6.60.0: `waitS`, for a subscription with `passTimeout`, follows the event name as the
141
+ * script's second argument (an argument, never an environment variable: a variable could
142
+ * be inherited from the shell that started Claude, an argument only comes from this block).
120
143
  */
121
- export function hookCommand(scriptPath: string, event: string, paths: HookPaths = currentHookPaths()): string {
122
- return `COS_GLASSES_HOME=${shellQuote(paths.home)} COS_HOOK_SPOOL=${shellQuote(paths.spoolDir)} ${shellQuote(scriptPath)} ${event}`
144
+ export function hookCommand(scriptPath: string, event: string, paths: HookPaths = currentHookPaths(), waitS?: number): string {
145
+ const base = `COS_GLASSES_HOME=${shellQuote(paths.home)} COS_HOOK_SPOOL=${shellQuote(paths.spoolDir)} ${shellQuote(scriptPath)} ${event}`
146
+ return waitS === undefined ? base : `${base} ${Math.trunc(waitS)}`
123
147
  }
124
148
 
125
149
  export interface HookPaths {
@@ -133,20 +157,57 @@ export function currentHookPaths(): HookPaths {
133
157
 
134
158
  type HookBlock = { matcher?: unknown; hooks?: unknown }
135
159
 
160
+ function hookIsOurs(h: unknown): boolean {
161
+ return !!h && typeof h === 'object' && typeof (h as { command?: unknown }).command === 'string'
162
+ && ((h as { command: string }).command.includes(HOOK_SCRIPT_MARKER) || (h as { command: string }).command.includes(HOOK_SCRIPT_NAME + ' '))
163
+ }
164
+
136
165
  function blockIsOurs(block: unknown): boolean {
137
166
  if (!block || typeof block !== 'object') return false
138
167
  const hooks = (block as HookBlock).hooks
139
168
  if (!Array.isArray(hooks)) return false
140
- return hooks.some(h => h && typeof h === 'object' && typeof (h as { command?: unknown }).command === 'string'
141
- && ((h as { command: string }).command.includes(HOOK_SCRIPT_MARKER) || (h as { command: string }).command.includes(HOOK_SCRIPT_NAME + ' ')))
169
+ return hooks.some(hookIsOurs)
142
170
  }
143
171
 
144
172
  function canonicalBlock(scriptPath: string, sub: HookSubscription, paths: HookPaths): Record<string, unknown> {
145
- const hook: Record<string, unknown> = { type: 'command', command: hookCommand(scriptPath, sub.event, paths), timeout: sub.timeout }
173
+ const command = hookCommand(scriptPath, sub.event, paths, sub.passTimeout ? sub.timeout : undefined)
174
+ const hook: Record<string, unknown> = { type: 'command', command, timeout: sub.timeout }
146
175
  if (sub.async) hook.async = true
147
176
  return sub.matcher ? { matcher: sub.matcher, hooks: [hook] } : { hooks: [hook] }
148
177
  }
149
178
 
179
+ /**
180
+ * 6.60.0: the block an install before 6.60.0 wrote for this event, when this release changed
181
+ * it only by the wait: PermissionRequest at 130 s with no wait argument. Null for any other.
182
+ */
183
+ function priorWaitBlock(scriptPath: string, sub: HookSubscription, paths: HookPaths): Record<string, unknown> | null {
184
+ if (sub.event !== 'PermissionRequest') return null
185
+ return canonicalBlock(scriptPath, { event: sub.event, async: sub.async, timeout: LEGACY_PERMISSION_HOOK_TIMEOUT_S }, paths)
186
+ }
187
+
188
+ /**
189
+ * 6.60.0: how long Claude Code waits on OUR PermissionRequest hook, read from a settings
190
+ * object: the smallest `timeout` among our entries (Claude runs each, so the first to give up
191
+ * bounds the wait). Null when none of ours is there, or one of ours has no usable number:
192
+ * the broker then keeps the old safe clamp (`lib/permission-broker.ts` `holdCeilingS`).
193
+ */
194
+ export function permissionHookTimeoutS(settings: unknown): number | null {
195
+ const hooks = settings && typeof settings === 'object' ? (settings as Record<string, unknown>).hooks : null
196
+ const blocks = hooks && typeof hooks === 'object' ? (hooks as Record<string, unknown>).PermissionRequest : null
197
+ if (!Array.isArray(blocks)) return null
198
+ let least: number | null = null
199
+ for (const block of blocks) {
200
+ if (!blockIsOurs(block)) continue
201
+ for (const h of (block as HookBlock).hooks as unknown[]) {
202
+ if (!hookIsOurs(h)) continue
203
+ const timeout = (h as { timeout?: unknown }).timeout
204
+ if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) return null
205
+ least = least === null ? timeout : Math.min(least, timeout)
206
+ }
207
+ }
208
+ return least
209
+ }
210
+
150
211
  export type MergeResult =
151
212
  | { ok: true; settings: Record<string, unknown>; changed: boolean }
152
213
  /** The file holds a `hooks` shape this merge would have to destroy to proceed. */
@@ -190,22 +251,29 @@ export function stripHookSettings(current: unknown): MergeResult {
190
251
  return { ok: true, settings, changed: JSON.stringify(settings) !== before }
191
252
  }
192
253
 
193
- /** Which of our events a settings object carries, by exact canonical command. */
194
- export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[] } {
254
+ /**
255
+ * Which of our events a settings object carries, by exact canonical command. `priorWait`
256
+ * (6.60.0) names the drifted events whose one block of ours is exactly the block an earlier
257
+ * release wrote, differing only by the wait (`priorWaitBlock`).
258
+ */
259
+ export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[]; priorWait: string[] } {
195
260
  const hooks = current && typeof current === 'object' && (current as Record<string, unknown>).hooks
196
261
  const table = hooks && typeof hooks === 'object' ? hooks as Record<string, unknown> : {}
197
262
  const subscribed: string[] = []
198
263
  const missing: string[] = []
199
264
  const drifted: string[] = []
265
+ const priorWait: string[] = []
200
266
  for (const sub of HOOK_SUBSCRIPTIONS) {
201
267
  const blocks = Array.isArray(table[sub.event]) ? table[sub.event] as unknown[] : []
202
268
  const ours = blocks.filter(blockIsOurs)
203
269
  if (ours.length === 0) { missing.push(sub.event); continue }
204
270
  const canonical = JSON.stringify(canonicalBlock(scriptPath, sub, paths))
205
- if (ours.length === 1 && JSON.stringify(ours[0]) === canonical) subscribed.push(sub.event)
206
- else drifted.push(sub.event)
271
+ if (ours.length === 1 && JSON.stringify(ours[0]) === canonical) { subscribed.push(sub.event); continue }
272
+ drifted.push(sub.event)
273
+ const prior = priorWaitBlock(scriptPath, sub, paths)
274
+ if (prior && ours.length === 1 && JSON.stringify(ours[0]) === JSON.stringify(prior)) priorWait.push(sub.event)
207
275
  }
208
- return { subscribed, missing, drifted }
276
+ return { subscribed, missing, drifted, priorWait }
209
277
  }
210
278
 
211
279
  export type HookInstallState =
@@ -231,6 +299,16 @@ export interface HookStatus {
231
299
  subscribed: string[]
232
300
  missing: string[]
233
301
  drifted: string[]
302
+ /** 6.60.0: drifted events whose block is exactly an earlier release's, but for the wait. */
303
+ priorWait: string[]
304
+ /**
305
+ * 6.60.0: `drift`, and the ONLY difference is that the settings still carry the earlier
306
+ * PermissionRequest block (130 s, no wait argument): an install from before 6.60.0 that
307
+ * has not run Install hooks since. Questions are then held at most 120 s, as before.
308
+ */
309
+ priorWaitOnly: boolean
310
+ /** 6.60.0: our PermissionRequest hook's `timeout` in the settings file, or null (`permissionHookTimeoutS`). */
311
+ permissionHookTimeoutS: number | null
234
312
  tokenPresent: boolean
235
313
  }
236
314
 
@@ -272,16 +350,19 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
272
350
  const tokenPresent = existsSync(hookTokenPath())
273
351
  const read = readSettings(settingsPath)
274
352
  if (!read.ok) {
275
- return { state: read.reason, installed: false, settingsPath, scriptPath, scriptSha, packageScriptSha, subscribed: [], missing: HOOK_SUBSCRIPTIONS.map(s => s.event), drifted: [], tokenPresent }
353
+ return { state: read.reason, installed: false, settingsPath, scriptPath, scriptSha, packageScriptSha, subscribed: [], missing: HOOK_SUBSCRIPTIONS.map(s => s.event), drifted: [], priorWait: [], priorWaitOnly: false, permissionHookTimeoutS: null, tokenPresent }
276
354
  }
277
355
  const events = subscribedEvents(read.settings, scriptPath, hookPaths)
356
+ const permissionWaitS = permissionHookTimeoutS(read.settings)
278
357
  let state: HookInstallState
279
358
  if (hooksDisabled(read.settings)) state = 'disabled_by_settings'
280
359
  else if (events.subscribed.length === 0 && events.drifted.length === 0) state = 'missing'
281
360
  else if (events.missing.length > 0 || events.drifted.length > 0) state = 'drift'
282
361
  else if (!scriptSha || (packageScriptSha && scriptSha !== packageScriptSha)) state = 'script_outdated'
283
362
  else state = 'installed'
284
- return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, tokenPresent }
363
+ const priorWaitOnly = state === 'drift' && events.missing.length === 0 && events.drifted.length > 0
364
+ && events.drifted.every(event => events.priorWait.includes(event))
365
+ return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, tokenPresent }
285
366
  }
286
367
 
287
368
  /**
@@ -295,22 +376,35 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
295
376
  * - c0b41bf8...: the 6.53.3 script as first built, before its /qa round made every event
296
377
  * drain stdin. Never published, but installable from a local build of that commit
297
378
  * (7433b7f), and it carries the same halt check.
379
+ * - df54677f...: the script 6.53.3 through 6.59.0 shipped. 6.60.0 changed it only for the
380
+ * away hold (the PermissionRequest wait); its halt check is the same.
298
381
  * Each is pinned against its bytes in `server/lib/__fixtures__/`.
299
382
  */
300
383
  export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
301
384
  '1158bb06297550128f01fc47caa68806a5287d6da2d05b0d1c812e8ae20764e7',
302
385
  'c0b41bf8581cfea498e1e1ac5220fe4e148bd97448d13b60a88879ee5992cc51',
386
+ 'df54677f79908893509ee87608cfe3f4a53ced03f88f337f16a63741d6b3d547',
303
387
  ]
304
388
 
305
389
  /**
306
390
  * 6.53.3: can the hooks on this Mac stop a desk Claude run now? `installed`, or the only
307
391
  * difference is a halt-capable earlier script (above). What the cancel route's
308
392
  * `hooksReady` and `/api/health` `features.sessionCancel.deskClaude` read.
393
+ *
394
+ * 6.60.0: or `drift` with `priorWaitOnly` (the settings still carry the earlier
395
+ * PermissionRequest wait, every other block is current) on this package's script or a
396
+ * halt-capable earlier one. The wait changes how long a question is held, not the halt
397
+ * check, so updating the server never turns cancel from the lens off. Any other drift
398
+ * still is not halt-ready.
309
399
  */
310
- export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'>): boolean {
400
+ export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'> & Partial<Pick<HookStatus, 'priorWaitOnly' | 'packageScriptSha'>>): boolean {
311
401
  if (status.installed === true) return true
312
- return status.state === 'script_outdated' && typeof status.scriptSha === 'string'
313
- && HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
402
+ const priorScript = typeof status.scriptSha === 'string' && HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
403
+ if (status.state === 'script_outdated') return priorScript
404
+ if (status.state === 'drift' && status.priorWaitOnly === true) {
405
+ return priorScript || (typeof status.scriptSha === 'string' && status.scriptSha === status.packageScriptSha)
406
+ }
407
+ return false
314
408
  }
315
409
 
316
410
  const INSTALL_COMMAND = 'npx --yes @gotcos/glasses-server@latest --hooks install'
@@ -319,8 +413,17 @@ const INSTALL_COMMAND = 'npx --yes @gotcos/glasses-server@latest --hooks install
319
413
  * 6.53.3: one plain sentence for `--hooks status` saying what the state means and what fixes
320
414
  * it. Null when installed. Carries no path: the status JSON beside it already names them.
321
415
  */
322
- export function hookStatusAdvice(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha' | 'packageScriptSha'>): string | null {
416
+ export function hookStatusAdvice(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha' | 'packageScriptSha'> & Partial<Pick<HookStatus, 'priorWaitOnly'>>): string | null {
323
417
  const short = (sha: string | null): string => (sha ? sha.slice(0, 12) : 'none')
418
+ // 6.60.0: the one drift an update itself causes. Say what it costs and that nothing broke.
419
+ if (status.state === 'drift' && status.priorWaitOnly === true) {
420
+ return 'The Claude settings file still carries the earlier permission hook wait (130 s), so a question that starts while you are away '
421
+ + 'is held for the glasses for at most 2 minutes instead of 10. '
422
+ + `Run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control. `
423
+ + (hookHaltReady(status)
424
+ ? 'Cancel from the lens still stops desk runs meanwhile.'
425
+ : 'Until then a desk run cannot be cancelled from the lens.')
426
+ }
324
427
  switch (status.state) {
325
428
  case 'installed':
326
429
  return null