acbridge 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +40 -0
- package/README.md +79 -3
- package/dist/acbridge.mjs +321 -0
- package/hub/dist/hub.cjs +189 -0
- package/package.json +32 -4
- package/plugin/.claude-plugin/marketplace.json +17 -0
- package/plugin/README.md +223 -0
- package/plugin/plugins/alexa/.claude-plugin/plugin.json +13 -0
- package/plugin/plugins/alexa/.mcp.json +8 -0
- package/plugin/plugins/alexa/dist/channel.mjs +165 -0
- package/plugin/plugins/alexa/hooks/hooks.json +166 -0
- package/plugin/plugins/alexa/scripts/codex-signal.mjs +406 -0
- package/plugin/plugins/alexa/scripts/gate.mjs +99 -0
- package/plugin/plugins/alexa/scripts/scope.mjs +270 -0
- package/plugin/plugins/alexa/scripts/signal.mjs +503 -0
- package/plugin/plugins/alexa/scripts/statusline-guard.mjs +75 -0
- package/plugin/plugins/alexa/scripts/statusline.mjs +322 -0
|
@@ -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
|