@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.
- package/package.json +2 -5
- package/src/agent-detect.js +1 -1
- package/src/bin.js +23 -4
- package/src/bridge/bin/commit-boundary.js +64 -0
- package/src/bridge/connectors/capture-guard.js +24 -0
- package/src/bridge/connectors/claude-code/hook-entry.js +45 -0
- package/src/bridge/connectors/claude-code/notify.js +57 -0
- package/src/bridge/connectors/codex/cursor-store.js +83 -0
- package/src/bridge/connectors/codex/hook-entry.js +215 -0
- package/src/bridge/connectors/codex/session-parser.js +100 -0
- package/src/bridge/connectors/opencode/hook-entry.js +55 -0
- package/src/bridge/core/bridge.js +205 -0
- package/src/bridge/core/commit-projection.js +288 -0
- package/src/bridge/core/compaction.js +23 -0
- package/src/bridge/core/consumer-registry.js +101 -0
- package/src/bridge/core/event-schema.js +127 -0
- package/src/bridge/core/fs-utils.js +81 -0
- package/src/bridge/core/journal-router.js +39 -0
- package/src/bridge/core/journal.js +507 -0
- package/src/bridge/core/lock.js +94 -0
- package/src/bridge/core/normalizer.js +114 -0
- package/src/bridge/core/paths.js +12 -0
- package/src/bridge/core/project-registry.js +176 -0
- package/src/bridge/core/repo-context.js +188 -0
- package/src/bridge/core/runtime-home.js +188 -0
- package/src/bridge/core/semver.js +23 -0
- package/src/bridge/core/turn-identity.js +127 -0
- package/src/bridge/index.js +86 -0
- package/src/bridge/installers/claude-code/installer.js +181 -0
- package/src/bridge/installers/codex/installer.js +114 -0
- package/src/bridge/installers/opencode/installer.js +96 -0
- package/src/bridge/installers/zcode/installer.js +188 -0
- package/src/bridge/query/conversation-query.js +194 -0
- 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 };
|