@sanqianx/ai-coding-event-bridge-console 0.5.1 → 0.6.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.
Files changed (34) hide show
  1. package/package.json +2 -5
  2. package/src/agent-detect.js +1 -1
  3. package/src/bin.js +23 -4
  4. package/src/bridge/bin/commit-boundary.js +64 -0
  5. package/src/bridge/connectors/capture-guard.js +24 -0
  6. package/src/bridge/connectors/claude-code/hook-entry.js +45 -0
  7. package/src/bridge/connectors/claude-code/notify.js +57 -0
  8. package/src/bridge/connectors/codex/cursor-store.js +83 -0
  9. package/src/bridge/connectors/codex/hook-entry.js +215 -0
  10. package/src/bridge/connectors/codex/session-parser.js +100 -0
  11. package/src/bridge/connectors/opencode/hook-entry.js +55 -0
  12. package/src/bridge/core/bridge.js +205 -0
  13. package/src/bridge/core/commit-projection.js +288 -0
  14. package/src/bridge/core/compaction.js +23 -0
  15. package/src/bridge/core/consumer-registry.js +101 -0
  16. package/src/bridge/core/event-schema.js +127 -0
  17. package/src/bridge/core/fs-utils.js +81 -0
  18. package/src/bridge/core/journal-router.js +39 -0
  19. package/src/bridge/core/journal.js +507 -0
  20. package/src/bridge/core/lock.js +94 -0
  21. package/src/bridge/core/normalizer.js +114 -0
  22. package/src/bridge/core/paths.js +12 -0
  23. package/src/bridge/core/project-registry.js +176 -0
  24. package/src/bridge/core/repo-context.js +188 -0
  25. package/src/bridge/core/runtime-home.js +188 -0
  26. package/src/bridge/core/semver.js +23 -0
  27. package/src/bridge/core/turn-identity.js +127 -0
  28. package/src/bridge/index.js +86 -0
  29. package/src/bridge/installers/claude-code/installer.js +181 -0
  30. package/src/bridge/installers/codex/installer.js +114 -0
  31. package/src/bridge/installers/opencode/installer.js +96 -0
  32. package/src/bridge/installers/zcode/installer.js +188 -0
  33. package/src/bridge/query/conversation-query.js +194 -0
  34. package/src/server.js +2 -2
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ const { randomUUID } = require('crypto');
4
+ const { validateAndNormalizeEvent } = require('./event-schema');
5
+
6
+ function assignTurnIdForPrompt() {
7
+ // A real captured user prompt owns a fresh turn identity. This is identity
8
+ // assignment for captured evidence, never content fabrication.
9
+ return `turn_${randomUUID()}`;
10
+ }
11
+
12
+ /**
13
+ * Claude Code hook payload -> normalized event.
14
+ * Claude Code ships the event name as hook_event_name; hookName/hook_name
15
+ * variants are accepted for compatibility. UserPromptSubmit carries the exact
16
+ * user prompt; Stop yields an assistant_response only when
17
+ * last_assistant_message exists — an empty Stop is not conversation evidence
18
+ * and produces no event. Turn closure binding happens in the journal's
19
+ * canonical append path.
20
+ */
21
+ function hookEventName(raw) {
22
+ return raw.hook_event_name || raw.hookName || raw.hook_name || null;
23
+ }
24
+
25
+ function normalizeClaudeCode(raw) {
26
+ if (!raw || typeof raw !== 'object') return null;
27
+ const base = { source: 'claude-code' };
28
+ if (hookEventName(raw) === 'UserPromptSubmit' || raw.eventType === 'user_prompt') {
29
+ const content = typeof raw.prompt === 'string' ? raw.prompt : null;
30
+ const event = {
31
+ ...base,
32
+ eventType: 'user_prompt',
33
+ role: 'user',
34
+ content,
35
+ sessionId: raw.session_id || null,
36
+ turnId: raw.turnId || assignTurnIdForPrompt(),
37
+ repoIdentity: raw.repoIdentity,
38
+ projectPath: raw.cwd,
39
+ identityConfidence: content === null ? 'partial' : 'exact',
40
+ captureStatus: content === null ? 'partial' : 'complete',
41
+ rawEventType: hookEventName(raw) || 'user_prompt'
42
+ };
43
+ return validateAndNormalizeEvent(event);
44
+ }
45
+ if (hookEventName(raw) === 'Stop' || raw.eventType === 'assistant_response') {
46
+ if (typeof raw.last_assistant_message !== 'string' || !raw.last_assistant_message) {
47
+ return null;
48
+ }
49
+ return validateAndNormalizeEvent({
50
+ ...base,
51
+ eventType: 'assistant_response',
52
+ role: 'assistant',
53
+ content: raw.last_assistant_message,
54
+ sessionId: raw.session_id || null,
55
+ turnId: raw.turnId || null,
56
+ repoIdentity: raw.repoIdentity,
57
+ projectPath: raw.cwd,
58
+ identityConfidence: 'partial',
59
+ captureStatus: 'complete',
60
+ rawEventType: hookEventName(raw) || 'assistant_response'
61
+ });
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Codex events arrive via notify + session parsing (BR05 owns the parser).
68
+ * The normalizer only maps a parsed turn record to the standard event shape.
69
+ */
70
+ function normalizeCodex(raw) {
71
+ if (!raw || typeof raw !== 'object') return null;
72
+ if (raw.eventType === 'user_prompt' || raw.eventType === 'assistant_response') {
73
+ return validateAndNormalizeEvent({
74
+ source: 'codex',
75
+ eventType: raw.eventType,
76
+ role: raw.eventType === 'user_prompt' ? 'user' : 'assistant',
77
+ content: typeof raw.content === 'string' ? raw.content : null,
78
+ sessionId: raw.sessionId || null,
79
+ turnId: raw.turnId || (raw.eventType === 'user_prompt' ? assignTurnIdForPrompt() : null),
80
+ repoIdentity: raw.repoIdentity,
81
+ projectPath: raw.projectPath || raw.cwd,
82
+ identityConfidence: raw.identityConfidence,
83
+ captureStatus: raw.captureStatus,
84
+ rawEventType: raw.rawEventType
85
+ });
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * OpenCode plugin events: user/assistant lifecycle from the official plugin.
92
+ */
93
+ function normalizeOpenCode(raw) {
94
+ if (!raw || typeof raw !== 'object') return null;
95
+ if (raw.type === 'user' || raw.type === 'assistant') {
96
+ const isUser = raw.type === 'user';
97
+ return validateAndNormalizeEvent({
98
+ source: 'opencode',
99
+ eventType: isUser ? 'user_prompt' : 'assistant_response',
100
+ role: isUser ? 'user' : 'assistant',
101
+ content: typeof raw.text === 'string' ? raw.text : null,
102
+ sessionId: raw.sessionId || null,
103
+ turnId: isUser ? (raw.turnId || assignTurnIdForPrompt()) : raw.turnId || null,
104
+ repoIdentity: raw.repoIdentity,
105
+ projectPath: raw.cwd || raw.projectPath,
106
+ identityConfidence: raw.identityConfidence,
107
+ captureStatus: raw.captureStatus,
108
+ rawEventType: raw.type
109
+ });
110
+ }
111
+ return null;
112
+ }
113
+
114
+ module.exports = { normalizeClaudeCode, normalizeCodex, normalizeOpenCode };
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ function bridgeHome(explicit) {
7
+ if (explicit) return explicit;
8
+ if (process.env.AI_CODING_EVENT_BRIDGE_HOME) return process.env.AI_CODING_EVENT_BRIDGE_HOME;
9
+ return path.join(os.homedir(), '.ai-coding-event-bridge');
10
+ }
11
+
12
+ module.exports = { bridgeHome };
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { writeJsonAtomicSync, readJsonIfExists, ensureDirSync } = require('./fs-utils');
6
+ const { CrossProcessLock } = require('./lock');
7
+ const { resolveRepoContext, comparisonForm, repoIdentityKey } = require('./repo-context');
8
+
9
+ const REGISTRY_VERSION = 1;
10
+
11
+ /**
12
+ * Imported-project registry, persisted at `<home>/projects.json`. A registered
13
+ * project owns a durable journal at `<store>/journal`; capture routes events
14
+ * whose repoIdentity.workspaceId matches the project id there instead of the
15
+ * global journal. Removing a project only unregisters it — its data on disk is
16
+ * never touched.
17
+ */
18
+
19
+ function registryFile(home) {
20
+ return path.join(home, 'projects.json');
21
+ }
22
+
23
+ function registryLockFile(home) {
24
+ return path.join(home, 'locks', 'projects.lock');
25
+ }
26
+
27
+ function loadRegistry(home) {
28
+ const raw = readJsonIfExists(registryFile(home));
29
+ if (!raw || !Array.isArray(raw.projects)) {
30
+ return { version: REGISTRY_VERSION, projects: [] };
31
+ }
32
+ return {
33
+ version: raw.version || REGISTRY_VERSION,
34
+ projects: raw.projects.filter(
35
+ (project) => project && typeof project === 'object' && typeof project.id === 'string' && typeof project.store === 'string'
36
+ )
37
+ };
38
+ }
39
+
40
+ function sameComparisonPath(a, b) {
41
+ return comparisonForm(a) === comparisonForm(b);
42
+ }
43
+
44
+ function defaultStoreFor(home, name) {
45
+ return path.join(home, 'projects', name);
46
+ }
47
+
48
+ async function addProject({ home, path: projectDir, store, resolveRepo = resolveRepoContext } = {}) {
49
+ if (typeof home !== 'string' || !home) {
50
+ throw new Error('addProject requires home');
51
+ }
52
+ if (typeof projectDir !== 'string' || !projectDir.trim()) {
53
+ const err = new Error('addProject requires a project directory');
54
+ err.code = 'PROJECT_PATH_REQUIRED';
55
+ throw err;
56
+ }
57
+ let stat = null;
58
+ try {
59
+ stat = fs.statSync(projectDir);
60
+ } catch (_) {
61
+ stat = null;
62
+ }
63
+ if (!stat || !stat.isDirectory()) {
64
+ const err = new Error(`project directory does not exist: ${projectDir}`);
65
+ err.code = 'PROJECT_DIR_INVALID';
66
+ throw err;
67
+ }
68
+
69
+ // The registry only accepts Git work trees: event identity (workspaceId) is
70
+ // derived from the Git toplevel, so a non-Git folder could never match the
71
+ // events captured inside it. Importing a subdirectory resolves to its repo
72
+ // root, which is the identity events actually carry.
73
+ const repo = await resolveRepo(projectDir);
74
+ if (!repo || !repo.repoIdentity) {
75
+ const err = new Error(`not a Git work tree (no repo identity): ${projectDir}`);
76
+ err.code = 'PROJECT_NOT_GIT';
77
+ throw err;
78
+ }
79
+
80
+ const name = path.basename(repo.projectPath) || repo.projectPath;
81
+ const entry = {
82
+ id: repo.repoIdentity.workspaceId,
83
+ name,
84
+ path: repo.projectPath,
85
+ remote: repo.repoIdentity.remote || null,
86
+ store: typeof store === 'string' && store.trim() ? path.resolve(store) : defaultStoreFor(home, name),
87
+ createdAt: new Date().toISOString()
88
+ };
89
+
90
+ const lock = new CrossProcessLock(registryLockFile(home));
91
+ return lock.withLock(async () => {
92
+ const registry = loadRegistry(home);
93
+ if (registry.projects.some((project) => project.id === entry.id)) {
94
+ const err = new Error(`project already imported: ${entry.path}`);
95
+ err.code = 'PROJECT_DUPLICATE';
96
+ throw err;
97
+ }
98
+ if (registry.projects.some((project) => sameComparisonPath(project.path, entry.path))) {
99
+ const err = new Error(`project path already imported: ${entry.path}`);
100
+ err.code = 'PROJECT_DUPLICATE';
101
+ throw err;
102
+ }
103
+ if (registry.projects.some((project) => sameComparisonPath(project.store, entry.store))) {
104
+ const err = new Error(`store location already used by another project: ${entry.store}`);
105
+ err.code = 'PROJECT_STORE_CONFLICT';
106
+ throw err;
107
+ }
108
+ try {
109
+ ensureDirSync(path.join(entry.store, 'journal'));
110
+ } catch (_) {
111
+ const err = new Error(`cannot create journal directory at: ${entry.store}`);
112
+ err.code = 'PROJECT_STORE_INVALID';
113
+ throw err;
114
+ }
115
+ writeJsonAtomicSync(registryFile(home), {
116
+ version: REGISTRY_VERSION,
117
+ projects: registry.projects.concat([entry])
118
+ });
119
+ return { ...entry };
120
+ });
121
+ }
122
+
123
+ async function removeProject({ home, id } = {}) {
124
+ if (typeof home !== 'string' || !home) {
125
+ throw new Error('removeProject requires home');
126
+ }
127
+ if (typeof id !== 'string' || !id) {
128
+ const err = new Error('removeProject requires a project id');
129
+ err.code = 'PROJECT_ID_REQUIRED';
130
+ throw err;
131
+ }
132
+ const lock = new CrossProcessLock(registryLockFile(home));
133
+ return lock.withLock(async () => {
134
+ const registry = loadRegistry(home);
135
+ let removed = null;
136
+ const projects = [];
137
+ for (const project of registry.projects) {
138
+ if (project.id === id) {
139
+ removed = project;
140
+ } else {
141
+ projects.push(project);
142
+ }
143
+ }
144
+ if (!removed) {
145
+ const err = new Error(`project not registered: ${id}`);
146
+ err.code = 'PROJECT_NOT_FOUND';
147
+ throw err;
148
+ }
149
+ writeJsonAtomicSync(registryFile(home), { version: REGISTRY_VERSION, projects });
150
+ return { ...removed };
151
+ });
152
+ }
153
+
154
+ function listProjects(home) {
155
+ return loadRegistry(home).projects.map((project) => ({ ...project }));
156
+ }
157
+
158
+ // Registry lookup by capture identity (repoIdentity.workspaceId, or the
159
+ // identity string itself for legacy records): the same rule journal routing
160
+ // uses to pick a project's journal.
161
+ function findProject(home, repoIdentity) {
162
+ const key = repoIdentityKey(repoIdentity);
163
+ if (!key) return null;
164
+ const project = loadRegistry(home).projects.find((candidate) => candidate.id === key);
165
+ return project ? { ...project } : null;
166
+ }
167
+
168
+ module.exports = {
169
+ REGISTRY_VERSION,
170
+ registryFile,
171
+ loadRegistry,
172
+ addProject,
173
+ removeProject,
174
+ listProjects,
175
+ findProject
176
+ };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { execFile } = require('child_process');
7
+
8
+ const REPO_IDENTITY_SCHEMA = 'repo-identity/v1';
9
+
10
+ function normalizeGitUrl(url) {
11
+ if (typeof url !== 'string') return null;
12
+ let u = url.trim();
13
+ if (!u) return null;
14
+ let match = /^git@([^:]+):(.+?)(?:\.git)?$/i.exec(u);
15
+ if (match) return `${match[1].toLowerCase()}/${match[2].replace(/\.git$/, '')}`;
16
+ match = /^(?:https?|ssh|git):\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i.exec(u);
17
+ if (match) return `${match[1].toLowerCase()}/${match[2].replace(/\.git$/, '')}`;
18
+ return u.replace(/\.git$/, '');
19
+ }
20
+
21
+ function runGitDefault(args, cwd) {
22
+ return new Promise((resolve, reject) => {
23
+ execFile('git', args, { cwd, windowsHide: true }, (err, stdout) => {
24
+ if (err) return reject(err);
25
+ resolve(String(stdout).trim());
26
+ });
27
+ });
28
+ }
29
+
30
+ function realpathBestEffort(target) {
31
+ try {
32
+ return fs.realpathSync.native ? fs.realpathSync.native(target) : fs.realpathSync(target);
33
+ } catch (_) {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ // Comparison form: identity comparisons are case-insensitive on Windows and
39
+ // separator-insensitive everywhere. The display workspaceRoot keeps its
40
+ // on-disk casing; only the hash/comparison form is normalized.
41
+ function comparisonForm(target) {
42
+ let value = String(target).replace(/\\/g, '/');
43
+ while (value.length > 1 && value.endsWith('/')) value = value.slice(0, -1);
44
+ if (process.platform === 'win32') value = value.toLowerCase();
45
+ return value;
46
+ }
47
+
48
+ function workspaceIdFor(workspaceRoot) {
49
+ const digest = crypto.createHash('sha256').update(comparisonForm(workspaceRoot)).digest('hex');
50
+ return `sha256:${digest}`;
51
+ }
52
+
53
+ function isValidRepoIdentityV1(identity) {
54
+ return Boolean(
55
+ identity &&
56
+ typeof identity === 'object' &&
57
+ identity.schema === REPO_IDENTITY_SCHEMA &&
58
+ typeof identity.workspaceId === 'string' &&
59
+ /^sha256:[0-9a-f]{64}$/.test(identity.workspaceId) &&
60
+ typeof identity.workspaceRoot === 'string' &&
61
+ identity.workspaceRoot.length > 0 &&
62
+ (identity.commonDir === null || identity.commonDir === undefined || typeof identity.commonDir === 'string') &&
63
+ (identity.remote === null || identity.remote === undefined || typeof identity.remote === 'string')
64
+ );
65
+ }
66
+
67
+ // Journal/projection key. v1 identities key by workspaceId; plain strings are
68
+ // legacy journal records written before repo-identity/v1 and stay readable.
69
+ function repoIdentityKey(identity) {
70
+ if (identity === null || identity === undefined) return null;
71
+ if (typeof identity === 'string') return identity;
72
+ if (isValidRepoIdentityV1(identity)) return identity.workspaceId;
73
+ return null;
74
+ }
75
+
76
+ // Turn-namespace key: events with no repo identity at all share the 'no-repo'
77
+ // namespace so same-session binding still works outside Git repos, while
78
+ // malformed identity objects get a unique namespace and never cross-bind.
79
+ function turnScopeKey(identity) {
80
+ if (identity === null || identity === undefined) return 'no-repo';
81
+ const key = repoIdentityKey(identity);
82
+ if (key !== null) return key;
83
+ try {
84
+ return `invalid:${JSON.stringify(identity).slice(0, 64)}`;
85
+ } catch (_) {
86
+ return 'invalid:unserializable';
87
+ }
88
+ }
89
+
90
+ function sameRepoIdentity(a, b) {
91
+ const keyA = repoIdentityKey(a);
92
+ const keyB = repoIdentityKey(b);
93
+ if (keyA === null || keyB === null) return false;
94
+ return keyA === keyB;
95
+ }
96
+
97
+ function buildRepoIdentityV1({ workspaceRoot, commonDir = null, remote = null }) {
98
+ return {
99
+ schema: REPO_IDENTITY_SCHEMA,
100
+ workspaceId: workspaceIdFor(workspaceRoot),
101
+ workspaceRoot,
102
+ commonDir: commonDir === undefined ? null : commonDir,
103
+ remote: remote === undefined ? null : remote
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Canonical workspace identity for an authoritative development cwd.
109
+ *
110
+ * 1. `git rev-parse --show-toplevel` (authoritative Git working tree root)
111
+ * 2. realpath + canonical separators for the display root
112
+ * 3. workspaceId = sha256 over the comparison form (case-insensitive on win32)
113
+ * 4. commonDir from `git rev-parse --path-format=absolute --git-common-dir`
114
+ * (metadata: worktrees share it, but their workspaceIds differ)
115
+ * 5. normalized origin URL as metadata only — never the primary identity
116
+ *
117
+ * Non-Git cwd => repoIdentity null + identityConfidence 'unavailable'.
118
+ * No fallback to process.cwd, no fake workspaceId, remote never overrides.
119
+ */
120
+ async function resolveRepoContext(cwd, { runGit = runGitDefault } = {}) {
121
+ const projectPath = realpathBestEffort(cwd) || path.resolve(cwd);
122
+ let toplevel = null;
123
+ try {
124
+ toplevel = await runGit(['rev-parse', '--show-toplevel'], cwd);
125
+ } catch (_) {
126
+ return {
127
+ repoIdentity: null,
128
+ projectPath,
129
+ branch: null,
130
+ headAtCapture: null,
131
+ identityConfidence: 'unavailable'
132
+ };
133
+ }
134
+ const realToplevel = path.resolve(realpathBestEffort(toplevel) || toplevel);
135
+
136
+ let commonDir = null;
137
+ try {
138
+ const rawCommonDir = await runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
139
+ if (rawCommonDir) commonDir = path.resolve(realpathBestEffort(rawCommonDir) || rawCommonDir);
140
+ } catch (_) {
141
+ commonDir = null;
142
+ }
143
+
144
+ let origin = null;
145
+ try {
146
+ origin = await runGit(['config', '--get', 'remote.origin.url'], cwd);
147
+ } catch (_) {
148
+ origin = null;
149
+ }
150
+ let branch = null;
151
+ try {
152
+ branch = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
153
+ } catch (_) {
154
+ branch = null;
155
+ }
156
+ let head = null;
157
+ try {
158
+ head = await runGit(['rev-parse', 'HEAD'], cwd);
159
+ } catch (_) {
160
+ head = null;
161
+ }
162
+
163
+ return {
164
+ repoIdentity: buildRepoIdentityV1({
165
+ workspaceRoot: realToplevel,
166
+ commonDir,
167
+ remote: normalizeGitUrl(origin)
168
+ }),
169
+ projectPath: realToplevel,
170
+ branch: branch === 'HEAD' ? null : branch,
171
+ headAtCapture: head,
172
+ identityConfidence: 'exact'
173
+ };
174
+ }
175
+
176
+ module.exports = {
177
+ REPO_IDENTITY_SCHEMA,
178
+ normalizeGitUrl,
179
+ resolveRepoContext,
180
+ realpathBestEffort,
181
+ comparisonForm,
182
+ workspaceIdFor,
183
+ buildRepoIdentityV1,
184
+ isValidRepoIdentityV1,
185
+ repoIdentityKey,
186
+ turnScopeKey,
187
+ sameRepoIdentity
188
+ };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { bridgeHome } = require('./paths');
6
+ const { CrossProcessLock, lockPathFor } = require('./lock');
7
+ const { writeJsonAtomicSync, readJsonIfExists, ensureDirSync, copyDirSync } = require('./fs-utils');
8
+ const semver = require('./semver');
9
+
10
+ // The shim is intentionally version-independent: AI client configs point at this
11
+ // stable path forever, and it delegates to whatever runtime version is active.
12
+ // It reads the client payload from stdin, forwards it to the active runtime and
13
+ // always exits 0 — fail-open is a product invariant.
14
+ const SHIM_SOURCE = `#!/usr/bin/env node
15
+ 'use strict';
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ function home() {
21
+ return process.env.AI_CODING_EVENT_BRIDGE_HOME || path.join(os.homedir(), '.ai-coding-event-bridge');
22
+ }
23
+
24
+ function readStdin() {
25
+ return new Promise((resolve) => {
26
+ let data = '';
27
+ try {
28
+ process.stdin.setEncoding('utf8');
29
+ process.stdin.on('data', (chunk) => (data += chunk));
30
+ process.stdin.on('end', () => resolve(data));
31
+ process.stdin.on('error', () => resolve(data));
32
+ } catch (_) {
33
+ resolve(data);
34
+ }
35
+ });
36
+ }
37
+
38
+ (async () => {
39
+ try {
40
+ const raw = await readStdin();
41
+ let payload = null;
42
+ try {
43
+ payload = JSON.parse(raw);
44
+ } catch (_) {
45
+ payload = null;
46
+ }
47
+ const active = JSON.parse(fs.readFileSync(path.join(home(), 'active-runtime.json'), 'utf8'));
48
+ const hookPath = path.join(home(), 'runtime', String(active.version), 'hook.cjs');
49
+ if (fs.existsSync(hookPath)) {
50
+ const runtime = require(hookPath);
51
+ if (typeof runtime.main === 'function') {
52
+ await runtime.main({ payload, home: home(), argv: process.argv.slice(2), env: process.env });
53
+ }
54
+ }
55
+ } catch (_) {
56
+ // Fail open by design: a broken Bridge must never block the AI client.
57
+ } finally {
58
+ process.exit(0);
59
+ }
60
+ })();
61
+ `;
62
+
63
+ // The versioned runtime entry: dispatches the client payload into the
64
+ // self-contained bridge tree materialized next to it. The capture-disable
65
+ // environment marker is honored before any connector is even loaded.
66
+ const RUNTIME_HOOK_SOURCE = `#!/usr/bin/env node
67
+ 'use strict';
68
+ async function main({ payload, home, argv, env }) {
69
+ const effectiveEnv = env || process.env;
70
+ if (effectiveEnv && effectiveEnv.AI_CODING_EVENT_BRIDGE_CAPTURE === '0') {
71
+ return { status: 'ignored', reason: 'capture-disabled' };
72
+ }
73
+ if (payload && typeof payload === 'object') {
74
+ // Claude Code ships hook_event_name (also seen as hookName/hook_name);
75
+ // all three identify a Claude hook payload and MUST route to the Claude
76
+ // connector — never fall through to Codex just because session_id exists.
77
+ if (payload.hookName || payload.hook_event_name || payload.hook_name) {
78
+ const claudeEntry = require('./bridge/connectors/claude-code/hook-entry.js');
79
+ return claudeEntry.mainFailOpen({ home, payload, argv, env });
80
+ }
81
+ if (payload.type === 'user' || payload.type === 'assistant') {
82
+ const opencodeEntry = require('./bridge/connectors/opencode/hook-entry.js');
83
+ return opencodeEntry.mainFailOpen({ home, payload });
84
+ }
85
+ if (payload.session_id || payload.sessionId) {
86
+ const codexEntry = require('./bridge/connectors/codex/hook-entry.js');
87
+ return codexEntry.mainFailOpen({ home, payload });
88
+ }
89
+ }
90
+ return { status: 'ignored' };
91
+ }
92
+ module.exports = { main };
93
+ `;
94
+
95
+ function materializeBridgeTree(runtimeDir) {
96
+ const srcRoot = path.join(__dirname, '..');
97
+ copyDirSync(path.join(srcRoot, 'core'), path.join(runtimeDir, 'bridge', 'core'));
98
+ copyDirSync(path.join(srcRoot, 'connectors'), path.join(runtimeDir, 'bridge', 'connectors'));
99
+ }
100
+
101
+ function writeTextAtomicSync(file, content) {
102
+ const tmp = `${file}.tmp`;
103
+ const fd = fs.openSync(tmp, 'w');
104
+ try {
105
+ fs.writeSync(fd, content);
106
+ fs.fsyncSync(fd);
107
+ } finally {
108
+ fs.closeSync(fd);
109
+ }
110
+ fs.renameSync(tmp, file);
111
+ }
112
+
113
+ async function ensureRuntimeHome({ homeDir, version } = {}) {
114
+ const home = bridgeHome(homeDir);
115
+ const requestedVersion = version || require('../../../package.json').version;
116
+ const lock = new CrossProcessLock(lockPathFor(home, 'install.lock'));
117
+ return lock.withLock(async () => {
118
+ ensureDirSync(path.join(home, 'bin'));
119
+ ensureDirSync(path.join(home, 'runtime'));
120
+ ensureDirSync(path.join(home, 'journal'));
121
+ ensureDirSync(path.join(home, 'locks'));
122
+
123
+ const shimPath = path.join(home, 'bin', 'bridge-hook.cjs');
124
+ writeTextAtomicSync(shimPath, SHIM_SOURCE);
125
+
126
+ const runtimeDir = path.join(home, 'runtime', requestedVersion);
127
+ const activeFile = path.join(home, 'active-runtime.json');
128
+ const active = readJsonIfExists(activeFile);
129
+
130
+ const materializeRuntime = () => {
131
+ ensureDirSync(runtimeDir);
132
+ writeTextAtomicSync(path.join(runtimeDir, 'hook.cjs'), RUNTIME_HOOK_SOURCE);
133
+ materializeBridgeTree(runtimeDir);
134
+ if (!fs.existsSync(path.join(runtimeDir, 'manifest.json'))) {
135
+ writeJsonAtomicSync(path.join(runtimeDir, 'manifest.json'), {
136
+ version: requestedVersion,
137
+ createdAt: new Date().toISOString()
138
+ });
139
+ }
140
+ };
141
+
142
+ let action;
143
+ if (!active || !active.version) {
144
+ materializeRuntime();
145
+ writeJsonAtomicSync(activeFile, { version: requestedVersion, activatedAt: new Date().toISOString() });
146
+ action = 'activated';
147
+ } else {
148
+ const cmp = semver.compare(active.version, requestedVersion);
149
+ if (cmp === null) {
150
+ materializeRuntime();
151
+ writeJsonAtomicSync(activeFile, { version: requestedVersion, activatedAt: new Date().toISOString() });
152
+ action = 'activated-unparseable-active';
153
+ } else if (cmp === 0) {
154
+ materializeRuntime();
155
+ action = 'noop-same-version';
156
+ } else if (cmp < 0) {
157
+ const activeMajor = semver.parse(active.version).major;
158
+ const requestedMajor = semver.parse(requestedVersion).major;
159
+ if (activeMajor !== requestedMajor) {
160
+ return {
161
+ home,
162
+ shimPath,
163
+ conflict: true,
164
+ activeVersion: active.version,
165
+ requestedVersion,
166
+ action: 'conflict-major-mismatch'
167
+ };
168
+ }
169
+ materializeRuntime();
170
+ writeJsonAtomicSync(activeFile, { version: requestedVersion, activatedAt: new Date().toISOString() });
171
+ action = 'upgraded';
172
+ } else {
173
+ action = 'kept-newer';
174
+ }
175
+ }
176
+
177
+ return {
178
+ home,
179
+ shimPath,
180
+ conflict: false,
181
+ activeVersion: readJsonIfExists(activeFile).version,
182
+ requestedVersion,
183
+ action
184
+ };
185
+ });
186
+ }
187
+
188
+ module.exports = { ensureRuntimeHome, SHIM_SOURCE };