@agentex/agent 0.0.24 → 0.0.26
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/CHANGELOG.md +338 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/derived.d.ts +5 -3
- package/dist/derived.d.ts.map +1 -1
- package/dist/derived.js +11 -7
- package/dist/derived.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/acp/index.d.ts +1 -1
- package/dist/providers/acp/index.d.ts.map +1 -1
- package/dist/providers/acp/index.js +5 -97
- package/dist/providers/acp/index.js.map +1 -1
- package/dist/providers/acp/session.d.ts +8 -1
- package/dist/providers/acp/session.d.ts.map +1 -1
- package/dist/providers/acp/session.js +94 -0
- package/dist/providers/acp/session.js.map +1 -1
- package/dist/providers/claude/attach.d.ts +8 -0
- package/dist/providers/claude/attach.d.ts.map +1 -0
- package/dist/providers/claude/attach.js +113 -0
- package/dist/providers/claude/attach.js.map +1 -0
- package/dist/providers/claude/goal-capability.d.ts +15 -0
- package/dist/providers/claude/goal-capability.d.ts.map +1 -0
- package/dist/providers/claude/goal-capability.js +20 -0
- package/dist/providers/claude/goal-capability.js.map +1 -0
- package/dist/providers/claude/index.d.ts.map +1 -1
- package/dist/providers/claude/index.js +8 -4
- package/dist/providers/claude/index.js.map +1 -1
- package/dist/providers/claude/session.d.ts +11 -9
- package/dist/providers/claude/session.d.ts.map +1 -1
- package/dist/providers/claude/session.js +29 -14
- package/dist/providers/claude/session.js.map +1 -1
- package/dist/providers/codex/attach.d.ts +9 -0
- package/dist/providers/codex/attach.d.ts.map +1 -0
- package/dist/providers/codex/attach.js +93 -0
- package/dist/providers/codex/attach.js.map +1 -0
- package/dist/providers/codex/goal-capability.d.ts +13 -0
- package/dist/providers/codex/goal-capability.d.ts.map +1 -0
- package/dist/providers/codex/goal-capability.js +18 -0
- package/dist/providers/codex/goal-capability.js.map +1 -0
- package/dist/providers/codex/index.d.ts +1 -0
- package/dist/providers/codex/index.d.ts.map +1 -1
- package/dist/providers/codex/index.js +9 -6
- package/dist/providers/codex/index.js.map +1 -1
- package/dist/providers/codex/session.d.ts +11 -7
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +24 -12
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/providers/codex/transcript-normalize.d.ts +28 -0
- package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
- package/dist/providers/codex/transcript-normalize.js +191 -0
- package/dist/providers/codex/transcript-normalize.js.map +1 -0
- package/dist/providers/cursor/index.d.ts.map +1 -1
- package/dist/providers/cursor/index.js +2 -2
- package/dist/providers/cursor/index.js.map +1 -1
- package/dist/providers/openclaw/index.d.ts.map +1 -1
- package/dist/providers/openclaw/index.js +2 -2
- package/dist/providers/openclaw/index.js.map +1 -1
- package/dist/providers/opencode/index.d.ts.map +1 -1
- package/dist/providers/opencode/index.js +3 -5
- package/dist/providers/opencode/index.js.map +1 -1
- package/dist/providers/pi/index.d.ts.map +1 -1
- package/dist/providers/pi/index.js +3 -5
- package/dist/providers/pi/index.js.map +1 -1
- package/dist/providers/process/index.d.ts.map +1 -1
- package/dist/providers/process/index.js +2 -2
- package/dist/providers/process/index.js.map +1 -1
- package/dist/registry.d.ts +0 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +0 -4
- package/dist/registry.js.map +1 -1
- package/dist/sessions/index.d.ts +3 -0
- package/dist/sessions/index.d.ts.map +1 -0
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/record.d.ts +43 -0
- package/dist/sessions/record.d.ts.map +1 -0
- package/dist/sessions/record.js +85 -0
- package/dist/sessions/record.js.map +1 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/uuid.d.ts +7 -1
- package/dist/utils/uuid.d.ts.map +1 -1
- package/dist/utils/uuid.js +21 -1
- package/dist/utils/uuid.js.map +1 -1
- package/package.json +64 -7
- package/src/derived.ts +311 -0
- package/src/goals/controller.ts +442 -0
- package/src/goals/index.ts +21 -0
- package/src/goals/normalize.ts +173 -0
- package/src/goals/sentinel.ts +90 -0
- package/src/index.ts +270 -0
- package/src/providers/_shared/http-agent.ts +304 -0
- package/src/providers/acp/index.ts +103 -0
- package/src/providers/acp/parse.ts +131 -0
- package/src/providers/acp/session.ts +744 -0
- package/src/providers/claude/attach.ts +147 -0
- package/src/providers/claude/codec.ts +43 -0
- package/src/providers/claude/execute.ts +300 -0
- package/src/providers/claude/goal-capability.ts +21 -0
- package/src/providers/claude/index.ts +72 -0
- package/src/providers/claude/mcp.ts +82 -0
- package/src/providers/claude/parse.ts +824 -0
- package/src/providers/claude/session.ts +1192 -0
- package/src/providers/claude/transcript.ts +555 -0
- package/src/providers/codex/attach.ts +123 -0
- package/src/providers/codex/codec.ts +50 -0
- package/src/providers/codex/execute.ts +337 -0
- package/src/providers/codex/goal-capability.ts +19 -0
- package/src/providers/codex/index.ts +57 -0
- package/src/providers/codex/modes.ts +159 -0
- package/src/providers/codex/parse.ts +691 -0
- package/src/providers/codex/plan-mode.ts +49 -0
- package/src/providers/codex/session.ts +1287 -0
- package/src/providers/codex/transcript-normalize.ts +197 -0
- package/src/providers/codex/transcript.ts +487 -0
- package/src/providers/codex/usage-scanner.ts +178 -0
- package/src/providers/copilot/index.ts +19 -0
- package/src/providers/cursor/codec.ts +44 -0
- package/src/providers/cursor/execute.ts +271 -0
- package/src/providers/cursor/index.ts +25 -0
- package/src/providers/cursor/parse.ts +288 -0
- package/src/providers/gemini/index.ts +21 -0
- package/src/providers/openclaw/codec.ts +40 -0
- package/src/providers/openclaw/execute.ts +19 -0
- package/src/providers/openclaw/index.ts +29 -0
- package/src/providers/opencode/codec.ts +50 -0
- package/src/providers/opencode/event-parse.ts +141 -0
- package/src/providers/opencode/execute.ts +251 -0
- package/src/providers/opencode/http-session.ts +427 -0
- package/src/providers/opencode/index.ts +30 -0
- package/src/providers/opencode/parse.ts +203 -0
- package/src/providers/opencode/server.ts +0 -0
- package/src/providers/pi/codec.ts +44 -0
- package/src/providers/pi/execute.ts +297 -0
- package/src/providers/pi/index.ts +30 -0
- package/src/providers/pi/parse.ts +231 -0
- package/src/providers/pi/session.ts +381 -0
- package/src/providers/process/execute.ts +148 -0
- package/src/providers/process/index.ts +52 -0
- package/src/registry.ts +40 -0
- package/src/sessions/index.ts +8 -0
- package/src/sessions/record.ts +108 -0
- package/src/types.ts +1638 -0
- package/src/utils/ask-user-question.ts +57 -0
- package/src/utils/auth.ts +661 -0
- package/src/utils/binary.ts +179 -0
- package/src/utils/endpoint.ts +172 -0
- package/src/utils/env.ts +63 -0
- package/src/utils/execute-all.ts +68 -0
- package/src/utils/exit-plan-mode.ts +40 -0
- package/src/utils/instructions.ts +427 -0
- package/src/utils/process.ts +223 -0
- package/src/utils/runtime-config.ts +100 -0
- package/src/utils/runtime-homes.ts +49 -0
- package/src/utils/skill-commands.ts +493 -0
- package/src/utils/skills.ts +500 -0
- package/src/utils/template.ts +16 -0
- package/src/utils/tool-names.ts +51 -0
- package/src/utils/uuid.ts +21 -0
- package/src/utils/workspace.ts +156 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { BaseStreamEventFields, StreamEvent } from "../../types.js";
|
|
2
|
+
import type { CodexTranscriptLine } from "./transcript.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalize a Codex on-disk transcript line into `StreamEvent`s — the library
|
|
6
|
+
* absorption of Flow's `codex-on-disk.ts` map/drop table, so `catchUp` replay
|
|
7
|
+
* yields the same event vocabulary as a live `onEvent` stream.
|
|
8
|
+
*
|
|
9
|
+
* Coverage (wrapped ≥0.10 rollout format; `line.payload` present):
|
|
10
|
+
*
|
|
11
|
+
* response_item / message (role="assistant") → assistant
|
|
12
|
+
* response_item / reasoning → thinking
|
|
13
|
+
* response_item / function_call → tool_call
|
|
14
|
+
* response_item / function_call_output → tool_result
|
|
15
|
+
* event_msg / task_complete → result (completed)
|
|
16
|
+
*
|
|
17
|
+
* Everything else — `session_meta`, `turn_context`, `task_started`,
|
|
18
|
+
* `token_count`, `agent_message`/`agent_reasoning` duplicates, user/developer
|
|
19
|
+
* messages, unwrapped legacy lines, unknown types — yields `[]`.
|
|
20
|
+
*
|
|
21
|
+
* The on-disk vocabulary is Codex-internal and version-shifting, so every field
|
|
22
|
+
* is read defensively and a weird line NEVER throws — it returns `[]`. Codex
|
|
23
|
+
* emits no per-line wire id, so `eventId`/`turnId` are null (hosts gate replay
|
|
24
|
+
* dedup on their own "not currently running" flag; see spec §9.7).
|
|
25
|
+
*/
|
|
26
|
+
export function codexLineToStreamEvents(
|
|
27
|
+
line: CodexTranscriptLine,
|
|
28
|
+
ctx: { sessionId: string | null },
|
|
29
|
+
): StreamEvent[] {
|
|
30
|
+
try {
|
|
31
|
+
return mapLine(line, ctx.sessionId);
|
|
32
|
+
} catch {
|
|
33
|
+
// Guardrail §9.5: never throw on a weird line.
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mapLine(line: CodexTranscriptLine, sessionId: string | null): StreamEvent[] {
|
|
39
|
+
const payload = line.payload;
|
|
40
|
+
// Flow's authoritative mapping only handles the wrapped format (payload
|
|
41
|
+
// present). Unwrapped legacy lines carry no reliable surface here → drop.
|
|
42
|
+
if (!payload) return [];
|
|
43
|
+
|
|
44
|
+
const base: BaseStreamEventFields = {
|
|
45
|
+
// "timestamp from the line or epoch-null fallback" (spec §5.4).
|
|
46
|
+
timestamp: line.timestamp ?? new Date(0).toISOString(),
|
|
47
|
+
providerType: "codex",
|
|
48
|
+
sessionId,
|
|
49
|
+
messageId: null,
|
|
50
|
+
eventId: null,
|
|
51
|
+
turnId: null,
|
|
52
|
+
parentToolCallId: null,
|
|
53
|
+
raw: line.raw,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const innerType = typeof payload["type"] === "string" ? (payload["type"] as string) : null;
|
|
57
|
+
|
|
58
|
+
if (line.type === "response_item") {
|
|
59
|
+
if (innerType === "message") {
|
|
60
|
+
// Only assistant messages surface; developer/user messages are
|
|
61
|
+
// system-prompt material we don't replay.
|
|
62
|
+
if (payload["role"] !== "assistant") return [];
|
|
63
|
+
return [{ type: "assistant", text: extractMessageText(payload["content"]) ?? "", ...base }];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (innerType === "reasoning") {
|
|
67
|
+
// Reasoning content may be empty / encrypted out-of-band; we still emit a
|
|
68
|
+
// `thinking` event (matching the live parser) — text is "" when the
|
|
69
|
+
// summary carries none.
|
|
70
|
+
return [{ type: "thinking", text: extractReasoningSummary(payload["summary"]) ?? "", ...base }];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (innerType === "function_call") {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
type: "tool_call",
|
|
77
|
+
toolCallId: str(payload["call_id"]) ?? str(payload["id"]),
|
|
78
|
+
name: str(payload["name"]) ?? "function_call",
|
|
79
|
+
input: parseToolArguments(payload["arguments"]) ?? str(payload["arguments"]) ?? null,
|
|
80
|
+
...base,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (innerType === "function_call_output") {
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
type: "tool_result",
|
|
89
|
+
toolCallId: str(payload["call_id"]),
|
|
90
|
+
// On-disk output carries no reliable name/error/exit signal; hosts
|
|
91
|
+
// correlate the name via the paired tool_call's call_id.
|
|
92
|
+
toolName: null,
|
|
93
|
+
content: extractOutputText(payload["output"]),
|
|
94
|
+
isError: false,
|
|
95
|
+
exitCode: null,
|
|
96
|
+
...base,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (line.type === "event_msg") {
|
|
105
|
+
if (innerType === "task_complete") {
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
type: "result",
|
|
109
|
+
text: str(payload["last_agent_message"]) ?? "",
|
|
110
|
+
costUsd: null,
|
|
111
|
+
isError: false,
|
|
112
|
+
stopReason: null,
|
|
113
|
+
terminalReason: "completed",
|
|
114
|
+
numTurns: null,
|
|
115
|
+
durationMs: null,
|
|
116
|
+
...base,
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Non-empty string or null. */
|
|
127
|
+
function str(v: unknown): string | null {
|
|
128
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `response_item/message.content` is an array of typed parts (`output_text`
|
|
133
|
+
* for assistant replies). Concat the text parts with a blank-line separator.
|
|
134
|
+
*/
|
|
135
|
+
function extractMessageText(content: unknown): string | null {
|
|
136
|
+
if (!Array.isArray(content)) return null;
|
|
137
|
+
const parts: string[] = [];
|
|
138
|
+
for (const entry of content) {
|
|
139
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
140
|
+
const block = entry as { text?: unknown };
|
|
141
|
+
if (typeof block.text === "string" && block.text.length > 0) parts.push(block.text);
|
|
142
|
+
}
|
|
143
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `response_item/reasoning.summary` is an array of `{type:"summary_text",
|
|
148
|
+
* text}` blocks; `content` is usually null and `encrypted_content` opaque, so
|
|
149
|
+
* the summary is the only readable representation.
|
|
150
|
+
*/
|
|
151
|
+
function extractReasoningSummary(summary: unknown): string | null {
|
|
152
|
+
if (!Array.isArray(summary)) return null;
|
|
153
|
+
const parts: string[] = [];
|
|
154
|
+
for (const entry of summary) {
|
|
155
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
156
|
+
const block = entry as { text?: unknown };
|
|
157
|
+
if (typeof block.text === "string" && block.text.length > 0) parts.push(block.text);
|
|
158
|
+
}
|
|
159
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `function_call_output.output` is usually a string, but some versions wrap it
|
|
164
|
+
* as `{ output: "...", metadata: {...} }`. Extract the readable text; fall back
|
|
165
|
+
* to a JSON dump so nothing is silently lost. Returns "" when unreadable
|
|
166
|
+
* (`tool_result.content` is a required string).
|
|
167
|
+
*/
|
|
168
|
+
function extractOutputText(output: unknown): string {
|
|
169
|
+
if (typeof output === "string") return output;
|
|
170
|
+
if (output && typeof output === "object" && !Array.isArray(output)) {
|
|
171
|
+
const inner = (output as Record<string, unknown>)["output"];
|
|
172
|
+
if (typeof inner === "string") return inner;
|
|
173
|
+
try {
|
|
174
|
+
return JSON.stringify(output);
|
|
175
|
+
} catch {
|
|
176
|
+
return "";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `function_call.arguments` is a JSON-encoded string (OpenAI function-call wire
|
|
184
|
+
* format). Parse to an object; tolerate malformed input (return null so the
|
|
185
|
+
* caller falls back to the raw string).
|
|
186
|
+
*/
|
|
187
|
+
function parseToolArguments(args: unknown): Record<string, unknown> | null {
|
|
188
|
+
if (typeof args !== "string" || args.length === 0) return null;
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(args);
|
|
191
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
192
|
+
? (parsed as Record<string, unknown>)
|
|
193
|
+
: null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex writes a durable JSONL rollout for every session under
|
|
3
|
+
* `<codexHome>/sessions/YYYY/MM/DD/rollout-<TIMESTAMP>-<sessionId>.jsonl`.
|
|
4
|
+
*
|
|
5
|
+
* Unlike Claude's project-keyed layout, Codex organizes rollouts by start
|
|
6
|
+
* date. The session ID is encoded at the end of the filename, after the
|
|
7
|
+
* launch timestamp. Locating a rollout by sessionId therefore requires a
|
|
8
|
+
* filename scan; there is no deterministic single-path-from-sessionId
|
|
9
|
+
* computation.
|
|
10
|
+
*
|
|
11
|
+
* On-disk format diverges from Codex's stream wire format. The wire format
|
|
12
|
+
* (handled by {@link parseCodexStreamLine}) emits either JSON-RPC
|
|
13
|
+
* notifications (`{method, params}`) or NDJSON events (`{type: "thread.started", ...}`).
|
|
14
|
+
* The on-disk format uses one of two shapes depending on Codex version:
|
|
15
|
+
*
|
|
16
|
+
* 1. Newer (≥0.10): `{timestamp, type: "session_meta"|"event_msg"|"response_item", payload: {...}}`
|
|
17
|
+
* 2. Older (pre-0.10): unwrapped — first line is `{id, timestamp, instructions}`,
|
|
18
|
+
* subsequent lines are `{type: "message"|"reasoning"|..., ...}` directly
|
|
19
|
+
*
|
|
20
|
+
* These helpers expose path discovery + raw-line streaming. Translating the
|
|
21
|
+
* on-disk types into `StreamEvent`s requires knowing the full Codex internal
|
|
22
|
+
* event vocabulary (which differs across versions and is not externally
|
|
23
|
+
* documented), so that work is left to consumers — they read structured raw
|
|
24
|
+
* lines and interpret payloads against the version they care about.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createReadStream } from "node:fs";
|
|
28
|
+
import { readdir, stat, open as fsOpen } from "node:fs/promises";
|
|
29
|
+
import * as os from "node:os";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import * as readline from "node:readline";
|
|
32
|
+
|
|
33
|
+
import { getDefaultRuntimeHome, getRuntimeHomeEnvVar } from "../../utils/runtime-homes.js";
|
|
34
|
+
import type { FoundTranscript, TranscriptOps } from "../../types.js";
|
|
35
|
+
|
|
36
|
+
/** Bytes scanned from the tail of the file in {@link peekCodexTranscript}. */
|
|
37
|
+
const PEEK_TAIL_BYTES = 16 * 1024;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve Codex's config home directory. Honors `$CODEX_HOME` first, falls
|
|
41
|
+
* back to `~/.codex`.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveCodexHome(override?: string): string {
|
|
44
|
+
if (override) return override;
|
|
45
|
+
const envVar = getRuntimeHomeEnvVar("codex");
|
|
46
|
+
const fromEnv = envVar ? process.env[envVar] : undefined;
|
|
47
|
+
return fromEnv ?? getDefaultRuntimeHome("codex") ?? path.join(os.homedir(), ".codex");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Path discovery
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export interface GetCodexTranscriptPathOptions {
|
|
55
|
+
/** Codex session ID (UUID). Matches the `id` field in the session_meta line. */
|
|
56
|
+
sessionId: string;
|
|
57
|
+
/** Override the Codex home. Defaults to `$CODEX_HOME` or `~/.codex`. */
|
|
58
|
+
codexHome?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Also search `<codexHome>/archived_sessions/`. Default `true`. Codex moves
|
|
61
|
+
* old rollouts here during cleanup; without this fallback, recovery of
|
|
62
|
+
* older sessions would fail.
|
|
63
|
+
*/
|
|
64
|
+
searchArchived?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CodexTranscriptLocation {
|
|
68
|
+
/** Absolute path to the rollout JSONL. */
|
|
69
|
+
filePath: string;
|
|
70
|
+
/** Which subtree it was found in. */
|
|
71
|
+
source: "active" | "archived";
|
|
72
|
+
/** The codex home that was searched. */
|
|
73
|
+
codexHome: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the literal cwd Codex was launched with, recovered from the first
|
|
78
|
+
* `session_meta` line in a rollout (or the legacy unwrapped first line for
|
|
79
|
+
* pre-0.10 transcripts).
|
|
80
|
+
*
|
|
81
|
+
* Returns `null` if the file has no recoverable cwd (truncated transcript,
|
|
82
|
+
* unrecognized format, etc.). Stops scanning after the first ~50 lines —
|
|
83
|
+
* `session_meta` is always the first line, but the older format may need a
|
|
84
|
+
* few lines to find the `environment_context` user_message with the cwd.
|
|
85
|
+
*/
|
|
86
|
+
export async function readCodexCwd(filePath: string): Promise<string | null> {
|
|
87
|
+
let count = 0;
|
|
88
|
+
for await (const { event } of readCodexTranscript({ filePath })) {
|
|
89
|
+
count++;
|
|
90
|
+
|
|
91
|
+
// Wrapped (≥0.10): first line is `{type: "session_meta", payload: {cwd}}`.
|
|
92
|
+
if (event.type === "session_meta" && event.payload) {
|
|
93
|
+
const cwd = event.payload["cwd"];
|
|
94
|
+
if (typeof cwd === "string" && cwd) return cwd;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Legacy unwrapped: cwd is buried inside a user `message` with an
|
|
98
|
+
// `environment_context` block. Two phrasings have shipped:
|
|
99
|
+
// 1. XML-style: `<environment_context><cwd>/path</cwd>...`
|
|
100
|
+
// 2. Plaintext: `<environment_context>\nCurrent working directory: /path\n...`
|
|
101
|
+
if (event.type === "message") {
|
|
102
|
+
const role = event.raw["role"];
|
|
103
|
+
const content = event.raw["content"];
|
|
104
|
+
if (role === "user" && Array.isArray(content)) {
|
|
105
|
+
for (const block of content) {
|
|
106
|
+
if (typeof block !== "object" || block === null) continue;
|
|
107
|
+
const b = block as Record<string, unknown>;
|
|
108
|
+
if (b["type"] !== "input_text") continue;
|
|
109
|
+
const text = typeof b["text"] === "string" ? b["text"] : "";
|
|
110
|
+
const xml = text.match(/<cwd>([^<]+)<\/cwd>/);
|
|
111
|
+
if (xml && xml[1]) return xml[1];
|
|
112
|
+
const plain = text.match(/Current working directory:\s*([^\n\r]+)/);
|
|
113
|
+
if (plain && plain[1]) return plain[1].trim();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (count >= 50) break;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Locate a Codex rollout file by session ID. Scans the date-organized tree
|
|
125
|
+
* under `<codexHome>/sessions/` in reverse-chronological order (newest first,
|
|
126
|
+
* since recent sessions are the common lookup target), then optionally
|
|
127
|
+
* `<codexHome>/archived_sessions/`.
|
|
128
|
+
*
|
|
129
|
+
* Returns `null` if no matching rollout is found. The session ID must be
|
|
130
|
+
* the exact UUID Codex assigned — partial matches are not accepted.
|
|
131
|
+
*/
|
|
132
|
+
export async function getCodexTranscriptPath(
|
|
133
|
+
opts: GetCodexTranscriptPathOptions,
|
|
134
|
+
): Promise<CodexTranscriptLocation | null> {
|
|
135
|
+
if (!opts.sessionId) throw new Error("getCodexTranscriptPath: sessionId is required");
|
|
136
|
+
|
|
137
|
+
const codexHome = resolveCodexHome(opts.codexHome);
|
|
138
|
+
const fileSuffix = `-${opts.sessionId}.jsonl`;
|
|
139
|
+
|
|
140
|
+
// Active sessions: newest-first walk.
|
|
141
|
+
const active = path.join(codexHome, "sessions");
|
|
142
|
+
const activeMatch = await findRolloutBySuffix(active, fileSuffix, /*reverseChrono*/ true);
|
|
143
|
+
if (activeMatch) return { filePath: activeMatch, source: "active", codexHome };
|
|
144
|
+
|
|
145
|
+
if (opts.searchArchived !== false) {
|
|
146
|
+
const archived = path.join(codexHome, "archived_sessions");
|
|
147
|
+
const archivedMatch = await findRolloutBySuffix(archived, fileSuffix, /*reverseChrono*/ false);
|
|
148
|
+
if (archivedMatch) return { filePath: archivedMatch, source: "archived", codexHome };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Walk a sessions root looking for a file whose name ends in `suffix`.
|
|
156
|
+
*
|
|
157
|
+
* Codex's `sessions/` is `YYYY/MM/DD/` — three layers of numeric directories.
|
|
158
|
+
* We probe top-down so we can short-circuit. `reverseChrono` flips each
|
|
159
|
+
* layer's traversal order to prioritize recent dates first. `archived_sessions/`
|
|
160
|
+
* is flat (no date layout), so we just scan its direct contents.
|
|
161
|
+
*/
|
|
162
|
+
async function findRolloutBySuffix(
|
|
163
|
+
root: string,
|
|
164
|
+
suffix: string,
|
|
165
|
+
reverseChrono: boolean,
|
|
166
|
+
): Promise<string | null> {
|
|
167
|
+
let entries;
|
|
168
|
+
try {
|
|
169
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Flat case (archived_sessions): files live directly in `root`.
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
177
|
+
return path.join(root, entry.name);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Date-tree case: scan YYYY/MM/DD/ ordered for fast recent-first lookup.
|
|
182
|
+
const dateDirs = entries.filter((e) => e.isDirectory() && /^\d{4}$/.test(e.name));
|
|
183
|
+
sortDirNamesChrono(dateDirs, reverseChrono);
|
|
184
|
+
|
|
185
|
+
for (const yearDir of dateDirs) {
|
|
186
|
+
const yearPath = path.join(root, yearDir.name);
|
|
187
|
+
let monthEntries;
|
|
188
|
+
try {
|
|
189
|
+
monthEntries = await readdir(yearPath, { withFileTypes: true });
|
|
190
|
+
} catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const months = monthEntries.filter((e) => e.isDirectory() && /^\d{2}$/.test(e.name));
|
|
194
|
+
sortDirNamesChrono(months, reverseChrono);
|
|
195
|
+
|
|
196
|
+
for (const monthDir of months) {
|
|
197
|
+
const monthPath = path.join(yearPath, monthDir.name);
|
|
198
|
+
let dayEntries;
|
|
199
|
+
try {
|
|
200
|
+
dayEntries = await readdir(monthPath, { withFileTypes: true });
|
|
201
|
+
} catch {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const days = dayEntries.filter((e) => e.isDirectory() && /^\d{2}$/.test(e.name));
|
|
205
|
+
sortDirNamesChrono(days, reverseChrono);
|
|
206
|
+
|
|
207
|
+
for (const dayDir of days) {
|
|
208
|
+
const dayPath = path.join(monthPath, dayDir.name);
|
|
209
|
+
let files;
|
|
210
|
+
try {
|
|
211
|
+
files = await readdir(dayPath, { withFileTypes: true });
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
for (const f of files) {
|
|
216
|
+
if (f.isFile() && f.name.endsWith(suffix)) {
|
|
217
|
+
return path.join(dayPath, f.name);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sortDirNamesChrono(entries: { name: string }[], reverse: boolean): void {
|
|
228
|
+
entries.sort((a, b) => (reverse ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Streaming raw-line reader
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Best-effort parsed view of a single Codex transcript line, normalized
|
|
237
|
+
* across the wrapped (≥0.10) and unwrapped (pre-0.10) on-disk formats.
|
|
238
|
+
*/
|
|
239
|
+
export interface CodexTranscriptLine {
|
|
240
|
+
/** Raw JSON-parsed object verbatim from the file. */
|
|
241
|
+
raw: Record<string, unknown>;
|
|
242
|
+
/**
|
|
243
|
+
* Outer wrapper type when present:
|
|
244
|
+
* - "session_meta", "event_msg", "response_item" — wrapped (≥0.10) lines
|
|
245
|
+
* - For unwrapped lines, falls back to the line's own `type` field
|
|
246
|
+
* (e.g., "message", "reasoning", "function_call_output").
|
|
247
|
+
* - `null` for lines with no `type` field (e.g., the older
|
|
248
|
+
* `{record_type: "state"}` markers or the bare-meta first line).
|
|
249
|
+
*/
|
|
250
|
+
type: string | null;
|
|
251
|
+
/** ISO timestamp from the line's `timestamp` field, or null if absent. */
|
|
252
|
+
timestamp: string | null;
|
|
253
|
+
/**
|
|
254
|
+
* Inner payload as a parsed object, when the wrapped format is used.
|
|
255
|
+
* `null` for unwrapped lines — in that case the meaningful fields are on
|
|
256
|
+
* {@link raw} directly.
|
|
257
|
+
*/
|
|
258
|
+
payload: Record<string, unknown> | null;
|
|
259
|
+
/**
|
|
260
|
+
* Replay-stable synthetic identity, set by {@link readCodexTranscript}:
|
|
261
|
+
* `codex:<rolloutSessionId>:<lineStartByteOffset>`. Codex emits no native
|
|
262
|
+
* per-event uuid, so this is the idempotency key hosts use to dedup
|
|
263
|
+
* transcript replays — deterministic across reads of the same file.
|
|
264
|
+
*
|
|
265
|
+
* `null` when a line is parsed standalone via {@link parseCodexLine} (no
|
|
266
|
+
* file/offset context). NOTE: live app-server events use a different
|
|
267
|
+
* synthetic scheme (`codex:<threadId>:<turnId>:<itemId>:<eventType>`) over a
|
|
268
|
+
* different wire vocabulary (`command_execution` vs `exec_command`), so ids
|
|
269
|
+
* do NOT match across the live and on-disk readers — cross-shape dedup
|
|
270
|
+
* remains a host concern.
|
|
271
|
+
*/
|
|
272
|
+
eventId: string | null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface ReadCodexTranscriptOptions {
|
|
276
|
+
/** Absolute path to the rollout JSONL. */
|
|
277
|
+
filePath: string;
|
|
278
|
+
/**
|
|
279
|
+
* Byte offset to resume from. Must be line-aligned. Use an offset previously
|
|
280
|
+
* yielded by this function. Defaults to 0 (start of file).
|
|
281
|
+
*/
|
|
282
|
+
fromOffset?: number;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface CodexTranscriptYield {
|
|
286
|
+
/**
|
|
287
|
+
* Parsed line, normalized for the consumer. Named `event` for symmetry
|
|
288
|
+
* with Claude's `readClaudeTranscript` and the polymorphic
|
|
289
|
+
* `provider.transcript.read` interface — both yield `{event, offset}`.
|
|
290
|
+
* The underlying type is provider-specific (`CodexTranscriptLine` here,
|
|
291
|
+
* `StreamEvent` for Claude).
|
|
292
|
+
*/
|
|
293
|
+
event: CodexTranscriptLine;
|
|
294
|
+
/**
|
|
295
|
+
* Byte offset immediately AFTER the trailing `\n` of this line. Pass back
|
|
296
|
+
* as {@link ReadCodexTranscriptOptions.fromOffset} to resume on the next line.
|
|
297
|
+
*/
|
|
298
|
+
offset: number;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Stream-read a Codex rollout JSONL, yielding parsed lines.
|
|
303
|
+
*
|
|
304
|
+
* Behavior:
|
|
305
|
+
* - Empty async iterable if the file doesn't exist (no throw).
|
|
306
|
+
* - Silently skips lines that fail to JSON.parse.
|
|
307
|
+
* - Does not interpret payloads — that's the consumer's responsibility,
|
|
308
|
+
* since the on-disk event vocabulary is version-specific.
|
|
309
|
+
*/
|
|
310
|
+
export async function* readCodexTranscript(
|
|
311
|
+
opts: ReadCodexTranscriptOptions,
|
|
312
|
+
): AsyncIterable<CodexTranscriptYield> {
|
|
313
|
+
const { filePath, fromOffset = 0 } = opts;
|
|
314
|
+
|
|
315
|
+
if (!(await pathExists(filePath))) return;
|
|
316
|
+
|
|
317
|
+
const stream = createReadStream(filePath, { start: fromOffset, encoding: undefined });
|
|
318
|
+
stream.on("error", () => {});
|
|
319
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
320
|
+
|
|
321
|
+
const fileIdentity = rolloutIdentityFromPath(filePath);
|
|
322
|
+
let pos = fromOffset;
|
|
323
|
+
try {
|
|
324
|
+
for await (const line of rl) {
|
|
325
|
+
const lineStart = pos;
|
|
326
|
+
pos += Buffer.byteLength(line, "utf8") + 1;
|
|
327
|
+
|
|
328
|
+
const trimmed = line.trim();
|
|
329
|
+
if (!trimmed) continue;
|
|
330
|
+
|
|
331
|
+
const parsed = parseCodexLine(trimmed);
|
|
332
|
+
if (!parsed) continue;
|
|
333
|
+
|
|
334
|
+
// Replay-stable synthetic identity: (rollout identity, line start offset).
|
|
335
|
+
// Codex emits no native per-event uuid, so this is the idempotency key
|
|
336
|
+
// hosts use to dedup transcript replays. Deterministic across reads.
|
|
337
|
+
parsed.eventId = `codex:${fileIdentity}:${lineStart}`;
|
|
338
|
+
|
|
339
|
+
yield { event: parsed, offset: pos };
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
const e = err as NodeJS.ErrnoException;
|
|
343
|
+
if (e?.code !== "ENOENT") throw err;
|
|
344
|
+
} finally {
|
|
345
|
+
rl.close();
|
|
346
|
+
stream.destroy();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Parse a single Codex transcript line into a normalized view. Returns
|
|
352
|
+
* `null` for lines that don't parse as JSON objects.
|
|
353
|
+
*
|
|
354
|
+
* Exported because consumers reading the file by other means (e.g. tailing
|
|
355
|
+
* a write stream) can use it to get the same shape this module yields.
|
|
356
|
+
*/
|
|
357
|
+
export function parseCodexLine(line: string): CodexTranscriptLine | null {
|
|
358
|
+
let obj: unknown;
|
|
359
|
+
try {
|
|
360
|
+
obj = JSON.parse(line);
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return null;
|
|
365
|
+
|
|
366
|
+
const raw = obj as Record<string, unknown>;
|
|
367
|
+
const type = typeof raw["type"] === "string" ? (raw["type"] as string) : null;
|
|
368
|
+
const timestamp = typeof raw["timestamp"] === "string" ? (raw["timestamp"] as string) : null;
|
|
369
|
+
|
|
370
|
+
let payload: Record<string, unknown> | null = null;
|
|
371
|
+
const rawPayload = raw["payload"];
|
|
372
|
+
if (typeof rawPayload === "object" && rawPayload !== null && !Array.isArray(rawPayload)) {
|
|
373
|
+
payload = rawPayload as Record<string, unknown>;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { raw, type, timestamp, payload, eventId: null };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Rollout filenames are `rollout-<TIMESTAMP>-<sessionId>.jsonl`; the session id
|
|
380
|
+
// is the trailing UUID. Fall back to the bare basename when it doesn't match
|
|
381
|
+
// (still deterministic for the same file).
|
|
382
|
+
const ROLLOUT_UUID_RE =
|
|
383
|
+
/-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
384
|
+
|
|
385
|
+
function rolloutIdentityFromPath(filePath: string): string {
|
|
386
|
+
const m = path.basename(filePath).match(ROLLOUT_UUID_RE);
|
|
387
|
+
return m ? m[1]! : path.basename(filePath, ".jsonl");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
391
|
+
try {
|
|
392
|
+
await stat(filePath);
|
|
393
|
+
return true;
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Peek (last line + size)
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
export interface CodexPeekResult {
|
|
404
|
+
/**
|
|
405
|
+
* Last successfully parsed line, or null if the file is empty/missing/unparseable.
|
|
406
|
+
* Named `lastEvent` for symmetry with Claude's `peekClaudeTranscript` and
|
|
407
|
+
* the polymorphic `provider.transcript.peek` interface.
|
|
408
|
+
*/
|
|
409
|
+
lastEvent: CodexTranscriptLine | null;
|
|
410
|
+
/** Total size of the file in bytes, or null if missing. */
|
|
411
|
+
size: number | null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Cheap drift-check: reads up to {@link PEEK_TAIL_BYTES} from the tail,
|
|
416
|
+
* walks back to the last parseable line, returns it plus the file size.
|
|
417
|
+
*/
|
|
418
|
+
export async function peekCodexTranscript(filePath: string): Promise<CodexPeekResult> {
|
|
419
|
+
let size: number;
|
|
420
|
+
try {
|
|
421
|
+
const s = await stat(filePath);
|
|
422
|
+
size = s.size;
|
|
423
|
+
} catch {
|
|
424
|
+
return { lastEvent: null, size: null };
|
|
425
|
+
}
|
|
426
|
+
if (size === 0) return { lastEvent: null, size: 0 };
|
|
427
|
+
|
|
428
|
+
const readBytes = Math.min(PEEK_TAIL_BYTES, size);
|
|
429
|
+
const start = size - readBytes;
|
|
430
|
+
const startedMidFile = start > 0;
|
|
431
|
+
|
|
432
|
+
let handle;
|
|
433
|
+
try {
|
|
434
|
+
handle = await fsOpen(filePath, "r");
|
|
435
|
+
} catch {
|
|
436
|
+
return { lastEvent: null, size };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const buf = Buffer.alloc(readBytes);
|
|
441
|
+
await handle.read(buf, 0, readBytes, start);
|
|
442
|
+
const text = buf.toString("utf8");
|
|
443
|
+
|
|
444
|
+
const lines = text.split("\n");
|
|
445
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
446
|
+
|
|
447
|
+
const minIdx = startedMidFile ? 1 : 0;
|
|
448
|
+
for (let i = lines.length - 1; i >= minIdx; i--) {
|
|
449
|
+
const raw = lines[i];
|
|
450
|
+
if (raw === undefined) continue;
|
|
451
|
+
const trimmed = raw.trim();
|
|
452
|
+
if (!trimmed) continue;
|
|
453
|
+
const parsed = parseCodexLine(trimmed);
|
|
454
|
+
if (!parsed) continue;
|
|
455
|
+
return { lastEvent: parsed, size };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { lastEvent: null, size };
|
|
459
|
+
} finally {
|
|
460
|
+
await handle.close();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// Polymorphic facade
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Polymorphic transcript ops for Codex. Delegates to the named functions
|
|
470
|
+
* above; mounted as `codexProvider.transcript`. The `cwd` hint to `find` is
|
|
471
|
+
* accepted for interface symmetry with Claude but is ignored — Codex
|
|
472
|
+
* rollouts are organized by date, not by cwd.
|
|
473
|
+
*/
|
|
474
|
+
export const codexTranscriptOps: TranscriptOps<CodexTranscriptLine> = {
|
|
475
|
+
async find(opts): Promise<FoundTranscript | null> {
|
|
476
|
+
const loc = await getCodexTranscriptPath({ sessionId: opts.sessionId });
|
|
477
|
+
if (!loc) return null;
|
|
478
|
+
const cwd = await readCodexCwd(loc.filePath);
|
|
479
|
+
return { filePath: loc.filePath, cwd };
|
|
480
|
+
},
|
|
481
|
+
read(opts) {
|
|
482
|
+
return readCodexTranscript(opts);
|
|
483
|
+
},
|
|
484
|
+
peek(filePath) {
|
|
485
|
+
return peekCodexTranscript(filePath);
|
|
486
|
+
},
|
|
487
|
+
};
|