Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 45 additions & 25 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Woopcode is a terminal-native coding agent (React Ink TUI + streaming agent loop) published to npm as `woopcode`. TypeScript throughout, running on Bun.

## Commands
Expand Down Expand Up @@ -52,8 +50,6 @@ tools/ the tool registry

The agent loop is `runtime/loop.ts`. It knows nothing about the interface, which is what lets the same loop drive both the TUI and the headless `--prompt` path; everything flows back out through `AgentCallbacks` (text, tool start, tool finish, error).

One structural fact to know before editing:

- **Approval is split in two.** `runtime/approval/classifier.ts` decides how risky a shell command is; `runtime/approval/policy.ts` decides whether that risk needs asking. Adding an approval mode is one entry in a table.

A turn: `cli.ts` → `AgentController` (owns client, model, cancellation) → `buildRepositoryContext` in `config/config.ts` (package metadata, README, agent instruction files, structure — each capped, the whole capped again) → `agentLoop` in `runtime/loop.ts` (stream, collect tool calls, execute, feed results back; 40 iterations per stretch, then it asks via `onBudgetExhausted` — absent handler means nobody to ask, and exhaustion throws as before) → tools resolved via `toolRegistry` in `tools/index.ts`.
Expand Down Expand Up @@ -139,35 +135,59 @@ matching one before starting that kind of work.

Before you do any work, mention how you could verify that work — the test, command, or observation that would show it actually worked. If a change can't be verified, say so before making it.

### Never call work finished without a green run in the tree as it stands now
### Never call work finished without checking the tree as it stands now

"Done", "shipped" and "verified" are claims about the working tree at the moment
you say them, not about a run from earlier in the session. Before any of those
words, run the suite and quote what it actually printed:
you say them, not a run from earlier. Before any of those words, run this and
quote what it printed:

```bash
bun install # if package.json or bun.lock moved since your last install
bun run verify --all # tsc + bun test + docs; the whole gate whatever changed
```

Three ways a green run goes stale underneath a claim, all of which have happened
here:

- **`node_modules` is stale.** A dependency landed on `main` and was never
installed locally, so every file that imports it fails with
`Cannot find package '<x>'`. Twenty-four tests went red this way and it reads
exactly like a code break. `bun install` first when `package.json` has moved.
- **`main` moved after your branch went green.** Two branches that each pass CI
can merge into a red `main`: git merges them without a textual conflict while
one silently fails to honour a parameter the other added. CI tested each side,
never the merge. After a fetch, merge or rebase — or when `origin/main` is
ahead — re-run the gate against the merged tree before saying anything.
- **A bare `bun run verify` on a fully staged tree** prints "nothing to check"
and exits 0. That is not a pass; see the note under Commands.

If a check was skipped or could not run, say which one and why, rather than a
sentence that implies a green run. A verification that is reported but not run is
worse than none, because it stops anyone else from looking.
Three ways a green run goes stale underneath a claim, all of which happened here:

- **`node_modules` is stale** — a dependency landed on `main`, never installed
locally, and every import of it fails with `Cannot find package '<x>'`. It went
red across twenty-four tests, reading exactly like a code break.
- **`main` moved after your branch went green** — two branches that each pass CI
can merge into a red `main`, git finding no textual conflict while one silently
fails to honour a parameter the other added. After any fetch, merge or rebase,
or whenever `origin/main` is ahead, re-run the gate on the merged tree.
- **A bare `bun run verify` on a staged tree** reports a pass it did not run; see Commands.

**But a green suite is not a review.** It says the cases you thought of hold and
nothing about the rest — and "I checked everything" is a claim about the rest.
The sessions work went green on every gate and sweep; reading the diff afterwards
found seven defects, one of which wrote into the session `--fork-session` exists
to protect. Walk the diff and ask, per changed file:

- **Every default argument** is an assumption about the caller (`forkSession`
defaulted to the current project; sessions elsewhere silently failed to fork).
- **Every `?? fallback` on a failure path** — what does it *do* when it fires?
(`fork() ?? original` turned a failed copy into a write to the original.)
- **Every read-modify-write** — two windows, two processes (the index lost a row
and the session stopped being listed at all).
- **Every counter or flag in a loop** — per-iteration or cumulative? (Prune's was
cumulative across projects.)
- **Every write recording that something happened** — does it create state where
the feature promises none? (`pruneIfDue` created the directory lazy creation
exists to avoid.)
- **Every optional CLI value** — what does the bare flag do? (`--resume` was
indistinguishable from `--continue`.)
- **Every empty string, empty array and zero** reaching a renderer.
- **Every caller you did not write** — a new signature is only as sound as the
stubs standing in for it elsewhere.

Then about the checking itself:

- **A regression test that has never failed proves nothing.** Revert the fix,
watch it go red, restore it — and confirm the revert actually applied. Twice a
mutation silently did not match, the suite stayed green, and the claim was void.
- **Name what you did not check.** Interactive input, live providers and other
processes are outside the suite's reach. Say so rather than letting a green run
imply them: a verification reported but not run stops anyone else looking.

Conventional commits (`feat(tools):`, `fix(runtime):`, …), TypeScript strict mode, small focused functions.

Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,14 @@ The conversation, provider configuration, and local state are stored in:

### Session history

Conversation history is written after every turn using an atomic write, so an interrupted session does not leave a half-written transcript behind. Restarting Woopcode in the same repository resumes from that history; `/new` clears it.
A session is one saved conversation, belonging to the project it happened in. It is written after every turn using an atomic write, so an interrupted session does not leave a half-written transcript behind. Restarting Woopcode in the same repository resumes the newest one; starting it somewhere else does not, because sessions live under `sessions/<project>/` rather than in one file shared by every repository.

`/new` starts a fresh session and keeps the old one — `/resume` goes back to it, `/rename` gives it a name, `/branch` copies it to try a second approach, and `woopcode --continue` / `--resume` do the same from the command line. Sessions are deleted 30 days after their last turn; `retentionDays` changes that and `0` keeps them forever.

Only user and assistant messages are persisted, capped at the most recent messages. Tool calls and their results are dropped: they are the bulk of a long transcript, they only mean something to the turn that produced them, and persisting half of a call/result pair would make the restored history invalid for the provider.

History written by a version before sessions existed is imported once into a `legacy` bucket, reachable from the `/resume` picker with <kbd>Ctrl</kbd>+<kbd>A</kbd>. Resume it and take a turn and it becomes that project's session; open it only to read and it stays put.

## Built-in tools

Woopcode ships with a fixed set of tools, grouped by what they touch.
Expand Down Expand Up @@ -146,7 +150,11 @@ Type `/` in the prompt to browse and autocomplete commands.
| Command | Description |
| ----------------------------- | ------------------------------------------------------------------------ |
| `/help` | Show all available commands. |
| `/new` | Start a new conversation. |
| `/new` | Start a new conversation, keeping the current one. |
| `/resume [name-or-id]` | Switch to a previous conversation, or pick one from a list. |
| `/sessions` | List saved conversations for this project. |
| `/rename <name>` | Name the current conversation so it can be resumed by name. |
| `/branch [name]` | Copy this conversation and continue in the copy. |
| `/provider [name]` | View or switch the configured provider. |
| `/login <provider> <api-key>` | Authenticate from inside the app. |
| `/logout [provider]` | Remove a saved provider key. |
Expand All @@ -157,7 +165,7 @@ Type `/` in the prompt to browse and autocomplete commands.
| `/version` | Show the Woopcode version. |
| `/exit` | Quit Woopcode. |

Most commands have short aliases: `/h` or `/?` for help, `/clear` or `/reset` for `/new`, `/p` for provider, `/m` or `/model` for models, `/v` for version, `/q` or `/quit` for exit.
Most commands have short aliases: `/h` or `/?` for help, `/clear` or `/reset` for `/new`, `/r` for resume, `/ls` for sessions, `/fork` for branch, `/p` for provider, `/m` or `/model` for models, `/v` for version, `/q` or `/quit` for exit.

| Key | Action |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
Expand Down
8 changes: 5 additions & 3 deletions cli.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/usr/bin/env bun
import { program } from "commander";
import { modelsCommand } from "./commands/models";
import { agentCommand, runAgent } from "./commands/agent";
import { addSessionOptions, agentCommand, runAgent } from "./commands/agent";
import { providerCommand } from "./commands/providers";
import { sessionsCommand } from "./commands/sessions";
import { VERSION } from "./config/version";

program
addSessionOptions(program)
.name("woopcode")
.description("Coding agent cli")
.version(VERSION)
Expand All @@ -20,7 +21,8 @@ program
.action(runAgent)
.addCommand(modelsCommand)
.addCommand(agentCommand)
.addCommand(providerCommand);
.addCommand(providerCommand)
.addCommand(sessionsCommand);

// A configuration failure (no provider, unusable key) is a normal outcome for
// an automated caller, not a crash. Reporting it as a one-line message with a
Expand Down
125 changes: 111 additions & 14 deletions commands/agent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getConfig } from "../config/config";
import type { AgentCallbacks, TurnSummary } from "../config/types";
import { App, store } from "../tui/src";
import { render } from "ink";
import { AgentController } from "./agentController";
import { AgentController, type InitializeOptions } from "./agentController";
import { DEFAULT_MODEL_ID } from "../providers/client";
import type { HomeScreenData } from "../tui/src/components/HomeScreen";
import { ensureProviderConfigured } from "../onboarding";
Expand All @@ -28,15 +28,77 @@ export interface RunAgentOptions {
model?: string;
/** Headless only: path to write a JSONL record of the run to. */
events?: string;
/** Reopen the newest session in this project. */
continue?: boolean;
/** A session name, id or id prefix to resume. `true` opens the picker. */
resume?: string | boolean;
/** Start a fresh session even where one would otherwise be continued. */
new?: boolean;
/** Branch whatever was resumed instead of writing into it. */
forkSession?: boolean;
/** Name a new session, so it can be resumed by name later. */
name?: string;
/** Headless only: run without ever writing a session file. */
sessionPersistence?: boolean;
}

export const agentCommand = new Command("agent")
.description("Runs the agent")
.option("-p, --prompt <prompt>", "run a single prompt headlessly and exit", "")
.option("--no-auto-approve", "with --prompt, reject tool edits and commands instead of approving them")
.option("-m, --model <model>", "model id to use for this run")
.option("--events <path>", "with --prompt, write a JSONL record of the run to this path")
.action(runAgent);
/**
* Turns the session flags into what `AgentController.initialize` takes.
*
* The two entry points differ in one default and it matters: the TUI continues
* where you left off, because that is what Woopcode has always done and what
* the documentation promises, while `-p` starts clean. A headless run used to
* inherit whatever the interactive session had been doing, which is a surprise
* for a scripted caller and impossible to opt out of.
*/
export function sessionOptionsFrom(
options: RunAgentOptions,
mode: "interactive" | "headless",
): InitializeOptions {
const resumeRef = typeof options.resume === "string" ? options.resume.trim() : "";
// `--resume` with no value. Interactively that opens the picker; headlessly
// there is nobody to pick, so the caller has to say which session.
const wantsPicker = options.resume === true;

if (wantsPicker && mode === "headless") {
throw new Error("--resume needs a session id when used with --prompt.");
}

return {
...(resumeRef ? { sessionRef: resumeRef } : {}),
continueLatest:
!options.new &&
!resumeRef &&
(options.continue === true || mode === "interactive"),
fork: options.forkSession === true,
...(options.name ? { name: options.name } : {}),
persist: options.sessionPersistence !== false,
openPicker: wantsPicker,
};
}

/**
* The session flags, declared on both the root program and `agent` because
* either can be the one commander parses.
*/
export function addSessionOptions(command: Command): Command {
return command
.option("-c, --continue", "resume the newest session in this project")
.option("--resume [session]", "resume a session by name or id")
.option("--new", "start a fresh session instead of continuing")
.option("--fork-session", "with --continue or --resume, branch instead of writing into it")
.option("-n, --name <name>", "name a new session so it can be resumed by name")
.option("--no-session-persistence", "with --prompt, do not save the session");
}

export const agentCommand = addSessionOptions(
new Command("agent")
.description("Runs the agent")
.option("-p, --prompt <prompt>", "run a single prompt headlessly and exit", "")
.option("--no-auto-approve", "with --prompt, reject tool edits and commands instead of approving them")
.option("-m, --model <model>", "model id to use for this run")
.option("--events <path>", "with --prompt, write a JSONL record of the run to this path"),
).action(runAgent);

/**
* Entry point for both `woopcode` and `woopcode agent`. With `--prompt` the
Expand All @@ -51,12 +113,19 @@ export async function runAgent(options: RunAgentOptions = {}, command?: Command)
options.autoApprove !== false && globals?.autoApprove !== false;
const model = options.model || globals?.model;
const events = options.events || globals?.events;
// Session flags can land on either the root program or the subcommand, the
// same way --prompt does.
const merged: RunAgentOptions = { ...globals, ...options };

if (prompt) {
return runHeadless(prompt, autoApprove, { model, events });
return runHeadless(prompt, autoApprove, {
model,
events,
session: sessionOptionsFrom(merged, "headless"),
});
}

return runInteractive(model);
return runInteractive(model, sessionOptionsFrom(merged, "interactive"));
}

/**
Expand All @@ -75,7 +144,7 @@ async function resolveModel(override: string | undefined): Promise<string> {
async function runHeadless(
prompt: string,
autoApprove: boolean,
options: { model?: string; events?: string } = {},
options: { model?: string; events?: string; session?: InitializeOptions } = {},
) {
registerCommands();
const { provider, apiKey } = await ensureProviderConfigured();
Expand Down Expand Up @@ -181,7 +250,12 @@ async function runHeadless(
};

const controller = new AgentController(provider, apiKey, selectedModel, callbacks);
await controller.initialize();
await controller.initialize(options.session);

// On stderr, not stdout: stdout is the agent's answer and a caller pipes it.
// Printed so a script can follow up with `--resume <id>` on the same session.
const session = controller.currentSession();
if (session) process.stderr.write(`session ${session.id}\n`);

const onSigint = () => {
controller.cancel();
Expand Down Expand Up @@ -215,7 +289,10 @@ async function runHeadless(
export const EXIT_BUDGET_EXHAUSTED = 2;

/** Runs the interactive TUI agent. */
async function runInteractive(modelOverride?: string) {
async function runInteractive(
modelOverride?: string,
session: InitializeOptions = { continueLatest: true },
) {
// Register slash commands
registerCommands();

Expand Down Expand Up @@ -316,7 +393,27 @@ async function runInteractive(modelOverride?: string) {
},
};
const controller = new AgentController(provider, apiKey, selectedModel, callbacks);
await controller.initialize();
try {
await controller.initialize(session);
} catch (error) {
// A --resume that names nothing is a usage error, not a crash: report it
// and stop rather than dropping the user into a session they did not ask
// for and might overwrite.
process.stderr.write(`✖ ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}

// Draws the resumed conversation. Without this the transcript is blank over a
// history the model can see, which reads as the history having been lost.
const resumed = controller.currentSession();
if (resumed && resumed.messages.length > 0) {
store.hydrateTimeline(resumed.messages);
}

// A bare `--resume` asks to choose. The newest session is loaded above so
// there is something behind the dialog and something to fall back to on Esc.
if (session.openPicker) store.openSessionPicker();

const homeScreen = await buildHomeScreen(provider);

const customStdin = new PassThrough() as any;
Expand Down
Loading