@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.
Files changed (37) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -74
  3. package/console/index.html +953 -948
  4. package/package.json +26 -26
  5. package/src/agent-detect.js +128 -128
  6. package/src/bin.js +80 -80
  7. package/src/bridge/bin/commit-boundary.js +64 -64
  8. package/src/bridge/connectors/capture-guard.js +24 -24
  9. package/src/bridge/connectors/claude-code/hook-entry.js +45 -45
  10. package/src/bridge/connectors/claude-code/notify.js +57 -57
  11. package/src/bridge/connectors/codex/cursor-store.js +83 -83
  12. package/src/bridge/connectors/codex/hook-entry.js +215 -215
  13. package/src/bridge/connectors/codex/session-parser.js +100 -100
  14. package/src/bridge/connectors/opencode/hook-entry.js +55 -55
  15. package/src/bridge/core/bridge.js +249 -246
  16. package/src/bridge/core/commit-projection.js +288 -288
  17. package/src/bridge/core/compaction.js +23 -23
  18. package/src/bridge/core/consumer-registry.js +111 -111
  19. package/src/bridge/core/event-schema.js +127 -127
  20. package/src/bridge/core/fs-utils.js +81 -81
  21. package/src/bridge/core/journal-router.js +39 -39
  22. package/src/bridge/core/journal.js +507 -507
  23. package/src/bridge/core/lock.js +96 -96
  24. package/src/bridge/core/normalizer.js +114 -114
  25. package/src/bridge/core/paths.js +12 -12
  26. package/src/bridge/core/project-registry.js +176 -176
  27. package/src/bridge/core/repo-context.js +191 -191
  28. package/src/bridge/core/runtime-home.js +189 -188
  29. package/src/bridge/core/semver.js +23 -23
  30. package/src/bridge/core/turn-identity.js +127 -127
  31. package/src/bridge/index.js +86 -86
  32. package/src/bridge/installers/claude-code/installer.js +181 -181
  33. package/src/bridge/installers/codex/installer.js +114 -114
  34. package/src/bridge/installers/opencode/installer.js +96 -96
  35. package/src/bridge/installers/zcode/installer.js +200 -188
  36. package/src/bridge/query/conversation-query.js +194 -194
  37. 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 };