acbridge 0.0.1 → 1.0.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.
@@ -0,0 +1,166 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "*",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/gate.mjs"
10
+ }
11
+ ]
12
+ },
13
+ {
14
+ "matcher": "AskUserQuestion|ExitPlanMode|Workflow",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
19
+ }
20
+ ]
21
+ }
22
+ ],
23
+ "SessionStart": [
24
+ {
25
+ "hooks": [
26
+ {
27
+ "type": "command",
28
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
29
+ }
30
+ ]
31
+ }
32
+ ],
33
+ "SessionEnd": [
34
+ {
35
+ "hooks": [
36
+ {
37
+ "type": "command",
38
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
39
+ }
40
+ ]
41
+ }
42
+ ],
43
+ "UserPromptSubmit": [
44
+ {
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
49
+ }
50
+ ]
51
+ }
52
+ ],
53
+ "Stop": [
54
+ {
55
+ "hooks": [
56
+ {
57
+ "type": "command",
58
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
59
+ }
60
+ ]
61
+ }
62
+ ],
63
+ "Notification": [
64
+ {
65
+ "hooks": [
66
+ {
67
+ "type": "command",
68
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
69
+ }
70
+ ]
71
+ }
72
+ ],
73
+ "PostToolUse": [
74
+ {
75
+ "matcher": "*",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
80
+ }
81
+ ]
82
+ }
83
+ ],
84
+ "SubagentStart": [
85
+ {
86
+ "hooks": [
87
+ {
88
+ "type": "command",
89
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
90
+ }
91
+ ]
92
+ }
93
+ ],
94
+ "SubagentStop": [
95
+ {
96
+ "hooks": [
97
+ {
98
+ "type": "command",
99
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
100
+ }
101
+ ]
102
+ }
103
+ ],
104
+ "PreCompact": [
105
+ {
106
+ "hooks": [
107
+ {
108
+ "type": "command",
109
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
110
+ }
111
+ ]
112
+ }
113
+ ],
114
+ "PostCompact": [
115
+ {
116
+ "hooks": [
117
+ {
118
+ "type": "command",
119
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
120
+ }
121
+ ]
122
+ }
123
+ ],
124
+ "StopFailure": [
125
+ {
126
+ "hooks": [
127
+ {
128
+ "type": "command",
129
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
130
+ }
131
+ ]
132
+ }
133
+ ],
134
+ "PostToolUseFailure": [
135
+ {
136
+ "matcher": "*",
137
+ "hooks": [
138
+ {
139
+ "type": "command",
140
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
141
+ }
142
+ ]
143
+ }
144
+ ],
145
+ "PermissionDenied": [
146
+ {
147
+ "hooks": [
148
+ {
149
+ "type": "command",
150
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
151
+ }
152
+ ]
153
+ }
154
+ ],
155
+ "TaskCompleted": [
156
+ {
157
+ "hooks": [
158
+ {
159
+ "type": "command",
160
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/signal.mjs"
161
+ }
162
+ ]
163
+ }
164
+ ]
165
+ }
166
+ }
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+ // Session-state SIGNAL hook for OpenAI Codex CLI (Task A4 — the Codex sibling of `signal.mjs`, which
3
+ // does the same job for Claude Code). Codex's hooks engine invokes ONE hook command per lifecycle event
4
+ // with a single JSON object on stdin; this script classifies the event and POSTs a lightweight signal to
5
+ // the LOCAL hub's loopback control API — POST /local/session/state for a state, POST
6
+ // /local/session/context for a state-less context TICK (PostToolUse). The hub's RulesEngine fires the
7
+ // matching Profile rule keyed by `agent:"codex"` (A3), so a repo-bound Profile reacts to Codex exactly
8
+ // like it reacts to Claude, distinguished by the `~codex` id suffix (below).
9
+ //
10
+ // hook source/condition state
11
+ // ───────────────── ─────────────────────── ──────────────────────────────────────────────────────
12
+ // SessionStart startup / clear / absent started
13
+ // SessionStart resume resumed
14
+ // SessionStart compact SILENT — Pre/PostCompact own compaction
15
+ // UserPromptSubmit — working
16
+ // Stop — finished
17
+ // PermissionRequest — needs_permission (observe-and-abstain: NEVER stdout —
18
+ // Codex's permission-shaped hooks read stdout as an
19
+ // approve/deny decision; this script never writes one)
20
+ // PreCompact — compacting
21
+ // PostCompact — compactDone
22
+ // SubagentStart — subagentStart
23
+ // SubagentStop — subagentFinished
24
+ // PostToolUse — a state-less context TICK (POST .../context)
25
+ // anything else — exit 0, nothing to report
26
+ //
27
+ // Unlike Claude Code, Codex's own hook payload carries `model` and `permission_mode` on EVERY event, so
28
+ // the mode/model FERRY (feeding the hub's modePlan/modeAuto/.../modelChanged synthesis, same as
29
+ // signal.mjs) is a straight field read here — no transcript walk needed for that part. The transcript
30
+ // walk we DO still need is `readRolloutTail` below: Codex's rollout JSONL carries the session's live
31
+ // token usage as `token_count` event rows, read the same defensive last-64KB-tail way signal.mjs reads
32
+ // Claude's transcript, to feed the hub's SessionContextTracker (contextLow/contextCritical thresholds).
33
+ //
34
+ // ACTIVATION: resolve this run's hub coordinates from (1) the BRIDGE_* env (a launcher that exports
35
+ // them), (2) the shared per-session handshake file `~/.claude-bridge/sessions/<session_id>.json`
36
+ // (agent-agnostic: the same dir/shape signal.mjs and the channel use, keyed by whatever agent's own
37
+ // session id), or (3) self-derive `${project}@${machine}~codex` when a hub token file exists on this
38
+ // machine (no hub configured here ⇒ exit 0, POST nothing). UNLIKE signal.mjs — which skips writing at
39
+ // SessionStart to dodge a race with the channel MCP server connecting — Codex has NO channel MCP to race,
40
+ // so the self-derived handshake is written on EVERY event, SessionStart included (scope.mjs's write
41
+ // helper is itself idempotent: an existing file, whoever wrote it, is always adopted, never overwritten).
42
+ // Off-switch: BRIDGE_AUTO_PROFILES=0/false or `"autoProfiles": false` in config.json — mirrors signal.mjs
43
+ // exactly, and likewise gates ONLY step (3): explicit env coords (1) and an existing handshake file (2)
44
+ // still work with the off-switch set.
45
+ //
46
+ // Part-B ADAPTER SUPPRESSION: a future adapter (`ACB_CODEX_ADAPTER` env, inherited by Codex's child
47
+ // processes) owns a session's states directly — if set (any non-empty value), this script is a silent
48
+ // no-op, so the two paths never double-fire signals for the same session.
49
+ //
50
+ // FAIL-SAFE: any parse/IO/network error ⇒ exit 0 within ~2s. A hook must NEVER block Codex; a dead hub, a
51
+ // missing token, or an unclassifiable event all degrade to silence. Zero npm dependencies (node: builtins
52
+ // only); stdout is NEVER written to (Codex hook stdout can influence approve/deny — this script is
53
+ // observe-only, on every path, always).
54
+
55
+ import { closeSync, fstatSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
56
+ import { request } from "node:http";
57
+ import os from "node:os";
58
+ import path from "node:path";
59
+ import { readActiveUserSub, resolveBridgeHome, resolveHubTokenFile, writePlainSessionHandshakeIfAbsent } from "./scope.mjs";
60
+
61
+ const DEFAULT_HUB_PORT = 8790; // mirrors @bridge/node-core HUB_PORT — the plugin can't import the workspace
62
+ // XPLAT-HOME (2026-07-28 review): WHICH `.claude-bridge` home this run should read — exactly what the
63
+ // Claude twins (signal.mjs / statusline.mjs) already resolve, and what this script used to skip. It
64
+ // pinned the LOCAL home unconditionally, so a Codex session running in WSL while the hub runs on Windows
65
+ // (or vice versa) read the wrong token / active-user / sessions dir: every signal silently no-opped and
66
+ // Codex Profiles were dead across the boundary, with no breadcrumb to say why.
67
+ const BRIDGE_HOME = resolveBridgeHome();
68
+ const SECRET_DIR = BRIDGE_HOME.dir;
69
+ /** THIS environment's own home — where a diagnostic breadcrumb belongs (a peer home is not ours). */
70
+ const LOCAL_SECRET_DIR = process.env.BRIDGE_SECRET_DIR || path.join(os.homedir(), ".claude-bridge");
71
+
72
+ /** Exit 0 no matter what — a hook that throws would block Codex; we only ever no-op or POST. */
73
+ function done() {
74
+ process.exit(0);
75
+ }
76
+
77
+ let event;
78
+ try {
79
+ event = JSON.parse(readFileSync(0, "utf8") || "{}");
80
+ } catch {
81
+ done(); // unreadable/malformed event → no-op
82
+ }
83
+ if (event === null || typeof event !== "object") done(); // non-object JSON (e.g. `null`, `42`) → nothing to classify
84
+
85
+ // Part-B: an adapter owns this session's states already — bail before touching the handshake or the hub.
86
+ if (process.env.ACB_CODEX_ADAPTER) done();
87
+
88
+ // ── Dormancy gate: resolve THIS run's hub coordinates, mirroring signal.mjs's resolution order. ──────
89
+ const isPlaceholder = (v) => Boolean(v) && /^\$\{.*\}$/.test(v); // sanitized-env literal `${BRIDGE_*}`
90
+ const envVar = (v) => (v && !isPlaceholder(v) ? v : undefined);
91
+
92
+ function resolveHandshake(sessionId) {
93
+ // Dev / direct path: explicit BRIDGE_* env wins.
94
+ const envId = envVar(process.env.BRIDGE_SESSION_ID);
95
+ if (envId) {
96
+ return {
97
+ id: envId,
98
+ hubPort: Number(envVar(process.env.BRIDGE_HUB_PORT)) || DEFAULT_HUB_PORT,
99
+ hubTokenFile: resolveHubTokenFile(SECRET_DIR, envVar(process.env.BRIDGE_HUB_TOKEN_FILE)),
100
+ };
101
+ }
102
+ // Correlate via Codex's own session_id → the shared per-session handshake file (agent-agnostic dir).
103
+ if (!sessionId) return null;
104
+ try {
105
+ const h = JSON.parse(readFileSync(path.join(SECRET_DIR, "sessions", `${sessionId}.json`), "utf8"));
106
+ if (!h || !h.id) return null;
107
+ return {
108
+ id: h.id,
109
+ hubPort: Number(h.hubPort) || DEFAULT_HUB_PORT,
110
+ hubTokenFile: resolveHubTokenFile(SECRET_DIR, h.hubTokenFile),
111
+ };
112
+ } catch {
113
+ return null; // no handshake file yet ⇒ try self-derive below
114
+ }
115
+ }
116
+
117
+ // Mirror @bridge/node-core machineLabel() + cli loadConfig() inline — the plugin can't import the
118
+ // workspace (same duplication signal.mjs/statusline.mjs already carry). `codexContextWindow` is the
119
+ // readRolloutTail override (see below) — read from the SAME config.json, tolerant of its absence.
120
+ function readCliConfig() {
121
+ let file = {};
122
+ try {
123
+ file = JSON.parse(readFileSync(path.join(SECRET_DIR, "config.json"), "utf8")) || {};
124
+ } catch {
125
+ /* no config file → env + hostname defaults */
126
+ }
127
+ return {
128
+ machine: envVar(process.env.BRIDGE_MACHINE) || file.machine || os.hostname().split(".")[0] || "node",
129
+ // BEACON BEFORE CONFIG (settled 2026-07-29). The beacon is the port the hub ACTUALLY bound; a
130
+ // config.json `hubPort` is only what it was asked to bind, and the two disagree whenever the hub
131
+ // fell back. The channel handshake, doctor and the app/CLI control planes all put the beacon first;
132
+ // the hooks were the odd ones out, so one box could answer this question two ways. An explicit
133
+ // BRIDGE_HUB_PORT still wins over both — that is a deliberate override, not a guess.
134
+ hubPort: Number(envVar(process.env.BRIDGE_HUB_PORT) || BRIDGE_HOME.beacon?.port || file.hubPort || DEFAULT_HUB_PORT),
135
+ codexContextWindow: typeof file.codexContextWindow === "number" && file.codexContextWindow > 0 ? file.codexContextWindow : undefined,
136
+ autoProfiles: file.autoProfiles,
137
+ };
138
+ }
139
+
140
+ // Auto-profiles is ON by default. Off-switch: BRIDGE_AUTO_PROFILES=0/false, or `"autoProfiles": false`
141
+ // in config.json — mirrors signal.mjs's autoProfilesEnabled() exactly. Gates ONLY the self-derive path
142
+ // below: explicit BRIDGE_* env coords and an existing per-session handshake file are unaffected.
143
+ function autoProfilesEnabled(cfg) {
144
+ const env = process.env.BRIDGE_AUTO_PROFILES;
145
+ if (env === "0" || env === "false") return false;
146
+ return cfg.autoProfiles !== false;
147
+ }
148
+
149
+ /** id `${project}@${machine}~codex`, name `"${project} codex (${machine})"` — the `~codex` suffix keeps
150
+ * a Claude and a Codex session in the SAME repo distinct in the hub's tracker/debounce (both key purely
151
+ * on this opaque id string). `name` has no wire slot today (neither SessionStateRequest/
152
+ * SessionContextRequest nor the shared handshake writer carries one) — computed for identity
153
+ * completeness so a future consumer doesn't have to re-derive the format. */
154
+ function selfDeriveIdentity(project, machine) {
155
+ return { id: `${project}@${machine}~codex`, name: `${project} codex (${machine})` };
156
+ }
157
+
158
+ // No per-session handshake, but this machine HAS a hub configured (a hub-token file exists). Derive the
159
+ // project@machine~codex id and WRITE the handshake — on every event, SessionStart included (no channel
160
+ // MCP here to race, unlike signal.mjs). Returns null when the bridge was never set up here (no token).
161
+ function selfDeriveHandshake(ev, cfg) {
162
+ const hubTokenFile = resolveHubTokenFile(SECRET_DIR, undefined);
163
+ try {
164
+ if (!readFileSync(hubTokenFile, "utf8").trim()) return null;
165
+ } catch {
166
+ return null; // no hub token (scoped or machine-global) → bridge not set up on this machine → silent
167
+ }
168
+ const cwd = typeof ev.cwd === "string" && ev.cwd ? ev.cwd : process.cwd();
169
+ const project = path.basename(cwd) || "session";
170
+ const identity = selfDeriveIdentity(project, cfg.machine);
171
+ // No signed-in account (readActiveUserSub → null) ⇒ the write helper itself writes nothing — an
172
+ // unattributable session must never be adopted. An existing (any writer's) file is never overwritten.
173
+ writePlainSessionHandshakeIfAbsent({
174
+ secretDir: SECRET_DIR,
175
+ id: identity.id,
176
+ hubPort: cfg.hubPort,
177
+ hubTokenFile,
178
+ cwd,
179
+ claudeSessionId: typeof ev.session_id === "string" ? ev.session_id : undefined,
180
+ sub: readActiveUserSub(SECRET_DIR),
181
+ });
182
+ return { id: identity.id, hubPort: cfg.hubPort, hubTokenFile };
183
+ }
184
+
185
+ const cfg = readCliConfig();
186
+ let handshake = resolveHandshake(typeof event.session_id === "string" ? event.session_id : undefined);
187
+ if (!handshake && autoProfilesEnabled(cfg)) handshake = selfDeriveHandshake(event, cfg);
188
+ if (!handshake) done(); // no bridge session AND no hub configured (or auto-profiles off) — stay silent
189
+
190
+ // ── Classify table: hook_event_name (+ source) → one wire state, {tick:true}, or null. ────────────────
191
+ function classify(ev) {
192
+ const name = String(ev.hook_event_name ?? "");
193
+ switch (name) {
194
+ case "SessionStart": {
195
+ const source = String(ev.source ?? "");
196
+ // A compact "restart" is the SAME session continuing — PostCompact owns compaction.
197
+ if (source === "compact") return null;
198
+ return { state: source === "resume" ? "resumed" : "started" };
199
+ }
200
+ case "UserPromptSubmit":
201
+ return { state: "working" };
202
+ case "Stop":
203
+ return { state: "finished" };
204
+ case "PermissionRequest":
205
+ // Observe-and-abstain: classify it for the device signal, but NEVER emit stdout (below, always).
206
+ return { state: "needs_permission" };
207
+ case "PreCompact":
208
+ return { state: "compacting" };
209
+ case "PostCompact":
210
+ return { state: "compactDone" };
211
+ case "SubagentStart":
212
+ return { state: "subagentStart" };
213
+ case "SubagentStop":
214
+ return { state: "subagentFinished" };
215
+ case "PostToolUse": {
216
+ // S2: legacy best-effort error detection — MIRRORS signal.mjs's exact tool_response check (the
217
+ // Claude sibling, same field names): fire only when the tool response explicitly signals a hard
218
+ // failure, biased to UNDER-fire (an unflagged failure stays silent) so a normal session never
219
+ // flashes the error scene.
220
+ const resp = ev.tool_response;
221
+ const isError =
222
+ resp &&
223
+ typeof resp === "object" &&
224
+ (resp.is_error === true ||
225
+ resp.isError === true ||
226
+ (typeof resp.error === "string" && resp.error.length > 0));
227
+ if (isError) return { state: "error" };
228
+ // The per-tool cadence that keeps the hub's context meter fresh during a long working stretch.
229
+ return { tick: true };
230
+ }
231
+ default:
232
+ return null; // an unregistered/future hook reached us → nothing to report
233
+ }
234
+ }
235
+
236
+ const mapped = classify(event);
237
+ if (!mapped) done(); // this event carries no state and no tick → no-op
238
+
239
+ // ── readRolloutTail: the context ferry. Positioned read of the last 64KB only — rollouts reach tens of
240
+ // MB and this runs per tool call. Walk BACKWARD for the first (= latest) token_count row; cumulative
241
+ // totals are interspersed frequently, so the latest one is the current reading. Field layout has drifted
242
+ // across Codex versions (`info` wrapper sometimes absent; `model_context_window` sometimes absent/null),
243
+ // so every field is read defensively — accept both `payload.info.total_token_usage` and
244
+ // `payload.total_token_usage`. Any error → no context reading (never guess); the signal still POSTs. ────
245
+
246
+ // Best-effort context-window sizes for named Codex model slugs (July 2026, approximate — Codex reports
247
+ // the REAL value in `model_context_window` when its payload row has it, so this table is only a fallback
248
+ // for rows/builds that omit it). A short honest table beats a fake-complete one; extend as OpenAI ships
249
+ // new slugs. An unlisted slug falls to DEFAULT_WINDOW (itself overridable via config `codexContextWindow`).
250
+ const MODEL_WINDOWS = {
251
+ "gpt-5-codex": 400_000,
252
+ "gpt-5": 400_000,
253
+ "o4-mini": 200_000,
254
+ };
255
+ const DEFAULT_WINDOW = 400_000;
256
+
257
+ /** One token_count row's usage, or null when the row isn't a usable token_count row. */
258
+ function extractUsage(rec) {
259
+ if (!rec || typeof rec !== "object" || rec.type !== "event_msg") return null;
260
+ const payload = rec.payload;
261
+ if (!payload || typeof payload !== "object" || payload.type !== "token_count") return null;
262
+ const info = payload.info && typeof payload.info === "object" ? payload.info : payload;
263
+ const usage = info.total_token_usage;
264
+ if (!usage || typeof usage !== "object") return null;
265
+ const inputTokens = usage.input_tokens;
266
+ if (typeof inputTokens !== "number" || !(inputTokens >= 0)) return null;
267
+ const windowRaw = info.model_context_window;
268
+ return {
269
+ // Deliberately NOT adding cached_input_tokens — flagging here: live-smoke calibration against
270
+ // Codex's own /status display may show that display sums input+cached, in which case this flips.
271
+ usedTokens: inputTokens,
272
+ windowTokens: typeof windowRaw === "number" && windowRaw > 0 ? windowRaw : undefined,
273
+ };
274
+ }
275
+
276
+ function readRolloutTail(transcriptPath, model, config) {
277
+ try {
278
+ if (typeof transcriptPath !== "string" || !transcriptPath) return null;
279
+ const fd = openSync(transcriptPath, "r");
280
+ let text, torn;
281
+ try {
282
+ const size = fstatSync(fd).size;
283
+ const len = Math.min(size, 65536);
284
+ if (len === 0) return null;
285
+ const buf = Buffer.alloc(len);
286
+ readSync(fd, buf, 0, len, size - len);
287
+ text = buf.toString("utf8");
288
+ torn = size > len; // the window may open mid-line → the first fragment isn't trustworthy JSON
289
+ } finally {
290
+ closeSync(fd);
291
+ }
292
+ const lines = text.split("\n");
293
+ for (let i = lines.length - 1; i >= (torn ? 1 : 0); i--) {
294
+ const line = lines[i];
295
+ if (!line || line.charCodeAt(0) !== 123 /* "{" */) continue;
296
+ let rec;
297
+ try {
298
+ rec = JSON.parse(line);
299
+ } catch {
300
+ continue;
301
+ }
302
+ const usage = extractUsage(rec);
303
+ if (!usage) continue;
304
+ const windowTokens = usage.windowTokens ?? MODEL_WINDOWS[model] ?? config.codexContextWindow ?? DEFAULT_WINDOW;
305
+ return { pct: Math.min(100, Math.round((usage.usedTokens / windowTokens) * 1000) / 10), usedTokens: usage.usedTokens, windowTokens };
306
+ }
307
+ return null; // no token_count row in the tail → no reading, never guess
308
+ } catch {
309
+ return null; // unreadable/garbled rollout → no reading, never a failure
310
+ }
311
+ }
312
+
313
+ const context = readRolloutTail(
314
+ typeof event.transcript_path === "string" ? event.transcript_path : undefined,
315
+ typeof event.model === "string" ? event.model : undefined,
316
+ cfg,
317
+ );
318
+
319
+ // ── POST the signal to the local hub, behind the loopback token guard (no Origin ⇒ passes). ──────────
320
+ let token;
321
+ try {
322
+ token = readFileSync(handshake.hubTokenFile, "utf8").trim();
323
+ } catch {
324
+ done(); // no token file (hub not up on this machine) → no-op
325
+ }
326
+ if (!token) done();
327
+
328
+ const ids = {
329
+ sessionId: handshake.id, // the canonical bridge id (project@machine~codex) the hub keys on
330
+ claudeSessionId: typeof event.session_id === "string" ? event.session_id : undefined, // handshake-stem join key (agent-agnostic by convention)
331
+ ts: Date.now(),
332
+ ...(typeof event.cwd === "string" ? { cwd: event.cwd } : {}),
333
+ };
334
+ // A tick (PostToolUse) targets the state-less context route; everything else is a state signal.
335
+ const apiPath = mapped.tick ? "/local/session/context" : "/local/session/state";
336
+ // Straight from the payload — unlike Claude, Codex ships model + permission_mode on every hook event.
337
+ const mode = typeof event.permission_mode === "string" && event.permission_mode ? event.permission_mode : undefined;
338
+ const model = typeof event.model === "string" && event.model ? event.model : undefined;
339
+ const ferry = { agent: "codex", ...(mode ? { mode } : {}), ...(model ? { model } : {}) };
340
+ const payload = JSON.stringify(
341
+ mapped.tick
342
+ ? { ...ids, ...(context ? { context } : {}), ...ferry }
343
+ : {
344
+ ...ids,
345
+ state: mapped.state,
346
+ event: `codex:${String(event.hook_event_name ?? "")}`,
347
+ ...(context ? { context } : {}),
348
+ ...ferry,
349
+ },
350
+ );
351
+
352
+ /** One bounded breadcrumb for a REJECTED signal — the twin of signal.mjs's, which this script lacked, so
353
+ * a dead cross-boundary Codex hook left no trace anywhere and `doctor` had nothing to report. Written to
354
+ * the LOCAL home even when the hub lives in a peer one: doctor asks about THIS environment. Best-effort
355
+ * and silent — a hook must never fail because it could not complain. */
356
+ function recordHookRejection(status) {
357
+ try {
358
+ writeFileSync(
359
+ path.join(LOCAL_SECRET_DIR, "hooks-last-error.json"),
360
+ JSON.stringify({
361
+ ts: Date.now(),
362
+ status,
363
+ home: BRIDGE_HOME.dir,
364
+ homeSource: BRIDGE_HOME.source,
365
+ hubPort: handshake.hubPort,
366
+ agent: "codex",
367
+ hint:
368
+ status === 401
369
+ ? "the hub rejected this machine's hub-token — the Codex hook is reading a stale or foreign one"
370
+ : "the hub refused the signal",
371
+ }),
372
+ { mode: 0o600 },
373
+ );
374
+ } catch {
375
+ /* unwritable home → nothing to do; the signal already failed */
376
+ }
377
+ }
378
+
379
+ // node:http (not fetch) for zero deps + guaranteed NO Origin header (the guard 403s any Origin) + a hard
380
+ // timeout. Observe-and-abstain: this response is drained and discarded — NOTHING from it (or anywhere
381
+ // else in this script) is ever written to our own stdout.
382
+ const req = request(
383
+ {
384
+ host: "127.0.0.1",
385
+ port: handshake.hubPort,
386
+ path: apiPath,
387
+ method: "POST",
388
+ headers: {
389
+ "content-type": "application/json",
390
+ "content-length": Buffer.byteLength(payload),
391
+ "x-bridge-hub-token": token,
392
+ },
393
+ timeout: 2000,
394
+ },
395
+ (res) => {
396
+ if (res.statusCode && res.statusCode >= 400) recordHookRejection(res.statusCode);
397
+ res.on("data", () => {}); // drain
398
+ res.on("end", done);
399
+ },
400
+ );
401
+ req.on("timeout", () => {
402
+ req.destroy();
403
+ done();
404
+ });
405
+ req.on("error", done); // hub down / connection refused → silent no-op
406
+ req.end(payload);
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook for the Alexa voice-bridge plugin channel.
3
+ //
4
+ // POLICY (owner decision, 2026-07 security review): AC Bridge adds NO tool restrictions of its own.
5
+ // Every tool call is approved here — ON A VOICE SESSION. Scoped to voice (docs/VOICE-FLAG.md); see
6
+ // isVoiceSession() at the bottom for why, and for what a non-voice session does instead (defer).
7
+ //
8
+ // The reasoning, so nobody "fixes" this later by re-adding rules:
9
+ // • AC Bridge is a local home tool and an extension of the user's OWN Claude Code install. The user
10
+ // already granted Claude its permissions at setup; a bridge that silently second-guesses that is
11
+ // adding friction, not safety.
12
+ // • Anyone who wants restrictions configures them in their own Claude Code settings, which is where
13
+ // tool policy belongs — one place, under the user's control, not split across a plugin they did not
14
+ // write and cannot easily inspect.
15
+ // • The hands-free flow is the product. A permission prompt mid-session means Alexa has to relay a
16
+ // spoken yes/no for something the user already authorised at install time.
17
+ //
18
+ // WHAT THIS GIVES UP, stated plainly rather than buried:
19
+ // • A previous version denied reads of credential stores (~/.ssh, ~/.aws, .env, the bridge's own
20
+ // token dir) on a live voice session, because the `reply` tool is an outbound channel — anything a
21
+ // tool reads can be SPOKEN back. That guard is gone: on a voice session, a misheard or
22
+ // prompt-injected request can read a secret and read it aloud.
23
+ // • A small set of destructive Bash patterns (rm -rf, mkfs, dd if=, fork bombs, force-push) was also
24
+ // denied. That is gone too, along with the mirrored managed-settings deny list.
25
+ // • Because a PreToolUse "allow" SHORT-CIRCUITS Claude Code's permission system, this also overrides
26
+ // restrictions the user sets in their own config. That override now applies ONLY to voice sessions,
27
+ // where the hands-free rationale actually holds; a plain `claude` session — and every session on a
28
+ // voice-off build, where this plugin is still installed to feed device Profiles — defers to the
29
+ // user's own config instead.
30
+ //
31
+ // Physical-device safety is NOT handled here and is unaffected: security-sensitive smart-home commands
32
+ // (locks, garages, valves, cameras) go through the hub's own fail-closed gate — see hub/src/device-gate.ts
33
+ // and relay/src/device-confirm.ts. Reading a file and unlocking a front door are different risks, and
34
+ // only the first one is being opened up.
35
+ //
36
+ // Contract (unchanged): read the PreToolUse event JSON on stdin, print a permissionDecision, exit 0.
37
+
38
+ import { existsSync, readFileSync } from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+
42
+ /** Emit a decision and exit. `null` ⇒ print nothing ⇒ defer to Claude Code's normal flow. */
43
+ function emit(decision, reason) {
44
+ if (decision) {
45
+ process.stdout.write(
46
+ JSON.stringify({
47
+ hookSpecificOutput: {
48
+ hookEventName: "PreToolUse",
49
+ permissionDecision: decision, // "allow" | "deny"
50
+ permissionDecisionReason: reason ?? "",
51
+ },
52
+ }),
53
+ );
54
+ }
55
+ process.exit(0);
56
+ }
57
+
58
+ /**
59
+ * Is THIS session actually reachable by voice? The blanket allow above short-circuits Claude Code's
60
+ * permission system — including restrictions the user set in their own config — and the argument for doing
61
+ * that is entirely hands-free: a permission prompt mid-voice-session has to be relayed to Alexa as a spoken
62
+ * yes/no. On a session voice cannot reach, that argument doesn't exist, so we defer instead. This matters
63
+ * because the plugin stays installed when the voice feature flag is off (its hooks are how agent state
64
+ * reaches the hub, which is what device Profiles react to) — without this check, a voice-off build would
65
+ * still be overriding the user's permissions everywhere, for a feature it does not ship.
66
+ *
67
+ * The signal is the POSITIVE `voice:true` marker every voice writer stamps into the session handshake
68
+ * (channel self-bootstrap + env branch in channel/src/handshake.ts, `acbridge run`'s buildHandshakeRecord),
69
+ * and which the channel upgrades an unmarked file to at MCP init — before it connects, so before any
70
+ * `reply` egress can exist. Absence is never treated as presence: no marker, no file, no session id, or an
71
+ * unreadable file all DEFER. Deferring is strictly less permissive than allowing, so every uncertain case
72
+ * lands on the user's own configuration, which is the safe direction.
73
+ */
74
+ function isVoiceSession() {
75
+ try {
76
+ const sid = process.env.CLAUDE_CODE_SESSION_ID;
77
+ if (!sid) return false; // a voice session always has this — it is how the channel identifies itself
78
+ const dir = process.env.BRIDGE_SECRET_DIR || path.join(os.homedir(), ".claude-bridge");
79
+ const file = path.join(dir, "sessions", `${sid}.json`);
80
+ if (!existsSync(file)) return false; // no handshake ⇒ no channel ⇒ nothing to be hands-free about
81
+ const h = JSON.parse(readFileSync(file, "utf8"));
82
+ return h?.voice === true;
83
+ } catch {
84
+ return false; // unreadable/corrupt ⇒ can't confirm voice ⇒ defer
85
+ }
86
+ }
87
+
88
+ // The event is read (and a malformed one still exits 0) purely to honour the hook contract — the
89
+ // decision comes from the SESSION, not from the event.
90
+ try {
91
+ readFileSync(0, "utf8");
92
+ } catch {
93
+ /* an unreadable event changes nothing — the session is what decides */
94
+ }
95
+
96
+ if (isVoiceSession()) {
97
+ emit("allow", "acbridge adds no tool restrictions — permissions are the user's own Claude Code config");
98
+ }
99
+ emit(null); // not voice-reachable ⇒ defer to Claude Code's normal permission flow