Skip to content

Commit 7f289dd

Browse files
committed
Fix review findings for input handling and docs
1 parent cc57212 commit 7f289dd

15 files changed

Lines changed: 342 additions & 95 deletions

File tree

README.md

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,85 @@
11
# agent-terminal
22

3-
Node/TypeScript CLI scaffold.
3+
`agent-terminal` is a Node/TypeScript CLI for launching, controlling, inspecting, and exporting reviewable terminal sessions.
4+
It is built for agent workflows that need both semantic state and visual artifacts from live or exited TUIs.
45

5-
## Setup
6+
## Quick start
67

7-
1. `mise install`
8-
2. `mise run bootstrap`
9-
3. `mise run ci`
8+
```bash
9+
mise install
10+
npm ci
11+
npx playwright install chromium
12+
npm run build
1013

11-
## CI
14+
SESSION_ID=$(node dist/cli/main.js create --json --name demo | jq -r '.data.sessionId')
15+
node dist/cli/main.js type "$SESSION_ID" 'echo hello from agent-terminal'
16+
node dist/cli/main.js send-keys "$SESSION_ID" Enter
17+
node dist/cli/main.js inspect "$SESSION_ID" --json
18+
node dist/cli/main.js destroy "$SESSION_ID"
19+
```
1220

13-
- GitHub Actions uses `mise` as the canonical entrypoint for tool setup and quality gates.
14-
- The committed workflow in `.github/workflows/ci.yml` is hand-curated. `mise generate github-action` is useful as a scaffold, but the checked-in file is the maintained source of truth because it includes repo-specific triggers, bootstrap behavior, and step-level logs.
15-
- CI uses `mise run bootstrap-ci` for deterministic `npm ci` installs, then explicitly runs `npx playwright install chromium` so renderer smoke coverage is exercised on GitHub Actions.
16-
- For v1, CI intentionally follows the major-version tool pins declared in `mise.toml` (`node = "24"`, `python = "3"`). This repo does not commit a `mise.lock` yet.
21+
## Feature highlights
22+
23+
- Full session lifecycle management: create, inspect, list, wait, destroy, and garbage-collect.
24+
- Semantic snapshots for structured or text inspection, including optional scrollback capture.
25+
- Renderer-backed screenshots and replay exports for reviewable visual evidence.
26+
- Recording export to asciicast (`.cast`) or WebM for artifact bundles.
27+
- Failure recovery via reconciliation, stale-session cleanup, and retained manifests/artifacts.
28+
29+
## CLI-wide flags
30+
31+
- `--home <path>`: override the agent-terminal home directory.
32+
- `--timeout-ms <n>`: apply a shared CLI timeout budget in milliseconds.
33+
- `--no-color`: disable ANSI color in human-readable output.
34+
- `--json`: available on user-facing commands to emit structured command envelopes.
35+
36+
## Commands
37+
38+
- `version`: print the CLI version.
39+
- `doctor`: validate local environment requirements.
40+
- `create [command...]`: create a session and launch the requested command or shell.
41+
- `list`: list sessions, optionally including exited ones.
42+
- `inspect <session-id>`: inspect manifest state and artifact metadata for a session.
43+
- `destroy <session-id>`: tear down a session, with optional forced shutdown.
44+
- `gc`: remove stale or old sessions.
45+
- `type <session-id> [text]`: type text into a session.
46+
- `paste <session-id> [text]`: paste text into a session.
47+
- `mark <session-id> <label>`: add a marker event to a session timeline.
48+
- `send-keys <session-id> <keys...>`: send key sequences such as `Enter` or `Ctrl+C`.
49+
- `resize <session-id>`: resize the PTY dimensions.
50+
- `signal <session-id> <signal>`: send a POSIX signal to the session child process.
51+
- `snapshot <session-id>`: capture a semantic snapshot of terminal contents.
52+
- `screenshot <session-id>`: capture a rendered PNG screenshot.
53+
- `record export <session-id>`: export replay artifacts as asciicast or WebM.
54+
- `wait <session-id>`: wait for exit, idleness, text, regex, cursor, or stable-screen conditions.
55+
56+
## Development setup
57+
58+
```bash
59+
mise install
60+
npm ci
61+
npx playwright install chromium
62+
```
63+
64+
Useful shortcuts:
65+
66+
- `mise run bootstrap`: install npm dependencies and Chromium in one step.
67+
- `npm run cli -- --help`: inspect the CLI locally without building.
68+
69+
## Verification
70+
71+
```bash
72+
npm run verify
73+
```
74+
75+
That runs formatting, linting, typechecking, unit/e2e tests, and the production build.
76+
77+
## Design docs
78+
79+
Design and implementation notes live under `design/`, especially `design/20260319_agent-terminal-v1/`.
80+
81+
## Repository notes
82+
83+
- CI uses `mise` for tool provisioning and quality-gate entrypoints.
84+
- Chromium is required locally for screenshot and replay export coverage.
85+
- Dogfood proof bundles and validation notes live under `dogfood/` and `design/`.

design/20260319_agent-terminal-v1/05-dogfooding-and-validation.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ The repo also now has four Week 4 proof bundles that cover the shipped gap-closi
3535
- `dogfood/20260322-week4-scrollback-review/` proves scrollback capture and replay review,
3636
- and `dogfood/20260322-week4-unicode-review/` proves unicode/width review coverage.
3737

38+
Bugfix verification bundles also ship alongside that Week 4 coverage:
39+
40+
- `dogfood/20260323-bugfix-resize/` proves post-resize snapshot/screenshot recovery,
41+
- and `dogfood/20260323-bugfix-scrollback/` proves scrollback viewport normalization.
42+
3843
Earlier proof bundles remain relevant context:
3944

4045
- `dogfood/20260321-post-hardening-smoke/` revalidates live inspect/wait/snapshot/screenshot/doctor flows,

dogfood/20260323-bugfix-resize/notes.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,20 @@ Scenario B-redux completed successfully. The previously failing post-resize `scr
1313

1414
## Command log
1515

16-
| User step | Stored file | Exit code | Command |
17-
| --- | --- | ---: | --- |
18-
| 2 | `01-create.json` | 0 | `npx tsx src/cli/main.ts create --cols 120 --rows 40 --json -- npx tsx test/fixtures/apps/resize-demo/main.ts` |
19-
| 3 | `02-wait-size.json` | 0 | `npx tsx src/cli/main.ts wait 01KMCYX32BMKX4F48WRMRSDC12 --text "SIZE:" --json` |
20-
| 4 | `03-screenshot-before-resize.json` | 0 | `npx tsx src/cli/main.ts screenshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
21-
| 5 | `04-resize-large.json` | 0 | `npx tsx src/cli/main.ts resize 01KMCYX32BMKX4F48WRMRSDC12 --cols 140 --rows 50 --json` |
22-
| 6 | `05-wait-stable-large.json` | 0 | `npx tsx src/cli/main.ts wait 01KMCYX32BMKX4F48WRMRSDC12 --screen-stable-ms 500 --json` |
23-
| 7 | `06-screenshot-after-resize-large.json` | 0 | `npx tsx src/cli/main.ts screenshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
24-
| 8 | `07-snapshot-after-resize-large.json` | 0 | `npx tsx src/cli/main.ts snapshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
25-
| 9 | `08-resize-small.json` | 0 | `npx tsx src/cli/main.ts resize 01KMCYX32BMKX4F48WRMRSDC12 --cols 80 --rows 24 --json` |
26-
| 10 | `09-wait-stable-small.json` | 0 | `npx tsx src/cli/main.ts wait 01KMCYX32BMKX4F48WRMRSDC12 --screen-stable-ms 500 --json` |
27-
| 11 | `10-screenshot-after-resize-small.json` | 0 | `npx tsx src/cli/main.ts screenshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
28-
| 12 | `11-snapshot-after-resize-small.json` | 0 | `npx tsx src/cli/main.ts snapshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
29-
| 13 | `12-destroy.json` | 0 | `npx tsx src/cli/main.ts destroy 01KMCYX32BMKX4F48WRMRSDC12 --json` |
16+
| User step | Stored file | Exit code | Command |
17+
| --------- | --------------------------------------- | --------: | -------------------------------------------------------------------------------------------------------------- |
18+
| 2 | `01-create.json` | 0 | `npx tsx src/cli/main.ts create --cols 120 --rows 40 --json -- npx tsx test/fixtures/apps/resize-demo/main.ts` |
19+
| 3 | `02-wait-size.json` | 0 | `npx tsx src/cli/main.ts wait 01KMCYX32BMKX4F48WRMRSDC12 --text "SIZE:" --json` |
20+
| 4 | `03-screenshot-before-resize.json` | 0 | `npx tsx src/cli/main.ts screenshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
21+
| 5 | `04-resize-large.json` | 0 | `npx tsx src/cli/main.ts resize 01KMCYX32BMKX4F48WRMRSDC12 --cols 140 --rows 50 --json` |
22+
| 6 | `05-wait-stable-large.json` | 0 | `npx tsx src/cli/main.ts wait 01KMCYX32BMKX4F48WRMRSDC12 --screen-stable-ms 500 --json` |
23+
| 7 | `06-screenshot-after-resize-large.json` | 0 | `npx tsx src/cli/main.ts screenshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
24+
| 8 | `07-snapshot-after-resize-large.json` | 0 | `npx tsx src/cli/main.ts snapshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
25+
| 9 | `08-resize-small.json` | 0 | `npx tsx src/cli/main.ts resize 01KMCYX32BMKX4F48WRMRSDC12 --cols 80 --rows 24 --json` |
26+
| 10 | `09-wait-stable-small.json` | 0 | `npx tsx src/cli/main.ts wait 01KMCYX32BMKX4F48WRMRSDC12 --screen-stable-ms 500 --json` |
27+
| 11 | `10-screenshot-after-resize-small.json` | 0 | `npx tsx src/cli/main.ts screenshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
28+
| 12 | `11-snapshot-after-resize-small.json` | 0 | `npx tsx src/cli/main.ts snapshot 01KMCYX32BMKX4F48WRMRSDC12 --json` |
29+
| 13 | `12-destroy.json` | 0 | `npx tsx src/cli/main.ts destroy 01KMCYX32BMKX4F48WRMRSDC12 --json` |
3030

3131
## Previously failing steps
3232

dogfood/20260323-bugfix-resize/summary.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,4 @@
5555
"text": ""
5656
}
5757
]
58-
}
58+
}

dogfood/20260323-bugfix-scrollback/notes.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ Scenario F-redux completed successfully. The wait matched `SCROLLBACK COMPLETE`,
1313

1414
## Command log
1515

16-
| User step | Stored file | Exit code | Command |
17-
| --- | --- | ---: | --- |
18-
| 2 | `01-create.json` | 0 | `npx tsx src/cli/main.ts create --cols 80 --rows 24 --json -- npx tsx test/fixtures/apps/scrollback-demo/main.ts` |
19-
| 3 | `02-wait-text.json` | 0 | `npx tsx src/cli/main.ts wait 01KMCYYK6GSKR6ZF3NAW7GFE5N --text "SCROLLBACK COMPLETE" --timeout 10000 --json` |
20-
| 4 | `03-snapshot-viewport.json` | 0 | `npx tsx src/cli/main.ts snapshot 01KMCYYK6GSKR6ZF3NAW7GFE5N --json` |
21-
| 5 | `04-snapshot-scrollback.json` | 0 | `npx tsx src/cli/main.ts snapshot 01KMCYYK6GSKR6ZF3NAW7GFE5N --include-scrollback --json` |
22-
| 6 | `05-screenshot.json` | 0 | `npx tsx src/cli/main.ts screenshot 01KMCYYK6GSKR6ZF3NAW7GFE5N --json` |
23-
| 7 | `06-wait-exit.txt` | 0 | `npx tsx src/cli/main.ts wait 01KMCYYK6GSKR6ZF3NAW7GFE5N --exit` |
24-
| 8 | `07-destroy.json` | 0 | `npx tsx src/cli/main.ts destroy 01KMCYYK6GSKR6ZF3NAW7GFE5N --json` |
16+
| User step | Stored file | Exit code | Command |
17+
| --------- | ----------------------------- | --------: | ----------------------------------------------------------------------------------------------------------------- |
18+
| 2 | `01-create.json` | 0 | `npx tsx src/cli/main.ts create --cols 80 --rows 24 --json -- npx tsx test/fixtures/apps/scrollback-demo/main.ts` |
19+
| 3 | `02-wait-text.json` | 0 | `npx tsx src/cli/main.ts wait 01KMCYYK6GSKR6ZF3NAW7GFE5N --text "SCROLLBACK COMPLETE" --timeout 10000 --json` |
20+
| 4 | `03-snapshot-viewport.json` | 0 | `npx tsx src/cli/main.ts snapshot 01KMCYYK6GSKR6ZF3NAW7GFE5N --json` |
21+
| 5 | `04-snapshot-scrollback.json` | 0 | `npx tsx src/cli/main.ts snapshot 01KMCYYK6GSKR6ZF3NAW7GFE5N --include-scrollback --json` |
22+
| 6 | `05-screenshot.json` | 0 | `npx tsx src/cli/main.ts screenshot 01KMCYYK6GSKR6ZF3NAW7GFE5N --json` |
23+
| 7 | `06-wait-exit.txt` | 0 | `npx tsx src/cli/main.ts wait 01KMCYYK6GSKR6ZF3NAW7GFE5N --exit` |
24+
| 8 | `07-destroy.json` | 0 | `npx tsx src/cli/main.ts destroy 01KMCYYK6GSKR6ZF3NAW7GFE5N --json` |
2525

2626
## Previously failing checks
2727

dogfood/20260323-bugfix-scrollback/summary.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,4 @@
4848
}
4949
],
5050
"screenshot": "05-screenshot.png"
51-
}
51+
}

src/cli/commands/inputSource.ts

Lines changed: 109 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import assert from 'node:assert/strict';
2-
import { constants as fsConstants } from 'node:fs';
3-
import { access, readFile } from 'node:fs/promises';
2+
import { lstat, readFile, stat } from 'node:fs/promises';
43

54
import { ERROR_CODES, makeCliError } from '../../protocol/errors.js';
65

@@ -10,6 +9,8 @@ interface ResolveCommandInputTextOptions {
109
file: string | undefined;
1110
}
1211

12+
export const MAX_INPUT_FILE_SIZE = 10_000_000;
13+
1314
function createInvalidInputError(
1415
message: string,
1516
details?: Record<string, unknown>,
@@ -30,6 +31,88 @@ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
3031
return error instanceof Error && 'code' in error;
3132
}
3233

34+
function createInputFileLookupError(
35+
filePath: string,
36+
error: unknown,
37+
): ReturnType<typeof createInvalidInputError> {
38+
if (isErrnoException(error) && error.code === 'ENOENT') {
39+
return createInvalidInputError(
40+
`Input file "${filePath}" was not found.`,
41+
{
42+
file: filePath,
43+
},
44+
error,
45+
);
46+
}
47+
48+
if (
49+
isErrnoException(error) &&
50+
['EACCES', 'EPERM'].includes(error.code ?? '')
51+
) {
52+
return createInvalidInputError(
53+
`Input file "${filePath}" is not readable.`,
54+
{
55+
file: filePath,
56+
},
57+
error,
58+
);
59+
}
60+
61+
return createInvalidInputError(
62+
`Failed to inspect input file "${filePath}".`,
63+
{
64+
file: filePath,
65+
},
66+
error,
67+
);
68+
}
69+
70+
function createInputFileReadError(
71+
filePath: string,
72+
error: unknown,
73+
): ReturnType<typeof createInvalidInputError> {
74+
if (isErrnoException(error) && error.code === 'ENOENT') {
75+
return createInvalidInputError(
76+
`Input file "${filePath}" was not found.`,
77+
{
78+
file: filePath,
79+
},
80+
error,
81+
);
82+
}
83+
84+
if (
85+
isErrnoException(error) &&
86+
['EACCES', 'EPERM'].includes(error.code ?? '')
87+
) {
88+
return createInvalidInputError(
89+
`Input file "${filePath}" is not readable.`,
90+
{
91+
file: filePath,
92+
},
93+
error,
94+
);
95+
}
96+
97+
if (isErrnoException(error) && error.code === 'EISDIR') {
98+
return createInvalidInputError(
99+
`Input file "${filePath}" must be a regular file. Directories, symlinks, and device files are not supported.`,
100+
{
101+
file: filePath,
102+
},
103+
error,
104+
);
105+
}
106+
107+
return createInvalidInputError(
108+
`Failed to read input file "${filePath}".`,
109+
{
110+
file: filePath,
111+
},
112+
error,
113+
);
114+
}
115+
33116
export async function resolveCommandInputText(
34117
options: ResolveCommandInputTextOptions,
35118
): Promise<string> {
@@ -68,28 +151,40 @@ export async function resolveCommandInputText(
68151
assert(typeof filePath === 'string', '--file must resolve to a string path');
69152
assert(filePath.length > 0, '--file path must be a non-empty string');
70153

154+
let fileStats: Awaited<ReturnType<typeof lstat>>;
71155
try {
72-
await access(filePath, fsConstants.F_OK);
156+
fileStats = await lstat(filePath);
73157
} catch (error: unknown) {
158+
throw createInputFileLookupError(filePath, error);
159+
}
160+
161+
if (!fileStats.isFile()) {
74162
throw createInvalidInputError(
75-
`Input file "${filePath}" was not found.`,
163+
`Input file "${filePath}" must be a regular file. Directories, symlinks, and device files are not supported.`,
76164
{
77165
file: filePath,
78166
},
79-
error,
80167
);
81168
}
82169

83170
try {
84-
await access(filePath, fsConstants.R_OK);
171+
const contentStats = await stat(filePath);
172+
if (contentStats.size > MAX_INPUT_FILE_SIZE) {
173+
throw createInvalidInputError(
174+
`Input file "${filePath}" exceeds the 10 MB limit for --file input.`,
175+
{
176+
file: filePath,
177+
sizeBytes: contentStats.size,
178+
maxSizeBytes: MAX_INPUT_FILE_SIZE,
179+
},
180+
);
181+
}
85182
} catch (error: unknown) {
86-
throw createInvalidInputError(
87-
`Input file "${filePath}" is not readable.`,
88-
{
89-
file: filePath,
90-
},
91-
error,
92-
);
183+
if (error instanceof Error && error.name === 'CliError') {
184+
throw error;
185+
}
186+
187+
throw createInputFileLookupError(filePath, error);
93188
}
94189

95190
try {
@@ -114,22 +209,6 @@ export async function resolveCommandInputText(
114209
throw error;
115210
}
116211

117-
if (isErrnoException(error) && error.code === 'EISDIR') {
118-
throw createInvalidInputError(
119-
`Input file "${filePath}" must be a file, not a directory.`,
120-
{
121-
file: filePath,
122-
},
123-
error,
124-
);
125-
}
126-
127-
throw createInvalidInputError(
128-
`Failed to read input file "${filePath}".`,
129-
{
130-
file: filePath,
131-
},
132-
error,
133-
);
212+
throw createInputFileReadError(filePath, error);
134213
}
135214
}

src/cli/exitCodes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ const EXIT_CODE_BY_ERROR_CODE: Readonly<Record<string, number>> = Object.freeze(
1616
[ERROR_CODES.EXPORT_ERROR]: 7,
1717
[ERROR_CODES.STORAGE_READ_ERROR]: 8,
1818
[ERROR_CODES.STORAGE_WRITE_ERROR]: 8,
19+
[ERROR_CODES.MANIFEST_VALIDATION_ERROR]: 8,
20+
[ERROR_CODES.PROTOCOL_ERROR]: 9,
21+
[ERROR_CODES.RPC_ERROR]: 9,
22+
[ERROR_CODES.REPLAY_ERROR]: 10,
23+
[ERROR_CODES.INTERNAL_ERROR]: 1,
1924
},
2025
);
2126

src/export/asciicast.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { EventRecord, SessionRecord } from '../protocol/schemas.js';
22

3+
import { DEFAULT_TERM } from '../config/defaults.js';
34
import { invariant } from '../util/assert.js';
45

5-
const DEFAULT_TERM = 'xterm-256color';
6-
76
export interface AsciicastHeader {
87
version: 2;
98
width: number;

0 commit comments

Comments
 (0)