diff --git a/apps/cli/README.md b/apps/cli/README.md index bc343f1c9cb..b55ed604f1a 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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 diff --git a/apps/cli/src/agent/__tests__/extension-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts index acc92c27057..03a6957adf3 100644 --- a/apps/cli/src/agent/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -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", () => { diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 78cbdf443d1..aa7cee3f3a8 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -108,6 +108,7 @@ export interface ExtensionHostInterface extends IExtensionHost runTask(prompt: string, taskId?: string): Promise + resumeTask(taskId: string): Promise sendToExtension(message: WebviewMessage): void dispose(): Promise } @@ -466,9 +467,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Task Management // ========================================================================== - public async runTask(prompt: string, taskId?: string): Promise { - this.sendToExtension({ type: "newTask", text: prompt, taskId }) - + private waitForTaskCompletion(): Promise { 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 { + this.sendToExtension({ type: "newTask", text: prompt, taskId }) + return this.waitForTaskCompletion() + } + + public async resumeTask(taskId: string): Promise { + this.sendToExtension({ type: "showTaskWithId", text: taskId }) + return this.waitForTaskCompletion() + } + // ========================================================================== // Public Agent State API // ========================================================================== diff --git a/apps/cli/src/commands/cli/__tests__/list.test.ts b/apps/cli/src/commands/cli/__tests__/list.test.ts index 09e05022443..e5b1eeabb77 100644 --- a/apps/cli/src/commands/cli/__tests__/list.test.ts +++ b/apps/cli/src/commands/cli/__tests__/list.test.ts @@ -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() + 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): Promise => { + 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)"]) + }) +}) diff --git a/apps/cli/src/commands/cli/__tests__/upgrade.test.ts b/apps/cli/src/commands/cli/__tests__/upgrade.test.ts new file mode 100644 index 00000000000..c6c0c70c469 --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/upgrade.test.ts @@ -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 + + 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.") + }) +}) diff --git a/apps/cli/src/commands/cli/index.ts b/apps/cli/src/commands/cli/index.ts index 629c665a759..b59f1ebfa82 100644 --- a/apps/cli/src/commands/cli/index.ts +++ b/apps/cli/src/commands/cli/index.ts @@ -1,2 +1,3 @@ export * from "./run.js" export * from "./list.js" +export * from "./upgrade.js" diff --git a/apps/cli/src/commands/cli/list.ts b/apps/cli/src/commands/cli/list.ts index 8d8e779c3ac..8c8d5ae93ac 100644 --- a/apps/cli/src/commands/cli/list.ts +++ b/apps/cli/src/commands/cli/list.ts @@ -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 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 { +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 { 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 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 } 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 { async function withHostAndSignalHandlers( options: BaseListOptions, + hostOptions: ListHostOptions, fn: (host: ExtensionHost) => Promise, ): Promise { - 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( export async function listCommands(options: BaseListOptions): Promise { 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 { export async function listModes(options: BaseListOptions): Promise { 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 { export async function listModels(options: BaseListOptions): Promise { 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 { outputModelsText(models) }) } + +export async function listSessions(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + const sessions = await readTaskSessionsFromStoragePath(DEFAULT_CLI_TASK_STORAGE_PATH) + + if (format === "json") { + outputJson({ sessions }) + return + } + + outputSessionsText(sessions) +} diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index e26f2c6a79e..d8ec6d50e88 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -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 { }) } +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 | --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] ") @@ -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() diff --git a/apps/cli/src/commands/cli/upgrade.ts b/apps/cli/src/commands/cli/upgrade.ts new file mode 100644 index 00000000000..8937eb4ee94 --- /dev/null +++ b/apps/cli/src/commands/cli/upgrade.ts @@ -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 +} + +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 { + 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 { + 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 { + 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.") +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index d83c6a618f6..757339be1ab 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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 ", "Read prompt from a file instead of command line argument") + .option("--session-id ", "Resume a specific task by task ID") + .option("-c, --continue", "Resume the most recent task in the current workspace", false) .option("-w, --workspace ", "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) => { } } +const runUpgradeAction = async (action: () => Promise) => { + 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[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[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 diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 2e500f41817..80b55cfeb2b 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -20,6 +20,8 @@ export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified" export type FlagOptions = { promptFile?: string + sessionId?: string + continue: boolean workspace?: string print: boolean stdinPromptStream: boolean diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index ee9bc41cee5..d38d5a3c18b 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -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, diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts index 78074aab4fc..e2feb9e0ea3 100644 --- a/apps/cli/src/ui/hooks/useExtensionHost.ts +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -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(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) { diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 7826c0da380..42b26d1bff3 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -4,3 +4,4 @@ export * from "./debug-log/index.js" export * from "./message-utils/index.js" +export * from "./task-history/index.js" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e5b42a07489..e63979cf8b8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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" diff --git a/packages/core/src/task-history/__tests__/task-history.spec.ts b/packages/core/src/task-history/__tests__/task-history.spec.ts new file mode 100644 index 00000000000..d9390f9daa6 --- /dev/null +++ b/packages/core/src/task-history/__tests__/task-history.spec.ts @@ -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//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([]) + }) +}) diff --git a/packages/core/src/task-history/index.ts b/packages/core/src/task-history/index.ts new file mode 100644 index 00000000000..384bdfc254f --- /dev/null +++ b/packages/core/src/task-history/index.ts @@ -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 { + 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 { + try { + const raw = await fs.readFile(filePath, "utf8") + return JSON.parse(raw) + } catch { + return undefined + } +} + +export async function readTaskSessionsFromStoragePath(storageBasePath: string): Promise { + const tasksDir = path.join(storageBasePath, "tasks") + const sessionsById = new Map() + + 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) +}