feat(cli): add interactive Zoo terminal client - #1160
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
53e11e2 to
82307ab
Compare
dc868c1 to
270bd8f
Compare
82307ab to
a350044
Compare
a34840d to
c6511df
Compare
a350044 to
46179b8
Compare
c6511df to
cb131fc
Compare
f5dee31 to
77c038d
Compare
e45bf88 to
04e2307
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
04e2307 to
7f0cd10
Compare
48e5924 to
ea45605
Compare
7f0cd10 to
0ac5595
Compare
9c3ac7f to
d6d57d0
Compare
3325a7a to
eb180c7
Compare
d6d57d0 to
571b4a2
Compare
1fc922e to
c47d0c5
Compare
571b4a2 to
5b85926
Compare
5b85926 to
e4fcdea
Compare
c47d0c5 to
e122a32
Compare
| } | ||
| const instance = render(<App />, { exitOnCtrlC: false }) | ||
| if (initialPrompt) actions.submit(initialPrompt) | ||
| const result = await settled |
There was a problem hiding this comment.
What happens if the host process dies after task.start succeeds but before a task.result event arrives? client.failed is never raced against this promise, so on a host crash (watchdog timeout, SIGKILL) settle() is never called and the CLI hangs here with no escape. automation.ts races client.failed.catch(...) in a Promise.race — should the interactive path do the same?
| .then((response) => { | ||
| if (response.data.commandType === "task.start") rootTaskId = response.data.task.rootTaskId | ||
| }) | ||
| .catch(() => settle?.(undefined)) |
There was a problem hiding this comment.
If task.start rejects (bad credentials, host error, crash while the start is pending), this .catch settles with undefined, which runInteractive maps to exit 0 at line 158 — a task that never launched reports success, with no stderr message. Should this surface as a non-zero exit, or reset starting so the user can retry?
| const failedCode = | ||
| result.error?.code === "task_timed_out" || result.error?.code === "cleanup_timed_out" | ||
| ? "task_failed" | ||
| : (result.error?.code ?? "task_failed") | ||
| return exitCodeFor( | ||
| result.outcome === "failed" | ||
| ? { outcome: "failed", errorCode: failedCode } | ||
| : result.outcome === "cancelled" | ||
| ? { outcome: "cancelled" } | ||
| : result.outcome === "timed_out" | ||
| ? { outcome: "timed_out" } | ||
| : { outcome: result.outcome }, | ||
| ) |
There was a problem hiding this comment.
This failedCode mapping mirrors automation.ts:155-173, and the two copies already diverge — automation passes a signal field for the cancelled outcome and keeps a timed-out sub-branch, this one omits both. Could this be a shared helper so the exit-code logic can't drift?
| }) | ||
|
|
||
| program.action(() => { | ||
| program.action(async (words: string[] | undefined, options: SharedOptions) => { |
There was a problem hiding this comment.
shared() is applied to the subcommands but not the root program, so none of --provider/--model/--approval/--ephemeral/--debug are registered here — at runtime options is {} and zoo --ephemeral "prompt" throws "unknown option". Should the root program call shared(program) before .action(...)?
| const client = new HostClient({ | ||
| workspace: options.workspace, | ||
| storageRoot, | ||
| extensionRoot: process.env.ZOO_EXTENSION_PATH ?? fileURLToPath(new URL("../../../src/dist", import.meta.url)), |
There was a problem hiding this comment.
Is ZOO_EXTENSION_PATH documented anywhere? It's the only way to point the CLI at a dev or custom extension root, but I don't see it in a README or .env.example.
| .catch(() => settle?.(undefined)) | ||
| return | ||
| } | ||
| if (currentTaskId) void client.command({ type: "task.input", taskId: currentTaskId, text }) |
There was a problem hiding this comment.
These follow-up client.command(...) calls (here and at ask.respond / cancel) have no .catch() — if the host dies, supervisor.ts rejects every pending command and this surfaces as an unhandled rejection, or a silently lost follow-up. Intentional?
| const result = await settled | ||
| instance.unmount() | ||
| await client.stop() | ||
| if (options.ephemeral) fs.rmSync(storageRoot, { recursive: true, force: true }) |
There was a problem hiding this comment.
If client.start() throws at line 110, control leaves runInteractive before this rmSync — the ephemeral mkdtemp directory leaks. automation.ts wraps its cleanup in a finally; should this match that pattern?
| let projection = initialProjection() | ||
| let update: ((projection: SessionProjection) => void) | undefined | ||
| let rootTaskId: string | undefined | ||
| let currentTaskId: string | undefined |
There was a problem hiding this comment.
This currentTaskId is overwritten with projection.currentTaskId on every event and only read at line 129, so it always equals the projection field. Could the reads just use projection.currentTaskId?
|
|
||
| program.action(() => { | ||
| program.action(async (words: string[] | undefined, options: SharedOptions) => { | ||
| if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive Zoo requires TTY stdin and stdout") |
There was a problem hiding this comment.
With this TTY guard in place and no interactive subprocess test, the default zoo path (onEvent → settle → unmount → exitCodeFor) has no packaged/CLI-layer coverage — subprocess.test.ts covers run/resume/sessions only. Worth a ZOO_FORCE_INTERACTIVE bypass and a fake-host subprocess test?
| "@roo-code/config-eslint": "workspace:^", | ||
| "@roo-code/config-typescript": "workspace:^", | ||
| "@types/node": "22.20.1", | ||
| "@types/react": "18.3.31", |
There was a problem hiding this comment.
why not match the react version v19?
Stack
Position 5 of 6 in the Zoo CLI stack.
fm/zoo-cli-automation(PR feat(cli): add Zoo automation client #1159)fm/zoo-cli-release-docs(planned)gh stack submitreports stacked PRs are not enabled.Scope
SessionProjectionused by automation.Acceptance Evidence
pnpm --dir apps/zoo test(10 tests, including Ink rendering and packaged subprocess automation)pnpm --dir apps/zoo check-typesRisk
Terminal input is intentionally focused rather than a broad control protocol. Ink is dynamically imported so its signal handling cannot affect
run, JSON, or NDJSON automation paths.flowchart LR U[TTY user] --> I[Ink client] I --> A[Input and approval actions] A --> H[Supervised zoo-host] H --> E[Production extension] E --> N[Normalized events] N --> R[SessionProjection reducer] R --> I