@elench/testkit 0.1.100 → 0.1.102

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 (38) hide show
  1. package/README.md +6 -3
  2. package/lib/cli/args.mjs +0 -19
  3. package/lib/cli/assistant/app.mjs +6 -0
  4. package/lib/cli/assistant/command-observer.mjs +75 -44
  5. package/lib/cli/assistant/command-results.mjs +29 -2
  6. package/lib/cli/assistant/context-pack.mjs +21 -1
  7. package/lib/cli/assistant/providers/claude.mjs +42 -7
  8. package/lib/cli/assistant/providers/codex.mjs +87 -9
  9. package/lib/cli/assistant/providers/events.mjs +71 -0
  10. package/lib/cli/assistant/providers/index.mjs +5 -4
  11. package/lib/cli/assistant/providers/shared.mjs +40 -21
  12. package/lib/cli/assistant/session.mjs +46 -8
  13. package/lib/cli/assistant/settings.mjs +29 -6
  14. package/lib/cli/assistant/state.mjs +181 -6
  15. package/lib/cli/assistant/transcript-text.mjs +35 -0
  16. package/lib/cli/assistant/view-model.mjs +11 -0
  17. package/lib/cli/command-flags.mjs +0 -3
  18. package/lib/cli/entrypoint.mjs +0 -2
  19. package/lib/cli/operations/run/operation.mjs +0 -3
  20. package/lib/runner/live-run.mjs +5 -1
  21. package/lib/runner/orchestrator.mjs +26 -26
  22. package/lib/runner/planning.mjs +0 -75
  23. package/lib/runner/provenance.mjs +20 -0
  24. package/lib/runner/reporting.mjs +14 -9
  25. package/lib/runner/run-finalization.mjs +5 -2
  26. package/lib/runner/run-guards.mjs +0 -1
  27. package/lib/runner/scheduler/estimates.mjs +61 -0
  28. package/lib/runner/scheduler/identity.mjs +31 -0
  29. package/lib/runner/scheduler/index.mjs +126 -0
  30. package/lib/runner/scheduler/observations.mjs +27 -0
  31. package/lib/runner/selection.mjs +1 -2
  32. package/lib/runner/worker-loop.mjs +3 -4
  33. package/lib/timing/index.mjs +33 -33
  34. package/node_modules/@elench/next-analysis/package.json +1 -1
  35. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  36. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  37. package/node_modules/@elench/ts-analysis/package.json +1 -1
  38. package/package.json +14 -8
package/README.md CHANGED
@@ -43,9 +43,6 @@ npx @elench/testkit --workers 8
43
43
  # One file-level wall clock budget for every suite file
44
44
  npx @elench/testkit --file-timeout-seconds 60
45
45
 
46
- # Run a deterministic shard
47
- npx @elench/testkit --shard 1/3
48
-
49
46
  # Specific service / suite
50
47
  npx @elench/testkit --service frontend --type pw -s navigation
51
48
  npx @elench/testkit --service api --type int -s health
@@ -126,6 +123,12 @@ output, emitted artifacts, and assistant-visible run state are persisted under
126
123
  run counts, pass/fail/skip counts, average duration, and last observed status,
127
124
  and those summaries are exposed in compact, verbose, and JSON discovery output.
128
125
 
126
+ Test execution also maintains a scheduler cache at `.testkit/timings.json`.
127
+ Completed file-level task durations are used to rank future runs with a
128
+ longest-estimated-duration-first policy, so slow files start earlier when
129
+ workers are available. Run artifacts include compact scheduler metadata under
130
+ `planning` so ordering decisions are inspectable.
131
+
129
132
  ## Automatic Regression Diagnosis
130
133
 
131
134
  If `regressions.file` is configured, every run automatically classifies observed
package/lib/cli/args.mjs CHANGED
@@ -58,25 +58,6 @@ export {
58
58
  parseWorkersOption,
59
59
  };
60
60
 
61
- export function parseShardOption(value) {
62
- if (!value) return null;
63
-
64
- const match = String(value).match(/^(\d+)\/(\d+)$/);
65
- if (!match) {
66
- throw new Error(
67
- `Invalid --shard value "${value}". Expected the form "i/n", e.g. 1/3.`
68
- );
69
- }
70
-
71
- const index = Number.parseInt(match[1], 10);
72
- const total = Number.parseInt(match[2], 10);
73
- if (index <= 0 || total <= 0 || index > total) {
74
- throw new Error(`Invalid --shard value "${value}". Expected 1 <= i <= n.`);
75
- }
76
-
77
- return { index, total };
78
- }
79
-
80
61
  export function resolveRequestedFiles(fileNames, productDir, invocationCwd = process.cwd()) {
81
62
  const resolved = [];
82
63
  const seen = new Set();
@@ -281,6 +281,9 @@ function colorWelcomeValue(label, value) {
281
281
  function colorMarker(block) {
282
282
  if (block.kind === "user") return cyan(block.marker);
283
283
  if (block.kind === "system") return red(block.marker);
284
+ if (block.kind === "provider-error") return red(block.marker);
285
+ if (block.kind === "provider-activity") return dim(block.marker);
286
+ if (block.kind === "provider-tool") return yellow(block.marker);
284
287
  if (block.kind === "tool-running") return yellow(block.marker);
285
288
  if (block.kind === "testkit-run") return green(block.marker);
286
289
  return block.marker;
@@ -289,6 +292,9 @@ function colorMarker(block) {
289
292
  function colorBlockText(block, text) {
290
293
  if (block.kind === "user") return text;
291
294
  if (block.kind === "system") return red(text);
295
+ if (block.kind === "provider-error") return red(text);
296
+ if (block.kind === "provider-activity") return dim(text);
297
+ if (block.kind === "provider-tool") return yellow(text);
292
298
  if (block.kind === "tool-running") return yellow(text);
293
299
  return text;
294
300
  }
@@ -1,9 +1,9 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { loadCurrentRunArtifact, loadLatestRunArtifact } from "../../results/artifacts.mjs";
4
3
 
5
4
  const POLL_INTERVAL_MS = 150;
6
5
  const OBSERVED_KINDS = new Set(["run", "discover", "status", "doctor", "typecheck"]);
6
+ const RUN_KINDS = new Set(["run", "int", "e2e", "scenario", "dal", "load", "pw", "all"]);
7
7
 
8
8
  export function createAssistantCommandObserver({
9
9
  productDir,
@@ -13,15 +13,16 @@ export function createAssistantCommandObserver({
13
13
  intervalMs = POLL_INTERVAL_MS,
14
14
  } = {}) {
15
15
  const seenResultFiles = new Set();
16
+ const seenCommandLogEvents = new Set();
17
+ const observedRunCommandIds = new Set();
16
18
  let timer = null;
17
19
  let running = false;
18
- let lastArtifactSignature = null;
19
- let preferLatestArtifact = false;
20
+ let lastArtifactSignatures = new Map();
20
21
 
21
22
  function start() {
22
23
  if (running) return;
23
24
  running = true;
24
- lastArtifactSignature = readArtifactSignature();
25
+ lastArtifactSignatures = readArtifactSignatures();
25
26
  scan();
26
27
  timer = setInterval(scan, intervalMs);
27
28
  }
@@ -34,10 +35,32 @@ export function createAssistantCommandObserver({
34
35
  }
35
36
 
36
37
  function scan() {
38
+ observeCommandLog();
37
39
  observeCommandResults();
38
40
  observeRunArtifact();
39
41
  }
40
42
 
43
+ function observeCommandLog() {
44
+ const commandLogPath = commandLog?.commandLogPath;
45
+ if (!commandLogPath || !fs.existsSync(commandLogPath)) return;
46
+ const lines = fs.readFileSync(commandLogPath, "utf8").split(/\r?\n/).filter(Boolean);
47
+ for (const [index, line] of lines.entries()) {
48
+ const eventKey = `${index}:${line}`;
49
+ if (seenCommandLogEvents.has(eventKey)) continue;
50
+ seenCommandLogEvents.add(eventKey);
51
+ let event = null;
52
+ try {
53
+ event = JSON.parse(line);
54
+ } catch {
55
+ continue;
56
+ }
57
+ if (event.sessionId && commandLog.sessionId && event.sessionId !== commandLog.sessionId) continue;
58
+ if (event.type === "command_start" && isRunCommand(event)) {
59
+ observedRunCommandIds.add(event.commandId);
60
+ }
61
+ }
62
+ }
63
+
41
64
  function observeCommandResults() {
42
65
  const resultDir = commandLog?.resultDir;
43
66
  if (!resultDir || !fs.existsSync(resultDir)) return;
@@ -54,10 +77,11 @@ export function createAssistantCommandObserver({
54
77
  } catch {
55
78
  continue;
56
79
  }
80
+ if (document.sessionId && commandLog.sessionId && document.sessionId !== commandLog.sessionId) continue;
57
81
  if (!OBSERVED_KINDS.has(document.kind)) continue;
58
82
  seenResultFiles.add(filePath);
59
83
  if (document.kind === "run") {
60
- preferLatestArtifact = true;
84
+ observedRunCommandIds.add(document.commandId);
61
85
  hydrateRunArtifact("command-result", document);
62
86
  }
63
87
  onEvent?.({
@@ -68,29 +92,34 @@ export function createAssistantCommandObserver({
68
92
  }
69
93
 
70
94
  function observeRunArtifact() {
71
- const signature = readArtifactSignature();
72
- if (!signature) return;
73
- if (signature === lastArtifactSignature) return;
74
- lastArtifactSignature = signature;
75
- hydrateRunArtifact("artifact", { artifactPath: signature.split(":")[0] });
76
- }
95
+ const signatures = readArtifactSignatures();
96
+ const changed = [...signatures.entries()]
97
+ .filter(([artifactPath, signature]) => lastArtifactSignatures.get(artifactPath) !== signature)
98
+ .map(([artifactPath, signature]) => ({
99
+ artifactPath,
100
+ signature,
101
+ mtimeMs: Number(signature.split(":").at(-2) || 0),
102
+ }))
103
+ .sort((left, right) => left.mtimeMs - right.mtimeMs);
104
+ lastArtifactSignatures = signatures;
77
105
 
78
- function readArtifactSignature() {
79
- const artifactPath = resolveObservableArtifactPath();
80
- if (!artifactPath) return null;
81
- const stat = fs.statSync(artifactPath);
82
- return `${artifactPath}:${stat.mtimeMs}:${stat.size}`;
106
+ for (const entry of changed) {
107
+ const artifact = readJsonFile(entry.artifactPath);
108
+ if (!artifact || !shouldHydrateObservedArtifact(artifact)) continue;
109
+ hydrateRunArtifact("artifact", { artifactPath: entry.artifactPath, artifact });
110
+ }
83
111
  }
84
112
 
85
- function resolveObservableArtifactPath() {
113
+ function readArtifactSignatures() {
86
114
  const livePath = path.join(productDir, ".testkit", "results", "live.json");
87
115
  const latestPath = path.join(productDir, ".testkit", "results", "latest.json");
88
- const liveStat = safeStat(livePath);
89
- const latestStat = safeStat(latestPath);
90
- if (preferLatestArtifact && latestStat) return latestPath;
91
- if (latestStat && (!liveStat || latestStat.mtimeMs >= liveStat.mtimeMs)) return latestPath;
92
- if (liveStat) return livePath;
93
- return null;
116
+ const signatures = new Map();
117
+ for (const artifactPath of [livePath, latestPath]) {
118
+ const stat = safeStat(artifactPath);
119
+ if (!stat) continue;
120
+ signatures.set(artifactPath, `${artifactPath}:${stat.mtimeMs}:${stat.size}`);
121
+ }
122
+ return signatures;
94
123
  }
95
124
 
96
125
  function hydrateRunArtifact(source, command = null) {
@@ -107,29 +136,31 @@ export function createAssistantCommandObserver({
107
136
 
108
137
  function loadObservedRunArtifact(command = null) {
109
138
  if (command?.result?.runArtifact) return command.result.runArtifact;
110
- if (command?.artifactPath && fs.existsSync(command.artifactPath)) {
111
- try {
112
- return JSON.parse(fs.readFileSync(command.artifactPath, "utf8"));
113
- } catch {
114
- return null;
115
- }
116
- }
117
- const artifactPath = resolveObservableArtifactPath();
118
- if (artifactPath) {
119
- try {
120
- return JSON.parse(fs.readFileSync(artifactPath, "utf8"));
121
- } catch {
122
- return null;
123
- }
124
- }
139
+ if (command?.artifact) return command.artifact;
140
+ if (command?.artifactPath && fs.existsSync(command.artifactPath)) return readJsonFile(command.artifactPath);
141
+ return null;
142
+ }
143
+
144
+ function shouldHydrateObservedArtifact(artifact) {
145
+ const assistant = artifact?.provenance?.assistant || {};
146
+ if (!assistant.sessionId || !assistant.commandId) return false;
147
+ if (commandLog?.sessionId && assistant.sessionId !== commandLog.sessionId) return false;
148
+ return observedRunCommandIds.has(assistant.commandId);
149
+ }
150
+
151
+ function isRunCommand(event) {
152
+ if (!event?.commandId) return false;
153
+ if (event.kind === "run") return true;
154
+ if (RUN_KINDS.has(event.kind)) return true;
155
+ const argv = Array.isArray(event.argv) ? event.argv : [];
156
+ return argv[0] === "run" || RUN_KINDS.has(argv[0]);
157
+ }
158
+
159
+ function readJsonFile(filePath) {
125
160
  try {
126
- return loadCurrentRunArtifact(productDir);
161
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
127
162
  } catch {
128
- try {
129
- return loadLatestRunArtifact(productDir);
130
- } catch {
131
- return null;
132
- }
163
+ return null;
133
164
  }
134
165
  }
135
166
 
@@ -7,6 +7,19 @@ export const ASSISTANT_COMMAND_LOG_ENV = "TESTKIT_ASSISTANT_COMMAND_LOG";
7
7
  export const ASSISTANT_COMMAND_ID_ENV = "TESTKIT_ASSISTANT_COMMAND_ID";
8
8
  export const ASSISTANT_WRAPPER_LOGGED_ENV = "TESTKIT_ASSISTANT_WRAPPER_LOGGED";
9
9
 
10
+ const RUN_SHORTCUTS = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
11
+ const FLAGS_WITH_VALUES = new Set([
12
+ "--dir",
13
+ "--service",
14
+ "--type",
15
+ "--suite",
16
+ "--file",
17
+ "--workers",
18
+ "--file-timeout-seconds",
19
+ "--seed",
20
+ "--output-mode",
21
+ ]);
22
+
10
23
  export function createAssistantCommandContext({
11
24
  kind,
12
25
  argv = process.argv.slice(2),
@@ -136,8 +149,22 @@ export function appendAssistantCommandLog(context, event) {
136
149
  }
137
150
 
138
151
  function inferCommandKind(argv) {
139
- const positionals = (Array.isArray(argv) ? argv : []).filter((arg) => !String(arg).startsWith("-"));
140
- return positionals[0] || "run";
152
+ const first = findFirstPositional(argv);
153
+ if (!first || RUN_SHORTCUTS.has(first)) return "run";
154
+ return first;
155
+ }
156
+
157
+ function findFirstPositional(argv) {
158
+ const args = Array.isArray(argv) ? argv : [];
159
+ for (let index = 0; index < args.length; index += 1) {
160
+ const arg = String(args[index]);
161
+ if (FLAGS_WITH_VALUES.has(arg)) {
162
+ index += 1;
163
+ continue;
164
+ }
165
+ if (!arg.startsWith("-")) return arg;
166
+ }
167
+ return null;
141
168
  }
142
169
 
143
170
  function inferExitCode(result) {
@@ -178,7 +178,27 @@ function appendCommandLog(event) {
178
178
  }
179
179
 
180
180
  function inferKind(args) {
181
- return args.find((arg) => !String(arg).startsWith("-")) || "run";
181
+ const runShortcuts = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
182
+ const flagsWithValues = new Set([
183
+ "--dir",
184
+ "--service",
185
+ "--type",
186
+ "--suite",
187
+ "--file",
188
+ "--workers",
189
+ "--file-timeout-seconds",
190
+ "--seed",
191
+ "--output-mode",
192
+ ]);
193
+ for (let index = 0; index < args.length; index += 1) {
194
+ const arg = String(args[index]);
195
+ if (flagsWithValues.has(arg)) {
196
+ index += 1;
197
+ continue;
198
+ }
199
+ if (!arg.startsWith("-")) return runShortcuts.has(arg) ? "run" : arg;
200
+ }
201
+ return "run";
182
202
  }
183
203
  `;
184
204
  }
@@ -4,14 +4,15 @@ import {
4
4
  buildStatusEvent,
5
5
  buildToolEvent,
6
6
  createHostedSessionRunner,
7
- extractTextFragments,
8
7
  } from "./shared.mjs";
8
+ import { providerAssistantDelta, providerAssistantFinal, providerToolEnd, providerToolStart } from "./events.mjs";
9
9
 
10
10
  export function startClaudeHostedSession({
11
11
  command = "claude",
12
12
  cwd,
13
13
  prompt,
14
14
  onEvent,
15
+ onRawLine,
15
16
  purpose = "assistant",
16
17
  model = null,
17
18
  effort = null,
@@ -49,6 +50,7 @@ export function startClaudeHostedSession({
49
50
  provider: "claude",
50
51
  child,
51
52
  onEvent,
53
+ onRawLine,
52
54
  parsePayload: parseClaudePayload,
53
55
  readFinalText(result) {
54
56
  return readClaudeFinalText(result?.stdout || "") || null;
@@ -77,7 +79,20 @@ export function parseClaudePayload(payload) {
77
79
  const streamEvent = payload.event || {};
78
80
  if (streamEvent.type === "content_block_delta" && streamEvent.delta?.type === "text_delta") {
79
81
  const text = String(streamEvent.delta.text || "");
80
- if (text) events.push({ type: "delta", text });
82
+ const event = providerAssistantDelta(text);
83
+ if (event) events.push(event);
84
+ return events;
85
+ }
86
+ if (streamEvent.type === "content_block_start" && streamEvent.content_block?.type === "tool_use") {
87
+ const tool = streamEvent.content_block;
88
+ const event = providerToolStart(
89
+ tool.name || tool.tool_name || "tool_use",
90
+ {
91
+ id: tool.id || streamEvent.index,
92
+ input: tool.input || null,
93
+ }
94
+ );
95
+ if (event) events.push(event);
81
96
  return events;
82
97
  }
83
98
  if (streamEvent.type === "tool_use" || streamEvent.content_block?.type === "tool_use") {
@@ -102,10 +117,9 @@ export function parseClaudePayload(payload) {
102
117
  }
103
118
 
104
119
  if (type === "assistant") {
105
- const fragments = [...new Set(extractTextFragments(payload.message?.content || payload.content || [], []))];
106
- for (const fragment of fragments) {
107
- events.push({ type: "delta", text: fragment });
108
- }
120
+ const fragments = extractClaudeTextFragments(payload.message?.content || payload.content || []);
121
+ const event = providerAssistantFinal(fragments.join(""));
122
+ if (event) events.push(event);
109
123
  return events;
110
124
  }
111
125
 
@@ -113,6 +127,10 @@ export function parseClaudePayload(payload) {
113
127
  if (payload.is_error || payload.subtype === "error") {
114
128
  const event = buildErrorEvent(payload.result || payload.error || "Claude command failed");
115
129
  if (event) events.push(event);
130
+ } else if (typeof payload.result === "string") {
131
+ const event = providerAssistantFinal(payload.result);
132
+ if (event) events.push(event);
133
+ events.push(providerToolEnd("claude", { status: "ok", durationMs: payload.duration_ms || null }));
116
134
  }
117
135
  return events;
118
136
  }
@@ -147,10 +165,27 @@ export function readClaudeFinalText(stdout) {
147
165
  }
148
166
 
149
167
  if (payload.type === "assistant") {
150
- const fragments = [...new Set(extractTextFragments(payload.message?.content || payload.content || [], []))];
168
+ const fragments = extractClaudeTextFragments(payload.message?.content || payload.content || []);
151
169
  if (fragments.length > 0) fallback = fragments.join("");
152
170
  }
153
171
  }
154
172
 
155
173
  return fallback;
156
174
  }
175
+
176
+ function extractClaudeTextFragments(content) {
177
+ const entries = Array.isArray(content) ? content : [content];
178
+ const fragments = [];
179
+ for (const entry of entries) {
180
+ if (typeof entry === "string") {
181
+ const text = entry.trim();
182
+ if (text) fragments.push(text);
183
+ continue;
184
+ }
185
+ if (!entry || typeof entry !== "object") continue;
186
+ if (entry.type !== "text") continue;
187
+ const text = String(entry.text || "").trim();
188
+ if (text) fragments.push(text);
189
+ }
190
+ return [...new Set(fragments)];
191
+ }
@@ -7,15 +7,16 @@ import {
7
7
  buildStatusEvent,
8
8
  buildToolEvent,
9
9
  createHostedSessionRunner,
10
- extractTextFragments,
11
10
  readTextFileIfPresent,
12
11
  } from "./shared.mjs";
12
+ import { providerAssistantDelta, providerAssistantFinal, providerToolEnd, providerToolStart, providerToolUpdate } from "./events.mjs";
13
13
 
14
14
  export function startCodexHostedSession({
15
15
  command = "codex",
16
16
  cwd,
17
17
  prompt,
18
18
  onEvent,
19
+ onRawLine,
19
20
  purpose = "assistant",
20
21
  model = null,
21
22
  providerArgs = [],
@@ -44,6 +45,7 @@ export function startCodexHostedSession({
44
45
  provider: "codex",
45
46
  child,
46
47
  onEvent,
48
+ onRawLine,
47
49
  parsePayload: parseCodexPayload,
48
50
  shouldIgnoreStatus(message) {
49
51
  return String(message || "").trim() === "Reading additional input from stdin...";
@@ -96,6 +98,45 @@ export function parseCodexPayload(payload) {
96
98
  return events;
97
99
  }
98
100
 
101
+ if (type === "thread.started") {
102
+ const event = buildStatusEvent(payload.thread_id ? `Codex thread ${payload.thread_id} started` : "Codex thread started");
103
+ if (event) events.push(event);
104
+ return events;
105
+ }
106
+
107
+ if (type === "turn.started") {
108
+ const event = buildStatusEvent("Codex turn started");
109
+ if (event) events.push(event);
110
+ return events;
111
+ }
112
+
113
+ if (type === "turn.completed") {
114
+ const event = providerToolEnd("codex", { status: "ok", usage: payload.usage || null });
115
+ if (event) events.push(event);
116
+ return events;
117
+ }
118
+
119
+ if (type === "item.started") {
120
+ const item = payload.item || {};
121
+ const event = codexItemStartedEvent(item);
122
+ if (event) events.push(event);
123
+ return events;
124
+ }
125
+
126
+ if (type === "item.updated") {
127
+ const item = payload.item || {};
128
+ const event = codexItemUpdatedEvent(item);
129
+ if (event) events.push(event);
130
+ return events;
131
+ }
132
+
133
+ if (type === "item.completed") {
134
+ const item = payload.item || {};
135
+ const event = codexItemCompletedEvent(item);
136
+ if (event) events.push(event);
137
+ return events;
138
+ }
139
+
99
140
  if (type && /(tool|command|patch|exec)/i.test(type)) {
100
141
  const event = buildToolEvent(
101
142
  payload.name || payload.tool_name || payload.command || type,
@@ -105,15 +146,52 @@ export function parseCodexPayload(payload) {
105
146
  return events;
106
147
  }
107
148
 
108
- const fragments = [...new Set(extractTextFragments(payload, []))];
109
- if (fragments.length > 0) {
110
- for (const fragment of fragments) {
111
- events.push({ type: "delta", text: fragment });
112
- }
113
- return events;
114
- }
115
-
116
149
  const statusEvent = buildStatusEvent(type ? `Codex event: ${type}` : JSON.stringify(payload));
117
150
  if (statusEvent) events.push(statusEvent);
118
151
  return events;
119
152
  }
153
+
154
+ function codexItemStartedEvent(item) {
155
+ if (!item || typeof item !== "object") return null;
156
+ if (item.type === "command_execution" || item.type === "tool_call") {
157
+ return providerToolStart(item.command || item.name || item.type, {
158
+ id: item.id || null,
159
+ input: item.arguments || item.input || null,
160
+ });
161
+ }
162
+ if (item.type === "agent_message") return buildStatusEvent("Codex started response");
163
+ if (item.type) return buildStatusEvent(`Codex started ${item.type}`);
164
+ return null;
165
+ }
166
+
167
+ function codexItemUpdatedEvent(item) {
168
+ if (!item || typeof item !== "object") return null;
169
+ if (item.type === "agent_message" && typeof item.text === "string") {
170
+ return providerAssistantDelta(item.text);
171
+ }
172
+ if (item.type === "command_execution" || item.type === "tool_call") {
173
+ return providerToolUpdate(item.command || item.name || item.type, {
174
+ id: item.id || null,
175
+ text: item.output || item.status || null,
176
+ data: item,
177
+ });
178
+ }
179
+ return null;
180
+ }
181
+
182
+ function codexItemCompletedEvent(item) {
183
+ if (!item || typeof item !== "object") return null;
184
+ if (item.type === "agent_message" && typeof item.text === "string") {
185
+ return providerAssistantFinal(item.text);
186
+ }
187
+ if (item.type === "command_execution" || item.type === "tool_call") {
188
+ return providerToolEnd(item.command || item.name || item.type, {
189
+ id: item.id || null,
190
+ status: item.status === "failed" ? "error" : "ok",
191
+ output: item.output || null,
192
+ data: item,
193
+ });
194
+ }
195
+ if (item.type) return buildStatusEvent(`Codex completed ${item.type}`);
196
+ return null;
197
+ }
@@ -0,0 +1,71 @@
1
+ export const PROVIDER_EVENT_TYPES = new Set([
2
+ "session-start",
3
+ "status",
4
+ "assistant-delta",
5
+ "assistant-final",
6
+ "tool-start",
7
+ "tool-update",
8
+ "tool-end",
9
+ "error",
10
+ "session-end",
11
+ ]);
12
+
13
+ export function providerEvent(type, fields = {}) {
14
+ if (!PROVIDER_EVENT_TYPES.has(type)) {
15
+ throw new Error(`Unknown provider event type: ${type}`);
16
+ }
17
+ return {
18
+ type,
19
+ ...fields,
20
+ };
21
+ }
22
+
23
+ export function providerStatus(text, fields = {}) {
24
+ const normalized = normalizeText(text);
25
+ if (!normalized) return null;
26
+ return providerEvent("status", { text: normalized, ...fields });
27
+ }
28
+
29
+ export function providerAssistantDelta(text, fields = {}) {
30
+ if (!text) return null;
31
+ return providerEvent("assistant-delta", { text: String(text), ...fields });
32
+ }
33
+
34
+ export function providerAssistantFinal(text, fields = {}) {
35
+ const normalized = normalizeText(text);
36
+ if (!normalized) return null;
37
+ return providerEvent("assistant-final", { text: normalized, ...fields });
38
+ }
39
+
40
+ export function providerToolStart(name, fields = {}) {
41
+ const normalized = normalizeText(name);
42
+ if (!normalized) return null;
43
+ return providerEvent("tool-start", { name: normalized, ...fields });
44
+ }
45
+
46
+ export function providerToolUpdate(name, fields = {}) {
47
+ const normalized = normalizeText(name);
48
+ if (!normalized && !fields.text && !fields.data) return null;
49
+ return providerEvent("tool-update", { ...(normalized ? { name: normalized } : {}), ...fields });
50
+ }
51
+
52
+ export function providerToolEnd(name, fields = {}) {
53
+ const normalized = normalizeText(name);
54
+ if (!normalized) return null;
55
+ return providerEvent("tool-end", { name: normalized, status: fields.status || "ok", ...fields });
56
+ }
57
+
58
+ export function providerError(text, fields = {}) {
59
+ const normalized = normalizeText(text);
60
+ if (!normalized) return null;
61
+ return providerEvent("error", { text: normalized, ...fields });
62
+ }
63
+
64
+ export function providerSessionEnd(fields = {}) {
65
+ return providerEvent("session-end", fields);
66
+ }
67
+
68
+ export function normalizeText(value) {
69
+ const text = String(value || "").trim();
70
+ return text || null;
71
+ }
@@ -3,7 +3,7 @@ import path from "path";
3
3
  import { startClaudeHostedSession } from "./claude.mjs";
4
4
  import { startCodexHostedSession } from "./codex.mjs";
5
5
 
6
- const PROVIDERS = ["codex", "claude"];
6
+ export const HOSTED_ASSISTANT_PROVIDERS = Object.freeze(["codex", "claude"]);
7
7
 
8
8
  export function resolvePreferredProvider(preferred = null, env = process.env) {
9
9
  if (preferred && preferred !== "auto") {
@@ -13,7 +13,7 @@ export function resolvePreferredProvider(preferred = null, env = process.env) {
13
13
  return preferred;
14
14
  }
15
15
 
16
- for (const provider of PROVIDERS) {
16
+ for (const provider of HOSTED_ASSISTANT_PROVIDERS) {
17
17
  if (isProviderInstalled(provider, env)) return provider;
18
18
  }
19
19
  throw new Error("Neither codex nor claude was found on PATH");
@@ -63,13 +63,14 @@ export function startProviderSession({
63
63
  cwd,
64
64
  prompt,
65
65
  onEvent,
66
+ onRawLine,
66
67
  purpose = "assistant",
67
68
  env = process.env,
68
69
  } = {}) {
69
70
  const resolvedProvider = resolvePreferredProvider(provider, env);
70
71
  const command = resolveProviderBinary(resolvedProvider, env);
71
72
  if (resolvedProvider === "claude") {
72
- return startClaudeHostedSession({ command, cwd, prompt, onEvent, purpose, model, effort, providerArgs, env });
73
+ return startClaudeHostedSession({ command, cwd, prompt, onEvent, onRawLine, purpose, model, effort, providerArgs, env });
73
74
  }
74
- return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose, model, providerArgs, env });
75
+ return startCodexHostedSession({ command, cwd, prompt, onEvent, onRawLine, purpose, model, providerArgs, env });
75
76
  }