@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/spec.ts CHANGED
@@ -125,7 +125,22 @@ export interface AgentSurfaces {
125
125
  external?: string[];
126
126
  }
127
127
 
128
- /** Permission model for an agent step (gate 8). `readonly` provably cannot write. */
128
+ /**
129
+ * Per-step permission declarations for an agent step.
130
+ *
131
+ * Validated by gate 1 and recorded in the compiled step spec (as `file_globs`,
132
+ * `network_allowlist` and `access_preset`), but **not currently enforced**:
133
+ * nothing reads these fields to gate a file or network access. Enforcement is
134
+ * gate 8 (#442). `accessPreset: 'readonly'` does not prevent writes today.
135
+ *
136
+ * `flows check` emits a `permissions_unenforced` warning for every agent step
137
+ * that declares this block in a declarative spec — YAML/JSON, or a `FlowSpec`
138
+ * handed to the check API. A declaration inside an authored `.flow.ts` body is
139
+ * **not** inspected: checking TypeScript preflights the flow header without
140
+ * executing the body, so those steps do not exist yet and the check passes
141
+ * silently. A clean check is not evidence that no unenforced declaration is
142
+ * present; the paragraph above is, for every dialect.
143
+ */
129
144
  export interface PermissionsSpec {
130
145
  fileGlobs?: string[];
131
146
  networkAllowlist?: string[];
@@ -0,0 +1,47 @@
1
+ // The four names a worker exports so an agent can find its own run on disk.
2
+ //
3
+ // Set on every direct claude/codex attempt that has a data directory
4
+ // (worker-cli.ts), and read by `flows status` when it is invoked with no run
5
+ // id. Identifiers and a path only: with these an agent can open its journal
6
+ // and nothing else. Env rather than a file in cwd because a cwd file pollutes
7
+ // the workspace, goes stale after a crash and breaks when `spec.cwd` is
8
+ // shared; the environment dies with the attempt.
9
+
10
+ import { resolve } from 'node:path';
11
+
12
+ export const DATA_DIR_ENV = 'RELAYFLOW_DATA_DIR';
13
+ export const RUN_ID_ENV = 'RELAYFLOW_RUN_ID';
14
+ export const STEP_ID_ENV = 'RELAYFLOW_STEP_ID';
15
+ export const ATTEMPT_ENV = 'RELAYFLOW_ATTEMPT';
16
+
17
+ export const STEP_ENV_NAMES = [DATA_DIR_ENV, RUN_ID_ENV, STEP_ID_ENV, ATTEMPT_ENV] as const;
18
+
19
+ export interface StepIdentity {
20
+ dataDir: string;
21
+ runId: string;
22
+ stepId: string;
23
+ /**
24
+ * Optional for the same reason it is optional on `SidechannelContext`: a
25
+ * spawn that names no attempt has none to export, and an absent
26
+ * `RELAYFLOW_ATTEMPT` is the honest form. `flows status` needs the run and
27
+ * the step to resolve itself; the attempt only picks a transcript tail.
28
+ */
29
+ attempt?: number;
30
+ }
31
+
32
+ /**
33
+ * Overwrite the four names in `env` from `identity`, or remove them when there
34
+ * is none. Removal matters: a worker that is itself running inside a step
35
+ * inherits its parent's values, and an agent must not mistake them for its own.
36
+ * The data dir is made absolute because the agent's cwd may be `spec.cwd`.
37
+ */
38
+ export function applyStepEnvironment(env: NodeJS.ProcessEnv, identity: StepIdentity | undefined): void {
39
+ for (const name of STEP_ENV_NAMES) delete env[name];
40
+ if (identity === undefined) return;
41
+ env[DATA_DIR_ENV] = resolve(identity.dataDir);
42
+ env[RUN_ID_ENV] = identity.runId;
43
+ env[STEP_ID_ENV] = identity.stepId;
44
+ // Left unset rather than set to "undefined": the four names are deleted
45
+ // above, so an agent sees no attempt instead of a bogus one.
46
+ if (identity.attempt !== undefined) env[ATTEMPT_ENV] = String(identity.attempt);
47
+ }
@@ -0,0 +1,193 @@
1
+ // Bounded transcript tails: the last 64 KiB of an agent attempt's stdout and
2
+ // stderr, on disk beside its `pty.sock`.
3
+ //
4
+ // Nothing else holds the transcript. The worker buffers it in memory and
5
+ // publishes it live to the sidechannel; a failed agent attempt's output is
6
+ // nulled in the journal (machine.rs), leaving at most 2,000 chars of gate
7
+ // `detail`. So "why did my last attempt end" was unanswerable from disk.
8
+ //
9
+ // These files are evidence, not the record: best-effort, bounded, rewritten
10
+ // in place, never a reason for a step to fail. The journal remains truth.
11
+ // The file is raw; `flows status` redacts on read, which is the same trust
12
+ // boundary as `pty.sock` today (same OS user, mode 0600).
13
+
14
+ import { mkdir, open, readFile, rename } from 'node:fs/promises';
15
+ import { dirname, join } from 'node:path';
16
+ import { ptySocketPath } from './pty-sidechannel.js';
17
+ import { redact } from './redact.js';
18
+ import type { StepTails, TailSource, TranscriptTail } from './cli/status.js';
19
+ import type { StepView } from './run-state.js';
20
+
21
+ export const TAIL_CAPACITY_BYTES = 64 * 1024;
22
+ /** At most four rewrites a second, however fast the agent talks. */
23
+ export const TAIL_FLUSH_INTERVAL_MS = 250;
24
+ /**
25
+ * How long a spawn's completion will wait for the final tail flush. Transcript
26
+ * evidence is best-effort, so a stalled filesystem must cost the step this
27
+ * much and no more — past it the invocation settles and the close runs on.
28
+ */
29
+ export const TAIL_CLOSE_TIMEOUT_MS = 2_000;
30
+
31
+ export type TailStream = 'stdout' | 'stderr';
32
+
33
+ export interface TailIdentity {
34
+ dataDir: string;
35
+ runId: string;
36
+ stepId: string;
37
+ attempt: number;
38
+ }
39
+
40
+ /** The first line of every tail file, so a stale file is never mistaken for this attempt's. */
41
+ export interface TailHeader {
42
+ v: 1;
43
+ run_id: string;
44
+ step_id: string;
45
+ attempt: number;
46
+ started_at_ms: number;
47
+ }
48
+
49
+ export function transcriptTailPath(identity: TailIdentity, stream: TailStream): string {
50
+ if (!Number.isSafeInteger(identity.attempt) || identity.attempt < 0) throw new Error('Invalid transcript tail attempt');
51
+ return join(dirname(ptySocketPath(identity)), `attempt-${identity.attempt}.${stream}.tail`);
52
+ }
53
+
54
+ export interface TranscriptTailWriter {
55
+ append(chunk: Buffer): void;
56
+ /** Flush what is pending and stop. Resolves once the last write has settled, success or not. */
57
+ close(): Promise<void>;
58
+ }
59
+
60
+ /**
61
+ * A ring of the last {@link TAIL_CAPACITY_BYTES} bytes, flushed to
62
+ * `attempt-<n>.<stream>.tail` at most every {@link TAIL_FLUSH_INTERVAL_MS}.
63
+ * A write failure is reported once as a process warning and then ignored:
64
+ * the attempt continues, and `flows status` will say no transcript is on disk.
65
+ */
66
+ export function openTranscriptTail(
67
+ identity: TailIdentity,
68
+ stream: TailStream,
69
+ startedAtMs = Date.now(),
70
+ warn: (message: string) => void = (message) => process.emitWarning(message),
71
+ ): TranscriptTailWriter {
72
+ const path = transcriptTailPath(identity, stream);
73
+ const header = JSON.stringify({ v: 1, run_id: identity.runId, step_id: identity.stepId, attempt: identity.attempt, started_at_ms: startedAtMs } satisfies TailHeader);
74
+ const ring: Buffer[] = [];
75
+ let ringBytes = 0;
76
+ let dirty = false;
77
+ let closed = false;
78
+ let failed = false;
79
+ let timer: NodeJS.Timeout | undefined;
80
+ let inFlight: Promise<void> = Promise.resolve();
81
+
82
+ const flush = (): Promise<void> => {
83
+ if (!dirty || failed) return inFlight;
84
+ dirty = false;
85
+ const body = Buffer.concat([Buffer.from(`${header}\n`), ...ring]);
86
+ inFlight = inFlight.then(async () => {
87
+ // Write beside, then rename over: a reader never sees a torn file.
88
+ const staging = `${path}.tmp`;
89
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
90
+ const file = await open(staging, 'w', 0o600);
91
+ try { await file.writeFile(body); } finally { await file.close(); }
92
+ await rename(staging, path);
93
+ }).catch((error: unknown) => {
94
+ if (failed) return;
95
+ failed = true;
96
+ warn(`Transcript tail for ${identity.runId}/${identity.stepId} attempt ${identity.attempt} (${stream}) could not be written; the step continues without it: ${error instanceof Error ? error.message : String(error)}`);
97
+ });
98
+ return inFlight;
99
+ };
100
+
101
+ return {
102
+ append(chunk) {
103
+ if (closed || failed || chunk.length === 0) return;
104
+ if (chunk.length >= TAIL_CAPACITY_BYTES) {
105
+ ring.splice(0, ring.length, Buffer.from(chunk.subarray(chunk.length - TAIL_CAPACITY_BYTES)));
106
+ ringBytes = TAIL_CAPACITY_BYTES;
107
+ } else {
108
+ ring.push(Buffer.from(chunk));
109
+ ringBytes += chunk.length;
110
+ while (ringBytes > TAIL_CAPACITY_BYTES) {
111
+ const excess = ringBytes - TAIL_CAPACITY_BYTES;
112
+ const head = ring[0]!;
113
+ if (head.length <= excess) { ring.shift(); ringBytes -= head.length; } else { ring[0] = head.subarray(excess); ringBytes -= excess; }
114
+ }
115
+ }
116
+ dirty = true;
117
+ if (timer === undefined) {
118
+ timer = setTimeout(() => { timer = undefined; void flush(); }, TAIL_FLUSH_INTERVAL_MS);
119
+ timer.unref();
120
+ }
121
+ },
122
+ async close() {
123
+ if (closed) return;
124
+ closed = true;
125
+ if (timer !== undefined) { clearTimeout(timer); timer = undefined; }
126
+ await flush();
127
+ },
128
+ };
129
+ }
130
+
131
+ /** A tail file parsed and checked against the attempt it claims to be. */
132
+ export interface ReadTail {
133
+ header: TailHeader;
134
+ /** Bytes of transcript on disk, after the header line. */
135
+ bytes: number;
136
+ text: string;
137
+ }
138
+
139
+ /**
140
+ * Read one tail file. `undefined` when absent, unparseable, or written for a
141
+ * different attempt — or for this attempt number but before the journal says
142
+ * the attempt began, which is a file left by an earlier life of this data dir.
143
+ */
144
+ export async function readTranscriptTail(
145
+ identity: TailIdentity,
146
+ stream: TailStream,
147
+ attemptStartedAtMs: number | null,
148
+ ): Promise<ReadTail | undefined> {
149
+ let raw: Buffer;
150
+ try {
151
+ raw = await readFile(transcriptTailPath(identity, stream));
152
+ } catch (error) {
153
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
154
+ throw error;
155
+ }
156
+ const newline = raw.indexOf(10);
157
+ if (newline < 0) return undefined;
158
+ let header: unknown;
159
+ try { header = JSON.parse(raw.subarray(0, newline).toString('utf8')); } catch { return undefined; }
160
+ if (header === null || typeof header !== 'object') return undefined;
161
+ const claimed = header as Partial<TailHeader>;
162
+ if (claimed.v !== 1 || claimed.run_id !== identity.runId || claimed.step_id !== identity.stepId
163
+ || claimed.attempt !== identity.attempt || typeof claimed.started_at_ms !== 'number') return undefined;
164
+ if (attemptStartedAtMs !== null && claimed.started_at_ms < attemptStartedAtMs) return undefined;
165
+ const body = raw.subarray(newline + 1);
166
+ return { header: claimed as TailHeader, bytes: body.length, text: body.toString('utf8') };
167
+ }
168
+
169
+ /**
170
+ * Redact the whole tail before splitting it. A direct agent inherits the
171
+ * worker's environment, so it can print a multiline secret; splitting first
172
+ * meant no line held the whole value and `replaceAll` matched none of them.
173
+ */
174
+ function lastLines(text: string, count: number, env: NodeJS.ProcessEnv): string[] {
175
+ const lines = redact(text, env).split('\n');
176
+ if (lines.at(-1) === '') lines.pop();
177
+ return lines.slice(Math.max(0, lines.length - count));
178
+ }
179
+
180
+ /** The {@link TailSource} `flows status` uses: this step's latest attempt, redacted. */
181
+ export function transcriptTailSource(env: NodeJS.ProcessEnv = process.env): TailSource {
182
+ return {
183
+ async read(dataDir: string, runId: string, step: StepView, lines: number): Promise<StepTails> {
184
+ if (step.type !== 'agent' || step.attempt === 0) return null;
185
+ const identity = { dataDir, runId, stepId: step.id, attempt: step.attempt };
186
+ const tail = async (stream: TailStream): Promise<TranscriptTail | null> => {
187
+ const read = await readTranscriptTail(identity, stream, step.started_at_ms);
188
+ return read === undefined ? null : { attempt: step.attempt, bytes: read.bytes, lines: lastLines(read.text, lines, env) };
189
+ };
190
+ return { stdout: await tail('stdout'), stderr: await tail('stderr') };
191
+ },
192
+ };
193
+ }
package/src/worker-cli.ts CHANGED
@@ -1,9 +1,13 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import { resolve } from 'node:path';
2
3
  import { diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js';
3
- import { decodeProviderResult, decodeWrapperResult, requirePricedUsage } from './worker-usage.js';
4
+ import { claudeResultOutcome, decodeProviderResult, decodeWrapperResult, requirePricedUsage } from './worker-usage.js';
4
5
  import { openSidechannel, type SidechannelContext } from './pty-sidechannel.js';
6
+ import { openTranscriptWriter, transcriptPath, type TranscriptDigest, type TranscriptFile, type TranscriptWriter } from './agent-transcript.js';
5
7
  import { spawn } from 'node:child_process';
6
- import { childStop, ownsProcessGroup } from './child-stop.js';
8
+ import { StringDecoder } from 'node:string_decoder';
9
+ import { childStop } from './child-stop.js';
10
+ import { reapOnExit } from './agent-reaper.js';
7
11
  import {
8
12
  agentExecution,
9
13
  llmExecution,
@@ -17,6 +21,9 @@ import {
17
21
  type WrapperSessionLimits,
18
22
  } from './wrapper-session.js';
19
23
  import { wrapperEnvironment } from './wrapper-runtime.js';
24
+ import { redactRelayError } from './redact.js';
25
+ import { applyStepEnvironment } from './step-env.js';
26
+ import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, transcriptTailPath, type TranscriptTailWriter } from './transcript-tail.js';
20
27
  import {
21
28
  runAgentRelayTask,
22
29
  AgentRelayTransportError,
@@ -50,6 +57,15 @@ export interface WorkerCliResult {
50
57
  * agent runs on another host.
51
58
  */
52
59
  artifacts?: string[];
60
+ /**
61
+ * The attempt's transcript digest (`agent-transcript.ts`): provider result
62
+ * metadata, tool calls summarized, final text, failure excerpt, and — when
63
+ * the spawn had a sidechannel context with an attempt — the pointer to the
64
+ * redacted, capped `stream-json` file. Journaled in `trajectory_tail`, never
65
+ * in `output`. Present for Claude/Codex executions; absent for wrappers and
66
+ * the relay transport, which stream no provider frames through this process.
67
+ */
68
+ transcript?: TranscriptDigest;
53
69
  }
54
70
 
55
71
  /**
@@ -93,23 +109,44 @@ export async function runAgentCli(
93
109
  // workspace to change. Executions sharing a working directory are
94
110
  // serialized around their snapshot-spawn-snapshot interval, so one agent's
95
111
  // writes are never attributed to a concurrent one in the same directory.
112
+ //
113
+ // This process writes its own evidence during that interval — the attempt's
114
+ // transcript file and the two `flows status --tail` tails — and when the data
115
+ // dir sits under the cwd (a local `--data-dir` inside the project) they all
116
+ // land inside the scanned tree. None of them is agent-authored content, so
117
+ // every one is dropped from the diff by path. Missing any of them reports
118
+ // our own bookkeeping back to the kernel as the step's artifacts.
96
119
  const artifactRoot = mode === 'agent' ? resolve(cwd ?? process.cwd()) : undefined;
97
120
  return artifactRoot === undefined
98
121
  ? execute()
99
122
  : serializedByDirectory(artifactRoot, async () => {
100
123
  const before = await snapshotWorkspaceFiles(artifactRoot);
101
124
  const result = await execute();
102
- return { ...result, artifacts: diffWorkspaceFiles(before, await snapshotWorkspaceFiles(artifactRoot)) };
125
+ const ours = new Set<string>();
126
+ for (const path of ownEvidencePaths(sidechannel)) {
127
+ const real = realPath(path);
128
+ if (real !== undefined) ours.add(real);
129
+ }
130
+ const artifacts = diffWorkspaceFiles(before, await snapshotWorkspaceFiles(artifactRoot))
131
+ .filter(path => {
132
+ const real = realPath(resolve(artifactRoot, path));
133
+ return real === undefined || !ours.has(real);
134
+ });
135
+ return { ...result, artifacts };
103
136
  });
104
137
 
105
138
  async function execute(): Promise<WorkerCliResult> {
106
139
  if (kind === 'relayflows-wrapper-v1') {
140
+ // The closed allowlist admits no ambient RELAYFLOW_* value; the four
141
+ // discovery names are set from this dispatch, exactly as for a direct spawn.
142
+ const wrapperEnv = wrapperEnvironment(process.env);
143
+ applyStepEnvironment(wrapperEnv, sidechannel);
107
144
  return requirePricedUsage(decodeWrapperResult(await runWrapperSession(
108
145
  cli,
109
146
  instruction,
110
147
  wakeContext,
111
148
  effectiveModel,
112
- wrapperEnvironment(process.env),
149
+ wrapperEnv,
113
150
  wrapperLimits,
114
151
  signal,
115
152
  cwd,
@@ -119,6 +156,9 @@ export async function runAgentCli(
119
156
  const env: NodeJS.ProcessEnv = { ...process.env };
120
157
  delete env[WAKE_CONTEXT_ENV];
121
158
  delete env[MODEL_ENV];
159
+ // Where this attempt's journal is, so the agent can run `flows status` on
160
+ // itself. Only a worker with a data dir knows; an ad-hoc spawn exports nothing.
161
+ applyStepEnvironment(env, sidechannel);
122
162
  const invocation = mode === 'llm' ? llmExecution(kind, instruction, effectiveModel) : agentExecution(kind, instruction, effectiveModel);
123
163
 
124
164
  if (wakeContext !== undefined) {
@@ -134,13 +174,71 @@ export async function runAgentCli(
134
174
  }
135
175
 
136
176
  if (invocation.modelEnv !== undefined) env[MODEL_ENV] = invocation.modelEnv;
137
- // Structured provider output carries the authoritative token counts.
177
+ // Structured provider output carries the authoritative token counts. Claude
178
+ // streams it, so its final result is seen when it is emitted rather than
179
+ // only once the process exits — which, after a background task, it may not.
138
180
  const args = [...invocation.args];
139
- args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'json'] : ['--json']));
140
- return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd), kind), effectiveModel);
181
+ args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'stream-json', '--verbose'] : ['--json']));
182
+ const completion = kind === 'claude' ? claudeResultOutcome : undefined;
183
+ return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion), kind, env), effectiveModel);
184
+ }
185
+ }
186
+
187
+ function openTails(context: SidechannelContext): { stdout: TranscriptTailWriter; stderr: TranscriptTailWriter } | undefined {
188
+ // Same rule as the per-attempt transcript file: no attempt, no tails.
189
+ const attempt = context.attempt;
190
+ if (attempt === undefined) return undefined;
191
+ const identity = { dataDir: context.dataDir, runId: context.runId, stepId: context.stepId, attempt };
192
+ try {
193
+ return { stdout: openTranscriptTail(identity, 'stdout'), stderr: openTranscriptTail(identity, 'stderr') };
194
+ } catch {
195
+ // An id the path cannot carry; the sidechannel declined it the same way.
196
+ return undefined;
141
197
  }
142
198
  }
143
199
 
200
+ /**
201
+ * Every path this process may write under the workspace during an attempt, so
202
+ * the artifact diff can drop all of them.
203
+ *
204
+ * Derived from the attempt identity — the same input `openTranscriptWriter` and
205
+ * `openTails` use to decide where to write — and never from the result. The
206
+ * transcript pointer on the result is optional: `finish` drops it when the
207
+ * close outruns `TAIL_CLOSE_TIMEOUT_MS` and `discardTranscript` drops it on
208
+ * abort, and on both paths the file is already on disk. An exclusion built from
209
+ * that pointer therefore lost the transcript exactly when the close was slow,
210
+ * which is the moment the file is most likely to still be there.
211
+ *
212
+ * Empty when there is no sidechannel or no attempt to name, which is exactly
213
+ * when nothing is written.
214
+ */
215
+ function ownEvidencePaths(context: SidechannelContext | undefined): string[] {
216
+ const attempt = context?.attempt;
217
+ if (context === undefined || attempt === undefined) return [];
218
+ const identity = { dataDir: context.dataDir, runId: context.runId, stepId: context.stepId, attempt };
219
+ try {
220
+ // For each tail, both the finished file and the `.tmp` the writer stages
221
+ // and renames over (`transcript-tail.ts`): a close that timed out or a
222
+ // flush that failed can leave the staging file behind, and it is no more
223
+ // agent-authored than the file it was going to become.
224
+ const paths = [transcriptPath(identity, attempt)];
225
+ for (const stream of ['stdout', 'stderr'] as const) {
226
+ const tail = transcriptTailPath(identity, stream);
227
+ paths.push(tail, `${tail}.tmp`);
228
+ }
229
+ return paths;
230
+ } catch {
231
+ // An id the path cannot carry; nothing was written under it either.
232
+ return [];
233
+ }
234
+ }
235
+
236
+ /** Symlink-free form of a path, or the path itself when it cannot be resolved. */
237
+ function realPath(path: string | undefined): string | undefined {
238
+ if (path === undefined) return undefined;
239
+ try { return realpathSync(path); } catch { return path; }
240
+ }
241
+
144
242
  /** One agent at a time per canonical working directory, for the artifact interval. */
145
243
  const directoryQueues = new Map<string, Promise<unknown>>();
146
244
  function serializedByDirectory<T>(directory: string, task: () => Promise<T>): Promise<T> {
@@ -186,13 +284,14 @@ async function runViaAgentRelay(
186
284
  }
187
285
  }
188
286
 
189
- function redactRelayError(message: string): string {
190
- for (const key of ['RELAY_AGENT_TOKEN', 'RELAY_API_KEY']) {
191
- const secret = process.env[key];
192
- if (secret) message = message.replaceAll(secret, '[redacted]');
193
- }
194
- return message.replace(/\b(?:at|rk|nt|ot|br|arr)_(?:live_)?[A-Za-z0-9_-]+/g, '[redacted]');
195
- }
287
+ /**
288
+ * How long a CLI that has reported its final result may take to exit. Claude
289
+ * Code in print mode waits, after its result, for every background task it
290
+ * started to finish; one that never finishes kept a finished step running
291
+ * until the whole run's deadline. Past this grace the result stands and the
292
+ * tree is stopped.
293
+ */
294
+ export const RESULT_EXIT_GRACE_MS = 30_000;
196
295
 
197
296
  async function spawnInvocation(
198
297
  cli: string,
@@ -201,17 +300,36 @@ async function spawnInvocation(
201
300
  signal?: AbortSignal,
202
301
  sidechannel?: SidechannelContext,
203
302
  cwd?: string,
303
+ completion?: (line: string) => { failed: boolean } | undefined,
204
304
  ): Promise<WorkerCliResult> {
205
305
  let writeInput: (bytes: Buffer) => Promise<boolean> = async () => false;
206
306
  let canDrive = () => false;
207
307
  let driven = false;
308
+ // The transcript file lives beside the PTY socket and is named by attempt.
309
+ // Its absence (no data dir, no attempt, unwritable dir) costs the step
310
+ // nothing: the digest is built from the buffered frames regardless. Opened
311
+ // BEFORE the sidechannel: once `onReady` has fired, a drive peer's HELLO may
312
+ // arrive at any time, and nothing may sit between that and the spawn that
313
+ // arms `canDrive`.
314
+ const writer: TranscriptWriter | undefined = sidechannel?.attempt === undefined ? undefined
315
+ : await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), env);
208
316
  const channel = sidechannel === undefined ? undefined : await openSidechannel({
209
317
  ...sidechannel,
210
318
  onDrive() { driven = true; sidechannel.onDrive(); },
211
319
  }, bytes => writeInput(bytes), () => canDrive());
212
- if (signal?.aborted) { channel?.close(); signal.throwIfAborted(); }
320
+ // Tee the transcript into bounded tail files beside the socket. Evidence
321
+ // for `flows status --tail`, never the record; a failure here is a warning.
322
+ const tails = sidechannel === undefined ? undefined : openTails(sidechannel);
323
+ if (signal?.aborted) {
324
+ channel?.close();
325
+ void writer?.close();
326
+ void tails?.stdout.close(); void tails?.stderr.close();
327
+ signal.throwIfAborted();
328
+ }
213
329
  return new Promise((resolve) => {
214
- const ownsGroup = ownsProcessGroup(signal);
330
+ // Always a group of its own off Windows, so every stop — and
331
+ // `reapOnExit` — reaches the whole agent tree, lease-bound or not.
332
+ const ownsGroup = process.platform !== 'win32';
215
333
  const child = spawn(cli, invocation.args, {
216
334
  stdio: ['pipe', 'pipe', 'pipe'], env,
217
335
  detached: ownsGroup,
@@ -232,22 +350,58 @@ async function spawnInvocation(
232
350
  child.stdin.write(bytes, error => resolve(!error));
233
351
  });
234
352
  const stop = childStop(child, ownsGroup);
353
+ const release = ownsGroup ? reapOnExit(stop) : () => {};
235
354
  const stdout: Buffer[] = [];
236
355
  const stderr: Buffer[] = [];
237
356
  let settled = false;
238
357
  let timer: NodeJS.Timeout | undefined;
239
- const finish = (result: WorkerCliResult): void => {
358
+ let graceTimer: NodeJS.Timeout | undefined;
359
+ const decoder = new StringDecoder('utf8');
360
+ let partialLine = '';
361
+ const finish = (result: WorkerCliResult, discardTranscript = false): void => {
240
362
  if (settled) return;
241
363
  settled = true;
242
364
  if (inputTimer !== undefined) clearTimeout(inputTimer);
243
365
  channel?.close();
244
366
  if (timer !== undefined) clearTimeout(timer);
367
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
245
368
  signal?.removeEventListener('abort', onAbort);
246
- resolve(result);
369
+ if (writer === undefined && tails === undefined) { resolve(result); return; }
370
+ if (writer !== undefined) {
371
+ const rest = partialLine + decoder.end();
372
+ if (rest.length > 0) writer.write(rest);
373
+ }
374
+ // Two independent pieces of evidence close here: the per-attempt
375
+ // transcript, whose descriptor rides back on the result as the digest's
376
+ // file pointer, and the `flows status --tail` tails, which nothing reads
377
+ // from the result.
378
+ //
379
+ // Neither may hold a step open. A stalled write would leave
380
+ // `runAgentCli` unresolved forever, and every later abort or timeout is
381
+ // already a no-op once `settled` is true — so the whole close is raced
382
+ // against a bounded deadline and the result stands either way. Past the
383
+ // deadline the transcript pointer is dropped rather than waited for: a
384
+ // completion without a pointer is recoverable, a step that never
385
+ // completes is not.
386
+ // The writer's result is recorded the moment it lands, not read out of
387
+ // the combined race: the two closes are independent, and a stalled tail
388
+ // must not throw away a transcript that finished and is on disk.
389
+ let transcriptFile: TranscriptFile | undefined;
390
+ const closedWriter = writer === undefined
391
+ ? Promise.resolve()
392
+ : writer.close().then((file) => { transcriptFile = file; }, () => {});
393
+ const closedTails = tails === undefined
394
+ ? Promise.resolve()
395
+ : Promise.all([tails.stdout.close(), tails.stderr.close()]).then(() => undefined, () => undefined);
396
+ const deadline = new Promise<void>((done) => setTimeout(done, TAIL_CLOSE_TIMEOUT_MS).unref?.());
397
+ void Promise.race([Promise.all([closedWriter, closedTails]), deadline]).then(() => {
398
+ resolve(discardTranscript || transcriptFile === undefined
399
+ ? result : { ...result, transcript: { file: transcriptFile } });
400
+ }, () => resolve(result));
247
401
  };
248
402
  const onAbort = (): void => {
249
403
  stop.kill();
250
- finish({ exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.' });
404
+ finish({ exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.' }, true);
251
405
  };
252
406
  /**
253
407
  * Same invariant as `wrapper-session.ts`: `'close'` and `'error'` are
@@ -263,13 +417,51 @@ async function spawnInvocation(
263
417
  };
264
418
  signal?.addEventListener('abort', onAbort, { once: true });
265
419
  if (signal?.aborted) onAbort();
266
- child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk); channel?.publish(chunk); });
267
- child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk); channel?.publish(chunk); });
420
+ /**
421
+ * The CLI has reported its final result. A normal exit inside the grace
422
+ * settles through `'close'` exactly as before, with the real exit code.
423
+ * Past it, the result settles the step with the exit status that result
424
+ * would have carried, and the stop outlives the settle as the timeout's
425
+ * does.
426
+ */
427
+ const onResult = (outcome: { failed: boolean }): void => {
428
+ if (graceTimer !== undefined || settled) return;
429
+ graceTimer = setTimeout(() => {
430
+ stop.terminate();
431
+ finish({
432
+ exit_code: outcome.failed ? 1 : 0,
433
+ stdout_tail: Buffer.concat(stdout).toString('utf8'),
434
+ stderr_tail: `${Buffer.concat(stderr).toString('utf8')}\nCLI reported its final result but had not exited ${RESULT_EXIT_GRACE_MS}ms later; its process tree was stopped.`.trim(),
435
+ });
436
+ }, RESULT_EXIT_GRACE_MS);
437
+ };
438
+ child.stdout.on('data', (chunk: Buffer) => {
439
+ stdout.push(chunk);
440
+ channel?.publish(chunk);
441
+ // The tails take raw bytes, so they are fed before any line splitting
442
+ // and regardless of what the writer or the detector still wants.
443
+ tails?.stdout.append(chunk);
444
+ // Lines are split whenever a writer or a completion detector wants
445
+ // them; the detector stops looking once a result has been seen, the
446
+ // writer keeps every line to the end.
447
+ if (writer === undefined && (completion === undefined || graceTimer !== undefined)) return;
448
+ const lines = (partialLine + decoder.write(chunk)).split('\n');
449
+ partialLine = lines.pop() ?? '';
450
+ for (const line of lines) {
451
+ writer?.write(line);
452
+ if (completion === undefined || graceTimer !== undefined) continue;
453
+ const outcome = completion(line);
454
+ if (outcome !== undefined) onResult(outcome);
455
+ }
456
+ });
457
+ child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk); channel?.publish(chunk); tails?.stderr.append(chunk); });
268
458
  child.once('error', (error) => finishOnChildExit({
269
459
  exit_code: null,
270
460
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
271
461
  stderr_tail: error.message,
272
462
  }));
463
+ child.once('error', () => { if (child.pid === undefined) release(); });
464
+ child.once('close', release);
273
465
  child.once('close', (code) => finishOnChildExit({
274
466
  exit_code: code,
275
467
  stdout_tail: Buffer.concat(stdout).toString('utf8'),