diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index f059bc7cb..ae42aa2b8 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -392,10 +392,12 @@ Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts` | Verb / Flag | Default | Description | | -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | _(no verb)_ | — | Interactive session; optional trailing task text | -| `exec` / `run` | — | Run a prompt (non-interactive / one-shot) | +| `exec` / `run` / `-p` | — | Run a prompt (non-interactive / one-shot). `-p` is the same path as `exec` and may appear in any flag position. | | `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel). Lists the 10 most recently persisted sessions, completed included. Type to filter. | -| `--resume []` | — | Open the interactive session picker, or reopen a specific session when an id is given | -| `resume ` | — | Reopen a specific session | +| `--resume []` | — | Interactive: open the session picker, or reopen a specific session when an id is given. With `exec` / `-p` the id is required, that session is continued, and the new prompt is sent (no picker). | +| `resume ` | — | Reopen a specific session in the TUI (does not auto-send a prompt) | +| `exec --resume ` | — | Headless: load that session and send ``, then exit. Missing or unreadable ids error and do not create a session. `--resume` without an id errors. | +| `-p --resume ` | — | Same headless continue path as `exec --resume` | | `resume --pick` / `--list` | — | Interactive session picker | | `--cwd ` | `process.cwd()` | Working directory | | `--config ` | `~/.corbits/settings.json` | Settings file to use for provider definitions; composes with (does not exclude) home-level codex/xai OAuth credentials | diff --git a/src/config.test.ts b/src/config.test.ts index 616e04c97..a7a135134 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,6 +1,6 @@ import { defined } from "../tests/helpers/defined.js"; import { afterEach, beforeEach, describe, test, expect } from "bun:test"; -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, writeFile, rm, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -50,9 +50,12 @@ import { import { generateSessionId, initSessionDir, + sessionContextDir, sessionDir, } from "./session/index.js"; import { saveState } from "./session/state.js"; +import { createOptimizedContextStore } from "./session/optimized-context-store.js"; +import { projectSessionsRoot } from "./session/project-key.js"; import { filterMcpServersForConnect } from "./trust/project-trust.js"; import { createExaMCPServerConfig } from "./mcp/exa.js"; import { withFileLogSink } from "../tests/helpers/file-log-sink.js"; @@ -122,6 +125,15 @@ async function emptyCwd(): Promise { return mkdtemp(join(tmpdir(), "ic-config-")); } +async function sessionIdsOnDisk(cwd: string, home: string): Promise { + try { + const names = await readdir(projectSessionsRoot(cwd, home)); + return names.filter((name) => name !== "latest").sort(); + } catch { + return []; + } +} + async function expectCliHelp(argv: readonly string[]): Promise { try { await loadConfig([...argv], { globalSettingsPath: NO_SETTINGS }); @@ -815,6 +827,226 @@ describe("loadConfig", () => { } }); + test("-p is the exec one-shot path in either flag order", async () => { + const cwd = await emptyCwd(); + try { + const globalPath = await writeGlobalSettings(cwd); + const model = "accounts/fireworks/routers/kimi-k2p6-turbo"; + const viaExec = await loadConfig(["exec", "--cwd", cwd, "do the thing"], { + globalSettingsPath: globalPath, + }); + const viaP = await loadConfig(["-p", "--cwd", cwd, "do the thing"], { + globalSettingsPath: globalPath, + }); + const providerFirst = await loadConfig( + ["-p", "--provider", "fireworks", "--cwd", cwd, "hello"], + { globalSettingsPath: globalPath }, + ); + const modelFirst = await loadConfig( + ["--model", model, "-p", "--cwd", cwd, "hello"], + { globalSettingsPath: globalPath }, + ); + const directorFirst = await loadConfig( + ["--director", "skywalker", "-p", "--cwd", cwd, "ship it"], + { globalSettingsPath: globalPath }, + ); + assertConfigured(viaExec); + assertConfigured(viaP); + assertConfigured(providerFirst); + assertConfigured(modelFirst); + assertConfigured(directorFirst); + expect(viaP.command).toBe("exec"); + expect(viaP.task).toBe(viaExec.task); + expect(viaP.providerName).toBe(viaExec.providerName); + expect(viaP.model).toBe(viaExec.model); + expect(providerFirst.command).toBe("exec"); + expect(providerFirst.providerName).toBe("fireworks"); + expect(providerFirst.task).toBe("hello"); + expect(modelFirst.command).toBe("exec"); + expect(modelFirst.model).toBe(model); + expect(modelFirst.task).toBe("hello"); + expect(directorFirst.command).toBe("exec"); + expect(directorFirst.director).toBe("skywalker"); + expect(directorFirst.task).toBe("ship it"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("exec --resume and -p --resume send the new prompt on that session", async () => { + const cwd = await emptyCwd(); + const home = await mkdtemp(join(tmpdir(), "ic-resume-home-")); + try { + const globalPath = await writeGlobalSettings(cwd); + const sessionId = generateSessionId(); + await initSessionDir(cwd, sessionId, home); + await saveState( + cwd, + sessionId, + { + status: "done", + turnsUsed: 2, + task: "original task", + startedAt: Date.now() - 1_000, + finishedAt: Date.now(), + }, + home, + ); + const viaExec = await loadConfig( + ["exec", "--resume", sessionId, "--cwd", cwd, "follow up"], + { globalSettingsPath: globalPath, home }, + ); + const viaP = await loadConfig( + ["-p", "--resume", sessionId, "--cwd", cwd, "follow up from p"], + { globalSettingsPath: globalPath, home }, + ); + const flagOrder = await loadConfig( + ["--resume", sessionId, "-p", "--cwd", cwd, "flag order"], + { globalSettingsPath: globalPath, home }, + ); + assertConfigured(viaExec); + assertConfigured(viaP); + assertConfigured(flagOrder); + expect(viaExec.command).toBe("exec"); + expect(viaExec.resumeMode).toBe("id"); + expect(viaExec.sessionId).toBe(sessionId); + expect(viaExec.skipInitialTask).toBeUndefined(); + expect(viaExec.resumePicker).toBeUndefined(); + expect(viaExec.task).toBe("follow up"); + expect(viaP.command).toBe("exec"); + expect(viaP.sessionId).toBe(sessionId); + expect(viaP.skipInitialTask).toBeUndefined(); + expect(viaP.task).toBe("follow up from p"); + expect(flagOrder.command).toBe("exec"); + expect(flagOrder.sessionId).toBe(sessionId); + expect(flagOrder.task).toBe("flag order"); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); + + test("exec --resume without an id errors and does not open a picker", async () => { + const cwd = await emptyCwd(); + try { + const globalPath = await writeGlobalSettings(cwd); + await expect( + loadConfig(["exec", "--resume", "--cwd", cwd], { + globalSettingsPath: globalPath, + }), + ).rejects.toThrow("--resume requires a session id in exec mode"); + await expect( + loadConfig(["-p", "--resume", "--cwd", cwd, "orphan prompt"], { + globalSettingsPath: globalPath, + }), + ).rejects.toThrow("--resume requires a session id in exec mode"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("exec --resume with a missing or unreadable id does not create a session", async () => { + const cwd = await emptyCwd(); + const home = await mkdtemp(join(tmpdir(), "ic-resume-home-")); + try { + const globalPath = await writeGlobalSettings(cwd); + const missing = generateSessionId(); + await expect( + loadConfig(["exec", "--resume", missing, "--cwd", cwd, "follow up"], { + globalSettingsPath: globalPath, + home, + }), + ).rejects.toThrow(new RegExp(`No session ${missing}`)); + expect(await sessionIdsOnDisk(cwd, home)).toEqual([]); + + const unreadable = generateSessionId(); + await initSessionDir(cwd, unreadable, home); + await writeFile(join(sessionDir(cwd, unreadable, home), "run.json"), "{"); + await expect( + loadConfig(["-p", "--resume", unreadable, "--cwd", cwd, "follow up"], { + globalSettingsPath: globalPath, + home, + }), + ).rejects.toBeInstanceOf(CliUserError); + expect(await sessionIdsOnDisk(cwd, home)).toEqual([unreadable]); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); + + test("a headless follow-up reopens the same context store and keeps prior turns", async () => { + const cwd = await emptyCwd(); + const home = await mkdtemp(join(tmpdir(), "ic-resume-home-")); + try { + const globalPath = await writeGlobalSettings(cwd); + const sessionId = generateSessionId(); + await initSessionDir(cwd, sessionId, home); + await saveState( + cwd, + sessionId, + { + status: "done", + turnsUsed: 1, + task: "first task", + startedAt: Date.now() - 1_000, + finishedAt: Date.now(), + }, + home, + ); + const contextDir = sessionContextDir(cwd, sessionId, home); + const first = await createOptimizedContextStore(contextDir); + await first.writeTurns([ + { + role: "user", + content: [{ type: "text", text: "first task" }], + timestamp: 1, + }, + { + role: "assistant", + content: [{ type: "text", text: "first answer" }], + model: "test", + timestamp: 2, + }, + ]); + await first.writeMetadata({ + pendingOperations: [], + tokenUsage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }, + }); + await first.commit({ message: "cycle" }); + + const config = await loadConfig( + ["exec", "--resume", sessionId, "--cwd", cwd, "second prompt"], + { globalSettingsPath: globalPath, home }, + ); + assertConfigured(config); + expect(config.command).toBe("exec"); + expect(config.sessionId).toBe(sessionId); + expect(config.task).toBe("second prompt"); + expect(config.skipInitialTask).toBeUndefined(); + + const reopened = await createOptimizedContextStore( + sessionContextDir(cwd, config.sessionId, home), + ); + const loaded = await reopened.load(); + expect( + loaded.turns.map((turn) => { + const block = turn.content[0]; + return block?.type === "text" ? block.text : ""; + }), + ).toEqual(["first task", "first answer"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); + test("--resume with a non-session-id token errors instead of leaking into task text", async () => { const cwd = await emptyCwd(); try { diff --git a/src/config/index.ts b/src/config/index.ts index a4631a956..eb6da1a91 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -676,14 +676,16 @@ export const CLI_HELP_TEXT = `corbits — coding agent CLI Usage: corbits [flags] [task...] - corbits exec|run [flags] + corbits exec|run|-p [flags] corbits resume|continue [session-id] [flags] Continue verbs (project-keyed to this checkout's git toplevel): resume / continue interactive session picker - --resume [] interactive session picker, or reopen a specific session - resume reopen a specific session + --resume [] interactive picker, or reopen a session; with exec/-p the id is required + resume reopen a specific session in the TUI resume --pick / --list interactive session picker + exec --resume headless: continue that session and send + -p --resume same one-shot path as exec --resume Flags: --cwd working directory (default: process.cwd()) @@ -691,7 +693,8 @@ Flags: --provider configured provider name --model model for the active provider --profile settings profile - --resume [] interactive session picker, or reopen a specific session + -p one-shot prompt (same as exec / run) + --resume [] interactive picker, or reopen a session; with exec/-p the id is required --director exec-only: run as this director (default: skywalker) --dangerously-skip-permissions skip permission prompts for this run only; @@ -744,7 +747,7 @@ export interface LoadConfigOptions { } function isFlagToken(arg: string): boolean { - return arg.startsWith("--") || arg === "-h"; + return arg.startsWith("--") || arg === "-h" || arg === "-p"; } export async function loadConfig( @@ -801,6 +804,18 @@ export async function loadConfig( } } + // `-p` is the exec one-shot path in any position (`-p --provider …`, + // `--model … -p …`). Detect it before value flags so exec-only options + // that appear before `-p` still see exec mode. + if (args.includes("-p")) { + if (leading === "resume" || leading === "continue") { + throw new Error( + `cannot combine resume with -p; use \`${COMMAND_NAME} -p --resume \` to continue a session headlessly`, + ); + } + command = "exec"; + } + let cwd = process.cwd(); let dangerouslySkipPermissions = false; // Auto mode is the default: non-destructive consequential actions (file @@ -881,18 +896,18 @@ export async function loadConfig( noWorkflow = true; continue; } + if (arg === "-p") { + command = "exec"; + continue; + } if (arg === "--resume") { - if (command === "exec") { - throw new Error("--resume is only available in interactive mode"); - } if (resumeMode === "id") { throw new Error("cannot combine a session id with --resume"); } - // Optional session id: `corbits --resume ` reopens that session - // directly (an alias for the `corbits resume ` form the exit - // hint prints); bare `--resume` opens the - // picker. A non-flag token that is not a session id errors exactly like - // the `resume` verb path instead of leaking into task text. + // Optional session id on the interactive path: `corbits --resume ` + // reopens that session (an alias for `corbits resume `); bare + // `--resume` opens the picker. Exec / `-p` require the id — headless + // has no picker. A non-id token errors instead of leaking into task text. const next = args[i + 1]; if (next !== undefined && !isFlagToken(next)) { if (!isSessionId(next)) { @@ -903,6 +918,10 @@ export async function loadConfig( resumeMode = "id"; resumeSessionId = next; i++; + } else if (command === "exec") { + throw new Error( + `--resume requires a session id in exec mode. Use \`${COMMAND_NAME} resume\` to choose a session.`, + ); } else { resumeMode = "pick"; } @@ -1106,8 +1125,12 @@ export async function loadConfig( } const state = loaded.state; sessionId = id; - skipInitialTask = true; - if (task.length === 0) resumeTask = state.task; + // The TUI reopens without auto-sending. Exec must send the new prompt + // on this session and must not substitute the stored task. + if (command !== "exec") { + skipInitialTask = true; + if (task.length === 0) resumeTask = state.task; + } } return { @@ -1123,7 +1146,12 @@ export async function loadConfig( globalSettingsPath: effectiveSettingsPath, sessionId, noWorkflow, - ...(resumeMode !== undefined ? { resumeMode, skipInitialTask } : {}), + ...(resumeMode !== undefined + ? { + resumeMode, + ...(skipInitialTask ? { skipInitialTask: true } : {}), + } + : {}), ...(resumePicker ? { resumePicker: true } : {}), ...(settings?.defaultProvider !== undefined ? { globalDefaultProvider: settings.defaultProvider }