@relayflows/sdk 2.0.21 → 2.0.22

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 (97) hide show
  1. package/dist/agent-transcript.d.ts +186 -0
  2. package/dist/agent-transcript.d.ts.map +1 -0
  3. package/dist/agent-transcript.js +585 -0
  4. package/dist/agent-transcript.js.map +1 -0
  5. package/dist/cli/run.js +2 -1
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/cli/status.d.ts +34 -0
  8. package/dist/cli/status.d.ts.map +1 -0
  9. package/dist/cli/status.js +307 -0
  10. package/dist/cli/status.js.map +1 -0
  11. package/dist/cli/step-failure.js +31 -7
  12. package/dist/cli/step-failure.js.map +1 -1
  13. package/dist/cli-commands.d.ts +13 -0
  14. package/dist/cli-commands.d.ts.map +1 -1
  15. package/dist/cli-commands.js +11 -0
  16. package/dist/cli-commands.js.map +1 -1
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +9 -0
  20. package/dist/cli.js.map +1 -1
  21. package/dist/failure-kinds.d.ts +15 -2
  22. package/dist/failure-kinds.d.ts.map +1 -1
  23. package/dist/failure-kinds.js +8 -0
  24. package/dist/failure-kinds.js.map +1 -1
  25. package/dist/journal-reader.d.ts +1 -1
  26. package/dist/journal-reader.d.ts.map +1 -1
  27. package/dist/journal-reader.js +3 -1
  28. package/dist/journal-reader.js.map +1 -1
  29. package/dist/llm-worker.d.ts.map +1 -1
  30. package/dist/llm-worker.js +12 -1
  31. package/dist/llm-worker.js.map +1 -1
  32. package/dist/permissions-preflight.d.ts +22 -0
  33. package/dist/permissions-preflight.d.ts.map +1 -0
  34. package/dist/permissions-preflight.js +49 -0
  35. package/dist/permissions-preflight.js.map +1 -0
  36. package/dist/preflight.d.ts.map +1 -1
  37. package/dist/preflight.js +6 -0
  38. package/dist/preflight.js.map +1 -1
  39. package/dist/pty-sidechannel.d.ts +7 -0
  40. package/dist/pty-sidechannel.d.ts.map +1 -1
  41. package/dist/pty-sidechannel.js.map +1 -1
  42. package/dist/redact.d.ts +16 -0
  43. package/dist/redact.d.ts.map +1 -0
  44. package/dist/redact.js +104 -0
  45. package/dist/redact.js.map +1 -0
  46. package/dist/run-state.d.ts +124 -0
  47. package/dist/run-state.d.ts.map +1 -0
  48. package/dist/run-state.js +368 -0
  49. package/dist/run-state.js.map +1 -0
  50. package/dist/spec.d.ts +16 -1
  51. package/dist/spec.d.ts.map +1 -1
  52. package/dist/spec.js.map +1 -1
  53. package/dist/step-env.d.ts +25 -0
  54. package/dist/step-env.d.ts.map +1 -0
  55. package/dist/step-env.js +34 -0
  56. package/dist/step-env.js.map +1 -0
  57. package/dist/transcript-tail.d.ts +54 -0
  58. package/dist/transcript-tail.d.ts.map +1 -0
  59. package/dist/transcript-tail.js +176 -0
  60. package/dist/transcript-tail.js.map +1 -0
  61. package/dist/worker-cli.d.ts +10 -0
  62. package/dist/worker-cli.d.ts.map +1 -1
  63. package/dist/worker-cli.js +158 -16
  64. package/dist/worker-cli.js.map +1 -1
  65. package/dist/worker-usage.d.ts +7 -2
  66. package/dist/worker-usage.d.ts.map +1 -1
  67. package/dist/worker-usage.js +21 -3
  68. package/dist/worker-usage.js.map +1 -1
  69. package/dist/worker.d.ts.map +1 -1
  70. package/dist/worker.js +31 -6
  71. package/dist/worker.js.map +1 -1
  72. package/dist/wrapper-runtime.d.ts +7 -1
  73. package/dist/wrapper-runtime.d.ts.map +1 -1
  74. package/dist/wrapper-runtime.js +7 -1
  75. package/dist/wrapper-runtime.js.map +1 -1
  76. package/package.json +2 -2
  77. package/src/agent-transcript.ts +651 -0
  78. package/src/cli/run.ts +2 -1
  79. package/src/cli/status.ts +329 -0
  80. package/src/cli/step-failure.ts +32 -7
  81. package/src/cli-commands.ts +11 -0
  82. package/src/cli.ts +8 -0
  83. package/src/failure-kinds.ts +15 -1
  84. package/src/journal-reader.ts +4 -2
  85. package/src/llm-worker.ts +12 -1
  86. package/src/permissions-preflight.ts +53 -0
  87. package/src/preflight.ts +6 -0
  88. package/src/pty-sidechannel.ts +7 -0
  89. package/src/redact.ts +106 -0
  90. package/src/run-state.ts +468 -0
  91. package/src/spec.ts +16 -1
  92. package/src/step-env.ts +47 -0
  93. package/src/transcript-tail.ts +193 -0
  94. package/src/worker-cli.ts +157 -17
  95. package/src/worker-usage.ts +26 -3
  96. package/src/worker.ts +32 -6
  97. package/src/wrapper-runtime.ts +7 -1
@@ -0,0 +1,329 @@
1
+ // `flows status`: what an agent can see about its own run, from disk.
2
+ //
3
+ // Read-only and offline. Opens exactly one file — the run's journal, through
4
+ // the same copy-then-verify `walkJournal` that `flows replay` uses — and never
5
+ // the registry, `connection.json`, the daemon socket or the network. There is
6
+ // no credential to hold because nothing here is authenticated: the journal is
7
+ // on the same filesystem as the process asking. Everything a hosted run knows
8
+ // about itself is in that file; whatever is not (its Cloud run id, its
9
+ // sandbox, its listener) is Cloud's to answer, and this verb does not guess.
10
+
11
+ import type { CliIo } from '../cli.js';
12
+ import { canonicalize } from '../canonical.js';
13
+ import { DEFAULT_DATA_DIR } from '../daemon-connection.js';
14
+ import { JournalReadError, walkJournal, type JournalEvent } from '../journal-client.js';
15
+ import { redact } from '../redact.js';
16
+ import { foldRunState, RunStateError, type RunView, type StepView } from '../run-state.js';
17
+ import { DATA_DIR_ENV, RUN_ID_ENV, STEP_ID_ENV } from '../step-env.js';
18
+
19
+ export interface StatusArgs {
20
+ command: 'status';
21
+ json: boolean;
22
+ dataDir?: string;
23
+ tail?: number;
24
+ runId?: string;
25
+ }
26
+
27
+ export const DEFAULT_TAIL_LINES = 20;
28
+ /** A gate's `detail` may be 2,000 chars (engine/remote.rs); the view shows the head. */
29
+ const DETAIL_LIMIT = 1024;
30
+ const BUSY_RETRIES = 5;
31
+ const BUSY_RETRY_DELAY_MS = 50;
32
+
33
+ /** One transcript tail on disk, already redacted, as `--tail` renders it. */
34
+ export interface TranscriptTail {
35
+ attempt: number;
36
+ bytes: number;
37
+ lines: string[];
38
+ }
39
+
40
+ export type StepTails = { stdout: TranscriptTail | null; stderr: TranscriptTail | null } | null;
41
+
42
+ /** How the verb finds transcript tails; `null` until the tail writer ships. */
43
+ export interface TailSource {
44
+ read(dataDir: string, runId: string, step: StepView, lines: number): Promise<StepTails>;
45
+ }
46
+
47
+ export interface StatusOptions {
48
+ env?: NodeJS.ProcessEnv;
49
+ now?: () => number;
50
+ tails?: TailSource;
51
+ sleep?: (ms: number) => Promise<void>;
52
+ }
53
+
54
+ export function parseStatusArgs(args: readonly string[]): StatusArgs | undefined {
55
+ let json = false;
56
+ let dataDir: string | undefined;
57
+ let tail: number | undefined;
58
+ const positionals: string[] = [];
59
+ for (let index = 0; index < args.length; index += 1) {
60
+ const argument = args[index]!;
61
+ if (argument === '--json') {
62
+ if (json) return undefined;
63
+ json = true;
64
+ } else if (argument === '--data-dir' || argument === '--tail') {
65
+ const value = args[++index];
66
+ if (value === undefined || value.length === 0 || value.startsWith('-')) return undefined;
67
+ if (argument === '--data-dir') {
68
+ if (dataDir !== undefined) return undefined;
69
+ dataDir = value;
70
+ } else {
71
+ if (tail !== undefined || !/^\d{1,4}$/.test(value)) return undefined;
72
+ tail = Number(value);
73
+ }
74
+ } else if (argument.startsWith('-')) {
75
+ return undefined;
76
+ } else {
77
+ positionals.push(argument);
78
+ }
79
+ }
80
+ if (positionals.length > 1) return undefined;
81
+ return {
82
+ command: 'status', json,
83
+ ...(dataDir === undefined ? {} : { dataDir }),
84
+ ...(tail === undefined ? {} : { tail }),
85
+ ...(positionals[0] === undefined ? {} : { runId: positionals[0] }),
86
+ };
87
+ }
88
+
89
+ interface Snapshot {
90
+ events: JournalEvent[];
91
+ partial: string[];
92
+ failure?: JournalReadError;
93
+ }
94
+
95
+ /**
96
+ * Take one consistent snapshot of the journal. `journal_busy` means a writer
97
+ * was mid-flight, so wait briefly and try again; any other mid-walk failure
98
+ * keeps the events read so far and reports the section as partial.
99
+ */
100
+ async function snapshot(runId: string, dataDir: string, sleep: (ms: number) => Promise<void>): Promise<Snapshot> {
101
+ for (let attempt = 1; ; attempt += 1) {
102
+ const events: JournalEvent[] = [];
103
+ try {
104
+ for await (const event of walkJournal(runId, dataDir)) events.push(event);
105
+ return { events, partial: [] };
106
+ } catch (error) {
107
+ const failure = error instanceof JournalReadError ? error
108
+ : new JournalReadError('journal_read_failed', error instanceof Error ? error.message : String(error));
109
+ if (failure.code === 'journal_busy' && attempt < BUSY_RETRIES) { await sleep(BUSY_RETRY_DELAY_MS); continue; }
110
+ return { events, partial: [failure.code], failure };
111
+ }
112
+ }
113
+ }
114
+
115
+ export async function runStatus(args: StatusArgs, io: CliIo, options: StatusOptions = {}): Promise<0 | 1 | 2> {
116
+ const env = options.env ?? process.env;
117
+ const now = options.now ?? Date.now;
118
+ const sleep = options.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
119
+ // Discovery: an explicit run id wins; otherwise the step's own identity from
120
+ // the environment the worker set. A shared data dir holds other people's
121
+ // runs, so there is no "list everything" mode to fall back to.
122
+ const runId = args.runId ?? env[RUN_ID_ENV];
123
+ if (runId === undefined || runId.length === 0) {
124
+ io.stderr(`REFUSED [run_unknown] pass <run-id> or run inside a step (${RUN_ID_ENV} unset)`);
125
+ return 2;
126
+ }
127
+ const dataDir = args.dataDir ?? env[DATA_DIR_ENV] ?? DEFAULT_DATA_DIR;
128
+ const thisStep = args.runId === undefined ? env[STEP_ID_ENV] ?? null : null;
129
+
130
+ const taken = await snapshot(runId, dataDir, sleep);
131
+ if (taken.events.length === 0) {
132
+ const failure = taken.failure ?? new JournalReadError('journal_read_failed', 'Journal holds no entries.');
133
+ io.stderr(`REFUSED [${failure.code}] ${failure.message}`);
134
+ return 2;
135
+ }
136
+ let view: RunView;
137
+ try {
138
+ view = foldRunState(taken.events, now());
139
+ } catch (error) {
140
+ const message = error instanceof RunStateError ? error.message : error instanceof Error ? error.message : String(error);
141
+ io.stderr(`REFUSED [run_state_invalid] ${message}`);
142
+ return 2;
143
+ }
144
+
145
+ // Tails are read for JSON (bounded, and the schema carries them) and for
146
+ // text only on request; a plain `flows status` opens the journal and nothing else.
147
+ const wantTails = args.json || args.tail !== undefined;
148
+ const tails = new Map<string, StepTails>();
149
+ if (options.tails !== undefined && wantTails) {
150
+ for (const step of view.steps) {
151
+ try {
152
+ tails.set(step.id, await options.tails.read(dataDir, runId, step, args.tail ?? DEFAULT_TAIL_LINES));
153
+ } catch {
154
+ if (!taken.partial.includes('transcript_tail_unreadable')) taken.partial.push('transcript_tail_unreadable');
155
+ tails.set(step.id, null);
156
+ }
157
+ }
158
+ }
159
+
160
+ const presented = present(view, thisStep, tails, env);
161
+ if (args.json) {
162
+ io.stdout(canonicalize({ v: 1, ...presented, partial: taken.partial }));
163
+ } else {
164
+ for (const line of renderText(presented, taken.partial, args.tail !== undefined)) io.stdout(line);
165
+ }
166
+ if (taken.failure !== undefined) io.stderr(`FAILED [${taken.failure.code}] ${taken.failure.message}`);
167
+ return taken.partial.length === 0 ? 0 : 1;
168
+ }
169
+
170
+ type PresentedStep = StepView & { tails: StepTails };
171
+ type Presented = Omit<RunView, 'steps'> & { this_step: string | null; steps: PresentedStep[] };
172
+
173
+ /** Apply the redaction and size bounds the on-disk model does not have. */
174
+ function present(view: RunView, thisStep: string | null, tails: Map<string, StepTails>, env: NodeJS.ProcessEnv): Presented {
175
+ return {
176
+ ...view,
177
+ this_step: thisStep,
178
+ steps: view.steps.map((step) => ({
179
+ ...step,
180
+ // An agent names the files it writes, and it inherits this process's
181
+ // environment, so a path is free text like any other here.
182
+ artifacts: { ...step.artifacts, paths: step.artifacts.paths.map((path) => redact(path, env)) },
183
+ last_attempt: step.last_attempt === null ? null : {
184
+ ...step.last_attempt,
185
+ verification: step.last_attempt.verification === null ? null : {
186
+ ...step.last_attempt.verification,
187
+ detail: redact(step.last_attempt.verification.detail, env).slice(0, DETAIL_LIMIT),
188
+ },
189
+ // The digest's strings were redacted when it was built (flows#491), by
190
+ // a redactor with a different rule set to this one. Redacting again on
191
+ // the way out costs nothing and closes the difference — this module is
192
+ // the one place that decides what reaches an agent-facing page.
193
+ transcript: step.last_attempt.transcript === null ? null : {
194
+ ...step.last_attempt.transcript,
195
+ path: step.last_attempt.transcript.path === null
196
+ ? null : redact(step.last_attempt.transcript.path, env),
197
+ model: step.last_attempt.transcript.model === null
198
+ ? null : redact(step.last_attempt.transcript.model, env),
199
+ failure: step.last_attempt.transcript.failure === null ? null : {
200
+ ...step.last_attempt.transcript.failure,
201
+ excerpt: redact(step.last_attempt.transcript.failure.excerpt, env).slice(0, DETAIL_LIMIT),
202
+ },
203
+ },
204
+ },
205
+ tails: tails.get(step.id) ?? null,
206
+ })),
207
+ };
208
+ }
209
+
210
+ const GLYPH: Record<StepView['state'], string> = {
211
+ pending: '○', runnable: '○', running: '↻', backoff: '↻', waiting: '⏸', needs_human: '⏸', done: '✓',
212
+ };
213
+
214
+ /** Agent-authored names cannot inject terminal control sequences (progress.ts). */
215
+ function safe(name: string): string {
216
+ return name.replace(/[\x00-\x1f\x7f-\x9f]/g, '?');
217
+ }
218
+
219
+ export function formatDuration(ms: number): string {
220
+ const seconds = Math.max(0, ms) / 1000;
221
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
222
+ const minutes = Math.floor(seconds / 60);
223
+ if (minutes < 60) return `${minutes}m${String(Math.floor(seconds % 60)).padStart(2, '0')}s`;
224
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
225
+ }
226
+
227
+ function thousands(value: number): string {
228
+ return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
229
+ }
230
+
231
+ function instant(ms: number): string {
232
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
233
+ }
234
+
235
+ function renderText(view: Presented, partial: string[], wantTails: boolean): string[] {
236
+ const lines: string[] = [];
237
+ const spend = `${thousands(view.spend.tokens_in)} in / ${thousands(view.spend.tokens_out)} out / $${view.spend.dollars}`
238
+ + (view.spend.dollars_unmetered ? ' (unmetered)' : '');
239
+ const finished = view.completion_reason === null ? '' : ` finished ${view.completion_reason}`;
240
+ lines.push(`RUN ${view.run_id} ${safe(view.name)} ${view.status} started ${formatDuration(view.now_ms - view.spawned_at_ms)} ago${finished} spend ${spend}`);
241
+ const counts = (['done', 'running', 'pending', 'backoff', 'waiting', 'needs_human'] as const)
242
+ .filter((key) => view.counts[key] > 0).map((key) => `${view.counts[key]} ${key}`);
243
+ // The denominator is always printed, even for zero steps, so a reader can
244
+ // tell "nothing to show" from "nothing was read".
245
+ lines.push(`steps ${view.counts.total}${counts.length === 0 ? '' : `: ${counts.join(' · ')}`}`);
246
+ if (partial.length > 0) lines.push(`partial: ${partial.join(', ')}`);
247
+ lines.push('');
248
+ const idWidth = Math.max(4, ...view.steps.map((step) => Math.min(24, step.id.length)));
249
+ for (const step of view.steps) lines.push(...renderStep(step, view, idWidth, wantTails));
250
+ return lines;
251
+ }
252
+
253
+ function renderStep(step: PresentedStep, view: Presented, idWidth: number, wantTails: boolean): string[] {
254
+ // `completion_reason`, not `last_attempt`: an epoch summary carries the
255
+ // reason without an attempt record, and reading the latter marked every
256
+ // compacted success as a failure.
257
+ const failed = step.state === 'done' && step.completion_reason !== 'success';
258
+ const glyph = failed ? '✗' : GLYPH[step.state];
259
+ const cells = [safe(step.id).slice(0, 24).padEnd(idWidth), step.type.padEnd(13), step.state.padEnd(11)];
260
+ const iterations = step.max_iterations === null ? '' : `/${step.max_iterations}`;
261
+ // `> 0` matters for a step carried done by an epoch summary: the summary
262
+ // has no attempt count, so the fold leaves it 0 and printing "0 attempts"
263
+ // would state a fact the journal no longer holds. Blank, like every other
264
+ // state does at 0.
265
+ if (step.state === 'done' && step.attempt > 0) cells.push(`${step.attempt} attempt${step.attempt === 1 ? '' : 's'}`);
266
+ else if (step.attempt > 0) cells.push(`attempt ${step.attempt}${iterations}`);
267
+ if (step.elapsed_ms !== null) cells.push(formatDuration(step.elapsed_ms));
268
+ if (step.state === 'running' && step.lease !== null) {
269
+ cells.push(step.lease.overdue_ms > 0
270
+ ? `LEASE OVERDUE by ${formatDuration(step.lease.overdue_ms)}`
271
+ : `lease ok (expires in ${formatDuration(step.lease.deadline_ms - view.now_ms)})`);
272
+ }
273
+ if (step.state === 'backoff' && step.backoff_until_ms !== null) cells.push(`backoff until ${instant(step.backoff_until_ms)}`);
274
+ if (step.wait !== null) cells.push(`awaiting ${step.wait.kind}: ${safe(step.wait.wait_id)}`);
275
+ if (step.state === 'done' && step.completion_reason !== null) {
276
+ // From the step, not the attempt: an epoch-carried step has the reason
277
+ // and no attempt record, and the reason is the thing worth printing.
278
+ cells.push(step.completion_reason);
279
+ const verification = step.last_attempt?.verification ?? null;
280
+ if (verification !== null) cells.push(`gate: ${verification.gate} ${verification.verdict}`);
281
+ }
282
+ if (step.id === view.this_step) cells.push('← this step');
283
+ const lines = [` ${glyph} ${cells.join(' ')}`.trimEnd()];
284
+
285
+ const last = step.last_attempt;
286
+ if (last !== null && step.state !== 'done') {
287
+ lines.push(` last attempt ${last.attempt}: ${last.completion_reason} → ${last.disposition}`);
288
+ }
289
+ if (last?.verification !== null && last?.verification !== undefined && last.verification.verdict !== 'pass') {
290
+ lines.push(` gate: ${last.verification.gate} ${last.verification.verdict.toUpperCase()} — ${JSON.stringify(last.verification.detail)}`);
291
+ }
292
+ const transcript = last?.transcript ?? null;
293
+ if (transcript !== null) {
294
+ // One line of provenance: what ran, how much it cost, and where the full
295
+ // transcript is. `flows status` never prints the transcript itself — that
296
+ // is `--tail`'s job for the live attempt, and the file's for a finished one.
297
+ const facts = [
298
+ transcript.model === null ? null : safe(transcript.model),
299
+ transcript.num_turns === null ? null : `${transcript.num_turns} turn${transcript.num_turns === 1 ? '' : 's'}`,
300
+ transcript.tool_calls === null ? null : `${transcript.tool_calls} tool call${transcript.tool_calls === 1 ? '' : 's'}`,
301
+ transcript.total_cost_usd === null ? null : `$${transcript.total_cost_usd}`,
302
+ ].filter((fact): fact is string => fact !== null);
303
+ const size = transcript.bytes === null ? '' : ` (${transcript.bytes} bytes${transcript.truncated ? ', truncated' : ''})`;
304
+ lines.push(` transcript (attempt ${last!.attempt})${facts.length === 0 ? '' : `: ${facts.join(' · ')}`}`);
305
+ if (transcript.path !== null) lines.push(` ${safe(transcript.path)}${size}`);
306
+ if (transcript.failure !== null && transcript.failure.excerpt.length > 0) {
307
+ lines.push(` failed at ${safe(transcript.failure.kind)}: ${JSON.stringify(transcript.failure.excerpt)}`);
308
+ }
309
+ }
310
+ if (step.type === 'agent' && last !== null) {
311
+ const shown = step.artifacts.paths.slice(0, 10).map(safe).join(', ');
312
+ const more = step.artifacts.paths.length > 10 ? ` +${step.artifacts.paths.length - 10} more` : '';
313
+ lines.push(` artifacts (attempt ${last.attempt}): ${step.artifacts.journaled
314
+ ? step.artifacts.paths.length === 0 ? 'none' : `${shown}${more}`
315
+ : 'none journaled (output was a JSON object)'}`);
316
+ }
317
+ if (wantTails && step.type === 'agent' && step.attempt > 0) {
318
+ for (const stream of ['stdout', 'stderr'] as const) {
319
+ const tail = step.tails?.[stream] ?? null;
320
+ if (tail === null) {
321
+ lines.push(` ${stream} tail: no transcript on disk for attempt ${step.attempt}`);
322
+ continue;
323
+ }
324
+ lines.push(` ${stream} tail (attempt ${tail.attempt}, last ${tail.lines.length} of ${tail.bytes} bytes):`);
325
+ for (const line of tail.lines) lines.push(` ${safe(line)}`);
326
+ }
327
+ }
328
+ return lines;
329
+ }
@@ -108,28 +108,53 @@ export function inspectionHint(
108
108
  * it does not. A truncated render (the daemon caps at 2,000 chars and appends
109
109
  * a truncation note) will not parse; that falls through to the raw string,
110
110
  * which is still the account of what went wrong.
111
+ *
112
+ * An agent or llm completion now also carries `trajectory_tail.transcript`
113
+ * (agent-transcript.ts) on every attempt. That object is not process-shaped,
114
+ * so it must not shadow the render that is: the first candidate that carries
115
+ * an exit code or a tail wins. The digest contributes what only it has — the
116
+ * failure excerpt the worker picked out of the provider's frames, and the
117
+ * path of the transcript file.
111
118
  */
112
119
  function evidence(payload: Record<string, unknown>): Partial<StepFailedDetails> {
113
120
  const detail = record(payload['verification'])?.['detail'];
114
- const structured = record(payload['output'])
115
- ?? record(payload['trajectory_tail'])
116
- ?? (typeof detail === 'string' ? parsed(detail) : undefined);
121
+ const candidates = [
122
+ record(payload['output']),
123
+ record(payload['trajectory_tail']),
124
+ typeof detail === 'string' ? parsed(detail) : undefined,
125
+ ].filter((candidate): candidate is Record<string, unknown> => candidate !== undefined);
126
+ const structured = candidates.find(processShaped) ?? candidates[0];
117
127
  const exitCode = structured?.['exit_code'];
118
128
  const stdout = structured?.['stdout_tail'];
119
129
  const stderr = structured?.['stderr_tail'];
120
- const structuredShape = typeof exitCode === 'number'
121
- || typeof stdout === 'string' || typeof stderr === 'string';
130
+ const structuredShape = structured !== undefined && processShaped(structured);
131
+ const transcript = record(record(payload['trajectory_tail'])?.['transcript']);
132
+ const failure = record(transcript?.['failure']);
133
+ const excerpt = failure?.['excerpt'];
134
+ const transcriptPath = record(transcript?.['file'])?.['path'];
135
+ // The excerpt is the worker's own pick of the failure; a `stderr` excerpt
136
+ // is the same bytes as `stderrTail`, so it is not printed twice.
137
+ const excerptDetail = typeof excerpt === 'string' && excerpt.length > 0
138
+ && !(failure?.['kind'] === 'stderr' && typeof stderr === 'string')
139
+ ? tail(excerpt) : undefined;
122
140
  return {
123
141
  ...(typeof exitCode === 'number' && Number.isSafeInteger(exitCode) ? { exitCode } : {}),
124
142
  ...(typeof stdout === 'string' && stdout.length > 0 ? { stdoutTail: tail(stdout) } : {}),
125
143
  ...(typeof stderr === 'string' ? { stderrTail: tail(stderr) } : {}),
126
144
  // Keep the daemon's account only when it was NOT just a render of the
127
145
  // fields above — otherwise the same bytes print twice.
128
- ...(typeof detail === 'string' && detail.length > 0 && !structuredShape
129
- ? { detail: tail(detail) } : {}),
146
+ ...(excerptDetail !== undefined ? { detail: excerptDetail }
147
+ : typeof detail === 'string' && detail.length > 0 && !structuredShape
148
+ ? { detail: tail(detail) } : {}),
149
+ ...(typeof transcriptPath === 'string' && transcriptPath.length > 0 ? { transcriptPath } : {}),
130
150
  };
131
151
  }
132
152
 
153
+ function processShaped(value: Record<string, unknown>): boolean {
154
+ return typeof value['exit_code'] === 'number'
155
+ || typeof value['stdout_tail'] === 'string' || typeof value['stderr_tail'] === 'string';
156
+ }
157
+
133
158
  function parsed(value: string): Record<string, unknown> | undefined {
134
159
  try {
135
160
  return record(JSON.parse(value));
@@ -265,6 +265,17 @@ export const CLI_VERBS = [
265
265
  ],
266
266
  variants: ['serve-webhook'],
267
267
  },
268
+ {
269
+ name: 'status',
270
+ description: 'Show a run’s steps, attempts, leases and last verdicts from its journal; offline, no daemon',
271
+ args: [{ name: 'run-id', description: 'Run to inspect; defaults to RELAYFLOW_RUN_ID inside a step', required: false }],
272
+ options: [
273
+ JSON_OPTION,
274
+ DATA_DIR_OPTION,
275
+ { flags: '--tail <n>', description: 'Lines of each agent attempt’s transcript tail to show' },
276
+ ],
277
+ variants: ['status'],
278
+ },
268
279
  {
269
280
  name: 'sync',
270
281
  description: 'Apply a hosted run’s code changes to a local tree (replaces `agent-relay cloud sync`)',
package/src/cli.ts CHANGED
@@ -24,6 +24,8 @@ import { checkAuthoredTriggers } from './cli/check-triggers.js';
24
24
  import { parseWebhookArgs, runServeWebhook } from './cli/serve-webhook.js';
25
25
  import { runDirectFlow } from './cli/direct-run.js';
26
26
  import { parseReplayArgs, replayJournal, type ReplayArgs } from './cli/replay.js';
27
+ import { parseStatusArgs, runStatus, type StatusArgs } from './cli/status.js';
28
+ import { transcriptTailSource } from './transcript-tail.js';
27
29
  import { checkTypeScriptFlow } from './cli/check-typescript.js';
28
30
  import { runCloudCli } from './cli/cloud-run.js';
29
31
  import { runCloudSyncCli } from './cli/cloud-sync.js';
@@ -59,6 +61,7 @@ type CliExitCode = 0 | 1 | 2 | 3;
59
61
  export type ParsedArgs =
60
62
  | { command: 'add'; value: string }
61
63
  | ReplayArgs
64
+ | StatusArgs
62
65
  | BuildArgs
63
66
  | DeployArgs
64
67
  | { command: 'serve-webhook'; dataDir: string; port: number; admitted?: readonly string[] }
@@ -104,6 +107,7 @@ const USAGE = [
104
107
  'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
105
108
  'flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>',
106
109
  'flows replay [--allow-human-influenced] [--json] [--data-dir <dir>] <run-id> [--at <step-id>]',
110
+ 'flows status [--json] [--data-dir <dir>] [--tail <n>] [<run-id>]',
107
111
  'flows observer [--data-dir <dir>]',
108
112
  'flows hn-monitor start [--data-dir <dir>] [--poll-interval-ms <n>] <spec.json>',
109
113
  ].join('\n');
@@ -195,6 +199,9 @@ export async function runCli(
195
199
  if (parsed.command === 'schedules') return runCloudSchedulesCli(parsed, io);
196
200
  if (parsed.command === 'unschedule') return runCloudUnscheduleCli(parsed, io);
197
201
  if (parsed.command === 'replay') return replayJournal(parsed, io);
202
+ // Daemon-free like `check`: reads one journal file and nothing else, so it
203
+ // works inside a step of a run whose daemon is gone (kernel/DAEMON-LIFECYCLE.md §4).
204
+ if (parsed.command === 'status') return runStatus(parsed, io, { tails: transcriptTailSource() });
198
205
  if (parsed.command === 'answer') {
199
206
  const execution = await answerFlow(parsed.runId, parsed.waitId, parsed.answer, parsed.dataDir, {
200
207
  ...(parsed.note === undefined ? {} : { note: parsed.note }),
@@ -498,6 +505,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
498
505
  if (command === undefined || !CLI_VERB_NAMES.has(command)) return undefined;
499
506
  if (command === 'add') return args.length === 2 ? { command: 'add', value: args[1]! } : undefined;
500
507
  if (command === 'replay') return parseReplayArgs(args.slice(1));
508
+ if (command === 'status') return parseStatusArgs(args.slice(1));
501
509
  if (command === 'build') return parseBuildArgs(args.slice(1));
502
510
  if (command === 'deploy') {
503
511
  // The positional decides the form: an authored source deploys a hosted
@@ -67,6 +67,13 @@ export const CHECK_FAILURE_KINDS = [
67
67
  * `dollars_unmetered: true`, and cannot cross `maxDollars`; token limits still
68
68
  * apply. It replaced the `budget_missing_price` refusal (#421), which older
69
69
  * `flows check` reports may still show.
70
+ *
71
+ * `permissions_unenforced` names an agent step that declares `permissions`.
72
+ * Gate 1 validates the declaration and the compiled step spec records it, but
73
+ * nothing reads it to gate a file or network access — enforcement is gate 8
74
+ * (#442). Warning-only by design: the declaration stays legal and the flow
75
+ * still runs, but an author who wrote one must not be left believing it
76
+ * sandboxes the step.
70
77
  */
71
78
  export const PREFLIGHT_WARNING_KINDS = [
72
79
  'unprovable_effects',
@@ -74,6 +81,7 @@ export const PREFLIGHT_WARNING_KINDS = [
74
81
  'command_unprovable',
75
82
  'vacuous_gate',
76
83
  'budget_unmetered',
84
+ 'permissions_unenforced',
77
85
  ] as const;
78
86
 
79
87
  /**
@@ -157,8 +165,14 @@ export interface StepFailedDetails {
157
165
  stdoutTail?: string;
158
166
  /** Terminal-safe UTF-8 excerpt, at most 1,024 bytes. */
159
167
  stderrTail?: string;
160
- /** The daemon's own account, when it was not a render of the fields above. */
168
+ /**
169
+ * The daemon's own account when it was not a render of the fields above, or
170
+ * — preferred when present — the failure excerpt from the worker's
171
+ * transcript digest (`trajectory_tail.transcript.failure`).
172
+ */
161
173
  detail?: string;
174
+ /** The attempt's redacted `stream-json` transcript on disk, when the worker wrote one. */
175
+ transcriptPath?: string;
162
176
  /** A runnable `flows replay` invocation for this run. */
163
177
  hint?: string;
164
178
  /** The on-disk journal for this run, when the data dir is known. */
@@ -28,7 +28,7 @@ export interface JournalEvent {
28
28
  }
29
29
 
30
30
  export type JournalReadFailure =
31
- | 'invalid_run_id' | 'run_not_found' | 'step_not_found' | 'journal_read_failed';
31
+ | 'invalid_run_id' | 'run_not_found' | 'step_not_found' | 'journal_busy' | 'journal_read_failed';
32
32
 
33
33
  export class JournalReadError extends Error {
34
34
  constructor(readonly code: JournalReadFailure, message: string) {
@@ -82,7 +82,9 @@ export async function* walkJournal(
82
82
  if (before[1] !== undefined) await copyFile(`${source}-wal`, `${snapshot}-wal`, constants.COPYFILE_EXCL);
83
83
  const after = await Promise.all([fingerprint(source), fingerprint(`${source}-wal`)]);
84
84
  if (before.some((value, index) => value !== after[index])) {
85
- throw new Error('Journal changed while taking the replay snapshot; retry replay.');
85
+ // Its own code: the journal is fine, a writer was mid-flight. A reader
86
+ // that can wait (`flows status`) retries on this and nothing else.
87
+ throw new JournalReadError('journal_busy', `Cannot read journal for run "${runId}": Journal changed while taking the snapshot; retry the command.`);
86
88
  }
87
89
  const file = await open(snapshot, 'r');
88
90
  try {
package/src/llm-worker.ts CHANGED
@@ -9,6 +9,7 @@ import { resolveCliModel } from './cli-adapter.js';
9
9
  import { withWorkerLease } from './worker-lease.js';
10
10
  import { workerInstruction } from './worker-input.js';
11
11
  import { jsonSchemaOutputError } from './json-schema.js';
12
+ import { LLM_ERROR_MAX_BYTES, boundTranscriptDigest, boundedText, redactText } from './agent-transcript.js';
12
13
 
13
14
  /** Bare value-producing LLM dispatch, without agent workspace/recovery pins. */
14
15
  export class LlmWorker extends EventEmitter {
@@ -74,11 +75,21 @@ export class LlmWorker extends EventEmitter {
74
75
  }
75
76
  // Never submit schema-invalid JSON as a successful completion. The kernel
76
77
  // also runs its own gate before making output available to dependents.
78
+ //
79
+ // `error` is redacted and bounded: unbounded stderr in the tail made the
80
+ // kernel refuse the whole completion past 16 KiB. The digest rides beside
81
+ // it on every completion; an llm step has no data dir, so it has no file.
82
+ const transcript = completed.transcript === undefined ? undefined
83
+ : boundTranscriptDigest({ attempt: dispatch.attempt, exit_code: result.exit_code, ...completed.transcript });
84
+ const trajectoryTail = {
85
+ ...(reason === 'success' ? {} : { error: boundedText(redactText(detail), LLM_ERROR_MAX_BYTES).text }),
86
+ ...(transcript === undefined ? {} : { transcript }),
87
+ };
77
88
  await this.client.stepComplete(dispatch.run_id, dispatch.step_id, dispatch.attempt,
78
89
  dispatch.idempotency_key, reason, {
79
90
  output,
80
91
  ...(usage !== undefined ? { usage } : {}),
81
- ...(reason === 'success' ? {} : { trajectory_tail: { error: detail } }),
92
+ ...(Object.keys(trajectoryTail).length === 0 ? {} : { trajectory_tail: trajectoryTail }),
82
93
  });
83
94
  }
84
95
  }
@@ -0,0 +1,53 @@
1
+ import type { CompiledFlowSpec } from './compile.js';
2
+ import type { PermissionsSpec } from './spec.js';
3
+ import type { PreflightWarning } from './preflight.js';
4
+
5
+ /** Declared fields, reported in this order so two runs read identically. */
6
+ const PERMISSION_FIELDS = ['fileGlobs', 'networkAllowlist', 'accessPreset'] as const;
7
+
8
+ /**
9
+ * An agent step's `permissions` block is accepted by gate 1, lowered to
10
+ * `file_globs` / `network_allowlist` / `access_preset`, carried in the kernel
11
+ * step spec and journalled with it. Nothing reads it to gate a file or network
12
+ * access: enforcement is gate 8 (#442). A spec that declares it therefore
13
+ * looks sandboxed and is not, and until this warning existed nothing said so
14
+ * at authoring time.
15
+ *
16
+ * Every defined declaration warns, including `{}` and empty arrays. SURFACE.md
17
+ * accepts those without inferring defaults, and an empty block is exactly as
18
+ * likely to read to an author as "a default sandbox applies". Omitting
19
+ * `permissions` stays silent — there is no belief to correct.
20
+ *
21
+ * Warning-only: `ok` is unaffected, the declaration stays legal, and no value
22
+ * is echoed back (a glob or host is often the least interesting part of the
23
+ * declaration and can be long). The message names which fields were declared
24
+ * so the author can find them, and nothing more.
25
+ */
26
+ export function permissionsDiagnostics(flow: CompiledFlowSpec): PreflightWarning[] {
27
+ const warnings: PreflightWarning[] = [];
28
+ for (const step of flow.steps) {
29
+ if (step.type !== 'agent' || step.permissions === undefined) continue;
30
+ warnings.push({
31
+ severity: 'warning',
32
+ kind: 'permissions_unenforced',
33
+ stepId: step.id,
34
+ message: permissionsMessage(step.id, step.permissions),
35
+ });
36
+ }
37
+ return warnings;
38
+ }
39
+
40
+ function permissionsMessage(stepId: string, permissions: PermissionsSpec): string {
41
+ const declared = PERMISSION_FIELDS.filter((field) => permissions[field] !== undefined);
42
+ const fields = declared.length === 0 ? '' : ` (${declared.join(', ')})`;
43
+ // `readonly` is the one value whose entire meaning is a guarantee, so it is
44
+ // the one value worth naming: an author reading "cannot write" needs to be
45
+ // told, in the same breath, that today nothing stops a write.
46
+ const preset = permissions.accessPreset === 'readonly'
47
+ ? " accessPreset: 'readonly' does not prevent writes."
48
+ : '';
49
+ return `Step "${stepId}" declares permissions${fields}. `
50
+ + 'This declaration is validated and recorded with the step spec; it is not '
51
+ + 'currently enforced (gate 8 / #442). These permissions do not restrict file '
52
+ + `or network access.${preset}`;
53
+ }
package/src/preflight.ts CHANGED
@@ -4,6 +4,7 @@ import type { FlowSpec, StepSpec, TriggerSpec, McpServerConfig } from './spec.js
4
4
  import { McpError, openMcpSession, type McpDiagnostic } from './mcp-client.js';
5
5
  import { BudgetSyntaxError } from './budget.js';
6
6
  import { budgetDiagnostics } from './budget-preflight.js';
7
+ import { permissionsDiagnostics } from './permissions-preflight.js';
7
8
  import type { TriggerSource } from '@relayflows/surface';
8
9
  import { acceptsAnyOutput, inspectStepGate, type StepGateInspection } from './gate-contract.js';
9
10
  import { compileSpec, CompileError } from './compile.js';
@@ -223,6 +224,11 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
223
224
  const resolutionByStep = new Map<string, CliResolution>();
224
225
  const cliProbeResults = new Map<string, CliProbeOutcome>();
225
226
 
227
+ // A pure fact about the compiled snapshot, collected before anything that
228
+ // can return early. An unresolved CLI, an unknown model or a bad scope all
229
+ // refuse below without probing, and the author should still be told that the
230
+ // permissions they declared on the same file are not enforced.
231
+ diagnostics.push(...permissionsDiagnostics(compiled));
226
232
  diagnostics.push(...scopeDiagnostics(compiled, options));
227
233
  for (const server of new Set(options.mcpServers ?? [])) {
228
234
  if (options.mcp !== undefined && Object.hasOwn(options.mcp, server)) continue;
@@ -6,6 +6,13 @@ export interface SidechannelContext {
6
6
  dataDir: string;
7
7
  runId: string;
8
8
  stepId: string;
9
+ /**
10
+ * Which attempt this is. Names the per-attempt transcript file
11
+ * (`agent-transcript.ts`) and the transcript tails (`transcript-tail.ts`),
12
+ * and is exported into the agent's environment. Optional: without it there
13
+ * is no attempt to name, so neither file is written.
14
+ */
15
+ attempt?: number;
9
16
  onReady?: (path: string) => void;
10
17
  onDrive: () => void;
11
18
  }