Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- CLI output no longer disappears when stdout or stderr is a pipe instead of a terminal. Node's stdio writes are asynchronous for pipes on macOS, so exiting in the same tick as the write discarded whatever was still buffered — `agent-relay cloud session --json | parser` and `$(agent-relay …)` could come back with empty stdout _and_ empty stderr, hiding the payload and the error that explained the failure. Every hard-exit path now drains stdio first.

## [11.4.0] - 2026-08-02

Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import { ensureWebSocketGlobal } from './lib/ensure-websocket.js';
import { assertSupportedNodeVersion } from './lib/node-version.js';
import { CliExit } from './lib/exit.js';
import { exitAfterFlush } from './lib/flush-stdio.js';
import { errorClassName } from './lib/telemetry-helpers.js';
import { registerSetupCommands } from './commands/setup.js';
import { registerCoreCommands, registerCoreMaintenance } from './commands/core.js';
Expand All @@ -50,8 +51,8 @@

dotenvConfig({ quiet: true });

const __filename = fileURLToPath(import.meta.url);

Check warning on line 54 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

Variable name `__filename` trimmed as `_filename` must match one of the following formats: camelCase, UPPER_CASE, PascalCase
const __dirname = path.dirname(__filename);

Check warning on line 55 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

Variable name `__dirname` trimmed as `_dirname` must match one of the following formats: camelCase, UPPER_CASE, PascalCase

function findPackageJson(startDir: string): string {
let dir = startDir;
Expand Down Expand Up @@ -129,7 +130,7 @@
// Inherited from a parent process: leave it exactly as-is.
if (process.env[IDENTITY_ENV_KEYS.userId]) return;

let identity: CloudIdentity | null = null;

Check warning on line 133 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

The value assigned to 'identity' is not used in subsequent statements
try {
identity = readStoredIdentitySync();
} catch {
Expand Down Expand Up @@ -454,7 +455,7 @@
* tree so we can't drift if a new verb is added without updating both
* places.
*/
function collectTopLevelVerbs(program: Command): Set<string> {

Check warning on line 458 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

'collectTopLevelVerbs' is defined but never used. Allowed unused vars must match /^_/u
const verbs = new Set<string>();
for (const command of program.commands) {
verbs.add(command.name());
Expand Down Expand Up @@ -522,8 +523,10 @@
}

if (isCliExit) {
// Flush is done — now actually exit with the code the command asked for.
process.exit(err.code);
// Telemetry is flushed — now drain whatever the command printed (a piped
// stdout is asynchronous on macOS, so exiting in this tick would drop it)
// and exit with the code the command asked for.
await exitAfterFlush(err.code);
}
throw err;
}
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/cli/commands/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,50 @@ describe('registerCloudCommands', () => {
expect(sessionJson).not.toHaveProperty('refreshToken');
});

it('writes the session JSON through the default stdout logger when stdout is not a TTY', async () => {
// Regression: scripted callers (`cloud session --json | parser`, `$(...)`)
// got empty stdout. The command must not gate its output on a TTY, and the
// production logger — not just an injected test double — must emit it.
vi.mocked(ensureCloudSession).mockResolvedValueOnce({
auth: {
apiUrl: 'https://cloud.test',
accessToken: 'access-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: '2999-01-01T00:00:00.000Z',
},
client: {} as never,
});

const program = new Command();
program.exitOverride();
// No dependency overrides: this exercises the production `console.log` path.
registerCloudCommands(program);

const chunks: string[] = [];
const log = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
chunks.push(args.map((arg) => String(arg)).join(' '));
});
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });

try {
await program.parseAsync(['node', 'agent-relay', 'cloud', 'session', '--json']);
} finally {
log.mockRestore();
if (originalIsTty) {
Object.defineProperty(process.stdout, 'isTTY', originalIsTty);
} else {
delete (process.stdout as { isTTY?: boolean }).isTTY;
}
}

expect(JSON.parse(chunks.join(''))).toEqual({
apiUrl: 'https://cloud.test',
accessToken: '…oken',
accessTokenExpiresAt: '2999-01-01T00:00:00.000Z',
});
});

it('includes the raw access token in JSON output only with --reveal-token', async () => {
const { program, deps } = createHarness();
vi.mocked(ensureCloudSession).mockResolvedValueOnce({
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { runAiSdkSidecarMain } from '@agent-relay/harnesses';

import { runCli } from './bootstrap.js';
import { describeError } from './lib/describe-error.js';
import { exitAfterFlush } from './lib/flush-stdio.js';

export * from './bootstrap.js';

Expand All @@ -23,12 +24,15 @@ function isEntrypoint(): boolean {

if (isEntrypoint()) {
const main = process.argv[2] === '__ai-sdk-sidecar' ? runAiSdkSidecarMain(process.argv.slice(3)) : runCli();
main.catch((err) => {
main.catch(async (err) => {
// Commander will have already printed a helpful message for parse errors.
// For other top-level failures, surface them to stderr and exit non-zero.
// `describeError` keeps a non-Error rejection (e.g. `{ status: 401 }` from
// a broker client) readable instead of collapsing it to `[object Object]`.
process.stderr.write(`${describeError(err)}\n`);
process.exit(1);
// Flush first: on macOS a piped stderr is asynchronous, so exiting in this
// tick would discard the message we just wrote and leave the caller with a
// silent non-zero exit.
await exitAfterFlush(1);
});
}
5 changes: 3 additions & 2 deletions packages/cli/src/cli/lib/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import { shutdown as shutdownTelemetry } from '../telemetry/index.js';
import { exitAfterFlush } from './flush-stdio.js';

export class CliExit extends Error {
/** Intended process exit code. */
Expand Down Expand Up @@ -67,10 +68,10 @@ export function runSignalHandler(handler: () => void | Promise<void>): void {
} catch {
// Best-effort — never let flush errors mask the intended exit.
}
process.exit(err.code);
await exitAfterFlush(err.code);
}
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
await exitAfterFlush(1);
});
}
102 changes: 102 additions & 0 deletions packages/cli/src/cli/lib/flush-stdio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest';

import { exitAfterFlush, flushStdio, flushStream, type FlushableStream } from './flush-stdio.js';

/**
* A stream that behaves the way a piped stdout does on macOS: the write is
* accepted immediately, but only completes once the OS has taken the bytes.
*/
function deferredStream(): FlushableStream & { complete: () => void; writes: string[] } {
const pending: (() => void)[] = [];
const writes: string[] = [];

return {
writes,
write(chunk: string, callback?: (error?: Error | null) => void): boolean {
writes.push(chunk);
if (callback) pending.push(() => callback(null));
return true;
},
complete(): void {
while (pending.length > 0) pending.shift()?.();
},
};
}

describe('flushStream', () => {
it('waits for the pending write to complete', async () => {
const stream = deferredStream();
let drained = false;
const flushed = flushStream(stream).then(() => {
drained = true;
});

await Promise.resolve();
expect(drained).toBe(false);

stream.complete();
await flushed;
expect(drained).toBe(true);
});

it('gives up after the timeout so a stalled reader cannot wedge the exit', async () => {
vi.useFakeTimers();
try {
const stream = deferredStream();
const flushed = flushStream(stream, 50);
await vi.advanceTimersByTimeAsync(50);
await expect(flushed).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
});

it('resolves without writing when there is no usable stream', async () => {
await expect(flushStream(undefined)).resolves.toBeUndefined();

const destroyed = { ...deferredStream(), destroyed: true };
await flushStream(destroyed);
expect(destroyed.writes).toEqual([]);
});

it('resolves when the write itself throws', async () => {
const stream: FlushableStream = {
write() {
throw new Error('EPIPE');
},
};

await expect(flushStream(stream)).resolves.toBeUndefined();
});
});

describe('flushStdio', () => {
it('drains every stream it is given', async () => {
const out = deferredStream();
const err = deferredStream();
const flushed = flushStdio({ streams: [out, err] });

out.complete();
err.complete();

await expect(flushed).resolves.toBeUndefined();
expect(out.writes).toEqual(['']);
expect(err.writes).toEqual(['']);
});
});

describe('exitAfterFlush', () => {
it('exits only once stdio has drained, with the requested code', async () => {
const out = deferredStream();
const exit = vi.fn((code: number) => code as never);

const exited = exitAfterFlush(3, { streams: [out], exit });

await Promise.resolve();
expect(exit).not.toHaveBeenCalled();

out.complete();
await exited;
expect(exit).toHaveBeenCalledWith(3);
});
});
94 changes: 94 additions & 0 deletions packages/cli/src/cli/lib/flush-stdio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Flush `process.stdout` / `process.stderr` before a hard exit.
*
* Why this exists: Node's stdio writes are only synchronous for files and for
* POSIX TTYs. Pipes and sockets are **asynchronous on macOS**, so a
* `process.exit()` in the same tick as a `console.log()` throws away whatever
* is still buffered. The visible symptom is a command that prints fine in a
* terminal and prints *nothing* when piped or captured with `$(...)` — the
* `--json` payload disappears, and so does the error text that would have
* explained why the command failed.
*
* Every exit path that goes through a real `process.exit(code)` should await
* {@link exitAfterFlush} instead, which drains both streams first. The drain
* is bounded by a timeout so a stalled reader can never wedge the exit.
*/

/** Minimal writable surface we need; keeps the helper testable. */
export interface FlushableStream {
write(chunk: string, callback?: (error?: Error | null) => void): boolean;
readonly destroyed?: boolean;
readonly writableEnded?: boolean;
}

/** How long to wait for a single stream to drain before giving up on it. */
export const DEFAULT_FLUSH_TIMEOUT_MS = 2_000;

export interface FlushOptions {
/** Streams to drain. Defaults to `[process.stdout, process.stderr]`. */
streams?: (FlushableStream | undefined)[];
/** Per-stream drain budget. Defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}. */
timeoutMs?: number;
}

/**
* Resolve once everything already written to `stream` has reached the OS.
*
* A zero-length write is queued behind the pending chunks, so its completion
* callback fires only after those chunks have been written — which is exactly
* the signal we lack on platforms where stdio is asynchronous. Errors (a
* closed pipe, for instance) resolve too: we are on our way out either way.
*/
export function flushStream(
stream: FlushableStream | undefined,
timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS
): Promise<void> {
return new Promise<void>((resolve) => {
if (!stream || typeof stream.write !== 'function' || stream.destroyed || stream.writableEnded) {
resolve();
return;
}

let settled = false;
const settle = (): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve();
};

const timer = setTimeout(settle, timeoutMs);
// Never let the flush watchdog be the reason the process stays alive.
timer.unref?.();

try {
stream.write('', () => settle());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch stream errors while flushing stdio

When a downstream reader closes a pipe while output is still pending (for example, a verbose command piped to head), the pending stdout write emits an asynchronous EPIPE error event. The callback and surrounding try/catch do not consume that event, and awaiting the flush gives Node time to treat it as unhandled, print a stack trace, and exit with code 1 instead of the command's requested code. This reproduces on Node 24 with a large process.stdout.write followed by this zero-length flush piped to head; install a temporary stream error handler during the flush or otherwise handle EPIPE explicitly.

Useful? React with 👍 / 👎.

} catch {
settle();
}
});
}

/** Drain stdout and stderr concurrently. Never rejects. */
export async function flushStdio(options: FlushOptions = {}): Promise<void> {
const streams = options.streams ?? [process.stdout, process.stderr];
const timeoutMs = options.timeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
await Promise.all(streams.map((stream) => flushStream(stream, timeoutMs)));
}

export interface ExitAfterFlushOptions extends FlushOptions {
/** Injectable for tests; defaults to the real `process.exit`. */
exit?: (code: number) => never;
}

/**
* Drain stdio, then exit with `code`.
*
* Returns `Promise<never>` because the default `exit` does not return; the
* declared return type keeps call sites from needing an unreachable `return`.
*/
export async function exitAfterFlush(code: number, options: ExitAfterFlushOptions = {}): Promise<never> {
await flushStdio(options);
const exit = options.exit ?? ((value: number) => process.exit(value));
return exit(code);
}
Loading