diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7444d7a0a..86e635278 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ ### New Features +- **Startup session naming** - `--name` / `-n` sets the session display name at startup for interactive, print, JSON, and RPC modes. - **Claude Opus 4.8 support** - Adds Anthropic Claude Opus 4.8 metadata and updates Opus adaptive-thinking coverage. - **Selective tool disablement** - `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while leaving the rest available. See [Tool Options](docs/usage.md#tool-options). - **Headless Codex subscription login** - `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions. See [Subscriptions](docs/providers.md#subscriptions) and [OpenAI Codex](docs/providers.md#openai-codex). @@ -17,6 +18,7 @@ ### Added +- Added `--name` / `-n` to set the session display name at startup ([#5153](https://github.com/earendil-works/pi/issues/5153)). - Added `--exclude-tools` / `-xt` to disable specific built-in, extension, or custom tools while leaving the rest available ([#5109](https://github.com/earendil-works/pi/issues/5109)). - Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)). - Added `streamingBehavior` to extension input events so extensions can distinguish idle prompts from mid-stream steers and queued follow-ups ([#5107](https://github.com/earendil-works/pi/pull/5107) by [@DanielThomas](https://github.com/DanielThomas)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index a326d2f07..0cf352551 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -239,6 +239,7 @@ Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory. pi -c # Continue most recent session pi -r # Browse and select from past sessions pi --no-session # Ephemeral mode (don't save) +pi --name "my task" # Set session display name at startup pi --session # Use specific session file or ID pi --fork # Fork specific session file or ID into a new session ``` @@ -545,6 +546,7 @@ cat README.md | pi -p "Summarize this text" | `--fork ` | Fork specific session file or partial UUID into a new session | | `--session-dir ` | Custom session storage directory | | `--no-session` | Ephemeral mode (don't save) | +| `--name `, `-n ` | Set session display name at startup | ### Tool Options @@ -605,6 +607,9 @@ pi -p "Summarize this codebase" # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" +# Named one-shot session +pi --name "release audit" -p "Audit this repository" + # Different model pi --provider openai --model gpt-4o "Help me refactor" diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index c7266346a..59026738a 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -136,6 +136,7 @@ Sessions are saved automatically: ```bash pi -c # Continue most recent session pi -r # Browse previous sessions +pi --name "my task" # Set session display name at startup pi --session # Open a specific session ``` diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 7a284453a..846162c3d 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -13,6 +13,7 @@ pi --mode rpc [options] Common options: - `--provider `: Set the LLM provider (anthropic, openai, google, etc.) - `--model `: Model pattern or ID (supports `provider/id` and optional `:`) +- `--name ` / `-n `: Set the session display name at startup - `--no-session`: Disable session persistence - `--session-dir `: Custom session storage directory @@ -694,7 +695,7 @@ Response: } ``` -The current session name is available via `get_state` in the `sessionName` field. +The current session name is available via `get_state` in the `sessionName` field. To set the initial name when starting RPC mode, pass `--name ` or `-n ` to the `pi --mode rpc` process. ### Commands diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index 848fc61ad..c32ce3906 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -282,7 +282,7 @@ Set `label` to `undefined` to clear a label. ### SessionInfoEntry -Session metadata (e.g., user-defined display name). Set via `/name` command or `pi.setSessionName()` in extensions. +Session metadata (e.g., user-defined display name). Set via `/name`, `--name` / `-n`, or `pi.setSessionName()` in extensions. ```json {"type":"session_info","id":"k1l2m3n4","parentId":"j0k1l2m3","timestamp":"2024-12-03T14:35:00.000Z","name":"Refactor auth module"} diff --git a/packages/coding-agent/docs/sessions.md b/packages/coding-agent/docs/sessions.md index d262f8531..1a50ee02d 100644 --- a/packages/coding-agent/docs/sessions.md +++ b/packages/coding-agent/docs/sessions.md @@ -10,6 +10,7 @@ Sessions auto-save to `~/.pi/agent/sessions/`, organized by working directory. E pi -c # Continue most recent session pi -r # Browse and select from past sessions pi --no-session # Ephemeral mode; do not save +pi --name "my task" # Set session display name at startup pi --session # Use a specific session file or partial session ID pi --fork # Fork a session file or partial session ID into a new session ``` @@ -56,6 +57,13 @@ Use `/name ` to set a human-readable session name: /name Refactor auth module ``` +Set the name at startup with `--name` or `-n`: + +```bash +pi --name "Refactor auth module" +pi --name "CI audit" -p "Review this build failure" +``` + Named sessions are easier to find in `/resume` and `pi -r`. ## Branching with `/tree` diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 6e6069121..47d08769c 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -76,6 +76,7 @@ Sessions are saved automatically to `~/.pi/agent/sessions/`, organized by workin pi -c # Continue most recent session pi -r # Browse and select a session pi --no-session # Ephemeral mode; do not save +pi --name "my task" # Set session display name at startup pi --session # Use a specific session file or session ID pi --fork # Fork a session into a new session file ``` @@ -178,6 +179,7 @@ cat README.md | pi -p "Summarize this text" | `--fork ` | Fork a session file or partial UUID into a new session | | `--session-dir ` | Custom session storage directory | | `--no-session` | Ephemeral mode; do not save | +| `--name `, `-n ` | Set session display name at startup | ### Tool Options @@ -242,6 +244,9 @@ pi -p "Summarize this codebase" # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" +# Named one-shot session +pi --name "release audit" -p "Audit this repository" + # Different model pi --provider openai --model gpt-4o "Help me refactor" diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 72317cfd3..2decae247 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -21,6 +21,7 @@ export interface Args { help?: boolean; version?: boolean; mode?: Mode; + name?: string; noSession?: boolean; session?: string; sessionId?: string; @@ -93,6 +94,12 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--append-system-prompt" && i + 1 < args.length) { result.appendSystemPrompt = result.appendSystemPrompt ?? []; result.appendSystemPrompt.push(args[++i]); + } else if (arg === "--name" || arg === "-n") { + if (i + 1 < args.length) { + result.name = args[++i]; + } else { + result.diagnostics.push({ type: "error", message: "--name requires a value" }); + } } else if (arg === "--no-session") { result.noSession = true; } else if (arg === "--session" && i + 1 < args.length) { @@ -237,6 +244,7 @@ ${chalk.bold("Options:")} --fork Fork specific session file or partial UUID into a new session --session-dir Directory for session storage and lookup --no-session Don't save session (ephemeral) + --name, -n Set session display name --models Comma-separated model patterns for Ctrl+P cycling Supports globs (anthropic/*, *sonnet*) and fuzzy matching --no-tools, -nt Disable all tools by default (built-in and extension) @@ -283,6 +291,9 @@ ${chalk.bold("Examples:")} # Continue previous session ${APP_NAME} --continue "What did we discuss?" + # Start a named session + ${APP_NAME} --name "Refactor auth module" + # Use different model ${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code" diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 4b6c4e165..353e48156 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -571,6 +571,14 @@ export async function main(args: string[], options?: MainOptions) { process.exit(1); } } + if (parsed.name !== undefined) { + const name = parsed.name.trim(); + if (!name) { + console.error(chalk.red("Error: --name requires a non-empty value")); + process.exit(1); + } + sessionManager.appendSessionInfo(name); + } time("createSessionManager"); const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 8f26a0eef..78fd832c8 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -157,6 +157,36 @@ describe("parseArgs", () => { }); }); + describe("--name flag", () => { + test("parses --name flag with value", () => { + const result = parseArgs(["--name", "my-session"]); + expect(result.name).toBe("my-session"); + }); + + test("parses -n shorthand", () => { + const result = parseArgs(["-n", "quick-session"]); + expect(result.name).toBe("quick-session"); + }); + + test("preserves empty values for main validation", () => { + const result = parseArgs(["--name", ""]); + expect(result.name).toBe(""); + }); + + test("reports missing value", () => { + const result = parseArgs(["--name"]); + expect(result.diagnostics).toEqual([{ type: "error", message: "--name requires a value" }]); + }); + + test("works alongside other flags", () => { + const result = parseArgs(["--name", "named-run", "--print", "--model", "gpt-4o", "hello"]); + expect(result.name).toBe("named-run"); + expect(result.print).toBe(true); + expect(result.model).toBe("gpt-4o"); + expect(result.messages).toEqual(["hello"]); + }); + }); + describe("--no-session flag", () => { test("parses --no-session flag", () => { const result = parseArgs(["--no-session"]); diff --git a/packages/coding-agent/test/startup-session-name.test.ts b/packages/coding-agent/test/startup-session-name.test.ts new file mode 100644 index 000000000..2d6f94ce5 --- /dev/null +++ b/packages/coding-agent/test/startup-session-name.test.ts @@ -0,0 +1,135 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.ts"; + +const cliPath = resolve(__dirname, "../src/cli.ts"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-startup-session-name-")); + tempDirs.push(dir); + return dir; +} + +interface CliDirs { + agentDir: string; + projectDir: string; + sessionFile: string; +} + +interface CliResult { + code: number | null; + signal: NodeJS.Signals | null; + stderr: string; +} + +function createSessionFile(projectDir: string, sessionFile: string): void { + const timestamp = new Date().toISOString(); + writeFileSync( + sessionFile, + `${JSON.stringify({ type: "session", version: 3, id: "existing-session", timestamp, cwd: projectDir })}\n${JSON.stringify( + { + type: "message", + id: "assistant-1", + parentId: null, + timestamp, + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + provider: "anthropic", + model: "claude-sonnet-4-5", + timestamp: Date.now(), + }, + }, + )}\n`, + ); +} + +function readSessionInfoNames(sessionFile: string): string[] { + return readFileSync(sessionFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { type?: string; name?: string }) + .filter((entry) => entry.type === "session_info") + .map((entry) => entry.name ?? ""); +} + +async function runCli(args: string[], dirs: CliDirs): Promise { + let stderr = ""; + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: dirs.projectDir, + env: { + ...process.env, + [ENV_AGENT_DIR]: dirs.agentDir, + PI_OFFLINE: "1", + TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + return new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + }, 10_000); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timeout); + resolvePromise({ code, signal, stderr }); + }); + }); +} + +function setup(): CliDirs { + const tempRoot = createTempDir(); + const dirs = { + agentDir: join(tempRoot, "agent"), + projectDir: join(tempRoot, "project"), + sessionFile: join(tempRoot, "session.jsonl"), + }; + mkdirSync(dirs.agentDir, { recursive: true }); + mkdirSync(dirs.projectDir, { recursive: true }); + createSessionFile(dirs.projectDir, dirs.sessionFile); + return dirs; +} + +describe("startup session name", () => { + it("sets --name on the selected session before runtime model validation", async () => { + const dirs = setup(); + const result = await runCli( + ["--session", dirs.sessionFile, "--name", " CLI Named Session ", "--model", "missing-model", "-p", "hi"], + dirs, + ); + + expect(result.code).toBe(1); + expect(result.signal).toBeNull(); + expect(readSessionInfoNames(dirs.sessionFile)).toEqual(["CLI Named Session"]); + }); + + it("rejects empty --name values without appending session metadata", async () => { + const dirs = setup(); + const result = await runCli( + ["--session", dirs.sessionFile, "--name", " ", "--model", "missing-model", "-p", "hi"], + dirs, + ); + + expect(result.code).toBe(1); + expect(result.signal).toBeNull(); + expect(result.stderr).toContain("--name requires a non-empty value"); + expect(readSessionInfoNames(dirs.sessionFile)).toEqual([]); + }); +});