@gotcos/glasses-server 6.47.0 → 6.48.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 +12 -0
- package/README.md +24 -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 +9 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -0
- package/server/lib/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/health.ts +2 -0
- package/server/routes/session-hooks.ts +70 -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,15 @@
|
|
|
1
|
+
## 6.48.0
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
- `--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.
|
|
6
|
+
- 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.
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
- `/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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
1
13
|
## 6.47.0
|
|
2
14
|
|
|
3
15
|
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,30 @@ 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 started after the install report `state_source: hook` on
|
|
185
|
+
`/api/agent-sessions` and `/api/claude-sessions`; tabs already open keep their startup
|
|
186
|
+
hook snapshot until restarted, and may show Claude's "hooks modified externally" notice once (expected: the user-level file changed under them). The next COS Control (0.5.231) offers the same install from
|
|
187
|
+
its Sessions tab. Rows change only while the server runs with `COS_CLAUDE_SESSIONS_ENABLED=1`
|
|
188
|
+
(`--hooks status` prints `serverApplies`). Turn the ingestion off with `COS_SESSION_HOOKS=0`
|
|
189
|
+
(the spool is still drained and stamped, rows are exactly as before). Removing the package
|
|
190
|
+
does not remove the hooks: run `--hooks uninstall` first.
|
|
191
|
+
|
|
168
192
|
## Configuration
|
|
169
193
|
|
|
170
194
|
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.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": {
|
|
@@ -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 { startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
|
|
25
27
|
import {
|
|
26
28
|
createAgentSessionBindingsRouter,
|
|
27
29
|
TargetGuard,
|
|
@@ -382,6 +384,11 @@ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
|
|
|
382
384
|
*/
|
|
383
385
|
const occupancyProbes = buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
|
|
384
386
|
|
|
387
|
+
// 6.48.0: the hook spool ingester and the one signal store every session row reads. Starts
|
|
388
|
+
// before any router is registered so the first list request already sees the replayed
|
|
389
|
+
// ledger. With COS_SESSION_HOOKS off it still drains and stamps the spool (see the module).
|
|
390
|
+
const sessionHooksRuntime = startSessionHooksRuntime({ port: PORT })
|
|
391
|
+
|
|
385
392
|
/**
|
|
386
393
|
* The shim between the route's request shape and the adapter's.
|
|
387
394
|
*
|
|
@@ -537,6 +544,7 @@ app.use('/api', agentSessionStreamRouter)
|
|
|
537
544
|
// Presence view of Claude Code sessions on this Mac. Dark unless
|
|
538
545
|
// COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
|
|
539
546
|
app.use('/api', claudeSessionsRouter)
|
|
547
|
+
app.use('/api', createSessionHooksRouter({ port: PORT }))
|
|
540
548
|
// Phase 0 of Continue Original Agent Thread: can COS write into a desktop thread
|
|
541
549
|
// without colliding with a live writer? Read-only — it answers, it never attaches.
|
|
542
550
|
// Registered AFTER agentSessionsRouter deliberately: its paths are 2 and 4 segments
|
|
@@ -760,6 +768,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
760
768
|
stopMorningBriefScheduler()
|
|
761
769
|
stopMeetingImportScheduler()
|
|
762
770
|
stopMeetingMergeScheduler()
|
|
771
|
+
sessionHooksRuntime.stop()
|
|
763
772
|
try {
|
|
764
773
|
await shutdownQueryJobRuntime('server_shutdown')
|
|
765
774
|
} catch (error) {
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// Installing the COS session hook into `~/.claude/settings.json`, and knowing whether it
|
|
2
|
+
// is installed.
|
|
3
|
+
//
|
|
4
|
+
// USER LEVEL ONLY. Hooks in `~/.claude/settings.json` fire for every Claude Code session
|
|
5
|
+
// on this Mac (Desktop tabs, the CLI, `claude -p` jobs). The project-level file the COS
|
|
6
|
+
// repo owns (seven memory and canvas hooks) is never touched; Claude merges levels, so
|
|
7
|
+
// both sets run.
|
|
8
|
+
//
|
|
9
|
+
// THE MERGE IS A PURE FUNCTION over the settings object (`mergeHookSettings`), tested
|
|
10
|
+
// against the real file's shape: keep every block whose commands do not name our script,
|
|
11
|
+
// drop every block that does, append one canonical block per subscribed event. Other
|
|
12
|
+
// events and every other top-level key pass through untouched. Nothing is written when
|
|
13
|
+
// the merged object equals the current one.
|
|
14
|
+
//
|
|
15
|
+
// STABLE PATH. The script is copied from the package (`bin/hooks/cos-session-hook`) to
|
|
16
|
+
// `~/.cos-glasses/bin/cos-session-hook`, because the managed runtime's generation
|
|
17
|
+
// directory changes on every server update and a settings file pointing into it would
|
|
18
|
+
// break at the first Update Server. `status` reports `script_outdated` when the package
|
|
19
|
+
// copy differs from the installed one, and `install` re-copies.
|
|
20
|
+
|
|
21
|
+
import { copyFileSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { dirname, join, resolve } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
|
|
30
|
+
/** Substring that marks a hook block as ours, whatever the home directory is. */
|
|
31
|
+
export const HOOK_SCRIPT_MARKER = '/.cos-glasses/bin/cos-session-hook'
|
|
32
|
+
export const HOOK_SCRIPT_NAME = 'cos-session-hook'
|
|
33
|
+
|
|
34
|
+
export interface HookSubscription {
|
|
35
|
+
event: string
|
|
36
|
+
matcher?: string
|
|
37
|
+
async: boolean
|
|
38
|
+
timeout: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every event the reducer understands, with the timing each one needs. SessionStart,
|
|
43
|
+
* UserPromptSubmit, Stop and SessionEnd are synchronous: measured 2026-09-15, an async
|
|
44
|
+
* Stop was dropped when a print-mode process exited right after it (PostToolUse landed,
|
|
45
|
+
* Stop did not), and those four carry the transitions a row is derived from (a dropped
|
|
46
|
+
* UserPromptSubmit would leave a running turn reading idle). The write is a few
|
|
47
|
+
* milliseconds. PermissionRequest must be synchronous to return a decision; the rest
|
|
48
|
+
* are fire-and-forget.
|
|
49
|
+
*/
|
|
50
|
+
export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
|
|
51
|
+
{ event: 'SessionStart', async: false, timeout: 5 },
|
|
52
|
+
{ event: 'SessionEnd', async: false, timeout: 5 },
|
|
53
|
+
{ event: 'UserPromptSubmit', async: false, timeout: 5 },
|
|
54
|
+
{ event: 'Stop', async: false, timeout: 5 },
|
|
55
|
+
{ event: 'StopFailure', async: true, timeout: 10 },
|
|
56
|
+
{ event: 'PermissionRequest', async: false, timeout: 130 },
|
|
57
|
+
{ event: 'PermissionDenied', async: true, timeout: 10 },
|
|
58
|
+
{ event: 'PreToolUse', matcher: 'AskUserQuestion|ExitPlanMode', async: true, timeout: 10 },
|
|
59
|
+
{ event: 'PostToolUse', async: true, timeout: 10 },
|
|
60
|
+
{ event: 'PostToolUseFailure', async: true, timeout: 10 },
|
|
61
|
+
{ event: 'Notification', matcher: 'permission_prompt|idle_prompt|elicitation_dialog|agent_needs_input', async: true, timeout: 10 },
|
|
62
|
+
{ event: 'SubagentStart', async: true, timeout: 10 },
|
|
63
|
+
{ event: 'SubagentStop', async: true, timeout: 10 },
|
|
64
|
+
{ event: 'PostCompact', async: true, timeout: 10 },
|
|
65
|
+
{ event: 'PostModelSwitch', async: true, timeout: 10 },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the token, port and spool live. Derived from `COS_DATA_DIR` when that is set, so
|
|
70
|
+
* a scratch server booted with a temp data dir never touches the production files under
|
|
71
|
+
* `~/.cos-glasses` (it did once, in review). `COS_GLASSES_HOME` overrides for tests.
|
|
72
|
+
*/
|
|
73
|
+
export function cosGlassesHome(): string {
|
|
74
|
+
if (process.env.COS_GLASSES_HOME) return resolve(process.env.COS_GLASSES_HOME)
|
|
75
|
+
if (process.env.COS_DATA_DIR) return dirname(resolve(process.env.COS_DATA_DIR))
|
|
76
|
+
return join(homedir(), '.cos-glasses')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The spool the server drains; baked into the installed command so the script agrees. */
|
|
80
|
+
export function hookSpoolDir(): string {
|
|
81
|
+
if (process.env.COS_SESSION_HOOKS_SPOOL_DIR) return resolve(process.env.COS_SESSION_HOOKS_SPOOL_DIR)
|
|
82
|
+
return join(process.env.COS_DATA_DIR ? resolve(process.env.COS_DATA_DIR) : join(cosGlassesHome(), 'data'), 'hook-spool')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function claudeSettingsPath(): string {
|
|
86
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ? resolve(process.env.CLAUDE_CONFIG_DIR) : join(homedir(), '.claude')
|
|
87
|
+
return join(configDir, 'settings.json')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function stableHookScriptPath(): string {
|
|
91
|
+
return join(cosGlassesHome(), 'bin', HOOK_SCRIPT_NAME)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The copy shipped in this package. */
|
|
95
|
+
export function packagedHookScriptPath(): string {
|
|
96
|
+
return resolve(__dirname, '..', '..', 'bin', 'hooks', HOOK_SCRIPT_NAME)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function hookTokenPath(): string { return join(cosGlassesHome(), 'hook-token') }
|
|
100
|
+
export function hookPortPath(): string { return join(cosGlassesHome(), 'hook-port') }
|
|
101
|
+
export function hookDeskIdlePath(): string { return join(cosGlassesHome(), 'hook-desk-idle-s') }
|
|
102
|
+
|
|
103
|
+
/** sh-safe quoting for the settings command string. Paths with spaces get single quotes. */
|
|
104
|
+
export function shellQuote(value: string): string {
|
|
105
|
+
return /^[A-Za-z0-9_\-./]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The installed command carries the two paths the script needs as environment, so the
|
|
110
|
+
* server and the script can never disagree about where the spool or the token is.
|
|
111
|
+
*/
|
|
112
|
+
export function hookCommand(scriptPath: string, event: string, paths: HookPaths = currentHookPaths()): string {
|
|
113
|
+
return `COS_GLASSES_HOME=${shellQuote(paths.home)} COS_HOOK_SPOOL=${shellQuote(paths.spoolDir)} ${shellQuote(scriptPath)} ${event}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface HookPaths {
|
|
117
|
+
home: string
|
|
118
|
+
spoolDir: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function currentHookPaths(): HookPaths {
|
|
122
|
+
return { home: cosGlassesHome(), spoolDir: hookSpoolDir() }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type HookBlock = { matcher?: unknown; hooks?: unknown }
|
|
126
|
+
|
|
127
|
+
function blockIsOurs(block: unknown): boolean {
|
|
128
|
+
if (!block || typeof block !== 'object') return false
|
|
129
|
+
const hooks = (block as HookBlock).hooks
|
|
130
|
+
if (!Array.isArray(hooks)) return false
|
|
131
|
+
return hooks.some(h => h && typeof h === 'object' && typeof (h as { command?: unknown }).command === 'string'
|
|
132
|
+
&& ((h as { command: string }).command.includes(HOOK_SCRIPT_MARKER) || (h as { command: string }).command.includes(HOOK_SCRIPT_NAME + ' ')))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function canonicalBlock(scriptPath: string, sub: HookSubscription, paths: HookPaths): Record<string, unknown> {
|
|
136
|
+
const hook: Record<string, unknown> = { type: 'command', command: hookCommand(scriptPath, sub.event, paths), timeout: sub.timeout }
|
|
137
|
+
if (sub.async) hook.async = true
|
|
138
|
+
return sub.matcher ? { matcher: sub.matcher, hooks: [hook] } : { hooks: [hook] }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type MergeResult =
|
|
142
|
+
| { ok: true; settings: Record<string, unknown>; changed: boolean }
|
|
143
|
+
/** The file holds a `hooks` shape this merge would have to destroy to proceed. */
|
|
144
|
+
| { ok: false; reason: 'settings_hooks_invalid' }
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Pure. Adds or refreshes our blocks; every foreign block and key survives in order. A
|
|
148
|
+
* `hooks` key that is not an object, or an event whose value is not an array, is refused
|
|
149
|
+
* rather than replaced: "preserving every existing hook block" is the contract.
|
|
150
|
+
*/
|
|
151
|
+
export function mergeHookSettings(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): MergeResult {
|
|
152
|
+
const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
153
|
+
const before = JSON.stringify(settings)
|
|
154
|
+
if (settings.hooks !== undefined && (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))) return { ok: false, reason: 'settings_hooks_invalid' }
|
|
155
|
+
const hooksIn = (settings.hooks ?? {}) as Record<string, unknown>
|
|
156
|
+
const hooks: Record<string, unknown> = { ...hooksIn }
|
|
157
|
+
for (const sub of HOOK_SUBSCRIPTIONS) {
|
|
158
|
+
const value = hooks[sub.event]
|
|
159
|
+
if (value !== undefined && !Array.isArray(value)) return { ok: false, reason: 'settings_hooks_invalid' }
|
|
160
|
+
const existing = Array.isArray(value) ? value : []
|
|
161
|
+
const foreign = existing.filter(b => !blockIsOurs(b))
|
|
162
|
+
hooks[sub.event] = [...foreign, canonicalBlock(scriptPath, sub, paths)]
|
|
163
|
+
}
|
|
164
|
+
settings.hooks = hooks
|
|
165
|
+
return { ok: true, settings, changed: JSON.stringify(settings) !== before }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Pure. Removes our blocks; emptied event arrays are deleted, everything else survives. */
|
|
169
|
+
export function stripHookSettings(current: unknown): MergeResult {
|
|
170
|
+
const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
171
|
+
const before = JSON.stringify(settings)
|
|
172
|
+
const hooksIn = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks as Record<string, unknown> : null
|
|
173
|
+
if (!hooksIn) return { ok: true, settings, changed: false }
|
|
174
|
+
const hooks: Record<string, unknown> = {}
|
|
175
|
+
for (const [event, blocks] of Object.entries(hooksIn)) {
|
|
176
|
+
if (!Array.isArray(blocks)) { hooks[event] = blocks; continue }
|
|
177
|
+
const foreign = blocks.filter(b => !blockIsOurs(b))
|
|
178
|
+
if (foreign.length > 0) hooks[event] = foreign
|
|
179
|
+
}
|
|
180
|
+
settings.hooks = hooks
|
|
181
|
+
return { ok: true, settings, changed: JSON.stringify(settings) !== before }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Which of our events a settings object carries, by exact canonical command. */
|
|
185
|
+
export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[] } {
|
|
186
|
+
const hooks = current && typeof current === 'object' && (current as Record<string, unknown>).hooks
|
|
187
|
+
const table = hooks && typeof hooks === 'object' ? hooks as Record<string, unknown> : {}
|
|
188
|
+
const subscribed: string[] = []
|
|
189
|
+
const missing: string[] = []
|
|
190
|
+
const drifted: string[] = []
|
|
191
|
+
for (const sub of HOOK_SUBSCRIPTIONS) {
|
|
192
|
+
const blocks = Array.isArray(table[sub.event]) ? table[sub.event] as unknown[] : []
|
|
193
|
+
const ours = blocks.filter(blockIsOurs)
|
|
194
|
+
if (ours.length === 0) { missing.push(sub.event); continue }
|
|
195
|
+
const canonical = JSON.stringify(canonicalBlock(scriptPath, sub, paths))
|
|
196
|
+
if (ours.length === 1 && JSON.stringify(ours[0]) === canonical) subscribed.push(sub.event)
|
|
197
|
+
else drifted.push(sub.event)
|
|
198
|
+
}
|
|
199
|
+
return { subscribed, missing, drifted }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type HookInstallState =
|
|
203
|
+
| 'installed'
|
|
204
|
+
| 'drift'
|
|
205
|
+
| 'missing'
|
|
206
|
+
| 'script_outdated'
|
|
207
|
+
/** The user-level file carries `disableAllHooks: true`: installed or not, nothing fires. */
|
|
208
|
+
| 'disabled_by_settings'
|
|
209
|
+
| 'settings_unparseable'
|
|
210
|
+
| 'settings_symlink'
|
|
211
|
+
/** The file exists but could not be read (EACCES, ELOOP, ENOTDIR): not "missing". */
|
|
212
|
+
| 'settings_unreadable'
|
|
213
|
+
|
|
214
|
+
export interface HookStatus {
|
|
215
|
+
state: HookInstallState
|
|
216
|
+
/** True only for `installed`: the boolean Control's banner keys off. */
|
|
217
|
+
installed: boolean
|
|
218
|
+
settingsPath: string
|
|
219
|
+
scriptPath: string
|
|
220
|
+
scriptSha: string | null
|
|
221
|
+
packageScriptSha: string | null
|
|
222
|
+
subscribed: string[]
|
|
223
|
+
missing: string[]
|
|
224
|
+
drifted: string[]
|
|
225
|
+
tokenPresent: boolean
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sha256File(path: string): string | null {
|
|
229
|
+
try { return createHash('sha256').update(readFileSync(path)).digest('hex') } catch { return null }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type SettingsRead = { ok: true; settings: unknown; existed: boolean } | { ok: false; reason: 'settings_unparseable' | 'settings_symlink' | 'settings_unreadable' }
|
|
233
|
+
|
|
234
|
+
function readSettings(path: string): SettingsRead {
|
|
235
|
+
try {
|
|
236
|
+
if (lstatSync(path).isSymbolicLink()) return { ok: false, reason: 'settings_symlink' }
|
|
237
|
+
} catch (error) {
|
|
238
|
+
// Only "no such file" is missing. Anything else is a file that could not be looked at,
|
|
239
|
+
// and "I could not look" must never render as "it is not there".
|
|
240
|
+
if ((error as { code?: string }).code === 'ENOENT') return { ok: true, settings: {}, existed: false }
|
|
241
|
+
return { ok: false, reason: 'settings_unreadable' }
|
|
242
|
+
}
|
|
243
|
+
let text: string
|
|
244
|
+
try { text = readFileSync(path, 'utf-8') } catch { return { ok: false, reason: 'settings_unreadable' } }
|
|
245
|
+
try {
|
|
246
|
+
return { ok: true, settings: JSON.parse(text), existed: true }
|
|
247
|
+
} catch {
|
|
248
|
+
return { ok: false, reason: 'settings_unparseable' }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function hooksDisabled(settings: unknown): boolean {
|
|
253
|
+
return !!settings && typeof settings === 'object' && (settings as { disableAllHooks?: unknown }).disableAllHooks === true
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function hookStatus(paths: { settingsPath?: string; scriptPath?: string; packageScriptPath?: string; hookPaths?: HookPaths } = {}): HookStatus {
|
|
257
|
+
const settingsPath = paths.settingsPath ?? claudeSettingsPath()
|
|
258
|
+
const scriptPath = paths.scriptPath ?? stableHookScriptPath()
|
|
259
|
+
const packageScriptPath = paths.packageScriptPath ?? packagedHookScriptPath()
|
|
260
|
+
const hookPaths = paths.hookPaths ?? currentHookPaths()
|
|
261
|
+
const scriptSha = sha256File(scriptPath)
|
|
262
|
+
const packageScriptSha = sha256File(packageScriptPath)
|
|
263
|
+
const tokenPresent = existsSync(hookTokenPath())
|
|
264
|
+
const read = readSettings(settingsPath)
|
|
265
|
+
if (!read.ok) {
|
|
266
|
+
return { state: read.reason, installed: false, settingsPath, scriptPath, scriptSha, packageScriptSha, subscribed: [], missing: HOOK_SUBSCRIPTIONS.map(s => s.event), drifted: [], tokenPresent }
|
|
267
|
+
}
|
|
268
|
+
const events = subscribedEvents(read.settings, scriptPath, hookPaths)
|
|
269
|
+
let state: HookInstallState
|
|
270
|
+
if (hooksDisabled(read.settings)) state = 'disabled_by_settings'
|
|
271
|
+
else if (events.subscribed.length === 0 && events.drifted.length === 0) state = 'missing'
|
|
272
|
+
else if (events.missing.length > 0 || events.drifted.length > 0) state = 'drift'
|
|
273
|
+
else if (!scriptSha || (packageScriptSha && scriptSha !== packageScriptSha)) state = 'script_outdated'
|
|
274
|
+
else state = 'installed'
|
|
275
|
+
return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, tokenPresent }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface InstallOptions {
|
|
279
|
+
settingsPath?: string
|
|
280
|
+
scriptPath?: string
|
|
281
|
+
packageScriptPath?: string
|
|
282
|
+
hookPaths?: HookPaths
|
|
283
|
+
port?: number
|
|
284
|
+
deskIdleSeconds?: number
|
|
285
|
+
dryRun?: boolean
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface InstallResult {
|
|
289
|
+
ok: boolean
|
|
290
|
+
reason?: string
|
|
291
|
+
changed: boolean
|
|
292
|
+
scriptCopied: boolean
|
|
293
|
+
backupPath: string | null
|
|
294
|
+
status: HookStatus
|
|
295
|
+
/** The merged settings, for `--dry-run` to print. */
|
|
296
|
+
merged?: Record<string, unknown>
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const BACKUPS_KEPT = 3
|
|
300
|
+
|
|
301
|
+
function backupSettings(settingsPath: string): string | null {
|
|
302
|
+
if (!existsSync(settingsPath)) return null
|
|
303
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
304
|
+
const backup = `${settingsPath}.cos-backup-${stamp}`
|
|
305
|
+
try {
|
|
306
|
+
copyFileSync(settingsPath, backup)
|
|
307
|
+
chmodSync(backup, 0o600)
|
|
308
|
+
} catch { return null }
|
|
309
|
+
try {
|
|
310
|
+
const dir = dirname(settingsPath)
|
|
311
|
+
const prefix = `${settingsPath.slice(dir.length + 1)}.cos-backup-`
|
|
312
|
+
const olds = readdirSync(dir).filter(n => n.startsWith(prefix)).sort()
|
|
313
|
+
for (const old of olds.slice(0, Math.max(0, olds.length - BACKUPS_KEPT))) {
|
|
314
|
+
try { unlinkSync(join(dir, old)) } catch { /* fine */ }
|
|
315
|
+
}
|
|
316
|
+
} catch { /* the backup itself succeeded */ }
|
|
317
|
+
return backup
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Mint the per-install token once (0600, atomically); write port and desk-idle files every time. */
|
|
321
|
+
export function ensureHookRuntimeFiles(port: number, deskIdleSeconds: number): void {
|
|
322
|
+
const home = cosGlassesHome()
|
|
323
|
+
mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
324
|
+
if (!existsSync(hookTokenPath())) atomicWriteFileSync(hookTokenPath(), randomBytes(32).toString('hex'), { mode: 0o600 })
|
|
325
|
+
atomicWriteFileSync(hookPortPath(), String(port), { mode: 0o600 })
|
|
326
|
+
atomicWriteFileSync(hookDeskIdlePath(), String(Math.max(0, Math.trunc(deskIdleSeconds))), { mode: 0o600 })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function readHookToken(): string | null {
|
|
330
|
+
try { return readFileSync(hookTokenPath(), 'utf-8').trim() || null } catch { return null }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Copy the packaged script to the stable path when it differs (`install`), or only when it
|
|
335
|
+
* is missing (`onlyIfMissing`, used at boot: an older server booting must never downgrade
|
|
336
|
+
* the script a newer `install` put there, since every Claude session on the Mac runs it).
|
|
337
|
+
* Returns true when copied.
|
|
338
|
+
*/
|
|
339
|
+
export function ensureStableHookScript(scriptPath = stableHookScriptPath(), packageScriptPath = packagedHookScriptPath(), onlyIfMissing = false): boolean {
|
|
340
|
+
const want = sha256File(packageScriptPath)
|
|
341
|
+
if (!want) throw new Error(`hook script missing from the package at ${packageScriptPath}`)
|
|
342
|
+
const have = sha256File(scriptPath)
|
|
343
|
+
if (have === want) return false
|
|
344
|
+
if (have && onlyIfMissing) return false
|
|
345
|
+
mkdirSync(dirname(scriptPath), { recursive: true, mode: 0o755 })
|
|
346
|
+
atomicWriteFileSync(scriptPath, readFileSync(packageScriptPath), { mode: 0o755 })
|
|
347
|
+
chmodSync(scriptPath, 0o755)
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function installClaudeHooks(options: InstallOptions = {}): InstallResult {
|
|
352
|
+
const settingsPath = options.settingsPath ?? claudeSettingsPath()
|
|
353
|
+
const scriptPath = options.scriptPath ?? stableHookScriptPath()
|
|
354
|
+
const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
|
|
355
|
+
const hookPaths = options.hookPaths ?? currentHookPaths()
|
|
356
|
+
const read = readSettings(settingsPath)
|
|
357
|
+
const statusNow = () => hookStatus({ settingsPath, scriptPath, packageScriptPath, hookPaths })
|
|
358
|
+
const refuse = (reason: string, scriptCopied = false): InstallResult => ({ ok: false, reason, changed: false, scriptCopied, backupPath: null, status: statusNow() })
|
|
359
|
+
if (!read.ok) return refuse(read.reason)
|
|
360
|
+
const merged = mergeHookSettings(read.settings, scriptPath, hookPaths)
|
|
361
|
+
if (!merged.ok) return refuse(merged.reason)
|
|
362
|
+
if (options.dryRun) {
|
|
363
|
+
return { ok: true, changed: merged.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: merged.settings }
|
|
364
|
+
}
|
|
365
|
+
let scriptCopied = false
|
|
366
|
+
try {
|
|
367
|
+
scriptCopied = ensureStableHookScript(scriptPath, packageScriptPath)
|
|
368
|
+
ensureHookRuntimeFiles(options.port ?? 3141, options.deskIdleSeconds ?? 90)
|
|
369
|
+
} catch (error) {
|
|
370
|
+
return refuse(error instanceof Error ? error.message : String(error), scriptCopied)
|
|
371
|
+
}
|
|
372
|
+
let backupPath: string | null = null
|
|
373
|
+
if (merged.changed) {
|
|
374
|
+
if (read.existed) {
|
|
375
|
+
backupPath = backupSettings(settingsPath)
|
|
376
|
+
// No backup, no overwrite: "with a backup of the file" is the contract.
|
|
377
|
+
if (!backupPath) return refuse('backup_failed', scriptCopied)
|
|
378
|
+
}
|
|
379
|
+
mkdirSync(dirname(settingsPath), { recursive: true, mode: 0o700 })
|
|
380
|
+
atomicWriteFileSync(settingsPath, JSON.stringify(merged.settings, null, 2) + '\n', { mode: 0o600 })
|
|
381
|
+
}
|
|
382
|
+
return { ok: true, changed: merged.changed, scriptCopied, backupPath, status: statusNow() }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function uninstallClaudeHooks(options: Pick<InstallOptions, 'settingsPath' | 'scriptPath' | 'packageScriptPath' | 'hookPaths' | 'dryRun'> = {}): InstallResult {
|
|
386
|
+
const settingsPath = options.settingsPath ?? claudeSettingsPath()
|
|
387
|
+
const scriptPath = options.scriptPath ?? stableHookScriptPath()
|
|
388
|
+
const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
|
|
389
|
+
const hookPaths = options.hookPaths ?? currentHookPaths()
|
|
390
|
+
const read = readSettings(settingsPath)
|
|
391
|
+
const statusNow = () => hookStatus({ settingsPath, scriptPath, packageScriptPath, hookPaths })
|
|
392
|
+
if (!read.ok) return { ok: false, reason: read.reason, changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
393
|
+
const stripped = stripHookSettings(read.settings)
|
|
394
|
+
if (!stripped.ok) return { ok: false, reason: stripped.reason, changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
395
|
+
if (options.dryRun) return { ok: true, changed: stripped.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: stripped.settings }
|
|
396
|
+
let backupPath: string | null = null
|
|
397
|
+
if (stripped.changed) {
|
|
398
|
+
backupPath = backupSettings(settingsPath)
|
|
399
|
+
if (!backupPath) return { ok: false, reason: 'backup_failed', changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
400
|
+
atomicWriteFileSync(settingsPath, JSON.stringify(stripped.settings, null, 2) + '\n', { mode: 0o600 })
|
|
401
|
+
}
|
|
402
|
+
return { ok: true, changed: stripped.changed, scriptCopied: false, backupPath, status: statusNow() }
|
|
403
|
+
}
|