@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
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.6.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
|
}
|
|
@@ -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 };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { validateAndNormalizeEvent } = require('../../core/event-schema');
|
|
6
|
+
const { resolveRepoContext } = require('../../core/repo-context');
|
|
7
|
+
const { journalFor } = require('../../core/journal-router');
|
|
8
|
+
const { CodexCursorStore } = require('./cursor-store');
|
|
9
|
+
const { parseSessionChunk, extractMessage, extractWorkspaceMeta } = require('./session-parser');
|
|
10
|
+
const { isCaptureDisabled, captureDisabledResult } = require('../capture-guard');
|
|
11
|
+
const { notifyConsumers } = require('../claude-code/notify');
|
|
12
|
+
const { CrossProcessLock } = require('../../core/lock');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Deterministic Codex capture.
|
|
16
|
+
*
|
|
17
|
+
* The notify payload is only a wake-up signal; it never carries the workspace.
|
|
18
|
+
* The real workspace comes from the session's authoritative rollout metadata
|
|
19
|
+
* (session_meta.payload.cwd / turn_context.payload.cwd) — NEVER from the
|
|
20
|
+
* session file's location, which is Codex runtime storage. Bytes are consumed
|
|
21
|
+
* incrementally per session; a partial trailing line stays unread until the
|
|
22
|
+
* next wake-up. When no authoritative cwd can be parsed, a
|
|
23
|
+
* codex-workspace-unresolved gap is recorded and the event is never bound to
|
|
24
|
+
* any project.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function recordGap(home, reason, meta = {}) {
|
|
28
|
+
try {
|
|
29
|
+
const gapsFile = path.join(home, 'journal', 'capture-gaps.jsonl');
|
|
30
|
+
fs.mkdirSync(path.dirname(gapsFile), { recursive: true });
|
|
31
|
+
fs.appendFileSync(
|
|
32
|
+
gapsFile,
|
|
33
|
+
`${JSON.stringify({ schema: 'bridge-capture-gap/v1', source: 'codex', reason, ...meta, at: new Date().toISOString() })}\n`
|
|
34
|
+
);
|
|
35
|
+
} catch (_) {
|
|
36
|
+
// Gap persistence is best effort; the decision to not guess stands regardless.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizedPath(filePath) {
|
|
41
|
+
const resolved = path.resolve(filePath);
|
|
42
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fileKey(sessionsRoot, filePath) {
|
|
46
|
+
return path.relative(path.resolve(sessionsRoot), path.resolve(filePath)).split(path.sep).join('/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function findSessionFiles(sessionsRoot, sessionId) {
|
|
50
|
+
// Deterministic discovery: Codex rollout files embed the session id in the
|
|
51
|
+
// file name; we search by exact id, never by mtime.
|
|
52
|
+
const stack = [sessionsRoot];
|
|
53
|
+
const matches = [];
|
|
54
|
+
while (stack.length) {
|
|
55
|
+
const dir = stack.pop();
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
59
|
+
} catch (_) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const full = path.join(dir, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
stack.push(full);
|
|
66
|
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl') && entry.name.includes(sessionId)) {
|
|
67
|
+
matches.push(full);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return matches.sort((left, right) => fileKey(sessionsRoot, left).localeCompare(fileKey(sessionsRoot, right)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function findSessionFile(sessionsRoot, sessionId) {
|
|
75
|
+
const files = await findSessionFiles(sessionsRoot, sessionId);
|
|
76
|
+
return files[0] || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function emptyFileState(filePath) {
|
|
80
|
+
return {
|
|
81
|
+
filePath,
|
|
82
|
+
byteOffset: 0,
|
|
83
|
+
lastRecordKey: null,
|
|
84
|
+
activeCwd: null,
|
|
85
|
+
repoIdentity: null,
|
|
86
|
+
projectPath: null,
|
|
87
|
+
branch: null,
|
|
88
|
+
headAtCapture: null
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function captureSessionUnlocked({ home, sessionsRoot, sessionId, cursorStore }) {
|
|
93
|
+
const cursor = cursorStore.read(sessionId);
|
|
94
|
+
const filePaths = await findSessionFiles(sessionsRoot, sessionId);
|
|
95
|
+
if (!filePaths.length) {
|
|
96
|
+
recordGap(home, 'session-file-not-found', { sessionId });
|
|
97
|
+
return { status: 'gap', reason: 'session-file-not-found' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (cursor.legacyFile) {
|
|
101
|
+
const legacyMatch = filePaths.find(candidate => normalizedPath(candidate) === normalizedPath(cursor.legacyFile.filePath));
|
|
102
|
+
if (legacyMatch) {
|
|
103
|
+
cursor.files[fileKey(sessionsRoot, legacyMatch)] = { ...cursor.legacyFile, filePath: legacyMatch };
|
|
104
|
+
}
|
|
105
|
+
delete cursor.legacyFile;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let captured = 0;
|
|
109
|
+
let completeBytes = 0;
|
|
110
|
+
|
|
111
|
+
for (const filePath of filePaths) {
|
|
112
|
+
const key = fileKey(sessionsRoot, filePath);
|
|
113
|
+
const state = cursor.files[key] || emptyFileState(filePath);
|
|
114
|
+
state.filePath = filePath;
|
|
115
|
+
const fromOffset = state.byteOffset || 0;
|
|
116
|
+
const parsed = parseSessionChunk(filePath, fromOffset);
|
|
117
|
+
let unresolvedGapRecorded = false;
|
|
118
|
+
|
|
119
|
+
for (let index = 0; index < parsed.records.length; index += 1) {
|
|
120
|
+
const record = parsed.records[index];
|
|
121
|
+
const meta = extractWorkspaceMeta(record);
|
|
122
|
+
if (meta) {
|
|
123
|
+
if (meta.cwd && meta.cwd !== state.activeCwd) {
|
|
124
|
+
// Authoritative workspace switch (session start or turn_context change).
|
|
125
|
+
const repo = await resolveRepoContext(meta.cwd);
|
|
126
|
+
state.activeCwd = meta.cwd;
|
|
127
|
+
state.repoIdentity = repo.repoIdentity;
|
|
128
|
+
state.projectPath = repo.projectPath;
|
|
129
|
+
state.branch = repo.branch;
|
|
130
|
+
state.headAtCapture = repo.headAtCapture;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const message = extractMessage(record);
|
|
135
|
+
if (!message) continue;
|
|
136
|
+
if (!state.repoIdentity && !unresolvedGapRecorded) {
|
|
137
|
+
recordGap(home, 'codex-workspace-unresolved', { sessionId, rolloutFile: key });
|
|
138
|
+
unresolvedGapRecorded = true;
|
|
139
|
+
}
|
|
140
|
+
const byteOffset = parsed.recordOffsets[index];
|
|
141
|
+
const recordKey = `${key}:${byteOffset}`;
|
|
142
|
+
const event = validateAndNormalizeEvent({
|
|
143
|
+
source: 'codex',
|
|
144
|
+
eventType: message.role === 'user' ? 'user_prompt' : 'assistant_response',
|
|
145
|
+
role: message.role,
|
|
146
|
+
content: message.text,
|
|
147
|
+
sessionId,
|
|
148
|
+
turnId: message.turnId,
|
|
149
|
+
repoIdentity: state.repoIdentity,
|
|
150
|
+
projectPath: state.projectPath,
|
|
151
|
+
branch: state.branch,
|
|
152
|
+
headAtCapture: state.headAtCapture,
|
|
153
|
+
identityConfidence: message.turnId ? 'exact' : 'partial',
|
|
154
|
+
captureStatus: 'complete',
|
|
155
|
+
rawEventType: record.type || 'message',
|
|
156
|
+
eventKey: `codex:${sessionId}:${recordKey}`,
|
|
157
|
+
meta: { recordKey, rolloutFile: key, byteOffset, phase: message.phase }
|
|
158
|
+
});
|
|
159
|
+
// The workspace can switch mid-session (turn_context), so the journal is
|
|
160
|
+
// routed per record from the authoritative identity, never per session.
|
|
161
|
+
const journal = journalFor(home, state.repoIdentity);
|
|
162
|
+
const appended = await journal.appendConversationEvent(event);
|
|
163
|
+
state.lastRecordKey = recordKey;
|
|
164
|
+
if (!appended.duplicate) captured += 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
state.byteOffset = fromOffset + parsed.completeBytes;
|
|
168
|
+
cursor.files[key] = state;
|
|
169
|
+
completeBytes += parsed.completeBytes;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
cursorStore.write(sessionId, cursor);
|
|
173
|
+
return { status: 'captured', captured, files: filePaths.length, completeBytes };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function captureSession({ home, sessionsRoot, sessionId }) {
|
|
177
|
+
const cursorStore = new CodexCursorStore(home);
|
|
178
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
179
|
+
const lock = new CrossProcessLock(path.join(home, 'cursors', 'codex', `${safe}.capture.lock`));
|
|
180
|
+
return lock.withLock(() => captureSessionUnlocked({ home, sessionsRoot, sessionId, cursorStore }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function main({ home, payload }) {
|
|
184
|
+
if (isCaptureDisabled(process.env, payload)) {
|
|
185
|
+
return captureDisabledResult();
|
|
186
|
+
}
|
|
187
|
+
if (!payload || typeof payload !== 'object') {
|
|
188
|
+
return { status: 'ignored' };
|
|
189
|
+
}
|
|
190
|
+
const sessionId = payload.session_id || payload.sessionId || null;
|
|
191
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
192
|
+
// Ambiguous notify: never fall back to the newest session by mtime.
|
|
193
|
+
recordGap(home, 'notify-session-unresolved', { keys: Object.keys(payload).slice(0, 8) });
|
|
194
|
+
return { status: 'gap', reason: 'notify-session-unresolved' };
|
|
195
|
+
}
|
|
196
|
+
const sessionsRoot =
|
|
197
|
+
payload.sessions_root ||
|
|
198
|
+
(process.env.CODEX_SESSIONS_ROOT) ||
|
|
199
|
+
path.join(home, 'fixtures', 'codex-sessions');
|
|
200
|
+
const result = await captureSession({
|
|
201
|
+
home,
|
|
202
|
+
sessionsRoot,
|
|
203
|
+
sessionId
|
|
204
|
+
});
|
|
205
|
+
if (result.status === 'captured' && result.captured > 0) {
|
|
206
|
+
await notifyConsumers(home, { source: 'codex', sessionId });
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function mainFailOpen({ home, payload }) {
|
|
212
|
+
return main({ home, payload }).catch(() => ({ status: 'fail-open' }));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { main, mainFailOpen, captureSession, findSessionFile, findSessionFiles, recordGap };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Incremental Codex session JSONL parser. Reads only appended bytes after the
|
|
7
|
+
* cursor offset, tolerates a partial trailing line, and classifies records
|
|
8
|
+
* into messages and workspace metadata. Never re-reads the whole file and
|
|
9
|
+
* never selects sessions by mtime — the caller identifies the session file
|
|
10
|
+
* deterministically.
|
|
11
|
+
*/
|
|
12
|
+
function parseSessionChunk(filePath, fromOffset) {
|
|
13
|
+
const size = fs.statSync(filePath).size;
|
|
14
|
+
if (size <= fromOffset) return { records: [], recordOffsets: [], completeBytes: 0, size };
|
|
15
|
+
const fd = fs.openSync(filePath, 'r');
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
const buf = Buffer.alloc(size - fromOffset);
|
|
19
|
+
const read = fs.readSync(fd, buf, 0, buf.length, fromOffset);
|
|
20
|
+
raw = buf.toString('utf8', 0, read);
|
|
21
|
+
} finally {
|
|
22
|
+
fs.closeSync(fd);
|
|
23
|
+
}
|
|
24
|
+
const records = [];
|
|
25
|
+
const recordOffsets = [];
|
|
26
|
+
let completeBytes = 0;
|
|
27
|
+
const chunks = raw.split('\n');
|
|
28
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
29
|
+
const line = chunks[i];
|
|
30
|
+
if (i === chunks.length - 1) break; // trailing bytes without newline = partial
|
|
31
|
+
if (line.length === 0) {
|
|
32
|
+
completeBytes += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(line);
|
|
38
|
+
} catch (_) {
|
|
39
|
+
parsed = null;
|
|
40
|
+
}
|
|
41
|
+
if (parsed && typeof parsed === 'object') {
|
|
42
|
+
records.push(parsed);
|
|
43
|
+
recordOffsets.push(fromOffset + completeBytes);
|
|
44
|
+
}
|
|
45
|
+
completeBytes += Buffer.byteLength(line, 'utf8') + 1;
|
|
46
|
+
}
|
|
47
|
+
return { records, recordOffsets, completeBytes, size };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Authoritative workspace metadata from Codex rollout records:
|
|
52
|
+
* session_meta.payload.cwd and turn_context.payload.cwd. A verified
|
|
53
|
+
* turn_context cwd may update the active workspace for subsequent events.
|
|
54
|
+
*/
|
|
55
|
+
function extractWorkspaceMeta(record) {
|
|
56
|
+
if (!record || typeof record !== 'object') return null;
|
|
57
|
+
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
|
|
58
|
+
if (record.type === 'session_meta' || (payload && payload.type === 'session_meta')) {
|
|
59
|
+
const cwd = payload && typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : null;
|
|
60
|
+
return { kind: 'session_meta', cwd };
|
|
61
|
+
}
|
|
62
|
+
if (record.type === 'turn_context' || (payload && payload.type === 'turn_context')) {
|
|
63
|
+
const cwd = payload && typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : null;
|
|
64
|
+
return { kind: 'turn_context', cwd };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractMessage(record) {
|
|
70
|
+
if (!record || typeof record !== 'object') return null;
|
|
71
|
+
const payload = record.payload && typeof record.payload === 'object' ? record.payload : record;
|
|
72
|
+
const role = payload.role || record.role;
|
|
73
|
+
if (role !== 'user' && role !== 'assistant') return null;
|
|
74
|
+
const text = collectText(payload.content !== undefined ? payload.content : record.content);
|
|
75
|
+
if (text === null) return null;
|
|
76
|
+
const passthrough = payload.internal_chat_message_metadata_passthrough;
|
|
77
|
+
const nestedTurnId = passthrough && typeof passthrough === 'object' ? passthrough.turn_id : null;
|
|
78
|
+
return {
|
|
79
|
+
role,
|
|
80
|
+
text,
|
|
81
|
+
turnId: payload.turn_id || nestedTurnId || record.turn_id || null,
|
|
82
|
+
phase: typeof payload.phase === 'string' && payload.phase ? payload.phase : null
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectText(content) {
|
|
87
|
+
if (typeof content === 'string') return content;
|
|
88
|
+
if (Array.isArray(content)) {
|
|
89
|
+
const parts = [];
|
|
90
|
+
for (const item of content) {
|
|
91
|
+
if (typeof item === 'string') parts.push(item);
|
|
92
|
+
else if (item && typeof item === 'object' && typeof item.text === 'string') parts.push(item.text);
|
|
93
|
+
}
|
|
94
|
+
if (parts.length) return parts.join('\n');
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { parseSessionChunk, extractMessage, extractWorkspaceMeta };
|