@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,23 @@
1
+ 'use strict';
2
+
3
+ function parse(version) {
4
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version));
5
+ if (!match) return null;
6
+ return {
7
+ major: Number(match[1]),
8
+ minor: Number(match[2]),
9
+ patch: Number(match[3])
10
+ };
11
+ }
12
+
13
+ function compare(a, b) {
14
+ const pa = parse(a);
15
+ const pb = parse(b);
16
+ if (!pa || !pb) return null;
17
+ if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
18
+ if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
19
+ if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
20
+ return 0;
21
+ }
22
+
23
+ module.exports = { parse, compare };
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // F-024: DevTask-Radar used to synthesize '(No captured user prompt for this
4
+ // turn)' as fake user text. Synthetic evidence is forbidden anywhere downstream.
5
+ const SYNTHETIC_PROMPT_MARKERS = [
6
+ '(no captured user prompt for this turn)',
7
+ '(no user prompt captured)',
8
+ 'unknown-session'
9
+ ];
10
+
11
+ function isSyntheticPrompt(content) {
12
+ if (typeof content !== 'string') return false;
13
+ const trimmed = content.trim().toLowerCase();
14
+ return SYNTHETIC_PROMPT_MARKERS.includes(trimmed);
15
+ }
16
+
17
+ function orderBySequence(events) {
18
+ return [...events].sort((a, b) => {
19
+ if (a.sequence !== b.sequence) return a.sequence - b.sequence;
20
+ return String(a.eventId || '').localeCompare(String(b.eventId || ''));
21
+ });
22
+ }
23
+
24
+ function isDuplicateEvent(a, b) {
25
+ if (!a || !b) return false;
26
+ if (a.eventKey && b.eventKey && a.eventKey === b.eventKey) return true;
27
+ return (
28
+ a.source === b.source &&
29
+ a.eventType === b.eventType &&
30
+ (a.sessionId || null) === (b.sessionId || null) &&
31
+ (a.turnId || null) === (b.turnId || null) &&
32
+ a.content === b.content &&
33
+ a.eventId !== undefined &&
34
+ a.eventId === b.eventId
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Deterministic turn projection over ordered events. Mirrors the journal's
40
+ * durable projection: user_prompt opens a turn; assistant_response closes the
41
+ * matching open turn (by turnId, or by sessionId when the client did not
42
+ * provide a turn id). Assistant events that cannot be tied to an open turn are
43
+ * orphan evidence — they are kept, never turned into fake prompts.
44
+ */
45
+ function deriveTurnState(events) {
46
+ const ordered = orderBySequence(events.filter((e) => e && typeof e === 'object'));
47
+ const openTurns = new Map();
48
+ const closedTurns = new Map();
49
+ const orphanAssistantEvents = [];
50
+
51
+ const findOpenTurnForAssistant = (event) => {
52
+ if (event.turnId && openTurns.has(event.turnId)) return openTurns.get(event.turnId);
53
+ if (event.turnId) return null;
54
+ if (!event.sessionId) return null;
55
+ const matches = [...openTurns.values()].filter((t) => t.sessionId === event.sessionId);
56
+ if (matches.length === 1) return matches[0];
57
+ return null;
58
+ };
59
+
60
+ for (const event of ordered) {
61
+ if (event.eventType === 'user_prompt') {
62
+ if (!event.turnId) continue; // unverifiable prompt: identity layer marks it, no fake turn
63
+ if (openTurns.has(event.turnId) || closedTurns.has(event.turnId)) {
64
+ const turn = openTurns.get(event.turnId) || closedTurns.get(event.turnId);
65
+ turn.userEvents.push(event);
66
+ continue;
67
+ }
68
+ openTurns.set(event.turnId, {
69
+ turnId: event.turnId,
70
+ sessionId: event.sessionId || null,
71
+ source: event.source,
72
+ startSequence: event.sequence,
73
+ endSequence: null,
74
+ userEvents: [event],
75
+ assistantEvents: []
76
+ });
77
+ continue;
78
+ }
79
+ if (event.eventType === 'assistant_response') {
80
+ const turn = findOpenTurnForAssistant(event);
81
+ if (!turn) {
82
+ orphanAssistantEvents.push(event);
83
+ continue;
84
+ }
85
+ turn.assistantEvents.push(event);
86
+ turn.endSequence = event.sequence;
87
+ openTurns.delete(turn.turnId);
88
+ closedTurns.set(turn.turnId, turn);
89
+ continue;
90
+ }
91
+ if (event.eventType === 'session_end' && event.sessionId) {
92
+ for (const turn of [...openTurns.values()]) {
93
+ if (turn.sessionId === event.sessionId) {
94
+ turn.endSequence = event.sequence;
95
+ openTurns.delete(turn.turnId);
96
+ closedTurns.set(turn.turnId, turn);
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ return {
103
+ openTurns: [...openTurns.values()],
104
+ closedTurns: [...closedTurns.values()],
105
+ orphanAssistantEvents
106
+ };
107
+ }
108
+
109
+ function assertNoSyntheticEvidence(events) {
110
+ for (const event of events) {
111
+ if (event && event.role === 'user' && isSyntheticPrompt(event.content)) {
112
+ throw new Error(`synthetic user prompt is forbidden: ${String(event.content).slice(0, 60)}`);
113
+ }
114
+ if (event && typeof event.sessionId === 'string' && event.sessionId.toLowerCase() === 'unknown-session') {
115
+ throw new Error('unknown-session is forbidden');
116
+ }
117
+ }
118
+ return true;
119
+ }
120
+
121
+ module.exports = {
122
+ isSyntheticPrompt,
123
+ orderBySequence,
124
+ isDuplicateEvent,
125
+ deriveTurnState,
126
+ assertNoSyntheticEvidence
127
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const packageInfo = require('../../package.json');
4
+ const { bridgeHome } = require('./core/paths');
5
+ const { Journal, JournalValidationError, JournalCorruptError, EVENT_SCHEMA, BOUNDARY_SCHEMA } = require('./core/journal');
6
+ const { ConsumerRegistry } = require('./core/consumer-registry');
7
+ const { compactJournal } = require('./core/compaction');
8
+ const { ensureRuntimeHome } = require('./core/runtime-home');
9
+ const semver = require('./core/semver');
10
+ const { validateAndNormalizeEvent, EventSchemaError } = require('./core/event-schema');
11
+ const {
12
+ normalizeGitUrl,
13
+ resolveRepoContext,
14
+ REPO_IDENTITY_SCHEMA,
15
+ buildRepoIdentityV1,
16
+ isValidRepoIdentityV1,
17
+ repoIdentityKey,
18
+ sameRepoIdentity,
19
+ workspaceIdFor
20
+ } = require('./core/repo-context');
21
+ const {
22
+ isSyntheticPrompt,
23
+ orderBySequence,
24
+ isDuplicateEvent,
25
+ deriveTurnState,
26
+ assertNoSyntheticEvidence
27
+ } = require('./core/turn-identity');
28
+ const { normalizeClaudeCode, normalizeCodex, normalizeOpenCode } = require('./core/normalizer');
29
+ const projectRegistry = require('./core/project-registry');
30
+ const commitProjection = require('./core/commit-projection');
31
+ const { journalFor, globalJournalDir, projectJournalDir } = require('./core/journal-router');
32
+ const { ConversationQuery } = require('./query/conversation-query');
33
+ const claudeHookEntry = require('./connectors/claude-code/hook-entry');
34
+ const claudeInstaller = require('./installers/claude-code/installer');
35
+ const codexInstaller = require('./installers/codex/installer');
36
+ const openCodeInstaller = require('./installers/opencode/installer');
37
+ const zcodeInstaller = require('./installers/zcode/installer');
38
+ const { createBridge } = require('./core/bridge');
39
+
40
+ module.exports = {
41
+ packageName: packageInfo.name,
42
+ version: packageInfo.version,
43
+ bridgeHome,
44
+ Journal,
45
+ JournalValidationError,
46
+ JournalCorruptError,
47
+ EVENT_SCHEMA,
48
+ BOUNDARY_SCHEMA,
49
+ ConsumerRegistry,
50
+ compactJournal,
51
+ ensureRuntimeHome,
52
+ semver,
53
+ validateAndNormalizeEvent,
54
+ EventSchemaError,
55
+ normalizeGitUrl,
56
+ resolveRepoContext,
57
+ REPO_IDENTITY_SCHEMA,
58
+ buildRepoIdentityV1,
59
+ isValidRepoIdentityV1,
60
+ repoIdentityKey,
61
+ sameRepoIdentity,
62
+ workspaceIdFor,
63
+ isSyntheticPrompt,
64
+ orderBySequence,
65
+ isDuplicateEvent,
66
+ deriveTurnState,
67
+ assertNoSyntheticEvidence,
68
+ normalizeClaudeCode,
69
+ normalizeCodex,
70
+ normalizeOpenCode,
71
+ projectRegistry,
72
+ commitProjection,
73
+ journalFor,
74
+ globalJournalDir,
75
+ projectJournalDir,
76
+ ConversationQuery,
77
+ claudeHookEntry,
78
+ claudeInstaller,
79
+ createBridge,
80
+ installers: {
81
+ claudeCode: claudeInstaller,
82
+ codex: codexInstaller,
83
+ openCode: openCodeInstaller,
84
+ zcode: zcodeInstaller
85
+ }
86
+ };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { ensureRuntimeHome } = require('../../core/runtime-home');
7
+ const { ConsumerRegistry } = require('../../core/consumer-registry');
8
+ const { readJsonIfExists, writeJsonAtomicSync } = require('../../core/fs-utils');
9
+
10
+ const HOOK_EVENTS = ['UserPromptSubmit', 'Stop'];
11
+
12
+ class InstallerError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'InstallerError';
16
+ }
17
+ }
18
+
19
+ function shimPathFor(homeDir) {
20
+ return path.join(homeDir, 'bin', 'bridge-hook.cjs');
21
+ }
22
+
23
+ function managedCommand(shimPath) {
24
+ return `node "${shimPath}"`;
25
+ }
26
+
27
+ function isManagedCommand(command, shimPath) {
28
+ return typeof command === 'string' && command.includes(shimPath);
29
+ }
30
+
31
+ function readSettings(settingsFile) {
32
+ const settings = readJsonIfExists(settingsFile);
33
+ if (settings === null) {
34
+ if (fs.existsSync(settingsFile)) {
35
+ throw new InstallerError(`settings file is not valid JSON: ${settingsFile}`);
36
+ }
37
+ return { hooks: {} };
38
+ }
39
+ if (typeof settings !== 'object' || settings === null) {
40
+ throw new InstallerError(`settings file must contain a JSON object: ${settingsFile}`);
41
+ }
42
+ if (!settings.hooks || typeof settings.hooks !== 'object') {
43
+ settings.hooks = {};
44
+ }
45
+ return settings;
46
+ }
47
+
48
+ function upsertManagedEntries(settings, shimPath) {
49
+ const command = managedCommand(shimPath);
50
+ const changed = [];
51
+ for (const eventName of HOOK_EVENTS) {
52
+ if (!Array.isArray(settings.hooks[eventName])) {
53
+ settings.hooks[eventName] = [];
54
+ }
55
+ const alreadyManaged = settings.hooks[eventName].some(
56
+ (group) =>
57
+ group &&
58
+ Array.isArray(group.hooks) &&
59
+ group.hooks.some((hook) => hook && hook.type === 'command' && isManagedCommand(hook.command, shimPath))
60
+ );
61
+ if (alreadyManaged) continue;
62
+ settings.hooks[eventName].push({
63
+ matcher: '*',
64
+ hooks: [{ type: 'command', command }]
65
+ });
66
+ changed.push(eventName);
67
+ }
68
+ return changed;
69
+ }
70
+
71
+ function removeManagedEntries(settings, shimPath) {
72
+ const removed = [];
73
+ for (const eventName of Object.keys(settings.hooks)) {
74
+ const groups = settings.hooks[eventName];
75
+ if (!Array.isArray(groups)) continue;
76
+ const kept = groups.filter(
77
+ (group) =>
78
+ !(
79
+ group &&
80
+ Array.isArray(group.hooks) &&
81
+ group.hooks.some((hook) => hook && hook.type === 'command' && isManagedCommand(hook.command, shimPath))
82
+ )
83
+ );
84
+ if (kept.length !== groups.length) removed.push(eventName);
85
+ if (kept.length === 0) {
86
+ delete settings.hooks[eventName];
87
+ } else {
88
+ settings.hooks[eventName] = kept;
89
+ }
90
+ }
91
+ return removed;
92
+ }
93
+
94
+ // Host-level consumer registration is owned by the host (Project-Knowledge
95
+ // registers "project-knowledge" once via createBridge()). Connector
96
+ // installers mutate the registry only on explicit opt-in:
97
+ // registerConsumer: true -> register consumerName (legacy convenience)
98
+ // unregisterConsumer: true -> unregister consumerName during uninstall
99
+ // Uninstalling a connector never deletes the shared runtime home.
100
+ async function installClaudeCodeHook({ homeDir, consumerName, consumerMeta = {}, registerConsumer = false, settingsFile, version } = {}) {
101
+ const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
102
+ const target = settingsFile || path.join(os.homedir(), '.claude', 'settings.json');
103
+ const runtime = await ensureRuntimeHome({ homeDir: home, version });
104
+ const registry = new ConsumerRegistry(home);
105
+ if (consumerName && registerConsumer) {
106
+ await registry.registerConsumer(consumerName, consumerMeta);
107
+ }
108
+ const settings = readSettings(target);
109
+ const added = upsertManagedEntries(settings, shimPathFor(home));
110
+ writeJsonAtomicSync(target, settings);
111
+ return { added, settingsFile: target, runtime, consumers: (await registry.getConsumers()).map((c) => c.name) };
112
+ }
113
+
114
+ async function statusClaudeCodeHook({ homeDir, settingsFile } = {}) {
115
+ const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
116
+ const target = settingsFile || path.join(os.homedir(), '.claude', 'settings.json');
117
+ const settings = readSettings(target);
118
+ const shimPath = shimPathFor(home);
119
+ const managedEvents = [];
120
+ let thirdPartyCount = 0;
121
+ for (const eventName of Object.keys(settings.hooks || {})) {
122
+ const groups = settings.hooks[eventName];
123
+ if (!Array.isArray(groups)) continue;
124
+ let hasManaged = false;
125
+ for (const group of groups) {
126
+ for (const hook of (group && group.hooks) || []) {
127
+ if (hook && hook.type === 'command') {
128
+ if (isManagedCommand(hook.command, shimPath)) {
129
+ hasManaged = true;
130
+ } else {
131
+ thirdPartyCount++;
132
+ }
133
+ }
134
+ }
135
+ }
136
+ if (hasManaged) managedEvents.push(eventName);
137
+ }
138
+ const registry = new ConsumerRegistry(home);
139
+ return {
140
+ installed: HOOK_EVENTS.every((eventName) => managedEvents.includes(eventName)),
141
+ managedEvents,
142
+ thirdPartyCount,
143
+ consumers: (await registry.getConsumers()).map((c) => c.name)
144
+ };
145
+ }
146
+
147
+ async function repairClaudeCodeHook({ homeDir, consumerName, consumerMeta = {}, registerConsumer = false, settingsFile, version } = {}) {
148
+ return installClaudeCodeHook({ homeDir, consumerName, consumerMeta, registerConsumer, settingsFile, version });
149
+ }
150
+
151
+ async function uninstallClaudeCodeHook({ homeDir, consumerName, unregisterConsumer = false, settingsFile } = {}) {
152
+ const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
153
+ const target = settingsFile || path.join(os.homedir(), '.claude', 'settings.json');
154
+ const registry = new ConsumerRegistry(home);
155
+ const settings = readSettings(target);
156
+ const removed = removeManagedEntries(settings, shimPathFor(home));
157
+ writeJsonAtomicSync(target, settings);
158
+ let consumerUnregistered = false;
159
+ if (consumerName && unregisterConsumer) {
160
+ await registry.unregisterConsumer(consumerName);
161
+ consumerUnregistered = true;
162
+ }
163
+ return {
164
+ removed: true,
165
+ removedEvents: removed,
166
+ settingsFile: target,
167
+ consumerUnregistered,
168
+ consumers: (await registry.getConsumers()).map((c) => c.name)
169
+ };
170
+ }
171
+
172
+ module.exports = {
173
+ HOOK_EVENTS,
174
+ InstallerError,
175
+ installClaudeCodeHook,
176
+ statusClaudeCodeHook,
177
+ repairClaudeCodeHook,
178
+ uninstallClaudeCodeHook,
179
+ managedCommand,
180
+ isManagedCommand
181
+ };
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { ensureRuntimeHome } = require('../../core/runtime-home');
7
+ const { ConsumerRegistry } = require('../../core/consumer-registry');
8
+
9
+ class InstallerError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'InstallerError';
13
+ }
14
+ }
15
+
16
+ function shimPathFor(homeDir) {
17
+ return path.join(homeDir, 'bin', 'bridge-hook.cjs');
18
+ }
19
+
20
+ function managedNotifyCommand(shimPath) {
21
+ return `["node", "${shimPath}"]`;
22
+ }
23
+
24
+ function isManagedNotifyLine(line, shimPath) {
25
+ return /^\s*notify\s*=/.test(line) && line.includes(shimPath);
26
+ }
27
+
28
+ /**
29
+ * Codex config.toml merge. The notify entry is a single TOML array; we install
30
+ * ours only when the key is absent, and refuse to displace a third-party
31
+ * notifier (report conflict instead). Uninstall removes the entry only when it
32
+ * is Bridge-managed.
33
+ */
34
+ // Registry mutations are host-owned; registerConsumer/unregisterConsumer are
35
+ // explicit opt-ins. Uninstall never deletes the shared runtime home.
36
+ async function installCodexNotify({ homeDir, consumerName, consumerMeta = {}, registerConsumer = false, configFile, version } = {}) {
37
+ const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
38
+ const target = configFile || path.join(os.homedir(), '.codex', 'config.toml');
39
+ const runtime = await ensureRuntimeHome({ homeDir: home, version });
40
+ const registry = new ConsumerRegistry(home);
41
+ if (consumerName && registerConsumer) await registry.registerConsumer(consumerName, consumerMeta);
42
+
43
+ const existing = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
44
+ const lines = existing.split(/\r?\n/);
45
+ let notifyIndex = -1;
46
+ let alreadyManaged = false;
47
+ let thirdParty = false;
48
+ for (let i = 0; i < lines.length; i++) {
49
+ if (/^\s*notify\s*=/.test(lines[i])) {
50
+ notifyIndex = i;
51
+ if (isManagedNotifyLine(lines[i], shimPathFor(home))) alreadyManaged = true;
52
+ else thirdParty = true;
53
+ break;
54
+ }
55
+ }
56
+ if (thirdParty && !alreadyManaged) {
57
+ return {
58
+ installed: false,
59
+ conflict: true,
60
+ reason: 'third-party-notify-present',
61
+ settingsFile: target,
62
+ runtime
63
+ };
64
+ }
65
+ if (!alreadyManaged) {
66
+ const entry = `notify = ${managedNotifyCommand(shimPathFor(home))}`;
67
+ if (notifyIndex === -1) {
68
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
69
+ lines.push('', entry);
70
+ } else {
71
+ lines[notifyIndex] = entry;
72
+ }
73
+ fs.mkdirSync(path.dirname(target), { recursive: true });
74
+ fs.writeFileSync(target, lines.join('\n').replace(/^\n/, '') + '\n');
75
+ }
76
+ return { installed: true, conflict: false, settingsFile: target, runtime };
77
+ }
78
+
79
+ async function statusCodexNotify({ homeDir, configFile } = {}) {
80
+ const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
81
+ const target = configFile || path.join(os.homedir(), '.codex', 'config.toml');
82
+ if (!fs.existsSync(target)) return { installed: false, thirdParty: false };
83
+ const line = fs.readFileSync(target, 'utf8').split(/\r?\n/).find(l => /^\s*notify\s*=/.test(l));
84
+ if (!line) return { installed: false, thirdParty: false };
85
+ return { installed: isManagedNotifyLine(line, shimPathFor(home)), thirdParty: !isManagedNotifyLine(line, shimPathFor(home)) };
86
+ }
87
+
88
+ async function uninstallCodexNotify({ homeDir, consumerName, unregisterConsumer = false, configFile } = {}) {
89
+ const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
90
+ const target = configFile || path.join(os.homedir(), '.codex', 'config.toml');
91
+ const registry = new ConsumerRegistry(home);
92
+ if (fs.existsSync(target)) {
93
+ const lines = fs.readFileSync(target, 'utf8').split(/\r?\n/);
94
+ const kept = lines.filter(line => !(isManagedNotifyLine(line, shimPathFor(home))));
95
+ if (kept.length !== lines.length) {
96
+ fs.writeFileSync(target, kept.join('\n').replace(/\n{3,}/g, '\n\n'));
97
+ }
98
+ }
99
+ let consumerUnregistered = false;
100
+ if (consumerName && unregisterConsumer) {
101
+ await registry.unregisterConsumer(consumerName);
102
+ consumerUnregistered = true;
103
+ }
104
+ return { removed: true, consumerUnregistered, consumers: (await registry.getConsumers()).map(c => c.name) };
105
+ }
106
+
107
+ module.exports = {
108
+ InstallerError,
109
+ installCodexNotify,
110
+ statusCodexNotify,
111
+ uninstallCodexNotify,
112
+ managedNotifyCommand,
113
+ isManagedNotifyLine
114
+ };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { ensureRuntimeHome } = require('../../core/runtime-home');
7
+ const { ConsumerRegistry } = require('../../core/consumer-registry');
8
+
9
+ const PLUGIN_FILE_NAME = 'ai-coding-event-bridge.js';
10
+
11
+ function shimPathFor(homeDir) {
12
+ return path.join(homeDir, 'bin', 'bridge-hook.cjs');
13
+ }
14
+
15
+ function pluginSource(shimPath) {
16
+ return `// Installed by @sanqianx/ai-coding-event-bridge. Captures user/assistant
17
+ // lifecycle events into the local durable Bridge journal via the stable shim.
18
+ // No HTTP endpoint is required; removing the file removes the integration.
19
+ const { spawn } = require('child_process');
20
+ module.exports = {
21
+ id: 'ai-coding-event-bridge',
22
+ init() {
23
+ return {
24
+ event(event) {
25
+ if (!event || (event.type !== 'user' && event.type !== 'assistant')) return;
26
+ const payload = {
27
+ type: event.type,
28
+ sessionId: event.sessionID || event.sessionId || null,
29
+ turnId: event.turnID || event.turnId || null,
30
+ cwd: event.cwd || process.cwd(),
31
+ text: typeof event.text === 'string' ? event.text : null
32
+ };
33
+ const child = spawn('node', [${JSON.stringify(shimPath)}], { stdio: ['pipe', 'ignore', 'ignore'] });
34
+ child.on('error', () => {});
35
+ child.stdin.write(JSON.stringify(payload));
36
+ child.stdin.end();
37
+ }
38
+ };
39
+ }
40
+ };
41
+ `;
42
+ }
43
+
44
+ // Registry mutations are host-owned; registerConsumer/unregisterConsumer are
45
+ // explicit opt-ins. Uninstall removes only the managed plugin file and never
46
+ // touches third-party plugins or the shared runtime home.
47
+ async function installOpenCodePlugin({ homeDir, consumerName, consumerMeta = {}, registerConsumer = false, pluginsDir, version } = {}) {
48
+ const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
49
+ const target = pluginsDir || path.join(os.homedir(), '.config', 'opencode', 'plugin');
50
+ const runtime = await ensureRuntimeHome({ homeDir: home, version });
51
+ const registry = new ConsumerRegistry(home);
52
+ if (consumerName && registerConsumer) await registry.registerConsumer(consumerName, consumerMeta);
53
+ fs.mkdirSync(target, { recursive: true });
54
+ const file = path.join(target, PLUGIN_FILE_NAME);
55
+ fs.writeFileSync(file, pluginSource(shimPathFor(home)));
56
+ return { installed: true, pluginFile: file, runtime };
57
+ }
58
+
59
+ async function statusOpenCodePlugin({ homeDir, pluginsDir } = {}) {
60
+ const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
61
+ const target = pluginsDir || path.join(os.homedir(), '.config', 'opencode', 'plugin');
62
+ const file = path.join(target, PLUGIN_FILE_NAME);
63
+ const exists = fs.existsSync(file);
64
+ let managed = false;
65
+ let thirdPartyFiles = 0;
66
+ if (fs.existsSync(target)) {
67
+ for (const entry of fs.readdirSync(target)) {
68
+ if (entry === PLUGIN_FILE_NAME) continue;
69
+ if (entry.endsWith('.js') || entry.endsWith('.ts')) thirdPartyFiles++;
70
+ }
71
+ }
72
+ if (exists) {
73
+ managed = fs.readFileSync(file, 'utf8').includes('ai-coding-event-bridge');
74
+ }
75
+ return { installed: managed && exists, pluginFile: file, thirdPartyFiles };
76
+ }
77
+
78
+ async function uninstallOpenCodePlugin({ homeDir, consumerName, unregisterConsumer = false, pluginsDir } = {}) {
79
+ const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
80
+ const target = pluginsDir || path.join(os.homedir(), '.config', 'opencode', 'plugin');
81
+ const registry = new ConsumerRegistry(home);
82
+ const file = path.join(target, PLUGIN_FILE_NAME);
83
+ try {
84
+ fs.rmSync(file, { force: true });
85
+ } catch (_) {
86
+ // Already gone.
87
+ }
88
+ let consumerUnregistered = false;
89
+ if (consumerName && unregisterConsumer) {
90
+ await registry.unregisterConsumer(consumerName);
91
+ consumerUnregistered = true;
92
+ }
93
+ return { removed: true, consumerUnregistered, consumers: (await registry.getConsumers()).map(c => c.name) };
94
+ }
95
+
96
+ module.exports = { installOpenCodePlugin, statusOpenCodePlugin, uninstallOpenCodePlugin, PLUGIN_FILE_NAME };