();
@@ -86,6 +105,7 @@ export function addSubscriber(
};
rec.subscribers.add(sub);
+ rec.emptySince = undefined;
// Bind close/error on both directions. Either side closing means the
// socket is gone for our purposes. We bind BEFORE writing anything because
@@ -105,7 +125,7 @@ export function removeSubscriber(sessionId: string, sub: Subscriber): void {
const rec = sessions.get(sessionId);
if (!rec) return;
rec.subscribers.delete(sub);
- tearDownIfEmpty(sessionId);
+ markEmpty(sessionId);
}
/**
@@ -172,10 +192,14 @@ export function replayForSubscriber(
afterSeq: number | undefined,
): ReplayResult {
const rec = sessions.get(sessionId);
- if (!rec) return { gap: false, from: 0, to: 0, events: [] };
if (afterSeq === undefined || afterSeq < 0) {
return { gap: false, from: 0, to: 0, events: [] };
}
+ // No record, but the client claims to have seen events: the buffer aged
+ // out (or the daemon restarted). We cannot know what was missed, so the
+ // only honest answer is a gap. Reporting "nothing missed" here is worse
+ // than eviction, which at least admits the loss.
+ if (!rec) return { gap: true, from: afterSeq + 1, to: afterSeq + 1 };
return rec.buffer.replayFrom(afterSeq);
}
@@ -262,12 +286,16 @@ function getOrCreateSession(sessionId: string): SessionRecord {
return rec;
}
-function tearDownIfEmpty(sessionId: string): void {
+/**
+ * The last subscriber left. Start the TTL rather than disposing: the buffer
+ * is exactly what a reconnect needs, and the common single-browser case
+ * always passes through here. The reaper does the actual disposal.
+ */
+function markEmpty(sessionId: string): void {
const rec = sessions.get(sessionId);
if (!rec) return;
- if (rec.subscribers.size === 0) {
- rec.buffer.dispose();
- sessions.delete(sessionId);
+ if (rec.subscribers.size === 0 && rec.emptySince === undefined) {
+ rec.emptySince = Date.now();
}
}
@@ -281,11 +309,18 @@ function tearDownIfEmpty(sessionId: string): void {
const HEARTBEAT_FRAME = ": heartbeat\n\n";
-function tick(): void {
- const now = Date.now();
+function tick(now: number = Date.now()): void {
for (const sessionId of [...sessions.keys()]) {
const rec = sessions.get(sessionId);
if (!rec) continue;
+ if (
+ rec.subscribers.size === 0 &&
+ rec.emptySince !== undefined &&
+ now - rec.emptySince > RECORD_TTL_MS
+ ) {
+ disposeSession(sessionId);
+ continue;
+ }
for (const sub of [...rec.subscribers]) {
// Idle reaper — backstop for half-open sockets that never fire close.
if (now - sub.lastWriteMs > IDLE_TIMEOUT_MS) {
@@ -316,4 +351,14 @@ reaper.unref?.();
export const STREAM_TIMINGS = Object.freeze({
HEARTBEAT_MS,
IDLE_TIMEOUT_MS,
-});
\ No newline at end of file
+ RECORD_TTL_MS,
+});
+
+/**
+ * Test seam: run one reaper pass synchronously, optionally at a simulated
+ * clock. The interval is unref'd and fires every HEARTBEAT_MS, which is far
+ * too slow to assert a five-minute TTL.
+ */
+export function runReaperForTests(now?: number): void {
+ tick(now);
+}
\ No newline at end of file
diff --git a/apps/docs/app/getting-started/_meta.ts b/apps/docs/app/getting-started/_meta.ts
index c2fda227..02b6921f 100644
--- a/apps/docs/app/getting-started/_meta.ts
+++ b/apps/docs/app/getting-started/_meta.ts
@@ -5,5 +5,6 @@ export default {
installation: "Installation",
quickstart: "Quickstart",
providers: "Providers & API keys",
+ "anthropic-subscription": "Anthropic subscription",
configuration: "Configuration"
} satisfies MetaRecord;
diff --git a/apps/docs/app/getting-started/anthropic-subscription/page.mdx b/apps/docs/app/getting-started/anthropic-subscription/page.mdx
new file mode 100644
index 00000000..b4e31e3a
--- /dev/null
+++ b/apps/docs/app/getting-started/anthropic-subscription/page.mdx
@@ -0,0 +1,129 @@
+---
+title: "Anthropic subscription login"
+description: "Use a Claude Pro/Max subscription instead of an API key — how it works, and the risk you take."
+---
+
+# Anthropic subscription login
+
+FreeCode can authenticate the `anthropic` provider with your Claude Pro/Max
+subscription instead of a metered API key:
+
+```bash
+freecode auth login anthropic
+```
+
+Read the next section before you run it. It is not a formality.
+
+## Before you turn this on
+
+Your Pro/Max allowance is only reachable through Anthropic's OAuth surface, and
+that surface only answers requests that look like Claude Code. So to use it,
+FreeCode sends **Claude Code's OAuth client id, its `User-Agent` and beta
+headers, and its identity line as the first system block.** It presents itself
+to Anthropic as Claude Code, because that is the only thing the endpoint
+accepts.
+
+> **This is a spoof, and we are not going to call it anything else.**
+>
+> - Anthropic reserves subscription inference for its own official surfaces.
+> Using it from FreeCode is against the spirit, and arguably the letter, of
+> your agreement with them — however common the practice is across
+> open-source agents.
+> - Anthropic has acted against tools that do this. The Cloudflare challenge at
+> the token endpoint, and tokens that refresh cleanly but are refused at
+> inference time, are both things FreeCode handles because they happen.
+> - **The account at risk is yours.** Not FreeCode's keys, not FreeCode's
+> infrastructure. Yours.
+
+That is the whole stance. We ship the feature, we do not hide what it does, and
+whether the trade is worth it is your call, on your account. If it isn't, use an
+[API key](/getting-started/providers) — that path is unchanged and is still the
+default.
+
+## What is on by default
+
+Nothing. API key is the default auth mode, and a machine with a key configured
+never silently switches to your subscription. OAuth activates on exactly three
+opt-ins:
+
+| Opt-in | Where |
+| --- | --- |
+| `freecode auth login anthropic` | pins the mode for you as part of logging in |
+| `providers.anthropic.authMode: "oauth"` | `~/.freecode/config.json` |
+| `FREECODE_ANTHROPIC_AUTH=oauth` | environment, wins over config |
+
+There is one more path, and it is narrow: if you have **no** Anthropic API key
+configured but do have a FreeCode login stored, it uses the login rather than
+failing. An official Claude Code login sitting in `~/.claude/.credentials.json`
+does **not** count — importing someone else's session is not an opt-in you made.
+
+## Logging in
+
+```bash
+freecode auth login anthropic # opens a browser
+freecode auth login anthropic --no-browser # print the URL, paste the code back
+```
+
+It runs PKCE against a `localhost` callback, waiting up to 120 seconds. If the
+callback cannot be reached — a remote box, a locked-down browser — it falls back
+to printing a URL you can open anywhere and pasting the resulting code back.
+Either way it is a single process: the OAuth `state` **is** the PKCE verifier, so
+there is no second command to run and no code to carry between terminals.
+
+Tokens land in `~/.freecode/auth.json` at mode `0600` — a different file from
+`config.json` on purpose, because that one gets hand-edited and sometimes ends up
+in a dotfiles repo. They refresh automatically.
+
+## Checking and reverting
+
+```bash
+freecode auth status
+```
+
+```
+anthropic auth mode: oauth
+ oauth token: valid until 05/09/2026, 18:41:00
+ scopes: user:inference user:profile
+```
+
+```bash
+freecode auth logout anthropic
+```
+
+Logout deletes the stored tokens **and** un-pins the mode, so `anthropic` goes
+straight back to your API key. Nothing else about your setup is touched.
+
+## When Anthropic says no
+
+If your organization is not allowed to use OAuth, the API returns a 403 that
+reads like a rejected login. FreeCode detects that specific refusal at the fetch
+layer, **latches it for the rest of the process** — retrying is pointless — and
+falls back to your API key if one is configured. The identity block is dropped
+along with it: a request authenticated with a real API key must never carry the
+Claude Code identity string, and that split is enforced in code with a test.
+
+A Cloudflare challenge at the token endpoint gets its own message, because the
+raw response looks like bad credentials and is not.
+
+## Cost accounting
+
+A subscription call is not free, it is prepaid — but it does not price like a
+metered one. FreeCode stamps the auth mode onto the recorded call
+(`model.response` carries `authMode`) rather than reading live config when the
+log is later folded, so a session's cost does not change because you switched
+modes afterwards.
+
+The eval harness goes further: `baselineFor` **refuses to compare across an
+auth-mode switch**, so a subscription run never becomes the bar an API-key run is
+measured against.
+
+## Known gaps
+
+Also tracked in `TODO.md`.
+
+- **Tool names are forwarded unmapped.** Other clients rename tools to the ones
+ Claude Code ships. FreeCode does not, which is a tool-use-*quality* question,
+ not an access or billing one — the endpoint accepts the calls either way.
+- **No multi-account support.** One Anthropic login per machine.
+- **`anthropic` is the only provider with an OAuth mode.** `freecode auth login`
+ rejects any other provider by name.
diff --git a/apps/docs/app/getting-started/installation/page.mdx b/apps/docs/app/getting-started/installation/page.mdx
index 0ee82907..652ece15 100644
--- a/apps/docs/app/getting-started/installation/page.mdx
+++ b/apps/docs/app/getting-started/installation/page.mdx
@@ -132,19 +132,21 @@ that works in the repo root and is broken everywhere else.
## Uninstalling
```bash
-freecode uninstall # asks first
-freecode uninstall --force # does not
+freecode uninstall # removes the binaries, keeps your data
+freecode uninstall --dry-run # prints the list, deletes nothing
+freecode uninstall --purge # also deletes ~/.freecode
+freecode uninstall --force # skips the y/N prompt
```
-It prints what it will remove, then deletes `~/.freecode` **entirely** plus a
-`freecode` binary found in `/usr/local/bin`, `/usr/bin`, `~/.local/bin`, or
-`~/.cargo/bin`.
+It prints what it will remove, then takes a `freecode` binary found in
+`/usr/local/bin`, `/usr/bin`, `~/.local/bin`, or `~/.cargo/bin`, plus
+`~/.freecode/builds`.
-> **That directory is not just the binary.** Your sessions, rollout logs,
-> per-project memory, prompt history, and usage data all live under
-> `~/.freecode/`. There is no `--keep-data` and nothing is backed up. Copy
-> `~/.freecode/projects/` and `~/.freecode/sessions/` first if you might want
-> them later.
+> **`--purge` is the one that costs you something.** Your sessions, rollout
+> logs, per-project memory, prompt history, and usage data all live under
+> `~/.freecode/`, and that flag is what deletes them. Nothing is backed up, so
+> copy `~/.freecode/projects/` and `~/.freecode/sessions/` first if you might
+> want them later.
There is also `curl -fsSL https://freecode.website/uninstall | bash`, for when
the binary itself is broken.
diff --git a/apps/docs/app/reference/cli/page.mdx b/apps/docs/app/reference/cli/page.mdx
index 68fccac4..cec80422 100644
--- a/apps/docs/app/reference/cli/page.mdx
+++ b/apps/docs/app/reference/cli/page.mdx
@@ -51,6 +51,7 @@ error — is swallowed and the current version opens.
```bash
freecode run [message..] [--model ] [--agent ] [--continue] [--session ]
+ [--max-turns ] [--yes] [--allow ]
```
| Flag | Alias | Type | Default | Meaning |
@@ -60,6 +61,9 @@ freecode run [message..] [--model ] [--agent ] [--continue] [--sessio
| `--agent` | — | string | `build` | `plan` \| `build` \| `review` \| `explore` \| `danger` |
| `--continue` | `-c` | boolean | `false` | continue the most recent *active* session for this directory |
| `--session` | `-s` | string | — | continue a specific session id |
+| `--max-turns` | — | number | unbounded | cap on agent iterations; loop-health and the gates are the only limit without it |
+| `--yes` | `-y` | boolean | `false` | answer permission prompts with *allow*. Deny rules and read-only modes still refuse |
+| `--allow` | — | string (repeatable) | `[]` | grant one permission rule for this run only, e.g. `--allow 'Bash(pnpm test:*)'` |
If no message positional is given and stdin is not a TTY, the prompt is read from
stdin — `echo "explain this repo" | freecode run` works.
@@ -70,17 +74,33 @@ tool activity, thinking, and errors go to **stderr**. That is what makes
`0` when the turn succeeded, `1` otherwise (including "no message provided" and
"no provider configured").
-The turn is **unbounded** — there is no `--max-turns`. Use
-`FREECODE_MAX_TURN_TOKENS` if you need a spend ceiling in CI.
+The turn is **unbounded** unless you pass `--max-turns`. That caps iterations,
+not spend — use `FREECODE_MAX_TURN_TOKENS` for a token ceiling in CI.
> **Read this before scripting `run`.** In headless mode nobody can answer a
> permission prompt, and an unanswerable prompt resolves to **deny**, never to a
> silent allow. In the default `build` mode every mutating tool (`write`, `edit`,
-> `bash`) defaults to *ask*, so a bare `freecode run "fix the test"` can read your
-> repository but will be denied every write. Make it work by granting the rules up
-> front in `.freecode/settings.json` (`"allow": ["Edit", "Write", "Bash(pnpm test:*)"]`)
-> — or, if you genuinely accept the risk in a sandbox, `--agent danger`, which
-> bypasses evaluation entirely. See [known gaps](#known-gaps).
+> `bash`) defaults to *ask*, so a bare `freecode run "fix the test"` reads your
+> repository fine and is denied every write.
+
+Three ways to make a headless run able to act, narrowest first:
+
+```bash
+# 1. Grant exactly what the task needs, for this run only.
+freecode run --allow 'Edit' --allow 'Bash(pnpm test:*)' "fix the failing test"
+
+# 2. Answer every prompt with allow. Deny rules and read-only modes still refuse.
+freecode run --yes "fix the failing test"
+
+# 3. Persist the grants for the repo, in .freecode/settings.json.
+# { "permissions": { "allow": ["Edit", "Write", "Bash(pnpm test:*)"] } }
+freecode run "fix the failing test"
+```
+
+`--yes` answers the *ask* tier and nothing else. A `deny` rule is still absolute,
+and `--agent plan|review|explore` is still read-only — those are decisions someone
+already made, not questions waiting for an answer. `--agent danger` remains the
+only flag that bypasses evaluation entirely; use it only in a sandbox.
## `freecode serve` — the backend alone
@@ -92,8 +112,9 @@ No flags. Starts the JSON-RPC 2.0 backend on stdin/stdout — the same process t
TUI spawns internally, and what any other frontend attaches to. See
[IPC methods](/reference/ipc-methods).
-This is also the only entry point that loads `settings.json` **hooks** and
-registers the built-in `rtk` rewrite hook.
+`settings.json` **hooks** and the built-in `rtk` rewrite hook load here through
+`hooks/bootstrap.ts` — the same bootstrap `freecode run` calls, so a headless run
+and a served session see the same hooks.
## `freecode web` — browser frontend
@@ -241,6 +262,38 @@ Servers are written to `~/.freecode/config.json` under `mcp.servers`, with
`enabled: true` and `timeout: 5000` defaults. There is no project-scoped MCP
config ([known gaps](#known-gaps)).
+## `freecode auth`
+
+```bash
+freecode auth login [provider] [--no-browser]
+freecode auth status
+freecode auth logout [provider]
+```
+
+Authenticates the `anthropic` provider with a Claude Pro/Max subscription
+instead of an API key. `provider` defaults to `anthropic` and any other value is
+rejected by name — it is the only provider with an OAuth mode.
+
+| Command | Flag | Notes |
+| --- | --- | --- |
+| `login` | `--no-browser` | skip opening the authorize URL; print it and paste the code back |
+| `status` | — | auth mode, token expiry, scopes; also reports an importable Claude Code login |
+| `logout` | — | deletes the stored tokens **and** un-pins the mode, reverting to your API key |
+
+`login` prints a disclosure before it does anything, and it means it: reaching
+subscription inference requires presenting FreeCode to Anthropic **as Claude
+Code** — its OAuth client id, its headers, its identity line. Anthropic reserves
+that inference for its own surfaces and has acted against tools doing this, and
+the account at risk is yours. The full stance, the three opt-ins, and what
+happens when Anthropic refuses are on
+[Anthropic subscription login](/getting-started/anthropic-subscription).
+
+The flow is PKCE against a `localhost` callback with a 120-second wait, falling
+back to paste. It is a single process — the OAuth `state` **is** the PKCE
+verifier, so there is no second command and no `--code` flag. Tokens go to
+`~/.freecode/auth.json` at mode `0600`, separate from `config.json`, and refresh
+automatically.
+
## `freecode update`
Runs `curl -fsSL https://freecode.website/install | bash` and exits with the
@@ -250,42 +303,35 @@ from source.
## `freecode uninstall`
```bash
-freecode uninstall [--force]
+freecode uninstall [--purge] [--dry-run] [--force]
```
| Flag | Alias | Default | Meaning |
| --- | --- | --- | --- |
-| `--force` | `-f` | `false` | skip the confirmation prompt |
-
-Removes `~/.freecode` **entirely**, plus a `freecode` binary found in
-`/usr/local/bin`, `/usr/bin`, `~/.local/bin`, or `~/.cargo/bin`. It prints the
-list first and asks `y/n` unless `--force` is given. Exit code is `1` if any
-removal failed.
-
-That directory is not just the binary: it contains your sessions, rollout logs,
-memory, prompt history, and usage data. There is no `--keep-data`, and nothing is
-backed up. Copy `~/.freecode/projects/` and `~/.freecode/sessions/` first if you
+| `--purge` | — | `false` | also delete `~/.freecode` — sessions, memory, history, usage |
+| `--dry-run` | — | `false` | print what would be removed, delete nothing |
+| `--force` | `-f`, `-y`, `--yes` | `false` | skip the confirmation prompt |
+
+**The program goes, the data stays.** By default it removes a `freecode` binary
+found in `/usr/local/bin`, `/usr/bin`, `~/.local/bin`, or `~/.cargo/bin`, plus
+`~/.freecode/builds` (the installed versions). Your sessions, rollout logs,
+memory, prompt history, and usage stay where they are.
+
+`--purge` is what takes `~/.freecode` entirely, and the confirmation names what
+is inside it rather than saying only "proceed". Nothing is backed up either way,
+so copy `~/.freecode/projects/` and `~/.freecode/sessions/` before purging if you
might want them back.
+It prints the list first and asks `y/N` unless `--force` is given; a
+non-terminal stdin is an error telling you to pass `--force`, not a silent "no".
+Exit code is `1` if any removal failed. These are the same semantics as
+`curl -fsSL https://freecode.website/uninstall | bash`, which has always drawn
+the line here.
+
## Known gaps
Found while writing this page; each is also tracked in `TODO.md`.
-- **`freecode run` runs without hooks.** `HookSettingsManager` is constructed only
- in `startServer()` (`server.ts:1106`), so a headless run never loads
- `settings.json` hooks and never registers the built-in `rtk` hook. Permission
- *rules* do apply (the loop builds its own `PermissionSettingsManager`), so the
- same command behaves differently under `serve` and under `run` — the formatter
- you rely on after every edit silently does not run in CI.
-- **Headless `build` mode is a trap.** Every unanswerable "ask" resolves to deny
- (`permission/prompt.ts:44`), and `build` defaults mutating tools to ask, so the
- common first command produces a turn full of denials with no hint that a rule
- would fix it. A `--yes`/`--allow ` flag, or a one-line explanation on the
- first denial, would remove the whole class of confusion.
-- **`--agent` is not validated.** `argv.agent as AgentMode` is an unchecked cast
- (`run.ts:140`); `--agent buld` falls through `modeDefault`'s `default` branch and
- silently runs with **build** semantics. yargs `choices` would catch it at parse
- time, as `mcp add`'s `type` already does.
- **`session` CLI covers 2 of 12 operations.** `fork`, `switch`, `archive`,
`export`, `import`, `upload`, `download` all exist over IPC and none has a CLI
surface, so scripting session management means speaking JSON-RPC by hand.
@@ -299,6 +345,7 @@ Found while writing this page; each is also tracked in `TODO.md`.
- **MCP config is user-scope only.** `getConfigDir()` is hard-wired to
`~/.freecode`, so a repository cannot ship the MCP servers its contributors need
the way it can ship permission rules and hooks.
-- **`uninstall` deletes user data with one `y`.** No `--keep-data`, no backup, and
- the prompt does not spell out that sessions and memory are inside the directory
- being removed.
+- **`uninstall` ignores the variables the installer honours.** It hard-codes
+ `~/.freecode` and four Unix bin paths, while `install.sh` supports
+ `FREECODE_HOME` and `FREECODE_INSTALL_DIR`, and the Windows launcher path is
+ not in the list at all.
diff --git a/apps/docs/app/reference/env/page.mdx b/apps/docs/app/reference/env/page.mdx
index 276c47d7..a585eef6 100644
--- a/apps/docs/app/reference/env/page.mdx
+++ b/apps/docs/app/reference/env/page.mdx
@@ -42,6 +42,15 @@ things you might genuinely need — an API key and a model — are better kept i
the app, exporting a different one changes nothing. A provider suffixed with
`-coding-plan` falls back to its base provider's key and variable.
+| Variable | Values | Effect |
+| --- | --- | --- |
+| `FREECODE_ANTHROPIC_AUTH` | `oauth`, `api-key` | pins how `anthropic` authenticates, overriding `providers.anthropic.authMode` |
+
+`oauth` bills a Claude Pro/Max subscription instead of a key, and setting it is
+one of the three ways to opt in. It is not a neutral switch — see
+[Anthropic subscription login](/getting-started/anthropic-subscription) for what
+the OAuth path does and the risk it carries.
+
## Provider requests
| Variable | Default | Read | Effect |
diff --git a/apps/docs/app/reference/hook-events/page.mdx b/apps/docs/app/reference/hook-events/page.mdx
index 819b4991..ec9d2439 100644
--- a/apps/docs/app/reference/hook-events/page.mdx
+++ b/apps/docs/app/reference/hook-events/page.mdx
@@ -209,5 +209,3 @@ Found while writing this page; each is also tracked in `TODO.md`.
the registered hook and nothing reads it.
- **`hook.triggered` / `hook.blocked` are published with a cast** and are not part
of the typed bus union, so no consumer gets checking on them.
-- **Hooks only load under `freecode serve`.** `freecode run` never constructs
- `HookSettingsManager`, so headless runs silently skip every hook in the file.
diff --git a/apps/docs/app/reference/ipc-methods/page.mdx b/apps/docs/app/reference/ipc-methods/page.mdx
index d9ca4e39..5deac7d9 100644
--- a/apps/docs/app/reference/ipc-methods/page.mdx
+++ b/apps/docs/app/reference/ipc-methods/page.mdx
@@ -110,14 +110,21 @@ itself never crosses the pipe.
| ✓ | Method | Params | Result |
| --- | --- | --- | --- |
-| | `config.get` | — | the parsed `~/.freecode/config.json` |
+| | `config.get` | — | `~/.freecode/config.json`, **redacted** |
| | `config.setApiKey` | `{ provider, apiKey, model? }` | `void` |
| | `config.getCurrentModel` / `config.setCurrentModel` | — / `{ provider, model }` | current pair / `void` |
| | `config.getLastAgentMode` / `config.setLastAgentMode` | — / `{ mode }` | mode / `void` |
-`config.get` returns the file as-is, **API keys included**. It is fine over a
-stdio pipe to a local frontend; it is worth knowing about before exposing the
-backend any other way.
+`config.get` returns the file with every secret replaced by whether it is set:
+each provider entry becomes `{ hasApiKey, model?, authMode? }` and each `web`
+entry `{ hasCredential }`. `current`, `lastAgentMode` and `recovery` pass
+through unchanged. The redaction is an allowlist built field by field, not a
+blocklist of known secret names, so a credential field added later is excluded
+by default rather than leaked until someone remembers it.
+
+That matters because the same method is reachable over the web transport's
+`POST /api`, whose `host` is a parameter — a backend bound to `0.0.0.0` would
+otherwise hand out API keys.
## Memory
@@ -230,6 +237,3 @@ Also tracked in `TODO.md`; the first three are shared with
- **`graph.explore` breaks the naming convention** and hard-codes `process.cwd()`
while every neighbouring memory method takes `projectPath`. It should be
`memory.graph.explore` with the same parameter.
-- **`config.get` returns API keys.** Fine for a local stdio frontend, wrong the
- moment the backend is reachable any other way; the web transport gates `/api` but
- the method itself does no redaction.
diff --git a/apps/docs/app/reference/page.mdx b/apps/docs/app/reference/page.mdx
index be88f1cc..3b6465a1 100644
--- a/apps/docs/app/reference/page.mdx
+++ b/apps/docs/app/reference/page.mdx
@@ -56,8 +56,9 @@ up. Everything FreeCode persists is under one root:
| `addons/graph-ui/` | the optional memory graph explorer (`freecode memory ui-install`) |
| `builds/` | installed binaries; `builds/stable/freecode` is the symlink the updater rewrites |
-`freecode uninstall` deletes this entire directory. That includes your sessions
-and your memory — see [CLI commands](/reference/cli#freecode-uninstall).
+`freecode uninstall` keeps this directory and takes only `builds/`; the entire
+directory — your sessions and your memory included — goes only with
+`--purge`. See [CLI commands](/reference/cli#freecode-uninstall).
## The pages
diff --git a/apps/docs/app/reference/settings/page.mdx b/apps/docs/app/reference/settings/page.mdx
index 25d3df8d..e0fbae11 100644
--- a/apps/docs/app/reference/settings/page.mdx
+++ b/apps/docs/app/reference/settings/page.mdx
@@ -14,19 +14,36 @@ agreed on travel with the repository. Credentials, the current model, and MCP
servers are *not* here — they live in `~/.freecode/config.json`; see
[the overview](/reference#the-three-surfaces).
-**Exactly three top-level keys are read.** Anything else in the file is ignored
-without comment.
+**Exactly four top-level keys are read.** Anything else produces a warning on
+startup naming the key — and, when it is a near miss, the key you probably
+meant.
| Key | Read by | Purpose |
| --- | --- | --- |
| [`permissions`](#permissions) | `permission/settings.ts` | which tool calls are allowed, asked about, or denied |
| [`hooks`](#hooks) | `hooks/settings.ts` | shell commands to run at lifecycle events |
| [`memory`](#memory) | `memory/extract-policy.ts` | automatic memory extraction |
+| [`redirect`](#redirect) | `agent/redirect/settings.ts` | trajectory redirection (off by default) |
+
+## Editor completion
+
+A JSON Schema ships at [`schemas/settings.schema.json`](https://github.com/ayan-de/freecode/blob/main/schemas/settings.schema.json).
+Point `$schema` at it and your editor completes and validates the file as you
+type:
+
+```json
+{
+ "$schema": "https://raw.githubusercontent.com/ayan-de/freecode/main/schemas/settings.schema.json"
+}
+```
+
+`$schema` is the one key FreeCode ignores on purpose.
## A complete file
```json
{
+ "$schema": "https://raw.githubusercontent.com/ayan-de/freecode/main/schemas/settings.schema.json",
"permissions": {
"allow": ["Read", "Grep", "Bash(pnpm test:*)"],
"ask": ["Bash(git push:*)"],
@@ -182,9 +199,10 @@ generation first, so reloading is idempotent.
The exit-code protocol (`0` continue, `2` block, anything else block) and the JSON
stdout form are documented under [hook events](/reference/hook-events#the-shell-protocol).
-> Hooks are only loaded by `freecode serve` — the backend the TUI and the other
-> frontends spawn. `freecode run` does not load them. See
-> [known gaps](#known-gaps).
+> Both `freecode serve` (the backend the TUI and the other frontends spawn) and
+> `freecode run` load these, through the shared bootstrap in
+> `apps/core/src/hooks/bootstrap.ts`. Only `serve` watches the file for changes —
+> a one-shot run exits before an edit could apply.
## `memory`
@@ -192,6 +210,10 @@ stdout form are documented under [hook events](/reference/hook-events#the-shell-
| --- | --- | --- | --- |
| `autoExtract` | boolean | `true` | mine finished turns for facts worth remembering |
| `extractEveryNRuns` | number | `8` | how often extraction is even considered; values `< 1` are ignored, non-integers are floored |
+| `retrievalJudge` | boolean | `true` | judge retrieved memories for relevance before injecting them; fails closed |
+| `autoConsolidate` | boolean | `true` | one cheap merge pass per project per day — merges only, never deletes |
+| `consolidateMinHours` | number | `24` | minimum hours between consolidation runs |
+| `consolidateMinSessions` | number | `5` | minimum sessions since the last run before consolidating again; values `< 1` are ignored |
```json
{ "memory": { "autoExtract": false } }
@@ -200,8 +222,9 @@ stdout form are documented under [hook events](/reference/hook-events#the-shell-
Scope merge here is **first definition wins, project → user → default**, per
field. `FREECODE_DISABLE_MEMORY_EXTRACTION=1` overrides both files. Throttling is
safe because each extraction rebuilds the transcript from the session's whole
-history, so a skipped run is covered by the next one. Background:
-[memory](/internals/memory).
+history, so a skipped run is covered by the next one.
+`FREECODE_DISABLE_MEMORY_JUDGE=1` and `FREECODE_DISABLE_MEMORY_CONSOLIDATION=1`
+do the same for the other two. Background: [memory](/internals/memory).
## `redirect`
@@ -245,17 +268,14 @@ Found while writing this page; each is also tracked in `TODO.md`.
`/getting-started/configuration` currently claims a single "project wins" rule
that is only true for hooks. A shared loader (parse once, hand each subsystem its
section) would make one answer true.
-- **Unknown keys are silently ignored.** `"permission"` instead of `"permissions"`,
- or a hook field typo, produces no warning anywhere — the settings simply do not
- apply, which is indistinguishable from the feature being broken. Each loader
- already warns on *malformed* input; warning on unrecognised top-level keys is
- the same cost.
-- **No published schema.** There is no `$schema` and no generated JSON Schema, so
- editors cannot complete or validate the file — for a hand-edited security-relevant
- file that is the highest-value missing piece.
+- ~~**Unknown keys are silently ignored.**~~ Fixed: `settings/known-keys.ts`
+ reports any key nothing reads, with a "did you mean" for near misses. It is a
+ *name* check only — values stay each loader's business, since they already
+ validate and default their own. Hook event names are still left to
+ `hooks/settings.ts`, which already names the valid list.
+- ~~**No published schema.**~~ Fixed: `schemas/settings.schema.json`, referenced
+ via `$schema` (see above). A test asserts the schema's keys and the runtime
+ key list agree, so the two cannot drift.
- **`once` is parsed but never enforced** (`hooks/settings.ts` → `RegisteredHook`).
Either implement per-session tracking or reject the field so it cannot look
configured when it is not.
-- **Hooks are not loaded in headless runs.** `HookSettingsManager` is constructed
- only in `startServer()` (`server.ts:1106`), so `freecode run` silently skips every
- hook in the file.
diff --git a/apps/tui/src/ipc/client.ts b/apps/tui/src/ipc/client.ts
index 77752ad8..0b67461a 100644
--- a/apps/tui/src/ipc/client.ts
+++ b/apps/tui/src/ipc/client.ts
@@ -689,8 +689,9 @@ export async function graphExplore(): Promise<
| { error: "not-installed" };
}
+/** Redacted: `config.get` reports whether a key is set, never the key. */
export interface ConfigInfo {
- providers?: Record;
+ providers?: Record;
current?: { provider: string; model: string };
}
diff --git a/docs/superpowers/specs/2026-08-29-eval-case-registry.md b/docs/superpowers/specs/2026-08-29-eval-case-registry.md
index b3557a21..53cb40b0 100644
--- a/docs/superpowers/specs/2026-08-29-eval-case-registry.md
+++ b/docs/superpowers/specs/2026-08-29-eval-case-registry.md
@@ -520,7 +520,7 @@ most expensive kind of wrong answer this harness can give.
| Category | What it needs first |
| --- | --- |
-| `compaction-boundary` | A turn long enough to compact. Reachable only by accident today, and an accident is not a p ≥ 0.99 case. |
+| `compaction-boundary` | A turn long enough to compact. Reachable only by accident today, and an accident is not a p ≥ 0.99 case — but see the note below: it may be cheaper than "larger" suggests. |
| `memory-recall` | A seeded memory dir. `files` paths are sandbox-relative and `assertSafeRelativePath` refuses to escape, which is correct — so a fixture cannot reach `~/.freecode`. |
| `resume` | A prior session to resume from. One `runEffect` per trial means there is no earlier turn. |
| `mcp-failure` | A fixture MCP server. `initRunner` calls `initMcpServers()` against the user's real config, so the suite is not hermetic here and could not be made to fail on purpose. |
@@ -531,6 +531,27 @@ The cheapest unlock is `resume` (a second `runEffect` on the same session id);
larger. None of it is Phase 4 work — it is harness work, and it should be
specified before it is built.
+**A cheaper route to `compaction-boundary` (noted 2026-09-05, not built.)** The
+objection above is that compacting is an *accident*, and an accident cannot
+carry a p ≥ 0.99 case. But the threshold is already a knob:
+`getCompactTarget()` reads `FREECODE_COMPACT_TARGET_TOKENS`, and
+`shouldCompact` takes `min(windowLimit, target) - buffer`. Set the target to a
+few thousand for one case and compaction stops being an accident and becomes
+the *point* of the case — deterministic, and reached by an ordinary short
+prompt instead of a manufactured 100k one.
+
+What it needs: a per-case `env` key on `EvalCase`, honoured by `runner.ts` and
+scoped to the trial. That is a real harness change and wants its own spec pass —
+in particular, whether a case may set arbitrary env (it should not; an allowlist
+of compaction knobs is the safe shape) and how the gate treats a case whose
+environment differs from every other case's.
+
+Why it matters now: the 2026-09-05 compaction changes — the head carve-out in
+`selectForCompaction` and the tool transcript from `addToolTurn` — are exactly
+what this category would measure, and today nothing does. They ship on unit
+tests and reasoning, which is weaker evidence than this suite exists to
+provide.
+
**Every phase in this spec is now built.** What is left is not in this spec: the harness capability §9.1 names, and plan §3's scripted provider. Phases 1–3 improved how the suite reports and Phase 4 added the coverage that could be added without new harness capability.
Phase 1 changes what the trajectory suite measures, so run it **before** any
diff --git a/docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md b/docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md
index 87ba0308..36d034c0 100644
--- a/docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md
+++ b/docs/superpowers/specs/2026-09-05-anthropic-oauth-provider.md
@@ -68,6 +68,28 @@ Consequences for the design:
what this does, that it impersonates Claude Code, that Anthropic may block or
action the account. No repeated nagging afterward.
+### 0.2 Resolution — the published stance (2026-09-05)
+
+This section was the open risk item blocking v1 (`beforeStable.md` P0 #5). It is
+resolved as **ship it, state the risk plainly, do not euphemize**:
+
+> FreeCode ships subscription auth as a real, supported, opt-in feature. It says
+> in plain words that it presents itself to Anthropic as Claude Code, that
+> Anthropic reserves that inference for its own surfaces and has acted against
+> tools doing this, and that the account at risk is the user's. Whether the
+> trade is worth it is the user's call, on the user's account. API key stays the
+> default and is one `freecode auth logout` away.
+
+The three constraints above are what make that stance honest rather than a
+disclaimer, and they stay invariants.
+
+What changed is that the stance is now **published**, not just implemented: a
+`/getting-started/anthropic-subscription` docs page, a `freecode auth` section in
+the CLI reference, `FREECODE_ANTHROPIC_AUTH` in the env reference, and a warning
+callout in `README.md` beside the command itself. Before this, the only place a
+user could read any of it was the paragraph `auth login` prints — after they had
+already decided to run it.
+
## 1. Motivation
1. The user pays for Claude Max. Freecode dev loops (evals excepted — see §8) burn
diff --git a/evals/trajectory.jsonl b/evals/trajectory.jsonl
index 86d26d27..e4d9125f 100644
--- a/evals/trajectory.jsonl
+++ b/evals/trajectory.jsonl
@@ -56,7 +56,7 @@
{"id": "read-respects-path-arg", "prompt": "Show me the first 30 lines of apps/core/src/eval/match.ts.", "failureCategory": "tool-routing", "whyModelBacked": "The tool a prompt should open with is chosen by the model from the prompt and the system prompt, not by deterministic code — there is no function to unit test.", "expectTool": "read", "expectFirstToolIn": ["read"], "expectInArgs": {"filePath": "eval/match.ts"}, "expectMaxTurns": 4, "forbidTools": ["write", "edit"]}
{"id": "todowrite-for-multistep", "prompt": "Planning-only task: immediately create a three-step todo list for adding Groq, Mistral, and Cohere providers, documenting them, and updating tests. Do not inspect or research the repository, ask questions, or begin implementation. Stop after creating the list.", "failureCategory": "tool-routing", "whyModelBacked": "Whether a multi-step request triggers a plan before exploration is a prompt-shaped decision; `todowrite` itself is trivially unit tested and proves nothing about when it fires.", "expectTool": "todowrite", "forbidTools": ["read", "ls", "glob", "grep", "bash", "webfetch", "websearch", "question", "write", "edit"]}
{"id": "grep-scoped-to-path", "prompt": "Search only inside apps/core/src/permission for the string PATH_TOOLS.", "failureCategory": "tool-routing", "whyModelBacked": "The tool a prompt should open with is chosen by the model from the prompt and the system prompt, not by deterministic code — there is no function to unit test.", "expectTool": "grep", "expectFirstToolIn": ["grep"], "expectInArgs": {"pattern": "PATH_TOOLS"}, "expectMaxTurns": 4, "forbidTools": ["write", "edit"]}
-{"id": "read-before-explaining-edit", "prompt": "I want to change HANG_THRESHOLD_MS in apps/core/src/rollout/trace.ts to 400000. Read the file first and tell me what else that would affect.", "failureCategory": "tool-routing", "whyModelBacked": "Reading before opining is a habit the system prompt asks for and the model may skip; nothing deterministic sequences it.", "agentMode": "explore", "expectTool": "read", "expectMaxTurns": 6, "forbidTools": ["write", "edit"]}
+{"id": "read-before-explaining-edit", "prompt": "I want to change HANG_THRESHOLD_MS in apps/core/src/rollout/trace.ts to 400000. Read the file first and tell me what else that would affect.", "failureCategory": "tool-routing", "whyModelBacked": "Reading before opining is a habit the system prompt asks for and the model may skip; nothing deterministic sequences it.", "agentMode": "explore", "expectTool": "read", "expectFirstToolIn": ["read"], "expectMaxTurns": 12, "forbidTools": ["write", "edit"]}
{"id": "glob-then-stop", "prompt": "How many .ts files are directly inside apps/core/src/eval? Use glob and tell me the count.", "failureCategory": "tool-routing", "whyModelBacked": "The tool a prompt should open with is chosen by the model from the prompt and the system prompt, not by deterministic code — there is no function to unit test.", "expectTool": "glob", "expectFirstToolIn": ["glob"], "expectMaxTurns": 4, "forbidTools": ["write", "edit"]}
{"id": "grep-for-a-function-name", "prompt": "Which file defines the function proposeQuarantine?", "failureCategory": "tool-routing", "whyModelBacked": "The tool a prompt should open with is chosen by the model from the prompt and the system prompt, not by deterministic code — there is no function to unit test.", "expectTool": "grep", "expectFirstToolIn": ["grep", "glob"], "expectInArgs": {"pattern": "proposeQuarantine"}, "expectMaxTurns": 4, "forbidTools": ["write", "edit"]}
{"id": "read-to-summarise", "prompt": "Summarise in two sentences what apps/core/src/eval/gate.ts does.", "failureCategory": "tool-routing", "whyModelBacked": "The tool a prompt should open with is chosen by the model from the prompt and the system prompt, not by deterministic code — there is no function to unit test.", "expectTool": "read", "expectFirstToolIn": ["read"], "expectInArgs": {"filePath": "gate.ts"}, "expectMaxTurns": 4, "forbidTools": ["write", "edit"]}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 41013a1e..c67cce69 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -31,6 +31,16 @@ export type {
ContextSegmentId,
ContextSegmentStat,
ContextBreakdown,
+ ModelLimit,
+ ModelCost,
+ ModelInfo,
+ MemoryType,
+ MemoryEntry,
+ MemoryGraphStats,
+ AnthropicAuthMode,
+ RedactedConfig,
+ TurnResult,
+ ExportedSession,
} from "./types.js";
// IPC Protocol
@@ -44,6 +54,7 @@ export type {
MethodName,
MethodParams,
MethodResult,
+ ParamType,
} from "./ipc/protocol.js";
-export { METHODS } from "./ipc/protocol.js";
+export { METHODS, REQUIRED_PARAMS } from "./ipc/protocol.js";
diff --git a/packages/shared/src/ipc/protocol.ts b/packages/shared/src/ipc/protocol.ts
index 46e4766b..9a05b117 100644
--- a/packages/shared/src/ipc/protocol.ts
+++ b/packages/shared/src/ipc/protocol.ts
@@ -218,15 +218,20 @@ export const METHODS = {
params: {} as {
sessionId: string;
message: string;
+ model?: string;
+ effort?: import("../types.js").EffortLevel;
+ agentMode?: string;
images?: Array<{ data: string; mediaType: string; altText?: string }>;
},
- // The LoopResult shape when the turn ran normally. When the session was
- // already busy, the call parks the prompt in the follow-up queue and
- // resolves immediately with { queued: true, id } — the UI uses the
- // `message_queued` stream event for the same data so web/SSE subscribes
- // stay in sync.
+ // The completed turn. This said `StreamResponse` for a long time and was
+ // simply wrong — the handler returns the loop's result, and the per-token
+ // output arrives on the stream channel, never as the RPC result. When the
+ // session was already busy the call parks the prompt in the follow-up
+ // queue and resolves immediately with { queued: true, id }; the UI uses
+ // the `message_queued` stream event for the same data so web/SSE
+ // subscribers stay in sync.
result: {} as
- | StreamResponse
+ | import("../types.js").TurnResult
| { queued: true; id: string },
},
"session.dequeue": {
@@ -367,8 +372,247 @@ export const METHODS = {
params: undefined,
result: {} as { url: string } | { error: "not-installed" },
},
+
+ // ===========================================================================
+ // Models
+ // ===========================================================================
+ "models.list": {
+ params: { providerId: "" },
+ result: [] as import("../types.js").ModelInfo[],
+ },
+ // Context window in tokens for one provider/model pair, or 0 when the
+ // catalogue has no entry — never a guessed default.
+ "models.contextLimit": {
+ params: { provider: "", model: "" },
+ result: 0 as number,
+ },
+
+ // ===========================================================================
+ // Config
+ //
+ // `config.get` returns the REDACTED view. There is deliberately no method
+ // that returns the raw config: the JSON-RPC surface is reachable over the
+ // web server's POST /api, and `host` is a parameter.
+ // ===========================================================================
+ "config.get": {
+ params: undefined,
+ result: {} as import("../types.js").RedactedConfig,
+ },
+ "config.setApiKey": {
+ params: {} as { provider: string; apiKey: string; model?: string },
+ result: undefined as void,
+ },
+ "config.setCurrentModel": {
+ params: { provider: "", model: "" },
+ result: undefined as void,
+ },
+ "config.getCurrentModel": {
+ params: undefined,
+ result: {} as { provider: string; model: string } | undefined,
+ },
+ "config.getLastAgentMode": {
+ params: undefined,
+ result: undefined as string | undefined,
+ },
+ "config.setLastAgentMode": {
+ params: { mode: "" },
+ result: undefined as void,
+ },
+
+ // ===========================================================================
+ // Memory
+ // ===========================================================================
+ "memory.list": {
+ params: {} as {
+ projectPath?: string;
+ type?: import("../types.js").MemoryType;
+ },
+ result: [] as import("../types.js").MemoryEntry[],
+ },
+ "memory.get": {
+ params: {} as {
+ name: string;
+ type: import("../types.js").MemoryType;
+ projectPath?: string;
+ },
+ result: null as import("../types.js").MemoryEntry | null,
+ },
+ "memory.save": {
+ params: {} as {
+ entry: import("../types.js").MemoryEntry;
+ projectPath?: string;
+ },
+ result: undefined as void,
+ },
+ "memory.delete": {
+ params: {} as {
+ name: string;
+ type: import("../types.js").MemoryType;
+ projectPath?: string;
+ },
+ result: false as boolean,
+ },
+ "memory.query": {
+ params: {} as {
+ query: string;
+ projectPath?: string;
+ limit?: number;
+ types?: import("../types.js").MemoryType[];
+ },
+ result: [] as import("../types.js").MemoryEntry[],
+ },
+ "memory.graph.rebuild": {
+ params: {} as { projectPath?: string },
+ result: {} as import("../types.js").MemoryGraphStats,
+ },
+ "memory.graph.stats": {
+ params: {} as { projectPath?: string },
+ result: {} as import("../types.js").MemoryGraphStats,
+ },
+ // The rendered block, for previewing what a turn would inject.
+ "memory.buildPrompt": {
+ params: {} as {
+ projectPath?: string;
+ types?: import("../types.js").MemoryType[];
+ limit?: number;
+ all?: boolean;
+ },
+ result: "" as string,
+ },
+
+ // ===========================================================================
+ // Session lifecycle
+ // ===========================================================================
+ "session.switch": {
+ params: { sessionId: "" },
+ result: undefined as void,
+ },
+ /** Returns the new session's id. */
+ "session.fork": {
+ params: { sessionId: "" },
+ result: "" as string,
+ },
+ "session.archive": {
+ params: { sessionId: "" },
+ result: undefined as void,
+ },
+ // `purge` also removes the session's on-disk artifacts; without it the
+ // record is marked deleted and the files stay.
+ "session.delete": {
+ params: {} as { sessionId: string; purge?: boolean },
+ result: undefined as void,
+ },
+ // The session whose last turn was killed mid-stream, if any — what the TUI
+ // offers to resume at startup.
+ "session.getInterrupted": {
+ params: undefined,
+ result: null as { sessionId: string; messageId: string } | null,
+ },
+
+ // ===========================================================================
+ // Remote sync
+ // ===========================================================================
+ "session.export": {
+ params: { sessionId: "" },
+ result: {} as import("../types.js").ExportedSession,
+ },
+ "session.import": {
+ params: { url: "" },
+ result: { sessionId: "" },
+ },
+ /** Returns the share URL the session was uploaded to. */
+ "session.upload": {
+ params: {} as { sessionId: string; endpoint: string; apiKey?: string },
+ result: "" as string,
+ },
+ /** Returns the id of the session the download created locally. */
+ "session.download": {
+ params: {} as { url: string; endpoint?: string; apiKey?: string },
+ result: "" as string,
+ },
} as const;
export type MethodName = keyof typeof METHODS;
+
+// =============================================================================
+// Runtime parameter contracts
+//
+// Handlers reach their params through `params as { … }` — a cast, which
+// checks nothing. A missing or mistyped field therefore became `undefined`
+// deep inside the handler and surfaced as an internal error (-32603), which
+// says "the server broke" when the truth is "you sent the wrong params".
+//
+// This table is what the server validates against before dispatch, so a bad
+// call gets -32602 and the field name. It is typed `Record`,
+// so a new method without an entry is a compile error — there is no path to
+// adding a method that silently skips validation. Methods with nothing
+// mandatory declare `{}`; optional params are deliberately absent, since
+// omitting them is legal and the handler already defaults them.
+// =============================================================================
+
+export type ParamType = "string" | "number" | "boolean" | "object" | "array";
+
+export const REQUIRED_PARAMS: Record<
+ MethodName,
+ Readonly>
+> = {
+ "tools.list": {},
+ "tools.call": { name: "string", args: "object" },
+ // projectPath is validated by the handler, which falls back to cwd when it
+ // is missing or does not exist — a fallback, not a contract violation.
+ "session.start": {},
+ "session.send": { sessionId: "string", message: "string" },
+ "session.dequeue": { sessionId: "string", id: "string" },
+ "session.stop": { sessionId: "string" },
+ "session.compact": { sessionId: "string" },
+ "session.list": {},
+ "session.resume": { sessionId: "string" },
+ "session.claudeList": {},
+ "session.claudeTranscript": { sessionId: "string" },
+ "providers.list": {},
+ "config.setWebCredential": { provider: "string", credential: "object" },
+ // projectPath is optional in both handlers (they fall back to the process
+ // cwd), so it is not required here. The rule for this table is what the
+ // handler actually needs — validation must never reject a call the handler
+ // would have served.
+ "commands.list": {},
+ "commands.resolve": { name: "string" },
+ "question.answer": { requestId: "string", answers: "array" },
+ "question.reject": { requestId: "string" },
+ "permission.answer": { requestId: "string", decision: "string" },
+ "permission.reject": { requestId: "string" },
+ "context.stats": { sessionId: "string" },
+ "usage.get": {},
+ "skills.list": {},
+ "mcp.status": {},
+ "history.list": {},
+ "history.append": { text: "string" },
+ "graph.explore": {},
+ "models.list": { providerId: "string" },
+ "models.contextLimit": { provider: "string", model: "string" },
+ "config.get": {},
+ "config.setApiKey": { provider: "string", apiKey: "string" },
+ "config.setCurrentModel": { provider: "string", model: "string" },
+ "config.getCurrentModel": {},
+ "config.getLastAgentMode": {},
+ "config.setLastAgentMode": { mode: "string" },
+ "memory.list": {},
+ "memory.get": { name: "string", type: "string" },
+ "memory.save": { entry: "object" },
+ "memory.delete": { name: "string", type: "string" },
+ "memory.query": { query: "string" },
+ "memory.graph.rebuild": {},
+ "memory.graph.stats": {},
+ "memory.buildPrompt": {},
+ "session.switch": { sessionId: "string" },
+ "session.fork": { sessionId: "string" },
+ "session.archive": { sessionId: "string" },
+ "session.delete": { sessionId: "string" },
+ "session.getInterrupted": {},
+ "session.export": { sessionId: "string" },
+ "session.import": { url: "string" },
+ "session.upload": { sessionId: "string", endpoint: "string" },
+ "session.download": { url: "string" },
+};
export type MethodParams = (typeof METHODS)[M]["params"];
export type MethodResult = (typeof METHODS)[M]["result"];
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 49fb3d33..d6223b7b 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -213,7 +213,15 @@ export interface SerializedMessage {
* `SessionResumeResult` below.
*/
export interface SessionContext extends SessionMeta {
- messages: SerializedMessage[];
+ messages: Array<{
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ timestamp: number;
+ }>;
+ memories: MemoryEntry[];
+ exportedAt: number;
+ expiresAt?: number;
}
/**
@@ -328,3 +336,147 @@ export interface ContextBreakdown {
mcpToolCount: number;
messageCount: number;
}
+
+// =============================================================================
+// Wire shapes for the remaining IPC methods
+//
+// These mirror types that live in `apps/core` — shared cannot import from an
+// app, and an app type carries internals (Effect handles, storage paths) that
+// have no business on the wire. Same pattern as `SessionMeta` and
+// `SerializedMessage` above. `ipc/wire-shapes.test.ts` in core asserts each
+// core type is assignable to its mirror, so drift is a typecheck failure
+// rather than a runtime surprise in a frontend.
+// =============================================================================
+
+/** Mirrors core's `ModelLimit` (`models-dev.ts`). */
+export interface ModelLimit {
+ /** Context-window size (max input tokens). */
+ context: number;
+ /** Max output tokens per response. */
+ output: number;
+}
+
+/** Mirrors core's `ModelCost` (`models-dev.ts`) — USD per million tokens. */
+export interface ModelCost {
+ input: number;
+ output: number;
+ cacheRead?: number;
+ cacheWrite?: number;
+}
+
+/** Mirrors core's `ProviderModel` (`models-dev.ts`) — one `models.list` row. */
+export interface ModelInfo {
+ id: string;
+ name: string;
+ description?: string;
+ /** Present when models.dev reports limits for this model. */
+ limit?: ModelLimit;
+ /** Present when models.dev publishes a rate card for this model. */
+ cost?: ModelCost;
+ /** Input modalities, e.g. ["text", "image", "pdf"]. Absent if unreported. */
+ inputModalities?: string[];
+}
+
+/** Mirrors core's `MemoryType` (`memory/mem-types.ts`). */
+export type MemoryType =
+ | "user"
+ | "feedback"
+ | "project"
+ | "reference"
+ | "episode";
+
+/** Mirrors core's `MemoryEntry` (`memory/mem-types.ts`). */
+export interface MemoryEntry {
+ name: string;
+ description: string;
+ type: MemoryType;
+ content: string;
+ createdAt: number;
+ updatedAt: number;
+ tags?: string[];
+ supersedes?: string[];
+ /** ISO date (YYYY-MM-DD) an episode describes; absent means undated. */
+ happened_at?: string;
+}
+
+/** Mirrors the return of core's `MemoryGraphService.stats()`. */
+export interface MemoryGraphStats {
+ vectors: number;
+ dims: number;
+ nodes: number;
+ edges: number;
+ clusters: number;
+ embedder: boolean;
+}
+
+export type AnthropicAuthMode = "oauth" | "api-key";
+
+/**
+ * Mirrors core's `RedactedConfig` (`providers/config.ts`) — the ONLY config
+ * shape that leaves the process. There is no wire type for the raw config
+ * because there must never be one: `config.get` returns this.
+ */
+export interface RedactedConfig {
+ providers?: Record<
+ string,
+ { hasApiKey: boolean; model?: string; authMode?: AnthropicAuthMode }
+ >;
+ web?: Record;
+ current?: { provider: string; model: string };
+ lastAgentMode?: string;
+ recovery?: { fallbackProviders?: string[] };
+}
+
+/**
+ * Mirrors the wire-facing half of core's `LoopResult` (`agent/types.ts`).
+ * `finalState` is deliberately omitted: it is the loop's internal state
+ * machine, and no frontend reads it.
+ */
+export interface TurnResult {
+ success: boolean;
+ message?: string;
+ content?: string;
+ thinking?: string;
+ turnCount: number;
+ iterationCount: number;
+ usage?: {
+ /** Already includes cache writes — they are billed as input. */
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadInputTokens?: number;
+ /** The same tokens as above, broken out for the hit rate. Not an addend. */
+ cacheCreationInputTokens?: number;
+ /** The last API call's full input — true context-window occupancy. */
+ contextTokens?: number;
+ };
+}
+
+/**
+ * Mirrors core's `ExportedSession` (`store/remote.ts`). Its `messages` are a
+ * flattened transcript, NOT `SerializedMessage` — the export format drops
+ * parts so it stays readable and stable across store versions.
+ */
+export interface ExportedSession {
+ version: 1;
+ metadata: {
+ id: string;
+ title: string;
+ projectPath: string;
+ provider: string;
+ status: "active" | "archived" | "deleted";
+ createdAt: number;
+ updatedAt: number;
+ lastTurnAt: number;
+ turnCount: number;
+ parentId?: string;
+ };
+ messages: Array<{
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ timestamp: number;
+ }>;
+ memories: MemoryEntry[];
+ exportedAt: number;
+ expiresAt?: number;
+}
diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json
new file mode 100644
index 00000000..9f7dd77b
--- /dev/null
+++ b/schemas/settings.schema.json
@@ -0,0 +1,146 @@
+{
+ "$schema": "https://json-schema.org/draft-07/schema#",
+ "$id": "https://raw.githubusercontent.com/ayan-de/freecode/main/schemas/settings.schema.json",
+ "title": "FreeCode settings.json",
+ "description": "Project (.freecode/settings.json) and user (~/.freecode/settings.json) settings. Both scopes use this shape; project wins per key.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+
+ "permissions": {
+ "description": "Per-rule allow/ask/deny. deny anywhere wins; scope does not break a tie within a tier.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "allow": { "$ref": "#/$defs/ruleList" },
+ "ask": { "$ref": "#/$defs/ruleList" },
+ "deny": { "$ref": "#/$defs/ruleList" }
+ }
+ },
+
+ "hooks": {
+ "description": "Shell commands run at lifecycle points, keyed by event name.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "PreToolUse": { "$ref": "#/$defs/hookList" },
+ "PostToolUse": { "$ref": "#/$defs/hookList" },
+ "PostToolUseFailure": { "$ref": "#/$defs/hookList" },
+ "PermissionRequest": { "$ref": "#/$defs/hookList" },
+ "PreCompact": { "$ref": "#/$defs/hookList" },
+ "PostCompact": { "$ref": "#/$defs/hookList" },
+ "SessionStart": { "$ref": "#/$defs/hookList" },
+ "UserPromptSubmit": { "$ref": "#/$defs/hookList" },
+ "SubagentStart": { "$ref": "#/$defs/hookList" },
+ "SubagentStop": { "$ref": "#/$defs/hookList" },
+ "Stop": { "$ref": "#/$defs/hookList" },
+ "TurnStart": { "$ref": "#/$defs/hookList" },
+ "TurnEnd": { "$ref": "#/$defs/hookList" },
+ "Notification": { "$ref": "#/$defs/hookList" }
+ }
+ },
+
+ "memory": {
+ "description": "Persistent cross-session memory: extraction, retrieval judging, consolidation.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "autoExtract": {
+ "type": "boolean",
+ "default": true,
+ "description": "Mine finished turns for facts worth remembering."
+ },
+ "extractEveryNRuns": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 8,
+ "description": "How often extraction is even considered. Values < 1 are ignored."
+ },
+ "retrievalJudge": {
+ "type": "boolean",
+ "default": true,
+ "description": "Judge retrieved memories for relevance before injecting them. Fails closed."
+ },
+ "autoConsolidate": {
+ "type": "boolean",
+ "default": true,
+ "description": "One cheap merge pass per project per day. Merges only — never deletes."
+ },
+ "consolidateMinHours": {
+ "type": "number",
+ "minimum": 0,
+ "description": "Minimum hours between consolidation runs."
+ },
+ "consolidateMinSessions": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Minimum sessions since the last run before consolidating again."
+ }
+ }
+ },
+
+ "redirect": {
+ "description": "Trajectory redirection. Off by default; see specs/2026-08-26-trajectory-redirection.md.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Allow redirection at all."
+ },
+ "maxPerRun": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 2,
+ "description": "Redirections per run. 0 disables as surely as enabled: false."
+ }
+ }
+ }
+ },
+
+ "$defs": {
+ "ruleList": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "description": "Rule, e.g. Bash(git *), Write(src/**), WebFetch(https://example.com/**)."
+ }
+ },
+ "hookList": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["name", "command"],
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Unique within the event; also the merge key across scopes."
+ },
+ "command": { "type": "string", "description": "Shell command to run." },
+ "matcher": {
+ "type": "string",
+ "description": "Tool-name pattern: *, exact, regex, or write|edit. Default: all tools."
+ },
+ "if": {
+ "type": "string",
+ "description": "Argument condition, Tool(pattern) — e.g. bash(git *), write(*.ts)."
+ },
+ "shell": { "enum": ["bash", "powershell"], "default": "bash" },
+ "timeout": {
+ "type": "number",
+ "default": 300,
+ "description": "SECONDS (multiplied by 1000 internally)."
+ },
+ "once": {
+ "type": "boolean",
+ "default": false,
+ "description": "Parsed and stored, but NOT enforced — treat as reserved."
+ }
+ }
+ }
+ }
+ }
+}