feat(hetero): add --raw-dump to persist agent raw stream-json for debugging (#15602)

*  feat(hetero): add --raw-dump to persist agent raw stream-json for debugging

The remote-device path (`spawnLhHeteroExec`) leaves no local execution
record: `lh hetero exec` consumes the agent's stdout internally and only
POSTs adapted events to the server, so a misbehaving remote run can't be
inspected. The adapted/ingested view also can't distinguish a CC-side
empty `tool_result` from an adapter extraction bug.

Add `lh hetero exec --raw-dump <dir>`: spawnAgent gains an `onRawStdout`
tee that captures the child's untouched stdout BEFORE the adapter; the
CLI writes it (plus stderr + a meta.json) to
`<dir>/<timestamp>-<operationId>/`, one file pair per spawn attempt.
Fully best-effort — a dump failure never affects the run or exit code.

Wire the desktop device path to pass `--raw-dump` (gated by the existing
`shouldTraceCliOutput` toggle, into `resolveTraceRootDir`), so remote-device
CC runs now leave a raw stream on the device — the same toggle/location the
local trace path already uses. Reusable later for the server sandbox path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔖 chore(cli): bump version to 0.0.27

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-09 22:16:05 +08:00
committed by GitHub
parent ce5833cb67
commit e01cadb779
7 changed files with 241 additions and 5 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.26" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.27" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.26",
"version": "0.0.27",
"type": "module",
"bin": {
"lh": "./dist/index.js",
+55
View File
@@ -1,3 +1,6 @@
import { mkdtemp, readdir, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import { Command } from 'commander';
@@ -716,4 +719,56 @@ describe('hetero exec command', () => {
// Second snapshot carries ONLY the second message — not "first messagesecond message".
expect(textSnapshots).toEqual(['first message', 'second message']);
});
it('--raw-dump writes a session folder with meta.json, wires onRawStdout, and tees stderr', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'hetero-rawdump-'));
mockSpawnAgent.mockReturnValue(
createFakeHandle({
events: [
{
data: { chunkType: 'text', content: 'hi' },
operationId: 'op-raw',
stepIndex: 0,
timestamp: 1,
type: 'stream_chunk',
},
],
exitCode: 0,
stderrChunks: ['warning: something happened\n'],
}),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--operation-id',
'op-raw',
'--render',
'none',
'--raw-dump',
root,
]);
// The raw stdout tee is handed to spawnAgent (the package captures the
// pre-adapter bytes — exercised in spawnAgent.test.ts).
expect(typeof mockSpawnAgent.mock.calls[0][0].onRawStdout).toBe('function');
// One session folder per exec, keyed by the operation id.
const sessions = await readdir(root);
expect(sessions).toHaveLength(1);
expect(sessions[0]).toContain('op-raw');
const sessionDir = path.join(root, sessions[0]!);
const meta = JSON.parse(await readFile(path.join(sessionDir, 'meta.json'), 'utf8'));
expect(meta).toMatchObject({ agentType: 'claude-code', operationId: 'op-raw' });
// stderr is teed to the attempt's log file.
const stderrDump = await readFile(path.join(sessionDir, 'attempt-1.stderr.log'), 'utf8');
expect(stderrDump).toContain('warning: something happened');
});
});
+106 -2
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { readFile } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type {
@@ -59,6 +60,12 @@ interface ExecOptions {
inputJson?: string;
operationId?: string;
prompt?: string;
/**
* When set, persist the agent process's RAW stdout/stderr (pre-adapter
* stream-json) under `<rawDump>/<timestamp>-<operationId>/` for debugging.
* Independent of `--render` and the server ingest path.
*/
rawDump?: string;
/**
* Output rendering mode.
* jsonl — emit each `AgentStreamEvent` as a JSONL line on stdout (default
@@ -291,6 +298,77 @@ class SerialServerIngester {
}
}
interface RawStreamDumpAttempt {
/** Flush + close both file streams. Resolves once the bytes are on disk. */
close: () => Promise<void>;
writeStderr: (chunk: Buffer) => void;
writeStdout: (chunk: Buffer) => void;
}
/**
* Persists the agent process's RAW stdout/stderr — the untouched stream-json,
* BEFORE the adapter — to disk for post-hoc debugging. The adapted/ingested
* view can't tell a CC-side empty `tool_result` apart from an adapter
* extraction bug; the raw dump can.
*
* Enabled via `lh hetero exec --raw-dump <dir>`. Each exec gets its own
* `<dir>/<timestamp>-<operationId>/` session folder; each spawn attempt (the
* resume retry is a second attempt) writes `<label>.stdout.jsonl` /
* `<label>.stderr.log`. Fully best-effort: any dump failure is logged and
* swallowed so it never affects the run or its exit code.
*
* Future: the server-side sandbox runner (`spawnHeteroSandbox`) and the
* desktop device path (`spawnLhHeteroExec`) can pass `--raw-dump` pointing at
* a collectable location to capture remote runs the same way.
*/
class RawStreamDump {
private constructor(private readonly dir: string) {}
static async create(
root: string,
operationId: string,
meta: Record<string, unknown>,
): Promise<RawStreamDump | undefined> {
try {
const safeTs = new Date().toISOString().replaceAll(/[.:]/g, '-');
const dir = path.join(path.resolve(root), `${safeTs}-${operationId}`);
await mkdir(dir, { recursive: true });
await writeFile(
path.join(dir, 'meta.json'),
`${JSON.stringify({ ...meta, operationId, startedAt: new Date().toISOString() }, null, 2)}\n`,
);
log.info(`Raw stream dump enabled → ${dir}`);
return new RawStreamDump(dir);
} catch (err) {
log.warn(
`Failed to initialize raw stream dump: ${err instanceof Error ? err.message : String(err)}`,
);
return undefined;
}
}
openAttempt(label: string): RawStreamDumpAttempt {
const stdout = createWriteStream(path.join(this.dir, `${label}.stdout.jsonl`));
const stderr = createWriteStream(path.join(this.dir, `${label}.stderr.log`));
// A failed dump write must never crash the run — drop write errors.
stdout.on('error', () => {});
stderr.on('error', () => {});
return {
close: () =>
Promise.all([
new Promise<void>((resolve) => stdout.end(() => resolve())),
new Promise<void>((resolve) => stderr.end(() => resolve())),
]).then(() => undefined),
writeStderr: (chunk: Buffer) => {
stderr.write(chunk);
},
writeStdout: (chunk: Buffer) => {
stdout.write(chunk);
},
};
}
}
const exec = async (options: ExecOptions): Promise<void> => {
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
log.error(
@@ -325,6 +403,17 @@ const exec = async (options: ExecOptions): Promise<void> => {
const operationId = options.operationId || randomUUID();
// Optional raw stream dump (pre-adapter stdout/stderr) for debugging.
let rawDump: RawStreamDump | undefined;
if (options.rawDump) {
rawDump = await RawStreamDump.create(options.rawDump, operationId, {
agentType: options.type,
cwd: options.cwd || process.cwd(),
resume: options.resume ?? null,
topicId: options.topic ?? null,
});
}
// Determine JSONL output mode.
// Explicit --render flag always wins. Otherwise: emit JSONL in standalone
// mode; suppress in server-ingest mode (sink handles the data path).
@@ -368,6 +457,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
const runOneAgent = async (
spawnOpts: Parameters<typeof spawnAgent>[0],
interceptResumeErrors: boolean,
runLabel: string,
): Promise<{
code: number | null;
ingestError: boolean;
@@ -376,12 +466,17 @@ const exec = async (options: ExecOptions): Promise<void> => {
signal: NodeJS.Signals | null;
stderrContent: string;
}> => {
// One raw-dump file pair per spawn attempt (the resume retry is a second
// attempt). The stdout tee runs inside `spawnAgent` before the adapter.
const dumpAttempt = rawDump?.openAttempt(runLabel);
// `spawnAgent` is async and can reject DURING image normalization — fetch
// failures, missing local --image paths, decode errors.
let handle: Awaited<ReturnType<typeof spawnAgent>>;
try {
handle = await spawnAgent(spawnOpts);
handle = await spawnAgent({ ...spawnOpts, onRawStdout: dumpAttempt?.writeStdout });
} catch (err) {
await dumpAttempt?.close();
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -398,6 +493,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
if (stderrContent.length < STDERR_CAP) {
stderrContent += chunk.toString();
}
dumpAttempt?.writeStderr(chunk);
});
handle.stderr.pipe(process.stderr);
@@ -471,6 +567,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
// best-effort
}
}
await dumpAttempt?.close();
process.exit(1);
} finally {
process.off('SIGINT', onSigint);
@@ -479,6 +576,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
const { code, signal } = await handle.exit;
await stderrEnded;
await dumpAttempt?.close();
// Fallback stderr detection: CC may exit non-zero without emitting a
// result event (e.g. it writes to stderr and quits immediately).
@@ -514,6 +612,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
resumeSessionId: options.resume,
},
interceptResume,
'attempt-1',
);
// ─── Auto-retry without --resume when the session cannot be used ─────────
@@ -542,6 +641,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
// No resumeSessionId — start fresh
},
false, // no need to intercept resume errors on a fresh run
'attempt-2-noresume',
);
}
@@ -629,5 +729,9 @@ export function registerHeteroCommand(program: Command) {
'--render <mode>',
'Output mode: jsonl (emit events as JSONL on stdout) | none (suppress stdout). Defaults to jsonl in standalone, none in server-ingest mode.',
)
.option(
'--raw-dump <dir>',
'Persist the agent process RAW stdout/stderr (pre-adapter stream-json) under <dir>/<timestamp>-<operationId>/ for debugging. Each spawn attempt writes its own .stdout.jsonl / .stderr.log. Best-effort; never affects the run.',
)
.action(exec);
}
@@ -1338,6 +1338,14 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
} = params;
const workDir = cwd ?? process.cwd();
// When CLI tracing is enabled (dev builds, or the Help-menu toggle in
// packaged builds), have `lh hetero exec` persist the agent process's RAW
// stream-json (pre-adapter) on this device. The remote-device path
// otherwise leaves no local record — the CLI consumes stdout internally and
// only POSTs adapted events to the server — so without this there's nothing
// to inspect when a remote run misbehaves.
const rawDumpDir = this.shouldTraceCliOutput ? this.resolveTraceRootDir(workDir) : undefined;
const args = [
'hetero',
'exec',
@@ -1354,6 +1362,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
'--cwd',
workDir,
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
...(rawDumpDir ? ['--raw-dump', rawDumpDir] : []),
];
const env = {
@@ -493,4 +493,51 @@ describe('spawnAgent', () => {
}
}).rejects.toThrow(/boom/);
});
it('tees the child raw stdout to onRawStdout verbatim, before adapting', async () => {
const fake = createFakeProc({ stdoutChunks: [ccInit, ccText] });
nextFakeProc = fake.proc;
const rawChunks: string[] = [];
const { spawnAgent } = await import('./spawnAgent');
const handle = await spawnAgent({
agentType: 'claude-code',
onRawStdout: (chunk) => rawChunks.push(chunk.toString()),
operationId: 'op-1',
prompt: 'go',
});
fake.start();
const events: any[] = [];
for await (const event of handle.events) events.push(event);
await handle.exit;
// The dump receives the untouched stream-json bytes — exactly what CC
// emitted — regardless of how the adapter parses them into events.
expect(rawChunks.join('')).toBe(`${ccInit}${ccText}`);
// ...and the adapter pipeline still produced events from the same stdout.
expect(events.length).toBeGreaterThan(0);
});
it('does not let a throwing onRawStdout disrupt the stream', async () => {
const fake = createFakeProc({ stdoutChunks: [ccInit, ccText] });
nextFakeProc = fake.proc;
const { spawnAgent } = await import('./spawnAgent');
const handle = await spawnAgent({
agentType: 'claude-code',
onRawStdout: () => {
throw new Error('dump sink exploded');
},
operationId: 'op-1',
prompt: 'go',
});
fake.start();
const events: any[] = [];
for await (const event of handle.events) events.push(event);
await handle.exit;
expect(events.length).toBeGreaterThan(0);
});
});
@@ -36,6 +36,15 @@ export interface SpawnAgentOptions {
* plain string this is unused.
*/
inputOptions?: BuildAgentInputOptions;
/**
* Optional tee for the child's RAW stdout bytes, invoked synchronously for
* every chunk BEFORE the adapter sees it. The pipeline still consumes stdout
* normally — this is a pure side-channel. `lh hetero exec --raw-dump` wires
* it to a file writer so the untouched stream-json can be inspected after the
* fact (e.g. to tell a CC-side empty `tool_result` apart from an adapter
* extraction bug, which the adapted/ingested view alone can't distinguish).
*/
onRawStdout?: (chunk: Buffer) => void;
/**
* Operation id stamped onto every emitted `AgentStreamEvent`. For ingest-
* connected runs this is the server-allocated op id; for standalone runs
@@ -332,7 +341,19 @@ export const spawnAgent = async (options: SpawnAgentOptions): Promise<SpawnAgent
});
};
stdout.on('data', enqueuePush);
stdout.on('data', (chunk: Buffer) => {
// Tee the raw bytes first so the dump captures exactly what CC emitted,
// independent of how the adapter later parses it. Best-effort: a throwing
// sink must not break the stream, so guard it.
if (options.onRawStdout) {
try {
options.onRawStdout(chunk);
} catch {
// raw dump is diagnostic-only; never let it disrupt the run
}
}
enqueuePush(chunk);
});
stdout.on('end', enqueueFlush);
stdout.on('error', (err) => {
// Append onto the same chain so the error is surfaced strictly after any