agentbox-flight-recorder 0.2.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.
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env bash
2
+ # agentbox — preflight.sh
3
+ # Run before every public post (Reddit, Show HN, Twitter, npm publish).
4
+ # Exit 0 = green light. Exit 1 = fix the failures first.
5
+ #
6
+ # Usage:
7
+ # ./scripts/preflight.sh
8
+ # ./scripts/preflight.sh --quick # skip slow e2e (demo/wrap)
9
+ # npm run preflight
10
+ set -euo pipefail
11
+
12
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
13
+ cd "$ROOT"
14
+
15
+ QUICK=0
16
+ for arg in "$@"; do
17
+ case "$arg" in
18
+ --quick|-q) QUICK=1 ;;
19
+ --help|-h)
20
+ echo "Usage: $0 [--quick]"
21
+ exit 0
22
+ ;;
23
+ esac
24
+ done
25
+
26
+ PASS=0
27
+ FAIL=0
28
+ WARN=0
29
+ failures=()
30
+ warnings=()
31
+
32
+ GREEN='\033[32m'
33
+ RED='\033[31m'
34
+ YELLOW='\033[33m'
35
+ DIM='\033[2m'
36
+ BOLD='\033[1m'
37
+ CYAN='\033[36m'
38
+ RESET='\033[0m'
39
+
40
+ ok() { PASS=$((PASS + 1)); printf " ${GREEN}✓${RESET} %s\n" "$1"; }
41
+ bad() { FAIL=$((FAIL + 1)); failures+=("$1"); printf " ${RED}✗${RESET} %s\n" "$1"; }
42
+ warn() { WARN=$((WARN + 1)); warnings+=("$1"); printf " ${YELLOW}!${RESET} %s\n" "$1"; }
43
+ section() { printf "\n${BOLD}${CYAN}▸ %s${RESET}\n" "$1"; }
44
+
45
+ # ─── 1. Repo shape ──────────────────────────────────────────────────────────
46
+ section "Repo shape"
47
+
48
+ [[ -f package.json ]] && ok "package.json present" || bad "package.json missing"
49
+ [[ -f LICENSE ]] && ok "LICENSE present" || bad "LICENSE missing"
50
+ [[ -f README.md ]] && ok "README.md present" || bad "README.md missing"
51
+ [[ -f bin/agentbox.js ]] && ok "bin/agentbox.js present" || bad "bin/agentbox.js missing"
52
+ [[ -f src/redact.js ]] && ok "src/redact.js present (redaction layer)" || bad "src/redact.js missing"
53
+ [[ -f src/chain.js ]] && ok "src/chain.js present" || bad "src/chain.js missing"
54
+ [[ -x bin/agentbox.js || -f bin/agentbox.js ]] && ok "CLI entry exists" || bad "CLI entry missing"
55
+
56
+ if [[ -f .gitignore ]]; then
57
+ if grep -qE '^\.agentbox/?$|^\.agentbox/' .gitignore || grep -q '\.agentbox' .gitignore; then
58
+ ok ".gitignore mentions .agentbox"
59
+ else
60
+ warn ".gitignore does not ignore .agentbox/ — session tapes could be committed"
61
+ fi
62
+ else
63
+ bad ".gitignore missing"
64
+ fi
65
+
66
+ # ─── 2. Identity / placeholders ─────────────────────────────────────────────
67
+ section "Identity (no placeholders for public post)"
68
+
69
+ PKG_NAME=$(node -p "require('./package.json').name" 2>/dev/null || echo "")
70
+ PKG_VER=$(node -p "require('./package.json').version" 2>/dev/null || echo "")
71
+ PKG_AUTHOR=$(node -p "require('./package.json').author" 2>/dev/null || echo "")
72
+ PKG_REPO=$(node -p "require('./package.json').repository && require('./package.json').repository.url" 2>/dev/null || echo "")
73
+
74
+ [[ -n "$PKG_NAME" ]] && ok "package name: $PKG_NAME" || bad "package name empty"
75
+ [[ -n "$PKG_VER" ]] && ok "package version: $PKG_VER" || bad "package version empty"
76
+
77
+ CHAIN_VER=$(node -p "require('./src/chain.js').VERSION" 2>/dev/null || echo "")
78
+ if [[ -n "$CHAIN_VER" && -n "$PKG_VER" ]]; then
79
+ if [[ "$CHAIN_VER" == "$PKG_VER" ]]; then
80
+ ok "VERSION matches package.json ($PKG_VER)"
81
+ else
82
+ bad "VERSION mismatch: chain.js=$CHAIN_VER package.json=$PKG_VER"
83
+ fi
84
+ fi
85
+
86
+ if echo "$PKG_AUTHOR" | grep -qiE 'you@example\.com|arunsoman|TODO|placeholder'; then
87
+ warn "package.json author still looks like a placeholder: $PKG_AUTHOR"
88
+ else
89
+ ok "package.json author set"
90
+ fi
91
+
92
+ if echo "$PKG_REPO" | grep -qiE 'example\.com|TODO'; then
93
+ warn "package.json repository URL still a placeholder: $PKG_REPO"
94
+ else
95
+ ok "package.json repository URL set"
96
+ fi
97
+
98
+ PLACEHOLDER_HITS=$(grep -RInE 'example\.com|you@example\.com|TODO' \
99
+ --include='*.md' --include='*.json' --include='*.yml' --include='*.js' \
100
+ README.md package.json action.yml 2>/dev/null | grep -v preflight | head -20 || true)
101
+ if [[ -n "$PLACEHOLDER_HITS" ]]; then
102
+ warn "placeholder strings still in published files:"
103
+ while IFS= read -r line; do printf " ${DIM}%s${RESET}\n" "$line"; done <<< "$PLACEHOLDER_HITS"
104
+ else
105
+ ok "no arunsoman/you@example placeholders in core published files"
106
+ fi
107
+
108
+ # ─── 3. Secret scanning (static) ────────────────────────────────────────────
109
+ section "Secret-looking literals (GitHub push protection)"
110
+
111
+ # Continuous token-ish literals that secret scanners flag.
112
+ # We allow fragmented construction in tests; we ban continuous forms in tracked source.
113
+ SECRET_PATTERNS=(
114
+ 'xox[baprs]-[0-9A-Za-z-]{10,}'
115
+ 'sk-[A-Za-z0-9]{20,}'
116
+ 'sk-ant-[A-Za-z0-9_-]{20,}'
117
+ 'ghp_[A-Za-z0-9]{20,}'
118
+ 'sk_live_[A-Za-z0-9]{16,}'
119
+ 'sk_test_[A-Za-z0-9]{16,}'
120
+ 'AKIA[0-9A-Z]{16}'
121
+ 'AIza[0-9A-Za-z_-]{20,}'
122
+ )
123
+
124
+ secret_hits=0
125
+ for pat in "${SECRET_PATTERNS[@]}"; do
126
+ # scan source + tests + docs; skip binary, skip node_modules, skip .agentbox sessions
127
+ hits=$(grep -RInE "$pat" \
128
+ --include='*.js' --include='*.md' --include='*.json' --include='*.yml' --include='*.html' \
129
+ src test examples bin README.md CHANGELOG.md package.json action.yml docs 2>/dev/null \
130
+ | grep -v 'node_modules\|\.agentbox\|preflight\.sh' || true)
131
+ if [[ -n "$hits" ]]; then
132
+ secret_hits=$((secret_hits + 1))
133
+ bad "pattern /$pat/ found in source:"
134
+ while IFS= read -r line; do printf " ${DIM}%s${RESET}\n" "$line"; done <<< "$(echo "$hits" | head -5)"
135
+ fi
136
+ done
137
+ if [[ $secret_hits -eq 0 ]]; then
138
+ ok "no continuous secret-like literals in source tree"
139
+ fi
140
+
141
+ # ─── 4. Security docs ───────────────────────────────────────────────────────
142
+ section "Security documentation"
143
+
144
+ if grep -qE 'Security & privacy|redact|AGENTBOX_REDACT' README.md; then
145
+ ok "README mentions redaction / security"
146
+ else
147
+ bad "README missing Security & privacy / redaction docs"
148
+ fi
149
+
150
+ if grep -q 'AGENTBOX_REDACT' README.md; then
151
+ ok "README documents AGENTBOX_REDACT kill-switch"
152
+ else
153
+ warn "README does not document AGENTBOX_REDACT=0"
154
+ fi
155
+
156
+ # ─── 5. Unit / integration tests ────────────────────────────────────────────
157
+ section "Test suite"
158
+
159
+ if npm test >/tmp/agentbox-preflight-tests.log 2>&1; then
160
+ tcount=$(grep -cE '✔|✓' /tmp/agentbox-preflight-tests.log 2>/dev/null || echo 0)
161
+ ok "npm test passed ($tcount checks logged)"
162
+ else
163
+ bad "npm test FAILED — see /tmp/agentbox-preflight-tests.log"
164
+ tail -20 /tmp/agentbox-preflight-tests.log | sed 's/^/ /'
165
+ fi
166
+
167
+ # ─── 6. Live smoke (skip with --quick) ──────────────────────────────────────
168
+ # Helper: run a command with a hard timeout and stdin from /dev/null so a
169
+ # leftover TTY stdin listener can never hang the preflight process.
170
+ run_timed() {
171
+ local secs="$1"; shift
172
+ if command -v timeout >/dev/null 2>&1; then
173
+ timeout "$secs" "$@" </dev/null
174
+ else
175
+ "$@" </dev/null
176
+ fi
177
+ }
178
+
179
+ if [[ $QUICK -eq 0 ]]; then
180
+ section "Live smoke (demo / wrap / receipt / verify)"
181
+ printf " ${DIM}(demo takes ~5–6s — agent is intentionally slow)${RESET}\n"
182
+
183
+ TMP=$(mktemp -d)
184
+ trap 'rm -rf "$TMP"' EXIT
185
+
186
+ # demo
187
+ printf " ${DIM}→ agentbox demo…${RESET}\n"
188
+ if run_timed 30 node bin/agentbox.js demo >/tmp/agentbox-preflight-demo.log 2>&1; then
189
+ ok "agentbox demo completed"
190
+ else
191
+ ec=$?
192
+ if [[ $ec -eq 124 ]]; then
193
+ bad "agentbox demo TIMED OUT (30s) — likely stdin not detached after wrap"
194
+ else
195
+ bad "agentbox demo failed (exit $ec)"
196
+ fi
197
+ tail -15 /tmp/agentbox-preflight-demo.log | sed 's/^/ /'
198
+ fi
199
+
200
+ # wrap a trivial command in an isolated cwd so we don't pollute the repo
201
+ (
202
+ cd "$TMP"
203
+ printf " ${DIM}→ agentbox wrap…${RESET}\n"
204
+ if run_timed 15 node "$ROOT/bin/agentbox.js" wrap --name preflight -- \
205
+ node -e "console.log('preflight-ok')" \
206
+ >/tmp/agentbox-preflight-wrap.log 2>&1; then
207
+ ok "agentbox wrap records a child process"
208
+ else
209
+ ec=$?
210
+ if [[ $ec -eq 124 ]]; then
211
+ bad "agentbox wrap TIMED OUT (15s)"
212
+ else
213
+ bad "agentbox wrap failed (exit $ec)"
214
+ fi
215
+ tail -10 /tmp/agentbox-preflight-wrap.log | sed 's/^/ /'
216
+ exit 0
217
+ fi
218
+
219
+ SESSION=$(ls -t .agentbox/sessions/*.jsonl 2>/dev/null | head -1 || true)
220
+ if [[ -n "$SESSION" ]]; then
221
+ ok "session file created: $(basename "$SESSION")"
222
+ if run_timed 10 node "$ROOT/bin/agentbox.js" verify "$SESSION" >/tmp/agentbox-preflight-verify.log 2>&1; then
223
+ ok "agentbox verify: chain intact"
224
+ else
225
+ bad "agentbox verify failed"
226
+ fi
227
+ if run_timed 10 node "$ROOT/bin/agentbox.js" receipt "$SESSION" >/tmp/agentbox-preflight-receipt.log 2>&1; then
228
+ ok "agentbox receipt renders"
229
+ else
230
+ bad "agentbox receipt failed"
231
+ fi
232
+ if run_timed 15 node "$ROOT/bin/agentbox.js" clip "$SESSION" --out "$TMP/clip.html" >/tmp/agentbox-preflight-clip.log 2>&1; then
233
+ if [[ -f "$TMP/clip.html" ]]; then
234
+ ok "agentbox clip wrote HTML"
235
+ else
236
+ bad "agentbox clip produced no file"
237
+ fi
238
+ else
239
+ bad "agentbox clip failed"
240
+ fi
241
+ else
242
+ bad "no session file after wrap"
243
+ fi
244
+ )
245
+
246
+ # redaction smoke: secret must not land on disk
247
+ (
248
+ cd "$TMP"
249
+ printf " ${DIM}→ redaction smoke…${RESET}\n"
250
+ # assemble at runtime so this script itself isn't flagged
251
+ FAKE_SECRET="sk-""abcdefghijklmnopqrstuvwxyz""012345"
252
+ run_timed 15 node "$ROOT/bin/agentbox.js" wrap --name redact-smoke -- \
253
+ node -e "console.log('KEY=${FAKE_SECRET}')" \
254
+ >/tmp/agentbox-preflight-redact.log 2>&1 || true
255
+ SFILE=$(ls -t .agentbox/sessions/*redact-smoke*.jsonl 2>/dev/null | head -1 || true)
256
+ if [[ -n "$SFILE" ]]; then
257
+ if grep -qF "$FAKE_SECRET" "$SFILE"; then
258
+ bad "redaction failed — secret found in session file"
259
+ else
260
+ ok "redaction: secret not present on disk"
261
+ fi
262
+ if grep -q '\[REDACTED\]' "$SFILE"; then
263
+ ok "redaction: [REDACTED] placeholder present"
264
+ else
265
+ warn "redaction: no [REDACTED] placeholder (pattern may not have matched)"
266
+ fi
267
+ else
268
+ warn "redaction smoke: no session file to inspect"
269
+ fi
270
+ )
271
+ else
272
+ section "Live smoke"
273
+ warn "skipped (--quick)"
274
+ fi
275
+
276
+ # ─── 7. Git hygiene (if in a git repo) ──────────────────────────────────────
277
+ section "Git hygiene"
278
+
279
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
280
+ if git diff --quiet && git diff --cached --quiet; then
281
+ ok "working tree clean"
282
+ else
283
+ warn "uncommitted changes present — commit or stash before posting"
284
+ fi
285
+
286
+ # scan last 5 commits for secret-like blobs (cheap)
287
+ if git log -5 --pretty=format: --name-only 2>/dev/null | grep -q .; then
288
+ hist=$(git grep -nE 'xox[baprs]-[0-9A-Za-z-]{10,}|ghp_[A-Za-z0-9]{20,}' $(git rev-list -5 HEAD) 2>/dev/null | head -5 || true)
289
+ if [[ -n "$hist" ]]; then
290
+ bad "secret-like strings in recent git history (push protection will block):"
291
+ while IFS= read -r line; do printf " ${DIM}%s${RESET}\n" "$line"; done <<< "$hist"
292
+ else
293
+ ok "no obvious secrets in last 5 commits (git grep)"
294
+ fi
295
+ fi
296
+ else
297
+ warn "not a git repo — skip history checks"
298
+ fi
299
+
300
+ # ─── Summary ────────────────────────────────────────────────────────────────
301
+ printf "\n${BOLD}────────────────────────────────────────${RESET}\n"
302
+ printf "${BOLD} preflight result${RESET}\n"
303
+ printf " ${GREEN}passed${RESET}: %d\n" "$PASS"
304
+ printf " ${YELLOW}warnings${RESET}: %d\n" "$WARN"
305
+ printf " ${RED}failed${RESET}: %d\n" "$FAIL"
306
+
307
+ if [[ $FAIL -gt 0 ]]; then
308
+ printf "\n${RED}${BOLD}✗ NOT READY TO POST${RESET}\n"
309
+ printf " Fix these first:\n"
310
+ for f in "${failures[@]}"; do printf " ${RED}•${RESET} %s\n" "$f"; done
311
+ [[ $WARN -gt 0 ]] && printf " Also consider:\n" && for w in "${warnings[@]}"; do printf " ${YELLOW}•${RESET} %s\n" "$w"; done
312
+ exit 1
313
+ fi
314
+
315
+ if [[ $WARN -gt 0 ]]; then
316
+ printf "\n${YELLOW}${BOLD}✓ tests green, but warnings remain${RESET}\n"
317
+ for w in "${warnings[@]}"; do printf " ${YELLOW}•${RESET} %s\n" "$w"; done
318
+ printf " ${DIM}Post only if those are intentional (placeholders, etc.).${RESET}\n"
319
+ exit 0
320
+ fi
321
+
322
+ printf "\n${GREEN}${BOLD}✓ CLEAR TO POST${RESET}\n"
323
+ printf " ${DIM}Demo GIF + honest title + Security section link in first comment.${RESET}\n"
324
+ exit 0
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+ /**
3
+ * agentbox — adapters/claude.js
4
+ * The Claude Code hook adapter: passive capture, no wrapper needed.
5
+ *
6
+ * agentbox init claude # one command: wires hooks into .claude/settings.json
7
+ * agentbox hook claude # what every hook invocation runs (reads the
8
+ * # hook payload JSON from stdin, appends one
9
+ * # hash-chained event, exits 0 — ALWAYS)
10
+ *
11
+ * Design rules (a recorder must never break a flight):
12
+ * 1. exit 0 no matter what — even on internal errors
13
+ * 2. never print to stdout on the hot path (hooks parse stdout as control JSON)
14
+ * 3. drop events under contention rather than fork the hash chain
15
+ * 4. per-Claude-session file, deterministic name → zero state files
16
+ *
17
+ * Session mapping: claude session_id → .agentbox/sessions/<stamp>-claude-<sid>.jsonl
18
+ */
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const {
23
+ VERSION, loadEvents, verifyChain, appendToChain, withFileLock, sessionsDir, assertNotSymlink,
24
+ } = require('../chain');
25
+ const { summarize, verdict } = require('../parse');
26
+ const { markdownReceipt } = require('../receipt');
27
+
28
+ // The hook events agentbox manages in settings.json. Entries are recognized
29
+ // for idempotent re-init / --remove by this marker inside the command string.
30
+ const HOOK_MARKER = 'hook claude';
31
+ const MANAGED_EVENTS = [
32
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
33
+ 'Notification', 'Stop', 'SessionEnd',
34
+ ];
35
+
36
+ function trunc(s, n) {
37
+ const str = s == null ? '' : String(s);
38
+ return str.length <= n ? str : str.slice(0, n);
39
+ }
40
+
41
+ /** Shrink a tool payload: keep objects under cap chars, else stringify+truncate. */
42
+ function slim(v, cap) {
43
+ if (v == null) return null;
44
+ if (typeof v === 'string') return trunc(v, cap);
45
+ try {
46
+ const j = JSON.stringify(v);
47
+ if (j.length <= cap) return v;
48
+ const keys = ['command', 'file_path', 'notebook_path', 'path', 'url', 'query', 'pattern'];
49
+ const summary = { _truncated: true };
50
+ for (const key of keys) if (v[key] != null) summary[key] = trunc(v[key], cap / 2);
51
+ return Object.keys(summary).length > 1 ? summary : trunc(j, cap);
52
+ } catch { return trunc(String(v), cap); }
53
+ }
54
+
55
+ function readStdin() {
56
+ return new Promise((resolve) => {
57
+ if (process.stdin.isTTY) return resolve('');
58
+ let buf = '';
59
+ const timer = setTimeout(() => { try { process.stdin.destroy(); } catch { /* gone */ } resolve(buf); }, 10000);
60
+ process.stdin.setEncoding('utf8');
61
+ process.stdin.on('data', (c) => { buf += c; });
62
+ process.stdin.on('end', () => { clearTimeout(timer); resolve(buf); });
63
+ process.stdin.on('error', () => { clearTimeout(timer); resolve(buf); });
64
+ });
65
+ }
66
+
67
+ /** Deterministic session file for a Claude session_id (no state file needed). */
68
+ function claudeSessionFile(cwd, sid) {
69
+ const dir = sessionsDir(cwd);
70
+ const safe = String(sid || 'unknown').replace(/[^\w-]/g, '').slice(0, 64) || 'unknown';
71
+ const suffix = `-claude-${safe}.jsonl`;
72
+ let files = [];
73
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith(suffix)).sort(); } catch { /* no dir yet */ }
74
+ if (files.length) return path.join(dir, files[files.length - 1]);
75
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
76
+ return path.join(dir, `${stamp}${suffix}`);
77
+ }
78
+
79
+ function toolResponseIsError(resp) {
80
+ if (!resp || typeof resp !== 'object') return false;
81
+ if (resp.is_error === true || resp.isError === true) return true;
82
+ return resp.error != null;
83
+ }
84
+
85
+ /** Write the auto receipt on SessionEnd (`.agentbox/receipts/<session>.md`). */
86
+ function autoReceipt(cwd, file) {
87
+ if (process.env.AGENTBOX_HOOKS_RECEIPT === '0') return;
88
+ const res = verifyChain(file);
89
+ if (!res.ok || !res.complete) return;
90
+ const stats = summarize(res.events);
91
+ const dir = path.join(cwd, '.agentbox', 'receipts');
92
+ const out = path.join(dir, path.basename(file).replace(/\.jsonl$/, '.md'));
93
+ assertNotSymlink(out);
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ const md = [
96
+ '# ⬢ agentbox — flight receipt (auto-generated on session end)',
97
+ '',
98
+ markdownReceipt(stats, true),
99
+ '',
100
+ `> ${verdict(stats)}`,
101
+ '',
102
+ ].join('\n');
103
+ fs.writeFileSync(out, md);
104
+ }
105
+
106
+ /**
107
+ * Route one hook payload into the chain. Sync on purpose — the process
108
+ * exits right after. Called under the session file lock.
109
+ */
110
+ function recordHookEvent(ev) {
111
+ const sid = ev.session_id || 'unknown';
112
+ const sidSafe = String(sid).replace(/[^\w-]/g, '').slice(0, 64) || 'unknown';
113
+ // Hook payloads are untrusted input; never let them redirect filesystem writes.
114
+ const cwd = process.cwd();
115
+ const file = claudeSessionFile(cwd, sid);
116
+ const isNew = !fs.existsSync(file);
117
+
118
+ withFileLock(file, (locked) => {
119
+ if (!locked) return; // contention timeout — drop the event, exit 0 (rule 3)
120
+ if (isNew) {
121
+ appendToChain(file, 'meta', {
122
+ name: `claude-${sidSafe.slice(0, 8)}`,
123
+ cmd: 'claude (passive — via hooks)',
124
+ argv: ['claude'],
125
+ adapter: 'claude-code',
126
+ session_id: sid,
127
+ cwd,
128
+ user: os.userInfo().username,
129
+ host: os.hostname(),
130
+ platform: `${process.platform} ${process.arch}`,
131
+ agentbox: VERSION,
132
+ pid: process.pid,
133
+ });
134
+ }
135
+
136
+ switch (ev.hook_event_name) {
137
+ case 'SessionStart':
138
+ appendToChain(file, 'note', { message: `session start (${ev.source || 'startup'})` });
139
+ break;
140
+ case 'UserPromptSubmit':
141
+ appendToChain(file, 'prompt', { text: trunc(ev.prompt, 1000), source: 'claude-code' });
142
+ break;
143
+ case 'PreToolUse':
144
+ appendToChain(file, 'tool_call', {
145
+ phase: 'start',
146
+ name: String(ev.tool_name || 'unknown'),
147
+ input: slim(ev.tool_input, 2000),
148
+ tool_use_id: ev.tool_use_id == null ? null : String(ev.tool_use_id),
149
+ source: 'claude-code',
150
+ });
151
+ break;
152
+ case 'PostToolUse':
153
+ appendToChain(file, 'tool_call', {
154
+ phase: 'end',
155
+ name: String(ev.tool_name || 'unknown'),
156
+ tool_use_id: ev.tool_use_id == null ? null : String(ev.tool_use_id),
157
+ status: toolResponseIsError(ev.tool_response) ? 'error' : 'ok',
158
+ preview: slim(ev.tool_response, 500),
159
+ source: 'claude-code',
160
+ });
161
+ break;
162
+ case 'Notification':
163
+ appendToChain(file, 'notification', { message: trunc(ev.message, 300) });
164
+ break;
165
+ case 'Stop':
166
+ appendToChain(file, 'turn_end', {});
167
+ break;
168
+ case 'SubagentStop':
169
+ appendToChain(file, 'note', { message: 'subagent stopped' });
170
+ break;
171
+ case 'PreCompact':
172
+ appendToChain(file, 'note', { message: 'context compacted' });
173
+ break;
174
+ case 'SessionEnd': {
175
+ let started = null;
176
+ try { started = loadEvents(file).events.find((e) => e.type === 'meta'); } catch { /* keep null */ }
177
+ appendToChain(file, 'exit', {
178
+ code: 0,
179
+ durationMs: started ? Date.now() - started.t : null,
180
+ reason: ev.reason || 'session-end',
181
+ source: 'claude-code',
182
+ });
183
+ try { autoReceipt(cwd, file); } catch { /* receipt is a luxury, never a failure */ }
184
+ break;
185
+ }
186
+ default:
187
+ // Unknown/forward-compatible hook events still land on the tape.
188
+ appendToChain(file, 'note', { message: `hook: ${ev.hook_event_name || 'unknown-event'}` });
189
+ }
190
+ });
191
+ }
192
+
193
+ /** `agentbox hook claude` — read payload, record, exit 0. Always. */
194
+ async function runHook() {
195
+ let ev = {};
196
+ try { ev = JSON.parse((await readStdin()) || '{}'); } catch { ev = { hook_event_name: 'unknown-event' }; }
197
+ try {
198
+ recordHookEvent(ev);
199
+ } catch (e) {
200
+ if (process.env.AGENTBOX_DEBUG) {
201
+ process.stderr.write(`agentbox hook: swallowed error: ${e && e.message}\n`);
202
+ }
203
+ }
204
+ process.exit(0); // rule 1: the flight always continues
205
+ }
206
+
207
+ /** Absolute, space-safe command string that lands inside settings.json hooks. */
208
+ function hookCommand() {
209
+ const bin = path.join(__dirname, '..', '..', 'bin', 'agentbox.js');
210
+ const quoted = `'${bin.replace(/'/g, `'"'"'`)}'`;
211
+ return `node ${quoted} ${HOOK_MARKER}`;
212
+ }
213
+
214
+ function settingsPath(opts) {
215
+ return path.join(process.cwd(), '.claude', opts && opts.local ? 'settings.local.json' : 'settings.json');
216
+ }
217
+
218
+ function entryIsOurs(entry) {
219
+ const expected = hookCommand();
220
+ return entry && Array.isArray(entry.hooks) && entry.hooks.some((h) => h && h.type === 'command' && h.command === expected);
221
+ }
222
+
223
+ /**
224
+ * `agentbox init claude [--local] [--remove]`
225
+ * Merge agentbox hooks into .claude/settings.json (idempotent), or strip them.
226
+ * Returns { file, changed, events }.
227
+ */
228
+ function initClaude(opts = {}) {
229
+ const file = settingsPath(opts);
230
+ assertNotSymlink(file);
231
+ const existed = fs.existsSync(file);
232
+ try {
233
+ if (fs.lstatSync(file).isSymbolicLink()) {
234
+ process.stderr.write(`⬢ agentbox: refusing symlinked settings file ${file}\n`);
235
+ process.exitCode = 1;
236
+ return { file, changed: false };
237
+ }
238
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
239
+ let settings = {};
240
+ if (existed) {
241
+ try { settings = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {
242
+ process.stderr.write(`⬢ agentbox: cannot parse ${file} — ${e.message}\nfix it first, or use --local for a separate file\n`);
243
+ process.exitCode = 1;
244
+ return { file, changed: false };
245
+ }
246
+ }
247
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
248
+
249
+ let changed = 0;
250
+
251
+ if (opts.remove) {
252
+ for (const event of Object.keys(settings.hooks)) {
253
+ const groups = settings.hooks[event];
254
+ if (!Array.isArray(groups)) continue;
255
+ const kept = groups.filter((g) => !entryIsOurs(g));
256
+ if (kept.length !== groups.length) changed += groups.length - kept.length;
257
+ if (kept.length) settings.hooks[event] = kept;
258
+ else delete settings.hooks[event];
259
+ }
260
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
261
+ } else {
262
+ // first-time safety net: never clobber a hand-written settings file silently
263
+ if (existed && !fs.existsSync(`${file}.agentbox-backup`)) {
264
+ try { fs.copyFileSync(file, `${file}.agentbox-backup`); } catch { /* best effort */ }
265
+ }
266
+ const cmd = hookCommand();
267
+ for (const event of MANAGED_EVENTS) {
268
+ const groups = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
269
+ if (groups.some(entryIsOurs)) continue; // idempotent
270
+ groups.push({ hooks: [{ type: 'command', command: cmd }] });
271
+ settings.hooks[event] = groups;
272
+ changed += 1;
273
+ }
274
+ }
275
+
276
+ if (changed > 0 || !existed) {
277
+ fs.mkdirSync(path.dirname(file), { recursive: true });
278
+ fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
279
+ }
280
+
281
+ const CYAN = '\x1b[36m'; const BOLD = '\x1b[1m'; const DIM = '\x1b[2m'; const GREEN = '\x1b[32m'; const RESET = '\x1b[0m';
282
+ if (opts.remove) {
283
+ process.stdout.write(`${CYAN}${BOLD}⬢ agentbox${RESET}: removed ${changed} hook entr${changed === 1 ? 'y' : 'ies'} from ${DIM}${file}${RESET}\n`);
284
+ } else if (changed === 0) {
285
+ process.stdout.write(`${CYAN}${BOLD}⬢ agentbox${RESET}: hooks already installed in ${DIM}${file}${RESET} ${GREEN}(nothing to do)${RESET}\n`);
286
+ } else {
287
+ process.stdout.write(`${CYAN}${BOLD}⬢ agentbox${RESET}: passive mode ON — ${changed} hooks → ${DIM}${file}${RESET}\n`);
288
+ if (existed) process.stdout.write(`${DIM} original backed up to ${file}.agentbox-backup${RESET}\n`);
289
+ process.stdout.write(`
290
+ ${BOLD}what gets recorded${RESET} (per claude session → .agentbox/sessions/):
291
+ SessionStart flight opened
292
+ UserPromptSubmit every prompt you type
293
+ PreToolUse every tool call before it runs (name + arguments)
294
+ PostToolUse every result (ok / error)
295
+ Notification agent pings
296
+ Stop turn boundaries
297
+ SessionEnd flight closed + auto receipt → .agentbox/receipts/
298
+
299
+ ${BOLD}next${RESET}: start a ${DIM}claude${RESET} session in this project, then:
300
+ agentbox list see the flight
301
+ agentbox receipt read the tape
302
+ `);
303
+ process.stdout.write(`${DIM} hooks are read at session start — restart claude to pick them up${RESET}\n`);
304
+ }
305
+ return { file, changed };
306
+ }
307
+
308
+ module.exports = { runHook, initClaude, claudeSessionFile, HOOK_MARKER, MANAGED_EVENTS };