Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **Choose how hard the model thinks, per session, on every backend** (#417).
One scale (Default, Off, Minimal, Low, Medium, High, Extra high, Max), mapped to each backend's own setting: Claude's effort, Codex's and OpenAI's reasoning effort, Qwen's and pi's levels, Gemini's thinking budget or level, llama.cpp's chat-template switches, and the Gemini CLI's model settings.
Set it from the web UI's **effort** selector, `/effort` (web, TUI, Telegram), `codeoid new --effort`, or `session.defaultEffort` for new sessions.
It stays with the session, like its model; it applies live where the backend can take it, otherwise from the next turn, and never cuts a running turn short.
The pickers offer the levels each model takes where the backend reports them (Claude, Codex, pi); elsewhere every level, marked as unknown.
docs/thinking-effort.md says what each level means on each backend.

- **An MCP server added through Claude reaches every backend in that directory** (#395).
codeoid imported only Claude's global servers, once at start; a `claude mcp add` (local scope) or a project's `.mcp.json` stayed Claude-only.
Now each session sees, on every backend, its directory's servers too: your own always, a project's once approved — in Claude Code (read only from your `~/.claude.json`), or in codeoid after reviewing the entry in full (the drawer's **Review & approve**, or `codeoid mcp approve`, which prints it and approves only with `--yes <fingerprint>`), bound to exactly the entry shown.
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ bun run lint # biome check

codeoid start # daemon + all frontends
codeoid ls # list sessions
codeoid new <n> [d] # create session (workdir required unless --worktree)
codeoid new <n> [d] # create session (workdir required unless --worktree); --model, --effort <level>
codeoid attach <n> # interactive attach (with scrollback replay)
codeoid send <n> <m> # one-shot message
codeoid interrupt <n>
Expand Down Expand Up @@ -211,6 +211,7 @@ TELEGRAM_ALLOWED_USER_IDS=123,456 # Required with bot token
CODEOID_MCP_CONTROL_ENABLED=false # mount /mcp/control (docs/mcp-control.md)
CODEOID_MCP_CONTROL_ACTIONS=false # also allow create / send / interrupt / destroy there
CODEOID_ROUTINES_ENABLED=true # scheduler + routine commands (docs/routines.md)
CODEOID_DEFAULT_EFFORT= # thinking effort for new sessions: off|minimal|low|medium|high|xhigh|max (docs/thinking-effort.md)

# Claude auth: uses your existing `claude login` session (Pro/Max subscription).
# No ANTHROPIC_API_KEY needed if you're logged in via Claude Code CLI.
Expand Down
81 changes: 81 additions & 0 deletions docs/thinking-effort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Thinking effort

How hard a session's model thinks before it answers, on one scale for every backend.

## The scale

**Default, Off, Minimal, Low, Medium, High, Extra high (`xhigh`), Max.**

**Default** is the backend's own choice, as it always was.
Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (a project's `.codex/config.toml` only if you trust the project, as Codex itself reads it) if the running model takes it, else that model's own default; pi gets the default in your pi settings (`defaultThinkingLevel`, else Medium; a project's `.pi/settings.json` isn't read, since pi honours it only for a project you trust).
On Codex, a model its catalog doesn't list, with no effort configured, has no known default: Default then sends nothing, so a level set earlier on that thread stays until the next thread.
Every other level is mapped to the backend's own setting (below).
A level a backend has no exact match for goes to its nearest one; pi clamps a level a model doesn't take to the nearest it does.

## Setting it

- **Web UI:** the **effort** selector next to the model picker, or `/effort` in the prompt box.
- **TUI:** `/effort` opens a picker; `/effort high` (or `off`, …, `max`, `default`) sets it directly.
- **Telegram:** `/effort` shows it; `/effort <level>` sets it.
- **CLI:** `codeoid new <name> <dir> --effort high`.
- **Config:** `session.defaultEffort` (or `CODEOID_DEFAULT_EFFORT`) for sessions created without one.

It belongs to the session, like its model: it survives a daemon restart, and it stays the same across a backend switch, since the scale is the same everywhere.
A session resumed after a restart keeps its own; the config default applies only to new sessions.
Changing it needs `session:send` on your own session.

## Which levels a model takes

Where the backend says, the pickers offer only the model's own levels:

- Claude reports each model's levels (and whether it takes effort at all).
- Codex reports each model's supported efforts and its default.
- pi reports which models reason and which levels each takes.

The others (OpenAI and OpenAI-compatible endpoints, llama.cpp, Qwen, Gemini, the Gemini CLI) don't, so every level is offered and marked "support unknown".
The Default entry shows what Default means on that session once the backend has said (Codex: your configured effort, a trusted project's included, on the model it actually runs; pi: your default).
Before a session's first turn, and after a model change until the next one, it shows what the model list says instead; on Codex that reads your user config only, so a project's own `model_reasoning_effort` shows from the first turn.
A level the server refuses fails the turn with a note saying so; pick another, or Default.

## When a change applies

A change never cuts a running turn short.

- **Now**, on the running backend: Claude (except to or from Off, or back to Default), Qwen (except back to Default), pi.
A change that needs a rebuild waits while the backend has background work running, so that work isn't stopped.
Until a change is in effect the pickers mark it "pending" (Stop ends background work), and the session says when it lands.
- **From the next turn**: Codex, OpenAI, llama.cpp, Gemini — they take it with every request — and the exceptions above, whose next turn starts a fresh query that resumes the conversation.
- **After a restart, between turns**: the Gemini CLI, which reads it only from its settings at startup.
It restarts before the next turn that starts with no background work running, and resumes the conversation, as after a model change.

## What each level means on each backend

| codeoid | Claude | Codex | OpenAI | Qwen | pi | Gemini 2.5 | Gemini 3 Flash / Pro | Gemini CLI (2.5 / 3) | llama.cpp |
|---|---|---|---|---|---|---|---|---|---|
| Off | thinking disabled | `none` | `none` | `low` (can't turn off) | `off` | budget 0 (Pro: 128) | `minimal` / `low` | 512 / `LOW` | `enable_thinking: false` |
| Minimal | `low` | `minimal` | `minimal` | `low` | `minimal` | budget 512 | `minimal` / `low` | 512 / `LOW` | `low` |
| Low | `low` | `low` | `low` | `low` | `low` | budget 2048 | `low` / `low` | 2048 / `LOW` | `low` |
| Medium | `medium` | `medium` | `medium` | `medium` | `medium` | budget 8192 | `medium` / `high` | 8192 / `HIGH` | `medium` |
| High | `high` | `high` | `high` | `high` | `high` | budget 16384 | `high` / `high` | 16384 / `HIGH` | `high` |
| Extra high | `xhigh` | `xhigh` | `xhigh` | `xhigh` | `xhigh` | budget 24576 | `high` / `high` | 24576 / `HIGH` | `high` |
| Max | `max` | `max` | `xhigh` | `max` | `max` | budget 32768 (Flash: 24576) | `high` / `high` | 24576 / `HIGH` | `high` |

Notes:

- **Claude** keeps adaptive thinking, with readable summaries, at every level but Off.
- **OpenAI** sends `reasoning_effort`; a model that doesn't reason rejects it.
- **Gemini** sends `thinkingConfig` per request: a token budget for 2.5 models (Flash-Lite thinks from 512, or 0 for Off), a level for 3.x (Pro takes only Low and High); other models get nothing.
- **The Gemini CLI** gets values every model of the family accepts, since its settings can't know which model runs: 2.5 budgets from 512 (Flash-Lite's floor; Pro can't turn thinking off, so Off is the lowest budget) to 24576 (Flash's ceiling), and for 3.x only Low and High (all that Pro takes).
It runs with a settings overlay of its usual home (an account's, or yours) whose `chat-base-2.5` and `chat-base-3` aliases carry the budget or level; logins and chats are linked, so nothing else changes.
The overlays live under `~/.codeoid/accounts/gemini-cli-effort/`.
- **llama.cpp** sends both through the model's chat template (`chat_template_kwargs`): `enable_thinking` for Qwen3-style templates, `reasoning_effort` (low / medium / high) for gpt-oss-style ones.
A template that reads neither ignores them.
- **pi** saves a thinking-level change as your global default in its settings, so codeoid runs it from an overlay of its agent directory: your logins and sessions are linked, and `settings.json` is a fresh copy at each start, so one session's effort never changes your own pi or other sessions.
The overlays live under `~/.codeoid/accounts/pi-session/`.
pi's OAuth refresh locks the real `auth.json`, so codeoid's pi and one you run yourself refresh in turn; only a sign-in or sign-out made in both at the same moment isn't serialized.
- Anything the Gemini CLI or pi writes in its overlay while it runs (a first sign-in, say) is moved to the home the overlay is of (yours, or the account's) at a later start, as it would have been written there without codeoid; if the copy there is newer, that one wins.
A save still in progress (a temp file, or anything changed in the last 10 seconds, which another session may still be writing) waits for a later start.
- The Gemini CLI and pi overlays of an account's home are removed with the account.
Those of your own home stay, each with a copy of your Gemini CLI or pi settings; like everything under `~/.codeoid/accounts/`, agents can't read them.
- **At Default**, a backend reads its own config as it always did: Qwen applies a project's `.qwen/settings.json` reasoning effort, Claude your Claude Code effort setting (your own, or the project's `.claude/settings.json`). A level you set overrides them.
- **A project's own Gemini CLI settings** (`.gemini/settings.json` in the workdir) outrank your user settings, so a project that redefines `chat-base-2.5` or `chat-base-3` there overrides the effort on the Gemini CLI.
14 changes: 13 additions & 1 deletion packages/core/src/slash.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, mock } from "bun:test";
import { dispatchSlash, parseSlash, type SlashContext } from "./slash.js";
import { dispatchSlash, effortChangeSummary, parseSlash, type SlashContext } from "./slash.js";

function ctx(): SlashContext & { sent: unknown[] } {
const sent: unknown[] = [];
Expand Down Expand Up @@ -76,6 +76,18 @@ describe("parseSlash", () => {
});
});

it("parses /effort: a level, default, or the picker (#417)", () => {
expect(parseSlash("/effort")).toEqual({ kind: "effort-picker" });
expect(parseSlash("/effort High")).toEqual({ kind: "effort", effort: "high" });
expect(parseSlash("/effort extra-high")).toEqual({ kind: "effort", effort: "xhigh" });
expect(parseSlash("/effort off")).toEqual({ kind: "effort", effort: "off" });
expect(parseSlash("/effort default")).toEqual({ kind: "effort", effort: null });
expect(() => parseSlash("/effort turbo")).toThrow(/off\|minimal/);
expect(effortChangeSummary({ effort: "max", applies: "restart" })).toMatch(/restarts/);
expect(effortChangeSummary({ effort: null, applies: "next-turn" })).toBe("Thinking effort: the backend's default, from the next turn.");
expect(effortChangeSummary({ effort: "off", applies: "next-turn", waitsForBackground: true })).toMatch(/no background work running \(Stop ends it\)/);
});

it("bare /model opens the picker instead of erroring", () => {
expect(parseSlash("/model")).toEqual({ kind: "model-picker" });
});
Expand Down
70 changes: 64 additions & 6 deletions packages/core/src/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@
* thin context object so we can mock send/newRequestId in tests.
*/

import type {
ClientMessage,
SessionInfo,
SessionMode,
McpReconnectResult,
McpRefreshResult,
import {
EFFORT_LEVELS,
type ClientMessage,
type EffortChangeResult,
type EffortLevel,
type SessionInfo,
type SessionMode,
type McpReconnectResult,
type McpRefreshResult,
} from "@highflame/codeoid-protocol";

export type SlashCommand =
Expand All @@ -32,6 +35,8 @@ export type SlashCommand =
| { kind: "mode"; mode: SessionMode; maxTurns?: number }
| { kind: "model"; model: string; fallback?: string | null }
| { kind: "model-picker" }
| { kind: "effort"; effort: EffortLevel | null }
| { kind: "effort-picker" }
| { kind: "provider"; providerId: string }
| { kind: "account"; account: string | null }
| { kind: "help" }
Expand Down Expand Up @@ -117,6 +122,11 @@ export function parseSlash(raw: string, opts?: ParseSlashOptions): SlashCommand
...(fallback !== undefined ? { fallback } : {}),
};
}
case "effort": {
// Thinking effort (#417). Bare `/effort` opens the picker.
if (rest.length === 0) return { kind: "effort-picker" };
return { kind: "effort", effort: parseEffort(rest[0]!) };
}
case "provider": {
// Switch the focused session's BACKEND (e.g. `/provider pi`). The
// daemon validates the id fail-closed and rejects mid-turn switches.
Expand Down Expand Up @@ -199,6 +209,7 @@ export interface SlashContext {
/** Optional UI hooks — caller provides as needed. */
showHelp?: () => void;
showModelPicker?: () => void;
showEffortPicker?: () => void;
showIdentity?: () => void;
showCapabilities?: (tab: "agents" | "skills" | "mcp" | "hooks") => void;
showSettings?: () => void;
Expand Down Expand Up @@ -292,6 +303,24 @@ export function dispatchSlash(cmd: SlashCommand, ctx: SlashContext): void {
case "model-picker":
ctx.showModelPicker?.();
return;
case "effort-picker":
ctx.showEffortPicker?.();
return;
case "effort": {
const msg: ClientMessage = { type: "session.set_effort", id: ctx.newRequestId(), sessionId: ctx.sessionId, effort: cmd.effort };
if (!ctx.request) {
ctx.send(msg);
return;
}
ctx
.request(msg)
.then((resp) => {
const r = (resp as { data?: EffortChangeResult } | undefined)?.data;
if (r) ctx.report?.(effortChangeSummary(r));
})
.catch((e) => ctx.report?.(e instanceof Error ? e.message : String(e)));
return;
}
case "provider":
fire({
type: "session.set_provider",
Expand Down Expand Up @@ -402,3 +431,32 @@ export function mcpRefreshSummary(r: McpRefreshResult): string {
? "MCP servers changed: work is running, so they're applied before the next turn."
: "MCP servers changed: the backend was stopped and starts with them on your next message.";
}

/** Words for a thinking effort (#417) — the scale's, or the backend's default. */
export const EFFORT_LABELS: Record<EffortLevel, string> = {
off: "Off",
minimal: "Minimal",
low: "Low",
medium: "Medium",
high: "High",
xhigh: "Extra high",
max: "Max",
};

/** `/effort <level>`'s argument: a level, or default / auto / reset for the backend's own. */
export function parseEffort(arg: string): EffortLevel | null {
const a = arg.trim().toLowerCase().replace(/[\s_-]/g, "");
if (a === "default" || a === "auto" || a === "reset") return null;
if (a === "extrahigh") return "xhigh";
if ((EFFORT_LEVELS as readonly string[]).includes(a)) return a as EffortLevel;
throw new Error(`/effort ${EFFORT_LEVELS.join("|")}|default`);
}

/** What `/effort` did, in words every frontend shows the same (#417). */
export function effortChangeSummary(r: EffortChangeResult): string {
const what = r.effort ? EFFORT_LABELS[r.effort] : "the backend's default";
if (r.applies === "now") return `Thinking effort: ${what}.`;
if (r.applies === "restart") return `Thinking effort: ${what} — the backend restarts with it between turns, once no work is running.`;
if (r.waitsForBackground) return `Thinking effort: ${what}, from the next turn with no background work running (Stop ends it).`;
return `Thinking effort: ${what}, from the next turn.`;
}
1 change: 1 addition & 0 deletions packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
"session.rotate": { type: "session.rotate", id: "r13", sessionId: "s1" },
"session.search": { type: "session.search", id: "r14", query: "auth bug", scope: "all", limit: 5 },
"session.set_model": { type: "session.set_model", id: "r15", sessionId: "s1", model: "opus", fallbackModel: null },
"session.set_effort": { type: "session.set_effort", id: "r15e", sessionId: "s1", effort: "high" },
"session.mcp_reconnect": { type: "session.mcp_reconnect", id: "r28m", sessionId: "s1", server: "slack" },
"session.mcp_refresh": { type: "session.mcp_refresh", id: "r28r", sessionId: "s1" },
"mcp.approve.preview": { type: "mcp.approve.preview", id: "r28p", sessionId: "s1", server: "db" },
Expand Down
12 changes: 11 additions & 1 deletion packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import { z } from "zod";
import type { AuthMsg, ClientMessage } from "./types.js";
import { LIMITS } from "./types.js";
import { EFFORT_LEVELS, LIMITS } from "./types.js";

// ── Shared field schemas ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -127,6 +127,8 @@ export const sessionCreateSchema = z.object({
* daemon can answer with a specific error, same rule as `providerId`.
*/
model: z.string().min(1).max(LIMITS.MODEL_MAX).optional(),
/** Thinking effort (#417). */
effort: z.enum(EFFORT_LEVELS).nullable().optional(),
/**
* Activate an installed SDLC pack on this session (ambient mode —
* docs/pack-loading.md): its constitution is injected into the system
Expand Down Expand Up @@ -329,6 +331,13 @@ export const sessionSetModelSchema = z.object({
fallbackModel: z.string().max(LIMITS.MODEL_MAX).nullable().optional(),
});

export const sessionSetEffortSchema = z.object({
...base,
type: z.literal("session.set_effort"),
sessionId: sessionIdField,
effort: z.enum(EFFORT_LEVELS).nullable(),
});

export const sessionForkSchema = z.object({
...base,
type: z.literal("session.fork"),
Expand Down Expand Up @@ -1091,6 +1100,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
sessionRotateSchema,
sessionSearchSchema,
sessionSetModelSchema,
sessionSetEffortSchema,
sessionSetProviderSchema,
sessionMcpReconnectSchema,
sessionMcpRefreshSchema,
Expand Down
Loading
Loading