Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/zoo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@
},
"dependencies": {
"@roo-code/zoo-protocol": "workspace:^",
"commander": "^12.1.0"
"commander": "^12.1.0",
"ink": "^6.6.0",
"react": "^19.1.0"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "22.20.1",
"@types/react": "18.3.31",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not match the react version v19?

"ink-testing-library": "4.0.0",
"rimraf": "6.0.1",
"tsup": "8.5.1",
"vitest": "4.1.9"
Expand Down
24 changes: 24 additions & 0 deletions apps/zoo/src/__tests__/interactive.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { render } from "ink-testing-library"
import { describe, expect, it, vi } from "vitest"

import { initialProjection } from "../projection.js"
import { InteractiveSession } from "../interactive.js"

describe("InteractiveSession", () => {
it("submits input and renders approval controls", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test renders the component but never drives view.stdin.write(...), so the useInput branches (approve y/n, submit, cancel, exit) are unexercised — deleting if (input.trim()) actions.submit(...) or flipping the y/n mapping wouldn't fail it. The name also implies submit is covered, but it never triggers it. Could you simulate keypresses and assert the mocks are called? The empty-pendingAsks case (no "Approval required") looks uncovered too.

const submit = vi.fn()
const projection = {
...initialProjection(),
pendingAsks: new Map([["ask-1", { taskId: "root", category: "tool", subject: "Write file?" }]]),
}
const view = render(
<InteractiveSession
projection={projection}
actions={{ submit, approve: vi.fn(), cancel: vi.fn(), exit: vi.fn() }}
/>,
)

expect(view.lastFrame()).toContain("Approval required")
expect(view.lastFrame()).toContain("Write file?")
})
})
10 changes: 8 additions & 2 deletions apps/zoo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ function normalized(options: SharedOptions & { format: OutputFormat; quiet: bool
return { ...options, workspace: resolveWorkspace(options.cwd), timeout: parseDuration(options.timeout) }
}

program.argument("[prompt...]")

automation(program.command("run [prompt...]").description("run one task without an interactive UI")).action(
async (words: string[] | undefined, options: SharedOptions & { format: OutputFormat; quiet: boolean }) => {
const positional = words?.join(" ").trim()
Expand All @@ -72,9 +74,13 @@ shared(
await listSessions({ ...options, workspace: resolveWorkspace(options.cwd) })
})

program.action(() => {
program.action(async (words: string[] | undefined, options: SharedOptions) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(...)?

if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive Zoo requires TTY stdin and stdout")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

throw new Error("Interactive Zoo is not available in this build")
const { runInteractive } = await import("./interactive.js")
process.exitCode = await runInteractive(words?.join(" ").trim(), {
...options,
workspace: resolveWorkspace(options.cwd ?? process.cwd()),
})
})

program.showSuggestionAfterError().showHelpAfterError()
Expand Down
172 changes: 172 additions & 0 deletions apps/zoo/src/interactive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"

import { Box, Text, render, useInput } from "ink"
import { useState } from "react"

import { exitCodeFor, type ZooRunResult } from "@roo-code/zoo-protocol"

import { runOverrides, type SharedOptions } from "./options.js"
import { initialProjection, reduceSession, type SessionProjection } from "./projection.js"
import { defaultStorageRoot, HostClient } from "./supervisor.js"

type InteractiveOptions = Omit<SharedOptions, "cwd" | "timeout"> & { workspace: string }

type Actions = {
submit: (text: string) => void
approve: (approve: boolean) => void
cancel: () => void
exit: () => void
}

export function InteractiveSession({ projection, actions }: { projection: SessionProjection; actions: Actions }) {
const [input, setInput] = useState("")
const ask = [...projection.pendingAsks.entries()][0]

useInput((value, key) => {
if (key.ctrl && value === "c") return actions.cancel()
if (key.ctrl && value === "d" && input.length === 0) return actions.exit()
if (ask && (value.toLowerCase() === "y" || value.toLowerCase() === "n")) {
actions.approve(value.toLowerCase() === "y")
return
}
if (key.return) {
if (input.trim()) actions.submit(input.trim())
setInput("")
return
}
if (key.backspace || key.delete) return setInput((current) => current.slice(0, -1))
if (!key.ctrl && !key.meta && value) setInput((current) => current + value)
})

return (
<Box flexDirection="column" paddingX={1}>
<Box borderStyle="round" borderColor="cyan" paddingX={1} justifyContent="space-between">
<Text bold color="cyan">
Zoo Code
</Text>
<Text>{projection.rootTaskId ? `session ${projection.rootTaskId}` : "ready"}</Text>
</Box>
{[...projection.messages.entries()].map(([id, message]) => (
<Box key={id} marginTop={1} flexDirection="column">
<Text color={message.role === "reasoning" ? "gray" : "white"}>{message.role}</Text>
<Text wrap="wrap">{message.content}</Text>
</Box>
))}
{[...projection.tools.entries()].map(([id, tool]) => (
<Box
key={id}
borderStyle="single"
borderColor={tool.state === "failed" ? "red" : "yellow"}
paddingX={1}>
<Text>{`${tool.name} · ${tool.state}${tool.output ? ` · ${tool.output}` : ""}`}</Text>
</Box>
))}
{ask ? (
<Box borderStyle="round" borderColor="magenta" paddingX={1} flexDirection="column">
<Text bold>Approval required</Text>
<Text>{ask[1].subject}</Text>
<Text color="gray">Press y to approve once or n to reject</Text>
</Box>
) : null}
{projection.result ? (
<Text color={projection.result.success ? "green" : "red"}>{projection.result.outcome}</Text>
) : null}
<Box marginTop={1}>
<Text color="cyan">› </Text>
<Text>{input}</Text>
</Box>
<Text color="gray">Enter sends · Ctrl+C cancels · Ctrl+D exits when idle</Text>
</Box>
)
}

export async function runInteractive(initialPrompt: string | undefined, options: InteractiveOptions): Promise<number> {
const storageRoot = options.ephemeral
? fs.mkdtempSync(path.join(os.tmpdir(), "zoo-"))
: path.join(defaultStorageRoot(), "state")
fs.mkdirSync(storageRoot, { recursive: true })
let projection = initialProjection()
let update: ((projection: SessionProjection) => void) | undefined
let rootTaskId: string | undefined
let currentTaskId: string | undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

let settle: ((result: ZooRunResult | undefined) => void) | undefined
const settled = new Promise<ZooRunResult | undefined>((resolve) => (settle = resolve))
const client = new HostClient({
workspace: options.workspace,
storageRoot,
extensionRoot: process.env.ZOO_EXTENSION_PATH ?? fileURLToPath(new URL("../../../src/dist", import.meta.url)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

debug: options.debug,
onEvent(event) {
projection = reduceSession(projection, event)
currentTaskId = projection.currentTaskId
update?.(projection)
if (event.type === "task.result") settle?.(event.result)
},
})

await client.start()
let starting = false
const actions: Actions = {
submit(text) {
if (!rootTaskId && !starting) {
starting = true
void client
.command({
type: "task.start",
workspace: options.workspace,
prompt: text,
overrides: runOverrides({ ...options, approval: "interactive" }),
})
.then((response) => {
if (response.data.commandType === "task.start") rootTaskId = response.data.task.rootTaskId
})
.catch(() => settle?.(undefined))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

return
}
if (currentTaskId) void client.command({ type: "task.input", taskId: currentTaskId, text })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

},
approve(approve) {
const pending = [...projection.pendingAsks.entries()][0]
if (!pending) return
void client.command({
type: "ask.respond",
taskId: pending[1].taskId,
askId: pending[0],
response: approve ? "approve" : "reject",
})
},
cancel() {
if (rootTaskId) void client.command({ type: "task.cancel", rootTaskId, reason: "user" })
else settle?.(undefined)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Ctrl+C lands between task.start being sent (line 116, starting = true) and rootTaskId being populated (line 124), this takes the settle(undefined) → exit 0 branch while the host may still accept the side-effecting task. Should cancel await the start outcome when starting is set?

},
exit: () => settle?.(undefined),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exit fires whenever input is empty, even with a task running or an ask pending — the in-flight outcome is discarded and runInteractive returns 0. The "exits when idle" hint at line 81 isn't enforced by any idle check. Should exit be gated when a task/ask is active?

}
const App = () => {
const [state, setState] = useState(projection)
update = setState
return <InteractiveSession projection={state} actions={actions} />
}
const instance = render(<App />, { exitOnCtrlC: false })
if (initialPrompt) actions.submit(initialPrompt)
const result = await settled

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

instance.unmount()
await client.stop()
if (options.ephemeral) fs.rmSync(storageRoot, { recursive: true, force: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

if (!result) return 0
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 },
)
Comment on lines +159 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

}
2 changes: 1 addition & 1 deletion apps/zoo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"extends": "@roo-code/config-typescript/base.json",
"compilerOptions": { "outDir": "dist" },
"compilerOptions": { "outDir": "dist", "jsx": "react-jsx" },
"include": ["src", "*.config.ts"],
"exclude": ["node_modules"]
}
5 changes: 5 additions & 0 deletions apps/zoo/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ export default defineConfig({
platform: "node",
banner: { js: "#!/usr/bin/env node" },
noExternal: ["@roo-code/zoo-protocol"],
external: ["react-devtools-core"],
esbuildOptions(options) {
options.jsx = "automatic"
options.jsxImportSource = "react"
},
})
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading