From 54e0b26eacf793002e1279011120813e2a6496c8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 29 Apr 2026 19:00:58 +0000 Subject: [PATCH] refactor: centralize command target resolution --- CONTEXT.md | 11 +- .../01-create-main.json | 12 + dogfood/issue-63-command-target/02-run.json | 13 + dogfood/issue-63-command-target/03-type.json | 6 + .../04-wait-typed.json | 13 + dogfood/issue-63-command-target/05-paste.json | 6 + .../issue-63-command-target/06-send-keys.json | 10 + .../07-wait-pasted.json | 13 + dogfood/issue-63-command-target/08-mark.json | 8 + .../issue-63-command-target/09-resize.json | 9 + .../10-wait-stable.json | 12 + .../11-create-signal.json | 12 + .../issue-63-command-target/12-signal.json | 9 + .../13-snapshot-text.json | 15 ++ .../14-screenshot.json | 20 ++ .../15-record-asciicast.json | 23 ++ .../16-record-webm.json | 24 ++ .../98-destroy-signal.json | 9 + .../99-destroy-main.json | 9 + dogfood/issue-63-command-target/README.md | 23 ++ .../agent-tty-home.txt | 1 + dogfood/issue-63-command-target/commands.sh | 115 +++++++++ .../main-session-events.jsonl | 15 ++ .../main-session-id.txt | 1 + .../main-session-manifest.json | 20 ++ .../issue-63-command-target/screenshot.png | Bin 0 -> 18355 bytes dogfood/issue-63-command-target/session.cast | 11 + dogfood/issue-63-command-target/session.webm | Bin 0 -> 22955 bytes .../signal-session-id.txt | 1 + .../issue-63-command-target/transcript.txt | 239 ++++++++++++++++++ src/cli/commandTarget.ts | 73 ++++++ src/cli/commands/mark.ts | 38 +-- src/cli/commands/paste.ts | 40 +-- src/cli/commands/resize.ts | 30 +-- src/cli/commands/run.ts | 39 +-- src/cli/commands/send-keys.ts | 38 +-- src/cli/commands/signal.ts | 30 +-- src/cli/commands/type.ts | 40 +-- test/helpers.ts | 22 ++ test/integration/cli.test.ts | 49 ++++ test/unit/cli/commandTarget.test.ts | 118 +++++++++ test/unit/commands/mark.test.ts | 152 +++-------- test/unit/commands/paste.test.ts | 84 ++++++ test/unit/commands/resize.test.ts | 97 +++++++ test/unit/commands/run.test.ts | 141 ++++------- test/unit/commands/send-keys.test.ts | 101 ++++++++ test/unit/commands/signal.test.ts | 96 +++++++ test/unit/commands/type.test.ts | 124 ++++----- 48 files changed, 1478 insertions(+), 494 deletions(-) create mode 100644 dogfood/issue-63-command-target/01-create-main.json create mode 100644 dogfood/issue-63-command-target/02-run.json create mode 100644 dogfood/issue-63-command-target/03-type.json create mode 100644 dogfood/issue-63-command-target/04-wait-typed.json create mode 100644 dogfood/issue-63-command-target/05-paste.json create mode 100644 dogfood/issue-63-command-target/06-send-keys.json create mode 100644 dogfood/issue-63-command-target/07-wait-pasted.json create mode 100644 dogfood/issue-63-command-target/08-mark.json create mode 100644 dogfood/issue-63-command-target/09-resize.json create mode 100644 dogfood/issue-63-command-target/10-wait-stable.json create mode 100644 dogfood/issue-63-command-target/11-create-signal.json create mode 100644 dogfood/issue-63-command-target/12-signal.json create mode 100644 dogfood/issue-63-command-target/13-snapshot-text.json create mode 100644 dogfood/issue-63-command-target/14-screenshot.json create mode 100644 dogfood/issue-63-command-target/15-record-asciicast.json create mode 100644 dogfood/issue-63-command-target/16-record-webm.json create mode 100644 dogfood/issue-63-command-target/98-destroy-signal.json create mode 100644 dogfood/issue-63-command-target/99-destroy-main.json create mode 100644 dogfood/issue-63-command-target/README.md create mode 100644 dogfood/issue-63-command-target/agent-tty-home.txt create mode 100755 dogfood/issue-63-command-target/commands.sh create mode 100644 dogfood/issue-63-command-target/main-session-events.jsonl create mode 100644 dogfood/issue-63-command-target/main-session-id.txt create mode 100644 dogfood/issue-63-command-target/main-session-manifest.json create mode 100644 dogfood/issue-63-command-target/screenshot.png create mode 100644 dogfood/issue-63-command-target/session.cast create mode 100644 dogfood/issue-63-command-target/session.webm create mode 100644 dogfood/issue-63-command-target/signal-session-id.txt create mode 100644 dogfood/issue-63-command-target/transcript.txt create mode 100644 src/cli/commandTarget.ts create mode 100644 test/unit/cli/commandTarget.test.ts create mode 100644 test/unit/commands/paste.test.ts create mode 100644 test/unit/commands/resize.test.ts create mode 100644 test/unit/commands/send-keys.test.ts create mode 100644 test/unit/commands/signal.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 067f104..9be3070 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,6 +23,10 @@ _Avoid_: Finished session **Commandable Session**: A **Session** that can still accept user input and control commands. +**Command Target**: +The **Commandable Session** selected and verified as eligible to receive an input or control command. +_Avoid_: Running Session Target + **Waited Run**: A run request where the caller asks `agent-tty` to wait until the command's completion signal is observed. _Avoid_: Blocking run @@ -78,6 +82,7 @@ The tag-triggered automation that validates, packages, and publishes a release a - An **Offline Replay Eligible Session** is reconstructed from its persisted **Event Log** and manifest. - A **Snapshot Result** is derived from exactly one **Semantic Snapshot**. - A **Snapshot Artifact** contains exactly the **Snapshot Result** emitted to the caller. +- A **Command Target** is exactly one **Commandable Session** selected by an input or control command. - A **Commandable Session** can accept a **Waited Run**. - A **Waited Run** may produce one **Run Completion**, time out for its caller, or be interrupted by **Session** exit. - Caller timeout does not cancel the underlying **Run Completion**; it may still be observed later to keep internal completion bytes out of artifacts. @@ -93,7 +98,10 @@ The tag-triggered automation that validates, packages, and publishes a release a > **Domain expert:** "No. It is still an **Active Session**, even though renderer inspection should use **Offline Replay** instead of the live host." > **Dev:** "Does **Snapshot Capture** ask the renderer for terminal state?" -> **Domain expert:** "No — the renderer first produces a **Semantic Snapshot**; **Snapshot Capture** derives and records the public result from that snapshot." +> **Domain expert:** "No. The renderer first produces a **Semantic Snapshot**; **Snapshot Capture** derives and records the public result from that snapshot." + +> **Dev:** "Should `snapshot` resolve a **Command Target**?" +> **Domain expert:** "No. A **Command Target** is for commands that must send input or control to a **Commandable Session**, not inspection or replay operations." > **Dev:** "If a **Waited Run** times out, did the command finish?" > **Domain expert:** "No. The caller stopped waiting, but the **Run Completion** may still arrive later and must still be recognized." @@ -101,3 +109,4 @@ The tag-triggered automation that validates, packages, and publishes a release a ## Flagged ambiguities - "Active" and "offline replay eligible" are independent classifications: `destroying` is both **Active** and **Offline Replay Eligible**. +- "Running Session Target" was used during design discussion, but the canonical term is **Command Target** because commandability is the policy being resolved. diff --git a/dogfood/issue-63-command-target/01-create-main.json b/dogfood/issue-63-command-target/01-create-main.json new file mode 100644 index 0000000..27be5a8 --- /dev/null +++ b/dogfood/issue-63-command-target/01-create-main.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-29T19:39:13.331Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "createdAt": "2026-04-29T19:39:12.504Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/issue-63-command-target/02-run.json b/dogfood/issue-63-command-target/02-run.json new file mode 100644 index 0000000..7cef3bb --- /dev/null +++ b/dogfood/issue-63-command-target/02-run.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "run", + "timestamp": "2026-04-29T19:39:14.891Z", + "result": { + "accepted": true, + "completed": true, + "timedOut": false, + "seq": 1, + "durationMs": 3, + "marker": "__AT_MARKER_b90108d5edac4369a6f43da422783394__" + } +} diff --git a/dogfood/issue-63-command-target/03-type.json b/dogfood/issue-63-command-target/03-type.json new file mode 100644 index 0000000..a35ce88 --- /dev/null +++ b/dogfood/issue-63-command-target/03-type.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-04-29T19:39:15.990Z", + "result": {} +} diff --git a/dogfood/issue-63-command-target/04-wait-typed.json b/dogfood/issue-63-command-target/04-wait-typed.json new file mode 100644 index 0000000..dca3f39 --- /dev/null +++ b/dogfood/issue-63-command-target/04-wait-typed.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T19:39:17.293Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "typed issue 63", + "cursorRow": 4, + "cursorCol": 10, + "capturedAtSeq": 7 + } +} diff --git a/dogfood/issue-63-command-target/05-paste.json b/dogfood/issue-63-command-target/05-paste.json new file mode 100644 index 0000000..2bc4a7e --- /dev/null +++ b/dogfood/issue-63-command-target/05-paste.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "paste", + "timestamp": "2026-04-29T19:39:18.396Z", + "result": {} +} diff --git a/dogfood/issue-63-command-target/06-send-keys.json b/dogfood/issue-63-command-target/06-send-keys.json new file mode 100644 index 0000000..ba3dedf --- /dev/null +++ b/dogfood/issue-63-command-target/06-send-keys.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-04-29T19:39:19.570Z", + "result": { + "accepted": ["Enter"], + "bytesWritten": 1, + "seq": 10 + } +} diff --git a/dogfood/issue-63-command-target/07-wait-pasted.json b/dogfood/issue-63-command-target/07-wait-pasted.json new file mode 100644 index 0000000..4324351 --- /dev/null +++ b/dogfood/issue-63-command-target/07-wait-pasted.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T19:39:20.827Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "pasted issue 63", + "cursorRow": 6, + "cursorCol": 10, + "capturedAtSeq": 11 + } +} diff --git a/dogfood/issue-63-command-target/08-mark.json b/dogfood/issue-63-command-target/08-mark.json new file mode 100644 index 0000000..10b5653 --- /dev/null +++ b/dogfood/issue-63-command-target/08-mark.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "mark", + "timestamp": "2026-04-29T19:39:21.913Z", + "result": { + "seq": 12 + } +} diff --git a/dogfood/issue-63-command-target/09-resize.json b/dogfood/issue-63-command-target/09-resize.json new file mode 100644 index 0000000..2d18635 --- /dev/null +++ b/dogfood/issue-63-command-target/09-resize.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "resize", + "timestamp": "2026-04-29T19:39:23.025Z", + "result": { + "cols": 100, + "rows": 30 + } +} diff --git a/dogfood/issue-63-command-target/10-wait-stable.json b/dogfood/issue-63-command-target/10-wait-stable.json new file mode 100644 index 0000000..7d89324 --- /dev/null +++ b/dogfood/issue-63-command-target/10-wait-stable.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T19:39:24.720Z", + "result": { + "matched": true, + "timedOut": false, + "cursorRow": 6, + "cursorCol": 10, + "capturedAtSeq": 14 + } +} diff --git a/dogfood/issue-63-command-target/11-create-signal.json b/dogfood/issue-63-command-target/11-create-signal.json new file mode 100644 index 0000000..6b6ff69 --- /dev/null +++ b/dogfood/issue-63-command-target/11-create-signal.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-29T19:39:26.433Z", + "result": { + "sessionId": "01KQDC1Q4BBGBZNZJ8EECKQPQ9", + "createdAt": "2026-04-29T19:39:25.710Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/issue-63-command-target/12-signal.json b/dogfood/issue-63-command-target/12-signal.json new file mode 100644 index 0000000..8df297e --- /dev/null +++ b/dogfood/issue-63-command-target/12-signal.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "signal", + "timestamp": "2026-04-29T19:39:27.581Z", + "result": { + "signal": "SIGUSR1", + "delivered": true + } +} diff --git a/dogfood/issue-63-command-target/13-snapshot-text.json b/dogfood/issue-63-command-target/13-snapshot-text.json new file mode 100644 index 0000000..6d01204 --- /dev/null +++ b/dogfood/issue-63-command-target/13-snapshot-text.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-04-29T19:39:28.672Z", + "result": { + "format": "text", + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "capturedAtSeq": 14, + "cols": 100, + "rows": 30, + "cursorRow": 6, + "cursorCol": 10, + "text": "bash-5.1$ printf 'hello issue 63\\n'\nhello issue 63\nbash-5.1$ echo typed issue 63\ntyped issue 63\nbash-5.1$ echo pasted issue 63\npasted issue 63\nbash-5.1$\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + } +} diff --git a/dogfood/issue-63-command-target/14-screenshot.json b/dogfood/issue-63-command-target/14-screenshot.json new file mode 100644 index 0000000..dd665a5 --- /dev/null +++ b/dogfood/issue-63-command-target/14-screenshot.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-04-29T19:39:29.863Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "capturedAtSeq": 14, + "profileName": "reference-dark", + "cols": 100, + "rows": 30, + "artifactPath": "/tmp/tmp.wLvWPyLbya/sessions/01KQDC1A7M9FN8EVETNKPJBDDT/artifacts/screenshot-14-reference-dark.png", + "pngSizeBytes": 18355, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 800, + "pixelHeight": 480, + "sha256": "3403c12fffcd366a8672c88233425946899268cc5c89a7da394fd671c5a842b8", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/issue-63-command-target/15-record-asciicast.json b/dogfood/issue-63-command-target/15-record-asciicast.json new file mode 100644 index 0000000..28ee663 --- /dev/null +++ b/dogfood/issue-63-command-target/15-record-asciicast.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-29T19:39:30.941Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "format": "asciicast", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-tty-8wce/dogfood/issue-63-command-target/session.cast", + "bytes": 741, + "sha256": "3dc13d10376a460aafbb63320be732e1121032caccaee67fb308c77c1292a250", + "capturedAtSeq": 14, + "durationMs": 9723, + "metadata": { + "width": 100, + "height": 30, + "title": "01KQDC1A7M9FN8EVETNKPJBDDT", + "timestamp": 1777491553, + "outputEventCount": 8, + "resizeEventCount": 1, + "markerCount": 1 + } + } +} diff --git a/dogfood/issue-63-command-target/16-record-webm.json b/dogfood/issue-63-command-target/16-record-webm.json new file mode 100644 index 0000000..0ba49db --- /dev/null +++ b/dogfood/issue-63-command-target/16-record-webm.json @@ -0,0 +1,24 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-29T19:39:33.368Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-tty-8wce/dogfood/issue-63-command-target/session.webm", + "bytes": 22955, + "sha256": "e76fe6870455cc2531421434fb5497e5ee6bb1e631cf15c46eb85e12463aa738", + "capturedAtSeq": 14, + "durationMs": 9723, + "metadata": { + "width": 100, + "height": 30, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "max-speed", + "rendererBackend": "ghostty-web", + "outputEventCount": 8, + "resizeEventCount": 1 + } + } +} diff --git a/dogfood/issue-63-command-target/98-destroy-signal.json b/dogfood/issue-63-command-target/98-destroy-signal.json new file mode 100644 index 0000000..d825fcb --- /dev/null +++ b/dogfood/issue-63-command-target/98-destroy-signal.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-04-29T19:39:35.814Z", + "result": { + "sessionId": "01KQDC1Q4BBGBZNZJ8EECKQPQ9", + "destroyed": true + } +} diff --git a/dogfood/issue-63-command-target/99-destroy-main.json b/dogfood/issue-63-command-target/99-destroy-main.json new file mode 100644 index 0000000..f5f7c27 --- /dev/null +++ b/dogfood/issue-63-command-target/99-destroy-main.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-04-29T19:39:34.611Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "destroyed": true + } +} diff --git a/dogfood/issue-63-command-target/README.md b/dogfood/issue-63-command-target/README.md new file mode 100644 index 0000000..a74f131 --- /dev/null +++ b/dogfood/issue-63-command-target/README.md @@ -0,0 +1,23 @@ +# Issue 63 command-target dogfood + +This bundle was generated with an isolated `AGENT_TTY_HOME` recorded in `agent-tty-home.txt`. + +## Refactored command-target commands exercised + +- `run`: `02-run.json` +- `type`: `03-type.json` +- `paste`: `05-paste.json` +- `send-keys`: `06-send-keys.json` +- `mark`: `08-mark.json` +- `resize`: `09-resize.json` +- `signal`: `12-signal.json` against a disposable signal session + +## Review artifacts + +- Command transcript: `transcript.txt` +- Text snapshot: `13-snapshot-text.json` +- Screenshot JSON: `14-screenshot.json` +- Screenshot image: `screenshot.png` +- Asciicast export: `session.cast` with JSON envelope `15-record-asciicast.json` +- WebM export: `session.webm` with JSON envelope `16-record-webm.json` +- Main session manifest/event log copies: `main-session-manifest.json`, `main-session-events.jsonl` diff --git a/dogfood/issue-63-command-target/agent-tty-home.txt b/dogfood/issue-63-command-target/agent-tty-home.txt new file mode 100644 index 0000000..041f368 --- /dev/null +++ b/dogfood/issue-63-command-target/agent-tty-home.txt @@ -0,0 +1 @@ +/tmp/tmp.wLvWPyLbya diff --git a/dogfood/issue-63-command-target/commands.sh b/dogfood/issue-63-command-target/commands.sh new file mode 100755 index 0000000..e8085b3 --- /dev/null +++ b/dogfood/issue-63-command-target/commands.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +BUNDLE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$BUNDLE/../.." && pwd)" +export AGENT_TTY_HOME="$(mktemp -d)" +printf '%s\n' "$AGENT_TTY_HOME" > "$BUNDLE/agent-tty-home.txt" + +cd "$REPO" +exec > >(tee "$BUNDLE/transcript.txt") 2>&1 + +run_json() { + local output="$1" + shift + printf '+' + for argument in "$@"; do + printf ' %q' "$argument" + done + printf '\n' + "$@" > "$output" + cat "$output" + printf '\n' +} + +json_field() { + local file="$1" + local expression="$2" + node -e "const fs = require('node:fs'); const envelope = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log($expression);" "$file" +} + +cleanup() { + if [[ -n "${MAIN_SESSION_ID:-}" ]]; then + npx tsx src/cli/main.ts destroy "$MAIN_SESSION_ID" --json > "$BUNDLE/99-destroy-main.json" || true + fi + if [[ -n "${SIGNAL_SESSION_ID:-}" ]]; then + npx tsx src/cli/main.ts destroy "$SIGNAL_SESSION_ID" --json > "$BUNDLE/98-destroy-signal.json" || true + fi + if [[ -n "${AGENT_TTY_HOME:-}" ]]; then + rm -rf "$AGENT_TTY_HOME" + fi +} +trap cleanup EXIT + +run_json "$BUNDLE/01-create-main.json" \ + npx tsx src/cli/main.ts create --json --name issue-63-command-target -- /bin/bash --noprofile --norc +MAIN_SESSION_ID="$(json_field "$BUNDLE/01-create-main.json" 'envelope.result.sessionId')" +printf '%s\n' "$MAIN_SESSION_ID" > "$BUNDLE/main-session-id.txt" + +run_json "$BUNDLE/02-run.json" \ + npx tsx src/cli/main.ts run "$MAIN_SESSION_ID" "printf 'hello issue 63\\n'" --json +run_json "$BUNDLE/03-type.json" \ + npx tsx src/cli/main.ts type "$MAIN_SESSION_ID" "echo typed issue 63" --append-newline --json +run_json "$BUNDLE/04-wait-typed.json" \ + npx tsx src/cli/main.ts wait "$MAIN_SESSION_ID" --text "typed issue 63" --timeout 10000 --json +run_json "$BUNDLE/05-paste.json" \ + npx tsx src/cli/main.ts paste "$MAIN_SESSION_ID" "echo pasted issue 63" --json +run_json "$BUNDLE/06-send-keys.json" \ + npx tsx src/cli/main.ts send-keys "$MAIN_SESSION_ID" Enter --json +run_json "$BUNDLE/07-wait-pasted.json" \ + npx tsx src/cli/main.ts wait "$MAIN_SESSION_ID" --text "pasted issue 63" --timeout 10000 --json +run_json "$BUNDLE/08-mark.json" \ + npx tsx src/cli/main.ts mark "$MAIN_SESSION_ID" issue-63-proof --json +run_json "$BUNDLE/09-resize.json" \ + npx tsx src/cli/main.ts resize "$MAIN_SESSION_ID" --cols 100 --rows 30 --json +run_json "$BUNDLE/10-wait-stable.json" \ + npx tsx src/cli/main.ts wait "$MAIN_SESSION_ID" --screen-stable-ms 300 --timeout 10000 --json + +run_json "$BUNDLE/11-create-signal.json" \ + npx tsx src/cli/main.ts create --json --name issue-63-signal -- /bin/sh -c 'trap "echo got-sigusr1" USR1; while :; do sleep 1; done' +SIGNAL_SESSION_ID="$(json_field "$BUNDLE/11-create-signal.json" 'envelope.result.sessionId')" +printf '%s\n' "$SIGNAL_SESSION_ID" > "$BUNDLE/signal-session-id.txt" +run_json "$BUNDLE/12-signal.json" \ + npx tsx src/cli/main.ts signal "$SIGNAL_SESSION_ID" SIGUSR1 --json + +run_json "$BUNDLE/13-snapshot-text.json" \ + npx tsx src/cli/main.ts snapshot "$MAIN_SESSION_ID" --format text --json +run_json "$BUNDLE/14-screenshot.json" \ + npx tsx src/cli/main.ts screenshot "$MAIN_SESSION_ID" --hide-cursor --json +SCREENSHOT_PATH="$(json_field "$BUNDLE/14-screenshot.json" 'envelope.result.artifactPath')" +cp "$SCREENSHOT_PATH" "$BUNDLE/screenshot.png" + +run_json "$BUNDLE/15-record-asciicast.json" \ + npx tsx src/cli/main.ts record export "$MAIN_SESSION_ID" --format asciicast --out "$BUNDLE/session.cast" --json +run_json "$BUNDLE/16-record-webm.json" \ + npx tsx src/cli/main.ts record export "$MAIN_SESSION_ID" --format webm --timing max-speed --out "$BUNDLE/session.webm" --json + +SESSION_DIR="$AGENT_TTY_HOME/sessions/$MAIN_SESSION_ID" +cp "$SESSION_DIR/session.json" "$BUNDLE/main-session-manifest.json" +cp "$SESSION_DIR/events.jsonl" "$BUNDLE/main-session-events.jsonl" + +cat > "$BUNDLE/README.md" <x~q99%Az4t1;CUJCtL7Mauib#{*YY0e@-g`%S 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 ZNqm%h@w0v&47@@j^IY*+{!_!Z{|k|xD$W1^ literal 0 HcmV?d00001 diff --git a/dogfood/issue-63-command-target/session.cast b/dogfood/issue-63-command-target/session.cast new file mode 100644 index 0000000..86dedc9 --- /dev/null +++ b/dogfood/issue-63-command-target/session.cast @@ -0,0 +1,11 @@ +{"version":2,"width":100,"height":30,"timestamp":1777491553,"title":"01KQDC1A7M9FN8EVETNKPJBDDT","sessionId":"01KQDC1A7M9FN8EVETNKPJBDDT","env":{"TERM":"xterm-256color"},"toolVersion":"0.1.1-beta.4"} +[0,"o","\u001b[?2004hbash-5.1$ "] +[1.199,"o","printf 'hello issue 63\\n'\r\n\u001b[?2004l\r"] +[1.2,"o","hello issue 63\r\n\u001b[?2004hbash-5.1$ \u001b[?2004l\r"] +[1.201,"o","\u001b[?2004hbash-5.1$ "] +[2.688,"o","echo typed issue 63\r\n\u001b[?2004l\rtyped issue 63\r\n\u001b[?2004hbash-5.1$ "] +[5.094,"o","\u001b[7mecho pasted issue 63\u001b[27m"] +[6.268,"o","\rbash-5.1$ echo pasted issue 63\r\n\u001b[?2004l\rpasted issue 63\r\n\u001b[?2004hbash-5.1$ "] +[8.61,"m","issue-63-proof"] +[9.722,"o","\r\u001b[Kbash-5.1$ "] +[9.723,"r","100x30"] diff --git a/dogfood/issue-63-command-target/session.webm b/dogfood/issue-63-command-target/session.webm new file mode 100644 index 0000000000000000000000000000000000000000..93690429f8dc7f03d916e42a4ace1b5e4c9943da GIT binary patch literal 22955 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 Iu~Bq(eAk?#j|R2C-cBW>8-5!YpH9XjcR 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

j*=##(F~UVbS@#zK_aM}P+zO#mc07D&GaKdN z;#SfAP0Rd&+K32U`3-^^&q2141ZXQYhK!SiRI=VH#n{+AA$@R~rJvDvG1M^m?8$nq zH!hzs91}@&Gr2$uUq5C@)O;s4Ct%dhj-$!KAzI^;_)&l62YHU3K&MIqdC>fzueM{g z#}y(j5Pb9cX^R>=Po=2HBGE>f8kbp-ZqGtSL~isgfl|q?SmT~46gp#3bY^?ny9QX) zGnhj%=!!QZ$p-n~yb(2Z?Hz3CCRH*xHO@osTqaSzh+KB*m_C`?uB_tmRp;e@Bfp@s zSg6AO7=uX|5oR&%U{F9mgQhN+%SFg_`c9iSe+Yk*(j?|{=E4OC zSNx@U6o2G8txw-}hCcVz%TNN##J&I&#~2%Llp)pp(dXD4)i+$~x95X!@>Ixm1cJU{ zhfl=>v~#;}NzA^$tV}bTGKC7yI_Kdf6f~@00XK>YKDal(kzo?VWuky|>cEDdb#C>yxr~F7Ycw*B}N3F zM}yAGxX}4?)P?-s^GAdn1a)Nm`7NfH$jshnJ{3}%i{ih9Xe{U_be-R0uo-si9mQ*; zts}!8C;P8T^Q(<1Ykv>HzIWHqPSiEO9cpd)F!$t9;`iTHT>s}6UfgFH(>{U^hofxR zu6I3*9WoO5hjkC}Y+?FTM-$MbbtQm}pqx-{o>A4@DuPvGB~s7Jm>I}7?I4tFB9pW@ z#C*hTSH9G6WSTYT)uPrTLWdv36XHme&P9g=KUk1Kq&2Ngej{p zP|HD!!lu&gZGIRk)MfC?`^HiA15=!Hl99R&0wW=ey7A7v$e#8K?k07UQNC6KS2NQL ztY8_NDe_y*G3hHut$`B3Nrfj$gRF-O2TjmT{U&dgKq?odEBmc50=qj=mL60#kV(eb4) z!Rn4e$`>sfNPk|fp`9N{gx4Gx2irjjkzcOxvF1s=gg*9@NI800Msq9;AZ&HeC8{gu zZcJehpw;b4m1ZwWyTJM+3aK3MI1s2?M-Tgkla%WIOFN(a}<7eUp8G!ve?FMzm> z1r$j58W2lA(+kQa$SDNph+2DSh9tgMK&q?LG}JwTGDl$bgYC2$IDKZDiqH2w1DnIx-!4jlS z7Se9>ntI-2nS(jeCSOm9@JMLA0Iem=NO9(Lb^$O=Y~rH;6Zx&fv8?T~G`=s(p~R;ca!^IH6Q>z|~AM#WGik-BL5H|i@`d5j$TNVo2|S#4G4 zt<&~lIVftnz`1#-g%i}9zZtJOK>G=~=)fM=Ga`$O)1@p&8@1LydA&HG{Fr!Vaf$mB zq8!=|14J)XjrKnpj7-q4e>Y9)Obnfotm3OSZ$JL7#y?PVe?z&~*hT6Mo2h!>Tt-(} zfcKER+^zzTibG|P8+WoIyY3eA73()%kTc-JxcGvfPjWIl0VT{C?%m~B57>+sr-(^0 z!?Be*DyBvxTD_A^*VnR*A`Emk`(8FuxONJ7mry(=19->ovin zGHx0g=}}43{CrZ+pgAbP1zhztrlbshibuK^IY?+k8p#gW*MQgq1cwDo;sPL1831oQ z`%kuhHfBlzo-sSZz-(Xxl@&Y~0Ap$7IxSmq5dge4SZ&Lon^j;zM!T* zW~}4cJSx9WEHguxB*K5r|BI<-bE5WE&F_)ap%dGKH}roFeE;2J&tK}wEcdcXVQ_V$ zRwASB=8{eyO$MV(eP(@DM4EdXG(J3584_{KLfwP$0m%)6>GSO-38F1A1pi6!15kz3 zl@xXv7F4}d4kP>V^D{7OtF{LY|Cj|QxL$09$=bHLYdBj#%eqdmJ6$u z&@YML2yw`niqRa+Dgg)HV~uMV;t27*$vv$CD271q%mw!K`vpG{A*|EHo+yazi9I|V zWV#V*L*zF-sgj97^axzJ3c(@pZYO8%iEj{9$vP|b-^`|7n zr;Uh*=MZ|5o=CmH*ly*>wxrua?T2TR*nF8_DuK*3W~(!5x^j(=$0$Y2=%=bpzGf90 z08u5NtMBaf@deJ$w$ip1v(AgvMwj8NPKn{L@Eki2ysDx@j`10sKnO@3c4+Vn?v2H% za_}{4bO?#UWW`t)RF1@qK*z;`_+3r%Gm-h!vTS1-)`SW0hSH^G<(SW|Fw4lkOB~SK zTB$5MBf$Mz3f~jkFy9pbnT1iDvw|{=4?LPY1Lm{&S2;B$Ne;*5k9goeOUCy|tur5Z zYgoO{#!gpE!X8{9EaE~iXY$%MisEr!Ccg1}_84Jk@K2bOSoH3PZWvtVg>&>C@5{U& zXeCy{1J1lPhhQ|q{z+lRMaGHLO>0(i%^HC*bugaNx5AO_rZLOrqb4fKko{zTJ+KBl5#zF%*)F!I`-{teCNaGv()Z78q$+9 zY4+try5XOpF6Jpp?n7nWw0w-C(}cdKdULGP%-*Ol{I?vK-s-3aX-+#@TDrGL2nHKz zbv(JBXo9pOd$)$&BQ12lshU1suOma@D50PUWP*aCio)FDutl);n|F3w6C1&JH0;p& zt~@{0r$+fJf+>GLrRX_%*U{H(4la9^bE{@6I)!F2vkcXr-k;`;%GQGWjyKJ}a(s*} z^?~!#+(`YSyRZO?TN2`%ykQY_JO<=^re>Z#wJ*UsNBr?^iQn^JF#TE7I)q)4BMwYX ze#|W$^t#+o679#k{Sc1FB>WVSjUtj%TG8U`nMKp|rp9g8_bK&!g2Fou&GwSR^@d=d zpDq7Q;aB1ud%HqbADTzsbZ#$5J2d*(__En4&lzPTFNQ1RLGSDZ0_Xskx70DF8%*gxDj7;+nB~N{cTjonPH!G1qm7 zWQ_-HJQ!mrtGDa4Ok;9q08!3^1-jC6j`W+v=)+KRcQ%w$D5D%Mm+ z-rb+rqC%=LV#q>xNjVzi5|m2HLzF8_Y0Lqh0*hzz1eS)S-@?Rf<86MRU1_3Dz1gHjZlT{@lAyp z)GMlJFB&dXsD|G;PRo^iBruh4Ob|ZYP(gk(3CSBXH~>Xj9J6qN z@oBG{tu-!6%DA?`vefB^kWJnTlMiGmiM*`S)LQu+grwF7G-Iuj!9A&m$dC#@i z1Y^DJOXGp~%a2m#Yk3-x!`IQBIiKZwu$&3cm8Vv%@aeylXb>^iNq=NTTP*dH;qhV} zB)F_%f|hy~_kCwq6N{oq)`paV`Ib%wbc29vQ$_>ETNdMgHLpj)667_L@wU&s|I(o83D=h}j%X%MRjQGMhO~C6)JM9WRU6E4yF%X2@+&;M_ZP|HmI@!jH6bmB-(Rnp z!eC}J`>kMzWOi65}aIVhwg4j2WIJ9C^Wxmbw(1QP0=@7QU5mHK0ZH|-?HjGq#v)>lFV zTMm&E?pF87CjmD)YVQpPnlsO-`RzT}jAitN(zah*p=Rr@B0p^D1;`mrDh?=9Cxr=_j zkMzz=H9RZt5kl>kqq0^EapyG{HNL6ugYJbH75HS(jazQfa?oruon@A0^oH`dq=?>8 z18gyrxp_<(fJksiVS{Wx(ZTHt5B@Cq@xi~}!u!Y0W|-{Pq+!#Vc|Gs?wx9?zK5e~^qymGHM zd^{pqH(@(P(3JjRC1c=lt6VQ44dCgJMqcY_uh1%>a2-9^fNSs97h4qmW_^YBUwj3^fIGUS9Df~M z8r;AZLt(giy2g+loB}fl*7yF$j1FL%U6~&YAL~n*dXTup35|Z98Z?^A(r`LbrzG#X zTiXp?_?)9;QNVGvNn+97MR0cW+hNz`S}dASOtyrSHUdr^%IinreBl)!cnN^@59rsg zV!o_h0nqnSU3uWakG7kbWE1R&NtoKZzGM|qOLD@FF997J?a+Bj%wX)5*>m4Us+c(Wbq8G%^oEi6SYS1z5u z^G*E52mU(Bzw+(=FFxCzfqEkGx&8sT2CzHChy&)1PfkvL|FlviK_u-o$m9pMO|?w} z=eVt7cntO|Xv(Yu20=Fe0`XwbsHB9xwcQDEEwJHfx}2{`(VFAL{n7FOAgrRCe6&2C_y6_nS==O42TFz_B^=U(+YDWzh$kuhPu zBwjuS`NG>k@H>F=-}(MG^=qfS#b2b4|BOC?^AGwhg~kdL?a25kp0i|6{WcSNh-j=% z|F_Q5zpLEl|GR+u*G1!*_lOKCJ8IJQW>XNUs4!G=Hl0AufuVc8EQ<=cI7k0C;7nK$O>~Gm{p4`q;lXviQvklS&5nZf z@hg&zzxBoqUA7D7zkhzd0xIRaDu@_m1phJBD4DMG(q|^xrK5BJzrP!rk#f;Xq9u&E z98VRF^Mwy@_29Gt9M}!hFbl{Ro(DoW{5`^i(gT=#l=nM{!CW}IZq zT-V6OU)^%8PYqkA_N$Wftzq;Nqdo~|IlQ9H0wGfVkDQhNx54TUIG614@Ef_oy2E}0 zOKm5ZxvqxDyJn2;xOk&Gj}HvwTM?LDSK|IVSm!SR_7@b}|0TfwbHyN0e+jVvTro)Q zUjpneC}!|Wfc*ux>B0Q=7sgBJcJ!2W__7QY18Ur;Re zmjL?S1F`_B~n?dOu? WT4HK}fQInbpJhG|04q5ILi~S9EL!*g literal 0 HcmV?d00001 diff --git a/dogfood/issue-63-command-target/signal-session-id.txt b/dogfood/issue-63-command-target/signal-session-id.txt new file mode 100644 index 0000000..3d5a77d --- /dev/null +++ b/dogfood/issue-63-command-target/signal-session-id.txt @@ -0,0 +1 @@ +01KQDC1Q4BBGBZNZJ8EECKQPQ9 diff --git a/dogfood/issue-63-command-target/transcript.txt b/dogfood/issue-63-command-target/transcript.txt new file mode 100644 index 0000000..688a91a --- /dev/null +++ b/dogfood/issue-63-command-target/transcript.txt @@ -0,0 +1,239 @@ ++ npx tsx src/cli/main.ts create --json --name issue-63-command-target -- /bin/bash --noprofile --norc +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-29T19:39:13.331Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "createdAt": "2026-04-29T19:39:12.504Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} + ++ npx tsx src/cli/main.ts run 01KQDC1A7M9FN8EVETNKPJBDDT printf\ \'hello\ issue\ 63\\n\' --json +{ + "ok": true, + "command": "run", + "timestamp": "2026-04-29T19:39:14.891Z", + "result": { + "accepted": true, + "completed": true, + "timedOut": false, + "seq": 1, + "durationMs": 3, + "marker": "__AT_MARKER_b90108d5edac4369a6f43da422783394__" + } +} + ++ npx tsx src/cli/main.ts type 01KQDC1A7M9FN8EVETNKPJBDDT echo\ typed\ issue\ 63 --append-newline --json +{ + "ok": true, + "command": "type", + "timestamp": "2026-04-29T19:39:15.990Z", + "result": {} +} + ++ npx tsx src/cli/main.ts wait 01KQDC1A7M9FN8EVETNKPJBDDT --text typed\ issue\ 63 --timeout 10000 --json +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T19:39:17.293Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "typed issue 63", + "cursorRow": 4, + "cursorCol": 10, + "capturedAtSeq": 7 + } +} + ++ npx tsx src/cli/main.ts paste 01KQDC1A7M9FN8EVETNKPJBDDT echo\ pasted\ issue\ 63 --json +{ + "ok": true, + "command": "paste", + "timestamp": "2026-04-29T19:39:18.396Z", + "result": {} +} + ++ npx tsx src/cli/main.ts send-keys 01KQDC1A7M9FN8EVETNKPJBDDT Enter --json +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-04-29T19:39:19.570Z", + "result": { + "accepted": [ + "Enter" + ], + "bytesWritten": 1, + "seq": 10 + } +} + ++ npx tsx src/cli/main.ts wait 01KQDC1A7M9FN8EVETNKPJBDDT --text pasted\ issue\ 63 --timeout 10000 --json +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T19:39:20.827Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "pasted issue 63", + "cursorRow": 6, + "cursorCol": 10, + "capturedAtSeq": 11 + } +} + ++ npx tsx src/cli/main.ts mark 01KQDC1A7M9FN8EVETNKPJBDDT issue-63-proof --json +{ + "ok": true, + "command": "mark", + "timestamp": "2026-04-29T19:39:21.913Z", + "result": { + "seq": 12 + } +} + ++ npx tsx src/cli/main.ts resize 01KQDC1A7M9FN8EVETNKPJBDDT --cols 100 --rows 30 --json +{ + "ok": true, + "command": "resize", + "timestamp": "2026-04-29T19:39:23.025Z", + "result": { + "cols": 100, + "rows": 30 + } +} + ++ npx tsx src/cli/main.ts wait 01KQDC1A7M9FN8EVETNKPJBDDT --screen-stable-ms 300 --timeout 10000 --json +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T19:39:24.720Z", + "result": { + "matched": true, + "timedOut": false, + "cursorRow": 6, + "cursorCol": 10, + "capturedAtSeq": 14 + } +} + ++ npx tsx src/cli/main.ts create --json --name issue-63-signal -- /bin/sh -c trap\ \"echo\ got-sigusr1\"\ USR1\;\ while\ :\;\ do\ sleep\ 1\;\ done +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-29T19:39:26.433Z", + "result": { + "sessionId": "01KQDC1Q4BBGBZNZJ8EECKQPQ9", + "createdAt": "2026-04-29T19:39:25.710Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} + ++ npx tsx src/cli/main.ts signal 01KQDC1Q4BBGBZNZJ8EECKQPQ9 SIGUSR1 --json +{ + "ok": true, + "command": "signal", + "timestamp": "2026-04-29T19:39:27.581Z", + "result": { + "signal": "SIGUSR1", + "delivered": true + } +} + ++ npx tsx src/cli/main.ts snapshot 01KQDC1A7M9FN8EVETNKPJBDDT --format text --json +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-04-29T19:39:28.672Z", + "result": { + "format": "text", + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "capturedAtSeq": 14, + "cols": 100, + "rows": 30, + "cursorRow": 6, + "cursorCol": 10, + "text": "bash-5.1$ printf 'hello issue 63\\n'\nhello issue 63\nbash-5.1$ echo typed issue 63\ntyped issue 63\nbash-5.1$ echo pasted issue 63\npasted issue 63\nbash-5.1$\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + } +} + ++ npx tsx src/cli/main.ts screenshot 01KQDC1A7M9FN8EVETNKPJBDDT --hide-cursor --json +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-04-29T19:39:29.863Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "capturedAtSeq": 14, + "profileName": "reference-dark", + "cols": 100, + "rows": 30, + "artifactPath": "/tmp/tmp.wLvWPyLbya/sessions/01KQDC1A7M9FN8EVETNKPJBDDT/artifacts/screenshot-14-reference-dark.png", + "pngSizeBytes": 18355, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 800, + "pixelHeight": 480, + "sha256": "3403c12fffcd366a8672c88233425946899268cc5c89a7da394fd671c5a842b8", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} + ++ npx tsx src/cli/main.ts record export 01KQDC1A7M9FN8EVETNKPJBDDT --format asciicast --out /home/coder/.mux/src/agent-terminal/agent-tty-8wce/dogfood/issue-63-command-target/session.cast --json +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-29T19:39:30.941Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "format": "asciicast", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-tty-8wce/dogfood/issue-63-command-target/session.cast", + "bytes": 741, + "sha256": "3dc13d10376a460aafbb63320be732e1121032caccaee67fb308c77c1292a250", + "capturedAtSeq": 14, + "durationMs": 9723, + "metadata": { + "width": 100, + "height": 30, + "title": "01KQDC1A7M9FN8EVETNKPJBDDT", + "timestamp": 1777491553, + "outputEventCount": 8, + "resizeEventCount": 1, + "markerCount": 1 + } + } +} + ++ npx tsx src/cli/main.ts record export 01KQDC1A7M9FN8EVETNKPJBDDT --format webm --timing max-speed --out /home/coder/.mux/src/agent-terminal/agent-tty-8wce/dogfood/issue-63-command-target/session.webm --json +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-29T19:39:33.368Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-tty-8wce/dogfood/issue-63-command-target/session.webm", + "bytes": 22955, + "sha256": "e76fe6870455cc2531421434fb5497e5ee6bb1e631cf15c46eb85e12463aa738", + "capturedAtSeq": 14, + "durationMs": 9723, + "metadata": { + "width": 100, + "height": 30, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "max-speed", + "rendererBackend": "ghostty-web", + "outputEventCount": 8, + "resizeEventCount": 1 + } + } +} + diff --git a/src/cli/commandTarget.ts b/src/cli/commandTarget.ts new file mode 100644 index 0000000..8fc2461 --- /dev/null +++ b/src/cli/commandTarget.ts @@ -0,0 +1,73 @@ +import type { SessionRecord } from '../protocol/schemas.js'; + +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import { invariant } from '../util/assert.js'; +import { readManifestIfExists } from '../storage/manifests.js'; +import { + manifestPath as resolveManifestPath, + sessionDir, + socketPath as resolveSocketPath, +} from '../storage/sessionPaths.js'; +import { assertSessionCommandable } from './sessionGuards.js'; + +export interface ResolveCommandTargetOptions { + home: string; + sessionId: string; +} + +export type CommandTargetManifest = SessionRecord & { status: 'running' }; + +export interface CommandTarget { + sessionId: string; + sessionDirectory: string; + manifestPath: string; + socketPath: string; + manifest: CommandTargetManifest; +} + +function assertRunningManifest( + manifest: SessionRecord, +): asserts manifest is CommandTargetManifest { + invariant( + manifest.status === 'running', + 'command target manifest must be running after commandability check', + ); +} + +/** + * Resolve the live command target for input/control commands. + * + * Throws SESSION_NOT_FOUND when the manifest is missing, + * SESSION_ALREADY_DESTROYED for destroyed sessions, and SESSION_NOT_RUNNING + * for other non-commandable statuses. This deliberately does not check that + * the socket exists or is connectable; sendRpc() remains the host liveness + * boundary. + */ +export async function resolveCommandTarget( + options: ResolveCommandTargetOptions, +): Promise { + const sessionDirectory = sessionDir(options.home, options.sessionId); + const resolvedManifestPath = resolveManifestPath(sessionDirectory); + const manifest = await readManifestIfExists(resolvedManifestPath); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: resolvedManifestPath, + }, + }); + } + + assertSessionCommandable(manifest, options.sessionId); + assertRunningManifest(manifest); + + return { + sessionId: options.sessionId, + sessionDirectory, + manifestPath: resolvedManifestPath, + socketPath: resolveSocketPath(sessionDirectory), + manifest, + }; +} diff --git a/src/cli/commands/mark.ts b/src/cli/commands/mark.ts index 3e28e7f..54966f3 100644 --- a/src/cli/commands/mark.ts +++ b/src/cli/commands/mark.ts @@ -1,17 +1,11 @@ import type { CommandContext } from '../context.js'; import type { MarkResult } from '../../protocol/messages.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { MarkResultSchema } from '../../protocol/messages.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export type { MarkResult } from '../../protocol/messages.js'; @@ -23,30 +17,14 @@ interface CommandOptions { } export async function runMarkCommand(options: CommandOptions): Promise { - const home = options.context.home; - 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, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); - const rawResult: unknown = await sendRpc( - socketPath(sessionDirectory), - 'mark', - { - label: options.label, - }, - ); + const rawResult: unknown = await sendRpc(target.socketPath, 'mark', { + label: options.label, + }); const parsedResult = MarkResultSchema.safeParse(rawResult); if (!parsedResult.success) { throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { diff --git a/src/cli/commands/paste.ts b/src/cli/commands/paste.ts index 23d4957..97a2ce0 100644 --- a/src/cli/commands/paste.ts +++ b/src/cli/commands/paste.ts @@ -1,16 +1,9 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; 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 { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; import { resolveCommandInputText } from './inputSource.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export interface PasteResult { [key: string]: never; @@ -31,33 +24,12 @@ export async function runPasteCommand(options: CommandOptions): Promise { file: options.file, }); - if (text.length === 0) { - throw makeCliError(ERROR_CODES.INVALID_INPUT, { - message: 'Text must not be empty.', - details: { - text, - }, - }); - } - - const home = options.context.home; - 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, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); - await sendRpc(socketPath(sessionDirectory), 'paste', { + await sendRpc(target.socketPath, 'paste', { text, }); diff --git a/src/cli/commands/resize.ts b/src/cli/commands/resize.ts index 0486bb3..cc6315b 100644 --- a/src/cli/commands/resize.ts +++ b/src/cli/commands/resize.ts @@ -1,15 +1,9 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; 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 { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export interface ResizeResult { cols: number; @@ -25,22 +19,10 @@ interface CommandOptions { } export async function runResizeCommand(options: CommandOptions): Promise { - const home = options.context.home; - 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, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); if ( !Number.isInteger(options.cols) || @@ -57,7 +39,7 @@ export async function runResizeCommand(options: CommandOptions): Promise { }); } - await sendRpc(socketPath(sessionDirectory), 'resize', { + await sendRpc(target.socketPath, 'resize', { cols: options.cols, rows: options.rows, }); diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 42fa8ed..6bb1dad 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -1,18 +1,12 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import type { RunResult } from '../../protocol/messages.js'; import { RunResultSchema } from '../../protocol/messages.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; import { resolveCommandInputText } from './inputSource.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; interface CommandOptions { context: CommandContext; @@ -31,15 +25,6 @@ export async function runRunCommand(options: CommandOptions): Promise { file: options.file, }); - if (command.length === 0) { - throw makeCliError(ERROR_CODES.INVALID_INPUT, { - message: 'Command text must not be empty.', - details: { - command, - }, - }); - } - if (!Number.isFinite(options.timeout) || options.timeout <= 0) { throw makeCliError(ERROR_CODES.INVALID_INPUT, { message: 'Timeout must be a positive integer in milliseconds', @@ -49,22 +34,10 @@ export async function runRunCommand(options: CommandOptions): Promise { }); } - const home = options.context.home; - 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, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); const noWait = !options.wait; const rpcParams: Record = { @@ -78,7 +51,7 @@ export async function runRunCommand(options: CommandOptions): Promise { const rpcTimeoutMs = noWait ? 10_000 : options.timeout + 10_000; const rawResult = await sendRpc( - socketPath(sessionDirectory), + target.socketPath, 'run', rpcParams, rpcTimeoutMs, diff --git a/src/cli/commands/send-keys.ts b/src/cli/commands/send-keys.ts index 28f4dd5..ca63142 100644 --- a/src/cli/commands/send-keys.ts +++ b/src/cli/commands/send-keys.ts @@ -1,17 +1,11 @@ import type { CommandContext } from '../context.js'; import type { SendKeysResult } from '../../protocol/messages.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { SendKeysResultSchema } from '../../protocol/messages.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export type { SendKeysResult } from '../../protocol/messages.js'; @@ -25,30 +19,14 @@ interface CommandOptions { export async function runSendKeysCommand( options: CommandOptions, ): Promise { - const home = options.context.home; - 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, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); - const rawResult: unknown = await sendRpc( - socketPath(sessionDirectory), - 'sendKeys', - { - keys: options.keys, - }, - ); + const rawResult: unknown = await sendRpc(target.socketPath, 'sendKeys', { + keys: options.keys, + }); const parsedResult = SendKeysResultSchema.safeParse(rawResult); if (!parsedResult.success) { throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { diff --git a/src/cli/commands/signal.ts b/src/cli/commands/signal.ts index b613dc6..a54f2ca 100644 --- a/src/cli/commands/signal.ts +++ b/src/cli/commands/signal.ts @@ -1,15 +1,9 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; 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 { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; const ALLOWED_SIGNALS = [ 'SIGTERM', @@ -33,22 +27,10 @@ interface CommandOptions { } export async function runSignalCommand(options: CommandOptions): Promise { - const home = options.context.home; - 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, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); if ( !ALLOWED_SIGNALS.includes( @@ -63,7 +45,7 @@ export async function runSignalCommand(options: CommandOptions): Promise { }); } - await sendRpc(socketPath(sessionDirectory), 'signal', { + await sendRpc(target.socketPath, 'signal', { signal: options.signal, }); diff --git a/src/cli/commands/type.ts b/src/cli/commands/type.ts index 322f4e8..8b8492c 100644 --- a/src/cli/commands/type.ts +++ b/src/cli/commands/type.ts @@ -1,16 +1,9 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; 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 { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; import { resolveCommandInputText } from './inputSource.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export interface TypeResult { [key: string]: never; @@ -34,33 +27,12 @@ export async function runTypeCommand(options: CommandOptions): Promise { const text = options.appendNewline === true ? resolvedText + '\n' : resolvedText; - if (text.length === 0) { - throw makeCliError(ERROR_CODES.INVALID_INPUT, { - message: 'Text must not be empty.', - details: { - text, - }, - }); - } - - const home = options.context.home; - 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, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); - await sendRpc(socketPath(sessionDirectory), 'type', { + await sendRpc(target.socketPath, 'type', { text, }); diff --git a/test/helpers.ts b/test/helpers.ts index a26b1f2..9b5c141 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; +import type { SessionRecord as ProtocolSessionRecord } from '../src/protocol/schemas.js'; import type { SemanticSnapshot } from '../src/renderer/types.js'; import { afterEach, expect } from 'vitest'; @@ -83,6 +84,27 @@ export function createTestSemanticSnapshot( }; } +export function createTestSessionRecord( + overrides: Partial = {}, +): ProtocolSessionRecord { + 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, + ...overrides, + }; +} + export async function createTemporarySessionDir( prefix: string, sessionId = 'session-01', diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index 400688f..0d60b84 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -313,6 +313,55 @@ describe('CLI integration', () => { expect(envelope.error.message).toContain('mutually exclusive'); }); + it('returns SESSION_NOT_FOUND for resize before validating dimensions', () => { + const result = runCli( + ['resize', 'missing-session', '--cols', '0', '--rows', '0', '--json'], + testEnv(), + ); + + expect(result.status).toBe(3); + expect(result.stderr).toBe(''); + + const envelope = parseErrorEnvelope(result.stdout); + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('resize'); + expect(envelope.error.code).toBe('SESSION_NOT_FOUND'); + expect(envelope.error.message).toBe( + 'Session "missing-session" was not found.', + ); + }); + + it('returns SESSION_NOT_FOUND for signal before validating the signal name', () => { + const result = runCli( + ['signal', 'missing-session', 'BAD', '--json'], + testEnv(), + ); + + expect(result.status).toBe(3); + expect(result.stderr).toBe(''); + + const envelope = parseErrorEnvelope(result.stdout); + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('signal'); + expect(envelope.error.code).toBe('SESSION_NOT_FOUND'); + expect(envelope.error.message).toBe( + 'Session "missing-session" was not found.', + ); + }); + + it('rejects empty run text before resolving the command target', () => { + const result = runCli(['run', 'missing-session', '', '--json'], testEnv()); + + expect(result.status).toBe(2); + expect(result.stderr).toBe(''); + + const envelope = parseErrorEnvelope(result.stdout); + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('run'); + expect(envelope.error.code).toBe('INVALID_INPUT'); + expect(envelope.error.message).toBe('Text must not be empty.'); + }); + it('prints a JSON envelope for doctor including the new health checks', () => { const result = runCli(['doctor', '--json'], testEnv()); expect(result.status).toBe(0); diff --git a/test/unit/cli/commandTarget.test.ts b/test/unit/cli/commandTarget.test.ts new file mode 100644 index 0000000..d4f2234 --- /dev/null +++ b/test/unit/cli/commandTarget.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveCommandTarget } from '../../../src/cli/commandTarget.js'; +import { ERROR_CODES } from '../../../src/protocol/errors.js'; +import { createTestSessionRecord } from '../../helpers.js'; +import type { + SessionRecord, + SessionStatus, +} from '../../../src/protocol/schemas.js'; +import { writeManifest } from '../../../src/storage/manifests.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../../src/storage/sessionPaths.js'; + +let testHome = ''; + +async function createHome(): Promise { + testHome = await mkdtemp(join(tmpdir(), 'agent-tty-command-target-')); + return testHome; +} + +afterEach(async () => { + if (testHome.length > 0) { + await rm(testHome, { recursive: true, force: true }); + } + testHome = ''; +}); + +async function writeSessionManifest( + home: string, + sessionId: string, + status: SessionStatus, +): Promise { + const manifest = createTestSessionRecord({ + sessionId, + status, + hostPid: status === 'running' ? 123 : null, + childPid: status === 'running' ? 456 : null, + exitCode: status === 'exited' ? 0 : null, + }); + await writeManifest(manifestPath(sessionDir(home, sessionId)), manifest); + return manifest; +} + +describe('resolveCommandTarget', () => { + it('throws SESSION_NOT_FOUND when the session manifest is missing', async () => { + const home = await createHome(); + + await expect( + resolveCommandTarget({ home, sessionId: 'missing-session' }), + ).rejects.toMatchObject({ + code: ERROR_CODES.SESSION_NOT_FOUND, + message: 'Session "missing-session" was not found.', + details: { + sessionId: 'missing-session', + manifestPath: join(home, 'sessions', 'missing-session', 'session.json'), + }, + }); + }); + + it('throws SESSION_NOT_RUNNING when the session is not commandable', async () => { + const home = await createHome(); + await writeSessionManifest(home, 'exited-session', 'exited'); + + await expect( + resolveCommandTarget({ home, sessionId: 'exited-session' }), + ).rejects.toMatchObject({ + code: ERROR_CODES.SESSION_NOT_RUNNING, + message: 'Session "exited-session" is not running.', + details: { + sessionId: 'exited-session', + status: 'exited', + }, + }); + }); + + it('throws SESSION_ALREADY_DESTROYED when the session is destroyed', async () => { + const home = await createHome(); + await writeSessionManifest(home, 'destroyed-session', 'destroyed'); + + await expect( + resolveCommandTarget({ home, sessionId: 'destroyed-session' }), + ).rejects.toMatchObject({ + code: ERROR_CODES.SESSION_ALREADY_DESTROYED, + message: 'Session "destroyed-session" is already destroyed.', + details: { + sessionId: 'destroyed-session', + status: 'destroyed', + }, + }); + }); + + it('returns the resolved command target for a running session', async () => { + const home = await createHome(); + const manifest = await writeSessionManifest( + home, + 'running-session', + 'running', + ); + const sessionDirectory = sessionDir(home, 'running-session'); + + await expect( + resolveCommandTarget({ home, sessionId: 'running-session' }), + ).resolves.toEqual({ + sessionId: 'running-session', + sessionDirectory, + manifestPath: manifestPath(sessionDirectory), + socketPath: socketPath(sessionDirectory), + manifest, + }); + }); +}); diff --git a/test/unit/commands/mark.test.ts b/test/unit/commands/mark.test.ts index 5752a39..4938273 100644 --- a/test/unit/commands/mark.test.ts +++ b/test/unit/commands/mark.test.ts @@ -4,12 +4,12 @@ import { ERROR_CODES } from '../../../src/protocol/errors.js'; const mocks = vi.hoisted(() => ({ emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), sendRpc: vi.fn(), - readManifestIfExists: vi.fn(), - resolveHome: vi.fn(), - sessionDir: vi.fn(), - manifestPath: vi.fn(), - socketPath: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, })); vi.mock('../../../src/cli/output.js', () => ({ @@ -20,20 +20,6 @@ vi.mock('../../../src/host/rpcClient.js', () => ({ sendRpc: mocks.sendRpc, })); -vi.mock('../../../src/storage/manifests.js', () => ({ - readManifestIfExists: mocks.readManifestIfExists, -})); - -vi.mock('../../../src/storage/home.js', () => ({ - resolveHome: mocks.resolveHome, -})); - -vi.mock('../../../src/storage/sessionPaths.js', () => ({ - sessionDir: mocks.sessionDir, - manifestPath: mocks.manifestPath, - socketPath: mocks.socketPath, -})); - import { runMarkCommand } from '../../../src/cli/commands/mark.js'; import { createLogger } from '../../../src/util/logger.js'; @@ -48,48 +34,22 @@ const TEST_CONTEXT = { configFile: null, } as const; -function createSessionRecord( - status: 'running' | 'exiting' | 'exited' | 'destroyed' = 'running', -) { - return { - version: 1, - sessionId: 'session-01', - createdAt: '2026-03-19T12:00:00.000Z', - updatedAt: '2026-03-19T12:00:01.000Z', - status, - command: ['/bin/sh'], - cwd: '/tmp/workspace', - cols: 80, - rows: 24, - hostPid: status === 'running' ? 123 : null, - childPid: status === 'running' ? 456 : null, - exitCode: status === 'exited' ? 0 : null, - exitSignal: null, - }; -} +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; describe('mark command', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.resolveHome.mockReturnValue('/tmp/agent-tty'); - mocks.sessionDir.mockImplementation( - (_home: string, sessionId: string) => - `/tmp/agent-tty/sessions/${sessionId}`, - ); - mocks.manifestPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/session.json`, - ); - mocks.socketPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, - ); - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('running'), - ); - }); - - it('sends the mark RPC for a running session and emits the committed seq', async () => { + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); mocks.sendRpc.mockResolvedValue({ seq: 12 }); + }); + it('sends the mark RPC for a command target and emits the committed seq', async () => { await runMarkCommand({ context: TEST_CONTEXT, json: false, @@ -97,8 +57,12 @@ describe('mark command', () => { label: 'checkpoint', }); + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'mark', { label: 'checkpoint' }, ); @@ -110,63 +74,6 @@ describe('mark command', () => { }); }); - it('throws SESSION_NOT_RUNNING when the session is not running', async () => { - mocks.readManifestIfExists.mockResolvedValue(createSessionRecord('exited')); - - await expect( - runMarkCommand({ - context: TEST_CONTEXT, - json: false, - sessionId: 'session-01', - label: 'checkpoint', - }), - ).rejects.toMatchObject({ - code: ERROR_CODES.SESSION_NOT_RUNNING, - message: 'Session "session-01" is not running.', - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_ALREADY_DESTROYED when the session is destroyed', async () => { - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('destroyed'), - ); - - await expect( - runMarkCommand({ - context: TEST_CONTEXT, - json: false, - sessionId: 'session-01', - label: 'checkpoint', - }), - ).rejects.toMatchObject({ - code: ERROR_CODES.SESSION_ALREADY_DESTROYED, - message: 'Session "session-01" is already destroyed.', - details: { - sessionId: 'session-01', - status: 'destroyed', - }, - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_NOT_FOUND when the session does not exist', async () => { - mocks.readManifestIfExists.mockResolvedValue(null); - - await expect( - runMarkCommand({ - context: TEST_CONTEXT, - json: false, - sessionId: 'session-01', - label: 'checkpoint', - }), - ).rejects.toMatchObject({ - code: ERROR_CODES.SESSION_NOT_FOUND, - message: 'Session "session-01" was not found.', - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - it('accepts an empty label', async () => { mocks.sendRpc.mockResolvedValue({ seq: 7 }); @@ -178,7 +85,7 @@ describe('mark command', () => { }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'mark', { label: '' }, ); @@ -207,4 +114,21 @@ describe('mark command', () => { lines: ['Marker set at seq 99.'], }); }); + + it('rejects PROTOCOL_ERROR responses without sending success output', async () => { + mocks.sendRpc.mockResolvedValueOnce({ unexpected: true }); + + await expect( + runMarkCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + label: 'broken', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.PROTOCOL_ERROR, + message: 'Unexpected response from host', + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); }); diff --git a/test/unit/commands/paste.test.ts b/test/unit/commands/paste.test.ts new file mode 100644 index 0000000..78a3a47 --- /dev/null +++ b/test/unit/commands/paste.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), + resolveCommandInputText: vi.fn(), + sendRpc: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/cli/commands/inputSource.js', () => ({ + resolveCommandInputText: mocks.resolveCommandInputText, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +import { runPasteCommand } from '../../../src/cli/commands/paste.js'; +import { createLogger } from '../../../src/util/logger.js'; + +const TEST_CONTEXT = { + home: '/tmp/agent-tty', + timeoutMs: undefined, + colorEnabled: true, + logLevel: 'info', + logger: createLogger('info', () => undefined), + profileDefault: undefined, + rendererDefault: 'ghostty-web', + configFile: null, +} as const; + +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; + +describe('paste command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); + mocks.resolveCommandInputText.mockResolvedValue('hello from paste'); + }); + + it('pastes resolved input into a command target', async () => { + await runPasteCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + text: 'hello from paste', + }); + + expect(mocks.resolveCommandInputText).toHaveBeenCalledWith({ + commandName: 'paste', + text: 'hello from paste', + file: undefined, + }); + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-tty/sockets/session-01.sock', + 'paste', + { text: 'hello from paste' }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'paste', + json: false, + result: {}, + lines: ['Pasted text into session.'], + }); + }); +}); diff --git a/test/unit/commands/resize.test.ts b/test/unit/commands/resize.test.ts new file mode 100644 index 0000000..0f89a60 --- /dev/null +++ b/test/unit/commands/resize.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), + sendRpc: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +import { runResizeCommand } from '../../../src/cli/commands/resize.js'; +import { createLogger } from '../../../src/util/logger.js'; + +const TEST_CONTEXT = { + home: '/tmp/agent-tty', + timeoutMs: undefined, + colorEnabled: true, + logLevel: 'info', + logger: createLogger('info', () => undefined), + profileDefault: undefined, + rendererDefault: 'ghostty-web', + configFile: null, +} as const; + +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; + +describe('resize command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); + }); + + it('sends resize dimensions to a command target and emits dimensions', async () => { + await runResizeCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + cols: 100, + rows: 30, + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-tty/sockets/session-01.sock', + 'resize', + { cols: 100, rows: 30 }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'resize', + json: false, + result: { cols: 100, rows: 30 }, + lines: ['Resized session to 100x30.'], + }); + }); + + it('rejects invalid dimensions after resolving the command target', async () => { + await expect( + runResizeCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + cols: 0, + rows: 30, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_DIMENSIONS, + message: 'Resize dimensions must be positive integers.', + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands/run.test.ts b/test/unit/commands/run.test.ts index ccd2343..5167f05 100644 --- a/test/unit/commands/run.test.ts +++ b/test/unit/commands/run.test.ts @@ -2,36 +2,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ emitSuccess: vi.fn(), - sendRpc: vi.fn(), - readManifestIfExists: vi.fn(), - sessionDir: vi.fn(), - manifestPath: vi.fn(), - socketPath: vi.fn(), + resolveCommandTarget: vi.fn(), resolveCommandInputText: vi.fn(), + sendRpc: vi.fn(), })); -vi.mock('../../../src/cli/output.js', () => ({ - emitSuccess: mocks.emitSuccess, -})); - -vi.mock('../../../src/host/rpcClient.js', () => ({ - sendRpc: mocks.sendRpc, -})); - -vi.mock('../../../src/storage/manifests.js', () => ({ - readManifestIfExists: mocks.readManifestIfExists, +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, })); -vi.mock('../../../src/storage/sessionPaths.js', () => ({ - sessionDir: mocks.sessionDir, - manifestPath: mocks.manifestPath, - socketPath: mocks.socketPath, +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, })); vi.mock('../../../src/cli/commands/inputSource.js', () => ({ resolveCommandInputText: mocks.resolveCommandInputText, })); +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + import { runRunCommand } from '../../../src/cli/commands/run.js'; import { ERROR_CODES } from '../../../src/protocol/errors.js'; import { createLogger } from '../../../src/util/logger.js'; @@ -47,25 +38,13 @@ const TEST_CONTEXT = { configFile: null, } as const; -function createSessionRecord( - status: 'running' | 'exited' | 'destroyed' = 'running', -) { - return { - version: 1, - sessionId: 'session-01', - createdAt: '2026-03-19T12:00:00.000Z', - updatedAt: '2026-03-19T12:00:01.000Z', - status, - command: ['/bin/sh'], - cwd: '/tmp/workspace', - cols: 80, - rows: 24, - hostPid: status === 'running' ? 123 : null, - childPid: status === 'running' ? 456 : null, - exitCode: status === 'exited' ? 0 : null, - exitSignal: null, - }; -} +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; type RunCommandOptions = Parameters[0]; @@ -86,19 +65,7 @@ function createOptions( describe('run command', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.sessionDir.mockImplementation( - (_home: string, sessionId: string) => - `/tmp/agent-tty/sessions/${sessionId}`, - ); - mocks.manifestPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/session.json`, - ); - mocks.socketPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, - ); - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('running'), - ); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); mocks.resolveCommandInputText.mockResolvedValue('echo hello'); mocks.sendRpc.mockResolvedValue({ accepted: true, @@ -114,7 +81,7 @@ describe('run command', () => { await runRunCommand(createOptions()); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'run', { command: 'echo hello', @@ -147,7 +114,7 @@ describe('run command', () => { await runRunCommand(createOptions({ wait: false })); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'run', { command: 'echo hello', @@ -166,11 +133,38 @@ describe('run command', () => { }); }); + it('reports the timed-out branch when the host signals a timeout', async () => { + mocks.sendRpc.mockResolvedValueOnce({ + accepted: true, + completed: false, + timedOut: true, + seq: 17, + durationMs: 30_000, + marker: 'test-marker', + }); + + await runRunCommand(createOptions()); + + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'run', + json: false, + result: { + accepted: true, + completed: false, + timedOut: true, + seq: 17, + durationMs: 30_000, + marker: 'test-marker', + }, + lines: ['Command timed out after 30000ms (seq=17).'], + }); + }); + it('uses the provided timeout for wait mode', async () => { await runRunCommand(createOptions({ timeout: 5_000 })); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'run', { command: 'echo hello', @@ -189,44 +183,7 @@ describe('run command', () => { code: ERROR_CODES.INVALID_INPUT, message: 'Timeout must be a positive integer in milliseconds', }); - expect(mocks.readManifestIfExists).not.toHaveBeenCalled(); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_NOT_FOUND when the session does not exist', async () => { - mocks.readManifestIfExists.mockResolvedValueOnce(null); - - await expect(runRunCommand(createOptions())).rejects.toMatchObject({ - name: 'CliError', - code: ERROR_CODES.SESSION_NOT_FOUND, - message: 'Session "session-01" was not found.', - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_NOT_RUNNING when the session is not running', async () => { - mocks.readManifestIfExists.mockResolvedValueOnce( - createSessionRecord('exited'), - ); - - await expect(runRunCommand(createOptions())).rejects.toMatchObject({ - name: 'CliError', - code: ERROR_CODES.SESSION_NOT_RUNNING, - message: 'Session "session-01" is not running.', - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_ALREADY_DESTROYED when the session is destroyed', async () => { - mocks.readManifestIfExists.mockResolvedValueOnce( - createSessionRecord('destroyed'), - ); - - await expect(runRunCommand(createOptions())).rejects.toMatchObject({ - name: 'CliError', - code: ERROR_CODES.SESSION_ALREADY_DESTROYED, - message: 'Session "session-01" is already destroyed.', - }); + expect(mocks.resolveCommandTarget).not.toHaveBeenCalled(); expect(mocks.sendRpc).not.toHaveBeenCalled(); }); diff --git a/test/unit/commands/send-keys.test.ts b/test/unit/commands/send-keys.test.ts new file mode 100644 index 0000000..2eae21c --- /dev/null +++ b/test/unit/commands/send-keys.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), + sendRpc: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +import { runSendKeysCommand } from '../../../src/cli/commands/send-keys.js'; +import { createLogger } from '../../../src/util/logger.js'; + +const TEST_CONTEXT = { + home: '/tmp/agent-tty', + timeoutMs: undefined, + colorEnabled: true, + logLevel: 'info', + logger: createLogger('info', () => undefined), + profileDefault: undefined, + rendererDefault: 'ghostty-web', + configFile: null, +} as const; + +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; + +describe('send-keys command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); + mocks.sendRpc.mockResolvedValue({ + accepted: ['Enter'], + bytesWritten: 1, + seq: 12, + }); + }); + + it('sends encoded key names to a command target and emits host results', async () => { + await runSendKeysCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + keys: ['Enter'], + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-tty/sockets/session-01.sock', + 'sendKeys', + { keys: ['Enter'] }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'send-keys', + json: false, + result: { + accepted: ['Enter'], + bytesWritten: 1, + seq: 12, + }, + lines: ['Sent 1 key(s) (1 byte(s), seq 12).'], + }); + }); + + it('rejects PROTOCOL_ERROR responses without sending success output', async () => { + mocks.sendRpc.mockResolvedValueOnce({ unexpected: true }); + + await expect( + runSendKeysCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + keys: ['Enter'], + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.PROTOCOL_ERROR, + message: 'Unexpected response from host', + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands/signal.test.ts b/test/unit/commands/signal.test.ts new file mode 100644 index 0000000..e65adab --- /dev/null +++ b/test/unit/commands/signal.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), + sendRpc: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +import { runSignalCommand } from '../../../src/cli/commands/signal.js'; +import { createLogger } from '../../../src/util/logger.js'; + +const TEST_CONTEXT = { + home: '/tmp/agent-tty', + timeoutMs: undefined, + colorEnabled: true, + logLevel: 'info', + logger: createLogger('info', () => undefined), + profileDefault: undefined, + rendererDefault: 'ghostty-web', + configFile: null, +} as const; + +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; + +describe('signal command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); + }); + + it('sends an allowed signal to a command target and emits delivery', async () => { + await runSignalCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + signal: 'SIGUSR1', + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-tty/sockets/session-01.sock', + 'signal', + { signal: 'SIGUSR1' }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'signal', + json: false, + result: { signal: 'SIGUSR1', delivered: true }, + lines: ['Signal SIGUSR1 delivered to session.'], + }); + }); + + it('rejects invalid signals after resolving the command target', async () => { + await expect( + runSignalCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + signal: 'BAD', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_SIGNAL, + message: + 'Signal must be one of: SIGTERM, SIGINT, SIGKILL, SIGHUP, SIGUSR1, SIGUSR2.', + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands/type.test.ts b/test/unit/commands/type.test.ts index caab8eb..52bdb07 100644 --- a/test/unit/commands/type.test.ts +++ b/test/unit/commands/type.test.ts @@ -1,39 +1,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ERROR_CODES } from '../../../src/protocol/errors.js'; - const mocks = vi.hoisted(() => ({ emitSuccess: vi.fn(), - sendRpc: vi.fn(), - readManifestIfExists: vi.fn(), - sessionDir: vi.fn(), - manifestPath: vi.fn(), - socketPath: vi.fn(), + resolveCommandTarget: vi.fn(), resolveCommandInputText: vi.fn(), + sendRpc: vi.fn(), })); -vi.mock('../../../src/cli/output.js', () => ({ - emitSuccess: mocks.emitSuccess, -})); - -vi.mock('../../../src/host/rpcClient.js', () => ({ - sendRpc: mocks.sendRpc, -})); - -vi.mock('../../../src/storage/manifests.js', () => ({ - readManifestIfExists: mocks.readManifestIfExists, +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, })); -vi.mock('../../../src/storage/sessionPaths.js', () => ({ - sessionDir: mocks.sessionDir, - manifestPath: mocks.manifestPath, - socketPath: mocks.socketPath, +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, })); vi.mock('../../../src/cli/commands/inputSource.js', () => ({ resolveCommandInputText: mocks.resolveCommandInputText, })); +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + import { runTypeCommand } from '../../../src/cli/commands/type.js'; import { createLogger } from '../../../src/util/logger.js'; @@ -48,42 +37,18 @@ const TEST_CONTEXT = { configFile: null, } as const; -function createSessionRecord( - status: 'running' | 'exiting' | 'exited' | 'destroyed' = 'running', -) { - return { - version: 1, - sessionId: 'session-01', - createdAt: '2026-03-19T12:00:00.000Z', - updatedAt: '2026-03-19T12:00:01.000Z', - status, - command: ['/bin/sh'], - cwd: '/tmp/workspace', - cols: 80, - rows: 24, - hostPid: status === 'running' ? 123 : null, - childPid: status === 'running' ? 456 : null, - exitCode: status === 'exited' ? 0 : null, - exitSignal: null, - }; -} +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; describe('type command', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.sessionDir.mockImplementation( - (_home: string, sessionId: string) => - `/tmp/agent-tty/sessions/${sessionId}`, - ); - mocks.manifestPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/session.json`, - ); - mocks.socketPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, - ); - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('running'), - ); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); mocks.resolveCommandInputText.mockResolvedValue('hello'); }); @@ -101,11 +66,21 @@ describe('type command', () => { text: 'hello', file: undefined, }); + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: 'hello\n' }, ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'type', + json: false, + result: {}, + lines: ['Typed text into session.'], + }); }); it('appends exactly one newline to file-backed text when requested', async () => { @@ -126,7 +101,7 @@ describe('type command', () => { file: '/tmp/input.txt', }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: 'from-file\n' }, ); @@ -144,7 +119,7 @@ describe('type command', () => { }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: 'hello\n\n' }, ); @@ -159,13 +134,13 @@ describe('type command', () => { }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: 'hello' }, ); }); - it('allows empty resolved text when appendNewline is enabled', async () => { + it('forwards an empty resolved string when appendNewline is enabled', async () => { mocks.resolveCommandInputText.mockResolvedValueOnce(''); await runTypeCommand({ @@ -177,32 +152,25 @@ describe('type command', () => { }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: '\n' }, ); }); - it('throws SESSION_ALREADY_DESTROYED when the session is destroyed', async () => { - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('destroyed'), - ); + it('preserves JSON mode in the success envelope', async () => { + await runTypeCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + text: 'hello', + }); - await expect( - runTypeCommand({ - context: TEST_CONTEXT, - json: false, - sessionId: 'session-01', - text: 'hello', - }), - ).rejects.toMatchObject({ - code: ERROR_CODES.SESSION_ALREADY_DESTROYED, - message: 'Session "session-01" is already destroyed.', - details: { - sessionId: 'session-01', - status: 'destroyed', - }, + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'type', + json: true, + result: {}, + lines: ['Typed text into session.'], }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); }); });