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.
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env node
2
+ // Session-state SIGNAL hook for the Alexa voice-bridge plugin (Profiles — docs/CLAUDE-SIGNALS.md).
3
+ //
4
+ // Goal: turn Claude Code's session lifecycle into a smart-home scene. One script, registered against
5
+ // every state-bearing hook (see hooks.json). It maps the firing hook (+ its payload) onto ONE wire state
6
+ // and POSTs a lightweight signal to the LOCAL hub's loopback control API (POST /local/session/state);
7
+ // a SUCCESSFUL PostToolUse instead posts a state-less context TICK (POST /local/session/context). The
8
+ // hub's RulesEngine fires the matching Profile rule locally (sub-second, never via the relay) — e.g.
9
+ // "lamp blue on working, green on finished". The relay only receives the signal afterwards for read-back.
10
+ // Every POST carries a `context` reading (the transcript-tail usage) feeding the hub's threshold
11
+ // synthesizer (contextLow / contextCritical — the user's per-profile fire-at sliders).
12
+ //
13
+ // state native hook note
14
+ // ───────────────── ───────────────────── ────────────────────────────────────────────────────────
15
+ // started SessionStart source startup/clear; source=compact is SILENT (PostCompact
16
+ // owns compaction — was a spurious `started` pre-expansion)
17
+ // resumed SessionStart source=resume
18
+ // ended SessionEnd
19
+ // working UserPromptSubmit + PostToolUse[Failure] on AskUserQuestion/ExitPlanMode —
20
+ // answering/deciding resumes the turn (a plan REJECTION is
21
+ // error-shaped by design and must NOT read as an error)
22
+ // finished Stop fires at the end of EVERY turn → the hub debounces (gotcha #1)
23
+ // needsInput PreToolUse tool_name=AskUserQuestion (Claude asked YOU a question);
24
+ // also Notification agent_needs_input / elicitation_dialog
25
+ // planReady PreToolUse tool_name=ExitPlanMode (a plan awaits your approval)
26
+ // idle Notification idle_prompt (typed) or the text fallback ┐ typed
27
+ // needs_permission Notification permission_prompt or the text fallback ┘ preferred
28
+ // rateLimited Notification/StopFail rate_limit_warning / error_type=rate_limit
29
+ // error StopFailure (non-rate-limit) + PostToolUseFailure + Notification mcp_server_error
30
+ // + legacy best-effort: PostToolUse whose tool_response signals a hard failure
31
+ // subagentStart SubagentStart ┐
32
+ // subagentFinished SubagentStop │ lifecycle
33
+ // compacting PreCompact │
34
+ // compactDone PostCompact │
35
+ // permissionDenied PermissionDenied │ a tool call auto-blocked without asking
36
+ // taskCompleted TaskCompleted ┘ + Notification agent_completed
37
+ // workflowStarted PreToolUse tool_name=Workflow (multi-agent orchestration kicked off)
38
+ // contextLow/… (hub-synthesized) the SessionContextTracker, from the context readings here
39
+ // modePlan/modeAuto/… (hub-synthesized) Claude Code has NO hook/statusline field for a permission-
40
+ // modeNormal/modeBypass mode or model switch, so every POST below FERRIES the raw
41
+ // modelChanged `permissionMode` (payload if present, else the transcript's
42
+ // last `permission-mode` record) + the last assistant model id;
43
+ // the hub diffs them per session (baseline-quiet).
44
+ //
45
+ // ACTIVATION (gotcha #3): resolve this run's hub coordinates from (1) the BRIDGE_* env (the `acbridge run`
46
+ // launch env, which hooks DO inherit — unlike the sanitized MCP env), (2) the per-session handshake file
47
+ // keyed by Claude's own session_id (written by `acbridge run` OR self-written by the channel on a bare
48
+ // `claude --channels …`), or (3) AUTO-PROFILES: if neither exists but the hub is set up on this machine (a
49
+ // hub-token file is present) and auto-profiles is on (default), SELF-DERIVE the id (project@machine). That
50
+ // is what makes lights react on ANY session while the hub daemon runs — the hub only actuates repos BOUND
51
+ // to a Profile, so unbound repos no-op. No hub configured on this machine (no token) ⇒ exit 0, POST nothing.
52
+ // Off-switch: BRIDGE_AUTO_PROFILES=0 or `"autoProfiles": false` in config.json. Voice stays flag-gated
53
+ // (the channel MCP server only boots under `--channels`); only the lights path auto-activates.
54
+ //
55
+ // FAIL-SAFE: any parse/IO/network error ⇒ exit 0. A hook must NEVER block Claude; a dead hub, a missing
56
+ // token, or an unclassifiable event all degrade to a silent no-op. The POST is bounded by a short timeout.
57
+ //
58
+ // Contract: read the hook event JSON on stdin; optionally POST; always exit 0.
59
+
60
+ import { closeSync, fstatSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
61
+ import { request } from "node:http";
62
+ import os from "node:os";
63
+ import path from "node:path";
64
+ import {
65
+ cleanupPlainSessionHandshake,
66
+ readActiveUserSub,
67
+ resolveBridgeHome,
68
+ resolveHubTokenFile,
69
+ writePlainSessionHandshakeIfAbsent,
70
+ } from "./scope.mjs";
71
+
72
+ const DEFAULT_HUB_PORT = 8790; // mirrors @bridge/node-core HUB_PORT — the plugin can't import the workspace
73
+ // WHICH `.claude-bridge` home this run should read (scope.mjs Part C). Usually this process's own — but a
74
+ // session running in WSL while the hub runs on Windows (or vice versa) shares the box's loopback and NOT
75
+ // its home dir, so the token/active-user/sessions files it needs live on the other side. Resolving the
76
+ // home is what makes Profiles fire for a session started ANYWHERE on the machine; without it the hooks
77
+ // silently 401 and then get dropped as account-scope "absent".
78
+ const BRIDGE_HOME = resolveBridgeHome();
79
+ const SECRET_DIR = BRIDGE_HOME.dir;
80
+ /** THIS environment's own home — where a diagnostic breadcrumb belongs (a peer home is read-only to us). */
81
+ const LOCAL_SECRET_DIR = process.env.BRIDGE_SECRET_DIR || path.join(os.homedir(), ".claude-bridge");
82
+
83
+ /** Exit 0 no matter what — a hook that throws would block the session; we only ever no-op or POST. */
84
+ function done() {
85
+ process.exit(0);
86
+ }
87
+
88
+ let event;
89
+ try {
90
+ event = JSON.parse(readFileSync(0, "utf8") || "{}");
91
+ } catch {
92
+ done(); // unreadable event → no-op
93
+ }
94
+ if (event === null || typeof event !== "object") done(); // non-object JSON (e.g. `null`, `42`) → nothing to classify
95
+
96
+ // ── Dormancy gate: resolve THIS run's hub coordinates, exactly like channel/src/index.ts. ────────────
97
+ const isPlaceholder = (v) => Boolean(v) && /^\$\{.*\}$/.test(v); // sanitized-env literal `${BRIDGE_*}`
98
+ const envVar = (v) => (v && !isPlaceholder(v) ? v : undefined);
99
+
100
+ function resolveHandshake(sessionId) {
101
+ // Dev / direct path: explicit BRIDGE_* env (Claude launched by `acbridge run`, or any launcher that exports them) wins.
102
+ const envId = envVar(process.env.BRIDGE_SESSION_ID);
103
+ if (envId) {
104
+ return {
105
+ id: envId,
106
+ hubPort: Number(envVar(process.env.BRIDGE_HUB_PORT)) || DEFAULT_HUB_PORT,
107
+ // AS-T9f Part A: env wins; else the scoped step (users/<seg>/hub-token) → machine-global fallback.
108
+ hubTokenFile: resolveHubTokenFile(SECRET_DIR, envVar(process.env.BRIDGE_HUB_TOKEN_FILE)),
109
+ };
110
+ }
111
+ // Plugin path: correlate via Claude's session id → the per-session handshake file `acbridge run` wrote.
112
+ if (!sessionId) return null;
113
+ try {
114
+ const h = JSON.parse(readFileSync(path.join(SECRET_DIR, "sessions", `${sessionId}.json`), "utf8"));
115
+ if (!h || !h.id) return null;
116
+ return {
117
+ id: h.id,
118
+ hubPort: Number(h.hubPort) || DEFAULT_HUB_PORT,
119
+ // AS-T9f Part A: the file's hubTokenFile (scoped, stamped by run.ts/9d) is authoritative; a legacy
120
+ // file lacking it falls to the scoped step → machine-global.
121
+ hubTokenFile: resolveHubTokenFile(SECRET_DIR, h.hubTokenFile),
122
+ };
123
+ } catch {
124
+ return null; // ordinary session — no handshake ⇒ dormant
125
+ }
126
+ }
127
+
128
+ // Mirror @bridge/node-core machineLabel() + cli loadConfig() inline — the plugin can't import the workspace.
129
+ function readCliConfig() {
130
+ let file = {};
131
+ try {
132
+ file = JSON.parse(readFileSync(path.join(SECRET_DIR, "config.json"), "utf8")) || {};
133
+ } catch {
134
+ /* no config file → env + hostname defaults */
135
+ }
136
+ return {
137
+ machine: envVar(process.env.BRIDGE_MACHINE) || file.machine || os.hostname().split(".")[0] || "node",
138
+ // The resolved home's beacon knows the port the hub ACTUALLY bound, which beats assuming the default
139
+ // (and is the only source of truth when the hub lives in a peer home whose config.json we don't own).
140
+ hubPort: Number(envVar(process.env.BRIDGE_HUB_PORT) || BRIDGE_HOME.beacon?.port || file.hubPort || DEFAULT_HUB_PORT),
141
+ autoProfiles: file.autoProfiles,
142
+ };
143
+ }
144
+
145
+ // Auto-profiles is ON by default. Off-switch: BRIDGE_AUTO_PROFILES=0/false, or `"autoProfiles": false`.
146
+ function autoProfilesEnabled(cfg) {
147
+ const env = process.env.BRIDGE_AUTO_PROFILES;
148
+ if (env === "0" || env === "false") return false;
149
+ return cfg.autoProfiles !== false;
150
+ }
151
+
152
+ // No per-session handshake, but this machine HAS a hub configured (a hub-token file exists). Derive the
153
+ // SAME id acbridge run / the channel would (project@machine) so the hub keys the session identically, and
154
+ // point at the fixed hub coords. Returns null when the bridge was never set up here (no token) ⇒ dormant.
155
+ function selfDeriveHandshake(ev, cfg) {
156
+ // AS-T9f Part A: scoped-first hub-token (users/<seg>/hub-token) → machine-global. The dormancy gate now
157
+ // reads whichever the chain resolves — a pinned hub that wrote ONLY a scoped token is still "set up here."
158
+ const hubTokenFile = resolveHubTokenFile(SECRET_DIR, undefined);
159
+ try {
160
+ if (!readFileSync(hubTokenFile, "utf8").trim()) return null;
161
+ } catch {
162
+ return null; // no hub token (scoped or machine-global) → bridge not set up on this machine → stay silent
163
+ }
164
+ const cwd = typeof ev.cwd === "string" && ev.cwd ? ev.cwd : process.cwd();
165
+ const project = path.basename(cwd) || "session";
166
+ const id = `${project}@${cfg.machine}`;
167
+ // AS-T9f Part B (owner-approved restore): a plain `claude` (no --channels) has NO handshake file, so
168
+ // AS-T8b's adoption gate classifies its signals "absent" under a pinned hub and DROPS them → auto-profiles
169
+ // stopped firing. Stamp one ourselves, keyed by Claude's real session id, so the hub adopts it under the
170
+ // SIGNED-IN account (sub === pinned scope). No account ⇒ nothing written ⇒ unattributable ⇒ still refused;
171
+ // an acbridge run / channel file is never overwritten; the selfWritten marker keeps gate.mjs dev-freedom.
172
+ //
173
+ // RACE-SAFETY — never write at SessionStart: SessionStart hooks are DOCUMENTED to fire BEFORE plugin MCP
174
+ // servers finish connecting (code.claude.com/docs/en/hooks). So on a bare `claude --channels` VOICE session
175
+ // the channel hasn't written its (non-selfWritten) handshake yet at SessionStart; writing a selfWritten file
176
+ // here would let the channel adopt it verbatim ("prefer it verbatim, never overwrite") and gate.mjs would
177
+ // then treat the VOICE session as plain and drop its credential-read guard. By any LATER hook a voice
178
+ // session's channel file already exists → resolveHandshake returns it → this self-derive path isn't reached.
179
+ // A plain session just picks up its handshake (and profiles) from its first post-start activity instead of
180
+ // the started scene — a small, deliberate cost to keep the voice guard sound.
181
+ if (String(ev.hook_event_name ?? "") !== "SessionStart") {
182
+ writePlainSessionHandshakeIfAbsent({
183
+ secretDir: SECRET_DIR,
184
+ id,
185
+ hubPort: cfg.hubPort,
186
+ hubTokenFile,
187
+ cwd,
188
+ claudeSessionId: typeof ev.session_id === "string" ? ev.session_id : undefined,
189
+ sub: readActiveUserSub(SECRET_DIR),
190
+ });
191
+ }
192
+ return { id, hubPort: cfg.hubPort, hubTokenFile };
193
+ }
194
+
195
+ const cfg = readCliConfig();
196
+ let handshake = resolveHandshake(typeof event.session_id === "string" ? event.session_id : undefined);
197
+ if (!handshake && autoProfilesEnabled(cfg)) handshake = selfDeriveHandshake(event, cfg);
198
+ if (!handshake) done(); // no bridge session AND no hub configured (or auto-profiles off) — stay silent
199
+
200
+ // ── Map the firing hook (+ payload) → one wire state, {tick:true} for a context tick, or null. ───────
201
+ const msgExtra = (ev) => (typeof ev.message === "string" && ev.message ? { message: ev.message.slice(0, 120) } : undefined);
202
+
203
+ function classify(ev) {
204
+ const name = String(ev.hook_event_name ?? "");
205
+ switch (name) {
206
+ case "SessionStart": {
207
+ const source = String(ev.source ?? "");
208
+ // A compact "restart" is the SAME session continuing — PostCompact owns compaction (firing
209
+ // `started` here re-ran the start scene after every auto-compact, the pre-expansion bug).
210
+ if (source === "compact") return null;
211
+ return { state: source === "resume" ? "resumed" : "started" };
212
+ }
213
+ case "SessionEnd":
214
+ return { state: "ended" };
215
+ case "SubagentStart":
216
+ return { state: "subagentStart", extra: ev.agent_type ? { agent: String(ev.agent_type) } : undefined };
217
+ case "SubagentStop":
218
+ return { state: "subagentFinished" };
219
+ case "PreCompact":
220
+ return { state: "compacting" };
221
+ case "PostCompact":
222
+ // Also what re-arms the hub's context latches (with started/resumed) — see SessionContextTracker.
223
+ return { state: "compactDone", extra: ev.trigger || ev.compaction_type ? { trigger: String(ev.trigger ?? ev.compaction_type) } : undefined };
224
+ case "UserPromptSubmit":
225
+ return { state: "working" };
226
+ case "Stop":
227
+ // Fires at the END OF EVERY assistant turn, not once per task (gotcha #1). The hub debounces
228
+ // per-(session,state); a per-profile finishOn:"idle" refinement lands in P4.
229
+ return { state: "finished" };
230
+ case "StopFailure": {
231
+ // The turn died on an API error. Rate limits get their own state (a very different human action —
232
+ // stop and wait); everything else (overloaded/auth/billing/server/…) is the error scene.
233
+ const errorType = String(ev.error_type ?? "");
234
+ const extra = {
235
+ ...(errorType ? { errorType } : {}),
236
+ ...(typeof ev.error_message === "string" && ev.error_message ? { message: ev.error_message.slice(0, 120) } : {}),
237
+ };
238
+ return { state: errorType === "rate_limit" ? "rateLimited" : "error", extra: Object.keys(extra).length ? extra : undefined };
239
+ }
240
+ case "PreToolUse": {
241
+ // Registered under the "AskUserQuestion|ExitPlanMode|Workflow" matcher (hooks.json) — double-guarded
242
+ // here. The first two BLOCK on the human (the question/plan is on screen the moment PreToolUse
243
+ // fires); Workflow is the multi-agent orchestration kick-off (expect a subagent burst until done).
244
+ const tool = String(ev.tool_name ?? "");
245
+ if (tool === "AskUserQuestion") return { state: "needsInput", extra: { tool } };
246
+ if (tool === "ExitPlanMode") return { state: "planReady", extra: { tool } };
247
+ if (tool === "Workflow") return { state: "workflowStarted", extra: { tool } };
248
+ return null;
249
+ }
250
+ case "PostToolUse":
251
+ case "PostToolUseFailure": {
252
+ const tool = String(ev.tool_name ?? "");
253
+ // Answering the question / deciding the plan resumes the turn — unconditionally `working`: a plan
254
+ // REJECTION returns an error-shaped tool_response by design and must NOT flash the error scene.
255
+ if (tool === "AskUserQuestion" || tool === "ExitPlanMode") return { state: "working", extra: { tool } };
256
+ if (name === "PostToolUseFailure") {
257
+ return {
258
+ state: "error",
259
+ extra: {
260
+ ...(tool ? { tool } : {}),
261
+ ...(typeof ev.error_message === "string" && ev.error_message ? { message: ev.error_message.slice(0, 120) } : {}),
262
+ },
263
+ };
264
+ }
265
+ // Legacy best-effort error detection (older Claude Code without PostToolUseFailure): fire only when
266
+ // the tool response explicitly signals a hard failure — biased to UNDER-fire (a failed grep / non-
267
+ // zero exit the runtime doesn't flag stays silent) so a normal session never flashes the error scene.
268
+ const resp = ev.tool_response;
269
+ const isError =
270
+ resp &&
271
+ typeof resp === "object" &&
272
+ (resp.is_error === true ||
273
+ resp.isError === true ||
274
+ (typeof resp.error === "string" && resp.error.length > 0));
275
+ if (isError) return { state: "error", extra: tool ? { tool } : undefined };
276
+ // A successful tool → a state-less context TICK — the per-tool cadence is what keeps the hub's
277
+ // context meter fresh during a long working stretch (the threshold sliders' data feed).
278
+ return { tick: true };
279
+ }
280
+ case "PermissionDenied":
281
+ return { state: "permissionDenied", extra: ev.tool_name ? { tool: String(ev.tool_name) } : undefined };
282
+ case "TaskCompleted":
283
+ return { state: "taskCompleted" };
284
+ case "Notification": {
285
+ // Prefer the TYPED notification_type (locale-proof); fall back to the message-text regex on older
286
+ // Claude Code (gotcha #2 — English default, unrecognized degrades to the benign `idle`).
287
+ const type = String(ev.notification_type ?? "");
288
+ if (type) {
289
+ switch (type) {
290
+ case "permission_prompt":
291
+ return { state: "needs_permission", extra: msgExtra(ev) };
292
+ case "idle_prompt":
293
+ return { state: "idle" };
294
+ case "agent_needs_input":
295
+ case "elicitation_dialog":
296
+ return { state: "needsInput", extra: msgExtra(ev) };
297
+ case "agent_completed":
298
+ return { state: "taskCompleted" };
299
+ case "rate_limit_warning":
300
+ return { state: "rateLimited", extra: msgExtra(ev) };
301
+ case "mcp_server_error":
302
+ return { state: "error", extra: msgExtra(ev) };
303
+ default:
304
+ // context_low stays UNMAPPED on purpose — the hub's SessionContextTracker owns the context
305
+ // states at the USER'S per-profile threshold, not Claude's fixed one. auth_success /
306
+ // elicitation_* / future types are not device states.
307
+ return null;
308
+ }
309
+ }
310
+ const msg = String(ev.message ?? "").toLowerCase();
311
+ const needsPermission = /permission|approve|wants to|needs your/.test(msg);
312
+ return {
313
+ state: needsPermission ? "needs_permission" : "idle",
314
+ extra: msg ? { message: msg.slice(0, 120) } : undefined,
315
+ };
316
+ }
317
+ default:
318
+ return null; // an unregistered event reached us → nothing to report
319
+ }
320
+ }
321
+
322
+ // ── Transcript-tail read: context reading + the mode/model FERRY (states + mode/model expansions). ───
323
+ // Positioned read of the last 64 KB only — transcripts reach tens of MB and this runs per tool call.
324
+ // One backward walk gathers three things:
325
+ // context — the last MAIN-CHAIN assistant usage (sidechain lines carry a SUBAGENT's usage → skipped);
326
+ // window: 1M for [1m] model ids, 200k otherwise (the statusline used_percentage formula).
327
+ // model — that assistant line's raw model id (the hub diffs it → modelChanged).
328
+ // mode — the last `permission-mode` record's raw permissionMode (the hub diffs it → mode states);
329
+ // the ONLY machine-readable mode source: no hook or statusline field exists (July 2026).
330
+ // Any error → whatever was gathered (usually nothing); the signal still POSTs, just without a reading.
331
+ function readTail(ev) {
332
+ const out = { context: null, mode: undefined, model: undefined };
333
+ try {
334
+ if (typeof ev.transcript_path !== "string" || !ev.transcript_path) return out;
335
+ const fd = openSync(ev.transcript_path, "r");
336
+ let text, torn;
337
+ try {
338
+ const size = fstatSync(fd).size;
339
+ const len = Math.min(size, 65536);
340
+ if (len === 0) return out;
341
+ const buf = Buffer.alloc(len);
342
+ readSync(fd, buf, 0, len, size - len);
343
+ text = buf.toString("utf8");
344
+ torn = size > len; // the window may open mid-line → the first fragment isn't trustworthy JSON
345
+ } finally {
346
+ closeSync(fd);
347
+ }
348
+ const lines = text.split("\n");
349
+ for (let i = lines.length - 1; i >= (torn ? 1 : 0); i--) {
350
+ const line = lines[i];
351
+ if (!line || line.charCodeAt(0) !== 123 /* "{" */) continue;
352
+ let rec;
353
+ try {
354
+ rec = JSON.parse(line);
355
+ } catch {
356
+ continue;
357
+ }
358
+ if (out.mode === undefined && rec.type === "permission-mode" && typeof rec.permissionMode === "string" && rec.permissionMode) {
359
+ out.mode = rec.permissionMode; // walking backwards ⇒ the first hit is the LAST record (current mode)
360
+ }
361
+ if (out.context) {
362
+ if (out.mode !== undefined) break; // everything gathered
363
+ continue;
364
+ }
365
+ if (rec.type !== "assistant" || rec.isSidechain === true) continue;
366
+ const model = rec.message && typeof rec.message.model === "string" && rec.message.model ? rec.message.model : undefined;
367
+ if (model && out.model === undefined) out.model = model; // newest assistant = the current model
368
+ const usage = rec.message && rec.message.usage;
369
+ if (!usage || typeof usage !== "object") continue;
370
+ const used = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
371
+ if (!(used > 0)) continue;
372
+ // The window CANNOT be read from the transcript: its assistant model id never carries the `[1m]`
373
+ // context-tier suffix (only the statusline's snap.model.id does — the same asymmetry the hub's
374
+ // canonicalModelId exists for). Testing for it here was therefore always false, so every
375
+ // 1M-context session was scored against 200k: a real session at 346,881 tokens reported pct 100
376
+ // instead of 34.7 and held contextLow + contextCritical tripped for its whole life.
377
+ //
378
+ // So ALSO infer the floor from the reading itself — a 200k window cannot hold more than 200k
379
+ // tokens. The suffix test is kept (it costs nothing and is right whenever an id does carry it);
380
+ // the token floor is what actually rescues the real transcripts. Both are a FALLBACK for a setup
381
+ // with no statusline tee — when the tee is installed (the default) it reports the true window and
382
+ // the hub's resolveContextPct overrides whatever is guessed here.
383
+ const windowTokens = String(rec.message.model ?? "").includes("[1m]") || used > 200_000 ? 1_000_000 : 200_000;
384
+ out.context = { pct: Math.min(100, Math.round((used / windowTokens) * 1000) / 10), usedTokens: used, windowTokens };
385
+ if (out.mode !== undefined) break;
386
+ }
387
+ return out;
388
+ } catch {
389
+ return out; // unreadable/garbled transcript → whatever was gathered, never a failure
390
+ }
391
+ }
392
+
393
+ const mapped = classify(event);
394
+ if (!mapped) done(); // this event carries no state and no tick → no-op
395
+
396
+ // The session's live context reading + the raw mode/model ferry — attached to every POST. A hook payload's
397
+ // own permission_mode/model_id (if Claude Code ever ships them) beats the transcript-derived values.
398
+ const tail = readTail(event);
399
+ const context = tail.context;
400
+ const mode = typeof event.permission_mode === "string" && event.permission_mode ? event.permission_mode : tail.mode;
401
+ const model = typeof event.model_id === "string" && event.model_id ? event.model_id : tail.model;
402
+ if (mapped.tick && !context && !mode && !model) done(); // a tick with nothing to report → no-op
403
+
404
+ // ── POST the signal to the local hub, behind the loopback token guard (no Origin ⇒ passes). ──────────
405
+ let token;
406
+ try {
407
+ token = readFileSync(handshake.hubTokenFile, "utf8").trim();
408
+ } catch {
409
+ done(); // no token file (hub not up on this machine) → no-op
410
+ }
411
+ if (!token) done();
412
+
413
+ const ids = {
414
+ sessionId: handshake.id, // the canonical bridge id (project@machine) the hub registry keys on
415
+ claudeSessionId: typeof event.session_id === "string" ? event.session_id : undefined,
416
+ ts: Date.now(),
417
+ // Carry this run's cwd so the hub can match the profile's repo even if the channel hasn't registered
418
+ // yet (the started-on-launch race) — the fix that makes EVERY state fire reliably, not just mid-session.
419
+ ...(typeof event.cwd === "string" ? { cwd: event.cwd } : {}),
420
+ };
421
+ // A tick (successful tool) targets the state-less context route; everything else is a state signal.
422
+ const apiPath = mapped.tick ? "/local/session/context" : "/local/session/state";
423
+ const ferry = { ...(mode ? { mode } : {}), ...(model ? { model } : {}) };
424
+ const payload = JSON.stringify(
425
+ mapped.tick
426
+ ? { ...ids, ...(context ? { context } : {}), ...ferry }
427
+ : {
428
+ ...ids,
429
+ state: mapped.state,
430
+ event: String(event.hook_event_name ?? ""),
431
+ ...(mapped.extra ? { extra: mapped.extra } : {}),
432
+ ...(context ? { context } : {}),
433
+ ...ferry,
434
+ },
435
+ );
436
+
437
+ // node:http (not fetch) for zero deps + guaranteed NO Origin header (the guard 403s any Origin) + a hard
438
+ /** One bounded breadcrumb for a REJECTED signal (see the call site). Written to the LOCAL home even when
439
+ * the hub lives in a peer one: a peer home is read-only to us by design, and doctor asks about THIS
440
+ * environment. Best-effort and silent — a hook must never fail because it could not complain. */
441
+ function recordHookRejection(status) {
442
+ try {
443
+ writeFileSync(
444
+ path.join(LOCAL_SECRET_DIR, "hooks-last-error.json"),
445
+ JSON.stringify({
446
+ ts: Date.now(),
447
+ status,
448
+ home: BRIDGE_HOME.dir,
449
+ homeSource: BRIDGE_HOME.source,
450
+ hubPort: handshake.hubPort,
451
+ hint:
452
+ status === 401
453
+ ? "the hub rejected this machine's hub-token — the hook is reading a stale or foreign one"
454
+ : "the hub refused the signal",
455
+ }),
456
+ { mode: 0o600 },
457
+ );
458
+ } catch {
459
+ /* unwritable home → nothing to do; the signal already failed */
460
+ }
461
+ }
462
+
463
+ // timeout. The hub route answers 200 the instant it queues the signal (actuation is fire-and-forget on
464
+ // its side), so this returns in well under the hook budget; the timeout only guards against a dead hub.
465
+ const req = request(
466
+ {
467
+ host: "127.0.0.1",
468
+ port: handshake.hubPort,
469
+ path: apiPath,
470
+ method: "POST",
471
+ headers: {
472
+ "content-type": "application/json",
473
+ "content-length": Buffer.byteLength(payload),
474
+ "x-bridge-hub-token": token,
475
+ },
476
+ timeout: 2000,
477
+ },
478
+ (res) => {
479
+ res.on("data", () => {}); // drain
480
+ res.on("end", () => {
481
+ // A REJECTED signal used to vanish without trace: the hub answered 401/4xx, this hook exited 0 by
482
+ // contract, and nothing anywhere recorded that Profiles had stopped working (that is how a stale
483
+ // hub-token went unnoticed for a whole day). Leave one bounded breadcrumb — in THIS environment's
484
+ // own home, never the peer's, which we only ever read — for `acbridge doctor` to surface.
485
+ if ((res.statusCode ?? 0) >= 400) recordHookRejection(res.statusCode ?? 0);
486
+ // AS-T9f Part B lifecycle: a plain session ENDING. The hub classifies + ingests this "ended" signal
487
+ // synchronously before answering 200, so by now it has fired the ended scene — safe to reap the
488
+ // handshake WE self-wrote (cleanupPlainSessionHandshake gates on selfWritten, so a voice writer's
489
+ // file is never touched). A crash that skips SessionEnd just leaves a harmless stale file (its UUID
490
+ // sid never recurs → never re-adopted) for a future sessions-dir sweep.
491
+ if (String(event.hook_event_name ?? "") === "SessionEnd" && typeof event.session_id === "string") {
492
+ cleanupPlainSessionHandshake(SECRET_DIR, event.session_id);
493
+ }
494
+ done();
495
+ });
496
+ },
497
+ );
498
+ req.on("timeout", () => {
499
+ req.destroy();
500
+ done();
501
+ });
502
+ req.on("error", done); // hub down / connection refused → silent no-op
503
+ req.end(payload);
@@ -0,0 +1,75 @@
1
+ // Self-defense for the statusline chain (statusline.mjs's renderChained). PURE + importable on purpose:
2
+ // statusline.mjs reads stdin and process.exit()s at module scope, so it can never be imported by a test —
3
+ // the predicates that decide whether to spawn anything live here instead, and are unit-tested in
4
+ // node-core/test/statusline-guard.test.ts.
5
+ //
6
+ // WHY THIS EXISTS (2026-07-30, DESKTOP-NK51AQV): `~/.claude-bridge/config.json` held a `statusline.prev`
7
+ // pointing at the bridge's OWN statusline script. Every Claude repaint ran statusline.mjs, which read
8
+ // `prev` — itself — and spawned it; the child did the same, forever. DEADLINE_MS bounds each process to
9
+ // ~1.5 s, so it never looked like a leak: it settled into a treadmill of ~372 node.exe (18.65 GB, 85% of
10
+ // host RAM) plus a cmd.exe + conhost.exe per link, all under 2 s old. Killing them achieved nothing — the
11
+ // chain regenerated within 1.5 s. Only breaking the chain fixed it.
12
+ //
13
+ // node-core/src/claude-setup/statusline.ts now (a) never stores our own command as `prev` and (b) heals a
14
+ // config already poisoned. These guards are the independent second layer: they hold even for a poisoned
15
+ // config whose installer never runs again, and for any future path that manages to write a bad `prev`.
16
+
17
+ /** Set in a chained child's env. Present on entry ⇒ we ARE a chained child ⇒ never chain again, so the
18
+ * chain depth can never exceed 1 no matter what `prev` says. */
19
+ export const CHAIN_DEPTH_ENV = "BRIDGE_STATUSLINE_DEPTH";
20
+
21
+ /** Our script path as it appears inside a built command, either separator (see statusline.ts's OURS_MARKER:
22
+ * a `prev` can be the WINDOWS home's, read by a Linux process, when bridge homes are peers). */
23
+ const MARKERS = ["plugins/alexa/scripts/statusline.mjs", "plugins\\alexa\\scripts\\statusline.mjs"];
24
+
25
+ /** Roots/launchers only the bridge installs under — used to attribute a RELOCATED or renamed copy of our
26
+ * script without also claiming a user's unrelated `statusline.mjs`. */
27
+ const BRIDGE_OWNED = /\.claude-bridge|@bridgeapp|bridge-node|ac-bridge/;
28
+
29
+ const norm = (s) => s.replace(/\\/g, "/").toLowerCase();
30
+
31
+ /**
32
+ * Would chaining `cmd` re-enter this script? Three ways to be ours, cheapest first:
33
+ * 1. it contains our plugin script path (any install root, either separator) — the field case;
34
+ * 2. it names this exact file (`selfPath`, normally import.meta.url of statusline.mjs);
35
+ * 3. it names `statusline.mjs` under a bridge-owned root or launcher — a relocated/legacy copy.
36
+ * Deliberately NOT "any command mentioning statusline.mjs": that would silently swallow a user's own
37
+ * statusline script of the same name. Ours is always attributable by (1), (2) or (3).
38
+ */
39
+ export function isSelfReferentialStatusline(cmd, selfPath) {
40
+ if (typeof cmd !== "string" || !cmd) return false;
41
+ const s = norm(cmd);
42
+ if (MARKERS.some((m) => s.includes(norm(m)))) return true;
43
+ if (typeof selfPath === "string" && selfPath && s.includes(norm(selfPath))) return true;
44
+ return s.includes("statusline.mjs") && BRIDGE_OWNED.test(s);
45
+ }
46
+
47
+ /** Should `prev` be spawned at all? Fail CLOSED: any doubt renders the default line instead, which is the
48
+ * documented fallback (`else finish(defaultLine())`) and costs the user a cosmetic statusline at worst. */
49
+ export function shouldChain(prev, { env = {}, selfPath } = {}) {
50
+ if (typeof prev !== "string" || !prev) return false;
51
+ if (env[CHAIN_DEPTH_ENV]) return false; // we are already a chained child
52
+ return !isSelfReferentialStatusline(prev, selfPath);
53
+ }
54
+
55
+ /** The env a chained child gets: everything we have, plus the fuse. */
56
+ export function chainChildEnv(env = {}) {
57
+ return { ...env, [CHAIN_DEPTH_ENV]: "1" };
58
+ }
59
+
60
+ /**
61
+ * PURE: which `statusline.prev` this run may consider, given BOTH bridge homes' parsed configs.
62
+ *
63
+ * Only the LOCAL home's config may supply it. node-core writes `prev` to THIS machine's own
64
+ * ~/.claude-bridge (its SECRET_DIR is never peer-resolved), whereas the RESOLVED hub home is the peer's
65
+ * across a WSL↔Windows boundary whenever the live hub sits on the other side (scope.mjs Part C). A peer's
66
+ * `prev` is a command string for the OTHER OS's shell — handing `node "C:\…"` to /bin/sh fails, silently
67
+ * shadowing the user's real statusline with our default line, and a poisoned peer value would be a
68
+ * cross-boundary self-reference. Encoded as a function so the choice is pinned by a test instead of
69
+ * living implicitly in one call site.
70
+ */
71
+ export function selectStatuslinePrev({ local, resolved } = {}) {
72
+ void resolved; // accepted to make the contract explicit, and deliberately never read
73
+ const sl = local && typeof local === "object" ? local.statusline : undefined;
74
+ return sl && typeof sl.prev === "string" && sl.prev ? sl.prev : null;
75
+ }