@relayflows/sdk 2.0.25 → 2.0.27
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/dist/authored-flow-executor.d.ts +6 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +12 -1
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/babysitter-catalog-export.d.ts +9 -0
- package/dist/babysitter-catalog-export.d.ts.map +1 -0
- package/dist/babysitter-catalog-export.js +36 -0
- package/dist/babysitter-catalog-export.js.map +1 -0
- package/dist/cloud-transcript-codex.d.ts +42 -0
- package/dist/cloud-transcript-codex.d.ts.map +1 -0
- package/dist/cloud-transcript-codex.js +411 -0
- package/dist/cloud-transcript-codex.js.map +1 -0
- package/dist/cloud-transcript-types.d.ts +161 -0
- package/dist/cloud-transcript-types.d.ts.map +1 -0
- package/dist/cloud-transcript-types.js +47 -0
- package/dist/cloud-transcript-types.js.map +1 -0
- package/dist/cloud-transcript.d.ts +2 -78
- package/dist/cloud-transcript.d.ts.map +1 -1
- package/dist/cloud-transcript.js +36 -38
- package/dist/cloud-transcript.js.map +1 -1
- package/dist/failure-kinds.d.ts +2 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/flow-extension-loader.d.ts +31 -0
- package/dist/flow-extension-loader.d.ts.map +1 -1
- package/dist/flow-extension-loader.js +68 -0
- package/dist/flow-extension-loader.js.map +1 -1
- package/dist/plugin-manifest.d.ts +1 -1
- package/dist/plugin-manifest.d.ts.map +1 -1
- package/dist/plugin-manifest.js +1 -0
- package/dist/plugin-manifest.js.map +1 -1
- package/package.json +9 -4
- package/src/authored-flow-executor.ts +25 -1
- package/src/babysitter-catalog-export.ts +44 -0
- package/src/cloud-transcript-codex.ts +429 -0
- package/src/cloud-transcript-types.ts +214 -0
- package/src/cloud-transcript.ts +44 -136
- package/src/flow-extension-loader.ts +87 -0
- package/src/plugin-manifest.ts +1 -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
|
+
}
|
package/src/cloud-transcript.ts
CHANGED
|
@@ -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
|
|
4
|
-
// by the `relayflow.attempt` markers the v2
|
|
5
|
-
// assembles one log out of several attempts.
|
|
6
|
-
// the one Cloud's dashboard renderer
|
|
7
|
-
// `packages/web/lib/workflows/agent-transcript.ts`) -- the
|
|
8
|
-
// CLI and the dashboard agree about what a run did. The
|
|
9
|
-
// shared: this package cannot import from the Cloud app,
|
|
10
|
-
// be deployed in step with a dashboard would be worse
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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)}`);
|
|
@@ -99,6 +99,93 @@ function subscriptionOf(handler: TriggerHandler): { provider: string; event: str
|
|
|
99
99
|
return action === undefined ? { provider, event: type } : { provider, event: type, action };
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Server-authenticated integration delivery metadata. This is executor
|
|
104
|
+
* authority, not authored input: a caller must derive it from its verified
|
|
105
|
+
* delivery record and pass it out of band. User-controlled `input.event`
|
|
106
|
+
* objects never become this value.
|
|
107
|
+
*/
|
|
108
|
+
const HOSTED_EXTENSION_DISPATCH_AUTHORITY = Symbol('hosted-extension-dispatch-authority');
|
|
109
|
+
|
|
110
|
+
export type HostedExtensionDispatch = {
|
|
111
|
+
readonly [HOSTED_EXTENSION_DISPATCH_AUTHORITY]: true;
|
|
112
|
+
readonly provenance: 'integration-watch';
|
|
113
|
+
readonly provider: string;
|
|
114
|
+
readonly eventType: string;
|
|
115
|
+
readonly deliveryId: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type HostedEventIdentity = { readonly provider: string; readonly event: string; readonly action?: string };
|
|
119
|
+
const DISPATCH_PROVIDER = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
120
|
+
const DISPATCH_EVENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
121
|
+
const DISPATCH_DELIVERY = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
122
|
+
|
|
123
|
+
function hostedEventIdentity(dispatch: unknown): HostedEventIdentity {
|
|
124
|
+
if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) {
|
|
125
|
+
throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.');
|
|
126
|
+
}
|
|
127
|
+
const { provenance, provider, eventType, deliveryId } = dispatch as Partial<HostedExtensionDispatch>;
|
|
128
|
+
if ((dispatch as Partial<HostedExtensionDispatch>)[HOSTED_EXTENSION_DISPATCH_AUTHORITY] !== true
|
|
129
|
+
|| provenance !== 'integration-watch'
|
|
130
|
+
|| typeof provider !== 'string' || !DISPATCH_PROVIDER.test(provider)
|
|
131
|
+
|| typeof deliveryId !== 'string' || !DISPATCH_DELIVERY.test(deliveryId)
|
|
132
|
+
|| typeof eventType !== 'string') {
|
|
133
|
+
throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.');
|
|
134
|
+
}
|
|
135
|
+
const parts = eventType.split('.');
|
|
136
|
+
if ((parts.length !== 1 && parts.length !== 2) || parts.some(part => !DISPATCH_EVENT.test(part))) {
|
|
137
|
+
throw new PluginError('plugin_event_unroutable', `Hosted extension event ${JSON.stringify(eventType)} is malformed.`);
|
|
138
|
+
}
|
|
139
|
+
return parts.length === 1
|
|
140
|
+
? { provider, event: parts[0]! }
|
|
141
|
+
: { provider, event: parts[0]!, action: parts[1]! };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Brand metadata only after the host has authenticated the integration
|
|
146
|
+
* delivery. The symbol is deliberately not serializable, so copying a direct
|
|
147
|
+
* run's JSON into executor options cannot mint dispatch authority.
|
|
148
|
+
*/
|
|
149
|
+
export function hostedExtensionDispatchFromVerifiedDelivery(
|
|
150
|
+
delivery: Omit<HostedExtensionDispatch, typeof HOSTED_EXTENSION_DISPATCH_AUTHORITY | 'provenance'>,
|
|
151
|
+
): HostedExtensionDispatch {
|
|
152
|
+
const dispatch = Object.freeze({
|
|
153
|
+
[HOSTED_EXTENSION_DISPATCH_AUTHORITY]: true as const,
|
|
154
|
+
provenance: 'integration-watch' as const,
|
|
155
|
+
...delivery,
|
|
156
|
+
});
|
|
157
|
+
hostedEventIdentity(dispatch);
|
|
158
|
+
return dispatch;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve a server-authenticated delivery to one extension handler. Authored
|
|
163
|
+
* input is deliberately absent from this API: direct runs may contain any
|
|
164
|
+
* JSON shape and cannot opt themselves into extension execution. Overlapping
|
|
165
|
+
* subscriptions fail closed, including a generic event handler overlapping
|
|
166
|
+
* an action-specific handler.
|
|
167
|
+
*/
|
|
168
|
+
export function extensionHandlerForHostedDispatch(
|
|
169
|
+
dispatch: unknown,
|
|
170
|
+
extensions: readonly Pick<LoadedFlowExtension, 'name' | 'handlers'>[],
|
|
171
|
+
): { readonly extension: Pick<LoadedFlowExtension, 'name' | 'handlers'>; readonly handler: TriggerHandler } | undefined {
|
|
172
|
+
if (dispatch === undefined) return undefined;
|
|
173
|
+
const identity = hostedEventIdentity(dispatch);
|
|
174
|
+
const matches = extensions.flatMap(extension => extension.handlers.flatMap(handler => {
|
|
175
|
+
const subscription = subscriptionOf(handler);
|
|
176
|
+
if (subscription === undefined || subscription.provider !== identity.provider || subscription.event !== identity.event) return [];
|
|
177
|
+
if (subscription.action !== undefined && subscription.action !== identity.action) return [];
|
|
178
|
+
return [{ extension, handler }];
|
|
179
|
+
}));
|
|
180
|
+
if (matches.length > 1) {
|
|
181
|
+
throw new PluginError(
|
|
182
|
+
'plugin_event_ambiguous',
|
|
183
|
+
`Hosted event ${identity.provider}.${identity.event}${identity.action === undefined ? '' : `.${identity.action}`} matches multiple extension handlers (${matches.map(match => match.extension.name).join(', ')}).`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return matches[0];
|
|
187
|
+
}
|
|
188
|
+
|
|
102
189
|
function assertDeclaredSubscription(name: string, manifest: FlowExtensionManifest, handler: TriggerHandler, index: number): void {
|
|
103
190
|
const subscription = subscriptionOf(handler);
|
|
104
191
|
if (handler.trigger.kind === 'schedule') unsupported(name, `handler ${index} (a schedule trigger)`);
|
package/src/plugin-manifest.ts
CHANGED
|
@@ -7,6 +7,7 @@ export const PLUGIN_FAILURE_KINDS = [
|
|
|
7
7
|
'plugin_unsupported', 'plugin_credential_missing', 'plugin_server_unreachable',
|
|
8
8
|
// schema 2 flow extensions (see flow-extension-manifest.ts, plugin-source.ts, plugin-github.ts)
|
|
9
9
|
'plugin_kind_invalid', 'plugin_incompatible', 'plugin_event_unroutable', 'plugin_source_invalid',
|
|
10
|
+
'plugin_event_ambiguous',
|
|
10
11
|
'plugin_source_unresolved', 'plugin_fetch_failed', 'plugin_path_invalid', 'plugin_too_large',
|
|
11
12
|
'plugin_source_drift', 'plugin_lock_invalid',
|
|
12
13
|
] as const;
|