acbridge 1.1.2 → 1.2.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.
@@ -14,6 +14,9 @@
14
14
  // SessionStart compact SILENT — Pre/PostCompact own compaction
15
15
  // UserPromptSubmit — working
16
16
  // Stop — finished
17
+ // SessionEnd — ended (upstream shipped SessionEnd mid-2026; the
18
+ // hub's 30-min idle close is now the BACKSTOP for
19
+ // abnormal exits / pre-SessionEnd Codex builds)
17
20
  // PermissionRequest — needs_permission (observe-and-abstain: NEVER stdout —
18
21
  // Codex's permission-shaped hooks read stdout as an
19
22
  // approve/deny decision; this script never writes one)
@@ -24,6 +27,10 @@
24
27
  // PostToolUse — a state-less context TICK (POST .../context)
25
28
  // anything else — exit 0, nothing to report
26
29
  //
30
+ // (synthesized) first event ever seen for a started, POSTed BEFORE the event's own signal —
31
+ // session is not a start/end the v0.146 TUI dispatches NO SessionStart hook
32
+ // (codex exec does); see TUI-SYNTHESIZED START below
33
+ //
27
34
  // Unlike Claude Code, Codex's own hook payload carries `model` and `permission_mode` on EVERY event, so
28
35
  // the mode/model FERRY (feeding the hub's modePlan/modeAuto/.../modelChanged synthesis, same as
29
36
  // signal.mjs) is a straight field read here — no transcript walk needed for that part. The transcript
@@ -52,7 +59,7 @@
52
59
  // only); stdout is NEVER written to (Codex hook stdout can influence approve/deny — this script is
53
60
  // observe-only, on every path, always).
54
61
 
55
- import { closeSync, fstatSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
62
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
56
63
  import { request } from "node:http";
57
64
  import os from "node:os";
58
65
  import path from "node:path";
@@ -85,6 +92,23 @@ if (event === null || typeof event !== "object") done(); // non-object JSON (e.g
85
92
  // Part-B: an adapter owns this session's states already — bail before touching the handshake or the hub.
86
93
  if (process.env.ACB_CODEX_ADAPTER) done();
87
94
 
95
+ // ── The memories thread guard (2026-08, live-verified). Codex opens a SECOND session at startup with
96
+ // cwd inside its own home (`~/.codex/memories`, 2.3s after the real one). It is not a human session —
97
+ // signalling it self-derives a `memories@<machine>~codex` identity, arms the hub's duration watch, and
98
+ // pollutes the context tracker. Any event whose cwd sits under the codex home is dropped HERE, before
99
+ // handshake resolution, so it can neither POST nor write a handshake file. CODEX_HOME (upstream's home
100
+ // relocation) is honoured. Accepted cost: a user deliberately running codex with cwd inside ~/.codex
101
+ // gets no profile signals for that session.
102
+ {
103
+ const evCwd = typeof event.cwd === "string" ? event.cwd : "";
104
+ if (evCwd) {
105
+ const norm = (p) => String(p).replace(/\\/g, "/").replace(/\/+$/, "");
106
+ const codexHome = norm(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
107
+ const c = norm(evCwd);
108
+ if (c === codexHome || c.startsWith(codexHome + "/")) done();
109
+ }
110
+ }
111
+
88
112
  // ── Dormancy gate: resolve THIS run's hub coordinates, mirroring signal.mjs's resolution order. ──────
89
113
  const isPlaceholder = (v) => Boolean(v) && /^\$\{.*\}$/.test(v); // sanitized-env literal `${BRIDGE_*}`
90
114
  const envVar = (v) => (v && !isPlaceholder(v) ? v : undefined);
@@ -183,8 +207,34 @@ function selfDeriveHandshake(ev, cfg) {
183
207
  }
184
208
 
185
209
  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);
210
+ const codexSid = typeof event.session_id === "string" ? event.session_id : undefined;
211
+ let handshake = resolveHandshake(codexSid);
212
+
213
+ // ── TUI-SYNTHESIZED START (2026-08-02, live-verified on Codex v0.146). The 0.146 interactive TUI
214
+ // dispatches NO SessionStart hook at open — the same binary's `codex exec` does, same hooks.json, same
215
+ // trust rows (a TUI session sat open with zero dispatches anywhere; every event after the first prompt
216
+ // then fired normally). No hook ⇒ no code of ours runs, so the closest honest approximation is: when the
217
+ // FIRST event we ever see for a session is not itself a start (SessionStart would double) or a close
218
+ // (started→ended in one breath is a pointless flash-and-revert), SessionStart never dispatched —
219
+ // synthesize `started` and POST it BEFORE the event's own signal. First-sighting detection is the
220
+ // self-derive handshake write itself: a lane where SessionStart DOES fire (exec, or a fixed future TUI)
221
+ // writes the file at start, so every later event finds it and never re-synthesizes. The explicit
222
+ // BRIDGE_SESSION_ID env path never synthesizes (a launcher that exports coords owns its own lifecycle),
223
+ // and a no-account run writes no file ⇒ no synthesis (the hub would refuse the unattributable POST anyway).
224
+ let synthesizeStart = false;
225
+ if (!handshake && autoProfilesEnabled(cfg)) {
226
+ const sessionsFile = codexSid ? path.join(SECRET_DIR, "sessions", `${codexSid}.json`) : null;
227
+ const hadFile = Boolean(sessionsFile) && existsSync(sessionsFile);
228
+ handshake = selfDeriveHandshake(event, cfg);
229
+ const name = String(event.hook_event_name ?? "");
230
+ synthesizeStart =
231
+ Boolean(handshake) &&
232
+ Boolean(sessionsFile) &&
233
+ !hadFile &&
234
+ existsSync(sessionsFile) && // the write really happened (needs a signed-in account)
235
+ name !== "SessionStart" &&
236
+ name !== "SessionEnd";
237
+ }
188
238
  if (!handshake) done(); // no bridge session AND no hub configured (or auto-profiles off) — stay silent
189
239
 
190
240
  // ── Classify table: hook_event_name (+ source) → one wire state, {tick:true}, or null. ────────────────
@@ -193,7 +243,8 @@ function classify(ev) {
193
243
  switch (name) {
194
244
  case "SessionStart": {
195
245
  const source = String(ev.source ?? "");
196
- // A compact "restart" is the SAME session continuing — PostCompact owns compaction.
246
+ // A compact "restart" is the SAME session continuing — PostCompact owns compaction. A "fork"
247
+ // deliberately falls through to `started` (a forked session IS a new session opening here).
197
248
  if (source === "compact") return null;
198
249
  return { state: source === "resume" ? "resumed" : "started" };
199
250
  }
@@ -201,6 +252,10 @@ function classify(ev) {
201
252
  return { state: "working" };
202
253
  case "Stop":
203
254
  return { state: "finished" };
255
+ case "SessionEnd":
256
+ // The real close (upstream hook, mid-2026): runs the hub's ONE ended path — arbiter stand-down +
257
+ // scene revert — immediately, instead of waiting out the 30-min SessionIdleWatch backstop.
258
+ return { state: "ended" };
204
259
  case "PermissionRequest":
205
260
  // Observe-and-abstain: classify it for the device signal, but NEVER emit stdout (below, always).
206
261
  return { state: "needs_permission" };
@@ -213,11 +268,11 @@ function classify(ev) {
213
268
  case "SubagentStop":
214
269
  return { state: "subagentFinished" };
215
270
  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;
271
+ // S2: legacy best-effort error detection — MIRRORS signal.mjs's exact check (the Claude sibling,
272
+ // same field names: upstream's `tool_output` first, older `tool_response` as fallback): fire only
273
+ // when the tool response explicitly signals a hard failure, biased to UNDER-fire (an unflagged
274
+ // failure stays silent) so a normal session never flashes the error scene.
275
+ const resp = ev.tool_output ?? ev.tool_response;
221
276
  const isError =
222
277
  resp &&
223
278
  typeof resp === "object" &&
@@ -234,7 +289,10 @@ function classify(ev) {
234
289
  }
235
290
 
236
291
  const mapped = classify(event);
237
- if (!mapped) done(); // this event carries no state and no tick → no-op
292
+ // A synthesized start still POSTs even when the triggering event itself classifies to nothing (a future
293
+ // unregistered hook as the first sighting) — otherwise that sighting writes the handshake, exits, and the
294
+ // session's start is lost forever.
295
+ if (!mapped && !synthesizeStart) done(); // no state, no tick, nothing to synthesize → no-op
238
296
 
239
297
  // ── readRolloutTail: the context ferry. Positioned read of the last 64KB only — rollouts reach tens of
240
298
  // MB and this runs per tool call. Walk BACKWARD for the first (= latest) token_count row; cumulative
@@ -331,23 +389,37 @@ const ids = {
331
389
  ts: Date.now(),
332
390
  ...(typeof event.cwd === "string" ? { cwd: event.cwd } : {}),
333
391
  };
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
392
  // Straight from the payload — unlike Claude, Codex ships model + permission_mode on every hook event.
337
393
  const mode = typeof event.permission_mode === "string" && event.permission_mode ? event.permission_mode : undefined;
338
394
  const model = typeof event.model === "string" && event.model ? event.model : undefined;
339
395
  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
- );
396
+ // This event's signal(s), POSTed strictly in order: a synthesized `started` must land BEFORE the event's
397
+ // own signal so the hub tracker sees start → activity exactly as a real dispatch order would. A tick
398
+ // (PostToolUse) targets the state-less context route; everything else is a state signal. The `event`
399
+ // audit string on the synthesized row says so — the state digest shows what really happened.
400
+ const posts = [];
401
+ if (synthesizeStart) {
402
+ posts.push({
403
+ apiPath: "/local/session/state",
404
+ payload: JSON.stringify({ ...ids, state: "started", event: "codex:SessionStart~synthesized", ...(context ? { context } : {}), ...ferry }),
405
+ });
406
+ }
407
+ if (mapped) {
408
+ posts.push({
409
+ apiPath: mapped.tick ? "/local/session/context" : "/local/session/state",
410
+ payload: JSON.stringify(
411
+ mapped.tick
412
+ ? { ...ids, ...(context ? { context } : {}), ...ferry }
413
+ : {
414
+ ...ids,
415
+ state: mapped.state,
416
+ event: `codex:${String(event.hook_event_name ?? "")}`,
417
+ ...(context ? { context } : {}),
418
+ ...ferry,
419
+ },
420
+ ),
421
+ });
422
+ }
351
423
 
352
424
  /** One bounded breadcrumb for a REJECTED signal — the twin of signal.mjs's, which this script lacked, so
353
425
  * a dead cross-boundary Codex hook left no trace anywhere and `doctor` had nothing to report. Written to
@@ -377,30 +449,37 @@ function recordHookRejection(status) {
377
449
  }
378
450
 
379
451
  // 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,
452
+ // timeout per request. Sequential: each POST goes out only after the previous one's response completes
453
+ // (order is the point of the chain); an unreachable/unresponsive hub aborts the remainder — bounded,
454
+ // never blocking Codex. Observe-and-abstain: responses are drained and discarded — NOTHING from them (or
455
+ // anywhere else in this script) is ever written to our own stdout.
456
+ function send(i) {
457
+ if (i >= posts.length) done();
458
+ const { apiPath, payload } = posts[i];
459
+ const req = request(
460
+ {
461
+ host: "127.0.0.1",
462
+ port: handshake.hubPort,
463
+ path: apiPath,
464
+ method: "POST",
465
+ headers: {
466
+ "content-type": "application/json",
467
+ "content-length": Buffer.byteLength(payload),
468
+ "x-bridge-hub-token": token,
469
+ },
470
+ timeout: 2000,
392
471
  },
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);
472
+ (res) => {
473
+ if (res.statusCode && res.statusCode >= 400) recordHookRejection(res.statusCode);
474
+ res.on("data", () => {}); // drain
475
+ res.on("end", () => send(i + 1));
476
+ },
477
+ );
478
+ req.on("timeout", () => {
479
+ req.destroy();
480
+ done(); // an unresponsive hub won't take the next one either
481
+ });
482
+ req.on("error", done); // hub down / connection refused → silent no-op
483
+ req.end(payload);
484
+ }
485
+ send(0);
@@ -170,25 +170,26 @@ function selfDeriveHandshake(ev, cfg) {
170
170
  // SIGNED-IN account (sub === pinned scope). No account ⇒ nothing written ⇒ unattributable ⇒ still refused;
171
171
  // an acbridge run / channel file is never overwritten; the selfWritten marker keeps gate.mjs dev-freedom.
172
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
- }
173
+ // The write runs on EVERY hook, SessionStart INCLUDED (2026-08). It used to be suppressed at
174
+ // SessionStart (which fires BEFORE plugin MCP servers connect, code.claude.com/docs/en/hooks) so a
175
+ // bare `claude --channels` channel could never mistake a selfWritten race file for its own — but that
176
+ // suppression made `started`/`resumed` DEAD in every plain/app-launched session: no handshake at
177
+ // start ⇒ the hub drops the whole start payload ⇒ start scenes never fire. The race it guarded is
178
+ // CLOSED by AS-T9f2 (channel/src/handshake.ts): the channel, on connect, ADOPTS a pre-existing file
179
+ // and MERGE-asserts `voice: true` onto it (every field preserved, selfWritten included), before any
180
+ // reply egress or the first PreToolUse gate — so gate.mjs sees the voice marker either way. The lone
181
+ // residual is handshake.ts's documented Finding A (that atomic merge-rewrite failing), judged
182
+ // practically unreachable there: a same-user atomic rewrite of a 0600 file milliseconds old, inside
183
+ // the sub-second window before mcp.connect.
184
+ writePlainSessionHandshakeIfAbsent({
185
+ secretDir: SECRET_DIR,
186
+ id,
187
+ hubPort: cfg.hubPort,
188
+ hubTokenFile,
189
+ cwd,
190
+ claudeSessionId: typeof ev.session_id === "string" ? ev.session_id : undefined,
191
+ sub: readActiveUserSub(SECRET_DIR),
192
+ });
192
193
  return { id, hubPort: cfg.hubPort, hubTokenFile };
193
194
  }
194
195
 
@@ -197,6 +198,71 @@ let handshake = resolveHandshake(typeof event.session_id === "string" ? event.se
197
198
  if (!handshake && autoProfilesEnabled(cfg)) handshake = selfDeriveHandshake(event, cfg);
198
199
  if (!handshake) done(); // no bridge session AND no hub configured (or auto-profiles off) — stay silent
199
200
 
201
+ // ── Agent device-brief (2026-08-02, "Make agent aware of devices in chat"). At SessionStart, ask the
202
+ // hub whether this cwd's bound profile enables the brief and print it as additionalContext — the ONE
203
+ // stdout this script ever writes, and only on this event (every other path stays byte-silent). Emit
204
+ // on startup/clear/fork AND on compact (compaction is precisely when previously-injected context is
205
+ // lost); SKIP resume (the resumed transcript already contains the brief). Silent on any error,
206
+ // non-200, enabled:false, or a dead hub (ECONNREFUSED returns instantly; the timeout only guards a
207
+ // hung hub — a session must never start late because of us). The GET creates no files and completes
208
+ // BEFORE the classify/POST flow below, so ordering and the exit-0 contract are unchanged.
209
+ await new Promise((resolveBrief) => {
210
+ const name = String(event.hook_event_name ?? "");
211
+ const source = String(event.source ?? "");
212
+ if (name !== "SessionStart" || source === "resume") return resolveBrief();
213
+ let token = "";
214
+ try {
215
+ token = readFileSync(handshake.hubTokenFile, "utf8").trim();
216
+ } catch {
217
+ return resolveBrief();
218
+ }
219
+ if (!token) return resolveBrief();
220
+ const cwd = typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
221
+ let settled = false;
222
+ const settle = () => {
223
+ if (!settled) {
224
+ settled = true;
225
+ resolveBrief();
226
+ }
227
+ };
228
+ const req = request(
229
+ {
230
+ host: "127.0.0.1",
231
+ port: handshake.hubPort,
232
+ path: `/local/agent/brief?cwd=${encodeURIComponent(cwd)}&agent=claude`,
233
+ method: "GET",
234
+ headers: { "x-bridge-hub-token": token },
235
+ timeout: 600,
236
+ },
237
+ (res) => {
238
+ let body = "";
239
+ res.setEncoding("utf8");
240
+ res.on("data", (c) => (body += c));
241
+ res.on("end", () => {
242
+ try {
243
+ if (res.statusCode === 200) {
244
+ const parsed = JSON.parse(body || "{}");
245
+ if (parsed && parsed.enabled === true && typeof parsed.brief === "string" && parsed.brief) {
246
+ // Nested exactly as the hooks contract requires; sliced defensively under the upstream
247
+ // 10k additionalContext cap (the hub already composes ≤9,500 — this guards version skew).
248
+ process.stdout.write(
249
+ JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: parsed.brief.slice(0, 9800) } }) + "\n",
250
+ );
251
+ }
252
+ }
253
+ } catch {
254
+ /* silent — the brief is an optimization, never a fault */
255
+ }
256
+ settle();
257
+ });
258
+ res.on("error", settle);
259
+ },
260
+ );
261
+ req.on("timeout", () => req.destroy());
262
+ req.on("error", settle);
263
+ req.end();
264
+ });
265
+
200
266
  // ── Map the firing hook (+ payload) → one wire state, {tick:true} for a context tick, or null. ───────
201
267
  const msgExtra = (ev) => (typeof ev.message === "string" && ev.message ? { message: ev.message.slice(0, 120) } : undefined);
202
268
 
@@ -207,6 +273,8 @@ function classify(ev) {
207
273
  const source = String(ev.source ?? "");
208
274
  // A compact "restart" is the SAME session continuing — PostCompact owns compaction (firing
209
275
  // `started` here re-ran the start scene after every auto-compact, the pre-expansion bug).
276
+ // `source:"fork"` (2026 upstream matcher) deliberately falls through to `started`: a forked
277
+ // session IS a new session opening in this repo. Pinned by channel/test/signal.test.ts.
210
278
  if (source === "compact") return null;
211
279
  return { state: source === "resume" ? "resumed" : "started" };
212
280
  }
@@ -265,7 +333,9 @@ function classify(ev) {
265
333
  // Legacy best-effort error detection (older Claude Code without PostToolUseFailure): fire only when
266
334
  // the tool response explicitly signals a hard failure — biased to UNDER-fire (a failed grep / non-
267
335
  // zero exit the runtime doesn't flag stays silent) so a normal session never flashes the error scene.
268
- const resp = ev.tool_response;
336
+ // Upstream renamed the payload field to `tool_output` (2026); older builds still send
337
+ // `tool_response` — read both, upstream name first.
338
+ const resp = ev.tool_output ?? ev.tool_response;
269
339
  const isError =
270
340
  resp &&
271
341
  typeof resp === "object" &&
@@ -23,8 +23,12 @@ export const CHAIN_DEPTH_ENV = "BRIDGE_STATUSLINE_DEPTH";
23
23
  const MARKERS = ["plugins/alexa/scripts/statusline.mjs", "plugins\\alexa\\scripts\\statusline.mjs"];
24
24
 
25
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/;
26
+ * script without also claiming a user's unrelated `statusline.mjs`.
27
+ * `@bridgeapp` is the Windows install directory up to v1.3.1 (one-click ⇒ named after the package,
28
+ * `@bridge/app`); `ac bridge` is the one from v1.3.2 (assisted ⇒ named after the product). Both are
29
+ * live on real machines forever, because an in-place upgrade keeps the directory it was installed in.
30
+ * Matched against `norm()`ed text, so lower-case with `/` separators. */
31
+ const BRIDGE_OWNED = /\.claude-bridge|@bridgeapp|ac bridge|bridge-node|ac-bridge/;
28
32
 
29
33
  const norm = (s) => s.replace(/\\/g, "/").toLowerCase();
30
34