@relayflows/sdk 2.0.20 → 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 (107) hide show
  1. package/dist/agent-reaper.d.ts +4 -0
  2. package/dist/agent-reaper.d.ts.map +1 -0
  3. package/dist/agent-reaper.js +53 -0
  4. package/dist/agent-reaper.js.map +1 -0
  5. package/dist/agent-transcript.d.ts +186 -0
  6. package/dist/agent-transcript.d.ts.map +1 -0
  7. package/dist/agent-transcript.js +585 -0
  8. package/dist/agent-transcript.js.map +1 -0
  9. package/dist/child-stop.d.ts +1 -1
  10. package/dist/child-stop.d.ts.map +1 -1
  11. package/dist/child-stop.js +68 -12
  12. package/dist/child-stop.js.map +1 -1
  13. package/dist/cli/run.js +2 -1
  14. package/dist/cli/run.js.map +1 -1
  15. package/dist/cli/status.d.ts +34 -0
  16. package/dist/cli/status.d.ts.map +1 -0
  17. package/dist/cli/status.js +307 -0
  18. package/dist/cli/status.js.map +1 -0
  19. package/dist/cli/step-failure.js +31 -7
  20. package/dist/cli/step-failure.js.map +1 -1
  21. package/dist/cli-commands.d.ts +13 -0
  22. package/dist/cli-commands.d.ts.map +1 -1
  23. package/dist/cli-commands.js +11 -0
  24. package/dist/cli-commands.js.map +1 -1
  25. package/dist/cli.d.ts +2 -1
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +9 -0
  28. package/dist/cli.js.map +1 -1
  29. package/dist/failure-kinds.d.ts +15 -2
  30. package/dist/failure-kinds.d.ts.map +1 -1
  31. package/dist/failure-kinds.js +8 -0
  32. package/dist/failure-kinds.js.map +1 -1
  33. package/dist/journal-reader.d.ts +1 -1
  34. package/dist/journal-reader.d.ts.map +1 -1
  35. package/dist/journal-reader.js +3 -1
  36. package/dist/journal-reader.js.map +1 -1
  37. package/dist/llm-worker.d.ts.map +1 -1
  38. package/dist/llm-worker.js +12 -1
  39. package/dist/llm-worker.js.map +1 -1
  40. package/dist/permissions-preflight.d.ts +22 -0
  41. package/dist/permissions-preflight.d.ts.map +1 -0
  42. package/dist/permissions-preflight.js +49 -0
  43. package/dist/permissions-preflight.js.map +1 -0
  44. package/dist/preflight.d.ts.map +1 -1
  45. package/dist/preflight.js +6 -0
  46. package/dist/preflight.js.map +1 -1
  47. package/dist/pty-sidechannel.d.ts +7 -0
  48. package/dist/pty-sidechannel.d.ts.map +1 -1
  49. package/dist/pty-sidechannel.js.map +1 -1
  50. package/dist/redact.d.ts +16 -0
  51. package/dist/redact.d.ts.map +1 -0
  52. package/dist/redact.js +104 -0
  53. package/dist/redact.js.map +1 -0
  54. package/dist/run-state.d.ts +124 -0
  55. package/dist/run-state.d.ts.map +1 -0
  56. package/dist/run-state.js +368 -0
  57. package/dist/run-state.js.map +1 -0
  58. package/dist/spec.d.ts +16 -1
  59. package/dist/spec.d.ts.map +1 -1
  60. package/dist/spec.js.map +1 -1
  61. package/dist/step-env.d.ts +25 -0
  62. package/dist/step-env.d.ts.map +1 -0
  63. package/dist/step-env.js +34 -0
  64. package/dist/step-env.js.map +1 -0
  65. package/dist/transcript-tail.d.ts +54 -0
  66. package/dist/transcript-tail.d.ts.map +1 -0
  67. package/dist/transcript-tail.js +176 -0
  68. package/dist/transcript-tail.js.map +1 -0
  69. package/dist/worker-cli.d.ts +18 -0
  70. package/dist/worker-cli.d.ts.map +1 -1
  71. package/dist/worker-cli.js +219 -22
  72. package/dist/worker-cli.js.map +1 -1
  73. package/dist/worker-usage.d.ts +15 -2
  74. package/dist/worker-usage.d.ts.map +1 -1
  75. package/dist/worker-usage.js +40 -3
  76. package/dist/worker-usage.js.map +1 -1
  77. package/dist/worker.d.ts.map +1 -1
  78. package/dist/worker.js +31 -6
  79. package/dist/worker.js.map +1 -1
  80. package/dist/wrapper-runtime.d.ts +7 -1
  81. package/dist/wrapper-runtime.d.ts.map +1 -1
  82. package/dist/wrapper-runtime.js +7 -1
  83. package/dist/wrapper-runtime.js.map +1 -1
  84. package/package.json +2 -2
  85. package/src/agent-reaper.ts +52 -0
  86. package/src/agent-transcript.ts +651 -0
  87. package/src/child-stop.ts +62 -12
  88. package/src/cli/run.ts +2 -1
  89. package/src/cli/status.ts +329 -0
  90. package/src/cli/step-failure.ts +32 -7
  91. package/src/cli-commands.ts +11 -0
  92. package/src/cli.ts +8 -0
  93. package/src/failure-kinds.ts +15 -1
  94. package/src/journal-reader.ts +4 -2
  95. package/src/llm-worker.ts +12 -1
  96. package/src/permissions-preflight.ts +53 -0
  97. package/src/preflight.ts +6 -0
  98. package/src/pty-sidechannel.ts +7 -0
  99. package/src/redact.ts +106 -0
  100. package/src/run-state.ts +468 -0
  101. package/src/spec.ts +16 -1
  102. package/src/step-env.ts +47 -0
  103. package/src/transcript-tail.ts +193 -0
  104. package/src/worker-cli.ts +213 -21
  105. package/src/worker-usage.ts +39 -3
  106. package/src/worker.ts +32 -6
  107. package/src/wrapper-runtime.ts +7 -1
package/src/child-stop.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChildProcess } from 'node:child_process';
1
+ import { execFileSync, type ChildProcess } from 'node:child_process';
2
2
 
3
3
  /**
4
4
  * Grace between the graceful signal and the force kill. A tree that ignores
@@ -78,25 +78,40 @@ export function childStop(
78
78
  if (forceTimer !== undefined) clearTimeout(forceTimer);
79
79
  forceTimer = undefined;
80
80
  };
81
- /** Whether anything is still in the group. See `maySettleOnChildExit`. */
81
+ // Descendants that moved into process groups of their own, captured at the
82
+ // first stop. Claude Code runs a `run_in_background` Bash command that way,
83
+ // so a group signal alone leaves it running after the agent is gone. They
84
+ // must be found while the direct child is still their ancestor: once it
85
+ // dies they are reparented and no longer traceable to this spawn.
86
+ let escapedGroups: readonly number[] | undefined;
87
+ /**
88
+ * Whether anything the stop reaches is still alive: the child's own group or
89
+ * any group a descendant escaped into. See `maySettleOnChildExit`.
90
+ */
82
91
  const groupAnswers = (): boolean => {
83
92
  // With no group of our own a stop never reached past the direct child, so
84
93
  // that child's exit IS the whole of our reach and there is nothing left to
85
94
  // ask about.
86
95
  if (!ownsGroup || pid === undefined) return false;
87
- try {
88
- process.kill(-pid, 0);
89
- return true;
90
- } catch (error) {
91
- // Only `ESRCH` proves the group is empty. `EPERM` proves the opposite —
92
- // something is in there that we may not signal — and any other errno
93
- // proves nothing at all, so both must read as alive: an unproven group is
94
- // not a reason to spare a survivor.
95
- return (error as NodeJS.ErrnoException).code !== 'ESRCH';
96
- }
96
+ return [pid, ...(escapedGroups ?? [])].some(group => {
97
+ try {
98
+ process.kill(-group, 0);
99
+ return true;
100
+ } catch (error) {
101
+ // Only `ESRCH` proves the group is empty. `EPERM` proves the opposite
102
+ // — something is in there that we may not signal — and any other
103
+ // errno proves nothing at all, so both must read as alive: an unproven
104
+ // group is not a reason to spare a survivor.
105
+ return (error as NodeJS.ErrnoException).code !== 'ESRCH';
106
+ }
107
+ });
97
108
  };
98
109
  const signalTree = (name: NodeJS.Signals): void => {
99
110
  if (ownsGroup && pid !== undefined) {
111
+ escapedGroups ??= descendantGroups(pid);
112
+ for (const group of escapedGroups) {
113
+ try { process.kill(-group, name); } catch { /* already gone */ }
114
+ }
100
115
  // `-pid` addresses the group this detached child leads, which is every
101
116
  // descendant that has not left it. It throws only once the whole group
102
117
  // is gone — the outcome we were asking for — so fall through and let the
@@ -136,3 +151,38 @@ export function childStop(
136
151
  },
137
152
  };
138
153
  }
154
+
155
+ /**
156
+ * Process groups, other than `rootPid`'s own and this process's, that hold a
157
+ * descendant of `rootPid`. Reads `ps` for pid, parent and group only — never a
158
+ * command line. Where `ps` cannot answer, the answer is none: the group signal
159
+ * still reaches everything that stayed in the group.
160
+ */
161
+ function descendantGroups(rootPid: number): number[] {
162
+ let table: string;
163
+ try {
164
+ table = execFileSync('ps', ['-A', '-o', 'pid=,ppid=,pgid='], {
165
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2_000,
166
+ });
167
+ } catch {
168
+ return [];
169
+ }
170
+ const children = new Map<number, number[]>();
171
+ const groupOf = new Map<number, number>();
172
+ for (const line of table.split('\n')) {
173
+ const fields = line.trim().split(/\s+/).map(Number);
174
+ if (fields.length !== 3 || !fields.every(Number.isSafeInteger)) continue;
175
+ const [row, parent, group] = fields as [number, number, number];
176
+ groupOf.set(row, group);
177
+ children.set(parent, [...(children.get(parent) ?? []), row]);
178
+ }
179
+ const spared = new Set([rootPid, groupOf.get(process.pid)]);
180
+ const groups = new Set<number>();
181
+ const pending = [...(children.get(rootPid) ?? [])];
182
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
183
+ const group = groupOf.get(next);
184
+ if (group !== undefined && group > 1 && !spared.has(group)) groups.add(group);
185
+ pending.push(...(children.get(next) ?? []));
186
+ }
187
+ return [...groups];
188
+ }
package/src/cli/run.ts CHANGED
@@ -808,7 +808,8 @@ function renderStepEvidence(details: StepFailedDetails): string {
808
808
  + '.'
809
809
  + (details.detail === undefined ? '' : `\nDetail: ${details.detail}`)
810
810
  + (details.stdoutTail ? `\nStdout (last 1,024 bytes):\n${details.stdoutTail}` : '')
811
- + (details.stderrTail ? `\nStderr (last 1,024 bytes):\n${details.stderrTail}` : '');
811
+ + (details.stderrTail ? `\nStderr (last 1,024 bytes):\n${details.stderrTail}` : '')
812
+ + (details.transcriptPath === undefined ? '' : `\nTranscript: ${details.transcriptPath}`);
812
813
  }
813
814
 
814
815
  function throwIfCanceled(signal: AbortSignal | undefined, stepId: string): void {
@@ -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
  }