@sanqianx/ai-coding-event-bridge-console 0.5.1 → 0.7.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/README.md +15 -4
- package/console/index.html +37 -11
- package/package.json +2 -5
- package/src/agent-detect.js +1 -1
- package/src/bin.js +28 -5
- 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 +246 -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 +111 -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 +96 -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 +54 -5
package/README.md
CHANGED
|
@@ -48,6 +48,15 @@ otherwise.
|
|
|
48
48
|
| `GET /api/turns?project&date&cursor&limit` | Merged turns + `annotations` (commit shas per turn) |
|
|
49
49
|
| `GET /api/sessions?project=` | Session index |
|
|
50
50
|
| `GET /api/search?q&project&limit` | Substring search over conversation bodies |
|
|
51
|
+
| `GET /api/journals` | Global and registered project journal identities |
|
|
52
|
+
| `POST /api/internal/workbench-event` | Private native Workbench capture; runtime token, loopback, no browser Origin |
|
|
53
|
+
|
|
54
|
+
The private capture route uses `KB_EVENT_BRIDGE_RUNTIME_TOKEN`. It resolves the
|
|
55
|
+
Git identity and requires an already registered code directory. Stable event
|
|
56
|
+
keys deduplicate retries; only original prompts, successful final answers and
|
|
57
|
+
turn endings enter the same bridge journal used by hooks. Credentials and
|
|
58
|
+
injected knowledge are not accepted as event metadata. Shell proxies must not
|
|
59
|
+
expose this route to browser callers.
|
|
51
60
|
|
|
52
61
|
Turn merging: for a registered project, the project journal and the global
|
|
53
62
|
journal (legacy pre-import events) are both read, filtered by repo identity,
|
|
@@ -57,7 +66,9 @@ one journal's sequence space; `openTurnIdsAtCommit` binding works across both.
|
|
|
57
66
|
## Notes
|
|
58
67
|
|
|
59
68
|
- Node >= 18, CommonJS, zero runtime dependencies beyond the core package.
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
- Public query APIs read journals; project import/unregister changes registration.
|
|
70
|
+
The authenticated Workbench endpoint delegates capture to the core bridge,
|
|
71
|
+
not a second conversation database.
|
|
72
|
+
- Consumer cursors and ACKs are journal-specific. Compaction may retire sealed
|
|
73
|
+
evidence only after every applicable consumer has acknowledged that journal;
|
|
74
|
+
creating a commit archive is not proof that the engine consumed it.
|
package/console/index.html
CHANGED
|
@@ -185,6 +185,10 @@
|
|
|
185
185
|
.btn-upg { height: 26px; padding: 0 12px; border-radius: 7px; background: var(--text); color: #16171a; font-size: 12px; font-weight: 600; flex-shrink: 0; }
|
|
186
186
|
.btn-upg:hover { background: #fff; }
|
|
187
187
|
.btn-upg:disabled { opacity: .45; cursor: not-allowed; }
|
|
188
|
+
body.kb-embedded[data-kb-theme="light"] { --bg-side:#f8f9fb; --bg-main:#fff; --bg-card:#f8f9fb; --bg-input:#edf1f4; --bg-hover:#edf1f4; --bg-active:#e6edf3; --line:#dce2e8; --line-soft:#dce2e8; --text:#202733; --text-2:#687587; --text-3:#8e99a7; --label:#687587; --blue:#516f89; --green:#397761; --red:#a05252; color-scheme:light; }
|
|
189
|
+
body.kb-embedded[data-kb-theme="dark"] { --bg-side:#1b2028; --bg-main:#20252d; --bg-card:#252b34; --bg-input:#2c333e; --bg-hover:#2c333e; --bg-active:#293748; --line:#39424f; --line-soft:#39424f; --text:#e6ebf0; --text-2:#a0aaba; --text-3:#788393; --label:#a0aaba; --blue:#8aa8cf; --green:#81b29a; --red:#d88b8b; color-scheme:dark; }
|
|
190
|
+
body.kb-embedded .topbar h1 { display:none; }
|
|
191
|
+
body.kb-embedded .topbar { padding:8px 24px; }
|
|
188
192
|
</style>
|
|
189
193
|
</head>
|
|
190
194
|
<body>
|
|
@@ -226,7 +230,7 @@
|
|
|
226
230
|
<span class="svg-i" data-i="gear"></span>
|
|
227
231
|
</button>
|
|
228
232
|
<div class="hist-wrap">
|
|
229
|
-
<button class="hist-btn" id="hist-btn"
|
|
233
|
+
<button class="hist-btn" id="hist-btn">历史</button>
|
|
230
234
|
<div class="hist-pop" id="hist-pop">
|
|
231
235
|
<div class="ph">历史对话</div>
|
|
232
236
|
<div id="hist-list"></div>
|
|
@@ -333,8 +337,14 @@ function toast(ok, text) {
|
|
|
333
337
|
}, 3200);
|
|
334
338
|
}
|
|
335
339
|
|
|
340
|
+
var embedQuery = new URLSearchParams(location.search);
|
|
341
|
+
var embedded = embedQuery.get('embed') === '1';
|
|
342
|
+
var moduleApiBase = /^\/api\/[a-z0-9/-]+$/i.test(embedQuery.get('apiBase') || '') ? embedQuery.get('apiBase').replace(/\/$/, '') : '/api';
|
|
343
|
+
var projectEpoch = 0;
|
|
344
|
+
var selectionEpoch = 0;
|
|
345
|
+
var embedVisible = true;
|
|
336
346
|
function api(path, options) {
|
|
337
|
-
return fetch(path, options).then(function (res) {
|
|
347
|
+
return fetch(path.replace(/^\/api(?=\/|$)/, moduleApiBase), options).then(function (res) {
|
|
338
348
|
return res.text().then(function (body) {
|
|
339
349
|
var payload = null;
|
|
340
350
|
try { payload = body ? JSON.parse(body) : null; } catch (_) { payload = null; }
|
|
@@ -376,7 +386,7 @@ function timeOf(iso) {
|
|
|
376
386
|
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
377
387
|
}
|
|
378
388
|
|
|
379
|
-
var SOURCE_LABELS = { 'claude-code': 'Claude', claude: 'Claude', codex: 'Codex', opencode: 'OpenCode' };
|
|
389
|
+
var SOURCE_LABELS = { 'claude-code': 'Claude', claude: 'Claude', codex: 'Codex', opencode: 'OpenCode', zcode: 'ZCode' };
|
|
380
390
|
|
|
381
391
|
/* ---------- 状态 ---------- */
|
|
382
392
|
|
|
@@ -490,6 +500,7 @@ function loadMoreTurns() {
|
|
|
490
500
|
|
|
491
501
|
function fetchTurns(options) {
|
|
492
502
|
if (!state.project) return Promise.resolve();
|
|
503
|
+
var epoch = projectEpoch;
|
|
493
504
|
var append = options && options.append;
|
|
494
505
|
state.loadingTurns = true;
|
|
495
506
|
if (!append) { state.turns = []; state.cursor = null; }
|
|
@@ -498,13 +509,14 @@ function fetchTurns(options) {
|
|
|
498
509
|
+ '&order=desc'
|
|
499
510
|
+ (state.cursor ? '&cursor=' + encodeURIComponent(state.cursor) : '');
|
|
500
511
|
return api(query).then(function (payload) {
|
|
501
|
-
if (!state.project || payload.project !== state.project.id) return;
|
|
512
|
+
if (epoch !== projectEpoch || !state.project || payload.project !== state.project.id) return;
|
|
502
513
|
state.turns = state.turns.concat(payload.turns || []);
|
|
503
514
|
state.cursor = payload.nextCursor || null;
|
|
504
515
|
state.totalTurns = payload.totalTurns || state.turns.length;
|
|
505
516
|
state.loadingTurns = false;
|
|
506
517
|
renderLive();
|
|
507
518
|
}).catch(function (err) {
|
|
519
|
+
if (epoch !== projectEpoch) return;
|
|
508
520
|
state.loadingTurns = false;
|
|
509
521
|
renderLive();
|
|
510
522
|
toast(false, '加载对话失败:' + err.message);
|
|
@@ -514,6 +526,7 @@ function fetchTurns(options) {
|
|
|
514
526
|
function selectProject(id, options) {
|
|
515
527
|
var project = state.projects.find(function (p) { return p.id === id; });
|
|
516
528
|
if (!project) return;
|
|
529
|
+
projectEpoch++;
|
|
517
530
|
state.project = project;
|
|
518
531
|
state.turns = [];
|
|
519
532
|
state.cursor = null;
|
|
@@ -688,13 +701,14 @@ document.getElementById('hist-list').addEventListener('click', function (ev) {
|
|
|
688
701
|
document.getElementById('back-btn').classList.add('show');
|
|
689
702
|
showDocLoading(true);
|
|
690
703
|
// 懒加载:此刻才请求这一份文档,其余历史永不加载
|
|
691
|
-
|
|
704
|
+
var epoch = projectEpoch;
|
|
705
|
+
fetch(moduleApiBase + '/commits/' + encodeURIComponent(file) + '?project=' + encodeURIComponent(state.project.id))
|
|
692
706
|
.then(function (res) {
|
|
693
707
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
694
708
|
return res.text();
|
|
695
709
|
})
|
|
696
710
|
.then(function (text) {
|
|
697
|
-
if (state.currentDocFile !== file) return;
|
|
711
|
+
if (epoch !== projectEpoch || state.currentDocFile !== file) return;
|
|
698
712
|
var parsed = parseSealedDoc(text);
|
|
699
713
|
var title = doc.subject || parsed.fm.subject || (parsed.fm.commitSha || file).slice(0, 12);
|
|
700
714
|
var subParts = [];
|
|
@@ -901,20 +915,32 @@ setInterval(loadHealth, 30000);
|
|
|
901
915
|
* URL 参数:?embed=1 进入嵌入态(默认折叠,sidebar=1 保持展开)。
|
|
902
916
|
* 就绪后回发 {type:'kb:ready', app:'event-bridge'};独立打开时不受控、侧栏恒显。 */
|
|
903
917
|
function embedAllowedOrigin(origin) {
|
|
904
|
-
|
|
905
|
-
try { const u = new URL(origin); return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]'; }
|
|
918
|
+
try { return origin === (document.referrer ? new URL(document.referrer).origin : location.origin); }
|
|
906
919
|
catch { return false; }
|
|
907
920
|
}
|
|
908
921
|
function setEmbedSideVisible(visible) { document.body.classList.toggle('embed-collapse', !visible); }
|
|
909
|
-
window.addEventListener('message', function (ev) {
|
|
910
|
-
if (!embedAllowedOrigin(ev.origin)) return;
|
|
922
|
+
window.addEventListener('message', async function (ev) {
|
|
923
|
+
if (ev.source !== window.parent || !embedAllowedOrigin(ev.origin)) return;
|
|
911
924
|
const d = ev.data;
|
|
912
925
|
if (!d || typeof d !== 'object') return;
|
|
926
|
+
if (d.type === 'kb:theme') { document.body.dataset.kbTheme = d.theme === 'dark' ? 'dark' : 'light'; return; }
|
|
927
|
+
if (d.type === 'kb:visibility') { embedVisible = d.visible === true; return; }
|
|
928
|
+
if (d.type === 'kb:refresh' && embedVisible && state.project) { fetchTurns(); refreshHist(); return; }
|
|
929
|
+
if (d.type === 'kb:select-project') {
|
|
930
|
+
var requestEpoch = ++selectionEpoch;
|
|
931
|
+
var find = function () { return state.projects.find(function (p) { return p.id === d.project || p.path === d.project; }); };
|
|
932
|
+
if (!find()) await loadProjects();
|
|
933
|
+
if (requestEpoch !== selectionEpoch) return;
|
|
934
|
+
var p = find();
|
|
935
|
+
if (p) selectProject(p.id);
|
|
936
|
+
ev.source.postMessage({ type: 'kb:projects', app: 'event-bridge', ok: !!p, projectId: p && p.id, requestId: d.requestId }, ev.origin);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
913
939
|
if (d.type === 'kb:sidebar') setEmbedSideVisible(!!d.visible);
|
|
914
940
|
});
|
|
915
941
|
(function () {
|
|
916
942
|
const q = new URLSearchParams(location.search);
|
|
917
|
-
if (q.get('embed') === '1') setEmbedSideVisible(q.get('sidebar')
|
|
943
|
+
if (q.get('embed') === '1') { document.body.classList.add('kb-embedded'); document.body.dataset.kbTheme = q.get('theme') === 'dark' ? 'dark' : 'light'; setEmbedSideVisible(q.get('sidebar') === '1'); }
|
|
918
944
|
if (window.parent !== window) window.parent.postMessage({ type: 'kb:ready', app: 'event-bridge' }, '*');
|
|
919
945
|
})();
|
|
920
946
|
</script>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanqianx/ai-coding-event-bridge-console",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Durable AI coding event bridge, all in one: capture connectors and installers (Claude Code, Codex, OpenCode, ZCode), journal with commit boundaries and per-commit conversation sealing, headless query, and a local conversation explorer console.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
7
7
|
"repository": {
|
|
@@ -22,8 +22,5 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "node ../../test/run-all.js"
|
|
25
|
-
},
|
|
26
|
-
"dependencies": {
|
|
27
|
-
"@sanqianx/ai-coding-event-bridge": "0.5.0"
|
|
28
25
|
}
|
|
29
26
|
}
|
package/src/agent-detect.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { execFileSync } = require('child_process');
|
|
7
|
-
const core = require('
|
|
7
|
+
const core = require('./bridge');
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Detection of AI coding agent tools on this machine. "Installed" means the
|
package/src/bin.js
CHANGED
|
@@ -2,17 +2,21 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const http = require('http');
|
|
5
|
-
const { bridgeHome } = require('
|
|
5
|
+
const { bridgeHome } = require('./bridge');
|
|
6
6
|
const { createConsoleServer } = require('./server');
|
|
7
7
|
const { warmFolderPicker } = require('./pick-folder');
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* ai-coding-event-bridge-console CLI
|
|
11
|
-
* `ai-coding-event-bridge serve` via the core package's dispatcher).
|
|
10
|
+
* ai-coding-event-bridge-console CLI — the single package of this project.
|
|
12
11
|
*
|
|
13
12
|
* serve [--port 8790] [--host 127.0.0.1] [--home DIR]
|
|
14
13
|
* Start the local console (explorer page + JSON API).
|
|
15
14
|
*
|
|
15
|
+
* commit-boundary [--cwd <repo>] [--home DIR] [--sha] [--branch]
|
|
16
|
+
* [--committed-at ISO] [--subject TEXT]
|
|
17
|
+
* Append one commit boundary event — the call a managed git
|
|
18
|
+
* post-commit hook makes.
|
|
19
|
+
*
|
|
16
20
|
* The server binds to localhost by default: journals contain full
|
|
17
21
|
* conversation bodies, so they stay on this machine unless --host says
|
|
18
22
|
* otherwise.
|
|
@@ -24,8 +28,23 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
24
28
|
return i >= 0 ? argv[i + 1] : undefined;
|
|
25
29
|
};
|
|
26
30
|
|
|
31
|
+
if (command === 'commit-boundary') {
|
|
32
|
+
// The boundary entry reads its own flags from process.argv; the extra
|
|
33
|
+
// positional is ignored by its --flag lookup. The contract is one JSON
|
|
34
|
+
// line (ok:true/false) on stdout, preserved even when required from here.
|
|
35
|
+
await require('./bridge/bin/commit-boundary').main().catch((err) => {
|
|
36
|
+
process.stderr.write(`${JSON.stringify({ ok: false, error: err && err.message ? err.message : String(err) })}\n`);
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
if (command !== 'serve') {
|
|
28
|
-
console.log('Usage: ai-coding-event-bridge-console
|
|
43
|
+
console.log('Usage: ai-coding-event-bridge-console <command> [options]');
|
|
44
|
+
console.log(' serve Start the local console (explorer page + JSON API)');
|
|
45
|
+
console.log(' --port 8790 --host 127.0.0.1 --home DIR');
|
|
46
|
+
console.log(' commit-boundary Append one commit boundary event (git post-commit hooks)');
|
|
47
|
+
console.log(' --cwd <repo> --home DIR --sha --branch');
|
|
29
48
|
process.exitCode = command ? 1 : 0;
|
|
30
49
|
return;
|
|
31
50
|
}
|
|
@@ -33,8 +52,12 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
33
52
|
const home = bridgeHome(get('home'));
|
|
34
53
|
const port = Number(get('port') || 8790);
|
|
35
54
|
const host = get('host') || '127.0.0.1';
|
|
36
|
-
const server = http.createServer(createConsoleServer({ home }));
|
|
55
|
+
const server = http.createServer(createConsoleServer({ home, runtimeToken: process.env.KB_EVENT_BRIDGE_RUNTIME_TOKEN }));
|
|
37
56
|
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
57
|
+
let stopping = false;
|
|
58
|
+
const stop = () => { if (stopping) return; stopping = true; server.closeAllConnections?.(); server.close(() => process.exit(0)); };
|
|
59
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, stop);
|
|
60
|
+
process.on('message', message => { if (message?.type === 'kb:shutdown') stop(); });
|
|
38
61
|
console.log(`ai-coding-event-bridge console serving on http://${host}:${port}`);
|
|
39
62
|
console.log(` home: ${home}`);
|
|
40
63
|
console.log(` import projects from the sidebar; conversations of imported`);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridge commit-boundary injection point. Any git hook (or a human, or a
|
|
6
|
+
* test) can signal "this repo just committed" by running:
|
|
7
|
+
*
|
|
8
|
+
* node commit-boundary.js --cwd <repo> [--home <bridge home>]
|
|
9
|
+
* [--sha <sha>] [--branch <name>] [--committed-at <iso>]
|
|
10
|
+
*
|
|
11
|
+
* The repo identity is resolved from the working tree (that is how the bridge
|
|
12
|
+
* knows which registered project — and therefore which store — the commit
|
|
13
|
+
* belongs to). Facts not provided are read from the repo's git. Output is a
|
|
14
|
+
* single JSON line on stdout; errors exit non-zero so callers can distinguish
|
|
15
|
+
* a failed injection from a skip.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { execFileSync } = require('child_process');
|
|
19
|
+
const { resolveRepoContext } = require('../core/repo-context');
|
|
20
|
+
const { createBridge } = require('../core/bridge');
|
|
21
|
+
|
|
22
|
+
function arg(name, fallback = '') {
|
|
23
|
+
const prefix = `--${name}`;
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
const index = argv.indexOf(prefix);
|
|
26
|
+
if (index === -1) return fallback;
|
|
27
|
+
const value = argv[index + 1];
|
|
28
|
+
return value === undefined || value.startsWith('--') ? fallback : value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function git(cwd, args) {
|
|
32
|
+
return String(execFileSync('git', args, { cwd, windowsHide: true, encoding: 'utf8' })).trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main() {
|
|
36
|
+
const cwd = arg('cwd', process.cwd());
|
|
37
|
+
const repo = await resolveRepoContext(cwd);
|
|
38
|
+
if (!repo || !repo.repoIdentity) {
|
|
39
|
+
throw new Error(`not a Git work tree (no repo identity): ${cwd}`);
|
|
40
|
+
}
|
|
41
|
+
const commitSha = arg('sha') || git(cwd, ['rev-parse', 'HEAD']);
|
|
42
|
+
const branch = arg('branch') || git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
43
|
+
const committedAt = arg('committed-at') || git(cwd, ['log', '-1', '--format=%cI']);
|
|
44
|
+
const subject = arg('subject') || git(cwd, ['log', '-1', '--format=%s']);
|
|
45
|
+
const homeDir = arg('home') || undefined;
|
|
46
|
+
const bridge = createBridge(homeDir ? { homeDir } : {});
|
|
47
|
+
const result = await bridge.appendCommitBoundary({
|
|
48
|
+
repoIdentity: repo.repoIdentity,
|
|
49
|
+
commitSha,
|
|
50
|
+
branch,
|
|
51
|
+
subject,
|
|
52
|
+
committedAt: committedAt || undefined
|
|
53
|
+
});
|
|
54
|
+
process.stdout.write(`${JSON.stringify({ ok: true, projectPath: repo.projectPath, ...result })}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { main };
|
|
58
|
+
|
|
59
|
+
if (require.main === module) {
|
|
60
|
+
main().catch((err) => {
|
|
61
|
+
process.stderr.write(`${JSON.stringify({ ok: false, error: err && err.message ? err.message : String(err) })}\n`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Capture-disable guard. Project-Knowledge (or any host) marks its internal
|
|
5
|
+
* AI SDK sessions with AI_CODING_EVENT_BRIDGE_CAPTURE=0 and
|
|
6
|
+
* AI_CODING_EVENT_ORIGIN. Every connector checks this FIRST — before
|
|
7
|
+
* normalization, repo Git resolution, or any journal write — so internal
|
|
8
|
+
* Workbench/Analyzer conversations never reach the durable journal.
|
|
9
|
+
*/
|
|
10
|
+
function isCaptureDisabled(env = process.env, payload = null) {
|
|
11
|
+
if (env && env.AI_CODING_EVENT_BRIDGE_CAPTURE === '0') {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
if (payload && typeof payload === 'object' && payload.meta && typeof payload.meta === 'object') {
|
|
15
|
+
return String(payload.meta.bridgeCapture) === '0';
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function captureDisabledResult() {
|
|
21
|
+
return { status: 'ignored', reason: 'capture-disabled' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { isCaptureDisabled, captureDisabledResult };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normalizeClaudeCode } = require('../../core/normalizer');
|
|
4
|
+
const { resolveRepoContext } = require('../../core/repo-context');
|
|
5
|
+
const { journalFor } = require('../../core/journal-router');
|
|
6
|
+
const { isCaptureDisabled, captureDisabledResult } = require('../capture-guard');
|
|
7
|
+
const { notifyConsumers } = require('./notify');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Durable-first Claude Code hook processing. The event is appended and fsynced
|
|
11
|
+
* to the Bridge journal before any consumer notification is attempted, and the
|
|
12
|
+
* whole entry fails open: a broken Bridge must never block the AI client.
|
|
13
|
+
* Internal host SDK sessions (AI_CODING_EVENT_BRIDGE_CAPTURE=0) are ignored
|
|
14
|
+
* before any Git/journal work happens.
|
|
15
|
+
*/
|
|
16
|
+
async function main({ home, payload }) {
|
|
17
|
+
if (isCaptureDisabled(process.env, payload)) {
|
|
18
|
+
return captureDisabledResult();
|
|
19
|
+
}
|
|
20
|
+
if (!payload || typeof payload !== 'object') {
|
|
21
|
+
return { status: 'ignored' };
|
|
22
|
+
}
|
|
23
|
+
const event = normalizeClaudeCode(payload);
|
|
24
|
+
if (!event) {
|
|
25
|
+
return { status: 'ignored' };
|
|
26
|
+
}
|
|
27
|
+
const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
28
|
+
const repo = await resolveRepoContext(cwd);
|
|
29
|
+
const journal = journalFor(home, event.repoIdentity || repo.repoIdentity);
|
|
30
|
+
const appended = await journal.appendConversationEvent({
|
|
31
|
+
...event,
|
|
32
|
+
repoIdentity: event.repoIdentity || repo.repoIdentity,
|
|
33
|
+
projectPath: event.projectPath || repo.projectPath,
|
|
34
|
+
branch: event.branch || repo.branch,
|
|
35
|
+
headAtCapture: event.headAtCapture || repo.headAtCapture
|
|
36
|
+
});
|
|
37
|
+
await notifyConsumers(home, { sequence: appended.sequence, eventId: appended.eventId });
|
|
38
|
+
return { status: 'captured', sequence: appended.sequence, turnId: appended.turnId };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mainFailOpen({ home, payload }) {
|
|
42
|
+
return main({ home, payload }).catch(() => ({ status: 'fail-open' }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { main, mainFailOpen };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const { ConsumerRegistry } = require('../../core/consumer-registry');
|
|
6
|
+
|
|
7
|
+
function postJson(url, body, timeoutMs = 1500) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
try {
|
|
10
|
+
const target = new URL(url);
|
|
11
|
+
const mod = target.protocol === 'https:' ? https : http;
|
|
12
|
+
const data = JSON.stringify(body);
|
|
13
|
+
const req = mod.request(
|
|
14
|
+
{
|
|
15
|
+
hostname: target.hostname,
|
|
16
|
+
port: target.port || (target.protocol === 'https:' ? 443 : 80),
|
|
17
|
+
path: `${target.pathname}${target.search}`,
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) },
|
|
20
|
+
timeout: timeoutMs
|
|
21
|
+
},
|
|
22
|
+
(res) => {
|
|
23
|
+
res.resume();
|
|
24
|
+
res.on('end', () => resolve({ ok: res.statusCode && res.statusCode < 500 }));
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
req.on('timeout', () => {
|
|
28
|
+
req.destroy();
|
|
29
|
+
resolve({ ok: false });
|
|
30
|
+
});
|
|
31
|
+
req.on('error', () => resolve({ ok: false }));
|
|
32
|
+
req.end(data);
|
|
33
|
+
} catch (_) {
|
|
34
|
+
resolve({ ok: false });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Best-effort wake-up for consumers that registered a notifyUrl. Always runs
|
|
41
|
+
* after the durable append; failures never propagate to the AI client.
|
|
42
|
+
*/
|
|
43
|
+
async function notifyConsumers(home, info) {
|
|
44
|
+
try {
|
|
45
|
+
const registry = new ConsumerRegistry(home);
|
|
46
|
+
const consumers = await registry.getConsumers();
|
|
47
|
+
const urls = consumers
|
|
48
|
+
.map((consumer) => consumer.meta && consumer.meta.notifyUrl)
|
|
49
|
+
.filter((url) => typeof url === 'string' && url);
|
|
50
|
+
await Promise.all(urls.map((url) => postJson(url, info)));
|
|
51
|
+
return { notified: urls.length };
|
|
52
|
+
} catch (_) {
|
|
53
|
+
return { notified: 0 };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { notifyConsumers, postJson };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { writeJsonAtomicSync, readJsonIfExists, ensureDirSync } = require('../../core/fs-utils');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Atomic per-session cursor state. One JSON file per Codex session under
|
|
9
|
+
* <home>/cursors/codex/, written via rename so a crash never corrupts it.
|
|
10
|
+
* Cursor v2 stores an independent offset and workspace context for every
|
|
11
|
+
* rollout file. A v1 cursor is upgraded in memory without resetting its
|
|
12
|
+
* consumed offset, so discovering a continuation file never replays history.
|
|
13
|
+
*/
|
|
14
|
+
const CURSOR_SCHEMA = 'codex-cursor/v2';
|
|
15
|
+
|
|
16
|
+
function fileState(state = {}) {
|
|
17
|
+
return {
|
|
18
|
+
filePath: state.filePath,
|
|
19
|
+
byteOffset: Number.isInteger(state.byteOffset) && state.byteOffset >= 0 ? state.byteOffset : 0,
|
|
20
|
+
lastRecordKey: state.lastRecordKey || null,
|
|
21
|
+
activeCwd: state.activeCwd || null,
|
|
22
|
+
repoIdentity: state.repoIdentity || null,
|
|
23
|
+
projectPath: state.projectPath || null,
|
|
24
|
+
branch: state.branch || null,
|
|
25
|
+
headAtCapture: state.headAtCapture || null
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeCursor(sessionId, state) {
|
|
30
|
+
const normalized = { schema: CURSOR_SCHEMA, sessionId: String(sessionId), files: {} };
|
|
31
|
+
if (!state || typeof state !== 'object') return normalized;
|
|
32
|
+
|
|
33
|
+
if (state.schema === CURSOR_SCHEMA && state.files && typeof state.files === 'object') {
|
|
34
|
+
for (const [key, value] of Object.entries(state.files)) {
|
|
35
|
+
if (!value || typeof value !== 'object' || typeof value.filePath !== 'string') continue;
|
|
36
|
+
normalized.files[key] = fileState(value);
|
|
37
|
+
}
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// v1 compatibility: preserve the exact consumed offset and context. The
|
|
42
|
+
// caller supplies the canonical key after resolving the legacy file path.
|
|
43
|
+
if (typeof state.filePath === 'string' && state.filePath) {
|
|
44
|
+
normalized.legacyFile = fileState(state);
|
|
45
|
+
}
|
|
46
|
+
return normalized;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class CodexCursorStore {
|
|
50
|
+
constructor(homeDir) {
|
|
51
|
+
this.dir = path.join(homeDir, 'cursors', 'codex');
|
|
52
|
+
ensureDirSync(this.dir);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_fileFor(sessionId) {
|
|
56
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
57
|
+
return path.join(this.dir, `${safe}.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
read(sessionId) {
|
|
61
|
+
return normalizeCursor(sessionId, readJsonIfExists(this._fileFor(sessionId)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
write(sessionId, state) {
|
|
65
|
+
const normalized = normalizeCursor(sessionId, state);
|
|
66
|
+
writeJsonAtomicSync(this._fileFor(sessionId), {
|
|
67
|
+
schema: CURSOR_SCHEMA,
|
|
68
|
+
sessionId: normalized.sessionId,
|
|
69
|
+
files: normalized.files,
|
|
70
|
+
updatedAt: new Date().toISOString()
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
clear(sessionId) {
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(this._fileFor(sessionId), { force: true });
|
|
77
|
+
} catch (_) {
|
|
78
|
+
// Already gone.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { CodexCursorStore, CURSOR_SCHEMA, normalizeCursor };
|