@sanqianx/ai-coding-event-bridge-console 0.7.1 → 0.7.3
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/LICENSE +21 -21
- package/README.md +81 -74
- package/console/index.html +953 -948
- package/package.json +26 -26
- package/src/agent-detect.js +128 -128
- package/src/bin.js +80 -80
- package/src/bridge/bin/commit-boundary.js +64 -64
- package/src/bridge/connectors/capture-guard.js +24 -24
- package/src/bridge/connectors/claude-code/hook-entry.js +45 -45
- package/src/bridge/connectors/claude-code/notify.js +57 -57
- package/src/bridge/connectors/codex/cursor-store.js +83 -83
- package/src/bridge/connectors/codex/hook-entry.js +215 -215
- package/src/bridge/connectors/codex/session-parser.js +100 -100
- package/src/bridge/connectors/opencode/hook-entry.js +55 -55
- package/src/bridge/core/bridge.js +249 -246
- package/src/bridge/core/commit-projection.js +288 -288
- package/src/bridge/core/compaction.js +23 -23
- package/src/bridge/core/consumer-registry.js +111 -111
- package/src/bridge/core/event-schema.js +127 -127
- package/src/bridge/core/fs-utils.js +81 -81
- package/src/bridge/core/journal-router.js +39 -39
- package/src/bridge/core/journal.js +507 -507
- package/src/bridge/core/lock.js +96 -96
- package/src/bridge/core/normalizer.js +114 -114
- package/src/bridge/core/paths.js +12 -12
- package/src/bridge/core/project-registry.js +176 -176
- package/src/bridge/core/repo-context.js +191 -191
- package/src/bridge/core/runtime-home.js +189 -188
- package/src/bridge/core/semver.js +23 -23
- package/src/bridge/core/turn-identity.js +127 -127
- package/src/bridge/index.js +86 -86
- package/src/bridge/installers/claude-code/installer.js +181 -181
- package/src/bridge/installers/codex/installer.js +114 -114
- package/src/bridge/installers/opencode/installer.js +96 -96
- package/src/bridge/installers/zcode/installer.js +200 -188
- package/src/bridge/query/conversation-query.js +194 -194
- package/src/server.js +825 -825
|
@@ -1,215 +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 };
|
|
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 };
|
|
@@ -1,100 +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 };
|
|
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 };
|