@relayflows/sdk 2.0.26 → 2.0.28

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 (138) hide show
  1. package/dist/authored-flow-executor.d.ts +8 -1
  2. package/dist/authored-flow-executor.d.ts.map +1 -1
  3. package/dist/authored-flow-executor.js +16 -7
  4. package/dist/authored-flow-executor.js.map +1 -1
  5. package/dist/authored-flow-lifecycle.d.ts +68 -1
  6. package/dist/authored-flow-lifecycle.d.ts.map +1 -1
  7. package/dist/authored-flow-lifecycle.js +108 -10
  8. package/dist/authored-flow-lifecycle.js.map +1 -1
  9. package/dist/authored-flow-operation.d.ts +9 -1
  10. package/dist/authored-flow-operation.d.ts.map +1 -1
  11. package/dist/authored-flow-operation.js +7 -1
  12. package/dist/authored-flow-operation.js.map +1 -1
  13. package/dist/authored-node-entry.js +4 -0
  14. package/dist/authored-node-entry.js.map +1 -1
  15. package/dist/authored-node-runner.d.ts.map +1 -1
  16. package/dist/authored-node-runner.js +2 -1
  17. package/dist/authored-node-runner.js.map +1 -1
  18. package/dist/authored-promise-graph.d.ts +19 -4
  19. package/dist/authored-promise-graph.d.ts.map +1 -1
  20. package/dist/authored-promise-graph.js +44 -19
  21. package/dist/authored-promise-graph.js.map +1 -1
  22. package/dist/authored-root.d.ts +2 -0
  23. package/dist/authored-root.d.ts.map +1 -1
  24. package/dist/authored-root.js +2 -0
  25. package/dist/authored-root.js.map +1 -1
  26. package/dist/authored-step-graph.d.ts +83 -0
  27. package/dist/authored-step-graph.d.ts.map +1 -0
  28. package/dist/authored-step-graph.js +217 -0
  29. package/dist/authored-step-graph.js.map +1 -0
  30. package/dist/authored-step-index.d.ts +30 -0
  31. package/dist/authored-step-index.d.ts.map +1 -1
  32. package/dist/authored-step-index.js +26 -1
  33. package/dist/authored-step-index.js.map +1 -1
  34. package/dist/authored-step-output.d.ts +3 -0
  35. package/dist/authored-step-output.d.ts.map +1 -1
  36. package/dist/authored-step-output.js +4 -2
  37. package/dist/authored-step-output.js.map +1 -1
  38. package/dist/authored-worker-step.d.ts +3 -1
  39. package/dist/authored-worker-step.d.ts.map +1 -1
  40. package/dist/authored-worker-step.js +25 -6
  41. package/dist/authored-worker-step.js.map +1 -1
  42. package/dist/cli/direct-run.d.ts.map +1 -1
  43. package/dist/cli/direct-run.js +5 -2
  44. package/dist/cli/direct-run.js.map +1 -1
  45. package/dist/cli/run.d.ts +2 -0
  46. package/dist/cli/run.d.ts.map +1 -1
  47. package/dist/cli/run.js +9 -5
  48. package/dist/cli/run.js.map +1 -1
  49. package/dist/cli/step-excerpt.d.ts +48 -0
  50. package/dist/cli/step-excerpt.d.ts.map +1 -0
  51. package/dist/cli/step-excerpt.js +302 -0
  52. package/dist/cli/step-excerpt.js.map +1 -0
  53. package/dist/cli/step-failure.d.ts.map +1 -1
  54. package/dist/cli/step-failure.js +9 -20
  55. package/dist/cli/step-failure.js.map +1 -1
  56. package/dist/cli-commands.d.ts +8 -0
  57. package/dist/cli-commands.d.ts.map +1 -1
  58. package/dist/cli-commands.js +6 -0
  59. package/dist/cli-commands.js.map +1 -1
  60. package/dist/cli.d.ts +2 -0
  61. package/dist/cli.d.ts.map +1 -1
  62. package/dist/cli.js +38 -7
  63. package/dist/cli.js.map +1 -1
  64. package/dist/cloud-transcript-codex.d.ts +42 -0
  65. package/dist/cloud-transcript-codex.d.ts.map +1 -0
  66. package/dist/cloud-transcript-codex.js +411 -0
  67. package/dist/cloud-transcript-codex.js.map +1 -0
  68. package/dist/cloud-transcript-types.d.ts +161 -0
  69. package/dist/cloud-transcript-types.d.ts.map +1 -0
  70. package/dist/cloud-transcript-types.js +47 -0
  71. package/dist/cloud-transcript-types.js.map +1 -0
  72. package/dist/cloud-transcript.d.ts +2 -78
  73. package/dist/cloud-transcript.d.ts.map +1 -1
  74. package/dist/cloud-transcript.js +36 -38
  75. package/dist/cloud-transcript.js.map +1 -1
  76. package/dist/failure-kinds.d.ts +10 -2
  77. package/dist/failure-kinds.d.ts.map +1 -1
  78. package/dist/failure-kinds.js.map +1 -1
  79. package/dist/journal-client.d.ts +8 -2
  80. package/dist/journal-client.d.ts.map +1 -1
  81. package/dist/journal-client.js +22 -6
  82. package/dist/journal-client.js.map +1 -1
  83. package/dist/llm-worker.d.ts +2 -1
  84. package/dist/llm-worker.d.ts.map +1 -1
  85. package/dist/llm-worker.js +5 -2
  86. package/dist/llm-worker.js.map +1 -1
  87. package/dist/local-agent.d.ts +1 -1
  88. package/dist/local-agent.d.ts.map +1 -1
  89. package/dist/local-agent.js +8 -3
  90. package/dist/local-agent.js.map +1 -1
  91. package/dist/preflight.d.ts.map +1 -1
  92. package/dist/preflight.js +29 -3
  93. package/dist/preflight.js.map +1 -1
  94. package/dist/promise-ancestry.d.ts +20 -0
  95. package/dist/promise-ancestry.d.ts.map +1 -0
  96. package/dist/promise-ancestry.js +104 -0
  97. package/dist/promise-ancestry.js.map +1 -0
  98. package/dist/validate.d.ts.map +1 -1
  99. package/dist/validate.js +7 -0
  100. package/dist/validate.js.map +1 -1
  101. package/dist/worker-cli.d.ts +7 -0
  102. package/dist/worker-cli.d.ts.map +1 -1
  103. package/dist/worker-cli.js +42 -10
  104. package/dist/worker-cli.js.map +1 -1
  105. package/dist/worker-slots.d.ts +42 -0
  106. package/dist/worker-slots.d.ts.map +1 -0
  107. package/dist/worker-slots.js +94 -0
  108. package/dist/worker-slots.js.map +1 -0
  109. package/package.json +2 -2
  110. package/src/authored-flow-executor.ts +22 -3
  111. package/src/authored-flow-lifecycle.ts +111 -16
  112. package/src/authored-flow-operation.ts +10 -0
  113. package/src/authored-node-entry.ts +4 -1
  114. package/src/authored-node-runner.ts +2 -1
  115. package/src/authored-promise-graph.ts +46 -19
  116. package/src/authored-root.ts +4 -0
  117. package/src/authored-step-graph.ts +246 -0
  118. package/src/authored-step-index.ts +44 -1
  119. package/src/authored-step-output.ts +7 -3
  120. package/src/authored-worker-step.ts +25 -6
  121. package/src/cli/direct-run.ts +5 -2
  122. package/src/cli/run.ts +11 -4
  123. package/src/cli/step-excerpt.ts +332 -0
  124. package/src/cli/step-failure.ts +9 -20
  125. package/src/cli-commands.ts +6 -0
  126. package/src/cli.ts +40 -9
  127. package/src/cloud-transcript-codex.ts +429 -0
  128. package/src/cloud-transcript-types.ts +214 -0
  129. package/src/cloud-transcript.ts +44 -136
  130. package/src/failure-kinds.ts +10 -2
  131. package/src/journal-client.ts +25 -6
  132. package/src/llm-worker.ts +7 -2
  133. package/src/local-agent.ts +8 -2
  134. package/src/preflight.ts +37 -2
  135. package/src/promise-ancestry.ts +98 -0
  136. package/src/validate.ts +7 -0
  137. package/src/worker-cli.ts +41 -9
  138. package/src/worker-slots.ts +92 -0
@@ -0,0 +1,214 @@
1
+ // The entry vocabulary `flows logs --step` renders, and the pure helpers both
2
+ // provider parsers need.
3
+ //
4
+ // This module is a leaf: it imports nothing, so `cloud-transcript.ts` (the
5
+ // public entry point and the Claude vocabulary) and `cloud-transcript-codex.ts`
6
+ // can both depend on it without either depending on the other.
7
+ //
8
+ // The entry union is a public TypeScript API — `index.ts` re-exports it and
9
+ // `flows logs --json` emits it. Everything added for Codex is either a new
10
+ // member of the union or an optional field, so a Claude entry serializes to
11
+ // exactly the bytes it serialized to before. A consumer switching
12
+ // exhaustively over `kind` must still add the new members.
13
+
14
+ export interface TranscriptAttempt {
15
+ kind: 'attempt';
16
+ attempt: number | null;
17
+ bytes: number | null;
18
+ truncated: boolean;
19
+ }
20
+
21
+ export interface TranscriptAttemptOmitted {
22
+ kind: 'attempt_omitted';
23
+ attempt: number | null;
24
+ bytes: number | null;
25
+ }
26
+
27
+ export interface TranscriptInit {
28
+ kind: 'init';
29
+ model: string | null;
30
+ version: string | null;
31
+ permission_mode: string | null;
32
+ tools: number | null;
33
+ mcp_servers: number | null;
34
+ session_id: string | null;
35
+ }
36
+
37
+ export interface TranscriptMessage {
38
+ kind: 'message';
39
+ role: 'assistant' | 'user';
40
+ text: string;
41
+ /** A frame carrying `parent_tool_use_id`: a subagent's turn, not the main one. */
42
+ nested: boolean;
43
+ /**
44
+ * Codex only, and only when false: the message was read off an
45
+ * `item.started`/`item.updated` snapshot that never completed, so the text
46
+ * is what had been written so far and not the whole of it.
47
+ */
48
+ complete?: boolean;
49
+ }
50
+
51
+ export interface TranscriptThinking {
52
+ kind: 'thinking';
53
+ /** The count only. A thinking block's text and signature never reach the page. */
54
+ chars: number;
55
+ nested: boolean;
56
+ }
57
+
58
+ /**
59
+ * The Codex half of a tool call. Present on an entry parsed from a Codex
60
+ * `command_execution` or `mcp_tool_call` item and absent on every Claude
61
+ * entry, which is what the renderer discriminates on: a Codex failure has to
62
+ * keep its exit code, its status and its result size on the same line, where
63
+ * Claude's renderer replaces the size with `ERROR`.
64
+ */
65
+ export interface TranscriptToolCodex {
66
+ /** The call's position among the calls of its attempt, counted from 1. */
67
+ seq: number;
68
+ /** `in_progress`, `completed` or `failed`, as the item reported it. */
69
+ status: string | null;
70
+ /** Null when the item carried no exit code — not the same fact as exit 0. */
71
+ exit_code: number | null;
72
+ /** Redacted, then bounded. Null when the item carried no output field at all. */
73
+ output_excerpt: string | null;
74
+ /** True when a character or line bound cut the excerpt. */
75
+ output_truncated: boolean;
76
+ /** False when no `item.completed` ever arrived for this call. */
77
+ complete: boolean;
78
+ /** Redacted, then bounded. The item's own failure text. */
79
+ error: string | null;
80
+ }
81
+
82
+ export interface TranscriptTool {
83
+ kind: 'tool';
84
+ name: string;
85
+ /** What the call was aimed at: a path, a command, a pattern. Null when the input had no string. */
86
+ target: string | null;
87
+ /** Null when no `tool_result` answered it -- not the same fact as a zero-byte result. */
88
+ result_chars: number | null;
89
+ is_error: boolean;
90
+ nested: boolean;
91
+ /** Codex only. Its presence is what tells the renderer which shape to print. */
92
+ codex?: TranscriptToolCodex;
93
+ }
94
+
95
+ /** One `apply_patch` item: file activity, not a tool call, so it is not numbered. */
96
+ export interface TranscriptFileChange {
97
+ kind: 'file_change';
98
+ changes: Array<{ path: string | null; change: string | null }>;
99
+ /** Change elements that were not objects. Counted rather than dropped. */
100
+ malformed: number;
101
+ status: string | null;
102
+ complete: boolean;
103
+ }
104
+
105
+ export interface TranscriptThread {
106
+ kind: 'thread';
107
+ thread_id: string | null;
108
+ }
109
+
110
+ export interface TranscriptTurn {
111
+ kind: 'turn';
112
+ phase: 'started' | 'completed' | 'failed';
113
+ tokens_in: number | null;
114
+ tokens_out: number | null;
115
+ cache_read: number | null;
116
+ cache_creation: number | null;
117
+ reasoning_out: number | null;
118
+ }
119
+
120
+ /** A `turn.failed`, a top-level `error` frame, or an `error` item. */
121
+ export interface TranscriptError {
122
+ kind: 'error';
123
+ /** The frame this came from, so the reader knows what failed. */
124
+ source: string;
125
+ message: string;
126
+ }
127
+
128
+ export interface TranscriptResult {
129
+ kind: 'result';
130
+ is_error: boolean;
131
+ subtype: string | null;
132
+ duration_ms: number | null;
133
+ num_turns: number | null;
134
+ total_cost_usd: number | null;
135
+ tokens_in: number | null;
136
+ tokens_out: number | null;
137
+ cache_read: number | null;
138
+ cache_creation: number | null;
139
+ }
140
+
141
+ export interface TranscriptUnknown {
142
+ kind: 'unknown';
143
+ type: string;
144
+ chars: number;
145
+ }
146
+
147
+ export interface TranscriptUnparsed {
148
+ kind: 'unparsed';
149
+ text: string;
150
+ }
151
+
152
+ export type TranscriptEntry =
153
+ | TranscriptAttempt | TranscriptAttemptOmitted | TranscriptInit | TranscriptMessage
154
+ | TranscriptThinking | TranscriptTool | TranscriptFileChange | TranscriptThread
155
+ | TranscriptTurn | TranscriptError | TranscriptResult | TranscriptUnknown | TranscriptUnparsed;
156
+
157
+ export interface ParsedTranscript {
158
+ entries: TranscriptEntry[];
159
+ /** How many attempts said their head was cut to fit the log cap. */
160
+ truncated_attempts: number;
161
+ /** How many attempts were dropped whole to fit the log cap. */
162
+ omitted_attempts: number;
163
+ /**
164
+ * False when the log carried no frame either vocabulary this module knows --
165
+ * a v1 plain-terminal sandbox log, say. The caller prints it raw rather than
166
+ * claiming an empty transcript.
167
+ */
168
+ stream_json: boolean;
169
+ }
170
+
171
+ /** A command, an argument list or a `server/tool` pair on one line. */
172
+ export const TARGET_MAX_CHARS = 160;
173
+
174
+ /** A command's or a tool's output excerpt: enough to read, bounded either way. */
175
+ export const OUTPUT_MAX_CHARS = 1000;
176
+ export const OUTPUT_MAX_LINES = 10;
177
+
178
+ /** Provider-supplied failure text. Prose from the agent is the only uncapped string. */
179
+ export const ERROR_MAX_CHARS = 1000;
180
+
181
+ /** A path stays identifiable, so it is bounded far above a one-line target. */
182
+ export const PATH_MAX_CHARS = 200;
183
+
184
+ export function isRecord(value: unknown): value is Record<string, unknown> {
185
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
186
+ }
187
+
188
+ export function str(value: unknown): string | null {
189
+ return typeof value === 'string' && value.length > 0 ? value : null;
190
+ }
191
+
192
+ export function num(value: unknown): number | null {
193
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
194
+ }
195
+
196
+ /** Collapse to one line and bound it; a tool input can be a whole file body. */
197
+ export function oneLine(text: string, limit = TARGET_MAX_CHARS): string {
198
+ const flat = text.replace(/\s+/gu, ' ').trim();
199
+ return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`;
200
+ }
201
+
202
+ export function thousands(value: number): string {
203
+ return String(value).replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
204
+ }
205
+
206
+ /** Terminal control characters never reach the page, wherever the text came from. */
207
+ export function safe(text: string): string {
208
+ return text.replace(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/gu, '?');
209
+ }
210
+
211
+ /** One rule with a label, the way an attempt boundary reads. */
212
+ export function separator(label: string): string {
213
+ return `── ${label} ${'─'.repeat(Math.max(2, 68 - label.length))}`;
214
+ }
@@ -1,13 +1,16 @@
1
1
  // Rendering an agent step's transcript JSONL for a terminal.
2
2
  //
3
- // The frames are what the harness wrote (Claude Code's `stream-json`), wrapped
4
- // by the `relayflow.attempt` markers the v2 executor interleaves when it
5
- // assembles one log out of several attempts. The frame vocabulary read here is
6
- // the one Cloud's dashboard renderer reads (cloud#3847,
7
- // `packages/web/lib/workflows/agent-transcript.ts`) -- the same shapes, so the
8
- // CLI and the dashboard agree about what a run did. The implementation is not
9
- // shared: this package cannot import from the Cloud app, and a CLI that had to
10
- // be deployed in step with a dashboard would be worse than one that does not.
3
+ // The frames are what the harness wrote -- Claude Code's `stream-json`, or
4
+ // `codex exec --json` -- wrapped by the `relayflow.attempt` markers the v2
5
+ // executor interleaves when it assembles one log out of several attempts.
6
+ // The Claude frame vocabulary read here is the one Cloud's dashboard renderer
7
+ // reads (cloud#3847, `packages/web/lib/workflows/agent-transcript.ts`) -- the
8
+ // same shapes, so the CLI and the dashboard agree about what a run did. The
9
+ // implementation is not shared: this package cannot import from the Cloud app,
10
+ // and a CLI that had to be deployed in step with a dashboard would be worse
11
+ // than one that does not. The Codex vocabulary lives in
12
+ // `cloud-transcript-codex.ts`; dispatch is per frame, not per provider, so a
13
+ // log that mixes them stays honest rather than being forced into a guess.
11
14
  //
12
15
  // Two rules the shapes do not give you:
13
16
  //
@@ -19,98 +22,21 @@
19
22
  // the redactor `flows status` uses. Transcript text is whatever the agent
20
23
  // printed, including anything it read out of its own environment.
21
24
 
25
+ import {
26
+ CODEX_FRAME_TYPES, codexFrame, indexCodexItems, renderCodexEntry, type CodexState,
27
+ } from './cloud-transcript-codex.js';
28
+ import {
29
+ TARGET_MAX_CHARS, isRecord, num, oneLine, safe, separator, str, thousands,
30
+ type ParsedTranscript, type TranscriptEntry,
31
+ } from './cloud-transcript-types.js';
22
32
  import { redact } from './redact.js';
23
33
 
24
- export interface TranscriptAttempt {
25
- kind: 'attempt';
26
- attempt: number | null;
27
- bytes: number | null;
28
- truncated: boolean;
29
- }
30
-
31
- export interface TranscriptAttemptOmitted {
32
- kind: 'attempt_omitted';
33
- attempt: number | null;
34
- bytes: number | null;
35
- }
36
-
37
- export interface TranscriptInit {
38
- kind: 'init';
39
- model: string | null;
40
- version: string | null;
41
- permission_mode: string | null;
42
- tools: number | null;
43
- mcp_servers: number | null;
44
- session_id: string | null;
45
- }
46
-
47
- export interface TranscriptMessage {
48
- kind: 'message';
49
- role: 'assistant' | 'user';
50
- text: string;
51
- /** A frame carrying `parent_tool_use_id`: a subagent's turn, not the main one. */
52
- nested: boolean;
53
- }
54
-
55
- export interface TranscriptThinking {
56
- kind: 'thinking';
57
- /** The count only. A thinking block's text and signature never reach the page. */
58
- chars: number;
59
- nested: boolean;
60
- }
61
-
62
- export interface TranscriptTool {
63
- kind: 'tool';
64
- name: string;
65
- /** What the call was aimed at: a path, a command, a pattern. Null when the input had no string. */
66
- target: string | null;
67
- /** Null when no `tool_result` answered it -- not the same fact as a zero-byte result. */
68
- result_chars: number | null;
69
- is_error: boolean;
70
- nested: boolean;
71
- }
72
-
73
- export interface TranscriptResult {
74
- kind: 'result';
75
- is_error: boolean;
76
- subtype: string | null;
77
- duration_ms: number | null;
78
- num_turns: number | null;
79
- total_cost_usd: number | null;
80
- tokens_in: number | null;
81
- tokens_out: number | null;
82
- cache_read: number | null;
83
- cache_creation: number | null;
84
- }
85
-
86
- export interface TranscriptUnknown {
87
- kind: 'unknown';
88
- type: string;
89
- chars: number;
90
- }
91
-
92
- export interface TranscriptUnparsed {
93
- kind: 'unparsed';
94
- text: string;
95
- }
96
-
97
- export type TranscriptEntry =
98
- | TranscriptAttempt | TranscriptAttemptOmitted | TranscriptInit | TranscriptMessage
99
- | TranscriptThinking | TranscriptTool | TranscriptResult | TranscriptUnknown | TranscriptUnparsed;
100
-
101
- export interface ParsedTranscript {
102
- entries: TranscriptEntry[];
103
- /** How many attempts said their head was cut to fit the log cap. */
104
- truncated_attempts: number;
105
- /** How many attempts were dropped whole to fit the log cap. */
106
- omitted_attempts: number;
107
- /**
108
- * False when the log carried no frame this vocabulary knows -- a v1
109
- * plain-terminal sandbox log, say. The caller prints it raw rather than
110
- * claiming an empty transcript.
111
- */
112
- stream_json: boolean;
113
- }
34
+ export type {
35
+ ParsedTranscript, TranscriptEntry, TranscriptAttempt, TranscriptAttemptOmitted, TranscriptError,
36
+ TranscriptFileChange, TranscriptInit, TranscriptMessage, TranscriptResult, TranscriptThinking,
37
+ TranscriptThread, TranscriptTool, TranscriptToolCodex, TranscriptTurn, TranscriptUnknown,
38
+ TranscriptUnparsed,
39
+ } from './cloud-transcript-types.js';
114
40
 
115
41
  /**
116
42
  * Which input field names a tool call. Order matters: the first present
@@ -122,26 +48,6 @@ const TARGET_KEYS = [
122
48
  'prompt', 'description', 'subagent_type',
123
49
  ] as const;
124
50
 
125
- const TARGET_MAX_CHARS = 160;
126
-
127
- function isRecord(value: unknown): value is Record<string, unknown> {
128
- return typeof value === 'object' && value !== null && !Array.isArray(value);
129
- }
130
-
131
- function str(value: unknown): string | null {
132
- return typeof value === 'string' && value.length > 0 ? value : null;
133
- }
134
-
135
- function num(value: unknown): number | null {
136
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
137
- }
138
-
139
- /** Collapse to one line and bound it; a tool input can be a whole file body. */
140
- function oneLine(text: string, limit = TARGET_MAX_CHARS): string {
141
- const flat = text.replace(/\s+/gu, ' ').trim();
142
- return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`;
143
- }
144
-
145
51
  /**
146
52
  * `clean` runs before `oneLine`, not after, and the order is load-bearing.
147
53
  *
@@ -154,12 +60,12 @@ function toolTarget(input: unknown, clean: (text: string) => string): string | n
154
60
  if (!isRecord(input)) return null;
155
61
  for (const key of TARGET_KEYS) {
156
62
  const value = str(input[key]);
157
- if (value !== null) return oneLine(clean(value));
63
+ if (value !== null) return oneLine(clean(value), TARGET_MAX_CHARS);
158
64
  }
159
65
  // An unfamiliar tool still shows something rather than nothing.
160
66
  for (const value of Object.values(input)) {
161
67
  const text = str(value);
162
- if (text !== null) return oneLine(clean(text));
68
+ if (text !== null) return oneLine(clean(text), TARGET_MAX_CHARS);
163
69
  }
164
70
  return null;
165
71
  }
@@ -219,13 +125,15 @@ export function parseAgentTranscript(content: string, env: NodeJS.ProcessEnv = p
219
125
  }
220
126
  }
221
127
  const results = indexToolResults(frames);
128
+ const codexIndex = indexCodexItems(raw);
129
+ const codexState: CodexState = { seq: 0 };
222
130
  const entries: TranscriptEntry[] = [];
223
131
  let truncatedAttempts = 0;
224
132
  let omittedAttempts = 0;
225
133
  let streamJson = false;
226
134
  const clean = (text: string): string => redact(text, env);
227
135
 
228
- for (const { frame, line } of raw) {
136
+ for (const [index, { frame, line }] of raw.entries()) {
229
137
  if (frame === undefined || !isRecord(frame)) {
230
138
  entries.push({ kind: 'unparsed', text: clean(line) });
231
139
  continue;
@@ -233,6 +141,8 @@ export function parseAgentTranscript(content: string, env: NodeJS.ProcessEnv = p
233
141
  const type = str(frame['type']);
234
142
  if (type === 'relayflow.attempt') {
235
143
  streamJson = true;
144
+ // Calls are numbered within an attempt, so a second attempt starts over.
145
+ codexState.seq = 0;
236
146
  const truncated = frame['truncated'] === true;
237
147
  if (truncated) truncatedAttempts += 1;
238
148
  entries.push({ kind: 'attempt', attempt: num(frame['attempt']), bytes: num(frame['bytes']), truncated });
@@ -240,10 +150,16 @@ export function parseAgentTranscript(content: string, env: NodeJS.ProcessEnv = p
240
150
  }
241
151
  if (type === 'relayflow.attempt.omitted') {
242
152
  streamJson = true;
153
+ codexState.seq = 0;
243
154
  omittedAttempts += 1;
244
155
  entries.push({ kind: 'attempt_omitted', attempt: num(frame['attempt']), bytes: num(frame['bytes']) });
245
156
  continue;
246
157
  }
158
+ if (type !== null && CODEX_FRAME_TYPES.has(type)) {
159
+ streamJson = true;
160
+ entries.push(...(codexFrame(frame, type, index, codexIndex, codexState, clean, line) ?? []));
161
+ continue;
162
+ }
247
163
  if (type === 'system' && frame['subtype'] === 'init') {
248
164
  streamJson = true;
249
165
  const tools = frame['tools'];
@@ -327,28 +243,14 @@ export function parseAgentTranscript(content: string, env: NodeJS.ProcessEnv = p
327
243
  return { entries, truncated_attempts: truncatedAttempts, omitted_attempts: omittedAttempts, stream_json: streamJson };
328
244
  }
329
245
 
330
- function thousands(value: number): string {
331
- return String(value).replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
332
- }
333
-
334
246
  function seconds(ms: number): string {
335
247
  return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
336
248
  }
337
249
 
338
- /** Terminal control characters never reach the page, wherever the text came from. */
339
- function safe(text: string): string {
340
- return text.replace(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/gu, '?');
341
- }
342
-
343
250
  function dollars(value: number): string {
344
251
  return `$${value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '')}`;
345
252
  }
346
253
 
347
- /** One rule with a label, the way an attempt boundary reads. */
348
- function separator(label: string): string {
349
- return `── ${label} ${'─'.repeat(Math.max(2, 68 - label.length))}`;
350
- }
351
-
352
254
  /**
353
255
  * Render a parsed transcript as terminal lines.
354
256
  *
@@ -359,6 +261,11 @@ function separator(label: string): string {
359
261
  export function renderAgentTranscript(parsed: ParsedTranscript): string[] {
360
262
  const lines: string[] = [];
361
263
  for (const entry of parsed.entries) {
264
+ // A Codex tool call carries its exit code, its status and its result size
265
+ // on one line, which the Claude shape below has no room for; everything
266
+ // the Codex vocabulary owns is rendered there.
267
+ const codex = renderCodexEntry(entry);
268
+ if (codex !== undefined) { lines.push(...codex); continue; }
362
269
  switch (entry.kind) {
363
270
  case 'attempt': {
364
271
  const size = entry.bytes === null ? '' : ` · ${thousands(entry.bytes)} bytes`;
@@ -380,7 +287,8 @@ export function renderAgentTranscript(parsed: ParsedTranscript): string[] {
380
287
  break;
381
288
  }
382
289
  case 'message': {
383
- const who = `${entry.role}${entry.nested ? ' (subagent)' : ''}`;
290
+ const state = entry.complete === false ? ' (incomplete)' : '';
291
+ const who = `${entry.role}${entry.nested ? ' (subagent)' : ''}${state}`;
384
292
  const body = entry.text.split('\n');
385
293
  lines.push(`${who}:`);
386
294
  for (const line of body) lines.push(` ${safe(line)}`);
@@ -177,9 +177,17 @@ export interface StepFailedDetails {
177
177
  */
178
178
  maxIterations?: number;
179
179
  exitCode?: number;
180
- /** Terminal-safe UTF-8 excerpt, at most 1,024 bytes. */
180
+ /**
181
+ * Terminal-safe UTF-8 excerpt of the captured stdout, at most
182
+ * `EXCERPT_BYTES` (cli/step-excerpt.ts). The field name is kept for
183
+ * compatibility; it is no longer a plain tail. When the captured field did
184
+ * not fit, the excerpt keeps a head, the lines from the elided middle that
185
+ * match a failure marker, and a tail, and states the elision in band. The
186
+ * absence of an elision marker means the field the journal carried fit
187
+ * whole — not that the command printed no more than this.
188
+ */
181
189
  stdoutTail?: string;
182
- /** Terminal-safe UTF-8 excerpt, at most 1,024 bytes. */
190
+ /** Terminal-safe UTF-8 excerpt of the captured stderr; see `stdoutTail`. */
183
191
  stderrTail?: string;
184
192
  /**
185
193
  * The daemon's own account when it was not a render of the fields above, or
@@ -61,6 +61,8 @@ export class JournalProtocolError extends Error {
61
61
 
62
62
  export class JournalClient extends EventEmitter {
63
63
  private socket: Socket | null = null;
64
+ /** Why the connection ended, so a later "not connected" names its cause rather than hiding it. */
65
+ private disconnectCause: Error | undefined;
64
66
  private buffer = '';
65
67
  private readonly pending = new Map<string, Pending>();
66
68
  private readonly requestTimeoutMs: number;
@@ -103,18 +105,31 @@ export class JournalClient extends EventEmitter {
103
105
  socket.once('connect', () => {
104
106
  clearTimeout(timer);
105
107
  socket.removeListener('error', onError);
106
- socket.on('error', (err) => this.failAll(err));
108
+ socket.on('error', (err) => { this.disconnectCause ??= err; this.failAll(err); });
107
109
  socket.on('data', (chunk) => this.onData(chunk));
108
- socket.on('close', () => this.failAll(new Error('journal client: connection closed')));
110
+ socket.on('close', () => {
111
+ const closed = new Error('journal client: connection closed');
112
+ this.disconnectCause ??= closed;
113
+ this.failAll(closed);
114
+ });
115
+ this.disconnectCause = undefined;
109
116
  this.socket = socket;
110
117
  resolve();
111
118
  });
112
119
  });
113
120
  }
114
121
 
115
- /** Close the connection and reject any pending requests. */
116
- close(): void {
117
- this.failAll(new Error('journal client: closed by caller'));
122
+ /**
123
+ * Close the connection and reject any pending requests. A `cause` (for
124
+ * example the worker failure that forced the close) is carried into every
125
+ * later "not connected" rejection.
126
+ */
127
+ close(cause?: unknown): void {
128
+ const closed = cause === undefined
129
+ ? new Error('journal client: closed by caller')
130
+ : new Error(`journal client: closed after ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
131
+ this.disconnectCause ??= closed;
132
+ this.failAll(closed);
118
133
  this.socket?.destroy();
119
134
  this.socket = null;
120
135
  this.buffer = '';
@@ -170,7 +185,11 @@ export class JournalClient extends EventEmitter {
170
185
  ): Promise<VerbContract[V]['result']> {
171
186
  return new Promise((resolve, reject) => {
172
187
  if (!this.socket || this.socket.destroyed) {
173
- reject(new Error(`journal client: not connected (${verb})`));
188
+ const cause = this.disconnectCause;
189
+ reject(new Error(
190
+ `journal client: not connected (${verb})${cause === undefined ? '' : `: ${cause.message}`}`,
191
+ cause === undefined ? undefined : { cause },
192
+ ));
174
193
  return;
175
194
  }
176
195
  const id = randomUUID();
package/src/llm-worker.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { workerSpend } from './worker-spend.js';
2
+ import { DEFAULT_LOCAL_AGENT_CAPACITY } from './worker-slots.js';
2
3
  import type { WorkerCliResult } from './worker-cli.js';
3
4
  import { EventEmitter } from 'node:events';
4
5
  import type { JournalClient } from './journal-client.js';
@@ -17,7 +18,11 @@ export class LlmWorker extends EventEmitter {
17
18
  private closing = false;
18
19
  private readonly inFlight = new Set<Promise<void>>();
19
20
 
20
- constructor(private readonly client: JournalClient, private readonly workerId: string) {
21
+ constructor(
22
+ private readonly client: JournalClient,
23
+ private readonly workerId: string,
24
+ private readonly capacity: number = DEFAULT_LOCAL_AGENT_CAPACITY,
25
+ ) {
21
26
  super();
22
27
  }
23
28
 
@@ -25,7 +30,7 @@ export class LlmWorker extends EventEmitter {
25
30
  if (this.attached || this.closing) throw new Error('llm worker: cannot attach twice or after close');
26
31
  this.client.on('step.dispatch', this.onDispatch);
27
32
  try {
28
- await this.client.workerAttach(this.workerId, ['llm'], { workspace: [], streams: [] }, 1);
33
+ await this.client.workerAttach(this.workerId, ['llm'], { workspace: [], streams: [] }, this.capacity);
29
34
  this.attached = true;
30
35
  } catch (error) {
31
36
  this.client.off('step.dispatch', this.onDispatch);
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { JournalClient } from './journal-client.js';
3
3
  import { AgentWorker } from './worker.js';
4
+ import { DEFAULT_LOCAL_AGENT_CAPACITY } from './worker-slots.js';
4
5
 
5
6
  /** A local worker for stream-only steps; no workspace recovery is claimed. */
6
7
  export async function attachLocalAgent(
@@ -8,6 +9,7 @@ export async function attachLocalAgent(
8
9
  dataDir?: string,
9
10
  onPtyReady?: (path: string) => void,
10
11
  requestedStream?: string,
12
+ capacity: number = DEFAULT_LOCAL_AGENT_CAPACITY,
11
13
  ): Promise<{
12
14
  stream: string;
13
15
  readonly failure: unknown;
@@ -18,12 +20,16 @@ export async function attachLocalAgent(
18
20
  const stream = requestedStream ?? `local-agent-${randomUUID()}`;
19
21
  const worker = new AgentWorker(client, {
20
22
  workerId: stream,
21
- capacity: 1,
23
+ // More than one: independent agent steps run side by side instead of the
24
+ // second parking behind the first. Authored bodies size their admission to
25
+ // this same number (worker-slots.ts), so they never ask for more.
26
+ capacity,
22
27
  dataDir, onPtyReady,
23
28
  pins: { workspace: [], streams: [{ stream, read_offset: 0 }] },
24
29
  });
25
30
  let failure: unknown;
26
- worker.on('error', error => { failure = error; client.close(); });
31
+ // The cause travels with the close, so the flow's next request names it.
32
+ worker.on('error', error => { failure = error; client.close(error); });
27
33
  await worker.attach();
28
34
  return {
29
35
  stream,