feat(cli): add session resume/history and upgrade command (#11768)

feat(cli): add session history/resume and upgrade command
This commit is contained in:
Chris Estreich 2026-02-26 13:55:46 -08:00 committed by GitHub
parent e25b1f2768
commit de0e3632d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 846 additions and 17 deletions

View file

@ -41,6 +41,12 @@ Re-run the install script to update to the latest version:
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
```
Or run:
```bash
roo upgrade
```
### Uninstalling
```bash

View file

@ -502,6 +502,37 @@ describe("ExtensionHost", () => {
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" })
})
it("should include taskId when provided", async () => {
const host = createTestHost()
host.markWebviewReady()
const emitSpy = vi.spyOn(host, "emit")
const client = getPrivate(host, "client") as ExtensionClient
const taskPromise = host.runTask("test prompt", "task-123")
const taskCompletedEvent = {
success: true,
stateInfo: {
state: AgentLoopState.IDLE,
isWaitingForInput: false,
isRunning: false,
isStreaming: false,
requiredAction: "start_task" as const,
description: "Task completed",
},
}
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
await taskPromise
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", {
type: "newTask",
text: "test prompt",
taskId: "task-123",
})
})
it("should resolve when taskCompleted is emitted on client", async () => {
const host = createTestHost()
host.markWebviewReady()
@ -525,6 +556,33 @@ describe("ExtensionHost", () => {
await expect(taskPromise).resolves.toBeUndefined()
})
it("should send showTaskWithId for resumeTask and resolve on completion", async () => {
const host = createTestHost()
host.markWebviewReady()
const emitSpy = vi.spyOn(host, "emit")
const client = getPrivate(host, "client") as ExtensionClient
const taskPromise = host.resumeTask("task-abc")
const taskCompletedEvent = {
success: true,
stateInfo: {
state: AgentLoopState.IDLE,
isWaitingForInput: false,
isRunning: false,
isStreaming: false,
requiredAction: "start_task" as const,
description: "Task completed",
},
}
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
await taskPromise
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "showTaskWithId", text: "task-abc" })
})
})
describe("initial settings", () => {

View file

@ -108,6 +108,7 @@ export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEven
client: ExtensionClient
activate(): Promise<void>
runTask(prompt: string, taskId?: string): Promise<void>
resumeTask(taskId: string): Promise<void>
sendToExtension(message: WebviewMessage): void
dispose(): Promise<void>
}
@ -466,9 +467,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
// Task Management
// ==========================================================================
public async runTask(prompt: string, taskId?: string): Promise<void> {
this.sendToExtension({ type: "newTask", text: prompt, taskId })
private waitForTaskCompletion(): Promise<void> {
return new Promise((resolve, reject) => {
const completeHandler = () => {
cleanup()
@ -509,6 +508,16 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
})
}
public async runTask(prompt: string, taskId?: string): Promise<void> {
this.sendToExtension({ type: "newTask", text: prompt, taskId })
return this.waitForTaskCompletion()
}
public async resumeTask(taskId: string): Promise<void> {
this.sendToExtension({ type: "showTaskWithId", text: taskId })
return this.waitForTaskCompletion()
}
// ==========================================================================
// Public Agent State API
// ==========================================================================

View file

@ -1,4 +1,17 @@
import { parseFormat } from "../list.js"
import * as os from "os"
import * as path from "path"
import { readTaskSessionsFromStoragePath } from "@roo-code/core/cli"
import { listSessions, parseFormat } from "../list.js"
vi.mock("@roo-code/core/cli", async (importOriginal) => {
const actual = await importOriginal<typeof import("@roo-code/core/cli")>()
return {
...actual,
readTaskSessionsFromStoragePath: vi.fn(),
}
})
describe("parseFormat", () => {
it("defaults to json when undefined", () => {
@ -27,3 +40,47 @@ describe("parseFormat", () => {
expect(() => parseFormat("")).toThrow("Invalid format")
})
})
describe("listSessions", () => {
const storagePath = path.join(os.homedir(), ".vscode-mock", "global-storage")
beforeEach(() => {
vi.clearAllMocks()
})
const captureStdout = async (fn: () => Promise<void>): Promise<string> => {
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
try {
await fn()
return stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("")
} finally {
stdoutSpy.mockRestore()
}
}
it("uses the CLI runtime storage path and prints JSON output", async () => {
vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([
{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" },
])
const output = await captureStdout(() => listSessions({ format: "json" }))
expect(readTaskSessionsFromStoragePath).toHaveBeenCalledWith(storagePath)
expect(JSON.parse(output)).toEqual({
sessions: [{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" }],
})
})
it("prints tab-delimited text output with ISO timestamps and formatted titles", async () => {
vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([
{ id: "s1", task: "Task 1", ts: Date.UTC(2024, 0, 1, 0, 0, 0) },
{ id: "s2", task: " ", ts: Date.UTC(2024, 0, 1, 1, 0, 0) },
])
const output = await captureStdout(() => listSessions({ format: "text" }))
const lines = output.trim().split("\n")
expect(lines).toEqual(["s1\t2024-01-01T00:00:00.000Z\tTask 1", "s2\t2024-01-01T01:00:00.000Z\t(untitled)"])
})
})

View file

@ -0,0 +1,88 @@
import { compareVersions, getLatestCliVersion, upgrade } from "../upgrade.js"
function createFetchResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response {
const { ok = true, status = 200 } = init
return {
ok,
status,
json: async () => body,
} as Response
}
describe("compareVersions", () => {
it("returns 1 when first version is newer", () => {
expect(compareVersions("0.2.0", "0.1.9")).toBe(1)
})
it("returns -1 when first version is older", () => {
expect(compareVersions("0.1.4", "0.1.5")).toBe(-1)
})
it("returns 0 when versions are equivalent", () => {
expect(compareVersions("v1.2.0", "1.2")).toBe(0)
})
it("supports cli tag prefixes and prerelease metadata", () => {
expect(compareVersions("cli-v1.2.3", "1.2.2")).toBe(1)
expect(compareVersions("1.2.3-beta.1", "1.2.3")).toBe(0)
})
})
describe("getLatestCliVersion", () => {
it("returns the first cli-v release tag from GitHub releases", async () => {
const fetchImpl = (async () =>
createFetchResponse([
{ tag_name: "v9.9.9" },
{ tag_name: "cli-v0.3.1" },
{ tag_name: "cli-v0.3.0" },
])) as typeof fetch
await expect(getLatestCliVersion(fetchImpl)).resolves.toBe("0.3.1")
})
it("throws when release check fails", async () => {
const fetchImpl = (async () => createFetchResponse({}, { ok: false, status: 503 })) as typeof fetch
await expect(getLatestCliVersion(fetchImpl)).rejects.toThrow("Failed to check latest version")
})
})
describe("upgrade", () => {
let logSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined)
})
afterEach(() => {
logSpy.mockRestore()
})
it("does not run installer when already up to date", async () => {
const runInstaller = vi.fn(async () => undefined)
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.1.4" }])) as typeof fetch
await upgrade({
currentVersion: "0.1.4",
fetchImpl,
runInstaller,
})
expect(runInstaller).not.toHaveBeenCalled()
expect(logSpy).toHaveBeenCalledWith("Roo CLI is already up to date.")
})
it("runs installer when a newer version is available", async () => {
const runInstaller = vi.fn(async () => undefined)
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.2.0" }])) as typeof fetch
await upgrade({
currentVersion: "0.1.4",
fetchImpl,
runInstaller,
})
expect(runInstaller).toHaveBeenCalledTimes(1)
expect(logSpy).toHaveBeenCalledWith("✓ Upgrade completed.")
})
})

View file

@ -1,2 +1,3 @@
export * from "./run.js"
export * from "./list.js"
export * from "./upgrade.js"

View file

@ -1,9 +1,11 @@
import fs from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import pWaitFor from "p-wait-for"
import { readTaskSessionsFromStoragePath, type TaskSessionEntry } from "@roo-code/core/cli"
import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types"
import { getProviderDefaultModelId } from "@roo-code/types"
@ -14,6 +16,7 @@ import { getApiKeyFromEnv } from "@/lib/utils/provider.js"
import { isRecord } from "@/lib/utils/guards.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const REQUEST_TIMEOUT_MS = 10_000
type ListFormat = "json" | "text"
@ -28,6 +31,9 @@ type BaseListOptions = {
type CommandLike = Pick<Command, "name" | "source" | "filePath" | "description" | "argumentHint">
type ModeLike = { slug: string; name: string }
type SessionLike = TaskSessionEntry
type ListHostOptions = { ephemeral: boolean }
const DEFAULT_CLI_TASK_STORAGE_PATH = path.join(os.homedir(), ".vscode-mock", "global-storage")
export function parseFormat(rawFormat: string | undefined): ListFormat {
const format = (rawFormat ?? "json").toLowerCase()
@ -81,7 +87,24 @@ function outputModelsText(models: ModelRecord): void {
}
}
async function createListHost(options: BaseListOptions): Promise<ExtensionHost> {
function formatSessionTitle(task: string): string {
const compact = task.replace(/\s+/g, " ").trim()
if (!compact) {
return "(untitled)"
}
return compact.length <= 120 ? compact : `${compact.slice(0, 117)}...`
}
function outputSessionsText(sessions: SessionLike[]): void {
for (const session of sessions) {
const startedAt = Number.isFinite(session.ts) ? new Date(session.ts).toISOString() : "unknown-time"
process.stdout.write(`${session.id}\t${startedAt}\t${formatSessionTitle(session.task)}\n`)
}
}
async function createListHost(options: BaseListOptions, hostOptions: ListHostOptions): Promise<ExtensionHost> {
const workspacePath = resolveWorkspacePath(options.workspace)
const extensionPath = resolveExtensionPath(options.extension)
const apiKey = options.apiKey || (await loadToken()) || getApiKeyFromEnv("roo")
@ -96,7 +119,7 @@ async function createListHost(options: BaseListOptions): Promise<ExtensionHost>
workspacePath,
extensionPath,
nonInteractive: true,
ephemeral: true,
ephemeral: hostOptions.ephemeral,
debug: options.debug ?? false,
exitOnComplete: true,
exitOnError: false,
@ -104,6 +127,7 @@ async function createListHost(options: BaseListOptions): Promise<ExtensionHost>
}
const host = new ExtensionHost(extensionHostOptions)
await host.activate()
// Best effort wait; mode/commands requests can still succeed without this.
@ -217,9 +241,10 @@ function requestRooModels(host: ExtensionHost): Promise<ModelRecord> {
async function withHostAndSignalHandlers<T>(
options: BaseListOptions,
hostOptions: ListHostOptions,
fn: (host: ExtensionHost) => Promise<T>,
): Promise<T> {
const host = await createListHost(options)
const host = await createListHost(options, hostOptions)
const shutdown = async (exitCode: number) => {
await host.dispose()
@ -244,7 +269,7 @@ async function withHostAndSignalHandlers<T>(
export async function listCommands(options: BaseListOptions): Promise<void> {
const format = parseFormat(options.format)
await withHostAndSignalHandlers(options, async (host) => {
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
const commands = await requestCommands(host)
if (format === "json") {
@ -259,7 +284,7 @@ export async function listCommands(options: BaseListOptions): Promise<void> {
export async function listModes(options: BaseListOptions): Promise<void> {
const format = parseFormat(options.format)
await withHostAndSignalHandlers(options, async (host) => {
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
const modes = await requestModes(host)
if (format === "json") {
@ -274,7 +299,7 @@ export async function listModes(options: BaseListOptions): Promise<void> {
export async function listModels(options: BaseListOptions): Promise<void> {
const format = parseFormat(options.format)
await withHostAndSignalHandlers(options, async (host) => {
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
const models = await requestRooModels(host)
if (format === "json") {
@ -285,3 +310,15 @@ export async function listModels(options: BaseListOptions): Promise<void> {
outputModelsText(models)
})
}
export async function listSessions(options: BaseListOptions): Promise<void> {
const format = parseFormat(options.format)
const sessions = await readTaskSessionsFromStoragePath(DEFAULT_CLI_TASK_STORAGE_PATH)
if (format === "json") {
outputJson({ sessions })
return
}
outputSessionsText(sessions)
}

View file

@ -3,6 +3,7 @@ import path from "path"
import { fileURLToPath } from "url"
import { createElement } from "react"
import type { HistoryItem } from "@roo-code/types"
import { setLogger } from "@roo-code/vscode-shim"
@ -22,6 +23,7 @@ import { JsonEventEmitter } from "@/agent/json-event-emitter.js"
import { createClient } from "@/lib/sdk/index.js"
import { loadToken, loadSettings } from "@/lib/storage/index.js"
import { isRecord } from "@/lib/utils/guards.js"
import { arePathsEqual } from "@/lib/utils/path.js"
import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
import { runOnboarding } from "@/lib/utils/onboarding.js"
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
@ -91,6 +93,38 @@ async function warmRooModels(host: ExtensionHost): Promise<void> {
})
}
function extractTaskHistoryFromMessage(message: unknown): HistoryItem[] | undefined {
if (!isRecord(message)) {
return undefined
}
if (message.type === "state") {
const state = isRecord(message.state) ? message.state : undefined
if (Array.isArray(state?.taskHistory)) {
return state.taskHistory as HistoryItem[]
}
}
if (message.type === "taskHistoryUpdated" && Array.isArray(message.taskHistory)) {
return message.taskHistory as HistoryItem[]
}
return undefined
}
function getMostRecentTaskIdInWorkspace(taskHistory: HistoryItem[], workspacePath: string): string | undefined {
const workspaceTasks = taskHistory.filter(
(item) => typeof item.workspace === "string" && arePathsEqual(item.workspace, workspacePath),
)
if (workspaceTasks.length === 0) {
return undefined
}
const sorted = [...workspaceTasks].sort((a, b) => b.ts - a.ts)
return sorted[0]?.id
}
export async function run(promptArg: string | undefined, flagOptions: FlagOptions) {
setLogger({
info: () => {},
@ -110,6 +144,26 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
prompt = fs.readFileSync(flagOptions.promptFile, "utf-8")
}
const requestedSessionId = flagOptions.sessionId?.trim()
const shouldContinueSession = flagOptions.continue
const isResumeRequested = Boolean(requestedSessionId || shouldContinueSession)
if (flagOptions.sessionId !== undefined && !requestedSessionId) {
console.error("[CLI] Error: --session-id requires a non-empty task id")
process.exit(1)
}
if (requestedSessionId && shouldContinueSession) {
console.error("[CLI] Error: cannot use --session-id with --continue")
process.exit(1)
}
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]")
process.exit(1)
}
// Options
let rooToken = await loadToken()
@ -282,10 +336,16 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
process.exit(1)
}
if (flagOptions.stdinPromptStream && isResumeRequested) {
console.error("[CLI] Error: cannot use --session-id/--continue with --stdin-prompt-stream")
console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]")
process.exit(1)
}
const useStdinPromptStream = flagOptions.stdinPromptStream
if (!isTuiEnabled) {
if (!prompt && !useStdinPromptStream) {
if (!prompt && !useStdinPromptStream && !isResumeRequested) {
if (flagOptions.print) {
console.error("[CLI] Error: no prompt provided")
console.error("[CLI] Usage: roo --print [options] <prompt>")
@ -317,6 +377,8 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
createElement(App, {
...extensionHostOptions,
initialPrompt: prompt,
initialSessionId: requestedSessionId,
continueSession: shouldContinueSession,
version: VERSION,
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
}),
@ -343,6 +405,16 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
let keepAliveInterval: NodeJS.Timeout | undefined
let isShuttingDown = false
let hostDisposed = false
let taskHistorySnapshot: HistoryItem[] = []
const onExtensionMessage = (message: unknown) => {
const taskHistory = extractTaskHistoryFromMessage(message)
if (taskHistory) {
taskHistorySnapshot = taskHistory
}
}
host.on("extensionWebviewMessage", onExtensionMessage)
const jsonEmitter = useJsonOutput
? new JsonEventEmitter({
@ -387,6 +459,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
}
hostDisposed = true
host.off("extensionWebviewMessage", onExtensionMessage)
jsonEmitter?.detach()
await host.dispose()
}
@ -486,7 +559,26 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
},
})
} else {
await host.runTask(prompt!)
if (isResumeRequested) {
const resolvedSessionId =
requestedSessionId ||
getMostRecentTaskIdInWorkspace(taskHistorySnapshot, effectiveWorkspacePath)
if (requestedSessionId && taskHistorySnapshot.length > 0) {
const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId)
if (!hasRequestedTask) {
throw new Error(`Session not found in task history: ${requestedSessionId}`)
}
}
if (!resolvedSessionId) {
throw new Error("No previous tasks found to continue in this workspace.")
}
await host.resumeTask(resolvedSessionId)
} else {
await host.runTask(prompt!)
}
}
await disposeHost()

View file

@ -0,0 +1,137 @@
import { spawn } from "child_process"
import { VERSION } from "@/lib/utils/version.js"
import { isRecord } from "@/lib/utils/guards.js"
const RELEASES_URL = "https://api.github.com/repos/RooCodeInc/Roo-Code/releases?per_page=100"
export const INSTALL_SCRIPT_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh"
export interface UpgradeOptions {
currentVersion?: string
fetchImpl?: typeof fetch
runInstaller?: () => Promise<void>
}
function parseVersion(version: string): number[] {
const cleaned = version
.trim()
.replace(/^cli-v/, "")
.replace(/^v/, "")
const core = cleaned.split("+", 1)[0]?.split("-", 1)[0]
if (!core) {
throw new Error(`Invalid version: ${version}`)
}
const parts = core.split(".")
if (parts.length === 0) {
throw new Error(`Invalid version: ${version}`)
}
return parts.map((part) => {
if (!/^\d+$/.test(part)) {
throw new Error(`Invalid version: ${version}`)
}
return Number.parseInt(part, 10)
})
}
/**
* Returns:
* - 1 when `a > b`
* - 0 when `a === b`
* - -1 when `a < b`
*/
export function compareVersions(a: string, b: string): number {
const aParts = parseVersion(a)
const bParts = parseVersion(b)
const maxLength = Math.max(aParts.length, bParts.length)
for (let i = 0; i < maxLength; i++) {
const aPart = aParts[i] ?? 0
const bPart = bParts[i] ?? 0
if (aPart > bPart) {
return 1
}
if (aPart < bPart) {
return -1
}
}
return 0
}
export async function getLatestCliVersion(fetchImpl: typeof fetch = fetch): Promise<string> {
const response = await fetchImpl(RELEASES_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "roo-cli",
},
})
if (!response.ok) {
throw new Error(`Failed to check latest version (HTTP ${response.status})`)
}
const releases = await response.json()
if (!Array.isArray(releases)) {
throw new Error("Invalid release response from GitHub.")
}
for (const release of releases) {
if (!isRecord(release)) {
continue
}
const tagName = release.tag_name
if (typeof tagName === "string" && tagName.startsWith("cli-v")) {
return tagName.slice("cli-v".length)
}
}
throw new Error("Could not determine the latest CLI release version.")
}
export function runUpgradeInstaller(spawnImpl: typeof spawn = spawn): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawnImpl("sh", ["-c", INSTALL_SCRIPT_COMMAND], { stdio: "inherit" })
child.once("error", (error) => {
reject(error)
})
child.once("close", (code, signal) => {
if (code === 0) {
resolve()
return
}
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`
reject(new Error(`Upgrade installer failed (${reason}).`))
})
})
}
export async function upgrade(options: UpgradeOptions = {}): Promise<void> {
const currentVersion = options.currentVersion ?? VERSION
const fetchImpl = options.fetchImpl ?? fetch
const runInstaller = options.runInstaller ?? (() => runUpgradeInstaller())
console.log(`Current version: ${currentVersion}`)
const latestVersion = await getLatestCliVersion(fetchImpl)
console.log(`Latest version: ${latestVersion}`)
if (compareVersions(latestVersion, currentVersion) <= 0) {
console.log("Roo CLI is already up to date.")
return
}
console.log(`Upgrading Roo CLI from ${currentVersion} to ${latestVersion}...`)
await runInstaller()
console.log("✓ Upgrade completed.")
}

View file

@ -2,7 +2,17 @@ import { Command } from "commander"
import { DEFAULT_FLAGS } from "@/types/constants.js"
import { VERSION } from "@/lib/utils/version.js"
import { run, login, logout, status, listCommands, listModes, listModels } from "@/commands/index.js"
import {
run,
login,
logout,
status,
listCommands,
listModes,
listModels,
listSessions,
upgrade,
} from "@/commands/index.js"
const program = new Command()
@ -14,6 +24,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("-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)
.option(
@ -48,7 +60,7 @@ program
)
.action(run)
const listCommand = program.command("list").description("List commands, modes, or models")
const listCommand = program.command("list").description("List commands, modes, models, or sessions")
const applyListOptions = (command: Command) =>
command
@ -69,6 +81,17 @@ const runListAction = async (action: () => Promise<void>) => {
}
}
const runUpgradeAction = async (action: () => Promise<void>) => {
try {
await action()
process.exit(0)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[CLI] Error: ${message}`)
process.exit(1)
}
}
applyListOptions(listCommand.command("commands").description("List available slash commands")).action(
async (options: Parameters<typeof listCommands>[0]) => {
await runListAction(() => listCommands(options))
@ -87,6 +110,19 @@ applyListOptions(listCommand.command("models").description("List available Roo m
},
)
applyListOptions(listCommand.command("sessions").description("List task sessions")).action(
async (options: Parameters<typeof listSessions>[0]) => {
await runListAction(() => listSessions(options))
},
)
program
.command("upgrade")
.description("Upgrade Roo Code CLI to the latest version")
.action(async () => {
await runUpgradeAction(() => upgrade())
})
const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
authCommand

View file

@ -20,6 +20,8 @@ export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified"
export type FlagOptions = {
promptFile?: string
sessionId?: string
continue: boolean
workspace?: string
print: boolean
stdinPromptStream: boolean

View file

@ -60,6 +60,8 @@ const PICKER_HEIGHT = 10
export interface TUIAppProps extends ExtensionHostOptions {
initialPrompt?: string
initialSessionId?: string
continueSession?: boolean
version: string
// Create extension host factory for dependency injection.
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
@ -71,6 +73,8 @@ export interface TUIAppProps extends ExtensionHostOptions {
function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) {
const {
initialPrompt,
initialSessionId,
continueSession,
workspacePath,
extensionPath,
user,
@ -170,6 +174,8 @@ function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps)
const { sendToExtension, runTask, cleanup } = useExtensionHost({
initialPrompt,
initialSessionId,
continueSession,
mode,
reasoningEffort,
user,

View file

@ -1,15 +1,46 @@
import { useEffect, useRef, useCallback, useMemo } from "react"
import { useApp } from "ink"
import { randomUUID } from "crypto"
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
import pWaitFor from "p-wait-for"
import type { ExtensionMessage, HistoryItem, WebviewMessage } from "@roo-code/types"
import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"
import { arePathsEqual } from "@/lib/utils/path.js"
import { useCLIStore } from "../store.js"
const TASK_HISTORY_WAIT_TIMEOUT_MS = 2_000
function extractTaskHistory(message: ExtensionMessage): HistoryItem[] | undefined {
if (message.type === "state" && Array.isArray(message.state?.taskHistory)) {
return message.state.taskHistory as HistoryItem[]
}
if (message.type === "taskHistoryUpdated" && Array.isArray(message.taskHistory)) {
return message.taskHistory as HistoryItem[]
}
return undefined
}
function getMostRecentTaskId(taskHistory: HistoryItem[], workspacePath: string): string | undefined {
const workspaceTasks = taskHistory.filter(
(item) => typeof item.workspace === "string" && arePathsEqual(item.workspace, workspacePath),
)
if (workspaceTasks.length === 0) {
return undefined
}
const sorted = [...workspaceTasks].sort((a, b) => b.ts - a.ts)
return sorted[0]?.id
}
// TODO: Unify with TUIAppProps?
export interface UseExtensionHostOptions extends ExtensionHostOptions {
initialPrompt?: string
initialSessionId?: string
continueSession?: boolean
onExtensionMessage: (msg: ExtensionMessage) => void
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
}
@ -32,6 +63,8 @@ export interface UseExtensionHostReturn {
*/
export function useExtensionHost({
initialPrompt,
initialSessionId,
continueSession,
mode,
reasoningEffort,
user,
@ -48,7 +81,8 @@ export function useExtensionHost({
createExtensionHost,
}: UseExtensionHostOptions): UseExtensionHostReturn {
const { exit } = useApp()
const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore()
const { addMessage, setComplete, setLoading, setHasStartedTask, setError, setCurrentTaskId, setIsResumingTask } =
useCLIStore()
const hostRef = useRef<ExtensionHostInterface | null>(null)
const isReadyRef = useRef(false)
@ -64,6 +98,10 @@ export function useExtensionHost({
useEffect(() => {
const init = async () => {
try {
const requestedSessionId = initialSessionId?.trim()
let taskHistorySnapshot: HistoryItem[] = []
let hasReceivedTaskHistory = false
const host = createExtensionHost({
mode,
user,
@ -83,7 +121,17 @@ export function useExtensionHost({
hostRef.current = host
isReadyRef.current = true
host.on("extensionWebviewMessage", (msg) => onExtensionMessage(msg as ExtensionMessage))
host.on("extensionWebviewMessage", (msg) => {
const extensionMessage = msg as ExtensionMessage
const taskHistory = extractTaskHistory(extensionMessage)
if (taskHistory) {
taskHistorySnapshot = taskHistory
hasReceivedTaskHistory = true
}
onExtensionMessage(extensionMessage)
})
host.client.on("taskCompleted", async () => {
setComplete(true)
@ -108,6 +156,37 @@ export function useExtensionHost({
host.sendToExtension({ type: "requestCommands" })
host.sendToExtension({ type: "requestModes" })
if (requestedSessionId || continueSession) {
await pWaitFor(() => hasReceivedTaskHistory, {
interval: 25,
timeout: TASK_HISTORY_WAIT_TIMEOUT_MS,
}).catch(() => undefined)
if (requestedSessionId && hasReceivedTaskHistory) {
const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId)
if (!hasRequestedTask) {
throw new Error(`Session not found in task history: ${requestedSessionId}`)
}
}
const resolvedSessionId =
requestedSessionId || getMostRecentTaskId(taskHistorySnapshot, workspacePath)
if (continueSession && !resolvedSessionId) {
throw new Error("No previous tasks found to continue in this workspace.")
}
if (resolvedSessionId) {
setCurrentTaskId(resolvedSessionId)
setIsResumingTask(true)
setHasStartedTask(true)
setLoading(true)
host.sendToExtension({ type: "showTaskWithId", text: resolvedSessionId })
return
}
}
setLoading(false)
if (initialPrompt) {

View file

@ -4,3 +4,4 @@
export * from "./debug-log/index.js"
export * from "./message-utils/index.js"
export * from "./task-history/index.js"

View file

@ -1,4 +1,5 @@
export * from "./custom-tools/index.js"
export * from "./debug-log/index.js"
export * from "./message-utils/index.js"
export * from "./task-history/index.js"
export * from "./worktree/index.js"

View file

@ -0,0 +1,114 @@
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import { readTaskSessionsFromStoragePath } from "../index.js"
describe("readTaskSessionsFromStoragePath", () => {
let tempDir: string
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-core-"))
})
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true })
})
it("reads sessions from _index.json and sorts by timestamp descending", async () => {
const tasksDir = path.join(tempDir, "tasks")
await fs.mkdir(path.join(tasksDir, "a"), { recursive: true })
await fs.mkdir(path.join(tasksDir, "b"), { recursive: true })
await fs.writeFile(
path.join(tasksDir, "_index.json"),
JSON.stringify({
entries: [
{ id: "a", task: "Task A", ts: 100, status: "completed" },
{ id: "b", task: "Task B", ts: 300, mode: "code" },
{ id: "invalid", ts: 200 },
],
}),
)
const sessions = await readTaskSessionsFromStoragePath(tempDir)
expect(sessions).toEqual([
{ id: "b", task: "Task B", ts: 300, mode: "code", workspace: undefined, status: undefined },
{ id: "a", task: "Task A", ts: 100, mode: undefined, workspace: undefined, status: "completed" },
])
})
it("merges missing sessions from tasks/<id>/history_item.json", async () => {
const tasksDir = path.join(tempDir, "tasks")
await fs.mkdir(path.join(tasksDir, "a"), { recursive: true })
await fs.mkdir(path.join(tasksDir, "c"), { recursive: true })
await fs.writeFile(
path.join(tasksDir, "_index.json"),
JSON.stringify({
entries: [{ id: "a", task: "Task A", ts: 100 }],
}),
)
await fs.writeFile(
path.join(tasksDir, "c", "history_item.json"),
JSON.stringify({ id: "c", task: "Task C", ts: 500, workspace: "/tmp/project" }),
)
const sessions = await readTaskSessionsFromStoragePath(tempDir)
expect(sessions).toEqual([
{ id: "c", task: "Task C", ts: 500, workspace: "/tmp/project", mode: undefined, status: undefined },
{ id: "a", task: "Task A", ts: 100, workspace: undefined, mode: undefined, status: undefined },
])
})
it("removes stale index entries that have no on-disk task directory", async () => {
const tasksDir = path.join(tempDir, "tasks")
await fs.mkdir(path.join(tasksDir, "live"), { recursive: true })
await fs.writeFile(
path.join(tasksDir, "_index.json"),
JSON.stringify({
entries: [
{ id: "stale", task: "Stale Task", ts: 999 },
{ id: "live", task: "Live Task", ts: 100 },
],
}),
)
const sessions = await readTaskSessionsFromStoragePath(tempDir)
expect(sessions).toEqual([
{ id: "live", task: "Live Task", ts: 100, workspace: undefined, mode: undefined, status: undefined },
])
})
it("ignores malformed JSON and invalid history entries", async () => {
const tasksDir = path.join(tempDir, "tasks")
await fs.mkdir(path.join(tasksDir, "good"), { recursive: true })
await fs.mkdir(path.join(tasksDir, "bad-json"), { recursive: true })
await fs.mkdir(path.join(tasksDir, "bad-shape"), { recursive: true })
await fs.writeFile(path.join(tasksDir, "_index.json"), "{not-valid-json")
await fs.writeFile(
path.join(tasksDir, "good", "history_item.json"),
JSON.stringify({ id: "good", task: "Good Task", ts: 10, status: "active" }),
)
await fs.writeFile(path.join(tasksDir, "bad-json", "history_item.json"), "{oops")
await fs.writeFile(
path.join(tasksDir, "bad-shape", "history_item.json"),
JSON.stringify({ id: "bad-shape", task: 123, ts: "not-a-number" }),
)
const sessions = await readTaskSessionsFromStoragePath(tempDir)
expect(sessions).toEqual([
{ id: "good", task: "Good Task", ts: 10, workspace: undefined, mode: undefined, status: "active" },
])
})
it("returns an empty list when tasks directory does not exist", async () => {
await expect(readTaskSessionsFromStoragePath(tempDir)).resolves.toEqual([])
})
})

View file

@ -0,0 +1,105 @@
import * as fs from "fs/promises"
import * as path from "path"
import type { HistoryItem } from "@roo-code/types"
const HISTORY_ITEM_FILENAME = "history_item.json"
const HISTORY_INDEX_FILENAME = "_index.json"
export interface TaskSessionEntry {
id: string
task: string
ts: number
workspace?: string
mode?: string
status?: HistoryItem["status"]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function extractSessionEntry(value: unknown): TaskSessionEntry | undefined {
if (!isRecord(value)) {
return undefined
}
const id = value.id
const task = value.task
const ts = value.ts
const workspace = value.workspace
const mode = value.mode
const status = value.status
if (typeof id !== "string" || typeof task !== "string" || typeof ts !== "number") {
return undefined
}
return {
id,
task,
ts,
workspace: typeof workspace === "string" ? workspace : undefined,
mode: typeof mode === "string" ? mode : undefined,
status: status === "active" || status === "completed" || status === "delegated" ? status : undefined,
}
}
async function readJsonFile(filePath: string): Promise<unknown | undefined> {
try {
const raw = await fs.readFile(filePath, "utf8")
return JSON.parse(raw)
} catch {
return undefined
}
}
export async function readTaskSessionsFromStoragePath(storageBasePath: string): Promise<TaskSessionEntry[]> {
const tasksDir = path.join(storageBasePath, "tasks")
const sessionsById = new Map<string, TaskSessionEntry>()
const historyIndex = await readJsonFile(path.join(tasksDir, HISTORY_INDEX_FILENAME))
const indexEntries = isRecord(historyIndex) && Array.isArray(historyIndex.entries) ? historyIndex.entries : []
for (const entry of indexEntries) {
const session = extractSessionEntry(entry)
if (session) {
sessionsById.set(session.id, session)
}
}
let taskDirs: string[] = []
try {
const entries = await fs.readdir(tasksDir, { withFileTypes: true })
taskDirs = entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("_") && !entry.name.startsWith("."))
.map((entry) => entry.name)
} catch {
// No tasks directory; return index-derived entries only.
}
for (const taskId of taskDirs) {
if (sessionsById.has(taskId)) {
continue
}
const historyItem = await readJsonFile(path.join(tasksDir, taskId, HISTORY_ITEM_FILENAME))
const session = extractSessionEntry(historyItem)
if (session) {
sessionsById.set(session.id, session)
}
}
if (taskDirs.length > 0) {
const onDiskIds = new Set(taskDirs)
for (const sessionId of sessionsById.keys()) {
if (!onDiskIds.has(sessionId)) {
sessionsById.delete(sessionId)
}
}
}
return Array.from(sessionsById.values()).sort((a, b) => b.ts - a.ts)
}