@sagentlab/navarch-runtime 0.1.87 → 0.1.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,7 +72,11 @@ machine.
72
72
  During a connected run, the runtime reports preparation, agent start, output
73
73
  activity, and completion checks to the control plane. Project members can see
74
74
  these phase updates in the task view. The activity signal contains no prompt
75
- or CLI output; the redacted transcript is uploaded when the run finishes.
75
+ or CLI output. The redacted transcript is uploaded to the session's private
76
+ transcript object every 10 seconds while the agent produces output (backing
77
+ off to 30–60 seconds for multi-megabyte transcripts), and once more in full
78
+ when the run finishes, so the task view can follow a connected run live.
79
+ Live snapshots hold the current turn's stderr back until the turn ends.
76
80
 
77
81
  The machine operator selects **Claude Code**, **OpenAI Codex**, **Google
78
82
  Gemini CLI**, **OpenCode**, or an **Agent Client Protocol (ACP)** agent when connecting a worker. Any selected agent can
@@ -751,7 +755,7 @@ cli.cts
751
755
  into Docker when NAVARCH_SANDBOX_MODE=docker
752
756
  4. selectAdapter(config.agentType) (adapters/index.cts) picks one AgentAdapter
753
757
  (adapters/types.cts) by NAVARCH_AGENT, then .run(...):
754
- - claudeCodeAdapter (adapters/claude.cts) — `claude -p --mcp-config <path> < prompt`
758
+ - claudeCodeAdapter (adapters/claude.cts) — `claude -p --mcp-config <path> --output-format stream-json --verbose < prompt`
755
759
  - codexAdapter (adapters/codex.cts) — `codex exec - --json -c mcp_servers.*=... < prompt`
756
760
  - geminiAdapter (adapters/gemini.cts) — `gemini --output-format stream-json < prompt`
757
761
  - openCodeAdapter (adapters/opencode.cts) — `opencode run --format json < prompt`
@@ -871,16 +875,17 @@ Claude Code headless → PR opens on GitHub → complete lands with evidence;
871
875
  secrets absent from disk after exit"):
872
876
 
873
877
  - Actually running `claude -p` headless and confirming its real
874
- `--output-format json` shape (WP-13). `adapters/claude.cts` now appends
875
- `--output-format json` by default and best-effort parses stdout as a single
876
- JSON result object shaped like `{ type, subtype, is_error, result,
878
+ result shape (WP-13). `adapters/claude.cts` appends
879
+ `--output-format stream-json --verbose` by default, so the uploaded
880
+ transcript records every message and tool call, and best-effort parses the
881
+ last `type: "result"` record on stdout as a JSON result object shaped like `{ type, subtype, is_error, result,
877
882
  session_id, total_cost_usd, usage: { input_tokens, output_tokens,
878
883
  cache_creation_input_tokens, cache_read_input_tokens } }` (see
879
884
  `exit-conditions.cts#parseClaudeJsonResult` for the exact assumption and
880
885
  fallback behavior). Unit tests (`tests/adapter.test.cts`,
881
886
  `tests/exit-conditions.test.cts`) cover the parsing and arg-construction
882
- logic against fixed JSON strings; what's untested offline is whether a real
883
- `claude -p --output-format json` invocation actually produces this shape.
887
+ logic against fixed JSON strings recorded from Claude Code 2.1; a future
888
+ CLI release could still change this shape.
884
889
  If it doesn't, parsing degrades to `null` and `tokensIn`/`tokensOut`/
885
890
  `costUsd` fall back to 0 (session.cts's existing default) — never a thrown
886
891
  error — but cost/spend data silently goes missing until the shape is
@@ -51,6 +51,7 @@ async function runAcpAdapter(options) {
51
51
  child.stdout.on("data", (chunk) => {
52
52
  options.onActivity?.();
53
53
  rawStdout.push(chunk);
54
+ options.onOutput?.("stdout", chunk.toString("utf8"));
54
55
  protocolInput.write(chunk);
55
56
  });
56
57
  child.stdout.on("end", () => protocolInput.end());
@@ -58,6 +59,7 @@ async function runAcpAdapter(options) {
58
59
  child.stderr.on("data", (chunk) => {
59
60
  options.onActivity?.();
60
61
  stderr.push(chunk);
62
+ options.onOutput?.("stderr", chunk);
61
63
  });
62
64
  child.on("error", (error) => {
63
65
  stderr.push(`\n${describeError(error)}`);
@@ -13,7 +13,7 @@ function hasFlag(args, flag) {
13
13
  }
14
14
  /**
15
15
  * Headless Claude Code adapter (project-plan.md §3.9 / implementation-plan.md
16
- * WP-07): `claude -p --mcp-config platform-mcp.json --output-format json`
16
+ * WP-07): `claude -p --mcp-config platform-mcp.json --output-format stream-json --verbose`
17
17
  * with the context bundle on stdin (`claude -p` reads its prompt from stdin
18
18
  * when none is given; Linux rejects any single argv string over 128 KiB, so
19
19
  * an argv prompt failed `spawn E2BIG` before Claude started). Returns raw stdout/stderr/exit signals (plus
@@ -25,11 +25,10 @@ function hasFlag(args, flag) {
25
25
  * (adapters/types.cts) — see adapters/codex.cts for the Codex CLI sibling
26
26
  * session.cts picks between via NAVARCH_AGENT (config.cts's `agentType`).
27
27
  *
28
- * NEEDS LIVE VERIFICATION (WP-13): `--output-format json` is assumed to make
29
- * `claude -p` print a single JSON result object with a `usage` block, per
30
- * Claude Code's documented headless-mode output. This cannot be confirmed
31
- * without a real `claude` binary in this offline build environment. If the
32
- * real shape differs, parseClaudeJsonResult() returns null and
28
+ * `--output-format stream-json` prints one JSON record per line and ends with
29
+ * the same result object (`type: "result"`, `usage`, `total_cost_usd`) that
30
+ * `--output-format json` prints alone; parseClaudeJsonResult() reads that
31
+ * last record. If the real shape differs, parseClaudeJsonResult() returns null and
33
32
  * tokensIn/tokensOut/costUsd fall back to 0 (session.cts's existing
34
33
  * default) rather than throwing -- see runtime/README.md.
35
34
  */
@@ -135,9 +134,14 @@ async function runClaudeAttempt(options, automaticFallback) {
135
134
  // Only append the default when the caller hasn't already asked for a
136
135
  // specific --output-format (extraArgs wins so an operator can opt back
137
136
  // into plain-text output, e.g. while diagnosing a live-verification
138
- // mismatch, without an adapter code change).
139
- if (!options.extraArgs.includes("--output-format")) {
140
- args.push("--output-format", "json");
137
+ // mismatch, without an adapter code change). stream-json records every
138
+ // message and tool call, which the task transcript viewer renders; plain
139
+ // `json` printed only the final result. Claude Code requires --verbose for
140
+ // stream-json under --print, and its last record is the same result object.
141
+ if (!hasFlag(options.extraArgs, "--output-format")) {
142
+ args.push("--output-format", "stream-json");
143
+ if (!hasFlag(options.extraArgs, "--verbose"))
144
+ args.push("--verbose");
141
145
  }
142
146
  args.push(...options.extraArgs);
143
147
  // Project/task policy is appended after machine-wide extra arguments so a
@@ -157,7 +161,7 @@ async function runClaudeAttempt(options, automaticFallback) {
157
161
  const raw = runOptions.dockerExec ? await runViaDocker(runOptions, args) : await runOnHost(runOptions, args);
158
162
  return attachUsage(raw);
159
163
  }
160
- /** Parses stdout for `claude -p --output-format json` usage and folds it onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd unset when parsing fails). */
164
+ /** Parses the final `claude -p --output-format stream-json` result record for usage and folds it onto the raw result (best-effort; leaves tokensIn/tokensOut/costUsd unset when parsing fails). */
161
165
  function attachUsage(result) {
162
166
  const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout);
163
167
  if (!parsed)
@@ -201,11 +205,15 @@ async function runOnHost(options, args) {
201
205
  onAbort();
202
206
  child.stdout.on("data", (d) => {
203
207
  options.onActivity?.();
204
- stdout += d.toString();
208
+ const text = d.toString();
209
+ stdout += text;
210
+ options.onOutput?.("stdout", text);
205
211
  });
206
212
  child.stderr.on("data", (d) => {
207
213
  options.onActivity?.();
208
- stderr += d.toString();
214
+ const text = d.toString();
215
+ stderr += text;
216
+ options.onOutput?.("stderr", text);
209
217
  });
210
218
  child.on("error", (err) => {
211
219
  clearTimeout(timer);
@@ -241,6 +249,8 @@ async function runViaDocker(options, args) {
241
249
  input: options.prompt,
242
250
  timeoutMs: options.timeoutMs,
243
251
  onActivity: options.onActivity,
252
+ onStdout: (chunk) => options.onOutput?.("stdout", chunk),
253
+ onStderr: (chunk) => options.onOutput?.("stderr", chunk),
244
254
  signal: options.signal,
245
255
  });
246
256
  return {
@@ -195,11 +195,15 @@ async function runOnHost(options, args) {
195
195
  onAbort();
196
196
  child.stdout.on("data", (d) => {
197
197
  options.onActivity?.();
198
- stdout += d.toString();
198
+ const text = d.toString();
199
+ stdout += text;
200
+ options.onOutput?.("stdout", text);
199
201
  });
200
202
  child.stderr.on("data", (d) => {
201
203
  options.onActivity?.();
202
- stderr += d.toString();
204
+ const text = d.toString();
205
+ stderr += text;
206
+ options.onOutput?.("stderr", text);
203
207
  });
204
208
  child.on("error", (err) => {
205
209
  clearTimeout(timer);
@@ -237,6 +241,8 @@ async function runViaDocker(options, args) {
237
241
  input: options.prompt,
238
242
  timeoutMs: options.timeoutMs,
239
243
  onActivity: options.onActivity,
244
+ onStdout: (chunk) => options.onOutput?.("stdout", chunk),
245
+ onStderr: (chunk) => options.onOutput?.("stderr", chunk),
240
246
  signal: options.signal,
241
247
  env: { ...process.env, ...options.env },
242
248
  });
@@ -217,11 +217,15 @@ async function runOnHost(options, args) {
217
217
  onAbort();
218
218
  child.stdout.on("data", (data) => {
219
219
  options.onActivity?.();
220
- stdout += data.toString();
220
+ const text = data.toString();
221
+ stdout += text;
222
+ options.onOutput?.("stdout", text);
221
223
  });
222
224
  child.stderr.on("data", (data) => {
223
225
  options.onActivity?.();
224
- stderr += data.toString();
226
+ const text = data.toString();
227
+ stderr += text;
228
+ options.onOutput?.("stderr", text);
225
229
  });
226
230
  child.on("error", (err) => {
227
231
  clearTimeout(timer);
@@ -255,9 +259,10 @@ async function runViaDocker(options, args) {
255
259
  const result = await runner.run("docker", ["exec", "-i", ...forwardedEnv, containerName, "sh", "-c", command], { input: options.prompt,
256
260
  timeoutMs: options.timeoutMs,
257
261
  onActivity: options.onActivity,
262
+ onStdout: (chunk) => options.onOutput?.("stdout", chunk),
263
+ onStderr: (chunk) => options.onOutput?.("stderr", chunk),
258
264
  signal: options.signal,
259
- env: { ...process.env, ...options.env },
260
- });
265
+ env: { ...process.env, ...options.env }, });
261
266
  return {
262
267
  exitCode: result.code,
263
268
  timedOut: false,
@@ -356,12 +356,16 @@ async function runOnHost(options, args, input = "") {
356
356
  onAbort();
357
357
  child.stdout.on("data", (data) => {
358
358
  options.onActivity?.();
359
- stdout += data.toString();
359
+ const text = data.toString();
360
+ stdout += text;
361
+ options.onOutput?.("stdout", text);
360
362
  monitor.push("stdout", data.toString());
361
363
  });
362
364
  child.stderr.on("data", (data) => {
363
365
  options.onActivity?.();
364
- stderr += data.toString();
366
+ const text = data.toString();
367
+ stderr += text;
368
+ options.onOutput?.("stderr", text);
365
369
  monitor.push("stderr", data.toString());
366
370
  });
367
371
  child.on("error", (error) => {
@@ -408,8 +412,8 @@ async function runViaDocker(options, args, input) {
408
412
  timeoutMs: options.timeoutMs,
409
413
  onActivity: options.onActivity,
410
414
  signal: controller.signal,
411
- onStdout: (chunk) => { streamed.stdout = true; monitor.push("stdout", chunk); },
412
- onStderr: (chunk) => { streamed.stderr = true; monitor.push("stderr", chunk); },
415
+ onStdout: (chunk) => { streamed.stdout = true; monitor.push("stdout", chunk); options.onOutput?.("stdout", chunk); },
416
+ onStderr: (chunk) => { streamed.stderr = true; monitor.push("stderr", chunk); options.onOutput?.("stderr", chunk); },
413
417
  env: { ...process.env, ...options.env },
414
418
  });
415
419
  // Also inspect runners that only return buffered output.
@@ -344,11 +344,15 @@ async function runOnHost(options, args, local = false) {
344
344
  onAbort();
345
345
  child.stdout.on("data", (data) => {
346
346
  options.onActivity?.();
347
- stdout += data.toString();
347
+ const text = data.toString();
348
+ stdout += text;
349
+ options.onOutput?.("stdout", text);
348
350
  });
349
351
  child.stderr.on("data", (data) => {
350
352
  options.onActivity?.();
351
- stderr += data.toString();
353
+ const text = data.toString();
354
+ stderr += text;
355
+ options.onOutput?.("stderr", text);
352
356
  });
353
357
  child.on("error", (err) => {
354
358
  clearTimeout(timer);
@@ -408,6 +412,8 @@ async function runViaDocker(options, args) {
408
412
  input: options.prompt,
409
413
  timeoutMs: options.timeoutMs,
410
414
  onActivity: options.onActivity,
415
+ onStdout: (chunk) => options.onOutput?.("stdout", chunk),
416
+ onStderr: (chunk) => options.onOutput?.("stderr", chunk),
411
417
  signal: options.signal,
412
418
  env: { ...process.env, ...options.env },
413
419
  });
@@ -102,11 +102,15 @@ async function runOnHost(options, args) {
102
102
  onAbort();
103
103
  child.stdout.on("data", (d) => {
104
104
  options.onActivity?.();
105
- stdout += d.toString();
105
+ const text = d.toString();
106
+ stdout += text;
107
+ options.onOutput?.("stdout", text);
106
108
  });
107
109
  child.stderr.on("data", (d) => {
108
110
  options.onActivity?.();
109
- stderr += d.toString();
111
+ const text = d.toString();
112
+ stderr += text;
113
+ options.onOutput?.("stderr", text);
110
114
  });
111
115
  child.on("error", (err) => {
112
116
  clearTimeout(timer);
@@ -139,6 +143,8 @@ async function runViaDocker(options, args) {
139
143
  input: options.prompt,
140
144
  timeoutMs: options.timeoutMs,
141
145
  onActivity: options.onActivity,
146
+ onStdout: (chunk) => options.onOutput?.("stdout", chunk),
147
+ onStderr: (chunk) => options.onOutput?.("stderr", chunk),
142
148
  signal: options.signal,
143
149
  });
144
150
  return {
@@ -24,8 +24,9 @@ function agentAuthFailureReport(detail) {
24
24
  "Log the coding agent in again on this machine (`claude /login` or `codex login`), then resume the agent.";
25
25
  }
26
26
  /**
27
- * Best-effort parse of `claude -p --output-format json` stdout into the
28
- * assumed result shape above. Tries the whole trimmed stdout first (the
27
+ * Best-effort parse of `claude -p --output-format json` (or `stream-json`,
28
+ * whose last result record has the same shape) stdout into the assumed
29
+ * result shape above. Tries the whole trimmed stdout first (the
29
30
  * expected case); if that fails, tries each non-empty line that looks like a
30
31
  * JSON object, in case a warning/log line preceded the result (last such
31
32
  * line wins, since the final result is expected last). Returns null rather
@@ -39,6 +40,9 @@ function parseClaudeJsonResult(stdout) {
39
40
  const isUsableResult = (value) => typeof value === "object" &&
40
41
  value !== null &&
41
42
  !Array.isArray(value) &&
43
+ // stream-json output interleaves typed message/tool records with the
44
+ // final `type: "result"` record; only that record carries the outcome.
45
+ (!("type" in value) || value.type === "result") &&
42
46
  (!("result" in value) || typeof value.result === "string") &&
43
47
  ("result" in value || "usage" in value || "cost_usd" in value || "total_cost_usd" in value);
44
48
  try {
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LiveTranscriptPublisher = void 0;
4
+ exports.renderSessionTranscript = renderSessionTranscript;
5
+ exports.renderLiveTranscript = renderLiveTranscript;
6
+ exports.liveUploadIntervalMs = liveUploadIntervalMs;
7
+ /** The shape both the live snapshots and the completion upload use. */
8
+ function renderSessionTranscript(attempts, redact) {
9
+ return attempts
10
+ .flatMap((attempt, index) => [
11
+ `# agent turn ${index + 1} stdout`,
12
+ redact(attempt.stdout),
13
+ "",
14
+ `# agent turn ${index + 1} stderr`,
15
+ redact(attempt.stderr),
16
+ "",
17
+ ])
18
+ .join("\n");
19
+ }
20
+ function renderLiveTranscript(attempts, currentStdout, redact, knownSecrets = []) {
21
+ const finished = attempts.length ? `${renderSessionTranscript(attempts, redact)}\n` : "";
22
+ const end = currentStdout.lastIndexOf("\n");
23
+ // A secret split across the cut would escape redaction, so redact whole lines only.
24
+ let complete = end >= 0 ? currentStdout.slice(0, end + 1) : "";
25
+ // Complete lines can still contain the beginning of a multiline secret.
26
+ // Hold that line and everything after it until redaction can see the value.
27
+ let safeEnd = complete.length;
28
+ for (const secret of knownSecrets) {
29
+ const newline = secret.indexOf("\n");
30
+ if (newline < 0)
31
+ continue;
32
+ const start = complete.lastIndexOf(secret.slice(0, newline + 1));
33
+ if (start >= 0 && complete.length - start < secret.length && secret.startsWith(complete.slice(start))) {
34
+ safeEnd = Math.min(safeEnd, complete.lastIndexOf("\n", start - 1) + 1);
35
+ }
36
+ }
37
+ const pem = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----/g;
38
+ for (const match of complete.matchAll(pem)) {
39
+ if (!complete.includes(`-----END ${match[1]}-----`, match.index + match[0].length)) {
40
+ safeEnd = Math.min(safeEnd, complete.lastIndexOf("\n", match.index - 1) + 1);
41
+ }
42
+ }
43
+ complete = complete.slice(0, safeEnd);
44
+ return `${finished}# agent turn ${attempts.length + 1} stdout\n${redact(complete)}`;
45
+ }
46
+ /** Signed upload URLs last an hour; refresh well before that. */
47
+ const UPLOAD_URL_TTL_MS = 45 * 60_000;
48
+ const DEFAULT_INTERVAL_MS = 10_000;
49
+ function liveUploadIntervalMs(bytes, baseMs = DEFAULT_INTERVAL_MS) {
50
+ if (bytes > 8 * 1024 * 1024)
51
+ return Math.max(baseMs, 60_000);
52
+ if (bytes > 1024 * 1024)
53
+ return Math.max(baseMs, 30_000);
54
+ return baseMs;
55
+ }
56
+ class LiveTranscriptPublisher {
57
+ options;
58
+ now;
59
+ intervalMs;
60
+ timer = null;
61
+ inFlight = null;
62
+ dirty = false;
63
+ stopped = false;
64
+ paused = false;
65
+ lastUploadedAt = 0;
66
+ lastUploaded = "";
67
+ uploadUrl = null;
68
+ published = null;
69
+ constructor(options) {
70
+ this.options = options;
71
+ this.now = options.now ?? Date.now;
72
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
73
+ }
74
+ /** Call whenever output arrives or a turn ends. */
75
+ markDirty() {
76
+ if (this.stopped)
77
+ return;
78
+ this.paused = false;
79
+ this.dirty = true;
80
+ this.schedule();
81
+ }
82
+ /** Locator of the last live snapshot that reached storage, if any. */
83
+ get publishedUrl() {
84
+ return this.published;
85
+ }
86
+ /** Cancels a pending upload and waits for an in-flight one, so neither can
87
+ * overwrite the completion transcript. A later markDirty() re-arms it. */
88
+ async idle() {
89
+ this.paused = true;
90
+ this.dirty = false;
91
+ if (this.timer)
92
+ clearTimeout(this.timer);
93
+ this.timer = null;
94
+ await this.inFlight;
95
+ }
96
+ async stop() {
97
+ this.stopped = true;
98
+ await this.idle();
99
+ }
100
+ schedule() {
101
+ if (this.timer || this.inFlight || this.stopped || this.paused)
102
+ return;
103
+ const gap = liveUploadIntervalMs(this.lastUploaded.length, this.intervalMs);
104
+ const wait = Math.max(0, this.lastUploadedAt + gap - this.now());
105
+ this.timer = setTimeout(() => {
106
+ this.timer = null;
107
+ this.inFlight = this.flush().finally(() => {
108
+ this.inFlight = null;
109
+ if (this.dirty)
110
+ this.schedule();
111
+ });
112
+ }, wait);
113
+ this.timer.unref?.();
114
+ }
115
+ async flush() {
116
+ if (this.stopped)
117
+ return;
118
+ this.dirty = false;
119
+ const content = this.options.render();
120
+ if (content === this.lastUploaded)
121
+ return;
122
+ try {
123
+ if (!this.uploadUrl || this.now() - this.uploadUrl.at > UPLOAD_URL_TTL_MS) {
124
+ const { uploadUrl, publicUrl } = await this.options.requestUploadUrl();
125
+ this.uploadUrl = { url: uploadUrl, publicUrl, at: this.now() };
126
+ }
127
+ if (this.stopped)
128
+ return;
129
+ await this.options.upload(this.uploadUrl.url, content);
130
+ this.lastUploaded = content;
131
+ this.published = this.uploadUrl.publicUrl;
132
+ }
133
+ catch (error) {
134
+ // A rejected signature is refreshed on the next attempt.
135
+ this.uploadUrl = null;
136
+ this.options.onError?.(error);
137
+ }
138
+ finally {
139
+ this.lastUploadedAt = this.now();
140
+ }
141
+ }
142
+ }
143
+ exports.LiveTranscriptPublisher = LiveTranscriptPublisher;
package/dist/session.cjs CHANGED
@@ -15,6 +15,7 @@ const index_cjs_1 = require("./adapters/index.cjs");
15
15
  const exit_conditions_cjs_1 = require("./exit-conditions.cjs");
16
16
  const redact_cjs_1 = require("./redact.cjs");
17
17
  const upload_cjs_1 = require("./upload.cjs");
18
+ const live_transcript_cjs_1 = require("./live-transcript.cjs");
18
19
  const prompt_cjs_1 = require("./prompt.cjs");
19
20
  const mcp_config_cjs_1 = require("./mcp-config.cjs");
20
21
  const logger_cjs_1 = require("./logger.cjs");
@@ -441,6 +442,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
441
442
  abortForRuntimeShutdown();
442
443
  lifecycle.enteredMainSessionTry = true;
443
444
  let browserEvidence;
445
+ let liveTranscript;
444
446
  try {
445
447
  reportActivity("preparing");
446
448
  await gitWorktree.prepare();
@@ -494,6 +496,18 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
494
496
  const adapter = (0, index_cjs_1.selectAdapter)(runtime);
495
497
  const { bin, extraArgs } = adapterCommand(config, runtime);
496
498
  const attempts = [];
499
+ // The task view follows the run through periodic redacted snapshots of
500
+ // the same object the completion upload below finally overwrites.
501
+ let liveStdout = "";
502
+ liveTranscript = new live_transcript_cjs_1.LiveTranscriptPublisher({
503
+ requestUploadUrl: async () => {
504
+ const { upload_url, public_url } = await api.getTranscriptUploadUrl(leaseId);
505
+ return { uploadUrl: upload_url, publicUrl: public_url };
506
+ },
507
+ upload: upload_cjs_1.uploadTranscript,
508
+ render: () => (0, live_transcript_cjs_1.renderLiveTranscript)(attempts, liveStdout, (text) => (0, redact_cjs_1.redactText)(text, registry.list()), registry.list()),
509
+ onError: (error) => log.warn(`live transcript upload failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(error), registry.list())}`),
510
+ });
497
511
  const agentBudgetStartedAt = node_perf_hooks_1.performance.now();
498
512
  const maxRuntimeMs = task.max_runtime_ms ?? config.sessionTimeoutMs;
499
513
  let nextPrompt = null;
@@ -544,6 +558,12 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
544
558
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
545
559
  signal: activeAbortController.signal,
546
560
  onActivity: () => { outputObservedAt = Date.now(); outputUpdates += 1; },
561
+ onOutput: (stream, chunk) => {
562
+ if (stream !== "stdout")
563
+ return;
564
+ liveStdout += chunk;
565
+ liveTranscript?.markDirty();
566
+ },
547
567
  }, task.idle_timeout_ms ?? config.sessionIdleTimeoutMs ?? 45 * 60_000, runtime === "opencode" ? config.opencodeStartupTimeoutMs : 0);
548
568
  activeAbortController = null;
549
569
  adapterRunning = false;
@@ -554,6 +574,8 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
554
574
  executionReport = { ...executionReport, model: turnResult.model };
555
575
  }
556
576
  attempts.push(turnResult);
577
+ liveStdout = "";
578
+ liveTranscript.markDirty();
557
579
  reportActivity("finishing");
558
580
  const capacityLimit = (0, adapter_capacity_cjs_1.detectAdapterCapacityLimit)(turnResult);
559
581
  if (capacityLimit) {
@@ -691,16 +713,8 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
691
713
  if (mapping.leaseOutcome === "failed") {
692
714
  log.warn(`adapter failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets)}`);
693
715
  }
694
- const transcript = attempts
695
- .flatMap((attempt, index) => [
696
- `# agent turn ${index + 1} stdout`,
697
- (0, redact_cjs_1.redactText)(attempt.stdout, knownSecrets),
698
- "",
699
- `# agent turn ${index + 1} stderr`,
700
- (0, redact_cjs_1.redactText)(attempt.stderr, knownSecrets),
701
- "",
702
- ])
703
- .join("\n");
716
+ const transcript = (0, live_transcript_cjs_1.renderSessionTranscript)(attempts, (text) => (0, redact_cjs_1.redactText)(text, knownSecrets));
717
+ await liveTranscript.idle();
704
718
  let transcriptUrl;
705
719
  try {
706
720
  const { upload_url, public_url } = await api.getTranscriptUploadUrl(leaseId);
@@ -711,6 +725,8 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
711
725
  // The message carries up to 500 chars of the storage response body, so
712
726
  // redact it like every other warn in this block.
713
727
  log.warn(`transcript upload failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}`);
728
+ // The last live snapshot is still in storage; keep it reviewable.
729
+ transcriptUrl = liveTranscript.publishedUrl ?? undefined;
714
730
  }
715
731
  const redactedReport = (0, redact_cjs_1.redactText)(mapping.reportSummary, knownSecrets);
716
732
  const completion = {
@@ -800,6 +816,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
800
816
  }
801
817
  }
802
818
  catch (err) {
819
+ await liveTranscript?.stop();
820
+ // A live snapshot already in storage is the best transcript a crash has.
821
+ const crashTranscriptUrl = lastCompletion?.transcript_url ?? liveTranscript?.publishedUrl;
803
822
  const knownSecrets = registry.list();
804
823
  // NavarchApiError's message is only the status line; the control plane's
805
824
  // explanation rides in `err.body`. Keep it in the agent log (redacted like
@@ -820,9 +839,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
820
839
  failed_before_start: !adapterStarted,
821
840
  evidence_urls: lastCompletion?.evidence_urls ?? [],
822
841
  cost: lastCompletion?.cost ?? { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
823
- ...(lastCompletion?.transcript_url
824
- ? { transcript_url: lastCompletion.transcript_url }
825
- : {}),
842
+ ...(crashTranscriptUrl ? { transcript_url: crashTranscriptUrl } : {}),
826
843
  exit_status: "crashed",
827
844
  agent_type: runtime,
828
845
  ...executionReport,
@@ -830,6 +847,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
830
847
  .catch((completeErr) => log.warn(`complete() after crash also failed: ${String(completeErr)}`));
831
848
  }
832
849
  finally {
850
+ await liveTranscript?.stop();
833
851
  deps.shutdownSignal?.removeEventListener("abort", abortForRuntimeShutdown);
834
852
  sessionClosing = true;
835
853
  clearInterval(heartbeatTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.87",
3
+ "version": "0.1.89",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",