mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
feat(cli): support --create-with-session-id and UUID session validation (#11859)
feat(cli): add create-with-session-id support rename public task id flag to --create-with-session-id validate session ids as UUIDs for create/resume and stdin start.taskId add integration coverage for create+resume loading correct session
This commit is contained in:
parent
0db51d8bfa
commit
0b0b33e6a2
12 changed files with 548 additions and 31 deletions
|
|
@ -113,15 +113,21 @@ Use `--print` for non-interactive execution and machine-readable output:
|
|||
```bash
|
||||
# Prompt is required
|
||||
roo --print "Summarize this repository"
|
||||
|
||||
# Create a new task with a specific session ID (UUID)
|
||||
roo --print --create-with-session-id 018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87 "Summarize this repository"
|
||||
```
|
||||
|
||||
### Stdin Stream Mode (`--stdin-prompt-stream`)
|
||||
|
||||
For programmatic control (one process, multiple prompts), use `--stdin-prompt-stream` with `--print`.
|
||||
Send one prompt per line via stdin:
|
||||
Send NDJSON commands via stdin:
|
||||
|
||||
```bash
|
||||
printf '1+1=?\n10!=?\n' | roo --print --stdin-prompt-stream --output-format stream-json
|
||||
printf '{"command":"start","requestId":"1","prompt":"1+1=?"}\n' | roo --print --stdin-prompt-stream --output-format stream-json
|
||||
|
||||
# Optional: provide taskId per start command
|
||||
printf '{"command":"start","requestId":"1","taskId":"018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87","prompt":"1+1=?"}\n' | roo --print --stdin-prompt-stream --output-format stream-json
|
||||
```
|
||||
|
||||
### Roo Code Cloud Authentication
|
||||
|
|
@ -170,26 +176,27 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo
|
|||
|
||||
## Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `[prompt]` | Your prompt (positional argument, optional) | None |
|
||||
| `--prompt-file <path>` | Read prompt from a file instead of command line argument | None |
|
||||
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
|
||||
| `-p, --print` | Print response and exit (non-interactive mode) | `false` |
|
||||
| `--stdin-prompt-stream` | Read prompts from stdin (one prompt per line, requires `--print`) | `false` |
|
||||
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
|
||||
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
|
||||
| `-a, --require-approval` | Require manual approval before actions execute | `false` |
|
||||
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
|
||||
| `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
|
||||
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
|
||||
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
|
||||
| `--terminal-shell <path>` | Absolute shell path for inline terminal command execution | Auto-detected shell |
|
||||
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
|
||||
| `--consecutive-mistake-limit <n>` | Consecutive error/repetition limit before guidance prompt (`0` disables the limit) | `10` |
|
||||
| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` |
|
||||
| `--oneshot` | Exit upon task completion | `false` |
|
||||
| `--output-format <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` |
|
||||
| Option | Description | Default |
|
||||
| --------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `[prompt]` | Your prompt (positional argument, optional) | None |
|
||||
| `--prompt-file <path>` | Read prompt from a file instead of command line argument | None |
|
||||
| `--create-with-session-id <session-id>` | Create a new task using the provided session ID (UUID) | None |
|
||||
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
|
||||
| `-p, --print` | Print response and exit (non-interactive mode) | `false` |
|
||||
| `--stdin-prompt-stream` | Read NDJSON control commands from stdin (requires `--print`) | `false` |
|
||||
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
|
||||
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
|
||||
| `-a, --require-approval` | Require manual approval before actions execute | `false` |
|
||||
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
|
||||
| `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
|
||||
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
|
||||
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
|
||||
| `--terminal-shell <path>` | Absolute shell path for inline terminal command execution | Auto-detected shell |
|
||||
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
|
||||
| `--consecutive-mistake-limit <n>` | Consecutive error/repetition limit before guidance prompt (`0` disables the limit) | `10` |
|
||||
| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` |
|
||||
| `--oneshot` | Exit upon task completion | `false` |
|
||||
| `--output-format <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` |
|
||||
|
||||
## Auth Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import readline from "readline"
|
||||
import { fileURLToPath } from "url"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
import { execa } from "execa"
|
||||
import type { TaskSessionEntry } from "@roo-code/core/cli"
|
||||
|
||||
type StreamEvent = {
|
||||
type?: string
|
||||
subtype?: string
|
||||
requestId?: string
|
||||
command?: string
|
||||
taskId?: string
|
||||
content?: string
|
||||
code?: string
|
||||
success?: boolean
|
||||
done?: boolean
|
||||
}
|
||||
|
||||
const RESUME_TIMEOUT_MS = 180_000
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function parseStreamEvent(line: string): StreamEvent | null {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed.startsWith("{")) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed) as StreamEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function listSessions(cliRoot: string, workspacePath: string): Promise<TaskSessionEntry[]> {
|
||||
const result = await execa("pnpm", ["dev", "list", "sessions", "--workspace", workspacePath, "--format", "json"], {
|
||||
cwd: cliRoot,
|
||||
reject: false,
|
||||
})
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`list sessions failed with exit code ${result.exitCode}: ${result.stderr || result.stdout}`)
|
||||
}
|
||||
|
||||
const stdoutLines = result.stdout.split("\n")
|
||||
const jsonStartIndex = stdoutLines.findIndex((line) => line.trim().startsWith("{"))
|
||||
if (jsonStartIndex === -1) {
|
||||
throw new Error(`list sessions output did not contain JSON payload: ${result.stdout}`)
|
||||
}
|
||||
|
||||
const jsonPayload = stdoutLines.slice(jsonStartIndex).join("\n").trim()
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(jsonPayload)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`failed to parse list sessions output as JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("sessions" in parsed) ||
|
||||
!Array.isArray((parsed as { sessions?: unknown }).sessions)
|
||||
) {
|
||||
throw new Error("list sessions output missing sessions array")
|
||||
}
|
||||
|
||||
return (parsed as { sessions: TaskSessionEntry[] }).sessions
|
||||
}
|
||||
|
||||
async function createSessionWithCustomId(
|
||||
cliRoot: string,
|
||||
workspacePath: string,
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
const result = await execa(
|
||||
"pnpm",
|
||||
[
|
||||
"dev",
|
||||
"--print",
|
||||
"--provider",
|
||||
"roo",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--workspace",
|
||||
workspacePath,
|
||||
"--create-with-session-id",
|
||||
sessionId,
|
||||
prompt,
|
||||
],
|
||||
{
|
||||
cwd: cliRoot,
|
||||
reject: false,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`create-with-session-id failed for ${sessionId} with exit code ${result.exitCode}: ${result.stderr || result.stdout}`,
|
||||
)
|
||||
}
|
||||
|
||||
const lines = result.stdout.split("\n")
|
||||
const events = lines.map(parseStreamEvent).filter((event): event is StreamEvent => Boolean(event))
|
||||
const errorEvent = events.find((event) => event.type === "error")
|
||||
|
||||
if (errorEvent) {
|
||||
throw new Error(
|
||||
`create-with-session-id emitted error for ${sessionId}: code=${errorEvent.code ?? "none"} content=${errorEvent.content ?? ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
const completion = events.find((event) => event.type === "result" && event.done === true)
|
||||
if (!completion) {
|
||||
throw new Error(`create-with-session-id did not emit final result for ${sessionId}`)
|
||||
}
|
||||
|
||||
if (completion.success !== true) {
|
||||
throw new Error(`create-with-session-id completed unsuccessfully for ${sessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeSessionAndSendMarker(
|
||||
cliRoot: string,
|
||||
workspacePath: string,
|
||||
sessionId: string,
|
||||
messageToken: string,
|
||||
): Promise<void> {
|
||||
const pingRequestId = `ping-${Date.now()}`
|
||||
const messageRequestId = `message-${Date.now()}`
|
||||
const shutdownRequestId = `shutdown-${Date.now()}`
|
||||
|
||||
const messagePrompt = `Resume marker token: ${messageToken}. Reply with exactly "ack-${messageToken}".`
|
||||
|
||||
const child = execa(
|
||||
"pnpm",
|
||||
[
|
||||
"dev",
|
||||
"--print",
|
||||
"--stdin-prompt-stream",
|
||||
"--provider",
|
||||
"roo",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--workspace",
|
||||
workspacePath,
|
||||
"--session-id",
|
||||
sessionId,
|
||||
],
|
||||
{
|
||||
cwd: cliRoot,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
reject: false,
|
||||
forceKillAfterDelay: 2_000,
|
||||
},
|
||||
)
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
process.stderr.write(chunk)
|
||||
})
|
||||
|
||||
let pingSent = false
|
||||
let messageSent = false
|
||||
let shutdownSent = false
|
||||
let sawMessageControlDone = false
|
||||
let sawUserTurnWithMarker = false
|
||||
let shutdownTaskId: string | undefined
|
||||
let handlerError: Error | null = null
|
||||
let timedOut = false
|
||||
|
||||
const sendCommand = (command: { command: "ping" | "message" | "shutdown"; requestId: string; prompt?: string }) => {
|
||||
if (!child.stdin || child.stdin.destroyed) {
|
||||
return
|
||||
}
|
||||
child.stdin.write(`${JSON.stringify(command)}\n`)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
handlerError = new Error(
|
||||
`timed out resuming session ${sessionId} (pingSent=${pingSent}, messageSent=${messageSent}, sawMessageControlDone=${sawMessageControlDone}, sawUserTurnWithMarker=${sawUserTurnWithMarker})`,
|
||||
)
|
||||
child.kill("SIGTERM")
|
||||
}, RESUME_TIMEOUT_MS)
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: child.stdout!,
|
||||
crlfDelay: Infinity,
|
||||
})
|
||||
|
||||
rl.on("line", (line) => {
|
||||
process.stdout.write(`${line}\n`)
|
||||
|
||||
const event = parseStreamEvent(line)
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "system" && event.subtype === "init" && !pingSent) {
|
||||
pingSent = true
|
||||
sendCommand({ command: "ping", requestId: pingRequestId })
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "control" &&
|
||||
event.subtype === "done" &&
|
||||
event.command === "ping" &&
|
||||
event.requestId === pingRequestId &&
|
||||
!messageSent
|
||||
) {
|
||||
messageSent = true
|
||||
sendCommand({
|
||||
command: "message",
|
||||
requestId: messageRequestId,
|
||||
prompt: messagePrompt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "control" &&
|
||||
event.subtype === "error" &&
|
||||
event.command === "message" &&
|
||||
event.requestId === messageRequestId
|
||||
) {
|
||||
handlerError = new Error(
|
||||
`message command failed while resuming ${sessionId}: code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
|
||||
)
|
||||
child.kill("SIGTERM")
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "control" &&
|
||||
event.subtype === "done" &&
|
||||
event.command === "message" &&
|
||||
event.requestId === messageRequestId
|
||||
) {
|
||||
sawMessageControlDone = true
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "user" && event.requestId === messageRequestId && event.content?.includes(messageToken)) {
|
||||
sawUserTurnWithMarker = true
|
||||
|
||||
if (!shutdownSent) {
|
||||
shutdownSent = true
|
||||
sendCommand({ command: "shutdown", requestId: shutdownRequestId })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "control" &&
|
||||
(event.subtype === "ack" || event.subtype === "done") &&
|
||||
event.command === "shutdown" &&
|
||||
event.requestId === shutdownRequestId &&
|
||||
typeof event.taskId === "string"
|
||||
) {
|
||||
shutdownTaskId = event.taskId
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "control" && event.subtype === "error" && event.requestId !== shutdownRequestId) {
|
||||
handlerError = new Error(
|
||||
`unexpected control error while resuming ${sessionId}: command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
|
||||
)
|
||||
child.kill("SIGTERM")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const result = await child
|
||||
clearTimeout(timeout)
|
||||
rl.close()
|
||||
|
||||
if (handlerError) {
|
||||
throw handlerError
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
throw new Error(`stream resume for ${sessionId} timed out`)
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`stream resume for ${sessionId} exited non-zero: ${result.exitCode}`)
|
||||
}
|
||||
|
||||
if (!sawMessageControlDone) {
|
||||
throw new Error(`did not observe message control completion while resuming ${sessionId}`)
|
||||
}
|
||||
|
||||
if (!sawUserTurnWithMarker) {
|
||||
throw new Error(`did not observe resumed user marker turn while resuming ${sessionId}`)
|
||||
}
|
||||
|
||||
if (shutdownTaskId !== sessionId) {
|
||||
throw new Error(
|
||||
`shutdown taskId did not match resumed session (expected=${sessionId}, actual=${shutdownTaskId ?? "none"})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cliRoot = process.env.ROO_CLI_ROOT
|
||||
? path.resolve(process.env.ROO_CLI_ROOT)
|
||||
: path.resolve(__dirname, "../../..")
|
||||
const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-cli-create-session-id-"))
|
||||
|
||||
const firstSessionId = randomUUID()
|
||||
const secondSessionId = randomUUID()
|
||||
const firstMarker = `FIRST-MARKER-${Date.now()}`
|
||||
const secondMarker = `SECOND-MARKER-${Date.now()}`
|
||||
|
||||
try {
|
||||
await createSessionWithCustomId(
|
||||
cliRoot,
|
||||
workspacePath,
|
||||
firstSessionId,
|
||||
`Create first session marker ${firstMarker}. Reply with exactly "ok-${firstMarker}".`,
|
||||
)
|
||||
await createSessionWithCustomId(
|
||||
cliRoot,
|
||||
workspacePath,
|
||||
secondSessionId,
|
||||
`Create second session marker ${secondMarker}. Reply with exactly "ok-${secondMarker}".`,
|
||||
)
|
||||
|
||||
const initialSessions = await listSessions(cliRoot, workspacePath)
|
||||
if (!initialSessions.some((session) => session.id === firstSessionId)) {
|
||||
throw new Error(`session list missing first custom session id ${firstSessionId}`)
|
||||
}
|
||||
if (!initialSessions.some((session) => session.id === secondSessionId)) {
|
||||
throw new Error(`session list missing second custom session id ${secondSessionId}`)
|
||||
}
|
||||
|
||||
const resumeMarkerForFirst = `resume-first-${Date.now()}`
|
||||
await resumeSessionAndSendMarker(cliRoot, workspacePath, firstSessionId, resumeMarkerForFirst)
|
||||
|
||||
const resumeMarkerForSecond = `resume-second-${Date.now()}`
|
||||
await resumeSessionAndSendMarker(cliRoot, workspacePath, secondSessionId, resumeMarkerForSecond)
|
||||
|
||||
console.log(`[PASS] created and resumed custom sessions: ${firstSessionId}, ${secondSessionId}`)
|
||||
} finally {
|
||||
await fs.rm(workspacePath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -10,6 +10,24 @@ describe("parseStdinStreamCommand", () => {
|
|||
expect(result).toEqual({ command: "start", requestId: "req-1", prompt: "hello" })
|
||||
})
|
||||
|
||||
it("parses a start command with taskId", () => {
|
||||
const result = parseStdinStreamCommand(
|
||||
JSON.stringify({
|
||||
command: "start",
|
||||
requestId: "req-task-id",
|
||||
prompt: "hello",
|
||||
taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87",
|
||||
}),
|
||||
1,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
command: "start",
|
||||
requestId: "req-task-id",
|
||||
prompt: "hello",
|
||||
taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87",
|
||||
})
|
||||
})
|
||||
|
||||
it("parses a message command", () => {
|
||||
const result = parseStdinStreamCommand(
|
||||
JSON.stringify({ command: "message", requestId: "req-2", prompt: "follow up" }),
|
||||
|
|
@ -129,6 +147,44 @@ describe("parseStdinStreamCommand", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("throws when start taskId is empty, not a string, or not a UUID", () => {
|
||||
expect(() =>
|
||||
parseStdinStreamCommand(
|
||||
JSON.stringify({
|
||||
command: "start",
|
||||
requestId: "req-start-task-id-empty",
|
||||
prompt: "hello",
|
||||
taskId: " ",
|
||||
}),
|
||||
1,
|
||||
),
|
||||
).toThrow('"start" taskId must be a non-empty string')
|
||||
|
||||
expect(() =>
|
||||
parseStdinStreamCommand(
|
||||
JSON.stringify({
|
||||
command: "start",
|
||||
requestId: "req-start-task-id-num",
|
||||
prompt: "hello",
|
||||
taskId: 123,
|
||||
}),
|
||||
1,
|
||||
),
|
||||
).toThrow('"start" taskId must be a non-empty string')
|
||||
|
||||
expect(() =>
|
||||
parseStdinStreamCommand(
|
||||
JSON.stringify({
|
||||
command: "start",
|
||||
requestId: "req-start-task-id-invalid-format",
|
||||
prompt: "hello",
|
||||
taskId: "task-123",
|
||||
}),
|
||||
1,
|
||||
),
|
||||
).toThrow('"start" taskId must be a valid UUID')
|
||||
})
|
||||
|
||||
it("throws when message command has empty prompt", () => {
|
||||
expect(() =>
|
||||
parseStdinStreamCommand(JSON.stringify({ command: "message", requestId: "req", prompt: " " }), 1),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
|
|||
import { runOnboarding } from "@/lib/utils/onboarding.js"
|
||||
import { validateTerminalShellPath } from "@/lib/utils/shell.js"
|
||||
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
|
||||
import { isValidSessionId } from "@/lib/utils/session-id.js"
|
||||
import { VERSION } from "@/lib/utils/version.js"
|
||||
|
||||
import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js"
|
||||
|
|
@ -126,11 +127,32 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
}
|
||||
|
||||
const requestedSessionId = flagOptions.sessionId?.trim()
|
||||
const requestedCreateSessionId = flagOptions.createWithSessionId?.trim()
|
||||
const shouldContinueSession = flagOptions.continue
|
||||
const isResumeRequested = Boolean(requestedSessionId || shouldContinueSession)
|
||||
|
||||
if (flagOptions.createWithSessionId !== undefined && !requestedCreateSessionId) {
|
||||
console.error("[CLI] Error: --create-with-session-id requires a non-empty session id")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (flagOptions.sessionId !== undefined && !requestedSessionId) {
|
||||
console.error("[CLI] Error: --session-id requires a non-empty task id")
|
||||
console.error("[CLI] Error: --session-id requires a non-empty session id")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (requestedCreateSessionId && !isValidSessionId(requestedCreateSessionId)) {
|
||||
console.error("[CLI] Error: --create-with-session-id must be a valid UUID session id")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (requestedSessionId && !isValidSessionId(requestedSessionId)) {
|
||||
console.error("[CLI] Error: --session-id must be a valid UUID session id")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (requestedCreateSessionId && isResumeRequested) {
|
||||
console.error("[CLI] Error: cannot use --create-with-session-id with --session-id/--continue")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +163,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
|
||||
if (isResumeRequested && prompt) {
|
||||
console.error("[CLI] Error: cannot use prompt or --prompt-file with --session-id/--continue")
|
||||
console.error("[CLI] Usage: roo [--session-id <task-id> | --continue] [options]")
|
||||
console.error("[CLI] Usage: roo [--session-id <session-id> | --continue] [options]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -342,6 +364,12 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
if (flagOptions.stdinPromptStream && requestedCreateSessionId) {
|
||||
console.error("[CLI] Error: --create-with-session-id is not supported with --stdin-prompt-stream")
|
||||
console.error('[CLI] Use per-request "taskId" in stdin start commands instead.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const useStdinPromptStream = flagOptions.stdinPromptStream
|
||||
let resolvedResumeSessionId: string | undefined
|
||||
|
||||
|
|
@ -389,6 +417,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
createElement(App, {
|
||||
...extensionHostOptions,
|
||||
initialPrompt: prompt,
|
||||
initialTaskId: requestedCreateSessionId,
|
||||
initialSessionId: resolvedResumeSessionId,
|
||||
continueSession: false,
|
||||
version: VERSION,
|
||||
|
|
@ -612,7 +641,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
if (isResumeRequested) {
|
||||
await host.resumeTask(resolvedResumeSessionId!)
|
||||
} else {
|
||||
await host.runTask(prompt!)
|
||||
await host.runTask(prompt!, requestedCreateSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from "@roo-code/types"
|
||||
|
||||
import { isRecord } from "@/lib/utils/guards.js"
|
||||
import { isValidSessionId } from "@/lib/utils/session-id.js"
|
||||
import { isCancellationLikeError, isExpectedControlFlowError, isNoActiveTaskLikeError } from "./cancellation.js"
|
||||
|
||||
import type { ExtensionHost } from "@/agent/index.js"
|
||||
|
|
@ -79,13 +80,38 @@ export function parseStdinStreamCommand(line: string, lineNumber: number): Stdin
|
|||
images = imagesRaw
|
||||
}
|
||||
|
||||
if (command === "start" && isRecord(parsed.configuration)) {
|
||||
if (command === "start") {
|
||||
const taskIdRaw = parsed.taskId
|
||||
let taskId: string | undefined
|
||||
|
||||
if (taskIdRaw !== undefined) {
|
||||
if (typeof taskIdRaw !== "string" || taskIdRaw.trim().length === 0) {
|
||||
throw new Error(`stdin command line ${lineNumber}: "start" taskId must be a non-empty string`)
|
||||
}
|
||||
taskId = taskIdRaw.trim()
|
||||
|
||||
if (!isValidSessionId(taskId)) {
|
||||
throw new Error(`stdin command line ${lineNumber}: "start" taskId must be a valid UUID`)
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(parsed.configuration)) {
|
||||
return {
|
||||
command,
|
||||
requestId,
|
||||
prompt: promptRaw,
|
||||
...(taskId !== undefined ? { taskId } : {}),
|
||||
...(images !== undefined ? { images } : {}),
|
||||
configuration: parsed.configuration as RooCliStartCommand["configuration"],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command,
|
||||
requestId,
|
||||
prompt: promptRaw,
|
||||
...(taskId !== undefined ? { taskId } : {}),
|
||||
...(images !== undefined ? { images } : {}),
|
||||
configuration: parsed.configuration as RooCliStartCommand["configuration"],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -616,7 +642,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
activeRequestId = stdinCommand.requestId
|
||||
activeTaskCommand = "start"
|
||||
setStreamRequestId(stdinCommand.requestId)
|
||||
latestTaskId = randomUUID()
|
||||
latestTaskId = stdinCommand.taskId ?? randomUUID()
|
||||
cancelRequestedForActiveTask = false
|
||||
awaitingPostCancelRecovery = false
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ program
|
|||
program
|
||||
.argument("[prompt]", "Your prompt")
|
||||
.option("--prompt-file <path>", "Read prompt from a file instead of command line argument")
|
||||
.option("--session-id <task-id>", "Resume a specific task by task ID")
|
||||
.option("--create-with-session-id <session-id>", "Create a new task with a specific session ID (must be a UUID)")
|
||||
.option("--session-id <session-id>", "Resume a specific task by session ID")
|
||||
.option("-c, --continue", "Resume the most recent task in the current workspace", false)
|
||||
.option("-w, --workspace <path>", "Workspace directory path (defaults to current working directory)")
|
||||
.option("-p, --print", "Print response and exit (non-interactive mode)", false)
|
||||
|
|
|
|||
5
apps/cli/src/lib/utils/session-id.ts
Normal file
5
apps/cli/src/lib/utils/session-id.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const SESSION_ID_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
export function isValidSessionId(value: string): boolean {
|
||||
return SESSION_ID_UUID_PATTERN.test(value)
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified"
|
|||
|
||||
export type FlagOptions = {
|
||||
promptFile?: string
|
||||
createWithSessionId?: string
|
||||
sessionId?: string
|
||||
continue: boolean
|
||||
workspace?: string
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ const PICKER_HEIGHT = 10
|
|||
|
||||
export interface TUIAppProps extends ExtensionHostOptions {
|
||||
initialPrompt?: string
|
||||
initialTaskId?: string
|
||||
initialSessionId?: string
|
||||
continueSession?: boolean
|
||||
version: string
|
||||
|
|
@ -73,6 +74,7 @@ export interface TUIAppProps extends ExtensionHostOptions {
|
|||
function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) {
|
||||
const {
|
||||
initialPrompt,
|
||||
initialTaskId,
|
||||
initialSessionId,
|
||||
continueSession,
|
||||
workspacePath,
|
||||
|
|
@ -174,6 +176,7 @@ function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps)
|
|||
|
||||
const { sendToExtension, runTask, cleanup } = useExtensionHost({
|
||||
initialPrompt,
|
||||
initialTaskId,
|
||||
initialSessionId,
|
||||
continueSession,
|
||||
mode,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ function getMostRecentTaskId(taskHistory: HistoryItem[], workspacePath: string):
|
|||
// TODO: Unify with TUIAppProps?
|
||||
export interface UseExtensionHostOptions extends ExtensionHostOptions {
|
||||
initialPrompt?: string
|
||||
initialTaskId?: string
|
||||
initialSessionId?: string
|
||||
continueSession?: boolean
|
||||
onExtensionMessage: (msg: ExtensionMessage) => void
|
||||
|
|
@ -63,6 +64,7 @@ export interface UseExtensionHostReturn {
|
|||
*/
|
||||
export function useExtensionHost({
|
||||
initialPrompt,
|
||||
initialTaskId,
|
||||
initialSessionId,
|
||||
continueSession,
|
||||
mode,
|
||||
|
|
@ -86,6 +88,7 @@ export function useExtensionHost({
|
|||
|
||||
const hostRef = useRef<ExtensionHostInterface | null>(null)
|
||||
const isReadyRef = useRef(false)
|
||||
const pendingInitialTaskIdRef = useRef<string | undefined>(initialTaskId?.trim() || undefined)
|
||||
|
||||
const cleanup = useCallback(async () => {
|
||||
if (hostRef.current) {
|
||||
|
|
@ -193,7 +196,9 @@ export function useExtensionHost({
|
|||
setHasStartedTask(true)
|
||||
setLoading(true)
|
||||
addMessage({ id: randomUUID(), role: "user", content: initialPrompt })
|
||||
await host.runTask(initialPrompt)
|
||||
const taskId = pendingInitialTaskIdRef.current
|
||||
pendingInitialTaskIdRef.current = undefined
|
||||
await host.runTask(initialPrompt, taskId)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
|
|
@ -221,7 +226,9 @@ export function useExtensionHost({
|
|||
return Promise.reject(new Error("Extension host not ready"))
|
||||
}
|
||||
|
||||
return hostRef.current.runTask(prompt)
|
||||
const taskId = pendingInitialTaskIdRef.current
|
||||
pendingInitialTaskIdRef.current = undefined
|
||||
return hostRef.current.runTask(prompt, taskId)
|
||||
}, [])
|
||||
|
||||
// Memoized return object to prevent unnecessary re-renders in consumers.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ describe("CLI types", () => {
|
|||
command: "start",
|
||||
requestId: "req-1",
|
||||
prompt: "hello",
|
||||
taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87",
|
||||
images: ["data:image/png;base64,abc"],
|
||||
configuration: {},
|
||||
})
|
||||
|
|
@ -38,6 +39,17 @@ describe("CLI types", () => {
|
|||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects a start command with invalid taskId format", () => {
|
||||
const result = rooCliInputCommandSchema.safeParse({
|
||||
command: "start",
|
||||
requestId: "req-invalid-task-id",
|
||||
prompt: "hello",
|
||||
taskId: "task-123",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("rooCliControlEventSchema", () => {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,15 @@ export const rooCliCommandBaseSchema = z.object({
|
|||
|
||||
export type RooCliCommandBase = z.infer<typeof rooCliCommandBaseSchema>
|
||||
|
||||
const rooCliSessionIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
|
||||
|
||||
export const rooCliStartCommandSchema = rooCliCommandBaseSchema.extend({
|
||||
command: z.literal("start"),
|
||||
prompt: z.string(),
|
||||
taskId: rooCliSessionIdSchema.optional(),
|
||||
images: z.array(z.string()).optional(),
|
||||
configuration: rooCodeSettingsSchema.optional(),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue