@gotcos/glasses-server 6.53.3 → 6.54.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
@@ -252,6 +252,21 @@ BIND_HOST=0.0.0.0
252
252
  # tier for the rest of the day. Default 40.
253
253
  # COS_MEETING_SUMMARY_DAILY_CAP=40
254
254
 
255
+ # ── LENS GIST: the summary behind the G2 prompt box (6.54.0) ────────────
256
+ # Outcome / So what / You asked for a finished session or message, written by the
257
+ # engine you pick: claude (default, when the Claude CLI is installed), codex, cursor,
258
+ # ollama, or off. A mistyped engine is off. PUT /api/lens-gist/config saves the same
259
+ # choice in the data dir; this setting wins over it.
260
+ # COS_LENS_GIST_ENGINE=claude
261
+ # COS_LENS_GIST_MODEL=sonnet
262
+ #
263
+ # Or a chain, asked in order when one fails, cools down or reports a usage limit.
264
+ # Each engine in it sees the reply. COS_LENS_GIST_MODEL does not apply to a chain.
265
+ # COS_LENS_GIST_ENGINE=claude:sonnet,codex:gpt-5.6-terra
266
+ #
267
+ # Answers per local day (attempts stop at twice this). Default 150.
268
+ # COS_LENS_GIST_DAILY_CAP=150
269
+
255
270
 
256
271
  # ── RETAINED MEETING-AUDIO PLAYBACK CLEANUP (6.21.32 canary) ────────────
257
272
  # Default OFF. When enabled, the first authenticated Play of a retained raw
package/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ ## 6.54.0
2
+
3
+ ### The summary behind the G2 prompt box, from the engine you choose
4
+
5
+ COS Glasses 6.9.545 floats your words over a dimmed card of the session or message you are replying to. The server now writes that card: `Outcome` (`Answer` for a message), `So what` and `You asked`, from a reply the phone already holds. The engine is swappable the way the COS indexer's is: Claude, Codex, Cursor or a local Ollama model.
6
+
7
+ - **`POST /api/lens-gist`** `{kind, ask, reply}` answers `ready` with the three lines, or `off`, `unavailable` (`cap`, `breaker`, `busy`, `retry-later`, `maintenance`) or `failed`, always as a 200 with a status; only a malformed request is a 400. **`GET /api/lens-gist/config`** lists the engines, whether each is installed, and the models it can run; **`PUT`** saves `{engine, model, dailyCap}` to `<data>/lens-gist.json`, which survives Update Server.
8
+ - **The engine:** `COS_LENS_GIST_ENGINE` and `COS_LENS_GIST_MODEL`, then the saved choice, then Claude Sonnet when the Claude CLI is installed (off otherwise). One engine never falls back to another; a mistyped one, or a saved file that cannot be read, is off.
9
+ - **A chain, for resilience:** `claude:sonnet,codex:gpt-5.6-terra` (env, or `PUT {"chain": ...}`) asks the next engine when one fails, is cooling down, or reports a spent session or usage limit, which opens that engine's breaker at once. A limit is read only from the provider's own error (the same pattern as the provider proof, including Claude Code's "You've hit your session limit", which it prints on stdout), never from the words of an answer. The chain shares one 70 s budget, and no engine starts on its last 3 s. A refusal from every engine reads as `breaker` only when every engine's breaker is open, so one engine's hiccup never pauses the phone. Opt-in only: a chain sends the reply to every provider in it; `PUT` takes either `engine` or `chain`, and a saved `off` wins. An answer any engine wrote is reused for the whole chain. Tested live: with Claude failing fast (a model it does not have), Codex Terra answered in 4.9 s end to end.
10
+ - **Measured with the shipped prompt** on real replies: Claude Sonnet 2 to 5 s on about 1.2k tokens; Codex `gpt-5.6-terra` (the Codex default, the peer of Sonnet) 3.2 to 3.7 s on about 11.4k, `gpt-6-luna` and `gpt-6-sol` about 3 s on about 12k (Codex's own harness); Cursor `grok-4.7-low-fast` and `composer-2.5-fast` 11 to 13 s on about 11k to 17k; Ollama `qwen3.8:27b` 14 s cold. Claude Haiku timed out at 30 s twice.
11
+ - **The reply is untrusted text.** Claude runs with no tools and every hook disabled; Codex with its shell, exec, image, agent, browser, computer, app and plugin tools disabled, web search off, no user config and no rules files; Cursor in ask mode (read-only tools) in an empty workspace; Ollama as a bare completion. Each call gets a private empty workspace, removed after it, and no server token in its environment. Text inside the exchange cannot close its own tag. Keys and tokens (OpenAI, GitHub, Slack, AWS, HubSpot, JWT, private keys and the server's own token) are redacted from cards, logs and the ledger.
12
+ - **How it stops:** answers cached on disk per reply, engine and model; a daily cap of 150 answers (`COS_LENS_GIST_DAILY_CAP` or the saved `dailyCap`, kept when a save does not mention it) and twice that in attempts; a failed reply refused for 10 minutes, then 6 hours (a spent provider limit does not count against it); a breaker per engine (3 failures in a row or one limit answer, 30 minutes, then one trial call); at most 2 running and 4 waiting, with the cap, breaker and drain checked again before each engine. The route sits outside the request lease, so a slow model never holds up Update Server, and it answers `maintenance` during a drain.
13
+ - **Visible:** a `g2-lens-gist` row in the token audit for every call, `<data>/lens-gist-runs.jsonl` (with `fallbackFrom` and `limit` when they apply), and `lens_gist` on `/api/health` (engine, model, calls today, cap, open breakers; no error text and no chain on the unauthenticated slice). Error text is redacted before it is cut, so a cut cannot split a token past the redaction.
14
+ - Stated limits: a session reply reaches the phone cut to its first 4,000 characters, so a long reply's ending is not summarized. Cursor's ask mode keeps read-only tools; the CLI has no switch to remove them. COS Control 0.5.239 drops a `COS_LENS_GIST_*` value set only in the plist; use `~/.cos-glasses/.env` or `PUT /api/lens-gist/config`.
15
+ - Checks: three /qa rounds (four reviewers; one per repo on the fixes; two on the chain before release). The limit reading is tested against real processes printing Claude Code's own failure shapes. 50 mutation cases, all killed from a green baseline. Full suite 5,214 passed, 2 skipped (343 files); typecheck clean; `npm audit --omit=dev` 0 vulnerabilities.
16
+
17
+ ## 6.53.4
18
+
19
+ ### The fixes from 6.53.3's review
20
+
21
+ 6.53.3 was published before its four-reviewer /qa round finished (the round was cut short by a usage limit and rerun the next morning). This release carries that round's server fixes, and corrects three claims 6.53.3 made. Install hooks once after updating.
22
+
23
+ - **A `ps` that fails never releases a fence (blocker in 6.53.3).** 6.53.3's fence auto-release read every `execFileSync` error as "not running", so a `ps` killed at its 2 s timeout, an `EAGAIN` fork failure or a missing binary could release a cancel fence whose child might still be writing. Only the measured "no such process" answer (exit 1, no signal, not killed, no error code, nothing on stdout or stderr) counts as gone now; everything else, and a clean exit that printed nothing, is unverifiable and keeps the fence. `ps` is `/bin/ps` when present. A fence that reads unknown is re-checked on every fence check and can still be released by hand in COS Control.
24
+ - **The halt re-arm matches the delivered prompt exactly.** 6.53.3 matched the first 120 characters with `includes`, so a short Continue ("yes") matched the person's own next desk prompt: their run was stopped "Cancelled from COS" and the cancelled COS turn then ran. Now the words must be equal (whitespace collapsed, Claude Code's peer-message wrapper taken off), and the first prompt after the hand-off decides: a different prompt spends the re-arm and is logged. Stated limit: if the person's own prompt reaches the session first, the queued COS turn is not stopped; an identical desk prompt typed after the send and taken first still re-arms. An unverified hand-off holds its re-arm 2 minutes, not an hour.
25
+ - **The hook drains stdin on every event.** PermissionRequest, Stop, PostToolUse, SessionStart, SessionEnd and the other non-front events read only 1 MiB and left the rest unread, and the spool guard exited before reading; a larger payload could EPIPE the writer. The header comment no longer says the PreToolUse branch writes no spool. Script sha256 `df54677f...`; the 6.53.0 script (`1158bb06...`) and 6.53.3's (`c0b41bf8...`) still stop desk runs until Install hooks is run. The PreToolUse common path is unchanged (40 runs each, interleaved: 11.2 and 10.9 ms before, 11.4 and 11.0 ms after).
26
+ - **The mutation gate needs a failing test.** A kill was any non-zero vitest exit; now a mutant is killed only when a named test fails, and a run that errors without one fails the gate. Every 6.53.x case was re-run under that rule on a green baseline: 137 of 137 killed by a failing test (16 added in this round), none errored.
27
+ - **Every server suite removes its temp folders.** 39 test files made `mkdtemp` folders and never removed them (one run left 581 entries in `$TMPDIR`; now none made by a test). 6.53.3's "hook and installer suites clean up their temp roots" was true only for three suites.
28
+ - **Corrections to 6.53.3's notes.** "`reaped: true` is never reported while an owned process survives" holds for cancel and timeout only: a natural exit settles as soon as the CLI is reaped, with no tree probe. The graceful end-to-end bound is now derived from the fixture's stop delay and `TREE_POLL_MS` (2.4 s, was 4 s). The closed-session test imports its retention constant. README gains a "Cancel a run from the glasses (6.53.x)" section.
29
+ - Tests: full suite 5,128 passed, 2 skipped; typecheck clean; `npm audit --omit=dev` 0 vulnerabilities.
30
+
1
31
  ## 6.53.3
2
32
 
3
33
  ### Cancel owns the whole process tree, desk cancels hold, and the hook is faster
package/README.md CHANGED
@@ -265,6 +265,95 @@ Allow once or deny only: no permission rule is ever written. Rows carry
265
265
  row reads `waiting_kind: question` whatever the switch says; `/api/health` reports
266
266
  `permissionBroker` (counters and timestamps, no ids or text).
267
267
 
268
+ ## Cancel a run from the glasses (6.53.x)
269
+
270
+ The glasses' Cancel run row stops what a session is doing, the way the desk's own stop
271
+ button does. It needs **server 6.53.0 or later and COS Glasses 6.9.529 or later**;
272
+ **6.53.4 is recommended** (it owns the whole process tree, holds a desk cancel, fixes
273
+ the false fences 6.53.1 left, and never releases a fence when `ps` cannot answer).
274
+
275
+ - **A turn COS started** (`cos_turn`) is stopped by the server: its whole process tree is
276
+ signalled and the turn settles `turn_cancelled`.
277
+ - **A run at the desk** (`desk_run`: a Desktop tab or a terminal `claude`) is stopped by the
278
+ session hook at its next tool call, so a reply that is pure text finishes first. This
279
+ needs the hooks installed, and after every server update that changes the hook script
280
+ (6.53.0, 6.53.3, 6.53.4) you must **run Install hooks once** (COS Control, or
281
+ `npx --yes @gotcos/glasses-server@latest --hooks install`). Until then
282
+ `--hooks status` reads `script_outdated` and its `advice` line says so; a script from
283
+ 6.53.0 to 6.53.3 can still stop desk runs meanwhile, anything older answers
284
+ `hooks_outdated`. The PreToolUse hook runs before every tool call on the Mac and never
285
+ contacts the server; it writes a spool file only for AskUserQuestion, ExitPlanMode and
286
+ prompts, and always drains its input.
287
+ - **Codex and Cursor** runs at the desk answer `cancel_unsupported` ("Stop it there").
288
+ - **Fences a cancel left release themselves** once every process the cancelled turn
289
+ started is confirmed gone: only `ps` answering "no such process" counts, and a `ps` that
290
+ times out, cannot start or is missing keeps the fence. Any other fence is still released
291
+ by hand (`GET /api/agent-sessions/fences`, then `POST .../fences/release` with
292
+ `confirm: true`).
293
+ - **A cancel that lands while COS is handing a turn to the open session** writes the desk
294
+ marker, and re-arms it once if the next prompt that session starts is exactly the
295
+ delivered one. If your own prompt reaches the session first, the re-arm is dropped (and
296
+ logged) rather than stop your run.
297
+
298
+ Meetings: a closed meeting's record ages out after **4 hours**, after which its status
299
+ reads `missing` and the phone stops asking to save it. Saving a known meeting that has no
300
+ transcript answers **404 `nothing_to_save`** (with its state); only an unknown id answers
301
+ `session_not_found`.
302
+
303
+ ## The summary behind the prompt box (6.54.0)
304
+
305
+ When you hold the ring on a finished session or message, COS Glasses 6.9.545 floats your
306
+ live words over a dimmed card of what you are replying to. From 6.54.0 the server writes
307
+ that card: `Outcome` (`Answer` for a message), `So what` and `You asked`, one lens row
308
+ each, instead of the reply's first lines. Older phones and older servers keep the
309
+ 6.9.544 card.
310
+
311
+ **Pick the engine** the way the COS indexer does: `COS_LENS_GIST_ENGINE` wins, then the
312
+ choice saved with `PUT /api/lens-gist/config`, then the default. One engine never falls back
313
+ to another; `off` turns the summary off.
314
+
315
+ **Or chain them, for resilience.** `claude:sonnet,codex:gpt-5.6-terra` asks Claude first and
316
+ Codex when Claude fails, is cooling down, or says its session or usage limit is spent (a
317
+ limit, read from the provider's own error and never from an answer, skips that engine for 30
318
+ minutes at once). Running low on one plan no longer turns the summary off. A chain gets 70
319
+ seconds in all, shared in order. It is opted into, never the default: it sends a session's
320
+ reply to every provider in it, and an installed CLI is not consent (ChatGPT.app ships
321
+ `codex`). `PUT` takes either `engine` or `chain`, never both, and a saved `off` wins.
322
+
323
+ | Engine | Default model | Measured (2026-09-25, one call) | Tools it keeps |
324
+ |---|---|---|---|
325
+ | `claude` (default) | `sonnet` | 2 to 5 s, about 1.2k tokens | none (`--tools ""`, hooks off) |
326
+ | `codex` | `gpt-5.6-terra` (`gpt-6-luna`, `gpt-6-sol` work as well) | 3.2 to 3.7 s, about 11.4k tokens | shell, browser, image, agent, app and plugin tools off; web search off |
327
+ | `cursor` | `grok-4.7-low-fast` | 11 to 13 s, about 11k to 17k tokens | ask mode: read-only tools in an empty workspace |
328
+ | `ollama` | the local model lens queries use | 2.5 s warm, 14 s cold | none |
329
+
330
+ With nothing chosen and no Claude CLI installed, the summary is off rather than sent to a
331
+ provider nobody picked. Claude `haiku` timed out at 30 s in testing and is not a default.
332
+
333
+ ```bash
334
+ curl -s -X PUT -H "X-COS-Token: $COS_API_TOKEN" -H 'content-type: application/json' \
335
+ -d '{"chain":"claude:sonnet,codex:gpt-5.6-terra","dailyCap":150}' http://127.0.0.1:3141/api/lens-gist/config
336
+ curl -s -X PUT -H "X-COS-Token: $COS_API_TOKEN" -H 'content-type: application/json' \
337
+ -d '{"engine":"codex","model":"gpt-6-sol"}' http://127.0.0.1:3141/api/lens-gist/config
338
+ curl -s -H "X-COS-Token: $COS_API_TOKEN" http://127.0.0.1:3141/api/lens-gist/config
339
+ ```
340
+
341
+ `GET /api/lens-gist/config` lists every engine, whether it is installed, and the models it
342
+ can run here. The saved choice lives in `<data>/lens-gist.json` and survives Update
343
+ Server; a file that cannot be read turns the summary off until it is saved again.
344
+
345
+ **How it stops.** The phone asks once per finished reply it has open (a session that is
346
+ still running or waiting on an approval asks for nothing). Answers are cached on disk per
347
+ engine and model. A daily cap (`COS_LENS_GIST_DAILY_CAP` or the saved `dailyCap`, default
348
+ 150) counts answers, and attempts stop at twice that. A reply that failed is refused for 10
349
+ minutes, then 6 hours (a spent provider limit does not count against the reply). Three
350
+ failures in a row, or one limit answer, pause that engine for 30 minutes, then one trial call
351
+ runs. At most 2 calls run at once with 4 waiting. A drain for Update Server stops queued
352
+ calls before each engine. Every call writes a `g2-lens-gist` row to the token audit and a line
353
+ to `<data>/lens-gist-runs.jsonl` (with `fallbackFrom` when a later engine answered).
354
+ `/api/health` shows `lens_gist` (engine, model, calls today, cap, open breakers; no error text
355
+ and no chain); `GET /api/lens-gist/config` shows the chain.
356
+
268
357
  ## Configuration
269
358
 
270
359
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
@@ -626,7 +715,8 @@ BIND_HOST=0.0.0.0 npm run start:server
626
715
  timers are frozen), `desk_active` (the Mac saw input), `approvals_off`
627
716
  (`COS_PERMISSION_BROKER=questions`), `broker_off`, `cursor`, `unsupported_tool`
628
717
  (ExitPlanMode is never held). `lastQuestionsPollAt` says when a client last counted.
629
- `--hooks status` must read `installed`. To turn it off with no reinstall, set
718
+ `--hooks status` must read `installed` (right after updating to 6.53.4 it reads
719
+ `script_outdated` until Install hooks is run once; its `advice` line says so). To turn it off with no reinstall, set
630
720
  `COS_PERMISSION_BROKER=0` in `~/.cos-glasses/.env` and restart. For the Messages
631
721
  trail, `/api/health` `messages_trail.readerErrors` counts lines a reader could not map.
632
722
  - *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
@@ -7,11 +7,15 @@
7
7
  # restarting never delays a Claude session.
8
8
  #
9
9
  # 6.53.0: PreToolUse is SYNCHRONOUS with no matcher, so the first branch below runs before
10
- # EVERY tool call in every Claude session on this Mac. It is file-only (no curl, no spool
11
- # write) and prints nothing unless the server left a halt marker for this session, when it
12
- # prints the one reply that stops the run (canary C1b, 2026-09-21: `continue:false` alone
13
- # still ran the tool; with the deny it did not). It never exits 2: exit 2 blocks the tool,
14
- # while a timeout or any other code fails open (C6, C7).
10
+ # EVERY tool call in every Claude session on this Mac. It never contacts the server. For an
11
+ # ordinary tool it writes nothing at all; it spools only AskUserQuestion and ExitPlanMode
12
+ # (the two tools the reducer reads, the 6.48 matcher's set), and UserPromptSubmit, which
13
+ # shares the branch, is always spooled. It prints nothing unless the server left a halt
14
+ # marker for this session, when it prints the one reply that stops the run (canary C1b,
15
+ # 2026-09-21: `continue:false` alone still ran the tool; with the deny it did not). It never
16
+ # exits 2: exit 2 blocks the tool, while a timeout or any other code fails open (C6, C7).
17
+ # Every path, every event, drains stdin before it exits, so Claude Code's writer never
18
+ # EPIPEs whatever the payload's size (6.53.3; every other event since its /qa round).
15
19
  #
16
20
  # The installer bakes COS_GLASSES_HOME and COS_HOOK_SPOOL into the command, so this
17
21
  # script and the server can never disagree about where the token or the spool is.
@@ -89,6 +93,9 @@ case "$EVENT" in PreToolUse|UserPromptSubmit)
89
93
  trap 'cat >/dev/null 2>&1' EXIT
90
94
  ;;
91
95
  esac
96
+ # Every other event: until the spool read below has taken stdin, any exit (a failed mkdir,
97
+ # the B8 guard) drains it, as the front does.
98
+ [ -n "$FRONT" ] || trap 'cat >/dev/null 2>&1' EXIT
92
99
  mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
93
100
  TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
94
101
  # B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
@@ -97,13 +104,15 @@ if [ "$EVENT" != PermissionRequest ]; then
97
104
  STAMP="$SPOOL/.last-drain"; [ ! -f "$STAMP" ] || [ -z "$(find "$STAMP" -mmin +1440 2>/dev/null)" ] || exit 0 # -mtime +1 would mean two days
98
105
  fi
99
106
  T="$SPOOL/.tmp.$$.$TS"
107
+ # Stdin to disk with a 1 MiB cap; whatever lies past the cap is drained, never left for the
108
+ # writer to EPIPE on.
109
+ trap - EXIT
100
110
  if [ -n "$FRONT" ]; then
101
- # 6.53.3: the 4 KiB the halt check read, then the rest of stdin, to disk with the same
102
- # 1 MiB cap; whatever lies past the cap is drained, never left for the writer to EPIPE on.
103
- trap - EXIT
111
+ # 6.53.3: the 4 KiB the halt check read, then the rest of stdin.
104
112
  { printf '%s' "$HEAD"; exec cat; } | { head -c 1048576 > "$T.in"; exec cat >/dev/null; }
105
113
  else
106
- head -c 1048576 > "$T.in" # stdin to disk, 1 MiB cap
114
+ # No `exec` here: this group runs in the script's own shell.
115
+ { head -c 1048576 > "$T.in"; cat >/dev/null; }
107
116
  fi
108
117
  case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
109
118
  { printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
@@ -34,7 +34,10 @@
34
34
  "COS_SESSION_HOOK_SSE",
35
35
  "COS_PERMISSION_BROKER",
36
36
  "COS_PERMISSION_BROKER_DESK_IDLE_S",
37
- "COS_PERMISSION_BROKER_TIMEOUT_S"
37
+ "COS_PERMISSION_BROKER_TIMEOUT_S",
38
+ "COS_LENS_GIST_ENGINE",
39
+ "COS_LENS_GIST_MODEL",
40
+ "COS_LENS_GIST_DAILY_CAP"
38
41
  ],
39
42
  "maintenance": {
40
43
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.53.3",
3
+ "version": "6.54.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -92,6 +92,7 @@ import { glossaryRouter } from './routes/glossary.js'
92
92
  import { handoffsRouter } from './routes/handoffs.js'
93
93
  import { recoveryRouter } from './routes/recovery.js'
94
94
  import { promptEditRouter } from './routes/prompt-edit.js'
95
+ import { lensGistRouter } from './routes/lens-gist.js'
95
96
  import { bookmarksRouter } from './routes/bookmarks.js'
96
97
  import { welcomeContextRouter } from './routes/welcome-context.js'
97
98
  import { liveCuesRouter } from './routes/live-cues.js'
@@ -311,6 +312,11 @@ app.use('/api', (req, res, next) => {
311
312
  || req.path.startsWith('/meeting-actions/')
312
313
  || req.path === '/meeting-engine/mode'
313
314
  || req.path.startsWith('/prompt-drafts')
315
+ // 6.54.0: a gist can wait behind two others and then run a chain for up to 70 s
316
+ // (LENS_GIST_CHAIN_BUDGET_MS), which could keep Control's 90 s drain waiting if it held
317
+ // the request lease. It refuses on its own while admissions are closed, and a queued
318
+ // chain checks again before each engine (routes/lens-gist.ts, lib/lens-gist.ts).
319
+ || req.path === '/lens-gist'
314
320
  || req.path.startsWith('/maintenance/drain')
315
321
  if (lifecycleOwned) return next()
316
322
 
@@ -889,6 +895,7 @@ app.use('/api', glossaryRouter)
889
895
  app.use('/api', handoffsRouter)
890
896
  app.use('/api', recoveryRouter)
891
897
  app.use('/api', promptEditRouter)
898
+ app.use('/api', lensGistRouter)
892
899
  app.use('/api', bookmarksRouter)
893
900
  app.use('/api', welcomeContextRouter)
894
901
  app.use('/api', liveCuesRouter)
@@ -186,6 +186,14 @@ export interface AttachedTurnFailureResult {
186
186
  /**
187
187
  * Did `close`/`exit` actually fire, i.e. did the kernel reap the child?
188
188
  *
189
+ * WHAT IT DOES AND DOES NOT PROMISE (6.53.3, scoped by its /qa round). On a CANCEL or a
190
+ * TIMEOUT, `reaped: true` also means the whole owned process tree was read gone after
191
+ * `close` (the tree poll); a tree that outlives SIGKILL settles `reaped: false`. On a
192
+ * NATURAL exit (the provider closed without a cancel or timeout) it means only that the
193
+ * CLI itself was reaped: that path settles at once, with no tree probe, so a detached
194
+ * background job the tool left running is not looked for. A pre-spawn cancel reports
195
+ * `reaped: true` because no process ever existed.
196
+ *
189
197
  * NOT DERIVABLE FROM `exitCode`. A child killed by a signal reports
190
198
  * `code === null`, and the handlers below only assign `exitCode` for a numeric
191
199
  * code — so the dominant timeout shape (SIGTERM, then SIGKILL) is reaped while
@@ -285,15 +285,21 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
285
285
  }
286
286
 
287
287
  /**
288
- * 6.53.3: sha256 of every EARLIER script that already carries the halt check: the one script
289
- * 6.53.0, 6.53.1 and 6.53.2 all shipped. 6.53.3 rewrote the script for speed (review A,
290
- * option a), so an install that has not run Install hooks since reads `script_outdated`
291
- * and Control shows its banner. Its script still stops a desk run exactly as before,
292
- * though, so a desk cancel is not refused `hooks_outdated` in the meantime
293
- * (`hookHaltReady`). A script older than 6.53.0 has no halt check and never qualifies.
288
+ * 6.53.3: sha256 of every EARLIER script that already carries the halt check. 6.53.3
289
+ * rewrote the script for speed (review A, option a), so an install that has not run Install
290
+ * hooks since reads `script_outdated` and Control shows its banner. Those scripts still stop
291
+ * a desk run exactly as before, though, so a desk cancel is not refused `hooks_outdated` in
292
+ * the meantime (`hookHaltReady`). A script older than 6.53.0 has no halt check and never
293
+ * qualifies.
294
+ * - 1158bb06...: the one script 6.53.0, 6.53.1 and 6.53.2 all shipped.
295
+ * - c0b41bf8...: the 6.53.3 script as first built, before its /qa round made every event
296
+ * drain stdin. Never published, but installable from a local build of that commit
297
+ * (7433b7f), and it carries the same halt check.
298
+ * Each is pinned against its bytes in `server/lib/__fixtures__/`.
294
299
  */
295
300
  export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
296
301
  '1158bb06297550128f01fc47caa68806a5287d6da2d05b0d1c812e8ae20764e7',
302
+ 'c0b41bf8581cfea498e1e1ac5220fe4e148bd97448d13b60a88879ee5992cc51',
297
303
  ]
298
304
 
299
305
  /**
@@ -27,6 +27,9 @@
27
27
  // human deciding whether to release; an over-confident `none_running` is worse than
28
28
  // admitting the probe could not see.
29
29
 
30
+ import { execFileSync } from 'node:child_process'
31
+ import { existsSync } from 'node:fs'
32
+
30
33
  /** Recorded spawn: a pid and the start COS measured when it created the child. */
31
34
  export interface RecordedSpawn {
32
35
  pid: number
@@ -55,7 +58,7 @@ export interface FenceLiveness {
55
58
  }
56
59
 
57
60
  export interface FenceLivenessDeps {
58
- /** Epoch ms at which `pid` started, or null when it is not running.
61
+ /** Epoch ms at which `pid` started, or null ONLY when it is provably not running.
59
62
  * MAY THROW; a throwing probe is not an answer and resolves to `unknown`. */
60
63
  pidStartMs: (pid: number) => number | null
61
64
  }
@@ -116,7 +119,9 @@ export function makePidStartProbe(
116
119
  const raw = run(pid)
117
120
  if (raw === null) return null
118
121
  const text = raw.trim()
119
- if (!text) return null
122
+ // A clean exit that printed nothing is not "not running" (only the runner's null is):
123
+ // throw, so it is counted unverifiable.
124
+ if (!text) throw new Error('empty process start from ps')
120
125
  const parsed = Date.parse(text)
121
126
  // Unparseable is NOT "not running" -- throw so the caller counts it
122
127
  // unverifiable rather than silently reading a live child as gone.
@@ -124,3 +129,72 @@ export function makePidStartProbe(
124
129
  return parsed
125
130
  }
126
131
  }
132
+
133
+ /** What this module reads off the error `execFileSync` throws. */
134
+ export interface ExecFailureLike {
135
+ status?: unknown
136
+ signal?: unknown
137
+ killed?: unknown
138
+ code?: unknown
139
+ error?: unknown
140
+ stdout?: unknown
141
+ stderr?: unknown
142
+ }
143
+
144
+ /** The slice of `execFileSync` the probe calls. Injectable so a test can throw every error
145
+ * shape Node produces without spawning anything. */
146
+ export type PsExec = (file: string, args: readonly string[], options: { encoding: 'utf8'; timeout: number }) => string
147
+
148
+ /** How long one `ps` may take before Node kills it (the kill is NOT an answer). */
149
+ export const PS_PROBE_TIMEOUT_MS = 2_000
150
+
151
+ const PS_BIN = existsSync('/bin/ps') ? '/bin/ps' : 'ps'
152
+
153
+ const emptyOutput = (value: unknown): boolean =>
154
+ (typeof value === 'string' && value.trim() === '') || (Buffer.isBuffer(value) && value.length === 0)
155
+
156
+ /**
157
+ * 6.53.3 /qa BLOCKER: is this `execFileSync` failure `ps -p` saying "no such process"?
158
+ *
159
+ * THE ONE ANSWER THAT MEANS GONE. `ps -p <pid>` for a pid that is not running exits 1 on its
160
+ * own and prints nothing on either stream (measured, macOS 26 and node 24: `{status: 1,
161
+ * signal: null, stdout: '', stderr: ''}` with no `code` and no `error`). Everything else is
162
+ * ps failing to answer, and says nothing about the child: a kill at the timeout (`ETIMEDOUT`,
163
+ * SIGTERM), a fork that failed (`EAGAIN`), no binary (`ENOENT`), a signal, output on stdout or
164
+ * a complaint on stderr, any other status. Until 6.53.3's QA every one of those read as gone,
165
+ * and gone is `none_running`, which releases a cancel fence (`releaseSettledCancelFences`) on a
166
+ * child that may still be writing.
167
+ */
168
+ export function psSaysNoSuchProcess(error: unknown): boolean {
169
+ if (error === null || typeof error !== 'object') return false
170
+ const e = error as ExecFailureLike
171
+ return e.status === 1
172
+ && (e.signal === null || e.signal === undefined)
173
+ && e.killed !== true
174
+ && (e.code === null || e.code === undefined)
175
+ && (e.error === null || e.error === undefined)
176
+ && emptyOutput(e.stdout)
177
+ && emptyOutput(e.stderr)
178
+ }
179
+
180
+ /**
181
+ * The production runner: `ps -p <pid> -o lstart=`'s stdout, or null ONLY when ps said the
182
+ * process does not exist (`psSaysNoSuchProcess`). Any other failure THROWS, which
183
+ * `fenceLiveness` counts unverifiable, so the fence stays.
184
+ */
185
+ export function psLstartRunner(exec: PsExec = execFileSync as unknown as PsExec): (pid: number) => string | null {
186
+ return (pid: number) => {
187
+ try {
188
+ return exec(PS_BIN, ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: PS_PROBE_TIMEOUT_MS })
189
+ } catch (error) {
190
+ if (psSaysNoSuchProcess(error)) return null
191
+ const e = (error ?? {}) as ExecFailureLike
192
+ throw new Error(`ps could not answer for pid ${pid} (status=${String(e.status ?? null)} signal=${String(e.signal ?? null)} code=${String(e.code ?? 'none')})`)
193
+ }
194
+ }
195
+ }
196
+
197
+ /** The probe the fence routes use in production. */
198
+ export function defaultPidStartProbe(exec?: PsExec): (pid: number) => number | null {
199
+ return makePidStartProbe(psLstartRunner(exec))
200
+ }