diff --git a/dogfood/20260319-lifecycle/01-create.json b/dogfood/20260319-lifecycle/01-create.json new file mode 100644 index 0000000..079b332 --- /dev/null +++ b/dogfood/20260319-lifecycle/01-create.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-03-19T18:02:15.173Z", + "result": { + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4" + } +} diff --git a/dogfood/20260319-lifecycle/02-list.json b/dogfood/20260319-lifecycle/02-list.json new file mode 100644 index 0000000..07146c7 --- /dev/null +++ b/dogfood/20260319-lifecycle/02-list.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "list", + "timestamp": "2026-03-19T18:02:15.471Z", + "result": { + "sessions": [ + { + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "status": "running", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/hello-prompt/main.ts" + ], + "createdAt": "2026-03-19T18:02:14.759Z" + } + ] + } +} diff --git a/dogfood/20260319-lifecycle/03-inspect-live.json b/dogfood/20260319-lifecycle/03-inspect-live.json new file mode 100644 index 0000000..29e847c --- /dev/null +++ b/dogfood/20260319-lifecycle/03-inspect-live.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:02:15.861Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "createdAt": "2026-03-19T18:02:14.759Z", + "updatedAt": "2026-03-19T18:02:15.078Z", + "status": "running", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/hello-prompt/main.ts" + ], + "cwd": "/home/coder/.mux/src/agent-terminal/agent_exec_d1aeb26ed6/dogfood/20260319-lifecycle", + "cols": 80, + "rows": 24, + "hostPid": 278189, + "childPid": 278201, + "exitCode": null, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-lifecycle/04-type.json b/dogfood/20260319-lifecycle/04-type.json new file mode 100644 index 0000000..bdda75a --- /dev/null +++ b/dogfood/20260319-lifecycle/04-type.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-03-19T18:02:16.162Z", + "result": {} +} diff --git a/dogfood/20260319-lifecycle/05-send-keys.json b/dogfood/20260319-lifecycle/05-send-keys.json new file mode 100644 index 0000000..35d1ccc --- /dev/null +++ b/dogfood/20260319-lifecycle/05-send-keys.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-03-19T18:02:16.456Z", + "result": {} +} diff --git a/dogfood/20260319-lifecycle/06-wait-idle.json b/dogfood/20260319-lifecycle/06-wait-idle.json new file mode 100644 index 0000000..16a1dd4 --- /dev/null +++ b/dogfood/20260319-lifecycle/06-wait-idle.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:16.977Z", + "result": { + "timedOut": false + } +} diff --git a/dogfood/20260319-lifecycle/07-paste.json b/dogfood/20260319-lifecycle/07-paste.json new file mode 100644 index 0000000..1f6f9e1 --- /dev/null +++ b/dogfood/20260319-lifecycle/07-paste.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "paste", + "timestamp": "2026-03-19T18:02:17.309Z", + "result": {} +} diff --git a/dogfood/20260319-lifecycle/08-send-keys-enter.json b/dogfood/20260319-lifecycle/08-send-keys-enter.json new file mode 100644 index 0000000..80deac5 --- /dev/null +++ b/dogfood/20260319-lifecycle/08-send-keys-enter.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-03-19T18:02:17.612Z", + "result": {} +} diff --git a/dogfood/20260319-lifecycle/09-wait-idle-2.json b/dogfood/20260319-lifecycle/09-wait-idle-2.json new file mode 100644 index 0000000..bee4734 --- /dev/null +++ b/dogfood/20260319-lifecycle/09-wait-idle-2.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:18.211Z", + "result": { + "timedOut": false + } +} diff --git a/dogfood/20260319-lifecycle/10-resize.json b/dogfood/20260319-lifecycle/10-resize.json new file mode 100644 index 0000000..d92df06 --- /dev/null +++ b/dogfood/20260319-lifecycle/10-resize.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "resize", + "timestamp": "2026-03-19T18:02:18.622Z", + "result": { + "cols": 120, + "rows": 40 + } +} diff --git a/dogfood/20260319-lifecycle/11-inspect-resized.json b/dogfood/20260319-lifecycle/11-inspect-resized.json new file mode 100644 index 0000000..40bded8 --- /dev/null +++ b/dogfood/20260319-lifecycle/11-inspect-resized.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:02:19.035Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "createdAt": "2026-03-19T18:02:14.759Z", + "updatedAt": "2026-03-19T18:02:18.619Z", + "status": "running", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/hello-prompt/main.ts" + ], + "cwd": "/home/coder/.mux/src/agent-terminal/agent_exec_d1aeb26ed6/dogfood/20260319-lifecycle", + "cols": 120, + "rows": 40, + "hostPid": 278189, + "childPid": 278201, + "exitCode": null, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-lifecycle/12-signal.json b/dogfood/20260319-lifecycle/12-signal.json new file mode 100644 index 0000000..6c005f7 --- /dev/null +++ b/dogfood/20260319-lifecycle/12-signal.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "signal", + "timestamp": "2026-03-19T18:02:19.371Z", + "result": { + "signal": "SIGINT", + "delivered": true + } +} diff --git a/dogfood/20260319-lifecycle/13-wait-exit.json b/dogfood/20260319-lifecycle/13-wait-exit.json new file mode 100644 index 0000000..521e24d --- /dev/null +++ b/dogfood/20260319-lifecycle/13-wait-exit.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:19.711Z", + "result": { + "timedOut": false, + "exitCode": 130 + } +} diff --git a/dogfood/20260319-lifecycle/14-inspect-exited.json b/dogfood/20260319-lifecycle/14-inspect-exited.json new file mode 100644 index 0000000..740e189 --- /dev/null +++ b/dogfood/20260319-lifecycle/14-inspect-exited.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:02:20.056Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "createdAt": "2026-03-19T18:02:14.759Z", + "updatedAt": "2026-03-19T18:02:19.379Z", + "status": "exited", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/hello-prompt/main.ts" + ], + "cwd": "/home/coder/.mux/src/agent-terminal/agent_exec_d1aeb26ed6/dogfood/20260319-lifecycle", + "cols": 120, + "rows": 40, + "hostPid": 278189, + "childPid": 278201, + "exitCode": 130, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-lifecycle/15-destroy.json b/dogfood/20260319-lifecycle/15-destroy.json new file mode 100644 index 0000000..950f1f4 --- /dev/null +++ b/dogfood/20260319-lifecycle/15-destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-03-19T18:02:20.328Z", + "result": { + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "destroyed": true + } +} diff --git a/dogfood/20260319-lifecycle/event-log.jsonl b/dogfood/20260319-lifecycle/event-log.jsonl new file mode 100644 index 0000000..4a246b1 --- /dev/null +++ b/dogfood/20260319-lifecycle/event-log.jsonl @@ -0,0 +1,14 @@ +{"seq":0,"ts":"2026-03-19T18:02:15.194Z","type":"output","payload":{"data":"READY> "}} +{"seq":1,"ts":"2026-03-19T18:02:16.160Z","type":"input_text","payload":{"data":"hello world"}} +{"seq":2,"ts":"2026-03-19T18:02:16.160Z","type":"output","payload":{"data":"hello world"}} +{"seq":3,"ts":"2026-03-19T18:02:16.454Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":4,"ts":"2026-03-19T18:02:16.455Z","type":"output","payload":{"data":"\r\n"}} +{"seq":5,"ts":"2026-03-19T18:02:16.455Z","type":"output","payload":{"data":"ECHO: hello world\r\nREADY> "}} +{"seq":6,"ts":"2026-03-19T18:02:17.308Z","type":"input_paste","payload":{"data":"\u001b[200~pasted-content\u001b[201~"}} +{"seq":7,"ts":"2026-03-19T18:02:17.308Z","type":"output","payload":{"data":"^[[200~pasted-content^[[201~"}} +{"seq":8,"ts":"2026-03-19T18:02:17.610Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":9,"ts":"2026-03-19T18:02:17.610Z","type":"output","payload":{"data":"\r\nECHO: pasted-content\r\nREADY> "}} +{"seq":10,"ts":"2026-03-19T18:02:18.620Z","type":"resize","payload":{"cols":120,"rows":40}} +{"seq":11,"ts":"2026-03-19T18:02:19.369Z","type":"signal","payload":{"signal":"SIGINT"}} +{"seq":12,"ts":"2026-03-19T18:02:19.370Z","type":"output","payload":{"data":"INTERRUPTED\r\n"}} +{"seq":13,"ts":"2026-03-19T18:02:19.380Z","type":"exit","payload":{"exitCode":130,"exitSignal":null}} diff --git a/dogfood/20260319-lifecycle/manifest.json b/dogfood/20260319-lifecycle/manifest.json new file mode 100644 index 0000000..41688dd --- /dev/null +++ b/dogfood/20260319-lifecycle/manifest.json @@ -0,0 +1,20 @@ +{ + "scenario": "lifecycle-proof", + "date": "2026-03-19", + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "commands": [ + "create", + "list", + "inspect", + "type", + "send-keys", + "wait", + "paste", + "resize", + "signal", + "destroy" + ], + "fixture": "hello-prompt", + "result": "pass", + "knownGaps": ["renderer screenshots", "asciicast export", "gc command"] +} diff --git a/dogfood/20260319-lifecycle/notes.md b/dogfood/20260319-lifecycle/notes.md new file mode 100644 index 0000000..18df254 --- /dev/null +++ b/dogfood/20260319-lifecycle/notes.md @@ -0,0 +1,54 @@ +# Lifecycle proof bundle + +- **Date:** 2026-03-19 +- **Scenario:** Full session lifecycle against the `hello-prompt` fixture +- **Fixture command:** `node --import tsx ../../test/fixtures/apps/hello-prompt/main.ts` +- **Session ID:** `01KM3M69V23RWMMDMS1EK3ZXB4` +- **Isolation:** run under a fresh `AGENT_TERMINAL_HOME=$(mktemp -d)` so only this scenario's state was present +- **Overall result:** pass; every JSON envelope in this directory has `ok: true` + +## What was run + +This scenario exercises the Week 1 control-plane lifecycle end to end: create, list, inspect, type, send Enter, wait for idle, paste, resize, signal, wait for exit, inspect the exited session, and destroy it. + +For the `create` step, the working invocation was `create --json -- node --import tsx ...` so the CLI parsed `--json` as a control-plane flag and `--import tsx ...` as the child command. + +## Step-by-step review guide + +| Step | File | What the command did | What the reviewer should observe | +| ---- | ------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `01-create.json` | Created a session running the `hello-prompt` fixture. | `ok: true`, `command: "create"`, and `result.sessionId == "01KM3M69V23RWMMDMS1EK3ZXB4"`. | +| 2 | `02-list.json` | Listed all sessions in the isolated home directory. | Exactly one session is present; its `sessionId` matches step 1, its `status` is `running`, and the command array points at the fixture. | +| 3 | `03-inspect-live.json` | Inspected the live session before any interaction. | `status: "running"`, `cols: 80`, `rows: 24`, and populated `hostPid` / `childPid`. `exitCode` and `exitSignal` are still `null`. | +| 4 | `04-type.json` | Sent literal text `hello world` to the PTY without pressing Enter. | Ack-only envelope: `ok: true` with an empty `result` object. The effect is visible in `event-log.jsonl` at seq 1-2. | +| 5 | `05-send-keys.json` | Sent the `Enter` key to submit the typed line. | Ack-only envelope. In the event log, seq 3-5 shows the Enter key, a newline, and the fixture response `ECHO: hello world` followed by `READY> `. | +| 6 | `06-wait-idle.json` | Waited for the session to go idle after the first prompt round-trip. | `timedOut: false`, proving the prompt settled within the 10s timeout. | +| 7 | `07-paste.json` | Sent a paste payload containing `pasted-content`. | Ack-only envelope. In the event log, seq 6 records `input_paste` with bracketed-paste wrappers and seq 7 shows the raw terminal echo. | +| 8 | `08-send-keys-enter.json` | Sent `Enter` so the pasted line would execute. | Ack-only envelope. Event-log seq 8-9 shows the Enter key and the fixture response `ECHO: pasted-content` followed by another prompt. | +| 9 | `09-wait-idle-2.json` | Waited for idle after the paste flow. | `timedOut: false`, confirming the second prompt cycle completed. | +| 10 | `10-resize.json` | Resized the PTY to 120x40. | `result.cols == 120` and `result.rows == 40`. | +| 11 | `11-inspect-resized.json` | Re-inspected the live session after resize. | Session is still `running`, and `cols` / `rows` now read `120` / `40`. | +| 12 | `12-signal.json` | Delivered `SIGINT` to the session. | `signal: "SIGINT"` and `delivered: true`. | +| 13 | `13-wait-exit.json` | Waited specifically for process exit. | `timedOut: false` and `exitCode: 130`, matching a Ctrl-C style termination. | +| 14 | `14-inspect-exited.json` | Inspected the terminated session before deletion. | `status: "exited"`, `exitCode: 130`, and the resized dimensions `120x40` are still preserved in metadata. | +| 15 | `15-destroy.json` | Deleted the session record from the isolated home directory. | `destroyed: true` and the same `sessionId` appears in the result. | + +## Event log observations + +- `event-log.jsonl` has 14 entries with monotonic sequence numbers `0` through `13`. +- The first entry is prompt output: `READY> `. +- The typed-text path is visible as `input_text` followed by echoed output. +- The paste path is distinct: seq 6 is `input_paste` and contains bracketed-paste control wrappers (`[200~` / `[201~`). +- The resize is recorded explicitly at seq 10 with `cols: 120` and `rows: 40`. +- The shutdown path is visible as `signal` -> `output` (`INTERRUPTED`) -> `exit` with `exitCode: 130`. + +## Known gaps + +- No renderer screenshots are included because the renderer path is not implemented yet. +- No asciicast export is available yet, so the proof is JSON/event-log based rather than video based. +- The `gc` command is not implemented yet, so garbage-collection behavior is out of scope for this bundle. + +## Additional notes + +- No command failed during this run, so there are no expected `ok: false` envelopes to explain. +- The bundle is intentionally self-contained for reviewer consumption: the JSON envelopes show command results, and `event-log.jsonl` shows the terminal-side evidence those commands produced. diff --git a/dogfood/20260319-lifecycle/screenshots/01-create.svg b/dogfood/20260319-lifecycle/screenshots/01-create.svg new file mode 100644 index 0000000..30035a8 --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/01-create.svg @@ -0,0 +1,29 @@ + + + + + + + + + $ Create Session + + +
$ npx tsx src/cli/main.ts create -- node --import tsx test/fixtures/apps/hello-prompt/main.ts --json 2>/dev/null
+
+Session created: 01KM3MPTG735D2K160BW3S452H
+
+✓ Session successfully created and running
+  Process ID: 299338
+  Terminal: 80x24
+  Status: running
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/02-list.svg b/dogfood/20260319-lifecycle/screenshots/02-list.svg new file mode 100644 index 0000000..39b95ed --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/02-list.svg @@ -0,0 +1,38 @@ + + + + + + + + + $ List Sessions + + +
$ npx tsx src/cli/main.ts list --json 2>/dev/null
+
+{
+  "ok": true,
+  "command": "list",
+  "result": {
+    "sessions": [
+      {
+        "sessionId": "01KM3MPTG735D2K160BW3S452H",
+        "status": "running",
+        "createdAt": "2026-03-19T18:11:16.108Z"
+      }
+    ]
+  }
+}
+
+✓ One active session found
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/03-after-input.svg b/dogfood/20260319-lifecycle/screenshots/03-after-input.svg new file mode 100644 index 0000000..b112b2d --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/03-after-input.svg @@ -0,0 +1,42 @@ + + + + + + + + + $ Type and Send Input + + +
$ npx tsx src/cli/main.ts type SESSION_ID "hello world" --json 2>/dev/null
+{
+  "ok": true,
+  "command": "type"
+}
+
+$ npx tsx src/cli/main.ts send-keys SESSION_ID Enter --json 2>/dev/null
+{
+  "ok": true,
+  "command": "send-keys"
+}
+
+$ npx tsx src/cli/main.ts wait SESSION_ID --idle-ms 500 --timeout 10000 --json 2>/dev/null
+{
+  "ok": true,
+  "result": {
+    "timedOut": false
+  }
+}
+
+✓ Session processed input successfully
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/04-inspect.svg b/dogfood/20260319-lifecycle/screenshots/04-inspect.svg new file mode 100644 index 0000000..ba7cf39 --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/04-inspect.svg @@ -0,0 +1,39 @@ + + + + + + + + + $ Inspect Live Session + + +
$ npx tsx src/cli/main.ts inspect SESSION_ID --json 2>/dev/null
+
+{
+  "ok": true,
+  "command": "inspect",
+  "result": {
+    "session": {
+      "sessionId": "01KM3MPTG735D2K160BW3S452H",
+      "status": "running",
+      "cols": 80,
+      "rows": 24,
+      "hostPid": 299326,
+      "childPid": 299338
+    }
+  }
+}
+
+✓ Session details retrieved
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/05-resize.svg b/dogfood/20260319-lifecycle/screenshots/05-resize.svg new file mode 100644 index 0000000..8e2f039 --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/05-resize.svg @@ -0,0 +1,40 @@ + + + + + + + + + $ Resize Session + + +
$ npx tsx src/cli/main.ts resize SESSION_ID --cols 120 --rows 40 --json 2>/dev/null
+
+{
+  "ok": true,
+  "result": {
+    "cols": 120,
+    "rows": 40
+  }
+}
+
+$ npx tsx src/cli/main.ts inspect SESSION_ID --json 2>/dev/null
+{
+  "session": {
+    "cols": 120,
+    "rows": 40
+  }
+}
+
+✓ Session resized from 80x24 to 120x40
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/06-destroy.svg b/dogfood/20260319-lifecycle/screenshots/06-destroy.svg new file mode 100644 index 0000000..40c1fc7 --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/06-destroy.svg @@ -0,0 +1,43 @@ + + + + + + + + + $ Destroy and Final State + + +
$ npx tsx src/cli/main.ts destroy SESSION_ID --force --json 2>/dev/null
+
+{
+  "ok": true,
+  "result": {
+    "sessionId": "01KM3MPTG735D2K160BW3S452H",
+    "destroyed": true
+  }
+}
+
+$ npx tsx src/cli/main.ts list --all --json 2>/dev/null
+
+{
+  "sessions": [
+    {
+      "sessionId": "01KM3MPTG735D2K160BW3S452H",
+      "status": "exited"
+    }
+  ]
+}
+
+✓ Session destroyed
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-nvim-demo/dogfood.md b/dogfood/20260319-nvim-demo/dogfood.md new file mode 100644 index 0000000..1416b05 --- /dev/null +++ b/dogfood/20260319-nvim-demo/dogfood.md @@ -0,0 +1,6 @@ +# Dogfood Demo + +This file was created by agent-terminal driving neovim. +All keystrokes were sent via the agent-terminal CLI. + +Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ diff --git a/dogfood/20260319-nvim-demo/event-log.jsonl b/dogfood/20260319-nvim-demo/event-log.jsonl new file mode 100644 index 0000000..dfac3ce --- /dev/null +++ b/dogfood/20260319-nvim-demo/event-log.jsonl @@ -0,0 +1,65 @@ +{"seq":0,"ts":"2026-03-19T18:23:55.324Z","type":"output","payload":{"data":"\u001b[?1049h\u001b[22;0;0t\u001b[22;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b]11;?\u0007\u001b[?2004h\u001b[?u\u001b[c\u001b[?25h"}} +{"seq":1,"ts":"2026-03-19T18:23:55.368Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\u001b[H\u001b[2J\u001b[96m\" ============================================================================\u001b(B\u001b[m\u001b[K\r\n\u001b[96m\" Netrw Directory Listing \u001b(B\u001b[0;1m\u001b[96m(netrw v171)\u001b(B\u001b[m\u001b[K\r\n\u001b[96m\" /home/coder/.mux/src/agent-terminal/planning-ws01\u001b(B\u001b[m\u001b[K\r\n\u001b[96m\" Sorted by\u001b(B\u001b[m\u001b[93m name\u001b(B\u001b[m\u001b[K\r\n\u001b[96m\" Sort sequence:\u001b(B\u001b[m\u001b[93m [\\/]$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.h$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.c$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.cpp$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\~\\=\\*$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m*\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.o$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.obj$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.info$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.swp$\r\n\u001b(B\u001b[m\u001b[96m\" Quick Help: \u001b(B\u001b[0;1m\u001b[96m\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mhelp \u001b(B\u001b[0;1m\u001b[96m-\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mgo up dir \u001b(B\u001b[0;1m\u001b[96mD\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mdelete \u001b(B\u001b[0;1m\u001b[96mR\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mrename \u001b(B\u001b[0;1m\u001b[96ms\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[msort-by \u001b(B\u001b[0;1m\u001b[96mx\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mspecial\u001b[K\r\n\u001b[96m\" ==============================================================================\u001b(B\u001b[m\u001b[K\r\n\u001b(B\u001b[0;4m\u001b[38;5;159m..\u001b(B\u001b[0;1;4m\u001b[96m/\u001b(B\u001b[0;4m \r\n\u001b(B\u001b[m\u001b[38;5;159m.\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159m.github\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159m.mux\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mdesign\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mdist\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mdogfood\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mnode_modules\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159msrc\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mtest\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n.editorconfig\u001b[K\r\n.git\u001b[K\r\n.gitignore\u001b[K\r\n.prettierignore\u001b[K\r\n.prettierrc.json\u001b[K\r\n.tsconfig.build.tsbuildinfo\u001b[K\r\n.tsconfig.tsbuildinfo\u001b[K\r\nREADME.md\u001b[K\r\neslint.config.mjs\u001b[K\r\nmise.toml\u001b[K\r\npackage-lock.json\u001b[K\r\n\u001b(B\u001b[0;1;7m[No Name] [RO] 8,1 Top\u001b]112\u0007\u001b[2 q\u001b]112\u0007\u001b[2 q\r\u001b[21A\u001b[?25h"}} +{"seq":2,"ts":"2026-03-19T18:23:55.408Z","type":"output","payload":{"data":"\u001b[?25l\u001b[?1004h\u001b[?25h"}} +{"seq":3,"ts":"2026-03-19T18:24:34.585Z","type":"input_keys","payload":{"keys":["Escape"]}} +{"seq":4,"ts":"2026-03-19T18:24:34.635Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H\u001b(B\u001b[m^[ \r\u001b[22A\u001b[?25h\u001b[?25l\u001b[30;90H \r\u001b[22A\u001b[?25h"}} +{"seq":5,"ts":"2026-03-19T18:24:35.206Z","type":"input_text","payload":{"data":":enew"}} +{"seq":6,"ts":"2026-03-19T18:24:35.207Z","type":"output","payload":{"data":"\u001b[?25l\u001b[22B\u001b[J:enew\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":7,"ts":"2026-03-19T18:24:35.840Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":8,"ts":"2026-03-19T18:24:35.840Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":9,"ts":"2026-03-19T18:24:35.841Z","type":"output","payload":{"data":"\u001b[?25l\u001b[H\u001b[78X\n\u001b[94m~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\u001b(B\u001b[0;1;7m\u001b[29;11H \u001b[68C0,0-1 All\u001b]112\u0007\u001b[2 q\u001b[H\u001b[?25h"}} +{"seq":10,"ts":"2026-03-19T18:24:37.175Z","type":"input_text","payload":{"data":":file dogfood"}} +{"seq":11,"ts":"2026-03-19T18:24:37.176Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\u001b[29B\u001b[5X:file\u001b[Cdogfood\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":12,"ts":"2026-03-19T18:24:37.751Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":13,"ts":"2026-03-19T18:24:37.751Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":14,"ts":"2026-03-19T18:24:37.752Z","type":"output","payload":{"data":"\u001b[?25l\u001b[A\u001b(B\u001b[0;1;7mdogfood \u001b]112\u0007\u001b[2 q\u001b[H\u001b[?25h"}} +{"seq":15,"ts":"2026-03-19T18:24:55.669Z","type":"input_keys","payload":{"keys":["i"]}} +{"seq":16,"ts":"2026-03-19T18:24:55.670Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H\u001b(B\u001b[mi\u001b[H\u001b[?25h\u001b[?25l\u001b[30;90H \u001b]112\u0007\u001b[6 q\u001b[H\u001b[?25h\u001b[?25l\u001b[29B\u001b(B\u001b[0;1m-- INSERT --\u001b(B\u001b[m \b\u001b[?25h\u001b[?25l\u001b[29;85H\u001b(B\u001b[0;1;7m1 \u001b[H\u001b[?25h"}} +{"seq":17,"ts":"2026-03-19T18:24:56.319Z","type":"input_text","payload":{"data":"# Dogfood Demo"}} +{"seq":18,"ts":"2026-03-19T18:24:56.319Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m# Dogfood Demo\u001b[29;9H\u001b(B\u001b[0;1;7m[+]\u001b[71C1,15\u001b[1;15H\u001b[?25h"}} +{"seq":19,"ts":"2026-03-19T18:24:56.985Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":20,"ts":"2026-03-19T18:24:56.985Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m2,1 \r\u001b[27A\u001b[?25h"}} +{"seq":21,"ts":"2026-03-19T18:24:57.575Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":22,"ts":"2026-03-19T18:24:57.575Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m3\r\u001b[26A\u001b[?25h"}} +{"seq":23,"ts":"2026-03-19T18:24:58.228Z","type":"input_text","payload":{"data":"This file was created by agent-terminal driving neovim."}} +{"seq":24,"ts":"2026-03-19T18:24:58.229Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[mThis file was created by agent-terminal driving neovim.\u001b[29;85H\u001b(B\u001b[0;1;7m56\u001b[3;56H\u001b[?25h"}} +{"seq":25,"ts":"2026-03-19T18:24:58.852Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":26,"ts":"2026-03-19T18:24:58.853Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m4,1 \r\u001b[25A\u001b[?25h"}} +{"seq":27,"ts":"2026-03-19T18:24:59.542Z","type":"input_text","payload":{"data":"All keystrokes were sent via the agent-terminal CLI."}} +{"seq":28,"ts":"2026-03-19T18:24:59.542Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[mAll keystrokes were sent via the agent-terminal CLI.\u001b[29;85H\u001b(B\u001b[0;1;7m53\u001b[4;53H\u001b[?25h"}} +{"seq":29,"ts":"2026-03-19T18:25:00.177Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":30,"ts":"2026-03-19T18:25:00.178Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m5,1 \r\u001b[24A\u001b[?25h"}} +{"seq":31,"ts":"2026-03-19T18:25:00.797Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":32,"ts":"2026-03-19T18:25:00.798Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m6\r\u001b[23A\u001b[?25h"}} +{"seq":33,"ts":"2026-03-19T18:25:01.417Z","type":"input_text","payload":{"data":"Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ"}} +{"seq":34,"ts":"2026-03-19T18:25:01.418Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[mSession ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ\u001b[29;85H\u001b(B\u001b[0;1;7m39\u001b[6;39H\u001b[?25h"}} +{"seq":35,"ts":"2026-03-19T18:25:15.048Z","type":"input_keys","payload":{"keys":["Escape"]}} +{"seq":36,"ts":"2026-03-19T18:25:15.099Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\u001b[24B\u001b[12X\u001b[29;86H\u001b(B\u001b[0;1;7m8\u001b]112\u0007\u001b[2 q\u001b[6;38H\u001b[?25h"}} +{"seq":37,"ts":"2026-03-19T18:25:16.377Z","type":"input_text","payload":{"data":":w"}} +{"seq":38,"ts":"2026-03-19T18:25:16.377Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[24B\u001b(B\u001b[m:w\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":39,"ts":"2026-03-19T18:25:16.987Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":40,"ts":"2026-03-19T18:25:16.987Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":41,"ts":"2026-03-19T18:25:16.987Z","type":"output","payload":{"data":"\u001b[?25l\u001b[97m\u001b[41mE17: \"/home/coder/.mux/src/agent-terminal/planning-ws01/dogfood\" is a directory\u001b]112\u0007\u001b[2 q\u001b[6;38H\u001b[?25h"}} +{"seq":42,"ts":"2026-03-19T18:25:31.906Z","type":"input_text","payload":{"data":":file dogfood.md"}} +{"seq":43,"ts":"2026-03-19T18:25:31.906Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\u001b[24B\u001b[79X:file\u001b[Cdogfood.md\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":44,"ts":"2026-03-19T18:25:32.480Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":45,"ts":"2026-03-19T18:25:32.481Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":46,"ts":"2026-03-19T18:25:32.481Z","type":"output","payload":{"data":"\u001b[?25l\u001b[29;8H\u001b(B\u001b[0;1;7m.md [+]\u001b]112\u0007\u001b[2 q\u001b[6;38H\u001b[?25h"}} +{"seq":47,"ts":"2026-03-19T18:25:33.779Z","type":"input_text","payload":{"data":":w"}} +{"seq":48,"ts":"2026-03-19T18:25:33.779Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\u001b[24B\u001b[16X:w\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":49,"ts":"2026-03-19T18:25:34.443Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":50,"ts":"2026-03-19T18:25:34.443Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":51,"ts":"2026-03-19T18:25:34.443Z","type":"output","payload":{"data":"\u001b[?25l\"dogfood.md\"\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":52,"ts":"2026-03-19T18:25:34.444Z","type":"output","payload":{"data":"\u001b[?25l\u001b[C[New] 6L, 165B written\u001b(B\u001b[0;1;7m\u001b[29;12H \u001b[6;38H\u001b[?25h"}} +{"seq":53,"ts":"2026-03-19T18:25:48.912Z","type":"input_keys","payload":{"keys":["g"]}} +{"seq":54,"ts":"2026-03-19T18:25:48.912Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H\u001b(B\u001b[mg\u001b[6;38H\u001b[?25h"}} +{"seq":55,"ts":"2026-03-19T18:25:49.539Z","type":"input_keys","payload":{"keys":["g"]}} +{"seq":56,"ts":"2026-03-19T18:25:49.540Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H \u001b[6;38H\u001b[?25h\u001b[?25l\u001b[30;90Hgg\u001b[6;38H\u001b[?25h"}} +{"seq":57,"ts":"2026-03-19T18:25:49.540Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H \u001b[29;83H\u001b(B\u001b[0;1;7m1,14\u001b[1;14H\u001b[?25h"}} +{"seq":58,"ts":"2026-03-19T18:25:57.400Z","type":"input_text","payload":{"data":":q"}} +{"seq":59,"ts":"2026-03-19T18:25:57.400Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\u001b[29B\u001b[35X:q\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":60,"ts":"2026-03-19T18:25:58.055Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":61,"ts":"2026-03-19T18:25:58.055Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":62,"ts":"2026-03-19T18:25:58.067Z","type":"output","payload":{"data":"\u001b[?25l\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":63,"ts":"2026-03-19T18:25:58.067Z","type":"output","payload":{"data":"\u001b[?25l\u001b]112\u0007\u001b[2 q\u001b(B\u001b[m\u001b[?25h\u001b[?1l\u001b>\u001b[?1049l\u001b[23;0;0t\u001b[23;0t\u001b[?2004l\u001b[?1004l\u001b[?25h"}} +{"seq":64,"ts":"2026-03-19T18:25:58.079Z","type":"exit","payload":{"exitCode":0,"exitSignal":null}} diff --git a/dogfood/20260319-nvim-demo/inspect-final.json b/dogfood/20260319-nvim-demo/inspect-final.json new file mode 100644 index 0000000..80a3205 --- /dev/null +++ b/dogfood/20260319-nvim-demo/inspect-final.json @@ -0,0 +1,22 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:26:09.136Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3NDZK4TXG5C3SQJ811ZGVJ", + "createdAt": "2026-03-19T18:23:54.983Z", + "updatedAt": "2026-03-19T18:25:58.079Z", + "status": "exited", + "command": ["nvim", "."], + "cwd": "/home/coder/.mux/src/agent-terminal/planning-ws01", + "cols": 100, + "rows": 30, + "hostPid": 347474, + "childPid": 347584, + "exitCode": 0, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-nvim-demo/manifest.json b/dogfood/20260319-nvim-demo/manifest.json new file mode 100644 index 0000000..27cc7df --- /dev/null +++ b/dogfood/20260319-nvim-demo/manifest.json @@ -0,0 +1,26 @@ +{ + "scenario": "nvim-dogfood", + "date": "2026-03-19", + "sessionId": "01KM3NDZK4TXG5C3SQJ811ZGVJ", + "command": ["nvim", "."], + "dimensions": { "cols": 100, "rows": 30 }, + "cliCommandsUsed": ["create", "wait", "send-keys", "type", "inspect"], + "vimMotionsUsed": [ + ":enew", + ":file", + "i (insert mode)", + ":w (save)", + "gg (go to top)", + ":q (quit)", + "Escape" + ], + "result": "pass", + "exitCode": 0, + "eventCount": 65, + "fileCreated": "dogfood.md", + "knownGaps": [ + "renderer screenshots (text-only snapshots from event log)", + "asciicast export", + "gc command" + ] +} diff --git a/dogfood/20260319-nvim-demo/notes.md b/dogfood/20260319-nvim-demo/notes.md new file mode 100644 index 0000000..a18e467 --- /dev/null +++ b/dogfood/20260319-nvim-demo/notes.md @@ -0,0 +1,87 @@ +# Nvim Dogfood Demo — agent-terminal Week 1 + +- **Date:** 2026-03-19 +- **Scenario:** Driving `neovim` entirely through the `agent-terminal` CLI +- **Session ID:** `01KM3NDZK4TXG5C3SQJ811ZGVJ` +- **Command:** `nvim .` +- **Dimensions:** `100x30` +- **Created:** `2026-03-19T18:23:54.983Z` +- **Exited:** `2026-03-19T18:25:58.079Z` +- **Exit code:** `0` +- **Overall result:** pass + +## Scenario summary + +This proof bundle demonstrates that the Week 1 control plane can drive a complex, modal, full-screen terminal application rather than only narrow fixture programs. In this run, `agent-terminal` launched `neovim`, opened a new buffer, named it, entered insert mode, typed multi-line Markdown content, saved the file, navigated with a real Vim motion (`gg`), and exited cleanly. + +That combination matters because `nvim` exercises several properties at once: full-screen terminal rendering, modal input handling, command-line mode, text insertion, file saving, cursor movement, and orderly process exit. The run therefore acts as a stronger dogfood demo than a simple line-oriented prompt loop. + +## Session metadata + +| Field | Value | +| ----------- | ---------------------------- | +| Session ID | `01KM3NDZK4TXG5C3SQJ811ZGVJ` | +| Command | `nvim .` | +| Dimensions | `100 cols x 30 rows` | +| Created | `2026-03-19T18:23:54.983Z` | +| Exited | `2026-03-19T18:25:58.079Z` | +| Exit status | `exited` | +| Exit code | `0` | + +`inspect-final.json` is the final machine-readable confirmation that the session exited normally with code `0`. + +## Step-by-step walkthrough + +| Step | CLI action | What it did | Expected evidence | +| ---- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| 1 | `create --cols 100 --rows 30 -- nvim .` | Started a new session and launched `nvim` against the current directory. | `01-nvim-launched.txt` should show the initial netrw directory listing inside Neovim. | +| 2 | `send-keys Escape` | Forced normal mode before issuing editor commands. | The session remains in Neovim and is ready for command-mode input. | +| 3 | `type ":enew"` + `send-keys Enter` | Created a fresh empty buffer. | `02-new-buffer.txt` should show an empty buffer named `dogfood`. | +| 4 | `type ":file dogfood"` + `send-keys Enter` | Assigned the new buffer an initial name. | The buffer name changes to `dogfood`, but this later proves ambiguous because a `dogfood/` directory already exists. | +| 5 | `send-keys i` | Entered INSERT mode. | Neovim is ready to accept literal text input. | +| 6 | `type "# Dogfood Demo"` + `send-keys Enter` | Wrote the Markdown heading. | The first line of the document is populated. | +| 7 | `type "This file was created by agent-terminal driving neovim."` + `send-keys Enter` | Added the first explanatory sentence. | The second line appears beneath the heading. | +| 8 | `type "All keystrokes were sent via the agent-terminal CLI."` + `send-keys Enter` | Added the second explanatory sentence. | The third line appears in the buffer. | +| 9 | `type "Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ"` | Added the run-specific session identifier to the file contents. | The fourth content line includes the exact session ID used for the demo. | +| 10 | `send-keys Escape` | Returned to normal mode. | Insert mode ends so Ex commands can be issued again. | +| 11 | `type ":file dogfood.md"` + `send-keys Enter` | Renamed the buffer to `dogfood.md`. | This is the real-world correction after discovering that `dogfood` conflicts with an existing directory name. | +| 12 | `type ":w"` + `send-keys Enter` | Saved the file to disk. | `04-file-saved.txt` should show the successful write message: `"dogfood.md" [New] 6L, 165B written`. | +| 13 | `send-keys g` + `send-keys g` | Executed the Vim motion `gg` to jump to the top of the file. | `05-gg-top.txt` should show the cursor positioned back at line 1. | +| 14 | `type ":q"` + `send-keys Enter` | Quit Neovim. | `06-nvim-quit.txt` should show the post-exit terminal state. | +| 15 | `wait --exit` | Waited for process termination. | The session exits cleanly without timing out. | +| 16 | `inspect` | Collected final session state. | `inspect-final.json` should report `status: "exited"` and `exitCode: 0`. | + +## Screenshot review guide + +The screenshot artifacts for this bundle are text snapshots captured from the event log rather than renderer-produced terminal frames. + +| File | What the reviewer should observe | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `screenshots/01-nvim-launched.txt` | Neovim has launched successfully and is showing the netrw directory browser for the current working directory. | +| `screenshots/02-new-buffer.txt` | An empty buffer is open after `:enew`, with the provisional name `dogfood`. | +| `screenshots/03-content-typed.txt` | The Markdown content has been typed into the buffer while in INSERT mode. | +| `screenshots/04-file-saved.txt` | The status area confirms the save succeeded as `dogfood.md` with `6L, 165B written`. | +| `screenshots/05-gg-top.txt` | The `gg` motion has moved the cursor to the top of the file. | +| `screenshots/06-nvim-quit.txt` | Neovim has exited, demonstrating clean control handoff back to the terminal session. | + +## Event log observations + +- `event-log.jsonl` contains **65 events** for this run. +- The log spans the important interaction categories for an editor demo: terminal `output`, typed text via `input_text`, individual keypresses via `input_keys`, and the final `exit` record. +- That coverage is important because it shows the control plane is not faking a one-shot file write; it is actually driving the interactive program through the same primitives exposed by the CLI. +- The final session result is corroborated by `inspect-final.json`, which records an exited session with exit code `0`. + +## Real-world debugging note: `dogfood` -> `dogfood.md` + +One useful detail from this run is that the first filename choice (`dogfood`) had to be corrected to `dogfood.md` because the repository already contains a `dogfood/` directory. That small rename is worth preserving in the notes because it shows the demo was interactive and realistic: the operator hit an ordinary naming conflict, adjusted the buffer name, and continued successfully. + +## Known gaps + +- The screenshot artifacts are **text snapshots derived from the event log**, not rendered terminal frames. +- A renderer-backed screenshot path is not implemented yet, so this bundle does not include pixel-faithful terminal captures. +- No asciicast export is included yet. +- The `gc` command is still out of scope for this bundle. + +## Conclusion + +This demo proves that the Week 1 `agent-terminal` control plane can drive a sophisticated interactive terminal program like Neovim end to end: launch it, switch modes, enter text, issue editor commands, save a file, navigate with Vim motions, and exit cleanly. Even without a renderer-backed screenshot pipeline, the combination of the session metadata, event log, final inspection output, and text-based screenshots is strong evidence that the control plane works on real terminal software rather than only on purpose-built fixtures. diff --git a/dogfood/20260319-nvim-demo/screenshots/01-nvim-launched.txt b/dogfood/20260319-nvim-demo/screenshots/01-nvim-launched.txt new file mode 100644 index 0000000..f990a56 --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/01-nvim-launched.txt @@ -0,0 +1,30 @@ +=== Step 1: nvim launched with netrw directory listing === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top diff --git a/dogfood/20260319-nvim-demo/screenshots/02-new-buffer.txt b/dogfood/20260319-nvim-demo/screenshots/02-new-buffer.txt new file mode 100644 index 0000000..f9aaa4b --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/02-new-buffer.txt @@ -0,0 +1,57 @@ +=== Step 2: New empty buffer named 'dogfood' === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood diff --git a/dogfood/20260319-nvim-demo/screenshots/03-content-typed.txt b/dogfood/20260319-nvim-demo/screenshots/03-content-typed.txt new file mode 100644 index 0000000..e464ff8 --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/03-content-typed.txt @@ -0,0 +1,62 @@ +=== Step 3: Content typed in insert mode === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood i -- INSERT -- 1 # Dogfood Demo[+]1,15 +2,1 +3 This file was created by agent-terminal driving neovim.56 +4,1 All keystrokes were sent via the agent-terminal CLI.53 +5,1 +6 Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ39 diff --git a/dogfood/20260319-nvim-demo/screenshots/04-file-saved.txt b/dogfood/20260319-nvim-demo/screenshots/04-file-saved.txt new file mode 100644 index 0000000..f52ad04 --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/04-file-saved.txt @@ -0,0 +1,62 @@ +=== Step 4: File saved as dogfood.md with :w === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood i -- INSERT -- 1 # Dogfood Demo[+]1,15 +2,1 +3 This file was created by agent-terminal driving neovim.56 +4,1 All keystrokes were sent via the agent-terminal CLI.53 +5,1 +6 Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ39 8 :w E17: "/home/coder/.mux/src/agent-terminal/planning-ws01/dogfood" is a directory :filedogfood.md .md [+] :w "dogfood.md"[New] 6L, 165B written diff --git a/dogfood/20260319-nvim-demo/screenshots/05-gg-top.txt b/dogfood/20260319-nvim-demo/screenshots/05-gg-top.txt new file mode 100644 index 0000000..b18e4aa --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/05-gg-top.txt @@ -0,0 +1,62 @@ +=== Step 5: Cursor at top of file (gg motion) === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood i -- INSERT -- 1 # Dogfood Demo[+]1,15 +2,1 +3 This file was created by agent-terminal driving neovim.56 +4,1 All keystrokes were sent via the agent-terminal CLI.53 +5,1 +6 Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ39 8 :w E17: "/home/coder/.mux/src/agent-terminal/planning-ws01/dogfood" is a directory :filedogfood.md .md [+] :w "dogfood.md"[New] 6L, 165B written g gg 1,14 diff --git a/dogfood/20260319-nvim-demo/screenshots/06-nvim-quit.txt b/dogfood/20260319-nvim-demo/screenshots/06-nvim-quit.txt new file mode 100644 index 0000000..a434879 --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/06-nvim-quit.txt @@ -0,0 +1,62 @@ +=== Step 6: nvim exited cleanly (exit code 0) === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood i -- INSERT -- 1 # Dogfood Demo[+]1,15 +2,1 +3 This file was created by agent-terminal driving neovim.56 +4,1 All keystrokes were sent via the agent-terminal CLI.53 +5,1 +6 Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ39 8 :w E17: "/home/coder/.mux/src/agent-terminal/planning-ws01/dogfood" is a directory :filedogfood.md .md [+] :w "dogfood.md"[New] 6L, 165B written g gg 1,14 :q diff --git a/dogfood/20260319-resize-demo/01-create.json b/dogfood/20260319-resize-demo/01-create.json new file mode 100644 index 0000000..d90eb10 --- /dev/null +++ b/dogfood/20260319-resize-demo/01-create.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-03-19T18:02:30.052Z", + "result": { + "sessionId": "01KM3M6RF40VCPP4WR580KDBE0" + } +} diff --git a/dogfood/20260319-resize-demo/02-wait-idle.json b/dogfood/20260319-resize-demo/02-wait-idle.json new file mode 100644 index 0000000..1356069 --- /dev/null +++ b/dogfood/20260319-resize-demo/02-wait-idle.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:30.640Z", + "result": { + "timedOut": false + } +} diff --git a/dogfood/20260319-resize-demo/03-resize.json b/dogfood/20260319-resize-demo/03-resize.json new file mode 100644 index 0000000..22cf70c --- /dev/null +++ b/dogfood/20260319-resize-demo/03-resize.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "resize", + "timestamp": "2026-03-19T18:02:30.975Z", + "result": { + "cols": 120, + "rows": 40 + } +} diff --git a/dogfood/20260319-resize-demo/04-wait-idle-2.json b/dogfood/20260319-resize-demo/04-wait-idle-2.json new file mode 100644 index 0000000..da9474b --- /dev/null +++ b/dogfood/20260319-resize-demo/04-wait-idle-2.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:31.572Z", + "result": { + "timedOut": false + } +} diff --git a/dogfood/20260319-resize-demo/05-inspect.json b/dogfood/20260319-resize-demo/05-inspect.json new file mode 100644 index 0000000..c082366 --- /dev/null +++ b/dogfood/20260319-resize-demo/05-inspect.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:02:31.966Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3M6RF40VCPP4WR580KDBE0", + "createdAt": "2026-03-19T18:02:29.736Z", + "updatedAt": "2026-03-19T18:02:30.973Z", + "status": "running", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/resize-demo/main.ts" + ], + "cwd": "/home/coder/.mux/src/agent-terminal/agent_exec_d1aeb26ed6/dogfood/20260319-resize-demo", + "cols": 120, + "rows": 40, + "hostPid": 280102, + "childPid": 280114, + "exitCode": null, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-resize-demo/06-destroy.json b/dogfood/20260319-resize-demo/06-destroy.json new file mode 100644 index 0000000..b926bc1 --- /dev/null +++ b/dogfood/20260319-resize-demo/06-destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-03-19T18:02:37.224Z", + "result": { + "sessionId": "01KM3M6RF40VCPP4WR580KDBE0", + "destroyed": true + } +} diff --git a/dogfood/20260319-resize-demo/event-log.jsonl b/dogfood/20260319-resize-demo/event-log.jsonl new file mode 100644 index 0000000..5fb8274 --- /dev/null +++ b/dogfood/20260319-resize-demo/event-log.jsonl @@ -0,0 +1,3 @@ +{"seq":0,"ts":"2026-03-19T18:02:30.129Z","type":"output","payload":{"data":"SIZE: 80x24\r\n"}} +{"seq":1,"ts":"2026-03-19T18:02:30.973Z","type":"output","payload":{"data":"SIZE: 120x40\r\n"}} +{"seq":2,"ts":"2026-03-19T18:02:30.974Z","type":"resize","payload":{"cols":120,"rows":40}} diff --git a/dogfood/20260319-resize-demo/manifest.json b/dogfood/20260319-resize-demo/manifest.json new file mode 100644 index 0000000..d1a17cd --- /dev/null +++ b/dogfood/20260319-resize-demo/manifest.json @@ -0,0 +1,9 @@ +{ + "scenario": "resize-demo", + "date": "2026-03-19", + "sessionId": "01KM3M6RF40VCPP4WR580KDBE0", + "commands": ["create", "wait", "resize", "inspect", "destroy"], + "fixture": "resize-demo", + "result": "pass", + "knownGaps": ["renderer screenshots", "asciicast export", "gc command"] +} diff --git a/dogfood/20260319-resize-demo/notes.md b/dogfood/20260319-resize-demo/notes.md new file mode 100644 index 0000000..31702c3 --- /dev/null +++ b/dogfood/20260319-resize-demo/notes.md @@ -0,0 +1,44 @@ +# Resize demo proof bundle + +- **Date:** 2026-03-19 +- **Scenario:** Resize behavior against the `resize-demo` fixture +- **Fixture command:** `node --import tsx ../../test/fixtures/apps/resize-demo/main.ts` +- **Session ID:** `01KM3M6RF40VCPP4WR580KDBE0` +- **Isolation:** run under a fresh `AGENT_TERMINAL_HOME=$(mktemp -d)` so only this scenario's state was present +- **Overall result:** pass; every JSON envelope in this directory has `ok: true` + +## What was run + +This scenario focuses on PTY size propagation. The fixture prints its current size on startup and again after resize, which makes it a compact proof that the control plane can create, wait, resize, observe the new size, inspect metadata, and destroy the session. + +For the `create` step, the working invocation was `create --json --cols 80 --rows 24 -- node --import tsx ...` so the size flags were consumed by the control plane and the remainder was passed to the child process. + +## Step-by-step review guide + +| Step | File | What the command did | What the reviewer should observe | +| ---- | --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| 1 | `01-create.json` | Created a session with explicit initial dimensions `80x24`. | `ok: true`, `command: "create"`, and `result.sessionId == "01KM3M6RF40VCPP4WR580KDBE0"`. | +| 2 | `02-wait-idle.json` | Waited for the fixture's initial size print to complete. | `timedOut: false`. The corresponding event-log output at seq 0 is `SIZE: 80x24`. | +| 3 | `03-resize.json` | Resized the PTY to `120x40`. | `result.cols == 120` and `result.rows == 40`. | +| 4 | `04-wait-idle-2.json` | Waited for the fixture to emit its post-resize size message. | `timedOut: false`. The event log records the new output `SIZE: 120x40`. | +| 5 | `05-inspect.json` | Inspected the still-running session after the resize. | `status: "running"`, `cols: 120`, `rows: 40`, and the command array points at the resize-demo fixture. | +| 6 | `06-destroy.json` | Force-destroyed the session after collecting evidence. | `destroyed: true` with the matching `sessionId`. | + +## Event log observations + +- `event-log.jsonl` has 3 entries with monotonic sequence numbers `0` through `2`. +- Seq 0 shows the initial size report `SIZE: 80x24`. +- Seq 1 shows the updated size report `SIZE: 120x40` after the resize command. +- Seq 2 records the explicit `resize` event with `cols: 120` and `rows: 40`. +- Notably, the fixture's output for the new size lands just before the explicit resize event entry in this run, so reviewers should treat both lines together as the resize proof rather than assuming a stricter output-before/after ordering contract. + +## Known gaps + +- No renderer screenshots are included because the renderer path is not implemented yet. +- No asciicast export is available yet, so the proof is JSON/event-log based rather than video based. +- The `gc` command is not implemented yet, so garbage-collection behavior is out of scope for this bundle. + +## Additional notes + +- No command failed during this run, so there are no expected `ok: false` envelopes to explain. +- This fixture is intentionally narrow: it exists to prove resize propagation rather than interactive input handling. diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts new file mode 100644 index 0000000..ab6917d --- /dev/null +++ b/src/cli/commands/create.ts @@ -0,0 +1,128 @@ +import { rm } from 'node:fs/promises'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; +import { + allocateSession, + launchHost, + reconcileSession, +} from '../../host/lifecycle.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { resolveHome } from '../../storage/home.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +const READINESS_POLL_INTERVAL_MS = 100; +const READINESS_MAX_ATTEMPTS = 50; +const READINESS_RPC_TIMEOUT_MS = 100; + +export interface CreateResult { + sessionId: string; +} + +interface CommandOptions { + json: boolean; + command: string[]; + shellCommand: string; + cwd: string; + cols: number; + rows: number; +} + +export async function runCreateCommand(options: CommandOptions): Promise { + let sessionId: string | undefined; + + try { + const allocatedSession = await allocateSession({ + command: options.command, + shellCommand: options.shellCommand, + cwd: options.cwd, + cols: options.cols, + rows: options.rows, + }); + sessionId = allocatedSession.sessionId; + + launchHost(sessionId); + } catch (error) { + if (sessionId !== undefined) { + const home = resolveHome(); + await rm(sessionDir(home, sessionId), { + recursive: true, + force: true, + }).catch(() => undefined); + } + + if (error instanceof CliError) { + throw error; + } + + throw makeCliError(ERROR_CODES.INTERNAL_ERROR, { + message: + error instanceof Error ? error.message : 'Failed to create session.', + cause: error, + }); + } + + const home = resolveHome(); + const sessionDirectory = sessionDir(home, sessionId); + const socketFile = socketPath(sessionDirectory); + let lastError: CliError | null = null; + + for (let attempt = 0; attempt < READINESS_MAX_ATTEMPTS; attempt += 1) { + try { + await sendRpc(socketFile, 'inspect', undefined, READINESS_RPC_TIMEOUT_MS); + emitSuccess({ + command: 'create', + json: options.json, + result: { sessionId }, + lines: [`Session created: ${sessionId}`], + }); + return; + } catch (error) { + if ( + error instanceof CliError && + (error.code === ERROR_CODES.HOST_UNREACHABLE || + error.code === ERROR_CODES.HOST_TIMEOUT) + ) { + const manifest = await readManifestIfExists( + manifestPath(sessionDirectory), + ); + if (manifest?.status === 'exited') { + emitSuccess({ + command: 'create', + json: options.json, + result: { sessionId }, + lines: [`Session created: ${sessionId}`], + }); + return; + } + + lastError = error; + if (attempt + 1 < READINESS_MAX_ATTEMPTS) { + await delay(READINESS_POLL_INTERVAL_MS); + continue; + } + } + + throw error; + } + } + + await reconcileSession(sessionDirectory).catch(() => undefined); + + throw makeCliError(ERROR_CODES.HOST_TIMEOUT, { + message: `Timed out waiting for session "${sessionId}" to become ready.`, + details: { + sessionId, + sessionDirectory, + causeCode: lastError?.code, + }, + cause: lastError, + }); +} diff --git a/src/cli/commands/destroy.ts b/src/cli/commands/destroy.ts new file mode 100644 index 0000000..1b2bdcf --- /dev/null +++ b/src/cli/commands/destroy.ts @@ -0,0 +1,29 @@ +import { emitSuccess } from '../output.js'; +import { destroySession } from '../../host/lifecycle.js'; + +export interface DestroyResult { + sessionId: string; + destroyed: boolean; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + force: boolean; +} + +export async function runDestroyCommand( + options: CommandOptions, +): Promise { + await destroySession(options.sessionId, options.force); + + emitSuccess({ + command: 'destroy', + json: options.json, + result: { + sessionId: options.sessionId, + destroyed: true, + }, + lines: [`Session destroyed: ${options.sessionId}`], + }); +} diff --git a/src/cli/commands/inspect.ts b/src/cli/commands/inspect.ts new file mode 100644 index 0000000..0322b24 --- /dev/null +++ b/src/cli/commands/inspect.ts @@ -0,0 +1,85 @@ +import type { SessionRecord } from '../../protocol/schemas.js'; + +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; +import { reconcileSession } from '../../host/lifecycle.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifest, readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +export interface InspectResult { + session: SessionRecord; +} + +interface CommandOptions { + json: boolean; + sessionId: string; +} + +function formatSessionLines(session: SessionRecord): string[] { + return [ + `Session ID: ${session.sessionId}`, + `Status: ${session.status}`, + `Command: ${session.command.join(' ')}`, + `Working Directory: ${session.cwd}`, + `Size: ${String(session.cols)}x${String(session.rows)}`, + `Created At: ${session.createdAt}`, + `Updated At: ${session.updatedAt}`, + `Host PID: ${String(session.hostPid ?? '-')}`, + `Child PID: ${String(session.childPid ?? '-')}`, + `Exit Code: ${String(session.exitCode ?? '-')}`, + `Exit Signal: ${session.exitSignal ?? '-'}`, + ]; +} + +export async function runInspectCommand( + options: CommandOptions, +): Promise { + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + let session = await readManifestIfExists(manifestFile); + + if (session === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (session.status !== 'exited') { + try { + const liveResult = (await sendRpc( + socketPath(sessionDirectory), + 'inspect', + )) as InspectResult; + session = liveResult.session; + } catch (error) { + if ( + error instanceof CliError && + error.code === ERROR_CODES.HOST_UNREACHABLE + ) { + await reconcileSession(sessionDirectory); + session = await readManifest(manifestFile); + } else { + throw error; + } + } + } + + emitSuccess({ + command: 'inspect', + json: options.json, + result: { session }, + lines: formatSessionLines(session), + }); +} diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts new file mode 100644 index 0000000..d2637bd --- /dev/null +++ b/src/cli/commands/list.ts @@ -0,0 +1,33 @@ +import { emitSuccess } from '../output.js'; +import { listSessions } from '../../host/lifecycle.js'; +import { resolveHome } from '../../storage/home.js'; + +export interface ListResult { + sessions: Array<{ + sessionId: string; + status: string; + command: string[]; + createdAt: string; + }>; +} + +interface CommandOptions { + json: boolean; + all: boolean; +} + +export async function runListCommand(options: CommandOptions): Promise { + const home = resolveHome(); + const sessions = await listSessions(home, options.all); + const lines = sessions.map( + (session) => + `${session.sessionId} ${session.status} ${session.command.join(' ')}`, + ); + + emitSuccess({ + command: 'list', + json: options.json, + result: { sessions }, + lines, + }); +} diff --git a/src/cli/commands/paste.ts b/src/cli/commands/paste.ts new file mode 100644 index 0000000..964d602 --- /dev/null +++ b/src/cli/commands/paste.ts @@ -0,0 +1,68 @@ +import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +export interface PasteResult { + [key: string]: never; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + text: string; +} + +export async function runPasteCommand(options: CommandOptions): Promise { + if (options.text.length === 0) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Text must not be empty.', + details: { + text: options.text, + }, + }); + } + + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'paste', { + text: options.text, + }); + + const result: PasteResult = {}; + emitSuccess({ + command: 'paste', + json: options.json, + result, + lines: ['Pasted text into session.'], + }); +} diff --git a/src/cli/commands/resize.ts b/src/cli/commands/resize.ts new file mode 100644 index 0000000..3022c07 --- /dev/null +++ b/src/cli/commands/resize.ts @@ -0,0 +1,82 @@ +import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +export interface ResizeResult { + cols: number; + rows: number; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + cols: number; + rows: number; +} + +export async function runResizeCommand(options: CommandOptions): Promise { + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + if ( + !Number.isInteger(options.cols) || + !Number.isInteger(options.rows) || + options.cols <= 0 || + options.rows <= 0 + ) { + throw makeCliError(ERROR_CODES.INVALID_DIMENSIONS, { + message: 'Resize dimensions must be positive integers.', + details: { + cols: options.cols, + rows: options.rows, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'resize', { + cols: options.cols, + rows: options.rows, + }); + + const result: ResizeResult = { + cols: options.cols, + rows: options.rows, + }; + emitSuccess({ + command: 'resize', + json: options.json, + result, + lines: [ + `Resized session to ${String(options.cols)}x${String(options.rows)}.`, + ], + }); +} diff --git a/src/cli/commands/send-keys.ts b/src/cli/commands/send-keys.ts new file mode 100644 index 0000000..04d603e --- /dev/null +++ b/src/cli/commands/send-keys.ts @@ -0,0 +1,61 @@ +import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +export interface SendKeysResult { + [key: string]: never; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + keys: string[]; +} + +export async function runSendKeysCommand( + options: CommandOptions, +): Promise { + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'sendKeys', { + keys: options.keys, + }); + + const result: SendKeysResult = {}; + emitSuccess({ + command: 'send-keys', + json: options.json, + result, + lines: ['Sent keys to session.'], + }); +} diff --git a/src/cli/commands/signal.ts b/src/cli/commands/signal.ts new file mode 100644 index 0000000..ca450ef --- /dev/null +++ b/src/cli/commands/signal.ts @@ -0,0 +1,85 @@ +import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +const ALLOWED_SIGNALS = [ + 'SIGTERM', + 'SIGINT', + 'SIGKILL', + 'SIGHUP', + 'SIGUSR1', + 'SIGUSR2', +] as const; + +export interface SignalResult { + signal: string; + delivered: boolean; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + signal: string; +} + +export async function runSignalCommand(options: CommandOptions): Promise { + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + if ( + !ALLOWED_SIGNALS.includes( + options.signal as (typeof ALLOWED_SIGNALS)[number], + ) + ) { + throw makeCliError(ERROR_CODES.INVALID_SIGNAL, { + message: `Signal must be one of: ${ALLOWED_SIGNALS.join(', ')}.`, + details: { + signal: options.signal, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'signal', { + signal: options.signal, + }); + + const result: SignalResult = { + signal: options.signal, + delivered: true, + }; + emitSuccess({ + command: 'signal', + json: options.json, + result, + lines: [`Signal ${options.signal} delivered to session.`], + }); +} diff --git a/src/cli/commands/type.ts b/src/cli/commands/type.ts new file mode 100644 index 0000000..9149c0f --- /dev/null +++ b/src/cli/commands/type.ts @@ -0,0 +1,68 @@ +import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +export interface TypeResult { + [key: string]: never; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + text: string; +} + +export async function runTypeCommand(options: CommandOptions): Promise { + if (options.text.length === 0) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Text must not be empty.', + details: { + text: options.text, + }, + }); + } + + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'type', { + text: options.text, + }); + + const result: TypeResult = {}; + emitSuccess({ + command: 'type', + json: options.json, + result, + lines: ['Typed text into session.'], + }); +} diff --git a/src/cli/commands/wait.ts b/src/cli/commands/wait.ts new file mode 100644 index 0000000..8619fd4 --- /dev/null +++ b/src/cli/commands/wait.ts @@ -0,0 +1,133 @@ +import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +export interface WaitResult { + exitCode?: number; + timedOut: boolean; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + waitForExit: boolean; + idleMs: number | undefined; + timeout: number | undefined; +} + +const DEFAULT_WAIT_TIMEOUT_MS = 600_000; + +function isPositiveInteger(value: number | undefined): value is number { + return value !== undefined && Number.isInteger(value) && value > 0; +} + +function waitLines(result: WaitResult): string[] { + if (result.timedOut) { + return ['Wait timed out.']; + } + + if (result.exitCode !== undefined) { + return [`Process exited with code ${String(result.exitCode)}.`]; + } + + return ['Wait condition met.']; +} + +export async function runWaitCommand(options: CommandOptions): Promise { + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + const hasIdleMs = options.idleMs !== undefined; + if (options.waitForExit === hasIdleMs) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: 'Specify exactly one of --exit or --idle-ms.', + }); + } + + if (hasIdleMs && !isPositiveInteger(options.idleMs)) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: '--idle-ms must be a positive integer.', + details: { + idleMs: options.idleMs, + }, + }); + } + + if ( + options.timeout !== undefined && + options.timeout !== 0 && + !isPositiveInteger(options.timeout) + ) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: '--timeout must be a non-negative integer (0 for infinite).', + details: { + timeout: options.timeout, + }, + }); + } + + if (!options.waitForExit && manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + if (options.waitForExit && manifest.status === 'exited') { + const result: WaitResult = { + timedOut: false, + ...(manifest.exitCode === null ? {} : { exitCode: manifest.exitCode }), + }; + + emitSuccess({ + command: 'wait', + json: options.json, + result, + lines: waitLines(result), + }); + return; + } + + const effectiveTimeout = options.timeout ?? DEFAULT_WAIT_TIMEOUT_MS; + const params = { + exit: options.waitForExit || undefined, + idleMs: options.idleMs ?? undefined, + timeoutMs: effectiveTimeout === 0 ? undefined : effectiveTimeout, + }; + const clientTimeout = effectiveTimeout === 0 ? 0 : effectiveTimeout + 5_000; + const result = (await sendRpc( + socketPath(sessionDirectory), + 'wait', + params, + clientTimeout, + )) as WaitResult; + + emitSuccess({ + command: 'wait', + json: options.json, + result, + lines: waitLines(result), + }); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index a0d3142..de603fb 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,32 +1,310 @@ #!/usr/bin/env node +import process from 'node:process'; + import { Command } from 'commander'; -import { CliError } from './errors.js'; +import { runCreateCommand } from './commands/create.js'; +import { runDestroyCommand } from './commands/destroy.js'; import { runDoctorCommand } from './commands/doctor.js'; +import { runInspectCommand } from './commands/inspect.js'; +import { runListCommand } from './commands/list.js'; +import { runPasteCommand } from './commands/paste.js'; +import { runResizeCommand } from './commands/resize.js'; +import { runSendKeysCommand } from './commands/send-keys.js'; +import { runSignalCommand } from './commands/signal.js'; +import { runTypeCommand } from './commands/type.js'; import { runVersionCommand } from './commands/version.js'; +import { runWaitCommand } from './commands/wait.js'; +import { CliError } from './errors.js'; import { emitFailure } from './output.js'; +function parseIntegerOption(value: string): number { + return Number.parseInt(value, 10); +} + +function wrapAction( + commandName: string, + fn: (...args: Args) => Promise, +): (...args: Args) => Promise { + return async (...args: Args) => { + try { + await fn(...args); + } catch (error: unknown) { + if (error instanceof CliError) { + const json = process.argv.includes('--json'); + emitFailure({ + command: commandName, + json, + error: { + code: error.code, + message: error.message, + retryable: error.retryable, + details: error.details, + }, + }); + process.exitCode = 1; + return; + } + + throw error; + } + }; +} + async function main(): Promise { const program = new Command() .name('agent-terminal') - .description('Terminal CLI') + .description('CLI for managing and controlling terminal sessions') .showHelpAfterError(); program .command('version') .description('Print version') .option('--json', 'Emit a JSON command envelope', false) - .action(async (options: { json: boolean }) => { - await runVersionCommand(options); - }); + .action( + wrapAction('version', async (options: { json: boolean }) => { + await runVersionCommand(options); + }), + ); program .command('doctor') .description('Check env') .option('--json', 'Emit a JSON command envelope', false) - .action(async (options: { json: boolean }) => { - await runDoctorCommand(options); + .action( + wrapAction('doctor', async (options: { json: boolean }) => { + await runDoctorCommand(options); + }), + ); + + // --- Session lifecycle --- + program + .command('create [command...]') + .description('Create a session') + .option( + '--command ', + 'Shell executable (defaults to $SHELL or sh)', + process.env.SHELL ?? process.env.ComSpec ?? 'sh', + ) + .option('--cwd ', 'Working directory', process.cwd()) + .option('--cols ', 'Initial columns', parseIntegerOption, 80) + .option('--rows ', 'Initial rows', parseIntegerOption, 24) + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'create', + async ( + command: string[], + options: { + command: string; + cwd: string; + cols: number; + rows: number; + json: boolean; + }, + ) => { + await runCreateCommand({ + json: options.json, + command, + shellCommand: options.command, + cwd: options.cwd, + cols: options.cols, + rows: options.rows, + }); + }, + ), + ); + + program + .command('list') + .description('List sessions') + .option('--all', 'Include exited sessions', false) + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction('list', async (options: { all: boolean; json: boolean }) => { + await runListCommand(options); + }), + ); + + program + .command('inspect ') + .description('Inspect a session') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'inspect', + async (sessionId: string, options: { json: boolean }) => { + await runInspectCommand({ + json: options.json, + sessionId, + }); + }, + ), + ); + + program + .command('destroy ') + .description('Destroy a session') + .option('--force', 'Skip graceful shutdown', false) + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'destroy', + async ( + sessionId: string, + options: { force: boolean; json: boolean }, + ) => { + await runDestroyCommand({ + json: options.json, + sessionId, + force: options.force, + }); + }, + ), + ); + + // --- Session control --- + program + .command('type ') + .description('Type text into a session') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'type', + async (sessionId: string, text: string, options: { json: boolean }) => { + await runTypeCommand({ + json: options.json, + sessionId, + text, + }); + }, + ), + ); + + program + .command('paste ') + .description('Paste text into a session') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'paste', + async (sessionId: string, text: string, options: { json: boolean }) => { + await runPasteCommand({ + json: options.json, + sessionId, + text, + }); + }, + ), + ); + + program + .command('send-keys ') + .description('Send keys to a session') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'send-keys', + async ( + sessionId: string, + keys: string[], + options: { json: boolean }, + ) => { + await runSendKeysCommand({ + json: options.json, + sessionId, + keys, + }); + }, + ), + ); + + program + .command('resize ') + .description('Resize a session') + .requiredOption('--cols ', 'Columns', parseIntegerOption) + .requiredOption('--rows ', 'Rows', parseIntegerOption) + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'resize', + async ( + sessionId: string, + options: { cols: number; rows: number; json: boolean }, + ) => { + await runResizeCommand({ + json: options.json, + sessionId, + cols: options.cols, + rows: options.rows, + }); + }, + ), + ); + + program + .command('signal ') + .description('Send a signal to a session') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'signal', + async ( + sessionId: string, + signal: string, + options: { json: boolean }, + ) => { + await runSignalCommand({ + json: options.json, + sessionId, + signal, + }); + }, + ), + ); + + // --- Observation --- + program + .command('wait ') + .description('Wait for a session condition') + .option('--exit', 'Wait for process exit', false) + .option('--idle-ms ', 'Wait for output idle period', parseIntegerOption) + .option( + '--timeout ', + 'Maximum wait time in milliseconds (0 for infinite)', + parseIntegerOption, + ) + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'wait', + async ( + sessionId: string, + options: { + exit: boolean; + idleMs?: number; + timeout?: number; + json: boolean; + }, + ) => { + await runWaitCommand({ + json: options.json, + sessionId, + waitForExit: options.exit, + idleMs: options.idleMs, + timeout: options.timeout, + }); + }, + ), + ); + + program + .command('_host ', { hidden: true }) + .description('Internal: run the session host process') + .action(async (sessionId: string) => { + const { runHost } = await import('../host/hostMain.js'); + await runHost(sessionId); }); await program.parseAsync(); @@ -36,9 +314,10 @@ try { await main(); } catch (error: unknown) { if (error instanceof CliError) { + const json = process.argv.includes('--json'); emitFailure({ command: 'agent-terminal', - json: false, + json, error: { code: error.code, message: error.message, diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..9fa09c0 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,9 @@ +import process from 'node:process'; + +export const DEFAULT_COLS = 80; +export const DEFAULT_ROWS = 24; +export const DEFAULT_SHELL = process.env.SHELL ?? '/bin/sh'; + +export const SOCKET_FILENAME = 'host.sock'; +export const MANIFEST_FILENAME = 'session.json'; +export const EVENT_LOG_FILENAME = 'events.jsonl'; diff --git a/src/config/resolveConfig.ts b/src/config/resolveConfig.ts new file mode 100644 index 0000000..b17c04b --- /dev/null +++ b/src/config/resolveConfig.ts @@ -0,0 +1,31 @@ +import { + DEFAULT_COLS, + DEFAULT_ROWS, + DEFAULT_SHELL, + EVENT_LOG_FILENAME, + MANIFEST_FILENAME, + SOCKET_FILENAME, +} from './defaults.js'; +import { resolveHome } from '../storage/home.js'; + +export interface AgentTerminalConfig { + readonly home: string; + readonly cols: number; + readonly rows: number; + readonly shell: string; + readonly socketFilename: string; + readonly manifestFilename: string; + readonly eventLogFilename: string; +} + +export function resolveConfig(): Readonly { + return Object.freeze({ + home: resolveHome(), + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + shell: DEFAULT_SHELL, + socketFilename: SOCKET_FILENAME, + manifestFilename: MANIFEST_FILENAME, + eventLogFilename: EVENT_LOG_FILENAME, + }); +} diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts new file mode 100644 index 0000000..f8d71a9 --- /dev/null +++ b/src/host/eventLog.ts @@ -0,0 +1,245 @@ +import { open, readFile } from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; + +import { z } from 'zod'; + +import { EventRecordSchema, type EventRecord } from '../protocol/schemas.js'; +import { invariant } from '../util/assert.js'; + +const OutputEventPayloadSchema = z + .object({ + data: z.string(), + }) + .strict(); + +type OutputEventPayload = z.infer; + +const InputTextEventPayloadSchema = z + .object({ + data: z.string(), + }) + .strict(); + +type InputTextEventPayload = z.infer; + +const InputPasteEventPayloadSchema = z + .object({ + data: z.string(), + }) + .strict(); + +type InputPasteEventPayload = z.infer; + +const InputKeysEventPayloadSchema = z + .object({ + keys: z.array(z.string().min(1)).min(1), + }) + .strict(); + +type InputKeysEventPayload = z.infer; + +const ResizeEventPayloadSchema = z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive(), + }) + .strict(); + +type ResizeEventPayload = z.infer; + +const SignalEventPayloadSchema = z + .object({ + signal: z.string().min(1), + }) + .strict(); + +type SignalEventPayload = z.infer; + +const ExitEventPayloadSchema = z + .object({ + exitCode: z.number().int().nullable(), + exitSignal: z.string().nullable(), + }) + .strict(); + +type ExitEventPayload = z.infer; + +type EventLogEventType = + | 'output' + | 'input_text' + | 'input_paste' + | 'input_keys' + | 'resize' + | 'signal' + | 'exit'; +type EventLogPayload = + | OutputEventPayload + | InputTextEventPayload + | InputPasteEventPayload + | InputKeysEventPayload + | ResizeEventPayload + | SignalEventPayload + | ExitEventPayload; + +function assertFilePath(filePath: string): void { + invariant(filePath.length > 0, 'filePath must be a non-empty string'); +} + +function validatePayload( + type: EventLogEventType, + payload: EventLogPayload, +): EventLogPayload { + switch (type) { + case 'output': { + const result = OutputEventPayloadSchema.safeParse(payload); + invariant(result.success, 'output payload must match schema'); + return result.data; + } + case 'input_text': { + const result = InputTextEventPayloadSchema.safeParse(payload); + invariant(result.success, 'input_text payload must match schema'); + return result.data; + } + case 'input_paste': { + const result = InputPasteEventPayloadSchema.safeParse(payload); + invariant(result.success, 'input_paste payload must match schema'); + return result.data; + } + case 'input_keys': { + const result = InputKeysEventPayloadSchema.safeParse(payload); + invariant(result.success, 'input_keys payload must match schema'); + return result.data; + } + case 'resize': { + const result = ResizeEventPayloadSchema.safeParse(payload); + invariant(result.success, 'resize payload must match schema'); + return result.data; + } + case 'signal': { + const result = SignalEventPayloadSchema.safeParse(payload); + invariant(result.success, 'signal payload must match schema'); + return result.data; + } + case 'exit': { + const result = ExitEventPayloadSchema.safeParse(payload); + invariant(result.success, 'exit payload must match schema'); + return result.data; + } + } +} + +function deriveNextSeq(content: string): number { + const lines = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (lines.length === 0) { + return 0; + } + + const lastLine = lines.at(-1); + invariant(lastLine !== undefined, 'event log must contain a last line'); + + let parsedLine: unknown; + try { + parsedLine = JSON.parse(lastLine); + } catch { + invariant(false, 'last event log line must be valid JSON'); + } + + const parsedRecord = EventRecordSchema.safeParse(parsedLine); + invariant( + parsedRecord.success, + 'last event log line must match EventRecordSchema', + ); + + const { seq } = parsedRecord.data; + invariant(Number.isInteger(seq), 'event log seq must be an integer'); + invariant(seq >= 0, 'event log seq must be non-negative'); + + return seq + 1; +} + +export class EventLog { + private writeQueue: Promise = Promise.resolve(); + + private constructor( + private readonly fileHandle: FileHandle, + private nextSeq: number, + private isClosed = false, + ) { + invariant(Number.isInteger(nextSeq), 'nextSeq must be an integer'); + invariant(nextSeq >= 0, 'nextSeq must be non-negative'); + } + + static async open(filePath: string): Promise { + assertFilePath(filePath); + + const fileHandle = await open(filePath, 'a'); + const fileStats = await fileHandle.stat(); + + let nextSeq = 0; + if (fileStats.size > 0) { + const existingContent = await readFile(filePath, 'utf8'); + nextSeq = deriveNextSeq(existingContent); + } + + return new EventLog(fileHandle, nextSeq); + } + + async append(type: 'output', payload: OutputEventPayload): Promise; + async append( + type: 'input_text', + payload: InputTextEventPayload, + ): Promise; + async append( + type: 'input_paste', + payload: InputPasteEventPayload, + ): Promise; + async append( + type: 'input_keys', + payload: InputKeysEventPayload, + ): Promise; + async append(type: 'resize', payload: ResizeEventPayload): Promise; + async append(type: 'signal', payload: SignalEventPayload): Promise; + async append(type: 'exit', payload: ExitEventPayload): Promise; + async append( + type: EventLogEventType, + payload: EventLogPayload, + ): Promise { + invariant(!this.isClosed, 'cannot append to a closed event log'); + + const validatedPayload = validatePayload(type, payload); + const seq = this.nextSeq; + this.nextSeq += 1; + + const record: EventRecord = { + seq, + ts: new Date().toISOString(), + type, + payload: validatedPayload, + }; + + const parsedRecord = EventRecordSchema.safeParse(record); + invariant( + parsedRecord.success, + 'event record must match EventRecordSchema', + ); + + const line = `${JSON.stringify(parsedRecord.data)}\n`; + this.writeQueue = this.writeQueue.then(() => + this.fileHandle.appendFile(line, 'utf8'), + ); + await this.writeQueue; + } + + async close(): Promise { + invariant(!this.isClosed, 'event log is already closed'); + // Drain any in-flight append writes before closing the file. + await this.writeQueue; + await this.fileHandle.sync(); + await this.fileHandle.close(); + this.isClosed = true; + } +} diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts new file mode 100644 index 0000000..12c6384 --- /dev/null +++ b/src/host/hostMain.ts @@ -0,0 +1,454 @@ +import process from 'node:process'; + +import { EventLog } from './eventLog.js'; +import { RpcServer, type MethodHandler } from './rpcServer.js'; +import { SessionState } from './sessionState.js'; +import { createPty } from '../pty/createPty.js'; +import { encodeKey } from '../pty/keyEncoder.js'; +import { encodePaste } from '../pty/pasteEncoder.js'; +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import type { + PasteParams, + ResizeParams, + SendKeysParams, + SignalParams, + TypeParams, + WaitParams, +} from '../protocol/messages.js'; +import { readManifest, writeManifest } from '../storage/manifests.js'; +import { resolveHome } from '../storage/home.js'; +import { + eventLogPath, + manifestPath, + sessionDir, + socketPath, +} from '../storage/sessionPaths.js'; +import { invariant } from '../util/assert.js'; + +const ALLOWED_SIGNALS = [ + 'SIGTERM', + 'SIGINT', + 'SIGKILL', + 'SIGHUP', + 'SIGUSR1', + 'SIGUSR2', +] as const; + +type WaitOutcome = { + exitCode?: number; + timedOut: boolean; +}; + +function normalizeExitSignal(signal: number | null): string | null { + invariant( + signal === null || (Number.isInteger(signal) && signal >= 0), + 'PTY exit signal must be a non-negative integer or null', + ); + + return signal === null || signal === 0 ? null : String(signal); +} + +function isSessionRunning(state: SessionState): boolean { + return state.snapshot().status === 'running'; +} + +function rethrowAsync(error: unknown): void { + process.nextTick(() => { + throw error; + }); +} + +export async function runHost(sessionId: string): Promise { + invariant( + typeof sessionId === 'string' && sessionId.length > 0, + 'sessionId must be a non-empty string', + ); + + const home = resolveHome(); + const sessDir = sessionDir(home, sessionId); + const mPath = manifestPath(sessDir); + const ePath = eventLogPath(sessDir); + const sPath = socketPath(sessDir); + + const manifest = await readManifest(mPath); + invariant( + manifest.sessionId === sessionId, + 'session manifest sessionId must match the requested session', + ); + + const state = new SessionState(manifest); + invariant( + Number.isInteger(process.pid) && process.pid > 0, + 'process.pid must be a positive integer', + ); + state.setHostPid(process.pid); + + const eventLog = await EventLog.open(ePath); + + let eventLogClosed = false; + let ptyExitHandled = false; + let ptyHasExited = false; + let lastOutputAt = Date.now(); + let rpcListenPromise: Promise | null = null; + let shutdownPromise: Promise | null = null; + let markPtyExited: () => void = () => { + invariant(false, 'PTY exit resolver must be initialized'); + }; + + const ptyExitPromise = new Promise((resolve) => { + markPtyExited = (): void => { + if (ptyHasExited) { + return; + } + + ptyHasExited = true; + resolve(); + }; + }); + + const pty = createPty({ + command: manifest.command, + cwd: manifest.cwd, + cols: manifest.cols, + rows: manifest.rows, + }); + + invariant( + Number.isInteger(pty.pid) && pty.pid > 0, + 'PTY child PID must be a positive integer', + ); + state.setChildPid(pty.pid); + + const initiateShutdown = (): Promise => { + if (shutdownPromise !== null) { + return shutdownPromise; + } + + shutdownPromise = (async () => { + try { + if (isSessionRunning(state)) { + pty.kill(); + state.requestDestroy(); + await writeManifest(mPath, state.snapshot()); + } + } finally { + try { + await ptyExitPromise; + } finally { + if (rpcListenPromise !== null) { + await rpcListenPromise.catch(() => undefined); + } + + if (!eventLogClosed) { + await eventLog.close(); + eventLogClosed = true; + } + + await rpcServer.close(); + } + } + })(); + + return shutdownPromise; + }; + + const startShutdown = (): void => { + void initiateShutdown().catch(rethrowAsync); + }; + + const handlePtyExit = (exitCode: number, signal: number | null): void => { + invariant(!ptyExitHandled, 'PTY exit must only be handled once'); + invariant(Number.isInteger(exitCode), 'PTY exit code must be an integer'); + + ptyExitHandled = true; + + const exitSignal = normalizeExitSignal(signal); + state.recordExit(exitCode, exitSignal); + markPtyExited(); + + void (async () => { + try { + await eventLog.append('exit', { exitCode, exitSignal }); + } finally { + try { + await writeManifest(mPath, state.snapshot()); + } finally { + await initiateShutdown(); + } + } + })().catch(rethrowAsync); + }; + + const handlers: Record = { + inspect: () => Promise.resolve({ session: state.snapshot() }), + type: async (params: unknown) => { + const { text } = params as TypeParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); + } + + invariant(typeof text === 'string', 'type text must be a string'); + pty.write(text); + await eventLog.append('input_text', { data: text }); + return {}; + }, + paste: async (params: unknown) => { + const { text } = params as PasteParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); + } + + invariant( + typeof text === 'string' && text.length > 0, + 'paste text must be a non-empty string', + ); + const encoded = encodePaste(text); + pty.write(encoded); + await eventLog.append('input_paste', { data: encoded }); + return {}; + }, + sendKeys: async (params: unknown) => { + const { keys } = params as SendKeysParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); + } + + invariant( + Array.isArray(keys) && keys.length > 0, + 'keys must be a non-empty array', + ); + + let encoded: string; + try { + encoded = keys.map((key) => encodeKey(key)).join(''); + } catch (error) { + throw makeCliError(ERROR_CODES.INVALID_KEYS, { + message: + error instanceof Error ? error.message : 'Invalid key sequence.', + cause: error, + }); + } + + pty.write(encoded); + await eventLog.append('input_keys', { keys }); + return {}; + }, + resize: async (params: unknown) => { + const { cols, rows } = params as ResizeParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); + } + + invariant( + Number.isInteger(cols) && cols > 0, + 'cols must be a positive integer', + ); + invariant( + Number.isInteger(rows) && rows > 0, + 'rows must be a positive integer', + ); + + pty.resize(cols, rows); + state.setDimensions(cols, rows); + await writeManifest(mPath, state.snapshot()); + await eventLog.append('resize', { cols, rows }); + return { cols, rows }; + }, + signal: async (params: unknown) => { + const { signal } = params as SignalParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); + } + + invariant( + typeof signal === 'string' && signal.length > 0, + 'signal must be a non-empty string', + ); + + if ( + !ALLOWED_SIGNALS.includes(signal as (typeof ALLOWED_SIGNALS)[number]) + ) { + throw makeCliError(ERROR_CODES.INVALID_SIGNAL, { + message: `Invalid signal: ${signal}. Allowed: ${ALLOWED_SIGNALS.join(', ')}`, + details: { signal, allowed: [...ALLOWED_SIGNALS] }, + }); + } + + const childPid = state.snapshot().childPid; + invariant( + childPid !== null && childPid > 0, + 'child PID must be set for signal delivery', + ); + + try { + process.kill(childPid, 0); + } catch { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Child process is no longer running.', + details: { childPid }, + }); + } + + process.kill(childPid, signal as (typeof ALLOWED_SIGNALS)[number]); + + await eventLog.append('signal', { signal }); + return {}; + }, + wait: async (params: unknown) => { + const { exit, idleMs, timeoutMs } = params as WaitParams; + const hasExit = exit === true; + const hasIdle = idleMs !== undefined; + + if (hasExit === hasIdle) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: 'Specify exactly one of exit or idleMs.', + }); + } + + if (hasIdle) { + invariant( + Number.isInteger(idleMs) && idleMs > 0, + 'idleMs must be a positive integer', + ); + } + if (timeoutMs !== undefined) { + invariant( + Number.isInteger(timeoutMs) && timeoutMs > 0, + 'timeoutMs must be a positive integer', + ); + } + + let waitCondition: Promise; + let clearWaitCondition: (() => void) | null = null; + + if (hasExit) { + if (ptyHasExited) { + const snapshot = state.snapshot(); + const result: WaitOutcome = { timedOut: false }; + if (snapshot.exitCode !== null) { + result.exitCode = snapshot.exitCode; + } + return result; + } + + waitCondition = ptyExitPromise.then(() => { + const snapshot = state.snapshot(); + const result: WaitOutcome = { timedOut: false }; + if (snapshot.exitCode !== null) { + result.exitCode = snapshot.exitCode; + } + return result; + }); + } else { + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); + } + + const idleDuration = idleMs ?? 0; + invariant( + Number.isInteger(idleDuration) && idleDuration > 0, + 'idleMs must be a positive integer', + ); + + const idleAnchor = Date.now(); + waitCondition = new Promise((resolve) => { + const checkInterval = setInterval( + () => { + const effectiveLastOutput = Math.max(lastOutputAt, idleAnchor); + const elapsed = Date.now() - effectiveLastOutput; + if (elapsed < idleDuration) { + return; + } + + clearInterval(checkInterval); + const snapshot = state.snapshot(); + const result: WaitOutcome = { timedOut: false }; + if (snapshot.exitCode !== null) { + result.exitCode = snapshot.exitCode; + } + resolve(result); + }, + Math.min(idleDuration / 2, 100), + ); + + clearWaitCondition = (): void => { + clearInterval(checkInterval); + }; + }); + } + + if (timeoutMs === undefined) { + return await waitCondition; + } + + return await new Promise((resolve) => { + const timeoutHandle = setTimeout(() => { + clearWaitCondition?.(); + resolve({ timedOut: true }); + }, timeoutMs); + + void waitCondition.then((result) => { + clearTimeout(timeoutHandle); + clearWaitCondition?.(); + resolve(result); + }); + }); + }, + destroy: () => { + startShutdown(); + return Promise.resolve({}); + }, + }; + const rpcServer = new RpcServer(sPath, handlers); + + pty.onData((data: string) => { + lastOutputAt = Date.now(); + void eventLog.append('output', { data }).catch(() => { + // Best-effort logging; shutdown should not fail on transient append errors. + }); + }); + + pty.onExit(({ exitCode, signal }) => { + handlePtyExit(exitCode, signal ?? null); + }); + + process.on('SIGTERM', () => { + startShutdown(); + }); + + try { + await writeManifest(mPath, state.snapshot()); + + if (!isSessionRunning(state)) { + await initiateShutdown(); + return; + } + + rpcListenPromise = rpcServer.listen(); + await rpcListenPromise; + + if (!isSessionRunning(state)) { + await initiateShutdown(); + } + } catch (error) { + await initiateShutdown().catch(() => undefined); + throw error; + } +} diff --git a/src/host/lifecycle.ts b/src/host/lifecycle.ts new file mode 100644 index 0000000..d30858c --- /dev/null +++ b/src/host/lifecycle.ts @@ -0,0 +1,552 @@ +import { spawn } from 'node:child_process'; +import { mkdir, readdir, stat, unlink } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import process from 'node:process'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { ulid } from 'ulid'; + +import { CliError } from '../cli/errors.js'; +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import type { SessionRecord } from '../protocol/schemas.js'; +import { ensureHome, resolveHome } from '../storage/home.js'; +import { + readManifest, + readManifestIfExists, + writeManifest, +} from '../storage/manifests.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../storage/sessionPaths.js'; +import { invariant } from '../util/assert.js'; +import { sendRpc } from './rpcClient.js'; + +const DESTROY_POLL_INTERVAL_MS = 100; +const DESTROY_MAX_ATTEMPTS = 50; + +interface NodeError extends Error { + code?: string; +} + +export interface AllocateConfig { + command: string[]; + shellCommand: string; + cwd: string; + cols: number; + rows: number; +} + +export interface AllocateResult { + sessionId: string; + sessionDirectory: string; +} + +export interface SessionSummary { + sessionId: string; + status: string; + command: string[]; + createdAt: string; +} + +function isNodeError(error: unknown): error is NodeError { + return error instanceof Error; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return isNodeError(error) && error.code === code; +} + +function makeInvalidDimensionError( + label: 'cols' | 'rows', + value: unknown, +): CliError { + return makeCliError(ERROR_CODES.INVALID_DIMENSIONS, { + message: `${label} must be a positive integer, got: ${String(value)}`, + details: { + [label]: value, + }, + }); +} + +function makeInvalidCwdError(cwd: unknown, cause?: unknown): CliError { + return makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { + message: + typeof cwd === 'string' && cwd.length > 0 + ? `Working directory does not exist or is not accessible: ${cwd}` + : 'Working directory must be a non-empty string.', + details: { cwd }, + cause, + }); +} + +function assertPositiveInteger(value: number, label: string): void { + invariant( + Number.isInteger(value) && value > 0, + `${label} must be a positive integer`, + ); +} + +function assertNonEmptyString( + value: unknown, + label: string, +): asserts value is string { + invariant(typeof value === 'string', `${label} must be a string`); + invariant(value.length > 0, `${label} must not be empty`); +} + +function isSessionTerminal(record: SessionRecord): boolean { + return record.status === 'exited'; +} + +function isSessionActive(record: SessionRecord): boolean { + return record.status === 'running' || record.status === 'exiting'; +} + +function isProcessAlive(pid: number | null): boolean { + if (pid === null) { + return false; + } + + assertPositiveInteger(pid, 'pid'); + + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (hasErrorCode(error, 'ESRCH')) { + return false; + } + + throw error; + } +} + +function killProcessBestEffort(pid: number | null): void { + if (pid === null) { + return; + } + + assertPositiveInteger(pid, 'pid'); + + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (hasErrorCode(error, 'ESRCH')) { + return; + } + + throw error; + } +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return false; + } + + throw error; + } +} + +async function unlinkIfPresent(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return; + } + + throw error; + } +} + +function getSessionPaths(sessionId: string): { + sessionDirectory: string; + manifestFile: string; + socketFile: string; +} { + assertNonEmptyString(sessionId, 'sessionId'); + + const home = resolveHome(); + const sessionDirectory = sessionDir(home, sessionId); + + return { + sessionDirectory, + manifestFile: manifestPath(sessionDirectory), + socketFile: socketPath(sessionDirectory), + }; +} + +async function readSessionManifestOrThrow( + sessionId: string, + manifestFile: string, +): Promise { + const manifest = await readManifestIfExists(manifestFile); + + if (manifest !== null) { + return manifest; + } + + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${sessionId}" was not found.`, + details: { + sessionId, + manifestPath: manifestFile, + }, + }); +} + +async function waitForTerminalManifest( + manifestFile: string, + maxAttempts: number = DESTROY_MAX_ATTEMPTS, + intervalMs: number = DESTROY_POLL_INTERVAL_MS, +): Promise { + invariant( + Number.isInteger(maxAttempts) && maxAttempts > 0, + 'maxAttempts must be a positive integer', + ); + invariant( + Number.isInteger(intervalMs) && intervalMs >= 0, + 'intervalMs must be a non-negative integer', + ); + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const manifest = await readManifest(manifestFile); + + if (isSessionTerminal(manifest)) { + return manifest; + } + + if (attempt + 1 < maxAttempts) { + await delay(intervalMs); + } + } + + return null; +} + +async function waitForProcessAndSocketShutdown( + hostPid: number | null, + childPid: number | null, + socketFile: string, + maxAttempts: number = DESTROY_MAX_ATTEMPTS, + intervalMs: number = DESTROY_POLL_INTERVAL_MS, +): Promise { + invariant( + Number.isInteger(maxAttempts) && maxAttempts > 0, + 'maxAttempts must be a positive integer', + ); + invariant( + Number.isInteger(intervalMs) && intervalMs >= 0, + 'intervalMs must be a non-negative integer', + ); + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const hostAlive = isProcessAlive(hostPid); + const childAlive = isProcessAlive(childPid); + const socketPresent = await pathExists(socketFile); + + if (!hostAlive && !childAlive && !socketPresent) { + return true; + } + + if (attempt + 1 < maxAttempts) { + await delay(intervalMs); + } + } + + return false; +} + +export async function allocateSession( + config: AllocateConfig, +): Promise { + const rawConfig: unknown = config; + invariant( + rawConfig !== null && typeof rawConfig === 'object', + 'config must be an object', + ); + invariant(Array.isArray(config.command), 'command must be an array'); + if ( + typeof config.cols !== 'number' || + !Number.isInteger(config.cols) || + config.cols <= 0 + ) { + throw makeInvalidDimensionError('cols', config.cols); + } + if ( + typeof config.rows !== 'number' || + !Number.isInteger(config.rows) || + config.rows <= 0 + ) { + throw makeInvalidDimensionError('rows', config.rows); + } + if (typeof config.cwd !== 'string' || config.cwd.length === 0) { + throw makeInvalidCwdError(config.cwd); + } + + const sessionId = ulid(); + assertNonEmptyString(sessionId, 'sessionId'); + + const home = await ensureHome(); + const sessionDirectory = sessionDir(home, sessionId); + await mkdir(sessionDirectory, { recursive: true }); + + const resolvedCwd = resolve(config.cwd); + try { + const cwdStats = await stat(resolvedCwd); + invariant( + cwdStats.isDirectory(), + 'cwd must resolve to an existing directory', + ); + } catch (error) { + if (error instanceof CliError) { + throw error; + } + + throw makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { + message: `Working directory does not exist or is not accessible: ${resolvedCwd}`, + details: { cwd: resolvedCwd }, + cause: error, + }); + } + + const effectiveCommand = + config.command.length > 0 ? [...config.command] : [config.shellCommand]; + invariant(effectiveCommand.length > 0, 'effective command must not be empty'); + for (const commandPart of effectiveCommand) { + assertNonEmptyString(commandPart, 'command segment'); + } + + const now = new Date().toISOString(); + await writeManifest(manifestPath(sessionDirectory), { + version: 1, + sessionId, + createdAt: now, + updatedAt: now, + status: 'running', + command: effectiveCommand, + cwd: resolvedCwd, + cols: config.cols, + rows: config.rows, + hostPid: null, + childPid: null, + exitCode: null, + exitSignal: null, + }); + + return { sessionId, sessionDirectory }; +} + +export function launchHost(sessionId: string): number { + assertNonEmptyString(sessionId, 'sessionId'); + invariant(process.execPath.length > 0, 'process.execPath must not be empty'); + + const entrypoint = process.argv[1]; + invariant( + typeof entrypoint === 'string' && entrypoint.length > 0, + 'CLI entrypoint path must be defined', + ); + + const child = spawn( + process.execPath, + [...process.execArgv, entrypoint, '_host', sessionId], + { + detached: true, + stdio: 'ignore', + }, + ); + child.unref(); + + invariant( + child.pid !== undefined && child.pid > 0, + 'Detached host process must expose a positive PID', + ); + + return child.pid; +} + +export async function destroySession( + sessionId: string, + force?: boolean, +): Promise { + const { sessionDirectory, manifestFile, socketFile } = + getSessionPaths(sessionId); + const manifest = await readSessionManifestOrThrow(sessionId, manifestFile); + + if (isSessionTerminal(manifest)) { + return; + } + + if (force === true) { + killProcessBestEffort(manifest.childPid); + killProcessBestEffort(manifest.hostPid); + + await waitForProcessAndSocketShutdown( + manifest.hostPid, + manifest.childPid, + socketFile, + ); + await reconcileSession(sessionDirectory); + + const reconciledManifest = await readSessionManifestOrThrow( + sessionId, + manifestFile, + ); + if (isSessionTerminal(reconciledManifest)) { + return; + } + + throw makeCliError(ERROR_CODES.HOST_TIMEOUT, { + message: `Timed out forcing session "${sessionId}" to exit.`, + details: { + sessionId, + sessionDirectory, + }, + }); + } + + try { + await sendRpc(socketFile, 'destroy'); + } catch (error) { + if ( + !(error instanceof CliError) || + error.code !== ERROR_CODES.HOST_UNREACHABLE + ) { + throw error; + } + + await reconcileSession(sessionDirectory); + const reconciledManifest = await readSessionManifestOrThrow( + sessionId, + manifestFile, + ); + if (isSessionTerminal(reconciledManifest)) { + return; + } + + throw error; + } + + const terminalManifest = await waitForTerminalManifest(manifestFile); + if (terminalManifest !== null) { + return; + } + + await reconcileSession(sessionDirectory); + const reconciledManifest = await readSessionManifestOrThrow( + sessionId, + manifestFile, + ); + if (isSessionTerminal(reconciledManifest)) { + return; + } + + throw makeCliError(ERROR_CODES.HOST_TIMEOUT, { + message: `Timed out waiting for session "${sessionId}" to exit after destroy request.`, + details: { + sessionId, + sessionDirectory, + }, + }); +} + +export async function listSessions( + home: string, + all?: boolean, +): Promise { + assertNonEmptyString(home, 'home'); + + const sessionsRoot = resolve(home, 'sessions'); + let sessionEntries: string[]; + try { + sessionEntries = await readdir(sessionsRoot); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return []; + } + + throw error; + } + + const summaries: SessionSummary[] = []; + + for (const entry of sessionEntries) { + const sessionDirectory = sessionDir(home, entry); + const manifestFile = manifestPath(sessionDirectory); + + let manifest: SessionRecord | null; + try { + manifest = await readManifestIfExists(manifestFile); + } catch { + continue; + } + + if (manifest === null) { + continue; + } + + if (isSessionActive(manifest)) { + try { + await reconcileSession(sessionDirectory); + manifest = await readManifestIfExists(manifestFile); + } catch { + continue; + } + + if (manifest === null) { + continue; + } + } + + if (all !== true && manifest.status === 'exited') { + continue; + } + + summaries.push({ + sessionId: manifest.sessionId, + status: manifest.status, + command: [...manifest.command], + createdAt: manifest.createdAt, + }); + } + + return summaries; +} + +export async function reconcileSession( + sessionDirectory: string, +): Promise { + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null || isSessionTerminal(manifest)) { + return; + } + + const hostAlive = isProcessAlive(manifest.hostPid); + if (manifest.hostPid !== null && hostAlive) { + return; + } + + if (manifest.childPid !== null && isProcessAlive(manifest.childPid)) { + killProcessBestEffort(manifest.childPid); + } + + const reconciledManifest: SessionRecord = { + ...manifest, + status: 'exited', + updatedAt: new Date().toISOString(), + hostPid: null, + childPid: null, + }; + + await writeManifest(manifestFile, reconciledManifest); + await unlinkIfPresent(socketPath(sessionDirectory)); +} diff --git a/src/host/rpcClient.ts b/src/host/rpcClient.ts new file mode 100644 index 0000000..e1bab2f --- /dev/null +++ b/src/host/rpcClient.ts @@ -0,0 +1,297 @@ +import { randomUUID } from 'node:crypto'; +import net from 'node:net'; + +import { CliError } from '../cli/errors.js'; +import { + ERROR_CODES, + makeCliError, + type ProtocolErrorCode, +} from '../protocol/errors.js'; +import { + RpcMethodSchemas, + RpcRequestSchema, + RpcResponseSchema, + type RpcMethod, +} from '../protocol/messages.js'; +import { invariant } from '../util/assert.js'; + +const DEFAULT_TIMEOUT_MS = 5_000; +const MAX_RPC_BUFFER_BYTES = 1_048_576; +const HOST_UNREACHABLE_SOCKET_CODES = new Set([ + 'ECONNREFUSED', + 'ENOENT', + 'ECONNRESET', +]); + +function isKnownRpcMethod(method: string): method is RpcMethod { + return Object.hasOwn(RpcMethodSchemas, method); +} + +function isProtocolErrorCode(code: string): code is ProtocolErrorCode { + return Object.values(ERROR_CODES).includes(code as ProtocolErrorCode); +} + +function toErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return fallback; +} + +function toTransportCliError( + error: unknown, + socketPath: string, + method: string, + timeoutMs: number, +): CliError { + if (error instanceof CliError) { + return error; + } + + if (error instanceof Error && 'code' in error) { + const errorCode = typeof error.code === 'string' ? error.code : undefined; + + if ( + errorCode !== undefined && + HOST_UNREACHABLE_SOCKET_CODES.has(errorCode) + ) { + return makeCliError(ERROR_CODES.HOST_UNREACHABLE, { + message: `Failed to reach RPC host at ${socketPath}.`, + details: { + method, + socketPath, + errno: errorCode, + }, + cause: error, + }); + } + } + + return makeCliError(ERROR_CODES.RPC_ERROR, { + message: toErrorMessage( + error, + `RPC request failed for method "${method}".`, + ), + details: { + method, + socketPath, + timeoutMs, + }, + cause: error, + }); +} + +function toResponseCliError(code: string, message: string): CliError { + if (isProtocolErrorCode(code)) { + return makeCliError(code, { message }); + } + + return new CliError(code, message); +} + +export async function sendRpc( + socketPath: string, + method: string, + params?: Record, + timeoutMs?: number, +): Promise { + const effectiveTimeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS; + invariant( + Number.isFinite(effectiveTimeoutMs) && effectiveTimeoutMs >= 0, + 'RPC timeout must be a non-negative finite number.', + ); + + const requestResult = RpcRequestSchema.safeParse({ + id: randomUUID(), + method, + params: params ?? {}, + }); + invariant( + requestResult.success, + 'Outbound RPC request must satisfy RpcRequestSchema.', + ); + + const request = requestResult.data; + + return await new Promise((resolve, reject) => { + const socket = net.connect({ path: socketPath }); + let settled = false; + let responseHandled = false; + let buffer = ''; + + const rejectWithCliError = (error: CliError): void => { + if (settled) { + return; + } + + settled = true; + socket.destroy(); + reject(error); + }; + + const rejectWithTransportError = (error: unknown): void => { + rejectWithCliError( + toTransportCliError(error, socketPath, method, effectiveTimeoutMs), + ); + }; + + const resolveWithResult = (result: unknown): void => { + if (settled) { + return; + } + + settled = true; + socket.destroy(); + resolve(result); + }; + + socket.setEncoding('utf8'); + socket.setTimeout(effectiveTimeoutMs); + + socket.on('connect', () => { + socket.write(`${JSON.stringify(request)}\n`); + }); + + socket.on('timeout', () => { + rejectWithCliError( + makeCliError(ERROR_CODES.HOST_TIMEOUT, { + message: `RPC request timed out after ${String(effectiveTimeoutMs)}ms.`, + details: { + method, + socketPath, + timeoutMs: effectiveTimeoutMs, + }, + }), + ); + }); + + socket.on('error', (error) => { + rejectWithTransportError(error); + }); + + socket.on('data', (chunk: string) => { + if (responseHandled) { + return; + } + + if (buffer.length + chunk.length > MAX_RPC_BUFFER_BYTES) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: 'RPC response exceeds maximum buffer size.', + details: { method, socketPath }, + }), + ); + return; + } + + buffer += chunk; + const newlineIndex = buffer.indexOf('\n'); + + if (newlineIndex < 0) { + return; + } + + responseHandled = true; + const line = buffer.slice(0, newlineIndex); + + try { + const rawResponse = JSON.parse(line) as unknown; + const responseResult = RpcResponseSchema.safeParse(rawResponse); + + if (!responseResult.success) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: 'RPC response failed schema validation.', + details: { + method, + socketPath, + }, + cause: responseResult.error, + }), + ); + return; + } + + const response = responseResult.data; + + if (response.id !== request.id) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: `RPC response id mismatch for method "${method}".`, + details: { + method, + socketPath, + expectedId: request.id, + actualId: response.id, + }, + }), + ); + return; + } + + if (response.ok) { + if (isKnownRpcMethod(method)) { + const resultResult = RpcMethodSchemas[method].result.safeParse( + response.result, + ); + + if (!resultResult.success) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: `RPC result failed validation for method "${method}".`, + details: { + method, + socketPath, + }, + cause: resultResult.error, + }), + ); + return; + } + + resolveWithResult(resultResult.data); + return; + } + + resolveWithResult(response.result); + return; + } + + rejectWithCliError( + toResponseCliError(response.error.code, response.error.message), + ); + } catch (error) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: toErrorMessage( + error, + `Failed to decode RPC response for method "${method}".`, + ), + details: { + method, + socketPath, + }, + cause: error, + }), + ); + } + }); + + socket.on('end', () => { + if (settled || responseHandled) { + return; + } + + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: `RPC connection closed before a complete response was received for method "${method}".`, + details: { + method, + socketPath, + }, + }), + ); + }); + }); +} diff --git a/src/host/rpcServer.ts b/src/host/rpcServer.ts new file mode 100644 index 0000000..873a523 --- /dev/null +++ b/src/host/rpcServer.ts @@ -0,0 +1,407 @@ +import { stat, unlink } from 'node:fs/promises'; +import net from 'node:net'; + +import { CliError } from '../cli/errors.js'; +import { ERROR_CODES } from '../protocol/errors.js'; +import { + RpcMethodSchemas, + RpcRequestSchema, + RpcResponseSchema, + type RpcMethod, + type RpcResponse, +} from '../protocol/messages.js'; +import { invariant } from '../util/assert.js'; + +const MAX_UNIX_SOCKET_PATH = 104; +const MAX_RPC_BUFFER_BYTES = 1_048_576; + +const UNKNOWN_REQUEST_ID = 'unknown'; + +export type MethodHandler = (params: unknown) => Promise; + +function isKnownRpcMethod(method: string): method is RpcMethod { + return Object.hasOwn(RpcMethodSchemas, method); +} + +function toErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return fallback; +} + +async function socketPathExists(socketPath: string): Promise { + try { + await stat(socketPath); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false; + } + + throw error; + } +} + +async function probeSocketLiveness(socketPath: string): Promise { + return await new Promise((resolve, reject) => { + const probe = net.connect({ path: socketPath }); + + probe.once('connect', () => { + probe.end(); + resolve(true); + }); + + probe.once('error', (error: NodeJS.ErrnoException) => { + probe.destroy(); + + if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT') { + resolve(false); + return; + } + + reject(error); + }); + }); +} + +async function unlinkIfPresent(socketPath: string): Promise { + try { + await unlink(socketPath); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return; + } + + throw error; + } +} + +function extractRequestId(value: unknown): string { + if (value !== null && typeof value === 'object' && 'id' in value) { + const id = value.id; + + if (typeof id === 'string' && id.length > 0) { + return id; + } + } + + return UNKNOWN_REQUEST_ID; +} + +function buildErrorResponse(id: string, message: string): RpcResponse { + const response = { + id, + ok: false, + error: { + code: ERROR_CODES.RPC_ERROR, + message, + }, + } as const; + const responseResult = RpcResponseSchema.safeParse(response); + invariant( + responseResult.success, + 'RPC error response must satisfy RpcResponseSchema.', + ); + + return responseResult.data; +} + +function buildCliErrorResponse(id: string, error: CliError): RpcResponse { + const response = { + id, + ok: false, + error: { + code: error.code, + message: error.message, + }, + } as const; + const responseResult = RpcResponseSchema.safeParse(response); + invariant( + responseResult.success, + 'RPC CliError response must satisfy RpcResponseSchema.', + ); + + return responseResult.data; +} + +function buildSuccessResponse(id: string, result: unknown): RpcResponse { + const response = { + id, + ok: true, + result, + } as const; + const responseResult = RpcResponseSchema.safeParse(response); + invariant( + responseResult.success, + 'RPC success response must satisfy RpcResponseSchema.', + ); + + return responseResult.data; +} + +export class RpcServer { + private readonly socketPath: string; + private readonly handlers: Readonly>; + private server: net.Server | null = null; + private closePromise: Promise | null = null; + + public constructor( + socketPath: string, + handlers: Record, + ) { + invariant(socketPath.length > 0, 'RPC socket path must not be empty.'); + + this.socketPath = socketPath; + this.handlers = handlers; + } + + public async listen(): Promise { + invariant(this.server === null, 'RPC server is already listening.'); + + await this.removeStaleSocketIfNeeded(); + invariant( + this.socketPath.length <= MAX_UNIX_SOCKET_PATH, + `Socket path exceeds Unix domain socket limit of ${String(MAX_UNIX_SOCKET_PATH)} bytes: ${this.socketPath} (${String(this.socketPath.length)} bytes)`, + ); + invariant( + !(await socketPathExists(this.socketPath)), + `RPC socket path must not exist before listen(): ${this.socketPath}`, + ); + + const server = net.createServer((socket) => { + this.handleConnection(socket); + }); + + server.on('error', () => { + // Keep server errors from becoming unhandled events after listen(). + }); + + this.server = server; + + try { + await new Promise((resolve, reject) => { + const onError = (error: Error): void => { + reject(error); + }; + + server.once('error', onError); + server.listen(this.socketPath, () => { + server.off('error', onError); + resolve(); + }); + }); + } catch (error) { + this.server = null; + throw error; + } + } + + public async close(): Promise { + if (this.closePromise !== null) { + await this.closePromise; + return; + } + + const server = this.server; + this.server = null; + + if (server === null) { + await unlinkIfPresent(this.socketPath); + return; + } + + this.closePromise = (async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) { + reject(error); + return; + } + + resolve(); + }); + }); + + await unlinkIfPresent(this.socketPath); + })(); + + try { + await this.closePromise; + } finally { + this.closePromise = null; + } + } + + private async removeStaleSocketIfNeeded(): Promise { + if (!(await socketPathExists(this.socketPath))) { + return; + } + + const socketIsLive = await probeSocketLiveness(this.socketPath); + invariant( + !socketIsLive, + `RPC socket already has a live listener: ${this.socketPath}`, + ); + + await unlinkIfPresent(this.socketPath); + } + + private handleConnection(socket: net.Socket): void { + socket.setEncoding('utf8'); + + let buffer = ''; + let handled = false; + + socket.on('error', () => { + socket.destroy(); + }); + + socket.on('data', (chunk: string) => { + if (handled) { + return; + } + + if (buffer.length + chunk.length > MAX_RPC_BUFFER_BYTES) { + handled = true; + this.sendResponse( + socket, + buildErrorResponse( + extractRequestId(undefined), + 'RPC request exceeds maximum buffer size.', + ), + ); + return; + } + + buffer += chunk; + const newlineIndex = buffer.indexOf('\n'); + + if (newlineIndex < 0) { + return; + } + + handled = true; + const line = buffer.slice(0, newlineIndex); + void this.processRequestLine(socket, line); + }); + + socket.on('end', () => { + if (handled) { + return; + } + + handled = true; + this.sendResponse( + socket, + buildErrorResponse( + UNKNOWN_REQUEST_ID, + buffer.length === 0 + ? 'RPC request ended before any data was received.' + : 'RPC request was not newline-delimited.', + ), + ); + }); + } + + private async processRequestLine( + socket: net.Socket, + line: string, + ): Promise { + let rawRequest: unknown; + + try { + rawRequest = JSON.parse(line) as unknown; + } catch (error) { + this.sendResponse( + socket, + buildErrorResponse( + UNKNOWN_REQUEST_ID, + toErrorMessage(error, 'Failed to parse RPC request JSON.'), + ), + ); + return; + } + + const requestResult = RpcRequestSchema.safeParse(rawRequest); + + if (!requestResult.success) { + this.sendResponse( + socket, + buildErrorResponse( + extractRequestId(rawRequest), + requestResult.error.message, + ), + ); + return; + } + + const request = requestResult.data; + const params = request.params ?? {}; + + if ( + !Object.hasOwn(this.handlers, request.method) || + !isKnownRpcMethod(request.method) + ) { + this.sendResponse( + socket, + buildErrorResponse(request.id, `Unsupported method: ${request.method}`), + ); + return; + } + + const handler = this.handlers[request.method]; + invariant( + typeof handler === 'function', + `RPC handler for method "${request.method}" must be a function.`, + ); + + const paramsResult = + RpcMethodSchemas[request.method].params.safeParse(params); + + if (!paramsResult.success) { + this.sendResponse( + socket, + buildErrorResponse(request.id, paramsResult.error.message), + ); + return; + } + + try { + const result = await handler(paramsResult.data); + const resultResult = + RpcMethodSchemas[request.method].result.safeParse(result); + + if (!resultResult.success) { + this.sendResponse( + socket, + buildErrorResponse(request.id, resultResult.error.message), + ); + return; + } + + this.sendResponse( + socket, + buildSuccessResponse(request.id, resultResult.data), + ); + } catch (error) { + this.sendResponse( + socket, + error instanceof CliError + ? buildCliErrorResponse(request.id, error) + : buildErrorResponse( + request.id, + toErrorMessage( + error, + `RPC handler failed for method "${request.method}".`, + ), + ), + ); + } + } + + private sendResponse(socket: net.Socket, response: RpcResponse): void { + socket.end(`${JSON.stringify(response)}\n`); + } +} diff --git a/src/host/sessionState.ts b/src/host/sessionState.ts new file mode 100644 index 0000000..a96f1b9 --- /dev/null +++ b/src/host/sessionState.ts @@ -0,0 +1,107 @@ +import type { SessionRecord } from '../protocol/schemas.js'; +import { invariant } from '../util/assert.js'; + +export class SessionState { + readonly #record: SessionRecord; + + public constructor(initialRecord: SessionRecord) { + this.#record = { + ...initialRecord, + command: [...initialRecord.command], + }; + } + + public snapshot(): SessionRecord { + return { + ...this.#record, + command: [...this.#record.command], + }; + } + + public setHostPid(pid: number): void { + invariant( + this.#record.status === 'running', + 'Cannot set host PID unless session is running', + ); + invariant(this.#record.hostPid === null, 'Host PID has already been set'); + invariant( + Number.isInteger(pid) && pid > 0, + 'Host PID must be a positive integer', + ); + + this.#record.hostPid = pid; + this.touch(); + } + + public setChildPid(pid: number): void { + invariant( + this.#record.status === 'running', + 'Cannot set child PID unless session is running', + ); + invariant(this.#record.childPid === null, 'Child PID has already been set'); + invariant( + Number.isInteger(pid) && pid > 0, + 'Child PID must be a positive integer', + ); + + this.#record.childPid = pid; + this.touch(); + } + + public setDimensions(cols: number, rows: number): void { + invariant( + this.#record.status === 'running', + 'Cannot set dimensions unless session is running', + ); + invariant( + Number.isInteger(cols) && cols > 0, + 'Columns must be a positive integer', + ); + invariant( + Number.isInteger(rows) && rows > 0, + 'Rows must be a positive integer', + ); + + this.#record.cols = cols; + this.#record.rows = rows; + this.touch(); + } + + public requestDestroy(): void { + invariant( + this.#record.status === 'running', + 'Cannot request destroy unless session is running', + ); + + this.#record.status = 'exiting'; + this.touch(); + } + + public recordExit(exitCode: number | null, exitSignal: string | null): void { + invariant( + this.#record.status === 'running' || this.#record.status === 'exiting', + 'Cannot record exit after session has exited', + ); + invariant( + this.#record.exitCode === null && this.#record.exitSignal === null, + 'Session exit has already been recorded', + ); + invariant( + exitCode === null || Number.isInteger(exitCode), + 'Exit code must be an integer or null', + ); + invariant( + exitSignal === null || typeof exitSignal === 'string', + 'Exit signal must be a string or null', + ); + + this.#record.exitCode = exitCode; + this.#record.exitSignal = exitSignal; + this.#record.status = 'exited'; + this.touch(); + } + + private touch(): void { + this.#record.updatedAt = new Date().toISOString(); + } +} diff --git a/src/index.ts b/src/index.ts index 36fd7dc..f3756cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,35 @@ export type { CommandErrorEnvelope, CommandSuccessEnvelope, } from './protocol/envelope.js'; +export type { + DestroyParams, + DestroyResult, + InspectParams, + InspectResult, + PasteParams, + PasteResult, + ResizeParams, + ResizeResult, + RpcError, + RpcErrorResponse, + RpcMethod, + RpcRequest, + RpcResponse, + RpcSuccessResponse, + SendKeysParams, + SendKeysResult, + SignalParams, + SignalResult, + TypeParams, + TypeResult, + WaitParams, + WaitResult, +} from './protocol/messages.js'; +export type { + EventRecord, + EventType, + SessionRecord, + SessionStatus, +} from './protocol/schemas.js'; +export type { ProtocolErrorCode } from './protocol/errors.js'; +export type { AgentTerminalConfig } from './config/resolveConfig.js'; diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts new file mode 100644 index 0000000..82015b1 --- /dev/null +++ b/src/protocol/errors.ts @@ -0,0 +1,83 @@ +import type { CliError } from '../cli/errors.js'; + +import { CliError as CliErrorClass } from '../cli/errors.js'; + +export const ERROR_CODES = { + SESSION_NOT_FOUND: 'SESSION_NOT_FOUND', + SESSION_NOT_RUNNING: 'SESSION_NOT_RUNNING', + SESSION_ALREADY_DESTROYED: 'SESSION_ALREADY_DESTROYED', + HOST_UNREACHABLE: 'HOST_UNREACHABLE', + HOST_TIMEOUT: 'HOST_TIMEOUT', + INVALID_SESSION_ID: 'INVALID_SESSION_ID', + INVALID_DIMENSIONS: 'INVALID_DIMENSIONS', + INVALID_SIGNAL: 'INVALID_SIGNAL', + INVALID_KEYS: 'INVALID_KEYS', + INVALID_DURATION: 'INVALID_DURATION', + INVALID_INPUT: 'INVALID_INPUT', + STORAGE_READ_ERROR: 'STORAGE_READ_ERROR', + STORAGE_WRITE_ERROR: 'STORAGE_WRITE_ERROR', + MANIFEST_VALIDATION_ERROR: 'MANIFEST_VALIDATION_ERROR', + RPC_ERROR: 'RPC_ERROR', + INTERNAL_ERROR: 'INTERNAL_ERROR', +} as const; + +export type ProtocolErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; + +export const DEFAULT_ERROR_MESSAGES: Record = { + [ERROR_CODES.SESSION_NOT_FOUND]: 'Session not found.', + [ERROR_CODES.SESSION_NOT_RUNNING]: 'Session is not running.', + [ERROR_CODES.SESSION_ALREADY_DESTROYED]: 'Session is already destroyed.', + [ERROR_CODES.HOST_UNREACHABLE]: 'Session host is unreachable.', + [ERROR_CODES.HOST_TIMEOUT]: 'Session host timed out.', + [ERROR_CODES.INVALID_SESSION_ID]: 'Session ID is invalid.', + [ERROR_CODES.INVALID_DIMENSIONS]: 'Terminal dimensions are invalid.', + [ERROR_CODES.INVALID_SIGNAL]: 'Signal is invalid.', + [ERROR_CODES.INVALID_KEYS]: 'Key sequence is invalid.', + [ERROR_CODES.INVALID_DURATION]: 'Duration value is invalid.', + [ERROR_CODES.INVALID_INPUT]: 'Invalid input provided.', + [ERROR_CODES.STORAGE_READ_ERROR]: 'Failed to read session storage.', + [ERROR_CODES.STORAGE_WRITE_ERROR]: 'Failed to write session storage.', + [ERROR_CODES.MANIFEST_VALIDATION_ERROR]: 'Session manifest is invalid.', + [ERROR_CODES.RPC_ERROR]: 'RPC request failed.', + [ERROR_CODES.INTERNAL_ERROR]: 'Internal error.', +}; + +const DEFAULT_RETRYABLE_CODES: ReadonlySet = new Set([ + ERROR_CODES.HOST_UNREACHABLE, + ERROR_CODES.HOST_TIMEOUT, + ERROR_CODES.RPC_ERROR, +]); + +export interface MakeCliErrorOptions { + message?: string; + retryable?: boolean; + details?: Record; + cause?: unknown; +} + +export function makeCliError( + code: ProtocolErrorCode, + overrides: MakeCliErrorOptions = {}, +): CliError { + const options: { + retryable?: boolean; + details?: Record; + cause?: unknown; + } = { + retryable: overrides.retryable ?? DEFAULT_RETRYABLE_CODES.has(code), + }; + + if (overrides.details !== undefined) { + options.details = overrides.details; + } + + if (overrides.cause !== undefined) { + options.cause = overrides.cause; + } + + return new CliErrorClass( + code, + overrides.message ?? DEFAULT_ERROR_MESSAGES[code], + options, + ); +} diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts new file mode 100644 index 0000000..e2e77ef --- /dev/null +++ b/src/protocol/messages.ts @@ -0,0 +1,196 @@ +import { z } from 'zod'; + +import { SessionRecordSchema } from './schemas.js'; + +const EmptyObjectSchema = z.object({}).strict(); +const NonEmptyStringSchema = z.string().min(1); +const DurationSchema = z.number().int().positive(); + +export const RpcRequestSchema = z + .object({ + id: NonEmptyStringSchema, + method: NonEmptyStringSchema, + params: z.record(z.string(), z.unknown()).optional(), + }) + .strict(); +export type RpcRequest = z.infer; + +export const RpcErrorSchema = z + .object({ + code: NonEmptyStringSchema, + message: NonEmptyStringSchema, + }) + .strict(); +export type RpcError = z.infer; + +export const RpcSuccessResponseSchema = z + .object({ + id: NonEmptyStringSchema, + ok: z.literal(true), + result: z.unknown(), + }) + .strict(); +export type RpcSuccessResponse = z.infer; + +export const RpcErrorResponseSchema = z + .object({ + id: NonEmptyStringSchema, + ok: z.literal(false), + error: RpcErrorSchema, + }) + .strict(); +export type RpcErrorResponse = z.infer; + +export const RpcResponseSchema = z.discriminatedUnion('ok', [ + RpcSuccessResponseSchema, + RpcErrorResponseSchema, +]); +export type RpcResponse = z.infer; + +export const InspectParamsSchema = EmptyObjectSchema; +export type InspectParams = z.infer; + +export const InspectResultSchema = z + .object({ + session: SessionRecordSchema, + }) + .strict(); +export type InspectResult = z.infer; + +export const TypeParamsSchema = z + .object({ + text: z.string().min(1), + }) + .strict(); +export type TypeParams = z.infer; + +export const TypeResultSchema = EmptyObjectSchema; +export type TypeResult = z.infer; + +export const PasteParamsSchema = z + .object({ + text: z.string().min(1), + }) + .strict(); +export type PasteParams = z.infer; + +export const PasteResultSchema = EmptyObjectSchema; +export type PasteResult = z.infer; + +export const SendKeysParamsSchema = z + .object({ + keys: z.array(NonEmptyStringSchema).min(1), + }) + .strict(); +export type SendKeysParams = z.infer; + +export const SendKeysResultSchema = EmptyObjectSchema; +export type SendKeysResult = z.infer; + +export const ResizeParamsSchema = z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive(), + }) + .strict(); +export type ResizeParams = z.infer; + +export const ResizeResultSchema = z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive(), + }) + .strict(); +export type ResizeResult = z.infer; + +export const SignalParamsSchema = z + .object({ + signal: NonEmptyStringSchema, + }) + .strict(); +export type SignalParams = z.infer; + +export const SignalResultSchema = EmptyObjectSchema; +export type SignalResult = z.infer; + +export const WaitParamsSchema = z + .object({ + exit: z.boolean().optional(), + idleMs: DurationSchema.optional(), + timeoutMs: DurationSchema.optional(), + }) + .strict(); +export type WaitParams = z.infer; + +export const WaitResultSchema = z + .object({ + exitCode: z.number().int().optional(), + timedOut: z.boolean(), + }) + .strict(); +export type WaitResult = z.infer; + +export const DestroyParamsSchema = z + .object({ + force: z.boolean().optional(), + }) + .strict(); +export type DestroyParams = z.infer; + +export const DestroyResultSchema = EmptyObjectSchema; +export type DestroyResult = z.infer; + +const RPC_METHODS = [ + 'inspect', + 'type', + 'paste', + 'sendKeys', + 'resize', + 'signal', + 'wait', + 'destroy', +] as const; + +export const RpcMethodSchema = z.enum(RPC_METHODS); +export type RpcMethod = z.infer; + +export const RpcMethodSchemas = { + inspect: { + params: InspectParamsSchema, + result: InspectResultSchema, + }, + type: { + params: TypeParamsSchema, + result: TypeResultSchema, + }, + paste: { + params: PasteParamsSchema, + result: PasteResultSchema, + }, + sendKeys: { + params: SendKeysParamsSchema, + result: SendKeysResultSchema, + }, + resize: { + params: ResizeParamsSchema, + result: ResizeResultSchema, + }, + signal: { + params: SignalParamsSchema, + result: SignalResultSchema, + }, + wait: { + params: WaitParamsSchema, + result: WaitResultSchema, + }, + destroy: { + params: DestroyParamsSchema, + result: DestroyResultSchema, + }, +} as const satisfies Record< + RpcMethod, + { + params: z.ZodType; + result: z.ZodType; + } +>; diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts new file mode 100644 index 0000000..44488fe --- /dev/null +++ b/src/protocol/schemas.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +export const SessionStatusSchema = z.enum(['running', 'exiting', 'exited']); +export type SessionStatus = z.infer; + +export const SessionRecordSchema = z + .object({ + version: z.literal(1), + sessionId: z.string(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), + status: SessionStatusSchema, + command: z.array(z.string()).min(1), + cwd: z.string(), + cols: z.number().int().positive(), + rows: z.number().int().positive(), + hostPid: z.number().int().positive().nullable(), + childPid: z.number().int().positive().nullable(), + exitCode: z.number().int().nullable(), + exitSignal: z.string().nullable(), + }) + .strict(); +export type SessionRecord = z.infer; + +export const EventTypeSchema = z.enum([ + 'output', + 'input_text', + 'input_paste', + 'input_keys', + 'resize', + 'signal', + 'exit', +]); +export type EventType = z.infer; + +export const EventRecordSchema = z + .object({ + seq: z.number().int().nonnegative(), + ts: z.iso.datetime(), + type: EventTypeSchema, + payload: z.unknown(), + }) + .strict(); +export type EventRecord = z.infer; diff --git a/src/pty/createPty.ts b/src/pty/createPty.ts new file mode 100644 index 0000000..2fb39fd --- /dev/null +++ b/src/pty/createPty.ts @@ -0,0 +1,33 @@ +import type { IPty } from 'node-pty'; +import { spawn } from 'node-pty'; +import { invariant } from '../util/assert.js'; + +export interface PtyOptions { + command: string[]; + cwd: string; + cols: number; + rows: number; +} + +export function createPty(options: PtyOptions): IPty { + const { command, cwd, cols, rows } = options; + + invariant(command.length > 0, 'PTY command must not be empty'); + invariant( + Number.isInteger(cols) && cols > 0, + 'PTY cols must be a positive integer', + ); + invariant( + Number.isInteger(rows) && rows > 0, + 'PTY rows must be a positive integer', + ); + + const file = command[0]; + invariant(file !== undefined, 'PTY command must have an executable'); + + return spawn(file, command.slice(1), { + cwd, + cols, + rows, + }); +} diff --git a/src/pty/keyEncoder.ts b/src/pty/keyEncoder.ts new file mode 100644 index 0000000..284c356 --- /dev/null +++ b/src/pty/keyEncoder.ts @@ -0,0 +1,287 @@ +import { invariant } from '../util/assert.js'; + +interface Modifiers { + ctrl: boolean; + alt: boolean; + shift: boolean; +} + +interface CsiFinalKeySpec { + unmodified: string; + final: string; +} + +const SIMPLE_KEY_ENCODINGS = { + enter: '\r', + tab: '\t', + escape: '\x1b', + backspace: '\x7f', +} as const; + +const CSI_FINAL_KEY_ENCODINGS: Record = { + up: { unmodified: '\x1b[A', final: 'A' }, + down: { unmodified: '\x1b[B', final: 'B' }, + right: { unmodified: '\x1b[C', final: 'C' }, + left: { unmodified: '\x1b[D', final: 'D' }, + home: { unmodified: '\x1b[H', final: 'H' }, + end: { unmodified: '\x1b[F', final: 'F' }, + f1: { unmodified: '\x1bOP', final: 'P' }, + f2: { unmodified: '\x1bOQ', final: 'Q' }, + f3: { unmodified: '\x1bOR', final: 'R' }, + f4: { unmodified: '\x1bOS', final: 'S' }, +}; + +const CSI_TILDE_KEY_ENCODINGS: Record = { + insert: 2, + delete: 3, + pageup: 5, + pagedown: 6, + f5: 15, + f6: 17, + f7: 18, + f8: 19, + f9: 20, + f10: 21, + f11: 23, + f12: 24, +}; + +const PRINTABLE_ASCII = /^[\x20-\x7e]$/; +const ASCII_LETTER = /^[A-Za-z]$/; + +export function encodeKey(keyName: string): string { + invariant(typeof keyName === 'string', 'Key name must be a string'); + + const { baseKey, modifiers } = parseKeyName(keyName); + const lowerBaseKey = baseKey.toLowerCase(); + + if (lowerBaseKey === 'space') { + return encodePrintableCharacter(' ', modifiers, baseKey); + } + + if (Object.hasOwn(SIMPLE_KEY_ENCODINGS, lowerBaseKey)) { + const simpleKey = + SIMPLE_KEY_ENCODINGS[lowerBaseKey as keyof typeof SIMPLE_KEY_ENCODINGS]; + return encodeSimpleKey(lowerBaseKey, simpleKey, modifiers); + } + + const csiFinalKey = CSI_FINAL_KEY_ENCODINGS[lowerBaseKey]; + if (csiFinalKey !== undefined) { + return encodeCsiFinalKey(csiFinalKey, modifiers); + } + + const csiTildeKeyCode = CSI_TILDE_KEY_ENCODINGS[lowerBaseKey]; + if (csiTildeKeyCode !== undefined) { + return encodeCsiTildeKey(csiTildeKeyCode, modifiers); + } + + if (baseKey.length === 1 && PRINTABLE_ASCII.test(baseKey)) { + return encodePrintableCharacter(baseKey, modifiers, baseKey); + } + + invariant(false, `Unknown base key: ${baseKey}`); +} + +function parseKeyName(keyName: string): { + baseKey: string; + modifiers: Modifiers; +} { + const trimmedKeyName = keyName.trim(); + invariant(trimmedKeyName.length > 0, 'Key name must not be empty'); + + const tokens = trimmedKeyName.split('+').map((token) => token.trim()); + invariant(tokens.length > 0, 'Key name must contain a base key'); + + const baseKey = tokens.at(-1); + invariant( + baseKey !== undefined && baseKey.length > 0, + 'Key name must contain a base key', + ); + + const modifiers: Modifiers = { + ctrl: false, + alt: false, + shift: false, + }; + + for (const token of tokens.slice(0, -1)) { + invariant(token.length > 0, `Invalid key token in ${keyName}`); + + const lowerToken = token.toLowerCase(); + invariant(lowerToken in modifiers, `Unknown modifier: ${token}`); + + const modifier = lowerToken as keyof Modifiers; + invariant(!modifiers[modifier], `Duplicate modifier: ${token}`); + modifiers[modifier] = true; + } + + invariant( + !(baseKey.toLowerCase() in modifiers), + `Missing base key in ${keyName}`, + ); + + return { baseKey, modifiers }; +} + +function encodeSimpleKey( + baseKey: string, + sequence: string, + modifiers: Modifiers, +): string { + if (!hasModifiers(modifiers)) { + return sequence; + } + + if ( + baseKey === 'tab' && + modifiers.shift && + !modifiers.ctrl && + !modifiers.alt + ) { + return '\x1b[Z'; + } + + if (modifiers.alt && !modifiers.ctrl && !modifiers.shift) { + return `\x1b${sequence}`; + } + + invariant( + false, + `Unsupported modifier combination for ${baseKey}: ${formatModifiers(modifiers)}`, + ); +} + +function encodeCsiFinalKey( + keySpec: CsiFinalKeySpec, + modifiers: Modifiers, +): string { + if (!hasModifiers(modifiers)) { + return keySpec.unmodified; + } + + const modifierParameter = String(getModifierParameter(modifiers)); + return `\x1b[1;${modifierParameter}${keySpec.final}`; +} + +function encodeCsiTildeKey(keyCode: number, modifiers: Modifiers): string { + const keyCodeText = String(keyCode); + + if (!hasModifiers(modifiers)) { + return `\x1b[${keyCodeText}~`; + } + + const modifierParameter = String(getModifierParameter(modifiers)); + return `\x1b[${keyCodeText};${modifierParameter}~`; +} + +function encodePrintableCharacter( + character: string, + modifiers: Modifiers, + displayKey: string, +): string { + invariant( + character.length === 1, + `Printable key must be a single character: ${displayKey}`, + ); + invariant( + PRINTABLE_ASCII.test(character), + `Unsupported printable key: ${displayKey}`, + ); + + if (!hasModifiers(modifiers)) { + return character; + } + + if (modifiers.ctrl) { + if (modifiers.shift) { + invariant( + ASCII_LETTER.test(character), + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); + } + + const controlCharacter = getControlCharacter(character); + invariant( + controlCharacter !== undefined, + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); + + return modifiers.alt ? `\x1b${controlCharacter}` : controlCharacter; + } + + if (modifiers.alt) { + if (modifiers.shift) { + invariant( + ASCII_LETTER.test(character), + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); + return `\x1b${character.toUpperCase()}`; + } + + return `\x1b${character}`; + } + + if (modifiers.shift) { + invariant( + ASCII_LETTER.test(character), + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); + return character.toUpperCase(); + } + + invariant( + false, + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); +} + +function getControlCharacter(character: string): string | undefined { + if (ASCII_LETTER.test(character)) { + return String.fromCharCode(character.toUpperCase().charCodeAt(0) - 64); + } + + switch (character) { + case ' ': + return '\x00'; + case '@': + return '\x00'; + case '[': + return '\x1b'; + case '\\': + return '\x1c'; + case ']': + return '\x1d'; + case '^': + return '\x1e'; + case '_': + return '\x1f'; + case '?': + return '\x7f'; + default: + return undefined; + } +} + +function hasModifiers(modifiers: Modifiers): boolean { + return modifiers.ctrl || modifiers.alt || modifiers.shift; +} + +function getModifierParameter(modifiers: Modifiers): number { + invariant( + hasModifiers(modifiers), + 'Modifier parameter requires at least one modifier', + ); + + return ( + 1 + + Number(modifiers.shift) + + Number(modifiers.alt) * 2 + + Number(modifiers.ctrl) * 4 + ); +} + +function formatModifiers(modifiers: Modifiers): string { + return ['ctrl', 'alt', 'shift'] + .filter((modifier) => modifiers[modifier as keyof Modifiers]) + .join('+'); +} diff --git a/src/pty/pasteEncoder.ts b/src/pty/pasteEncoder.ts new file mode 100644 index 0000000..329f67e --- /dev/null +++ b/src/pty/pasteEncoder.ts @@ -0,0 +1,8 @@ +import { invariant } from '../util/assert.js'; + +export function encodePaste(text: string): string { + invariant(typeof text === 'string', 'Paste text must be a string'); + invariant(text.length > 0, 'Paste text must not be empty'); + + return `\x1b[200~${text}\x1b[201~`; +} diff --git a/src/storage/home.ts b/src/storage/home.ts new file mode 100644 index 0000000..b00e759 --- /dev/null +++ b/src/storage/home.ts @@ -0,0 +1,42 @@ +import { mkdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, normalize } from 'node:path'; +import process from 'node:process'; + +import { invariant } from '../util/assert.js'; + +const DEFAULT_HOME_DIRECTORY_NAME = '.agent-terminal'; + +export function resolveHome(): string { + const configuredHome = process.env.AGENT_TERMINAL_HOME; + + if (configuredHome !== undefined) { + invariant( + configuredHome.length > 0, + 'AGENT_TERMINAL_HOME must not be empty', + ); + invariant( + isAbsolute(configuredHome), + 'AGENT_TERMINAL_HOME must be an absolute path', + ); + + return normalize(configuredHome); + } + + const resolvedHome = normalize(join(homedir(), DEFAULT_HOME_DIRECTORY_NAME)); + + invariant( + isAbsolute(resolvedHome), + 'resolved agent-terminal home must be absolute', + ); + + return resolvedHome; +} + +export async function ensureHome(): Promise { + const home = resolveHome(); + + await mkdir(home, { recursive: true }); + + return home; +} diff --git a/src/storage/manifests.ts b/src/storage/manifests.ts new file mode 100644 index 0000000..b899bc8 --- /dev/null +++ b/src/storage/manifests.ts @@ -0,0 +1,120 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute } from 'node:path'; + +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import { SessionRecordSchema } from '../protocol/schemas.js'; +import { invariant } from '../util/assert.js'; +import type { SessionRecord } from '../protocol/schemas.js'; + +interface NodeError { + code?: string; +} + +function assertAbsoluteManifestPath(path: string): void { + invariant(path.length > 0, 'manifest path must be a non-empty string'); + invariant(isAbsolute(path), 'manifest path must be absolute'); +} + +function isEnoentError(error: unknown): error is Error & NodeError { + return ( + error instanceof Error && + 'code' in error && + (error as NodeError).code === 'ENOENT' + ); +} + +function validateManifestData(path: string, data: unknown): SessionRecord { + const parsedManifest = SessionRecordSchema.safeParse(data); + + if (parsedManifest.success) { + return parsedManifest.data; + } + + throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { + message: `Session manifest validation failed for ${path}.`, + details: { + path, + issues: parsedManifest.error.issues, + }, + }); +} + +function parseManifestJson(path: string, rawManifest: string): SessionRecord { + try { + return validateManifestData(path, JSON.parse(rawManifest) as unknown); + } catch (error) { + if (error instanceof SyntaxError) { + throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { + message: `Session manifest contains invalid JSON at ${path}.`, + details: { path }, + cause: error, + }); + } + + throw error; + } +} + +async function readManifestInternal( + path: string, + allowMissing: boolean, +): Promise { + assertAbsoluteManifestPath(path); + + let rawManifest: string; + try { + rawManifest = await readFile(path, 'utf8'); + } catch (error) { + if (allowMissing && isEnoentError(error)) { + return null; + } + + throw makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { + message: `Failed to read session manifest at ${path}.`, + details: { path }, + cause: error, + }); + } + + return parseManifestJson(path, rawManifest); +} + +export async function readManifest(path: string): Promise { + const manifest = await readManifestInternal(path, false); + + invariant(manifest !== null, 'readManifest must return a manifest record'); + + return manifest; +} + +export async function readManifestIfExists( + path: string, +): Promise { + return readManifestInternal(path, true); +} + +export async function writeManifest( + path: string, + record: SessionRecord, +): Promise { + assertAbsoluteManifestPath(path); + + const validatedRecord = validateManifestData(path, record); + const serializedManifest = `${JSON.stringify(validatedRecord, null, 2)}\n`; + const manifestDirectory = dirname(path); + const temporaryPath = `${path}.tmp-${randomUUID()}`; + + try { + await mkdir(manifestDirectory, { recursive: true }); + await writeFile(temporaryPath, serializedManifest, 'utf8'); + await rename(temporaryPath, path); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw makeCliError(ERROR_CODES.STORAGE_WRITE_ERROR, { + message: `Failed to write session manifest at ${path}.`, + details: { path }, + cause: error, + }); + } +} diff --git a/src/storage/sessionPaths.ts b/src/storage/sessionPaths.ts new file mode 100644 index 0000000..5ff6a70 --- /dev/null +++ b/src/storage/sessionPaths.ts @@ -0,0 +1,71 @@ +import { dirname, isAbsolute, resolve } from 'node:path'; + +import { + EVENT_LOG_FILENAME, + MANIFEST_FILENAME, + SOCKET_FILENAME, +} from '../config/defaults.js'; +import { invariant } from '../util/assert.js'; + +function assertNonEmptyString( + value: string, + label: string, +): asserts value is string { + invariant(value.length > 0, `${label} must be a non-empty string`); +} + +function assertAbsolutePath(pathValue: string, label: string): void { + assertNonEmptyString(pathValue, label); + invariant(isAbsolute(pathValue), `${label} must be an absolute path`); +} + +function assertSessionId(sessionId: string): void { + assertNonEmptyString(sessionId, 'sessionId'); + invariant(sessionId !== '.', 'sessionId must not be "."'); + invariant(sessionId !== '..', 'sessionId must not be ".."'); + invariant( + !sessionId.includes('/') && !sessionId.includes('\\'), + 'sessionId must not contain path separators', + ); +} + +export function sessionDir(home: string, sessionId: string): string { + assertAbsolutePath(home, 'home'); + assertSessionId(sessionId); + + const sessionsRoot = resolve(home, 'sessions'); + const resolvedSessionDirectory = resolve(sessionsRoot, sessionId); + + invariant( + dirname(resolvedSessionDirectory) === sessionsRoot, + 'session directory must stay within the sessions root', + ); + + return resolvedSessionDirectory; +} + +function childPath(sessionDirectory: string, filename: string): string { + assertAbsolutePath(sessionDirectory, 'sessionDir'); + + const normalizedSessionDirectory = resolve(sessionDirectory); + const child = resolve(normalizedSessionDirectory, filename); + + invariant( + dirname(child) === normalizedSessionDirectory, + `${filename} must stay within the session directory`, + ); + + return child; +} + +export function manifestPath(sessionDirectory: string): string { + return childPath(sessionDirectory, MANIFEST_FILENAME); +} + +export function eventLogPath(sessionDirectory: string): string { + return childPath(sessionDirectory, EVENT_LOG_FILENAME); +} + +export function socketPath(sessionDirectory: string): string { + return childPath(sessionDirectory, SOCKET_FILENAME); +} diff --git a/test/e2e/hello-prompt.test.ts b/test/e2e/hello-prompt.test.ts new file mode 100644 index 0000000..dd3b0a4 --- /dev/null +++ b/test/e2e/hello-prompt.test.ts @@ -0,0 +1,283 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupHome, + createIsolatedHome, + DEFAULT_IDLE_MS, + DEFAULT_WAIT_TIMEOUT_MS, + fixtureCommand, + normalizeTerminalOutput, + readOutput, + runCli, + runCliJson, + type SessionRecord, + type SuccessEnvelope, + type WaitResult, +} from './helpers.js'; + +interface CreateResult { + sessionId: string; +} + +interface InspectResult { + session: SessionRecord; +} + +function testEnv(home: string): Record { + return { AGENT_TERMINAL_HOME: home }; +} + +describe('hello-prompt e2e', { timeout: 30_000 }, () => { + let testHome = ''; + let createdSessionIds: string[] = []; + + beforeEach(async () => { + testHome = await createIsolatedHome(); + createdSessionIds = []; + }); + + afterEach(async () => { + const env = testEnv(testHome); + + for (const sessionId of createdSessionIds) { + runCli(['destroy', sessionId, '--force', '--json'], env); + } + + await cleanupHome(testHome); + }); + + it('full interaction flow', async () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + ['create', '--', ...fixtureCommand('hello-prompt')], + env, + ); + + expect(createEnvelope.ok).toBe(true); + expect(createEnvelope.command).toBe('create'); + + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const waitForReady = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForReady.ok).toBe(true); + expect(waitForReady.command).toBe('wait'); + expect(waitForReady.result.timedOut).toBe(false); + await expect( + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), + ).resolves.toContain('READY> '); + + const typeEnvelope = runCliJson>>( + ['type', sessionId, 'hello world'], + env, + ); + expect(typeEnvelope.ok).toBe(true); + expect(typeEnvelope.command).toBe('type'); + expect(typeEnvelope.result).toEqual({}); + + const sendKeysEnvelope = runCliJson>>( + ['send-keys', sessionId, 'Enter'], + env, + ); + expect(sendKeysEnvelope.ok).toBe(true); + expect(sendKeysEnvelope.command).toBe('send-keys'); + expect(sendKeysEnvelope.result).toEqual({}); + + const waitForEcho = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForEcho.result.timedOut).toBe(false); + await expect( + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), + ).resolves.toContain('ECHO: hello world\nREADY> '); + + const inspectRunning = runCliJson>( + ['inspect', sessionId], + env, + ); + expect(inspectRunning.ok).toBe(true); + expect(inspectRunning.command).toBe('inspect'); + expect(inspectRunning.result.session.status).toBe('running'); + expect(inspectRunning.result.session.exitCode).toBeNull(); + + const typeExitEnvelope = runCliJson>>( + ['type', sessionId, 'exit'], + env, + ); + expect(typeExitEnvelope.ok).toBe(true); + expect(typeExitEnvelope.command).toBe('type'); + expect(typeExitEnvelope.result).toEqual({}); + + const sendExitEnterEnvelope = runCliJson< + SuccessEnvelope> + >(['send-keys', sessionId, 'Enter'], env); + expect(sendExitEnterEnvelope.ok).toBe(true); + expect(sendExitEnterEnvelope.command).toBe('send-keys'); + expect(sendExitEnterEnvelope.result).toEqual({}); + + const waitForExit = runCliJson>( + [ + 'wait', + sessionId, + '--exit', + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForExit.ok).toBe(true); + expect(waitForExit.result.timedOut).toBe(false); + expect(waitForExit.result.exitCode).toBe(0); + await expect( + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), + ).resolves.toContain('BYE\n'); + + const inspectExited = runCliJson>( + ['inspect', sessionId], + env, + ); + expect(inspectExited.result.session.status).toBe('exited'); + expect(inspectExited.result.session.exitCode).toBe(0); + + const destroyEnvelope = runCliJson< + SuccessEnvelope<{ sessionId: string; destroyed: boolean }> + >(['destroy', sessionId, '--force'], env); + expect(destroyEnvelope.ok).toBe(true); + expect(destroyEnvelope.command).toBe('destroy'); + expect(destroyEnvelope.result.sessionId).toBe(sessionId); + expect(destroyEnvelope.result.destroyed).toBe(true); + + createdSessionIds = createdSessionIds.filter( + (value) => value !== sessionId, + ); + }); + + it('paste and exit-code', () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + ['create', '--', ...fixtureCommand('hello-prompt')], + env, + ); + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const waitForReady = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForReady.result.timedOut).toBe(false); + + const pasteEnvelope = runCliJson>>( + ['paste', sessionId, 'exit-code 42'], + env, + ); + expect(pasteEnvelope.ok).toBe(true); + expect(pasteEnvelope.command).toBe('paste'); + expect(pasteEnvelope.result).toEqual({}); + + const sendKeysEnvelope = runCliJson>>( + ['send-keys', sessionId, 'Enter'], + env, + ); + expect(sendKeysEnvelope.ok).toBe(true); + expect(sendKeysEnvelope.command).toBe('send-keys'); + expect(sendKeysEnvelope.result).toEqual({}); + + const waitForExit = runCliJson>( + [ + 'wait', + sessionId, + '--exit', + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForExit.ok).toBe(true); + expect(waitForExit.result.timedOut).toBe(false); + expect(waitForExit.result.exitCode).toBe(42); + }); + + it('signal handling', async () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + ['create', '--', ...fixtureCommand('hello-prompt')], + env, + ); + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const waitForReady = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForReady.result.timedOut).toBe(false); + + const signalEnvelope = runCliJson< + SuccessEnvelope<{ signal: string; delivered: boolean }> + >(['signal', sessionId, 'SIGINT'], env); + expect(signalEnvelope.ok).toBe(true); + expect(signalEnvelope.command).toBe('signal'); + expect(signalEnvelope.result).toEqual({ + signal: 'SIGINT', + delivered: true, + }); + + const waitForExit = runCliJson>( + [ + 'wait', + sessionId, + '--exit', + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForExit.ok).toBe(true); + expect(waitForExit.result.timedOut).toBe(false); + expect(waitForExit.result.exitCode).toBe(130); + await expect( + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), + ).resolves.toContain('INTERRUPTED\n'); + }); +}); diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts new file mode 100644 index 0000000..b516335 --- /dev/null +++ b/test/e2e/helpers.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { readEvents, runCli } from '../helpers.js'; + +export { + cleanupHome, + createSession, + destroySession, + inspectSession, + readEvents, + runCli, + sleep, + type EventRecord, + type SessionRecord, + type SuccessEnvelope, + type WaitResult, +} from '../helpers.js'; + +export const DEFAULT_CLI_TIMEOUT_MS = 30_000; +export const DEFAULT_IDLE_MS = 500; +export const DEFAULT_WAIT_TIMEOUT_MS = 10_000; + +function withJsonFlag(args: string[]): string[] { + const commandSeparatorIndex = args.indexOf('--'); + + if (commandSeparatorIndex === -1) { + return [...args, '--json']; + } + + return [ + ...args.slice(0, commandSeparatorIndex), + '--json', + ...args.slice(commandSeparatorIndex), + ]; +} + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- typed JSON helper keeps call sites concise in test code. +export function runCliJson( + args: string[], + env: Record, +): TResult { + const { stdout } = runCli(withJsonFlag(args), env, DEFAULT_CLI_TIMEOUT_MS); + + assert(stdout.length > 0, 'expected JSON output from CLI command'); + + return JSON.parse(stdout) as TResult; +} + +export function normalizeTerminalOutput(output: string): string { + return output.replaceAll('\r\n', '\n'); +} + +export async function createIsolatedHome(): Promise { + return mkdtemp(join(tmpdir(), 'agent-terminal-e2e-home-')); +} + +export async function readOutput( + home: string, + sessionId: string, +): Promise { + const events = await readEvents(home, sessionId); + + return events + .filter((event) => event.type === 'output') + .map((event) => { + const data = event.payload.data; + return typeof data === 'string' ? data : ''; + }) + .join(''); +} + +export function fixtureCommand( + appName: 'hello-prompt' | 'resize-demo', +): string[] { + return ['node', '--import', 'tsx', `test/fixtures/apps/${appName}/main.ts`]; +} diff --git a/test/e2e/resize-demo.test.ts b/test/e2e/resize-demo.test.ts new file mode 100644 index 0000000..2c911ab --- /dev/null +++ b/test/e2e/resize-demo.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupHome, + createIsolatedHome, + DEFAULT_IDLE_MS, + DEFAULT_WAIT_TIMEOUT_MS, + fixtureCommand, + normalizeTerminalOutput, + readOutput, + runCli, + runCliJson, + type SessionRecord, + type SuccessEnvelope, + type WaitResult, +} from './helpers.js'; + +interface CreateResult { + sessionId: string; +} + +interface InspectResult { + session: SessionRecord; +} + +function testEnv(home: string): Record { + return { AGENT_TERMINAL_HOME: home }; +} + +describe('resize-demo e2e', { timeout: 30_000 }, () => { + let testHome = ''; + let createdSessionIds: string[] = []; + + beforeEach(async () => { + testHome = await createIsolatedHome(); + createdSessionIds = []; + }); + + afterEach(async () => { + const env = testEnv(testHome); + + for (const sessionId of createdSessionIds) { + runCli(['destroy', sessionId, '--force', '--json'], env); + } + + await cleanupHome(testHome); + }); + + it('initial size and resize', async () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + [ + 'create', + '--cols', + '80', + '--rows', + '24', + '--', + ...fixtureCommand('resize-demo'), + ], + env, + ); + expect(createEnvelope.ok).toBe(true); + + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const waitForInitialOutput = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForInitialOutput.ok).toBe(true); + expect(waitForInitialOutput.result.timedOut).toBe(false); + await expect( + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), + ).resolves.toContain('SIZE: 80x24\n'); + + const resizeEnvelope = runCliJson< + SuccessEnvelope<{ cols: number; rows: number }> + >(['resize', sessionId, '--cols', '120', '--rows', '40'], env); + expect(resizeEnvelope.ok).toBe(true); + expect(resizeEnvelope.command).toBe('resize'); + expect(resizeEnvelope.result.cols).toBe(120); + expect(resizeEnvelope.result.rows).toBe(40); + + const waitForResizeOutput = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForResizeOutput.result.timedOut).toBe(false); + await expect( + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), + ).resolves.toContain('SIZE: 120x40\n'); + + const typeQuitEnvelope = runCliJson>>( + ['type', sessionId, 'quit'], + env, + ); + expect(typeQuitEnvelope.ok).toBe(true); + expect(typeQuitEnvelope.command).toBe('type'); + expect(typeQuitEnvelope.result).toEqual({}); + + const sendKeysEnvelope = runCliJson>>( + ['send-keys', sessionId, 'Enter'], + env, + ); + expect(sendKeysEnvelope.ok).toBe(true); + expect(sendKeysEnvelope.command).toBe('send-keys'); + expect(sendKeysEnvelope.result).toEqual({}); + + const waitForExit = runCliJson>( + [ + 'wait', + sessionId, + '--exit', + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForExit.ok).toBe(true); + expect(waitForExit.result.timedOut).toBe(false); + expect(waitForExit.result.exitCode).toBe(0); + }); + + it('inspect reflects resize', () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + ['create', '--', ...fixtureCommand('resize-demo')], + env, + ); + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const resizeEnvelope = runCliJson< + SuccessEnvelope<{ cols: number; rows: number }> + >(['resize', sessionId, '--cols', '100', '--rows', '50'], env); + expect(resizeEnvelope.ok).toBe(true); + expect(resizeEnvelope.result.cols).toBe(100); + expect(resizeEnvelope.result.rows).toBe(50); + + const inspectEnvelope = runCliJson>( + ['inspect', sessionId], + env, + ); + expect(inspectEnvelope.ok).toBe(true); + expect(inspectEnvelope.command).toBe('inspect'); + expect(inspectEnvelope.result.session.status).toBe('running'); + expect(inspectEnvelope.result.session.cols).toBe(100); + expect(inspectEnvelope.result.session.rows).toBe(50); + }); +}); diff --git a/test/fixtures/apps/hello-prompt/main.ts b/test/fixtures/apps/hello-prompt/main.ts new file mode 100644 index 0000000..f03710f --- /dev/null +++ b/test/fixtures/apps/hello-prompt/main.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import readline from 'node:readline'; + +const READY_PROMPT = 'READY> '; +const BRACKETED_PASTE_START = '\u001b[200~'; +const BRACKETED_PASTE_END = '\u001b[201~'; +const EXIT_CODE_PREFIX = 'exit-code '; + +function writeStdout(text: string): void { + process.stdout.write(text); +} + +function printReadyPrompt(): void { + writeStdout(READY_PROMPT); +} + +function normalizeInput(input: string): string { + return input + .replaceAll(BRACKETED_PASTE_START, '') + .replaceAll(BRACKETED_PASTE_END, ''); +} + +function parseExitCode(input: string): number { + const rawCode = input.slice(EXIT_CODE_PREFIX.length).trim(); + const exitCode = Number.parseInt(rawCode, 10); + + assert(rawCode.length > 0, 'exit-code command requires a numeric argument'); + assert( + Number.isInteger(exitCode), + 'exit-code command must parse to an integer', + ); + assert( + String(exitCode) === rawCode, + 'exit-code command only accepts canonical integers', + ); + + return exitCode; +} + +const lineReader = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, +}); + +process.on('SIGINT', () => { + writeStdout('INTERRUPTED\n'); + process.exit(130); +}); + +lineReader.on('line', (line) => { + const normalizedLine = normalizeInput(line); + + if (normalizedLine === 'exit') { + writeStdout('BYE\n'); + process.exit(0); + } + + if (normalizedLine.startsWith(EXIT_CODE_PREFIX)) { + process.exit(parseExitCode(normalizedLine)); + } + + writeStdout(`ECHO: ${normalizedLine}\n`); + printReadyPrompt(); +}); + +lineReader.on('close', () => { + process.stdin.pause(); +}); + +printReadyPrompt(); diff --git a/test/fixtures/apps/resize-demo/main.ts b/test/fixtures/apps/resize-demo/main.ts new file mode 100644 index 0000000..879aa01 --- /dev/null +++ b/test/fixtures/apps/resize-demo/main.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import readline from 'node:readline'; + +function readTerminalSize(): { cols: number; rows: number } { + const { columns, rows } = process.stdout; + + assert(typeof columns === 'number', 'stdout.columns must be available'); + assert(typeof rows === 'number', 'stdout.rows must be available'); + + return { cols: columns, rows }; +} + +function printTerminalSize(): void { + const { cols, rows } = readTerminalSize(); + process.stdout.write(`SIZE: ${String(cols)}x${String(rows)}\n`); +} + +const lineReader = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, +}); + +process.on('SIGWINCH', () => { + printTerminalSize(); +}); + +lineReader.on('line', (line) => { + if (line === 'quit') { + process.exit(0); + } +}); + +lineReader.on('close', () => { + process.stdin.pause(); +}); + +printTerminalSize(); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..6cb0312 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,180 @@ +import { spawnSync } from 'node:child_process'; +import { readFile, readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { expect } from 'vitest'; + +const DEFAULT_CLI_TIMEOUT_MS = 30_000; + +interface CommandResult { + stdout: string; + stderr: string; + status: number | null; + exitCode: number; +} + +export interface SuccessEnvelope { + ok: true; + command: string; + timestamp: string; + result: TResult; +} + +export interface SessionRecord { + version: 1; + sessionId: string; + createdAt: string; + updatedAt: string; + status: string; + command: string[]; + cwd: string; + cols: number; + rows: number; + hostPid: number | null; + childPid: number | null; + exitCode: number | null; + exitSignal: string | null; +} + +export interface EventRecord { + seq: number; + ts: string; + type: string; + payload: Record; +} + +export interface WaitResult { + exitCode?: number; + timedOut: boolean; +} + +export function runCli( + args: string[], + env: Record = {}, + timeout = DEFAULT_CLI_TIMEOUT_MS, +): CommandResult { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', './src/cli/main.ts', ...args], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout, + }, + ); + + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status, + exitCode: result.status ?? 1, + }; +} + +export async function cleanupHome(home: string): Promise { + if (home.length === 0) { + return; + } + + try { + const sessionsDir = join(home, 'sessions'); + const entries = await readdir(sessionsDir).catch((): string[] => []); + + for (const entry of entries) { + const manifestFile = join(sessionsDir, entry, 'session.json'); + + try { + const manifest = JSON.parse( + await readFile(manifestFile, 'utf8'), + ) as Record; + + for (const pidKey of ['childPid', 'hostPid'] as const) { + const pid = manifest[pidKey]; + if (typeof pid === 'number' && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // best-effort cleanup, ignore errors + } + } + } + } catch { + // best-effort cleanup, ignore errors + } + } + } catch { + // best-effort cleanup, ignore errors + } + + await rm(home, { recursive: true, force: true }); +} + +export function createSession( + testHome: string, + command: string[] = ['/bin/sh', '-c', 'exec cat'], +): string { + const result = runCli(['create', '--json', '--', ...command], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + sessionId: string; + }>; + expect(envelope.ok).toBe(true); + return envelope.result.sessionId; +} + +export function destroySession(testHome: string, sessionId: string): void { + if (sessionId.length === 0) { + return; + } + + runCli(['destroy', sessionId, '--force', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); +} + +export function inspectSession( + testHome: string, + sessionId: string, +): SessionRecord { + const result = runCli(['inspect', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + session: SessionRecord; + }>; + expect(envelope.ok).toBe(true); + return envelope.result.session; +} + +export async function readEvents( + testHome: string, + sessionId: string, +): Promise { + const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); + const content = await readFile(eventsPath, 'utf8'); + + if (content.trim().length === 0) { + return []; + } + + return content + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as EventRecord); +} + +export async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index 43e31a4..041da69 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -1,32 +1,16 @@ -import { spawnSync } from 'node:child_process'; -import process from 'node:process'; - import { describe, expect, it } from 'vitest'; -function runCli(args: string[]): string { - const result = spawnSync( - process.execPath, - ['--import', 'tsx', './src/cli/main.ts', ...args], - { - cwd: process.cwd(), - encoding: 'utf8', - }, - ); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(''); - - return result.stdout; -} +import { runCli, type SuccessEnvelope } from '../helpers.js'; describe('CLI integration', () => { it('prints a JSON envelope for version', () => { - const stdout = runCli(['version', '--json']); - const parsed = JSON.parse(stdout) as { - ok: boolean; - command: string; - result: { cliVersion: string }; - }; + const result = runCli(['version', '--json']); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const parsed = JSON.parse(result.stdout) as SuccessEnvelope<{ + cliVersion: string; + }>; expect(parsed.ok).toBe(true); expect(parsed.command).toBe('version'); @@ -34,12 +18,13 @@ describe('CLI integration', () => { }); it('prints a JSON envelope for doctor', () => { - const stdout = runCli(['doctor', '--json']); - const parsed = JSON.parse(stdout) as { - ok: boolean; - command: string; - result: { checks: Array<{ ok: boolean; name: string }> }; - }; + const result = runCli(['doctor', '--json']); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const parsed = JSON.parse(result.stdout) as SuccessEnvelope<{ + checks: Array<{ ok: boolean; name: string }>; + }>; expect(parsed.ok).toBe(true); expect(parsed.command).toBe('doctor'); diff --git a/test/integration/event-log.test.ts b/test/integration/event-log.test.ts new file mode 100644 index 0000000..39bf9c6 --- /dev/null +++ b/test/integration/event-log.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupHome, + createSession, + destroySession, + readEvents, + runCli, + sleep, + type SuccessEnvelope, + type WaitResult, +} from '../helpers.js'; + +function runMixedActionSequence(testHome: string, sessionId: string): void { + const env = { AGENT_TERMINAL_HOME: testHome }; + + const typeResult = runCli(['type', sessionId, 'hello', '--json'], env); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + + const sendKeysResult = runCli( + ['send-keys', sessionId, 'Enter', '--json'], + env, + ); + expect(sendKeysResult.status).toBe(0); + expect(sendKeysResult.stderr).toBe(''); + + const pasteResult = runCli(['paste', sessionId, 'paste-text', '--json'], env); + expect(pasteResult.status).toBe(0); + expect(pasteResult.stderr).toBe(''); + + const resizeResult = runCli( + ['resize', sessionId, '--cols', '100', '--rows', '30', '--json'], + env, + ); + expect(resizeResult.status).toBe(0); + expect(resizeResult.stderr).toBe(''); + + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '500', '--timeout', '5000', '--json'], + env, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(false); +} + +let testHome = ''; + +describe('event-log integration', { timeout: 30000 }, () => { + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('mixed action sequence has monotonic seq', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + runMixedActionSequence(testHome, sessionId); + await sleep(300); + + const events = await readEvents(testHome, sessionId); + expect(events.length).toBeGreaterThan(0); + expect(events.map((event) => event.seq)).toEqual( + events.map((_, index) => index), + ); + + const eventTypes = new Set(events.map((event) => event.type)); + expect([...eventTypes]).toEqual( + expect.arrayContaining([ + 'input_text', + 'input_keys', + 'input_paste', + 'resize', + 'output', + ]), + ); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('all event records validate against expected structure', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + runMixedActionSequence(testHome, sessionId); + await sleep(300); + + const events = await readEvents(testHome, sessionId); + expect(events.length).toBeGreaterThan(0); + + for (const event of events) { + expect(typeof event.seq).toBe('number'); + expect(Number.isInteger(event.seq)).toBe(true); + expect(event.seq).toBeGreaterThanOrEqual(0); + expect(typeof event.ts).toBe('string'); + expect(new Date(event.ts).toISOString()).toBe(event.ts); + expect(typeof event.type).toBe('string'); + expect(event.type.length).toBeGreaterThan(0); + expect(typeof event.payload).toBe('object'); + expect(event.payload).not.toBeNull(); + expect(Array.isArray(event.payload)).toBe(false); + } + } finally { + destroySession(testHome, sessionId); + } + }); +}); diff --git a/test/integration/io-loop.test.ts b/test/integration/io-loop.test.ts new file mode 100644 index 0000000..7005ae5 --- /dev/null +++ b/test/integration/io-loop.test.ts @@ -0,0 +1,250 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupHome, + createSession, + destroySession, + inspectSession, + readEvents, + runCli, + sleep, + type SuccessEnvelope, + type WaitResult, +} from '../helpers.js'; + +let testHome = ''; + +describe('io-loop integration', { timeout: 30000 }, () => { + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('type + send-keys Enter + wait --idle-ms produces output', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exec /bin/sh']); + await sleep(500); + + const typeResult = runCli( + ['type', sessionId, 'echo test-marker', '--json'], + { + AGENT_TERMINAL_HOME: testHome, + }, + ); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + + const sendKeysResult = runCli( + ['send-keys', sessionId, 'Enter', '--json'], + { + AGENT_TERMINAL_HOME: testHome, + }, + ); + expect(sendKeysResult.status).toBe(0); + expect(sendKeysResult.stderr).toBe(''); + + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '500', '--timeout', '5000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(false); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const allOutput = events + .filter((event) => event.type === 'output') + .map((event) => { + const data = event.payload.data; + return typeof data === 'string' ? data : ''; + }) + .join(''); + expect(allOutput).toContain('test-marker'); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('wait --idle-ms measures idle from call time, not host startup', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exec cat']); + await sleep(2000); + + const start = Date.now(); + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '1000', '--timeout', '5000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + const elapsed = Date.now() - start; + + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(false); + expect(elapsed).toBeGreaterThan(800); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('wait --idle-ms times out when timeout expires first', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exec cat']); + await sleep(500); + + const typeResult = runCli(['type', sessionId, 'keepalive', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + + const sendKeysResult = runCli( + ['send-keys', sessionId, 'Enter', '--json'], + { + AGENT_TERMINAL_HOME: testHome, + }, + ); + expect(sendKeysResult.status).toBe(0); + expect(sendKeysResult.stderr).toBe(''); + + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '60000', '--timeout', '300', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(true); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('signal SIGTERM terminates session', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const signalResult = runCli(['signal', sessionId, 'SIGTERM', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(signalResult.status).toBe(0); + expect(signalResult.stderr).toBe(''); + const signalEnvelope = JSON.parse( + signalResult.stdout, + ) as SuccessEnvelope<{ + signal: string; + delivered: boolean; + }>; + expect(signalEnvelope.ok).toBe(true); + expect(signalEnvelope.result).toEqual({ + signal: 'SIGTERM', + delivered: true, + }); + + const waitResult = runCli( + ['wait', sessionId, '--exit', '--timeout', '5000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const waitEnvelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; + expect(waitEnvelope.ok).toBe(true); + expect(waitEnvelope.result.timedOut).toBe(false); + + await sleep(300); + + const session = inspectSession(testHome, sessionId); + expect(session.status).toBe('exited'); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('wait --exit returns exit code for a short-lived command', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exit 42']); + await sleep(700); + + const waitResult = runCli( + ['wait', sessionId, '--exit', '--timeout', '5000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.exitCode).toBe(42); + expect(envelope.result.timedOut).toBe(false); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('wait --exit returns for an already-exited session', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exit 0']); + await sleep(700); + + const session = inspectSession(testHome, sessionId); + expect(session.status).toBe('exited'); + + const waitResult = runCli( + ['wait', sessionId, '--exit', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.exitCode).toBe(0); + expect(envelope.result.timedOut).toBe(false); + } finally { + destroySession(testHome, sessionId); + } + }); +}); diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts new file mode 100644 index 0000000..5afb508 --- /dev/null +++ b/test/integration/lifecycle.test.ts @@ -0,0 +1,213 @@ +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupHome, + runCli, + type EventRecord, + type SessionRecord, + type SuccessEnvelope, +} from '../helpers.js'; + +interface ErrorEnvelope { + ok: false; + command: string; + error: { + code: string; + message: string; + retryable: boolean; + details: Record; + }; +} + +interface SessionSummary { + sessionId: string; + status: string; + command: string[]; + createdAt: string; +} + +let testHome = ''; + +describe('lifecycle integration', { timeout: 30000 }, () => { + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('full lifecycle: create → list → inspect → destroy', () => { + const createResult = runCli( + ['create', '--json', '--', '/bin/sh', '-c', 'echo ready; sleep 30'], + { AGENT_TERMINAL_HOME: testHome }, + ); + expect(createResult.status).toBe(0); + expect(createResult.stderr).toBe(''); + const createEnvelope = JSON.parse(createResult.stdout) as SuccessEnvelope<{ + sessionId: string; + }>; + expect(createEnvelope.ok).toBe(true); + const sessionId = createEnvelope.result.sessionId; + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); + + const listResult = runCli(['list', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(listResult.status).toBe(0); + expect(listResult.stderr).toBe(''); + const listEnvelope = JSON.parse(listResult.stdout) as SuccessEnvelope<{ + sessions: SessionSummary[]; + }>; + expect(listEnvelope.ok).toBe(true); + expect(listEnvelope.result.sessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sessionId, status: 'running' }), + ]), + ); + + const inspectResult = runCli(['inspect', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(inspectResult.status).toBe(0); + expect(inspectResult.stderr).toBe(''); + const inspectEnvelope = JSON.parse( + inspectResult.stdout, + ) as SuccessEnvelope<{ + session: SessionRecord; + }>; + expect(inspectEnvelope.ok).toBe(true); + expect(inspectEnvelope.result.session.sessionId).toBe(sessionId); + expect(inspectEnvelope.result.session.status).toBe('running'); + expect(inspectEnvelope.result.session.hostPid).toBeTypeOf('number'); + expect(inspectEnvelope.result.session.childPid).toBeTypeOf('number'); + + const destroyResult = runCli(['destroy', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(destroyResult.status).toBe(0); + expect(destroyResult.stderr).toBe(''); + const destroyEnvelope = JSON.parse( + destroyResult.stdout, + ) as SuccessEnvelope<{ + sessionId: string; + destroyed: boolean; + }>; + expect(destroyEnvelope.ok).toBe(true); + expect(destroyEnvelope.result.destroyed).toBe(true); + }); + + it('exited sessions hidden by default list, visible with --all', () => { + const createResult = runCli( + ['create', '--json', '--', '/bin/sh', '-c', 'echo done; sleep 30'], + { AGENT_TERMINAL_HOME: testHome }, + ); + expect(createResult.status).toBe(0); + expect(createResult.stderr).toBe(''); + const sessionId = ( + JSON.parse(createResult.stdout) as SuccessEnvelope<{ sessionId: string }> + ).result.sessionId; + + const destroyResult = runCli(['destroy', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(destroyResult.status).toBe(0); + expect(destroyResult.stderr).toBe(''); + + const listDefault = runCli(['list', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(listDefault.status).toBe(0); + expect(listDefault.stderr).toBe(''); + const defaultSessions = ( + JSON.parse(listDefault.stdout) as SuccessEnvelope<{ + sessions: SessionSummary[]; + }> + ).result.sessions; + expect( + defaultSessions.find((session) => session.sessionId === sessionId), + ).toBeUndefined(); + + const listAll = runCli(['list', '--all', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(listAll.status).toBe(0); + expect(listAll.stderr).toBe(''); + const allSessions = ( + JSON.parse(listAll.stdout) as SuccessEnvelope<{ + sessions: SessionSummary[]; + }> + ).result.sessions; + expect(allSessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sessionId, status: 'exited' }), + ]), + ); + }); + + it('inspect nonexistent session returns SESSION_NOT_FOUND', () => { + const result = runCli(['inspect', 'NONEXISTENT', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as ErrorEnvelope; + expect(envelope.ok).toBe(false); + expect(envelope.error.code).toBe('SESSION_NOT_FOUND'); + }); + + it('event log contains output and exit records', async () => { + const createResult = runCli( + [ + 'create', + '--json', + '--', + '/bin/sh', + '-c', + 'echo marker-test-output; exit 0', + ], + { AGENT_TERMINAL_HOME: testHome }, + ); + expect(createResult.status).toBe(0); + expect(createResult.stderr).toBe(''); + const sessionId = ( + JSON.parse(createResult.stdout) as SuccessEnvelope<{ sessionId: string }> + ).result.sessionId; + + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + + const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); + const eventContent = await readFile(eventsPath, 'utf8'); + const events = eventContent + .trim() + .split('\n') + .map((line) => JSON.parse(line) as EventRecord); + + const outputEvents = events.filter((event) => event.type === 'output'); + expect(outputEvents.length).toBeGreaterThan(0); + + const allOutput = outputEvents + .map((event) => { + const data = event.payload.data; + return typeof data === 'string' ? data : ''; + }) + .join(''); + expect(allOutput).toContain('marker-test-output'); + + const exitEvents = events.filter((event) => event.type === 'exit'); + expect(exitEvents.length).toBe(1); + expect(exitEvents[0]?.payload.exitCode).toBe(0); + + const destroyResult = runCli(['destroy', sessionId, '--force', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(destroyResult.status).toBe(0); + }); +}); diff --git a/test/integration/pty-basics.test.ts b/test/integration/pty-basics.test.ts new file mode 100644 index 0000000..59471fe --- /dev/null +++ b/test/integration/pty-basics.test.ts @@ -0,0 +1,161 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupHome, + createSession, + destroySession, + inspectSession, + readEvents, + runCli, + sleep, + type SuccessEnvelope, +} from '../helpers.js'; + +let testHome = ''; + +describe('pty-basics integration', { timeout: 30000 }, () => { + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('type writes and records input_text', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const typeResult = runCli(['type', sessionId, 'hello', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + const envelope = JSON.parse(typeResult.stdout) as SuccessEnvelope< + Record + >; + expect(envelope.ok).toBe(true); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const inputTextEvents = events.filter( + (event) => event.type === 'input_text', + ); + expect(inputTextEvents.length).toBeGreaterThan(0); + expect(inputTextEvents[0]?.payload.data).toBe('hello'); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('send-keys Enter records input_keys', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const sendKeysResult = runCli( + ['send-keys', sessionId, 'Enter', '--json'], + { + AGENT_TERMINAL_HOME: testHome, + }, + ); + expect(sendKeysResult.status).toBe(0); + expect(sendKeysResult.stderr).toBe(''); + const envelope = JSON.parse(sendKeysResult.stdout) as SuccessEnvelope< + Record + >; + expect(envelope.ok).toBe(true); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const inputKeyEvents = events.filter( + (event) => event.type === 'input_keys', + ); + expect(inputKeyEvents.length).toBeGreaterThan(0); + expect(inputKeyEvents[0]?.payload.keys).toEqual(['Enter']); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('paste records input_paste with bracketed paste markers', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const pasteResult = runCli(['paste', sessionId, 'test-text', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(pasteResult.status).toBe(0); + expect(pasteResult.stderr).toBe(''); + const envelope = JSON.parse(pasteResult.stdout) as SuccessEnvelope< + Record + >; + expect(envelope.ok).toBe(true); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const inputPasteEvents = events.filter( + (event) => event.type === 'input_paste', + ); + expect(inputPasteEvents.length).toBeGreaterThan(0); + + const data = inputPasteEvents[0]?.payload.data; + expect(typeof data).toBe('string'); + expect(data).toContain('\u001b[200~'); + expect(data).toContain('test-text'); + expect(data).toContain('\u001b[201~'); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('resize records resize and inspect reflects new dimensions', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const resizeResult = runCli( + ['resize', sessionId, '--cols', '120', '--rows', '40', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + ); + expect(resizeResult.status).toBe(0); + expect(resizeResult.stderr).toBe(''); + const envelope = JSON.parse(resizeResult.stdout) as SuccessEnvelope<{ + cols: number; + rows: number; + }>; + expect(envelope.ok).toBe(true); + expect(envelope.result).toEqual({ cols: 120, rows: 40 }); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const resizeEvents = events.filter((event) => event.type === 'resize'); + expect(resizeEvents.length).toBeGreaterThan(0); + expect(resizeEvents[0]?.payload).toEqual({ cols: 120, rows: 40 }); + + const session = inspectSession(testHome, sessionId); + expect(session.cols).toBe(120); + expect(session.rows).toBe(40); + } finally { + destroySession(testHome, sessionId); + } + }); +}); diff --git a/test/unit/protocol/messages.test.ts b/test/unit/protocol/messages.test.ts new file mode 100644 index 0000000..c3288a3 --- /dev/null +++ b/test/unit/protocol/messages.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest'; + +import { + DestroyParamsSchema, + InspectResultSchema, + PasteParamsSchema, + ResizeResultSchema, + RpcMethodSchemas, + RpcRequestSchema, + RpcResponseSchema, + SendKeysParamsSchema, + TypeParamsSchema, + WaitParamsSchema, + WaitResultSchema, +} from '../../../src/protocol/messages.js'; +import { + EventRecordSchema, + SessionRecordSchema, +} from '../../../src/protocol/schemas.js'; + +function createSessionRecord() { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status: 'running' as const, + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: 123, + childPid: 456, + exitCode: null, + exitSignal: null, + }; +} + +describe('protocol schemas', () => { + it('accepts a valid session record', () => { + const result = SessionRecordSchema.safeParse(createSessionRecord()); + + expect(result.success).toBe(true); + }); + + it('rejects a session record with invalid dimensions', () => { + const result = SessionRecordSchema.safeParse({ + ...createSessionRecord(), + cols: 0, + }); + + expect(result.success).toBe(false); + }); + + it('accepts a valid event record', () => { + const result = EventRecordSchema.safeParse({ + seq: 0, + ts: '2026-03-19T12:00:02.000Z', + type: 'resize', + payload: { cols: 120, rows: 40 }, + }); + + expect(result.success).toBe(true); + }); + + it('rejects an event record with a negative sequence', () => { + const result = EventRecordSchema.safeParse({ + seq: -1, + ts: '2026-03-19T12:00:02.000Z', + type: 'resize', + payload: {}, + }); + + expect(result.success).toBe(false); + }); +}); + +describe('RPC message schemas', () => { + it('accepts a base RPC request', () => { + const result = RpcRequestSchema.safeParse({ + id: 'request-1', + method: 'resize', + params: { cols: 80, rows: 24 }, + }); + + expect(result.success).toBe(true); + }); + + it('rejects a request with a non-object params payload', () => { + const result = RpcRequestSchema.safeParse({ + id: 'request-1', + method: 'resize', + params: 'bad', + }); + + expect(result.success).toBe(false); + }); + + it('accepts success and error responses', () => { + expect( + RpcResponseSchema.safeParse({ + id: 'request-1', + ok: true, + result: {}, + }).success, + ).toBe(true); + expect( + RpcResponseSchema.safeParse({ + id: 'request-1', + ok: false, + error: { + code: 'HOST_TIMEOUT', + message: 'Session host timed out.', + }, + }).success, + ).toBe(true); + }); + + it('rejects an error response without a message', () => { + const result = RpcResponseSchema.safeParse({ + id: 'request-1', + ok: false, + error: { + code: 'HOST_TIMEOUT', + }, + }); + + expect(result.success).toBe(false); + }); + + it('validates inspect results against the session schema', () => { + const result = InspectResultSchema.safeParse({ + session: createSessionRecord(), + }); + + expect(result.success).toBe(true); + }); + + it('rejects empty key arrays for sendKeys', () => { + const result = SendKeysParamsSchema.safeParse({ + keys: [], + }); + + expect(result.success).toBe(false); + }); + + it('rejects empty paste text', () => { + const result = PasteParamsSchema.safeParse({ + text: '', + }); + + expect(result.success).toBe(false); + }); + + it('rejects empty type text', () => { + const result = TypeParamsSchema.safeParse({ + text: '', + }); + + expect(result.success).toBe(false); + }); + + it('rejects zero-valued wait durations', () => { + expect( + WaitParamsSchema.safeParse({ + idleMs: 0, + }).success, + ).toBe(false); + expect( + WaitParamsSchema.safeParse({ + timeoutMs: 0, + }).success, + ).toBe(false); + }); + + it('accepts resize results with positive dimensions', () => { + const result = ResizeResultSchema.safeParse({ + cols: 120, + rows: 40, + }); + + expect(result.success).toBe(true); + }); + + it('rejects resize results without positive dimensions', () => { + expect(ResizeResultSchema.safeParse({}).success).toBe(false); + expect( + ResizeResultSchema.safeParse({ + cols: 0, + rows: 40, + }).success, + ).toBe(false); + }); + + it('rejects invalid wait result exit codes', () => { + const result = WaitResultSchema.safeParse({ + exitCode: 2.5, + timedOut: false, + }); + + expect(result.success).toBe(false); + }); + + it('accepts destroy params with an optional force flag', () => { + expect(DestroyParamsSchema.safeParse({}).success).toBe(true); + expect(DestroyParamsSchema.safeParse({ force: true }).success).toBe(true); + }); + + it('exposes method schemas for every Week 1 RPC method', () => { + expect(Object.keys(RpcMethodSchemas)).toEqual([ + 'inspect', + 'type', + 'paste', + 'sendKeys', + 'resize', + 'signal', + 'wait', + 'destroy', + ]); + }); +}); diff --git a/test/unit/pty/keyEncoder.test.ts b/test/unit/pty/keyEncoder.test.ts new file mode 100644 index 0000000..d1d8d61 --- /dev/null +++ b/test/unit/pty/keyEncoder.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { encodeKey } from '../../../src/pty/keyEncoder.js'; + +describe('encodeKey', () => { + it('encodes Enter', () => { + expect(encodeKey('Enter')).toBe('\r'); + }); + + it('encodes Tab', () => { + expect(encodeKey('Tab')).toBe('\t'); + }); + + it('encodes Escape', () => { + expect(encodeKey('Escape')).toBe('\x1b'); + }); + + it('encodes Backspace', () => { + expect(encodeKey('Backspace')).toBe('\x7f'); + }); + + it('encodes Space', () => { + expect(encodeKey('Space')).toBe(' '); + }); + + it('encodes ctrl+c', () => { + expect(encodeKey('ctrl+c')).toBe('\x03'); + }); + + it('encodes ctrl+a', () => { + expect(encodeKey('ctrl+a')).toBe('\x01'); + }); + + it('encodes ctrl+z', () => { + expect(encodeKey('ctrl+z')).toBe('\x1a'); + }); + + it('encodes ctrl+C case-insensitively', () => { + expect(encodeKey('ctrl+C')).toBe('\x03'); + }); + + it('encodes alt+x', () => { + expect(encodeKey('alt+x')).toBe('\x1bx'); + }); + + it('encodes Up', () => { + expect(encodeKey('Up')).toBe('\x1b[A'); + }); + + it('encodes Down', () => { + expect(encodeKey('Down')).toBe('\x1b[B'); + }); + + it('encodes Right', () => { + expect(encodeKey('Right')).toBe('\x1b[C'); + }); + + it('encodes Left', () => { + expect(encodeKey('Left')).toBe('\x1b[D'); + }); + + it('encodes shift+Up', () => { + expect(encodeKey('shift+Up')).toBe('\x1b[1;2A'); + }); + + it('encodes ctrl+Up', () => { + expect(encodeKey('ctrl+Up')).toBe('\x1b[1;5A'); + }); + + it('encodes ctrl+shift+Up', () => { + expect(encodeKey('ctrl+shift+Up')).toBe('\x1b[1;6A'); + }); + + it('encodes alt+Up', () => { + expect(encodeKey('alt+Up')).toBe('\x1b[1;3A'); + }); + + it('encodes F1', () => { + expect(encodeKey('F1')).toBe('\x1bOP'); + }); + + it('encodes F2', () => { + expect(encodeKey('F2')).toBe('\x1bOQ'); + }); + + it('encodes F3', () => { + expect(encodeKey('F3')).toBe('\x1bOR'); + }); + + it('encodes F4', () => { + expect(encodeKey('F4')).toBe('\x1bOS'); + }); + + it('encodes F5', () => { + expect(encodeKey('F5')).toBe('\x1b[15~'); + }); + + it('encodes F12', () => { + expect(encodeKey('F12')).toBe('\x1b[24~'); + }); + + it('encodes Home', () => { + expect(encodeKey('Home')).toBe('\x1b[H'); + }); + + it('encodes End', () => { + expect(encodeKey('End')).toBe('\x1b[F'); + }); + + it('encodes Delete', () => { + expect(encodeKey('Delete')).toBe('\x1b[3~'); + }); + + it('encodes Insert', () => { + expect(encodeKey('Insert')).toBe('\x1b[2~'); + }); + + it('encodes PageUp', () => { + expect(encodeKey('PageUp')).toBe('\x1b[5~'); + }); + + it('encodes PageDown', () => { + expect(encodeKey('PageDown')).toBe('\x1b[6~'); + }); + + it('encodes single char a', () => { + expect(encodeKey('a')).toBe('a'); + }); + + it('encodes single char Z', () => { + expect(encodeKey('Z')).toBe('Z'); + }); + + it('throws on an unknown key', () => { + expect(() => encodeKey('BOGUS')).toThrow(); + }); + + it('throws on an empty string', () => { + expect(() => encodeKey('')).toThrow(); + }); + + it('throws on a duplicate modifier', () => { + expect(() => encodeKey('ctrl+ctrl+a')).toThrow(); + }); +}); diff --git a/test/unit/storage/sessionPaths.test.ts b/test/unit/storage/sessionPaths.test.ts new file mode 100644 index 0000000..94f2e92 --- /dev/null +++ b/test/unit/storage/sessionPaths.test.ts @@ -0,0 +1,125 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { SessionRecord } from '../../../src/protocol/schemas.js'; +import { + manifestPath, + eventLogPath, + sessionDir, + socketPath, +} from '../../../src/storage/sessionPaths.js'; +import { + readManifest, + readManifestIfExists, + writeManifest, +} from '../../../src/storage/manifests.js'; + +function createSessionRecord(): SessionRecord { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status: 'running', + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: 123, + childPid: 456, + exitCode: null, + exitSignal: null, + }; +} + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe('session paths', () => { + it('builds session-specific absolute paths', () => { + const home = '/tmp/agent-terminal-home'; + const directory = sessionDir(home, 'session-01'); + + expect(directory).toBe('/tmp/agent-terminal-home/sessions/session-01'); + expect(manifestPath(directory)).toBe( + '/tmp/agent-terminal-home/sessions/session-01/session.json', + ); + expect(eventLogPath(directory)).toBe( + '/tmp/agent-terminal-home/sessions/session-01/events.jsonl', + ); + expect(socketPath(directory)).toBe( + '/tmp/agent-terminal-home/sessions/session-01/host.sock', + ); + }); + + it('asserts on invalid path helper inputs', () => { + expect(() => sessionDir('', 'session-01')).toThrow( + /home must be a non-empty string/u, + ); + expect(() => sessionDir('relative/home', 'session-01')).toThrow( + /home must be an absolute path/u, + ); + expect(() => sessionDir('/tmp/home', '')).toThrow( + /sessionId must be a non-empty string/u, + ); + expect(() => sessionDir('/tmp/home', '../oops')).toThrow( + /path separators/u, + ); + expect(() => manifestPath('relative/path')).toThrow( + /sessionDir must be an absolute path/u, + ); + }); +}); + +describe('manifest storage', () => { + it('writes and reads manifests with validation', async () => { + const home = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + temporaryDirectories.push(home); + const path = manifestPath(sessionDir(home, 'session-01')); + const record = createSessionRecord(); + + await writeManifest(path, record); + + const roundTripped = await readManifest(path); + + expect(roundTripped).toEqual(record); + }); + + it('returns null when a manifest does not exist', async () => { + const home = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + temporaryDirectories.push(home); + const path = manifestPath(sessionDir(home, 'missing-session')); + + await expect(readManifestIfExists(path)).resolves.toBeNull(); + }); + + it('rejects invalid manifest contents during reads', async () => { + const home = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + temporaryDirectories.push(home); + const path = manifestPath(sessionDir(home, 'session-01')); + + await writeManifest(path, createSessionRecord()); + await writeFile( + path, + JSON.stringify({ + ...createSessionRecord(), + rows: 0, + }), + 'utf8', + ); + + await expect(readManifest(path)).rejects.toMatchObject({ + code: 'MANIFEST_VALIDATION_ERROR', + }); + }); +});