@gotcos/glasses-server 6.47.0 → 6.48.1
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 +26 -0
- package/README.md +35 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +3 -1
- package/server/index.ts +78 -6
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +44 -0
- package/server/lib/occupancy-probes.ts +43 -0
- package/server/lib/session-hook-events.ts +205 -0
- package/server/lib/session-hook-ledger.ts +133 -0
- package/server/lib/session-hook-spool.ts +272 -0
- package/server/lib/session-hooks-runtime.ts +338 -0
- package/server/lib/session-signal-store.ts +389 -0
- package/server/lib/session-state-derive.ts +249 -0
- package/server/lib/session-stream-bus.ts +93 -9
- package/server/lib/session-stream-events.ts +144 -0
- package/server/lib/thread-drain-kick.ts +153 -0
- package/server/lib/thread-occupancy.ts +36 -1
- package/server/lib/thread-turn-queue-deliver.ts +17 -6
- package/server/lib/thread-turn-queue-store.ts +70 -23
- package/server/lib/thread-turn-queue.ts +19 -3
- package/server/routes/agent-session-stream.ts +169 -11
- package/server/routes/agent-sessions.ts +88 -9
- package/server/routes/claude-sessions.ts +42 -21
- package/server/routes/health.ts +2 -0
- package/server/routes/session-hooks.ts +80 -0
- package/server/routes/thread-turn-queue.ts +15 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
## 6.48.1
|
|
2
|
+
|
|
3
|
+
One derivation everywhere, a Desktop turn ends when the engine says it ended, a queued follow-up drains then instead of on the 30 s idle window, and the live feed carries the same state every row does. No client change is required: the next Control and EHPK builds read the new keys, and today's consumers ignore them.
|
|
4
|
+
|
|
5
|
+
- A queued follow-up is delivered when the turn ends, and "ends" means what the engine means. Claude Code does not close a turn at the `Stop` hook: it closes it when every Stop hook has RETURNED, and only then dequeues anything typed meanwhile and flips its registry record to `idle`. On the release Mac the COS repo's own Stop hooks run 12-38 s, so a delivery at Stop + 2 s would have raced a prompt typed at the desk. The drain now waits for the registry's `idle` stamped at or after the Stop (polled every second, 60 s at most), then sweeps through one in-flight guard shared with the 20 s timer, so a burst of Stops folds into exactly one follow-up pass and a sentence is never delivered twice. A kick is spent only when a queue names that session; a COS child's own end kicks at once so chained follow-ups do not wait for the timer. Measured on the release Mac against a live holder whose own Stop hook ran 25 s: the registry flipped idle 25.0 s after the Stop hook, the follow-up was delivered 185 ms after that flip (its user row landed 1.5 s later, once, answered once, in the holder's own transcript, with the holder still open). Before this release: 35 s or more from the turn's end, and a delivery during the Stop hooks was possible.
|
|
6
|
+
- The attach gate reads the same end of turn. With `COS_SESSION_HOOKS` on, a foreign Desktop holder whose newest hook event is a `Stop`, with the turn closed, no sub-agent open, and the registry idle at or after that Stop, reads idle at once instead of after the 30 s window. Null, unknown, a record that says busy, Codex and a far-future clock stay strict; the clause narrows a `working` verdict and never lifts a doubt. A COS Continue child registers under the tab's session id: when two records name the session the tab's is read, the child's only when it is alone.
|
|
7
|
+
- The transcript's own rule for "the turn ended", used where the hooks did not see the session: the newest assistant record's `stop_reason` is terminal (end_turn, stop_sequence, max_tokens, refusal) with no `tool_use` still awaiting its result, a `result` row, or a user interrupt; a new prompt reopens it, a `<task-notification>` and a cross-session message included; a prompt queued during the Stop hooks (Claude's `queue-operation` row) keeps it open; other meta and compact rows do not. The tail read is 512 KiB: 3 of the 10 newest Desktop transcripts on the release Mac end in a ~76 KB attachment row after the terminal record, which a 64 KiB read could not see past. Positive evidence that a turn is OPEN (the hooks, or the tail) now holds ahead of the tail's ended verdict and ahead of the idle backstop, each bounded to the last 30 minutes so a crashed session still drains.
|
|
8
|
+
- Every `status` draft on the session stream carries `agent_state`, `state_source`, `state_since` and, when known, `waiting_kind`, `waiting_detail`, `failure`, and on an idle state `last_reply`, as extra fields on the existing kind; `state` keeps its vocabulary (running and waiting read `working`, idle and failed `idle`, ended `done`). Every change of a session's hook state is a live status draft, so a client's state line moves on the engine's events. The stream derives from the same inputs the rows do (the hook signal, the registry record, and whether a COS turn is attached), so an Esc-interrupted turn reads idle on the lens the moment the list says so, and a Continue's trail reads `working` for as long as the child writes, never `done` from a stale Stop or from the child's own end. A shipped lens renders the frames exactly as before (its parser keeps `seq` and strips unknown fields). `COS_SESSION_HOOK_SSE=0` omits the extra fields and the live state drafts.
|
|
9
|
+
- Reconnect without a reseed: each streamed session keeps a ring of its last 500 events (the active turn kept whole up to 2,000, the ring outliving the last subscriber by five minutes), each event carries a `cursor` and the server's `epoch`, and the SSE `id:` line is `<epoch>.<cursor>`. `?after=` (or `Last-Event-ID`) replays what the ring holds after that cursor, from the same server life only; a cursor from another life, one past the ring, and one with nothing after it all seed as a fresh open, so a reconnect is never an empty screen. `?seed=turn` opens a session at its current prompt and every step since, oldest first, bounded by the same 512 KiB tail read. The default seed is unchanged.
|
|
10
|
+
- `queued_turns` on every `/api/agent-sessions` row (list, search, detail) and every `/api/claude-sessions` peer: the waiting follow-ups on that thread, omitted at 0. Search hits carry the same derived state fields the list does.
|
|
11
|
+
- Two 6.48.0 defects: a tab adopted mid-turn read idle from a `PostToolUse` with no prompt seen (Claude 2.1.272 reloads hooks when the settings change, so open tabs report without a restart); a main-thread tool event now opens the turn, and a sub-agent's (`agent_id`) does not, live or replayed. `GET /api/session-hooks/runs` listed an interactive tab as a run with no end; runs carry the registry `entrypoint` (`sdk-cli` for `claude -p`, `claude-desktop`, `cli`), the ledger row remembers it so a restart cannot forget which sessions were tabs, and tabs are listed only with `?all=1`.
|
|
12
|
+
- Two defects the live proof found in the follow-up path, both older than 6.48.0. The drainer's loopback delivery sent a turn body without `epoch` and `targetKey`, which the turns route has required since the binding hardening, so every drained follow-up was refused `invalid_request`, refunded and held for its whole TTL: it now sends the epoch the attach minted, the server's own target key and the attach's `boundTo`. And the 30 s idle backstop delivered a follow-up INTO a turn whose tool was still running (a 45 s tool leaves the transcript untouched for 45 s): the open-turn evidence above holds it. A queued turn whose `cosSessionId` the attach route would refuse is refused at enqueue instead of sitting in the queue.
|
|
13
|
+
- `/api/health` `sessionHooks.drain` reports the kick's passes, kicks, folds and open waits.
|
|
14
|
+
|
|
15
|
+
## 6.48.0
|
|
16
|
+
|
|
17
|
+
Session state now comes from Claude Code itself. A hook script spools every session event (session start and end, prompt submitted, turn stopped, permission wanted) and every session row carries one derived state with its provenance. No client change is required: Control 0.5.230 and glasses 6.9.478 ignore the new keys; Control 0.5.231 and the next EHPK read them.
|
|
18
|
+
|
|
19
|
+
- `--hooks install|status|uninstall [--dry-run] [--port N]` writes fifteen hook subscriptions into `~/.claude/settings.json` (user level, so Desktop tabs, the CLI and `claude -p` jobs all report), preserving every existing hook block and every other key, with a backup of the file and an atomic 0600 write. The script is copied to the stable path `~/.cos-glasses/bin/cos-session-hook` so a server update never breaks it; `status` reports `script_outdated` when the package copy moved on and `install` re-copies. The same actions are on `POST /api/session-hooks/install` and `/uninstall` for COS Control. `status` also reports `disabled_by_settings` (the file carries `disableAllHooks`) and `settings_unreadable`, and `serverApplies` says whether the running server would read the spool into rows at all. A hooks install changes sessions started after it; tabs already open keep their startup snapshot until restarted. Removing the package does not remove the hooks: run `--hooks uninstall` first.
|
|
20
|
+
- The hook is a POSIX sh script that writes one file per event into `~/.cos-glasses/data/hook-spool` and exits 0 on every path; it never talks to the server, so a session is never delayed by a server restart. It stops spooling when the spool holds 2,000 files or the server has not drained for a day, except permission requests. Only `PermissionRequest` may contact the server, and only after the desk has been idle (`ioreg` HIDIdleTime, default 90 s), for the broker that ships in 6.48.2; on 6.48.0 that call is refused (401) and the hook prints nothing, so the native dialog shows as before. An unreadable idle time never contacts the server.
|
|
21
|
+
- The server ingests the spool (2 s sweep, `fs.watch` as a wake-up, bounded batches), keeps a projected ledger at `~/.cos-glasses/data/session-hook-events.jsonl` (10 MB rotation, replayed for the last 6 hours at boot), and reduces the events into one record per session. A pending permission clears only on evidence for that request (its own PostToolUse by fingerprint, a PermissionDenied naming the tool, Stop, a new prompt, SessionEnd; a parallel auto-allowed tool of the same name ending does not clear it); a Continue child that shares the tab's session id never changes the tab's state; a dead registry pid on two consecutive scans ends the row.
|
|
22
|
+
- Rows on `/api/agent-sessions` (list and detail) and peers on `/api/claude-sessions` gain `agent_state` (running, waiting, idle, failed, ended), `state_source` (hook, registry, transcript), `state_since`, and when known `waiting_kind` (permission, question, plan, mcp_input), `waiting_detail` (the tool and its target), `failure`, and `last_reply` (the turn's last assistant message, 400 chars; the ledger keeps the same 400 chars of each prompt). `pending_permission_id` is reserved for the 6.48.2 broker and never set here. `state` keeps its old meaning and values. Older clients ignore the new keys.
|
|
23
|
+
- `/api/health` gains `sessionHooks` (enabled, state, installed, scriptSha, tokenPresent, lastEventAt, spoolBacklog, spoolStuck, applyErrors, ledgerBytes, ledgerError and spoolError as codes, signals). A pending wait is also cleared by evidence the hooks cannot see: the registry moving on after it, the transcript moving on well after it, or nothing alive standing behind it for 30 minutes; an interrupted turn (no Stop fires) follows the registry to idle at once. `GET /api/session-hooks/runs?since=<ms>` lists the sessions the hooks saw start and end, for Control's scheduled-job ledger.
|
|
24
|
+
- Flags: `COS_SESSION_HOOKS` (default on whenever `COS_CLAUDE_SESSIONS_ENABLED=1`; off still drains and stamps the spool and every row is exactly as 6.47.0), `COS_SESSION_HOOKS_SPOOL_DIR` (baked into the installed hook command together with the token directory, so the script and the server always agree; reinstall after changing it, `status` reports `drift` until then), `managed-runtime-contract.json` lists all six, and the reserved `COS_SESSION_HOOK_SSE`, `COS_PERMISSION_BROKER`, `COS_PERMISSION_BROKER_DESK_IDLE_S`, `COS_PERMISSION_BROKER_TIMEOUT_S` for the next releases. The next Control (0.5.231) adds all six to its runtime environment allowlist; until then a hand-set flag is dropped by Control's next Update Server, which is the fail-safe direction.
|
|
25
|
+
- Fixtures under `server/lib/__fixtures__/session-hooks-6.48.0/` are real 2.1.272 recordings (paths redacted) and are excluded from the npm tarball. Recorded there: `SessionStart.source`, `SessionEnd.reason`, a `Stop` with no stop reason, a `PermissionRequest` with no `tool_use_id`, two permission hooks in one turn running one after the other, and a hook deny whose message reaches Claude with no `PermissionDenied` afterwards. Measured limit: an asynchronous hook that fires right before a print-mode process exits can be dropped by Claude (a `PostToolUse` in one recording, a `Stop` in another), so SessionStart, UserPromptSubmit, Stop and SessionEnd (the four events a row's state turns on) are installed synchronously with a 5 s timeout and were never dropped that way. The script itself is behaviour-tested under its own restricted PATH: spool naming, the 1 MiB cap, both B8 guards, the empty-token and closed-port paths, and a PermissionRequest round trip against a local listener.
|
|
26
|
+
|
|
1
27
|
## 6.47.0
|
|
2
28
|
|
|
3
29
|
Meetings from Fireflies join the library, and recordings of the same meeting merge into one richer record. Paired Control build: 0.5.230. Glasses 6.9.474 adds Ref on the lens and needs no server change.
|
package/README.md
CHANGED
|
@@ -165,6 +165,41 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
|
|
|
165
165
|
`COS_WEATHER_DEFAULT_*`, otherwise omits weather. Optional `nextEvent`
|
|
166
166
|
appears when `COS_SCRIPTS_DIR` calendar data is available.
|
|
167
167
|
|
|
168
|
+
## Session hooks (6.48.0)
|
|
169
|
+
|
|
170
|
+
Claude Code can tell the server what each session is doing (started, prompt submitted,
|
|
171
|
+
waiting on a permission, turn stopped, ended) through its own hooks. Install them once:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
npx --yes @gotcos/glasses-server@latest --hooks install --dry-run # shows the merge, writes nothing
|
|
175
|
+
npx --yes @gotcos/glasses-server@latest --hooks install # merges into ~/.claude/settings.json
|
|
176
|
+
npx --yes @gotcos/glasses-server@latest --hooks status
|
|
177
|
+
npx --yes @gotcos/glasses-server@latest --hooks uninstall
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The install keeps every hook you already had, backs the file up, and copies a small
|
|
181
|
+
POSIX sh script to `~/.cos-glasses/bin/cos-session-hook`. The script writes one file per
|
|
182
|
+
event into `~/.cos-glasses/data/hook-spool` and never contacts the server (a permission
|
|
183
|
+
request may, only after the desk has been idle for 90 s, for the approval feature that ships
|
|
184
|
+
next). Sessions report `state_source: hook` on `/api/agent-sessions` and
|
|
185
|
+
`/api/claude-sessions` from their next event on (Claude Code 2.1.272 reloads its hooks
|
|
186
|
+
when the settings file changes, so open tabs need no restart; they may show Claude's
|
|
187
|
+
"hooks modified externally" notice once, which is expected: the user-level file changed
|
|
188
|
+
under them). A later COS Control offers the same install from its Sessions tab. Rows
|
|
189
|
+
change only while the server runs with `COS_CLAUDE_SESSIONS_ENABLED=1`
|
|
190
|
+
(`--hooks status` prints `serverApplies`). Turn the ingestion off with `COS_SESSION_HOOKS=0`
|
|
191
|
+
(the spool is still drained and stamped, rows are exactly as before). Removing the package
|
|
192
|
+
does not remove the hooks: run `--hooks uninstall` first.
|
|
193
|
+
|
|
194
|
+
Since 6.48.1 the hooks also drive the follow-up queue (a queued Continue lands when the
|
|
195
|
+
engine closes the turn: its `Stop` hook followed by its registry record flipping idle,
|
|
196
|
+
which is when every Stop hook has returned), the attach gate (a Desktop holder whose
|
|
197
|
+
turn just ended reads idle at once), and the live session stream (every `status` draft
|
|
198
|
+
carries `agent_state` and friends; `?after=<epoch>.<cursor>` replays what the ring holds
|
|
199
|
+
after a reconnect and seeds afresh when it holds nothing, `?seed=turn` opens at the
|
|
200
|
+
current prompt). `COS_SESSION_HOOK_SSE=0` omits the extra status fields and the live
|
|
201
|
+
state drafts; the `cursor`, `epoch` and `id:` line stay.
|
|
202
|
+
|
|
168
203
|
## Configuration
|
|
169
204
|
|
|
170
205
|
Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
|
package/bin/cli.cjs
CHANGED
|
@@ -27,6 +27,7 @@ const CONFIG_DIR = join(homedir(), '.cos-glasses')
|
|
|
27
27
|
const PREPARE_ONLY = process.argv.includes('--prepare-only')
|
|
28
28
|
const SETUP_TRANSCRIPTION = process.argv.includes('--setup-transcription')
|
|
29
29
|
const SETUP_SPEAKER_MODEL = process.argv.includes('--setup-speaker-model')
|
|
30
|
+
const HOOKS_ACTION = process.argv.includes('--hooks') ? process.argv[process.argv.indexOf('--hooks') + 1] : undefined
|
|
30
31
|
function optionValue(name) {
|
|
31
32
|
const index = process.argv.indexOf(name)
|
|
32
33
|
return index >= 0 ? process.argv[index + 1] : undefined
|
|
@@ -145,6 +146,26 @@ function setupSpeakerModel() {
|
|
|
145
146
|
return 0
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
// --hooks install|status|uninstall: the Claude Code session hook (6.48.0). Runs before the
|
|
150
|
+
// banner and the runtime checks, resolving tsx itself; the managed runtime never comes
|
|
151
|
+
// through here (Control uses POST /api/session-hooks/install on the running server).
|
|
152
|
+
if (process.argv.includes('--hooks')) {
|
|
153
|
+
if (!['install', 'status', 'uninstall'].includes(HOOKS_ACTION)) {
|
|
154
|
+
console.error('Usage: --hooks install|status|uninstall [--dry-run] [--port N]')
|
|
155
|
+
process.exit(64)
|
|
156
|
+
}
|
|
157
|
+
let hooksTsx
|
|
158
|
+
try {
|
|
159
|
+
hooksTsx = require.resolve('tsx/esm', { paths: [PKG_ROOT] })
|
|
160
|
+
} catch {
|
|
161
|
+
console.error('COS package dependencies are incomplete; run npm install in the package first.')
|
|
162
|
+
process.exit(2)
|
|
163
|
+
}
|
|
164
|
+
const rest = process.argv.slice(process.argv.indexOf('--hooks') + 1)
|
|
165
|
+
const child = require('child_process').spawnSync(process.execPath, ['--import', hooksTsx, join(PKG_ROOT, 'server', 'scripts', 'hooks-cli.ts'), ...rest], { cwd: PKG_ROOT, stdio: 'inherit' })
|
|
166
|
+
process.exit(child.status ?? 1)
|
|
167
|
+
}
|
|
168
|
+
|
|
148
169
|
if (SETUP_SPEAKER_MODEL) {
|
|
149
170
|
console.log('')
|
|
150
171
|
console.log(bold(' COS Glasses - voiceprint model'))
|
|
@@ -162,6 +183,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
162
183
|
console.log(' npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier balanced|max')
|
|
163
184
|
console.log(' npx --yes @gotcos/glasses-server@latest --setup-speaker-model')
|
|
164
185
|
console.log(' npx --yes @gotcos/glasses-server@latest --prepare-only')
|
|
186
|
+
console.log(' npx --yes @gotcos/glasses-server@latest --hooks install|status|uninstall [--dry-run] [--port N]')
|
|
165
187
|
console.log('')
|
|
166
188
|
console.log(' Requirements:')
|
|
167
189
|
console.log(' - Node.js 20.11+')
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# COS session hook (glasses-server 6.48.0). Installed into ~/.claude/settings.json by
|
|
3
|
+
# `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
|
|
4
|
+
# payload on stdin. It spools one JSON file per event into the hook spool and exits 0
|
|
5
|
+
# on every path. Only PermissionRequest ever talks to the server, and only when the
|
|
6
|
+
# desk has been idle. Everything else is a file write, so the server being down or
|
|
7
|
+
# restarting never delays a Claude session.
|
|
8
|
+
#
|
|
9
|
+
# The installer bakes COS_GLASSES_HOME and COS_HOOK_SPOOL into the command, so this
|
|
10
|
+
# script and the server can never disagree about where the token or the spool is.
|
|
11
|
+
PATH=/usr/bin:/bin:/usr/sbin; umask 077; trap '' PIPE
|
|
12
|
+
HOME=${HOME:-$(cd ~ 2>/dev/null && pwd)}; [ -n "$HOME" ] || exit 0
|
|
13
|
+
GH=${COS_GLASSES_HOME:-$HOME/.cos-glasses}
|
|
14
|
+
SPOOL=${COS_HOOK_SPOOL:-$GH/data/hook-spool}; EVENT="$1"
|
|
15
|
+
mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
|
|
16
|
+
TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
|
|
17
|
+
# B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
|
|
18
|
+
if [ "$EVENT" != PermissionRequest ]; then
|
|
19
|
+
N=$(ls "$SPOOL" 2>/dev/null | wc -l | tr -d ' '); [ "${N:-0}" -lt 2000 ] || exit 0
|
|
20
|
+
STAMP="$SPOOL/.last-drain"; [ ! -f "$STAMP" ] || [ -z "$(find "$STAMP" -mmin +1440 2>/dev/null)" ] || exit 0 # -mtime +1 would mean two days
|
|
21
|
+
fi
|
|
22
|
+
T="$SPOOL/.tmp.$$.$TS"; head -c 1048576 > "$T.in" # stdin to disk, 1 MiB cap
|
|
23
|
+
case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
|
|
24
|
+
{ printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
|
|
25
|
+
rm -f "$T.in"
|
|
26
|
+
if [ "$EVENT" != PermissionRequest ]; then mv "$T" "$SPOOL/$TS-$$-$EVENT.json"; exit 0; fi # rename = publish
|
|
27
|
+
# A permission request keeps its own copy: the server unlinks the published file within
|
|
28
|
+
# milliseconds of the rename, and the request body below must outlive that.
|
|
29
|
+
cp "$T" "$T.req" 2>/dev/null; mv "$T" "$SPOOL/$TS-$$-$EVENT.json"
|
|
30
|
+
cleanup() { rm -f "$T.req"; }
|
|
31
|
+
# Desk-active fast path decided HERE (about 30 ms): no HTTP at all when someone is at the
|
|
32
|
+
# keyboard. An unreadable idle time fails the same way: nothing is contacted, the native
|
|
33
|
+
# dialog shows.
|
|
34
|
+
IDLE_NS=$(ioreg -c IOHIDSystem 2>/dev/null | awk -F'= ' '/HIDIdleTime/{print $2; exit}')
|
|
35
|
+
DESK_S=$(cat "$GH/hook-desk-idle-s" 2>/dev/null || echo 90)
|
|
36
|
+
{ [ -n "$IDLE_NS" ] && [ "$(( ${IDLE_NS%.*} / 1000000000 ))" -ge "$DESK_S" ]; } || { cleanup; exit 0; }
|
|
37
|
+
TOK=$(cat "$GH/hook-token" 2>/dev/null); [ -n "$TOK" ] || { cleanup; exit 0; } # no token, no request
|
|
38
|
+
PORT=$(cat "$GH/hook-port" 2>/dev/null || echo 3141)
|
|
39
|
+
OUT=$(curl -s --connect-timeout 2 --max-time 125 -H "X-Cos-Hook-Token: $TOK" -H 'content-type: application/json' \
|
|
40
|
+
--data-binary @"$T.req" "http://127.0.0.1:$PORT/api/permission-requests/ask") || { cleanup; exit 0; }
|
|
41
|
+
cleanup
|
|
42
|
+
case "$OUT" in '{"hookSpecificOutput"'*) printf '%s' "$OUT" ;; esac
|
|
43
|
+
exit 0
|
|
@@ -26,7 +26,13 @@
|
|
|
26
26
|
"COS_UNSAVED_AUDIO_RETENTION_HOURS",
|
|
27
27
|
"COS_CLAUDE_TRUST_MODE",
|
|
28
28
|
"COS_CODEX_SANDBOX",
|
|
29
|
-
"COS_SCRIPTS_DIR"
|
|
29
|
+
"COS_SCRIPTS_DIR",
|
|
30
|
+
"COS_SESSION_HOOKS",
|
|
31
|
+
"COS_SESSION_HOOKS_SPOOL_DIR",
|
|
32
|
+
"COS_SESSION_HOOK_SSE",
|
|
33
|
+
"COS_PERMISSION_BROKER",
|
|
34
|
+
"COS_PERMISSION_BROKER_DESK_IDLE_S",
|
|
35
|
+
"COS_PERMISSION_BROKER_TIMEOUT_S"
|
|
30
36
|
],
|
|
31
37
|
"maintenance": {
|
|
32
38
|
"scope": "cross_boot",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.48.1",
|
|
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": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"files": [
|
|
30
30
|
"bin/cli.cjs",
|
|
31
31
|
"bin/managed-server.cjs",
|
|
32
|
+
"bin/hooks/cos-session-hook",
|
|
32
33
|
"managed-runtime-contract.json",
|
|
33
34
|
"server",
|
|
34
35
|
"!server/data/**",
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
"!server/scripts/engine-canary.ts",
|
|
41
42
|
"!server/scripts/mutation-gate.mjs",
|
|
42
43
|
"!server/lib/meeting-engine/__fixtures__/**",
|
|
44
|
+
"!server/lib/__fixtures__/**",
|
|
43
45
|
"!shared/**/*.test.ts",
|
|
44
46
|
".env.example",
|
|
45
47
|
".cos-profile.example.json",
|
package/server/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { agentSessionsRouter } from './routes/agent-sessions.js'
|
|
|
22
22
|
import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
|
|
23
23
|
import { createAttachedTurnStream } from './lib/session-stream-producer.js'
|
|
24
24
|
import { claudeSessionsRouter } from './routes/claude-sessions.js'
|
|
25
|
+
import { createSessionHooksRouter } from './routes/session-hooks.js'
|
|
26
|
+
import { registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
|
|
25
27
|
import {
|
|
26
28
|
createAgentSessionBindingsRouter,
|
|
27
29
|
TargetGuard,
|
|
@@ -31,7 +33,7 @@ import {
|
|
|
31
33
|
import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
|
|
32
34
|
import { targetKey } from './lib/agent-session-binding-store.js'
|
|
33
35
|
import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
|
|
34
|
-
import { buildOccupancyProbes, realOccupancyDirs } from './lib/occupancy-probes.js'
|
|
36
|
+
import { buildOccupancyProbes, realOccupancyDirs, withHookTurnClock } from './lib/occupancy-probes.js'
|
|
35
37
|
import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
|
|
36
38
|
import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
|
|
37
39
|
import { forkThread, realForkDeps } from './lib/fork-thread.js'
|
|
@@ -127,7 +129,9 @@ import {
|
|
|
127
129
|
|
|
128
130
|
const app = express()
|
|
129
131
|
import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
|
|
130
|
-
import { transcriptTurnEnded } from './lib/thread-turn-queue-store.js'
|
|
132
|
+
import { queuedThreadKeys, transcriptTurnEnded, transcriptTurnVerdict } from './lib/thread-turn-queue-store.js'
|
|
133
|
+
import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
|
|
134
|
+
import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
|
|
131
135
|
import { transcriptPathFor } from './lib/native-head.js'
|
|
132
136
|
import { deliverQueuedTurnOverLoopback } from './lib/thread-turn-queue-deliver.js'
|
|
133
137
|
import { readFences, writeFences } from './lib/thread-fence-store.js'
|
|
@@ -380,7 +384,17 @@ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
|
|
|
380
384
|
* Read the canary evidence in `thread-occupancy.ts` under THE IDLE-HOLDER
|
|
381
385
|
* RELAXATION before changing this line.
|
|
382
386
|
*/
|
|
383
|
-
const occupancyProbes =
|
|
387
|
+
const occupancyProbes = sessionHooksEnabled()
|
|
388
|
+
// 6.48.1, the B6 clause: with the hooks on, a foreign Desktop holder whose newest hook
|
|
389
|
+
// event is a Stop reads idle at once instead of after the 30 s transcript window. The
|
|
390
|
+
// wrapper only adds a probe; the gate's precedence is unchanged. Off is 6.48.0.
|
|
391
|
+
? withHookTurnClock(buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled()), signalFor, registryIdleAfterStop)
|
|
392
|
+
: buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
|
|
393
|
+
|
|
394
|
+
// 6.48.0: the hook spool ingester and the one signal store every session row reads. Starts
|
|
395
|
+
// before any router is registered so the first list request already sees the replayed
|
|
396
|
+
// ledger. With COS_SESSION_HOOKS off it still drains and stamps the spool (see the module).
|
|
397
|
+
const sessionHooksRuntime = startSessionHooksRuntime({ port: PORT })
|
|
384
398
|
|
|
385
399
|
/**
|
|
386
400
|
* The shim between the route's request shape and the adapter's.
|
|
@@ -537,6 +551,7 @@ app.use('/api', agentSessionStreamRouter)
|
|
|
537
551
|
// Presence view of Claude Code sessions on this Mac. Dark unless
|
|
538
552
|
// COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
|
|
539
553
|
app.use('/api', claudeSessionsRouter)
|
|
554
|
+
app.use('/api', createSessionHooksRouter({ port: PORT }))
|
|
540
555
|
// Phase 0 of Continue Original Agent Thread: can COS write into a desktop thread
|
|
541
556
|
// without colliding with a live writer? Read-only — it answers, it never attaches.
|
|
542
557
|
// Registered AFTER agentSessionsRouter deliberately: its paths are 2 and 4 segments
|
|
@@ -614,11 +629,44 @@ if (threadAttachEnabled()) {
|
|
|
614
629
|
turnEnded: (provider: string, threadId: string) => {
|
|
615
630
|
if (provider !== 'claude' && provider !== 'codex') return false
|
|
616
631
|
try {
|
|
632
|
+
// 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
|
|
633
|
+
// AND the registry flipped idle after it (the Stop hooks have returned; a prompt
|
|
634
|
+
// typed during them is queued and dequeues only then). The transcript tail
|
|
635
|
+
// (`turnFromTail`) answers for everything the hooks did not see. Both refuse on doubt.
|
|
636
|
+
if (provider === 'claude') {
|
|
637
|
+
const signal = signalFor(threadId)
|
|
638
|
+
if (signal && !signal.turnOpen && signal.lastEvent === 'Stop' && typeof signal.stopAt === 'number'
|
|
639
|
+
&& signal.stopAt >= (signal.turnStartedAt ?? 0) && signal.subagentsOpen === 0
|
|
640
|
+
&& registryIdleAfterStop(threadId, signal.stopAt) === true) return true
|
|
641
|
+
}
|
|
617
642
|
return transcriptTurnEnded(provider, transcriptPathFor(provider, threadId, nativeHeadDeps))
|
|
618
643
|
} catch {
|
|
619
644
|
return false
|
|
620
645
|
}
|
|
621
646
|
},
|
|
647
|
+
// 6.48.1: positive evidence the holder's turn is OPEN, which outranks the 30 s idle
|
|
648
|
+
// backstop (a long tool leaves the transcript untouched while it runs). Bounded: a
|
|
649
|
+
// signal or a tail older than OPEN_TURN_CEILING_MS is not evidence any more, so a
|
|
650
|
+
// crashed mid-tool session still drains by the backstop rather than holding for the TTL.
|
|
651
|
+
turnOpen: (provider: string, threadId: string): boolean => {
|
|
652
|
+
if (provider !== 'claude' && provider !== 'codex') return false
|
|
653
|
+
try {
|
|
654
|
+
const now = Date.now()
|
|
655
|
+
if (provider === 'claude') {
|
|
656
|
+
const signal = signalFor(threadId)
|
|
657
|
+
if (signal && signal.turnOpen && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) return true
|
|
658
|
+
}
|
|
659
|
+
const path = transcriptPathFor(provider, threadId, nativeHeadDeps)
|
|
660
|
+
const verdict = transcriptTurnVerdict(provider, path)
|
|
661
|
+
if (!verdict || verdict.ended) return false
|
|
662
|
+
if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
|
|
663
|
+
const read = occupancyProbes.transcriptMtimeMs
|
|
664
|
+
const mtime = typeof read === 'function' ? read(provider, threadId) : null
|
|
665
|
+
return typeof mtime === 'number' && now - mtime <= OPEN_TURN_CEILING_MS
|
|
666
|
+
} catch {
|
|
667
|
+
return false
|
|
668
|
+
}
|
|
669
|
+
},
|
|
622
670
|
activity: (provider: string, threadId: string): 'working' | 'idle' | 'unknown' => {
|
|
623
671
|
try {
|
|
624
672
|
const read = occupancyProbes.transcriptMtimeMs
|
|
@@ -637,10 +685,33 @@ if (threadAttachEnabled()) {
|
|
|
637
685
|
// Every 20s. Fast enough that a freed thread drains while the user is still looking
|
|
638
686
|
// at the pending row, slow enough to be nothing: the sweep does no work at all when
|
|
639
687
|
// no queue file exists. Unref'd so it never holds the process open.
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
688
|
+
//
|
|
689
|
+
// 6.48.1: the timer and the hooks' Stop share ONE in-flight guard (`thread-drain-kick`),
|
|
690
|
+
// so a Stop landing mid-sweep folds into exactly one follow-up pass and a sentence is
|
|
691
|
+
// never delivered twice. A Stop kicks only when a queue file names that session. A
|
|
692
|
+
// SubagentStop does NOT kick (QA, 2026-09-15): a background sub-agent finishing after
|
|
693
|
+
// the tab's Stop is answered by the model in a turn of its own, whose Stop kicks; and
|
|
694
|
+
// neither the hook short-circuit nor the B6 clause vouches for a session whose newest
|
|
695
|
+
// event is a SubagentStop, so a kick there could only fall through to the 30 s backstop.
|
|
696
|
+
const drainKick = createDrainKick({
|
|
697
|
+
drain: () => drainAllThreads(queueDeps),
|
|
698
|
+
queuedThreadIds: () => queuedThreadKeys().map(k => k.threadId),
|
|
699
|
+
})
|
|
700
|
+
registerDrainKickStats(() => drainKick.stats())
|
|
701
|
+
const queueDrainTimer = setInterval(() => { void drainKick.sweep() }, 20_000)
|
|
643
702
|
queueDrainTimer.unref()
|
|
703
|
+
if (sessionHooksEnabled()) {
|
|
704
|
+
sessionSignalStore.subscribe((signal, env, child) => {
|
|
705
|
+
// The rule is `kickPlanFor` (pure, pinned in thread-drain-kick.test.ts). The Stop is
|
|
706
|
+
// not the engine's end of turn: that is the registry flipping idle once every Stop
|
|
707
|
+
// hook has returned (12-38 s on this Mac). Wait for it, then sweep; a session with
|
|
708
|
+
// no registry record (a print run that exited) is ready at once.
|
|
709
|
+
const plan = kickPlanFor(signal, env.event, env.ts, child)
|
|
710
|
+
if (!plan) return
|
|
711
|
+
if (plan.kind === 'kick') drainKick.kick(signal.sessionId)
|
|
712
|
+
else drainKick.kickWhen(signal.sessionId, () => registryIdleAfterStop(signal.sessionId, plan.stopAt) !== false)
|
|
713
|
+
})
|
|
714
|
+
}
|
|
644
715
|
}
|
|
645
716
|
|
|
646
717
|
app.use('/api', createAgentSessionBindingsRouter({
|
|
@@ -760,6 +831,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
760
831
|
stopMorningBriefScheduler()
|
|
761
832
|
stopMeetingImportScheduler()
|
|
762
833
|
stopMeetingMergeScheduler()
|
|
834
|
+
sessionHooksRuntime.stop()
|
|
763
835
|
try {
|
|
764
836
|
await shutdownQueryJobRuntime('server_shutdown')
|
|
765
837
|
} catch (error) {
|