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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ A colleague then used `agent-tty` to build an experimental TUI for Coder agents

## Command surface

Every user-facing command takes `--json` and returns a stable, machine-readable envelope, and exits with a stable code (`0` success, `2` usage error, `3` session not found, …) so scripts can branch without parsing output.
Every user-facing command takes `--json` and returns a stable, machine-readable envelope, and exits with a stable code (`0` success, `2` usage error, `3` session not found, `11` wait timeout, …) so scripts can branch without parsing output.

| Group | Commands |
| ----------------------- | ------------------------------------------------------------------------ |
Expand Down
34 changes: 17 additions & 17 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Useful flags:
- `--exit`: wait for the process to exit.
- `--timeout <ms>`: maximum wait time in milliseconds, with `0` meaning infinite.

On timeout, a standalone `wait` still exits `0` and reports `matched: false` / `timedOut: true` in the JSON result — check the envelope, not the exit code. Inside `batch`, a timed-out `wait` step is a step failure (`WAIT_TIMEOUT`, exit code `11` under fail-fast).
On timeout, a standalone `wait` exits `11` (`WAIT_TIMEOUT`) while preserving a success JSON envelope with `timedOut: true` in the result (`matched: false` for render waits). Inside `batch`, a timed-out `wait` step is a step failure with the same `WAIT_TIMEOUT` exit code under fail-fast.

### Screen Hash

Expand Down Expand Up @@ -235,22 +235,22 @@ A lone `'%'` does **not** restore the marker (zsh treats it as a prompt escape t

## Exit Codes

Every command exits with a stable code, so scripts can branch without parsing output. The `--json` error envelope carries the precise `error.code` (for example `WAIT_TIMEOUT`); the exit code is a coarser, stable summary of it.

| Exit code | Meaning |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | Success. |
| `1` | Internal or unclassified error. |
| `2` | Usage error: unknown command or flag, or an invalid argument (session ID, dimensions, keys, duration, signal, input). |
| `3` | Session not found. |
| `4` | Session is not running or already destroyed. |
| `5` | Session host timed out. |
| `6` | Session host unreachable. |
| `7` | Export failed. |
| `8` | Storage read/write or manifest validation error. |
| `9` | Protocol or RPC error. |
| `10` | Replay failed. |
| `11` | A `wait` step inside a fail-fast `batch` timed out (standalone `wait` exits `0` with `timedOut: true` in the result — see [`wait`](#wait)). |
Every command exits with a stable code, so scripts can branch without parsing output. The `--json` error envelope carries the precise `error.code` when a command fails before producing a result. Commands that preserve an observable result for a failed predicate, such as timed-out `wait` and fail-fast `batch`, still emit a success envelope while exiting non-zero.

| Exit code | Meaning |
| --------- | --------------------------------------------------------------------------------------------------------------------------- |
| `0` | Success. |
| `1` | Internal or unclassified error. |
| `2` | Usage error: unknown command or flag, or an invalid argument (session ID, dimensions, keys, duration, signal, input). |
| `3` | Session not found. |
| `4` | Session is not running or already destroyed. |
| `5` | Session host timed out. |
| `6` | Session host unreachable. |
| `7` | Export failed. |
| `8` | Storage read/write or manifest validation error. |
| `9` | Protocol or RPC error. |
| `10` | Replay failed. |
| `11` | A standalone `wait` timed out, or a `wait` step inside a fail-fast `batch` timed out (`WAIT_TIMEOUT`; see [`wait`](#wait)). |

A fail-fast `batch` exits with the failed step's code (for example `11` for a wait timeout); `--keep-going` exits `1` if any step failed.

Expand Down
11 changes: 11 additions & 0 deletions src/cli/commands/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import type {
import type { PreparedRenderWaitCondition } from '../../renderWait/matcher.js';
import type { SemanticSnapshot } from '../../renderer/types.js';

import process from 'node:process';

import { CliError } from '../errors.js';
import type { CommandContext } from '../context.js';

import { exitCodeForError } from '../exitCodes.js';
import { emitSuccess } from '../output.js';
import { sendRpc } from '../../host/rpcClient.js';
import { ERROR_CODES, makeCliError } from '../../protocol/errors.js';
Expand Down Expand Up @@ -102,6 +105,12 @@ function renderWaitLines(result: WaitForRenderResult): string[] {
return lines;
}

function setWaitTimeoutExitCode(result: { timedOut: boolean }): void {
if (result.timedOut) {
process.exitCode = exitCodeForError(ERROR_CODES.WAIT_TIMEOUT);
}
}

function buildOfflineRenderWaitResult(
preparedCondition: PreparedRenderWaitCondition,
snapshot: SemanticSnapshot,
Expand Down Expand Up @@ -298,6 +307,7 @@ export async function runWaitCommand(options: CommandOptions): Promise<void> {
result,
lines: renderWaitLines(result),
});
setWaitTimeoutExitCode(result);
return;
}

Expand Down Expand Up @@ -379,4 +389,5 @@ export async function runWaitCommand(options: CommandOptions): Promise<void> {
result,
lines: waitLines(result),
});
setWaitTimeoutExitCode(result);
}
2 changes: 1 addition & 1 deletion test/integration/io-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ describe('io-loop integration', { timeout: 30000 }, () => {
{ AGENT_TTY_HOME: testHome },
30000,
);
expect(waitResult.status).toBe(0);
expect(waitResult.status).toBe(11);
expect(waitResult.stderr).toBe('');

const envelope = JSON.parse(
Expand Down
2 changes: 1 addition & 1 deletion test/integration/screen-hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ describe('screen hash integration', { timeout: 120_000 }, () => {
15_000,
);

expect(result.status).toBe(0);
expect(result.status).toBe(11);
expect(result.stderr).toBe('');
const envelope = JSON.parse(
result.stdout,
Expand Down
54 changes: 54 additions & 0 deletions test/integration/wait-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,60 @@ describe('wait render integration', { timeout: 120_000 }, () => {
expect(envelope.result.cursorCol).toBe(snapshot.cursorCol);
});

it('exits 11 with a success envelope when CLI --text times out', () => {
const result = runCli(
[
'wait',
sessionId,
'--text',
'MISSING_TEXT',
'--timeout',
'1000',
'--json',
],
{ AGENT_TTY_HOME: testHome },
10_000,
);

expect(result.exitCode).toBe(11);
expect(result.stderr).toBe('');
const envelope = JSON.parse(
result.stdout,
) as SuccessEnvelope<WaitForRenderResult>;
expect(envelope.ok).toBe(true);
expect(envelope.result.matched).toBe(false);
expect(envelope.result.timedOut).toBe(true);
expect(envelope.result.capturedAtSeq).toBeGreaterThanOrEqual(0);
});

it('exits 11 with a success envelope when legacy CLI --idle-ms times out', () => {
const result = runCli(
['wait', sessionId, '--idle-ms', '10000', '--timeout', '1000', '--json'],
{ AGENT_TTY_HOME: testHome },
10_000,
);

expect(result.exitCode).toBe(11);
expect(result.stderr).toBe('');
const envelope = JSON.parse(result.stdout) as SuccessEnvelope<WaitResult>;
expect(envelope.ok).toBe(true);
expect(envelope.result.timedOut).toBe(true);
});

it('exits 11 with a success envelope when legacy CLI --exit times out', () => {
const result = runCli(
['wait', sessionId, '--exit', '--timeout', '1000', '--json'],
{ AGENT_TTY_HOME: testHome },
10_000,
);

expect(result.exitCode).toBe(11);
expect(result.stderr).toBe('');
const envelope = JSON.parse(result.stdout) as SuccessEnvelope<WaitResult>;
expect(envelope.ok).toBe(true);
expect(envelope.result.timedOut).toBe(true);
});

it('rejects non-integer CLI --cursor-row values', () => {
const result = runCli(
['wait', sessionId, '--cursor-row', '1.5', '--json'],
Expand Down
43 changes: 42 additions & 1 deletion test/unit/commands/wait.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import process from 'node:process';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { exitCodeForError } from '../../../src/cli/exitCodes.js';
import { ERROR_CODES, makeCliError } from '../../../src/protocol/errors.js';

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -144,6 +147,10 @@ describe('wait command', () => {
mocks.readManifestIfExists.mockResolvedValue(createSessionRecord());
});

afterEach(() => {
process.exitCode = undefined;
});

it('rejects --text and --regex together', async () => {
await expect(
runWaitCommand(createOptions({ text: 'hello', regex: 'world' })),
Expand Down Expand Up @@ -381,6 +388,40 @@ describe('wait command', () => {
);
});

it('sets WAIT_TIMEOUT exit code for legacy wait timeouts while preserving success output', async () => {
const result = { timedOut: true };
mocks.sendRpc.mockResolvedValue(result);

await runWaitCommand(createOptions({ waitForExit: true, timeout: 1000 }));

expect(process.exitCode).toBe(exitCodeForError(ERROR_CODES.WAIT_TIMEOUT));
expect(mocks.emitSuccess).toHaveBeenCalledWith({
command: 'wait',
json: false,
result,
lines: ['Wait timed out.'],
});
});

it('sets WAIT_TIMEOUT exit code for render wait timeouts while preserving success output', async () => {
const result = {
matched: false,
timedOut: true,
capturedAtSeq: 7,
};
mocks.sendRpc.mockResolvedValue(result);

await runWaitCommand(createOptions({ text: 'missing', timeout: 1000 }));

expect(process.exitCode).toBe(exitCodeForError(ERROR_CODES.WAIT_TIMEOUT));
expect(mocks.emitSuccess).toHaveBeenCalledWith({
command: 'wait',
json: false,
result,
lines: ['Wait timed out. (capturedAtSeq: 7)'],
});
});

it('routes --text waits to the render wait RPC', async () => {
const result = {
matched: true,
Expand Down
Loading