@dashclaw/cli 0.7.4 → 0.8.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.
@@ -59,12 +59,10 @@ const HOOK_FILES = [
59
59
  'dashclaw_pretool.py',
60
60
  'dashclaw_posttool.py',
61
61
  'dashclaw_stop.py',
62
- // dashclaw_stop.py imports this for Code Sessions ingest; without it the
63
- // import fails inside a try/except and ingest silently no-ops (v2.7 fix).
64
- 'dashclaw_code_session_reporter.py',
65
- // SessionStart digest (v3.7 item 6) — wired once codex-cli 0.139.0's
66
- // SessionStart hook event was confirmed to fire (see PLUGIN_PARITY.md).
67
- 'dashclaw_session_digest.py',
62
+ // SessionStart enforcement-liveness probe (v8.2) the SessionStart hook
63
+ // (wired once codex-cli 0.139.0's SessionStart event was confirmed to fire;
64
+ // see PLUGIN_PARITY.md). Replaced the retired session-digest hook.
65
+ 'enforcement_liveness_probe.py',
68
66
  ];
69
67
  const HOOK_INTEL_DIR = 'dashclaw_agent_intel';
70
68
 
@@ -192,7 +190,7 @@ export function buildConfigTomlBlock({
192
190
  const pre = join(hooksDir, 'dashclaw_pretool.py');
193
191
  const post = join(hooksDir, 'dashclaw_posttool.py');
194
192
  const stop = join(hooksDir, 'dashclaw_stop.py');
195
- const sessionStart = join(hooksDir, 'dashclaw_session_digest.py');
193
+ const sessionStart = join(hooksDir, 'enforcement_liveness_probe.py');
196
194
 
197
195
  const lines = [
198
196
  MANAGED_START,
@@ -244,11 +242,14 @@ export function buildConfigTomlBlock({
244
242
  '',
245
243
  // Codex CLI 0.139.0's hook-event enum contains SessionStart alongside
246
244
  // Pre/Post/Stop; wired once that lifecycle was verified to fire
247
- // (roadmap v3.7 item 6 — see PLUGIN_PARITY.md).
245
+ // (roadmap v3.7 item 6 — see PLUGIN_PARITY.md). Runs the enforcement-
246
+ // liveness probe (v8.2): `--source session-start` throttles it to once/12h
247
+ // and detaches, so session start is never delayed. No --agent-id — the
248
+ // probe forces its own synthetic identity internally.
248
249
  '[[hooks.SessionStart]]',
249
250
  '[[hooks.SessionStart.hooks]]',
250
251
  'type = "command"',
251
- `command = ${tomlString(`${py} ${sessionStart} --agent-id codex`)}`,
252
+ `command = ${tomlString(`${py} ${sessionStart} --source session-start`)}`,
252
253
  MANAGED_END,
253
254
  );
254
255
 
package/lib/up/db.js CHANGED
@@ -125,6 +125,19 @@ export function embeddedFailureMessage(err) {
125
125
  return `${base}. Retry with --db docker or --db url.`;
126
126
  }
127
127
 
128
+ /**
129
+ * Extra embedded-postgres options when running as root on POSIX (the fresh
130
+ * root-VPS case, caught live by the first `drill:fresh-linux --as-root` run):
131
+ * postgres refuses to run as root, and embedded-postgres's escape hatch is
132
+ * `createPostgresUser: true` (it provisions a `postgres` system user and runs
133
+ * the cluster as that user). No-op everywhere else — including Windows, where
134
+ * the elevated-token case is handled by the pg_ctl lifecycle instead.
135
+ */
136
+ export function rootPostgresOptions({ platform = process.platform, getuid = process.getuid } = {}) {
137
+ if (platform === 'win32' || typeof getuid !== 'function') return {};
138
+ return getuid() === 0 ? { createPostgresUser: true } : {};
139
+ }
140
+
128
141
  export const DEFAULT_DB_PORT = 5433;
129
142
  // Scan window when the preferred port is taken: preferred+1 .. preferred+10.
130
143
  const PORT_SCAN_SPAN = 10;
@@ -409,6 +422,7 @@ export async function provisionDatabase({
409
422
  port,
410
423
  persistent: true,
411
424
  initdbFlags: INITDB_FLAGS,
425
+ ...rootPostgresOptions(),
412
426
  });
413
427
  try {
414
428
  // initdb refuses a non-empty data dir, so only initialise a cluster that
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "@dashclaw/cli",
3
- "version": "0.7.4",
4
- "description": "DashClaw terminal client — approve agent actions and diagnose your instance",
5
- "type": "module",
6
- "keywords": [
7
- "dashclaw",
8
- "cli",
9
- "claude-code",
10
- "ai-governance",
11
- "agent-governance"
12
- ],
13
- "bin": {
14
- "dashclaw": "./bin/dashclaw.js"
15
- },
16
- "files": [
17
- "bin/",
18
- "lib/",
19
- "README.md"
20
- ],
21
- "engines": {
22
- "node": ">=18.0.0"
23
- },
24
- "scripts": {
25
- "test": "node --test test/**/*.test.js"
26
- },
27
- "dependencies": {
28
- "dashclaw": "^2.2.1",
29
- "embedded-postgres": "18.4.0-beta.17",
30
- "pg": "^8.22.0",
31
- "tar": "^7.4.0"
32
- },
33
- "license": "MIT",
34
- "publishConfig": {
35
- "access": "public"
36
- }
37
- }
1
+ {
2
+ "name": "@dashclaw/cli",
3
+ "version": "0.8.0",
4
+ "description": "DashClaw terminal client — approve agent actions and diagnose your instance",
5
+ "type": "module",
6
+ "keywords": [
7
+ "dashclaw",
8
+ "cli",
9
+ "claude-code",
10
+ "ai-governance",
11
+ "agent-governance"
12
+ ],
13
+ "bin": {
14
+ "dashclaw": "./bin/dashclaw.js"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "lib/",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18.0.0"
23
+ },
24
+ "scripts": {
25
+ "test": "node --test test/**/*.test.js"
26
+ },
27
+ "dependencies": {
28
+ "dashclaw": "^2.2.1",
29
+ "embedded-postgres": "18.4.0-beta.17",
30
+ "pg": "^8.22.0",
31
+ "tar": "^7.4.0"
32
+ },
33
+ "license": "MIT",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }
package/lib/code/apply.js DELETED
@@ -1,164 +0,0 @@
1
- // `dashclaw code apply <manifestId>` — fetch a write plan from the server
2
- // and apply it locally. The server emits the manifest (Phase 6) with a
3
- // 24h TTL. This module is the only place the CLI writes to disk for the
4
- // Optimal Files feature.
5
-
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
- import { _ensureInsideProject, applyMerge } from './vendored.js';
9
-
10
- const SECRET_PATTERNS = [
11
- { name: 'stripe_test', re: /sk_test_[A-Za-z0-9]{8,}/g },
12
- { name: 'stripe_live', re: /sk_live_[A-Za-z0-9]{8,}/g },
13
- { name: 'stripe_webhook', re: /whsec_[A-Za-z0-9]{8,}/g },
14
- { name: 'openai_key', re: /sk-[A-Za-z0-9]{20,}/g },
15
- { name: 'anthropic_key', re: /sk-ant-[A-Za-z0-9_\-]{20,}/g },
16
- { name: 'github_pat', re: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g },
17
- { name: 'aws_access', re: /AKIA[0-9A-Z]{16}/g },
18
- { name: 'jwt', re: /eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}/g },
19
- { name: 'private_key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
20
- { name: 'env_assign', re: /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CRED|AUTH))\s*=\s*['"]?[^\s'"\n]+/g },
21
- ];
22
-
23
- function scanForSecrets(content) {
24
- let redactions = 0;
25
- let out = String(content == null ? '' : content);
26
- for (const p of SECRET_PATTERNS) {
27
- out = out.replace(p.re, (_match, captured) => {
28
- redactions++;
29
- if (p.name === 'env_assign' && captured) return `${captured}=<REDACTED:${p.name}>`;
30
- return `<REDACTED:${p.name}>`;
31
- });
32
- }
33
- return { status: redactions === 0 ? 'passed' : 'redacted', redactions, redacted: out };
34
- }
35
-
36
- async function fetchManifest(baseUrl, apiKey, manifestId, { fetchImpl = fetch }) {
37
- const url = baseUrl.replace(/\/+$/, '') + '/api/code-sessions/manifests/' + encodeURIComponent(manifestId);
38
- const res = await fetchImpl(url, {
39
- headers: { 'x-api-key': apiKey },
40
- });
41
- let body = null;
42
- try { body = await res.json(); } catch { /* null */ }
43
- return { status: res.status, ok: res.ok, body };
44
- }
45
-
46
- export async function runApply({
47
- baseUrl,
48
- apiKey,
49
- manifestId,
50
- dest,
51
- yes = false,
52
- allowRedactions = false,
53
- allowOverwriteSideBySide = false,
54
- fetchImpl = fetch,
55
- logger = console,
56
- }) {
57
- if (!manifestId) throw new Error('runApply: manifestId is required');
58
- if (!dest) throw new Error('runApply: --dest=<dir> is required');
59
- if (!baseUrl || !apiKey) throw new Error('runApply: baseUrl and apiKey are required');
60
-
61
- const destAbs = path.resolve(dest);
62
- try { fs.mkdirSync(destAbs, { recursive: true }); }
63
- catch (err) { throw new Error('Could not create destination ' + destAbs + ': ' + err.message); }
64
-
65
- const { status, ok, body } = await fetchManifest(baseUrl, apiKey, manifestId, { fetchImpl });
66
- if (!ok) throw new Error('Failed to load manifest: HTTP ' + status + (body?.error ? ' — ' + body.error : ''));
67
- const plan = Array.isArray(body?.plan) ? body.plan : Array.isArray(body?.plan?.results) ? body.plan.results : null;
68
- if (!plan) throw new Error('Manifest payload missing plan');
69
-
70
- const results = [];
71
- for (const entry of plan) {
72
- const target = entry.path;
73
- if (!target) {
74
- results.push({ path: '(unknown)', status: 'invalid_entry' });
75
- continue;
76
- }
77
- const abs = _ensureInsideProject(destAbs, target);
78
- if (!abs) {
79
- results.push({ path: target, status: 'unsafe_path' });
80
- logger.warn(` ${target} -> unsafe_path (refused)`);
81
- continue;
82
- }
83
- const content = entry.content;
84
- if (typeof content !== 'string') {
85
- results.push({ path: target, status: 'no_content' });
86
- continue;
87
- }
88
- const scan = scanForSecrets(content);
89
- if (scan.status === 'redacted' && !allowRedactions) {
90
- results.push({ path: target, status: 'redacted', redactions: scan.redactions });
91
- logger.warn(` ${target} -> ${scan.redactions} secret pattern(s) detected, refused (use --allow-redactions)`);
92
- continue;
93
- }
94
- const mode = entry.mode || 'create';
95
-
96
- try {
97
- if (mode === 'merge') {
98
- if (!fs.existsSync(abs)) {
99
- fs.mkdirSync(path.dirname(abs), { recursive: true });
100
- fs.writeFileSync(abs, scan.redacted, 'utf8');
101
- results.push({ path: target, status: 'created', bytes: Buffer.byteLength(scan.redacted, 'utf8') });
102
- logger.info(` ${target} -> created`);
103
- continue;
104
- }
105
- const existing = fs.readFileSync(abs, 'utf8');
106
- const merged = applyMerge(existing, scan.redacted, {
107
- acceptedHeadings: entry.acceptedHeadings || [],
108
- acceptedBullets: entry.acceptedBullets || [],
109
- });
110
- fs.writeFileSync(abs, merged.merged, 'utf8');
111
- results.push({ path: target, status: 'merged', additions: merged.additions });
112
- logger.info(` ${target} -> merged`);
113
- } else if (mode === 'side_by_side') {
114
- const sideAbs = entry.absolutePath
115
- ? path.resolve(entry.absolutePath)
116
- : sideBySidePath(abs);
117
- const guardedSide = _ensureInsideProject(destAbs, path.relative(destAbs, sideAbs));
118
- if (!guardedSide) {
119
- results.push({ path: target, status: 'unsafe_path' });
120
- continue;
121
- }
122
- fs.mkdirSync(path.dirname(guardedSide), { recursive: true });
123
- if (fs.existsSync(guardedSide) && !allowOverwriteSideBySide) {
124
- results.push({ path: target, status: 'side_by_side_conflict' });
125
- logger.warn(` ${target} -> side_by_side_conflict (pass --overwrite to replace)`);
126
- continue;
127
- }
128
- fs.writeFileSync(guardedSide, scan.redacted, 'utf8');
129
- results.push({ path: target, status: 'side_by_side', bytes: Buffer.byteLength(scan.redacted, 'utf8') });
130
- logger.info(` ${target} -> side_by_side`);
131
- } else if (mode === 'skip') {
132
- results.push({ path: target, status: 'skipped' });
133
- } else {
134
- // 'create' or 'overwrite'
135
- fs.mkdirSync(path.dirname(abs), { recursive: true });
136
- const existed = fs.existsSync(abs);
137
- if (existed && mode !== 'overwrite' && !yes) {
138
- results.push({ path: target, status: 'refused_overwrite_without_yes' });
139
- logger.warn(` ${target} -> exists; pass --yes or rebuild manifest with mode='overwrite'`);
140
- continue;
141
- }
142
- fs.writeFileSync(abs, scan.redacted, 'utf8');
143
- results.push({ path: target, status: existed ? 'overwritten' : 'created' });
144
- logger.info(` ${target} -> ${existed ? 'overwritten' : 'created'}`);
145
- }
146
- } catch (err) {
147
- results.push({ path: target, status: 'error', error: err.message });
148
- logger.warn(` ${target} -> error: ${err.message}`);
149
- }
150
- }
151
- return results;
152
- }
153
-
154
- function sideBySidePath(abs) {
155
- const dir = path.dirname(abs);
156
- const base = path.basename(abs);
157
- const ext = path.extname(base);
158
- if (!ext) return path.join(dir, base + '.NEW');
159
- return path.join(dir, base.slice(0, -ext.length) + '.NEW' + ext);
160
- }
161
-
162
- // Exported for the sync script + tests that want to assert the scan layer
163
- // stays in lock-step with app/lib/claude-code/optimal-files/secret-scan.js.
164
- export { scanForSecrets };
@@ -1,360 +0,0 @@
1
- // VENDORED from app/lib/codex/parser.ts — GENERATED, do not edit by hand.
2
- // The CLI runs as raw Node ESM and is published without app/, so it cannot import
3
- // the app TypeScript source. Regenerate by compiling app/lib/codex/parser.ts.
4
- // See cli/lib/code/vendored.js for the same sibling-package rationale.
5
-
6
- // app/lib/codex/parser.js
7
- //
8
- // Codex CLI session JSONL parser.
9
- //
10
- // Codex writes session rollouts to `~/.codex/sessions/rollout-<ts>-<uuid>.jsonl`.
11
- // Each line has the shape:
12
- //
13
- // { "timestamp": "...", "type": "session_meta"|"response_item"|...,
14
- // "payload": { ... } }
15
- //
16
- // We normalize the rollout into a session object that's intentionally
17
- // parallel to the Claude Code parser output (app/lib/claude-code/parser.js)
18
- // so downstream insights, alerts, and rules engines can be reused with
19
- // minimal branching. The shape differs only where Codex's data model
20
- // fundamentally diverges from Claude's (token semantics, reasoning tokens).
21
- //
22
- // Token mapping (Codex → normalized):
23
- // input_tokens → input_tokens
24
- // output_tokens → output_tokens (excludes reasoning)
25
- // cached_input_tokens → cache_read_tokens
26
- // reasoning_output_tokens → reasoning_tokens (Codex-only field)
27
- // total_tokens → recomputed at the request level for safety
28
- //
29
- // There is no Codex equivalent to Claude's `cache_creation_tokens`. We emit
30
- // 0 for that field on Codex sessions to keep the schema compatible.
31
- //
32
- // This parser is deliberately tolerant: malformed lines are counted in
33
- // `skippedLines` and never throw. It returns a partial session even when
34
- // the rollout was truncated mid-write.
35
- import fs from 'node:fs';
36
- import readline from 'node:readline';
37
- export const CODEX_PARSER_VERSION = 1;
38
- function safeParse(line) {
39
- try {
40
- return JSON.parse(line);
41
- }
42
- catch {
43
- return null;
44
- }
45
- }
46
- function previewText(value, max = 200) {
47
- if (typeof value !== 'string')
48
- return '';
49
- return value.slice(0, max);
50
- }
51
- // Best-effort agent-kind tag for downstream branching. Always 'codex' here.
52
- export const CODEX_AGENT_KIND = 'codex';
53
- function _initState({ sourceFile, sourceMtime }) {
54
- return {
55
- session: {
56
- agentKind: CODEX_AGENT_KIND,
57
- sourceFile: sourceFile || null,
58
- sourceMtime: sourceMtime || null,
59
- sessionUuid: null,
60
- projectSlug: null,
61
- cwd: null,
62
- gitBranch: null,
63
- gitCommit: null,
64
- gitRepoUrl: null,
65
- cliVersion: null,
66
- originator: null,
67
- modelProvider: null,
68
- modelPrimary: null,
69
- startedAt: null,
70
- endedAt: null,
71
- parserVersion: CODEX_PARSER_VERSION,
72
- // counters
73
- jsonlRecords: 0,
74
- responseItems: 0,
75
- eventMessages: 0,
76
- compactedItems: 0,
77
- skippedLines: 0,
78
- turnCount: 0,
79
- toolCallCount: 0,
80
- // token totals
81
- totals: {
82
- input_tokens: 0,
83
- output_tokens: 0,
84
- cache_read_tokens: 0,
85
- cache_creation_tokens: 0, // always 0 for Codex; kept for schema parity
86
- reasoning_tokens: 0,
87
- },
88
- // per-turn token snapshots, in arrival order
89
- turns: [], // { turnId, inputTokens, outputTokens, cachedInputTokens, reasoningTokens, total, at }
90
- // canonical message timeline (user + assistant + reasoning + tool_call + tool_result)
91
- messages: [],
92
- // tool uses across the session
93
- toolUses: [],
94
- },
95
- seenTurnIds: new Set(),
96
- };
97
- }
98
- function _handleSessionMeta(state, payload, ts) {
99
- const s = state.session;
100
- if (!s.sessionUuid && payload?.id)
101
- s.sessionUuid = payload.id;
102
- if (!s.cwd && payload?.cwd)
103
- s.cwd = payload.cwd;
104
- if (!s.cliVersion && payload?.cli_version)
105
- s.cliVersion = payload.cli_version;
106
- if (!s.originator && payload?.originator)
107
- s.originator = payload.originator;
108
- if (!s.modelProvider && payload?.model_provider)
109
- s.modelProvider = payload.model_provider;
110
- if (!s.startedAt)
111
- s.startedAt = ts || payload?.timestamp || null;
112
- if (payload?.git && typeof payload.git === 'object') {
113
- const git = payload.git;
114
- if (!s.gitBranch && git.branch)
115
- s.gitBranch = git.branch;
116
- if (!s.gitCommit && git.commit_hash)
117
- s.gitCommit = git.commit_hash;
118
- if (!s.gitRepoUrl && git.repository_url)
119
- s.gitRepoUrl = git.repository_url;
120
- }
121
- }
122
- function _handleResponseItem(state, payload, ts) {
123
- const s = state.session;
124
- s.responseItems += 1;
125
- if (!payload || typeof payload !== 'object')
126
- return;
127
- const role = payload.role;
128
- // Response items can be of several shapes:
129
- // - { role: 'user'|'assistant'|'system', content: [...] }
130
- // - { type: 'function_call', name, arguments, call_id }
131
- // - { type: 'function_call_output', call_id, output }
132
- // - { type: 'reasoning', summary: [...] }
133
- if (payload.type === 'function_call' || (payload.name && payload.call_id)) {
134
- s.toolCallCount += 1;
135
- s.toolUses.push({
136
- name: payload.name || 'unknown',
137
- tool_use_id: payload.call_id || payload.id || null,
138
- arguments_preview: previewText(payload.arguments, 240),
139
- target: safeToolTarget(payload),
140
- at: ts,
141
- });
142
- s.messages.push({
143
- role: 'tool_call',
144
- name: payload.name,
145
- tool_use_id: payload.call_id || null,
146
- preview: previewText(payload.arguments, 240),
147
- at: ts,
148
- });
149
- return;
150
- }
151
- if (payload.type === 'function_call_output') {
152
- s.messages.push({
153
- role: 'tool_result',
154
- tool_use_id: payload.call_id || null,
155
- preview: previewText(typeof payload.output === 'string' ? payload.output : JSON.stringify(payload.output ?? ''), 240),
156
- at: ts,
157
- });
158
- return;
159
- }
160
- if (payload.type === 'reasoning') {
161
- // Reasoning items are first-class in Codex — we keep a stub on the
162
- // timeline so downstream tools can correlate cost with thinking.
163
- s.messages.push({ role: 'reasoning', preview: '(reasoning)', at: ts });
164
- return;
165
- }
166
- if (role && Array.isArray(payload.content)) {
167
- s.messages.push({
168
- role,
169
- preview: extractTextPreview(payload.content),
170
- at: ts,
171
- });
172
- }
173
- }
174
- function extractTextPreview(content) {
175
- for (const c of content) {
176
- if (!c)
177
- continue;
178
- if (typeof c === 'string')
179
- return previewText(c);
180
- const part = c;
181
- if (part.type === 'input_text' && typeof part.text === 'string')
182
- return previewText(part.text);
183
- if (part.type === 'output_text' && typeof part.text === 'string')
184
- return previewText(part.text);
185
- if (part.type === 'text' && typeof part.text === 'string')
186
- return previewText(part.text);
187
- }
188
- return '';
189
- }
190
- function safeToolTarget(p) {
191
- // Codex tools include `local_shell` (command exec) and a generic `apply_patch`
192
- // among many MCP tools. We try to extract a SHORT non-secret identifier.
193
- try {
194
- const args = typeof p.arguments === 'string'
195
- ? JSON.parse(p.arguments)
196
- : p.arguments;
197
- if (!args || typeof args !== 'object')
198
- return null;
199
- if (p.name === 'local_shell' || p.name === 'shell') {
200
- const cmd = typeof args.command === 'string'
201
- ? args.command
202
- : Array.isArray(args.command) ? args.command.join(' ') : '';
203
- const head = cmd.trim().split(/\s+/)[0] || '';
204
- return head ? head.slice(0, 60) : null;
205
- }
206
- if (typeof args.file_path === 'string')
207
- return args.file_path.slice(0, 160);
208
- if (typeof args.path === 'string')
209
- return args.path.slice(0, 160);
210
- if (typeof args.url === 'string') {
211
- try {
212
- return new URL(args.url).host;
213
- }
214
- catch {
215
- return null;
216
- }
217
- }
218
- }
219
- catch { /* fall through */ }
220
- return null;
221
- }
222
- function _handleEventMsg(state, payload, ts) {
223
- const s = state.session;
224
- s.eventMessages += 1;
225
- if (!payload || typeof payload !== 'object')
226
- return;
227
- const type = payload.type;
228
- if (type === 'token_count') {
229
- // Payload shape: { type: 'token_count', info: { total_token_usage, last_token_usage, model_context_window } }
230
- const last = payload.info?.last_token_usage;
231
- if (last) {
232
- const input = Number(last.input_tokens) || 0;
233
- const output = Number(last.output_tokens) || 0;
234
- const cachedInput = Number(last.cached_input_tokens) || 0;
235
- const reasoning = Number(last.reasoning_output_tokens) || 0;
236
- const total = Number(last.total_tokens) || (input + output + cachedInput + reasoning);
237
- s.totals.input_tokens += input;
238
- s.totals.output_tokens += output;
239
- s.totals.cache_read_tokens += cachedInput;
240
- s.totals.reasoning_tokens += reasoning;
241
- // Turn snapshot — Codex emits one token_count per model turn. We use
242
- // the event ordinal as a synthetic turn id since the payload doesn't
243
- // carry one. The token-count event always corresponds to one turn.
244
- const turnId = `t${s.turns.length + 1}`;
245
- s.turns.push({
246
- turnId,
247
- inputTokens: input,
248
- outputTokens: output,
249
- cachedInputTokens: cachedInput,
250
- reasoningTokens: reasoning,
251
- total,
252
- at: ts,
253
- });
254
- s.turnCount = s.turns.length;
255
- }
256
- return;
257
- }
258
- if (type === 'session_configured') {
259
- if (!s.modelPrimary && payload.model)
260
- s.modelPrimary = payload.model;
261
- return;
262
- }
263
- if (type === 'agent_message') {
264
- if (typeof payload.message === 'string') {
265
- s.messages.push({ role: 'assistant', preview: previewText(payload.message), at: ts });
266
- }
267
- return;
268
- }
269
- if (type === 'user_message') {
270
- if (typeof payload.message === 'string') {
271
- s.messages.push({ role: 'user', preview: previewText(payload.message), at: ts });
272
- }
273
- return;
274
- }
275
- }
276
- function _handleCompacted(state, payload, ts) {
277
- const s = state.session;
278
- s.compactedItems += 1;
279
- s.messages.push({
280
- role: 'system',
281
- preview: '(compacted) ' + previewText(payload?.message || '', 180),
282
- at: ts,
283
- });
284
- }
285
- function _processLine(state, line, lineNo) {
286
- if (!line || !line.trim())
287
- return;
288
- const rec = safeParse(line);
289
- const s = state.session;
290
- if (!rec || typeof rec !== 'object') {
291
- s.skippedLines += 1;
292
- return;
293
- }
294
- s.jsonlRecords += 1;
295
- const ts = typeof rec.timestamp === 'string' ? rec.timestamp : null;
296
- if (ts) {
297
- if (!s.startedAt || ts < s.startedAt)
298
- s.startedAt = ts;
299
- if (!s.endedAt || ts > s.endedAt)
300
- s.endedAt = ts;
301
- }
302
- // RolloutLine flattens `type` to the top level (serde flatten).
303
- const type = rec.type;
304
- const payload = rec.payload;
305
- switch (type) {
306
- case 'session_meta':
307
- _handleSessionMeta(state, payload, ts);
308
- break;
309
- case 'response_item':
310
- _handleResponseItem(state, payload, ts);
311
- break;
312
- case 'event_msg':
313
- _handleEventMsg(state, payload, ts);
314
- break;
315
- case 'compacted':
316
- _handleCompacted(state, payload, ts);
317
- break;
318
- case 'turn_context':
319
- // No-op for now; turn_context carries the per-turn model + tool list
320
- // but we don't yet use it for normalization.
321
- break;
322
- default:
323
- // Unknown line type — counted in jsonlRecords but no further action.
324
- break;
325
- }
326
- }
327
- /**
328
- * Parse an array of raw JSONL lines (in-memory). Returns the normalized
329
- * session object. The server uses this so it never touches the filesystem.
330
- */
331
- export function parseCodexSessionLines(lines, { sourceFile = null, sourceMtime = null } = {}) {
332
- if (!Array.isArray(lines)) {
333
- throw new TypeError('parseCodexSessionLines: lines must be an array of strings');
334
- }
335
- const state = _initState({ sourceFile, sourceMtime });
336
- for (let i = 0; i < lines.length; i++) {
337
- _processLine(state, lines[i], i + 1);
338
- }
339
- return state.session;
340
- }
341
- /**
342
- * Stream-parse a Codex JSONL file from disk. Used by the CLI ingest path.
343
- */
344
- export async function parseCodexSessionFile(filePath, { mtime } = {}) {
345
- const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
346
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
347
- let actualMtime = mtime;
348
- if (!actualMtime) {
349
- try {
350
- actualMtime = fs.statSync(filePath).mtime.toISOString();
351
- }
352
- catch { /* keep null */ }
353
- }
354
- const state = _initState({ sourceFile: filePath, sourceMtime: actualMtime });
355
- let lineNo = 0;
356
- for await (const line of rl) {
357
- _processLine(state, line, ++lineNo);
358
- }
359
- return state.session;
360
- }