From f4f3ad567b952fdb559f071f47cf9c2ea3c5bcbc Mon Sep 17 00:00:00 2001
From: Willem
Date: Sat, 7 Nov 2026 14:31:37 +0100
Subject: [PATCH 1/9] Separate Ultracode session mode from reasoning effort
---
electron/ipc-threads.js | 6 ++
electron/preload.js | 1 +
electron/projectCommands.js | 5 +-
electron/providers.js | 58 ++++++++++++++--
electron/runner-provider-claude.js | 15 ++++
electron/services-threads.js | 21 +++++-
electron/services.js | 2 +
electron/store-migrate.js | 3 +-
electron/store-normalize.js | 6 +-
electron/test/agent-profiles.test.js | 3 +-
electron/test/reasoning-effort.test.js | 35 ++++++----
electron/test/ultracode.test.js | 94 ++++++++++++++++++++++++++
electron/webScopes.js | 1 +
src/App.tsx | 2 +
src/coder/useCoderThreadActions.ts | 29 ++++++++
src/components/Composer.tsx | 24 +++++--
src/components/ThreadView.tsx | 4 ++
src/components/settings/AgentsPane.tsx | 6 ++
src/dev/threads.ts | 13 ++++
src/modelPicker.ts | 6 +-
src/shared/ipc.ts | 9 ++-
src/shared/ipcChannels.ts | 1 +
src/useCoder.ts | 3 +
test/composer.test.tsx | 35 +++++++++-
24 files changed, 346 insertions(+), 36 deletions(-)
create mode 100644 electron/test/ultracode.test.js
diff --git a/electron/ipc-threads.js b/electron/ipc-threads.js
index bb5110264..f0ba5d859 100644
--- a/electron/ipc-threads.js
+++ b/electron/ipc-threads.js
@@ -738,6 +738,12 @@ module.exports = {
ctx.broadcast("threads:changed", services.listThreads(ctx.store));
return updated;
},
+ "threads:setUltracode": async (ctx, input) => {
+ const updated = leaveProfile(ctx, input.threadId, services.setUltracode(ctx.store, input));
+ services.recordLastUsedDefaults(ctx.store, input.threadId);
+ ctx.broadcast("threads:changed", services.listThreads(ctx.store));
+ return updated;
+ },
"threads:setFast": async (ctx, input) => {
const updated = services.setFast(ctx.store, input);
ctx.broadcast("threads:changed", services.listThreads(ctx.store));
diff --git a/electron/preload.js b/electron/preload.js
index 4a56a1186..dc1bba23a 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -282,6 +282,7 @@ const IPC_CHANNELS = Object.freeze([
{ ns: "threads", method: "setAgent" },
{ ns: "threads", method: "listAgents" },
{ ns: "threads", method: "setFast" },
+ { ns: "threads", method: "setUltracode" },
{ ns: "threads", method: "setRoute" },
{ ns: "threads", method: "setVerifyCommand" },
{ ns: "threads", method: "runVerify" },
diff --git a/electron/projectCommands.js b/electron/projectCommands.js
index 7a8f704af..065beb6c5 100644
--- a/electron/projectCommands.js
+++ b/electron/projectCommands.js
@@ -101,7 +101,7 @@ function normalizeQuickActions(raw) {
* null so the key can be deleted from the stored project.
*
* @param {unknown} raw
- * @returns {{ provider?: string, model?: string, reasoningEffort?: string, permissionMode?: string, lastUsed?: true } | null}
+ * @returns {{ provider?: string, model?: string, reasoningEffort?: string, ultracode?: boolean, permissionMode?: string, lastUsed?: true } | null}
*/
function normalizeThreadDefaults(raw) {
if (!raw || typeof raw !== "object") return null;
@@ -111,7 +111,7 @@ function normalizeThreadDefaults(raw) {
typeof v === "string" && v.trim() && v.trim().length <= max
? v.trim()
: "";
- /** @type {{ provider?: string, model?: string, reasoningEffort?: string, permissionMode?: string, lastUsed?: true }} */
+ /** @type {{ provider?: string, model?: string, reasoningEffort?: string, ultracode?: boolean, permissionMode?: string, lastUsed?: true }} */
const out = {};
const r = /** @type {Record} */ (raw);
const provider = str(r.provider, 64);
@@ -122,6 +122,7 @@ function normalizeThreadDefaults(raw) {
}
const effort = str(r.reasoningEffort, 32);
if (effort) out.reasoningEffort = effort;
+ if (r.ultracode === true) out.ultracode = true;
if (typeof r.permissionMode === "string" && PERMISSION_MODES.has(r.permissionMode)) {
out.permissionMode = r.permissionMode;
}
diff --git a/electron/providers.js b/electron/providers.js
index 846f23122..b31497049 100644
--- a/electron/providers.js
+++ b/electron/providers.js
@@ -10,8 +10,8 @@ const { posixQuote } = require("./ssh.js");
* Data-driven provider registry for agent CLIs.
*
* Effort support (verified against installed CLIs / contract correction):
- * - claude: `--effort ` low|medium|high|xhigh|max, plus session-only
- * `ultracode` (2.1.203+: no unknown-value warning on 2.1.219). Unknown
+ * - claude: `--effort ` low|medium|high|xhigh|max. Ultracode is a
+ * separate session setting, always running at xhigh (2.1.219). Unknown
* values are a silent warning + default, so our boundary must reject.
* Haiku 4.5 is not effort-capable (empty per-model list).
* - grok: `--reasoning-effort` (alias `--effort`) errors on unknown; live CLI
@@ -132,7 +132,7 @@ function maybeEmitEffort(allowed, reasoningEffort, emit) {
emit(level);
}
-const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultracode"];
+const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
/** Astra, Sol, Terra: full ladder including ultra (parallel subagents). */
const CODEX_SOL_TERRA_EFFORTS = [
@@ -191,6 +191,44 @@ function codexModelAcceptsImages(modelId) {
return id !== "gpt-5.3-codex-spark";
}
+const ultracodeVersionCache = new Map();
+/** Probe once per binary revision; unknown/older CLIs stay off. No agent session. */
+function cliSupportsUltracode(binary, env = process.env) {
+ try {
+ const resolved = defaultWhich(binary, env);
+ if (!resolved) return false;
+ const stat = fs.statSync(resolved);
+ const key = `${resolved}:${stat.mtimeMs}:${stat.size}`;
+ if (!ultracodeVersionCache.has(key)) {
+ const output = execFileSync(resolved, ["--version"], { encoding: "utf8", timeout: 2000, env, stdio: ["ignore", "pipe", "ignore"] });
+ const version = output.match(/(\d+\.\d+\.\d+)/)?.[1];
+ ultracodeVersionCache.set(key, Boolean(version && !catalogDivergence.versionBelow(version, "2.1.219")));
+ }
+ return ultracodeVersionCache.get(key);
+ } catch { return false; }
+}
+
+const remoteUltracodeVersionCache = new Map();
+/** Read-only version discovery through the same SSH/WSL wrapper as launches. */
+async function remoteCliSupportsUltracode(project, binary, envExtra) {
+ // Only PATH changes binary resolution; do not forward credentials for discovery.
+ const env = envExtra?.PATH ? { PATH: envExtra.PATH } : undefined;
+ const key = JSON.stringify([project.remoteHost, project.remotePath, project.path, binary, env]);
+ const hit = remoteUltracodeVersionCache.get(key);
+ if (hit && Date.now() - hit.at < 300000) return hit.value;
+ const command = require("./ssh.js").wrapCommand(project, binary, ["--version"], undefined, env);
+ const value = new Promise((resolve, reject) => execFile(command.bin, command.args, { encoding: "utf8", timeout: 5000 }, (err, stdout) => err ? reject(err) : resolve(stdout))).then((output) => {
+ const version = String(output).match(/(\d+\.\d+\.\d+)/)?.[1];
+ return Boolean(version && !catalogDivergence.versionBelow(version, "2.1.219"));
+ }).catch(() => false);
+ remoteUltracodeVersionCache.set(key, { at: Date.now(), value });
+ return value;
+}
+
+function modelSupportsUltracode(entry, modelId) {
+ return entry?.id === "claude" && Boolean((entry.modelInfo || []).find((m) => m.id === modelId)?.efforts?.includes("xhigh"));
+}
+
/**
* Whether the fast tier reaches the CLI for this model. Default / custom ids
* stay off: the toggle only shows on a model that lists `fast`. Codex
@@ -389,8 +427,8 @@ const PROVIDERS = [
efforts: [],
},
],
- // claude --help lists low..max; 2.1.219 also accepts ultracode (no
- // unknown-value warning). Haiku 4.5 is not effort-capable; Haiku 5.5 is.
+ // Normal effort levels; Ultracode is a separate session setting.
+ // Haiku 4.5 is not effort-capable; Haiku 5.5 is.
efforts: CLAUDE_EFFORTS.slice(),
supportsAgents: true,
supportsPlanModel: true,
@@ -401,6 +439,8 @@ const PROVIDERS = [
permissionMode,
model,
reasoningEffort,
+ ultracode,
+ cliSupportsUltracode: ultracodeCli = false,
fast,
agent,
forkSessionAt,
@@ -452,7 +492,7 @@ const PROVIDERS = [
// swallow them (variadic flags have bitten this project twice).
maybeEmitEffort(
honouredEfforts(getProvider("claude"), model),
- reasoningEffort,
+ ultracode === true && ultracodeCli && modelSupportsUltracode(getProvider("claude"), model) ? "xhigh" : reasoningEffort,
(level) => {
args.push("--effort", level);
},
@@ -464,6 +504,8 @@ const PROVIDERS = [
}
/** @type {Record} */
const settings = {};
+ // Claude 2.1.219 session settings schema: this mode always runs at xhigh.
+ if (ultracode === true && ultracodeCli && modelSupportsUltracode(getProvider("claude"), model)) settings.ultracode = true;
// 2.1.283: init reports fast_mode_state; without this it is off with
// sdk_opt_in_required. Account limits can still refuse (runner notes it).
if (fast === true && modelSupportsFast(getProvider("claude"), model)) {
@@ -2124,6 +2166,7 @@ function listProviders(opts = {}) {
supportsSearch: entry.supportsSearch === true,
supportsSteer: entry.supportsSteer === true,
supportsAgents: entry.supportsAgents === true,
+ ...(entry.id === "claude" ? { supportsUltracode: isBinAvailable(bin, whichFn, env) && cliSupportsUltracode(bin, env) } : {}),
...(entry.supportsPlanModel ? { supportsPlanModel: true } : {}),
permissionModes: honouredPermissionModes(entry),
};
@@ -2179,6 +2222,9 @@ module.exports = {
listProviders,
honouredEfforts,
modelSupportsFast,
+ modelSupportsUltracode,
+ cliSupportsUltracode,
+ remoteCliSupportsUltracode,
codexModelAcceptsImages,
probeCatalogCli,
catalogCliProbeStarted,
diff --git a/electron/runner-provider-claude.js b/electron/runner-provider-claude.js
index 06f8d3a8a..4218c9fb7 100644
--- a/electron/runner-provider-claude.js
+++ b/electron/runner-provider-claude.js
@@ -504,6 +504,18 @@ function createClaudeRun(ctx) {
// provider's overlay row. Joins the reuse key below too: changing it
// mid-thread must respawn a warm interactive process.
const extraArgv = threadExtraArgv(store.getSettings(), thread);
+ if (thread.ultracode === true && entryDef.id === "claude" && !require("./providers.js").modelSupportsUltracode(entryDef, thread.model)) {
+ throw new Error("Ultracode requires an xhigh-capable Claude model for this turn. Pick a supported model or turn Ultracode off.");
+ }
+ const versionEnv = thread.ultracode === true ? withProjectEnv(project, threadInstanceEnv(store.getSettings(), thread, { local: !crossesBoundary(project), userDataPath })) : undefined;
+ const ultracodeCli = thread.ultracode === true && entryDef.id === "claude"
+ ? (crossesBoundary(project)
+ ? await require("./providers.js").remoteCliSupportsUltracode(project, binary, versionEnv)
+ : require("./providers.js").cliSupportsUltracode(binary, { ...process.env, ...versionEnv }))
+ : false;
+ if (thread.ultracode === true && entryDef.id === "claude" && !ultracodeCli) {
+ throw new Error("Ultracode requires Claude Code 2.1.219 or newer. Could not verify support on this host. Update the CLI or turn Ultracode off.");
+ }
const args = entryDef.buildArgs({
prompt,
sessionId: thread.sessionId || null,
@@ -513,6 +525,8 @@ function createClaudeRun(ctx) {
webSearch: thread.webSearch === true,
agent: thread.agent || null,
fast: thread.fast === true,
+ ultracode: thread.ultracode === true,
+ cliSupportsUltracode: ultracodeCli,
forkSessionAt: thread.forkSessionAt || null,
addDirs: extraDirs,
askWriteDirs,
@@ -1572,6 +1586,7 @@ function createClaudeRun(ctx) {
reasoningEffort: thread.reasoningEffort || null,
// --settings fastMode is argv: toggling Fast must respawn (#1529).
fast: thread.fast === true,
+ ultracode: thread.ultracode === true,
// --add-dir / ask rules are argv: a folder change or grant respawns.
dirs: [extraDirs, askWriteDirs, askReads],
// Extra launch arguments (#1780) are argv: an edit must respawn.
diff --git a/electron/services-threads.js b/electron/services-threads.js
index 26be03171..4a4530c4a 100644
--- a/electron/services-threads.js
+++ b/electron/services-threads.js
@@ -335,6 +335,7 @@ function createThread(store, input) {
sessionId: null,
permissionMode,
reasoningEffort,
+ ultracode: defaults?.ultracode === true && require("./providers.js").modelSupportsUltracode(entry, model),
webSearch: false,
fast: false,
worktreePath: null,
@@ -406,6 +407,7 @@ function recordLastUsedDefaults(store, threadId) {
provider: threadProviderRef(thread),
model: thread.model,
reasoningEffort: thread.reasoningEffort,
+ ultracode: thread.ultracode === true,
permissionMode: thread.permissionMode,
});
store.setProjects(
@@ -568,6 +570,7 @@ function applyAgentProfile(store, input) {
? profile.reasoningEffort
: null,
});
+ setUltracode(store, { threadId, ultracode: profile.ultracode === true });
const mode = snapPermissionModeForThread(
getProvider(live.provider),
profile.permissionMode,
@@ -647,6 +650,17 @@ function setReasoningEffort(store, input) {
return updated ? { ...updated } : { ...thread, reasoningEffort: level };
}
+/** Set the session mode without changing the saved normal effort. */
+function setUltracode(store, input) {
+ const thread = store.getThread(input.threadId);
+ if (!thread) throw new Error(`Unknown thread: ${input.threadId}`);
+ const enabled = input.ultracode === true;
+ if (enabled && !require("./providers.js").modelSupportsUltracode(getProvider(thread.provider), thread.model)) throw new Error("Ultracode requires an xhigh-capable Claude model");
+ const updated = store.updateThread(thread.id, { ultracode: enabled });
+ store.save();
+ return { ...updated };
+}
+
/**
* Fast tier toggle (#1529). Like effort it is a preference: it survives
* model and provider switches, and buildArgs only sends it for a model that
@@ -817,6 +831,8 @@ function forkThread(store, input) {
provider: nextProvider,
...(nextInstance ? { providerInstance: nextInstance } : {}),
model: nextModel,
+ reasoningEffort: honouredEfforts(getProvider(nextProvider), nextModel).includes(source.reasoningEffort) ? source.reasoningEffort : null,
+ ultracode: source.ultracode === true && require("./providers.js").modelSupportsUltracode(getProvider(nextProvider), nextModel),
permissionMode: source.permissionMode,
handoffFrom: source.id,
sessionId: null,
@@ -1216,7 +1232,7 @@ function setProvider(store, input) {
const instanceChanging =
ref != null && (ref.instance || null) !== (thread.providerInstance || null);
- /** @type {{ provider?: string, providerInstance?: string | null, model?: string | null, sessionId?: null, replayContext?: boolean, reasoningEffort?: null, webSearch?: boolean, planModel?: null, planReasoningEffort?: null }} */
+ /** @type {{ provider?: string, providerInstance?: string | null, model?: string | null, sessionId?: null, replayContext?: boolean, ultracode?: boolean, reasoningEffort?: null, webSearch?: boolean, planModel?: null, planReasoningEffort?: null }} */
const patch = {};
if ((providerChanging || instanceChanging) && thread.status === "working") {
@@ -1302,6 +1318,8 @@ function setProvider(store, input) {
}
}
+ if (!require("./providers.js").modelSupportsUltracode(getProvider(nextProvider), patch.model !== undefined ? patch.model : thread.model)) patch.ultracode = false;
+
if (patch.sessionId === null) {
// The new CLI (or re-pinned model) starts with no memory of this thread.
// Replay its own retained tail on the next send, same as a rewind (#254),
@@ -2193,6 +2211,7 @@ module.exports = {
setWebSearch,
setAgent,
setFast,
+ setUltracode,
forkThread,
forkFromCheckpoint,
forkWorkerThread,
diff --git a/electron/services.js b/electron/services.js
index 3c68a7a96..cbc8c925e 100644
--- a/electron/services.js
+++ b/electron/services.js
@@ -75,6 +75,7 @@ const {
setWebSearch,
setAgent,
setFast,
+ setUltracode,
setRoute,
forkThread,
forkFromCheckpoint,
@@ -239,6 +240,7 @@ module.exports = {
setWebSearch,
setAgent,
setFast,
+ setUltracode,
setRoute,
setProvider,
// normalizeModelForProvider / isKnownProviderId / truncateThreadTitle stay
diff --git a/electron/store-migrate.js b/electron/store-migrate.js
index da1724a79..43c70cf73 100644
--- a/electron/store-migrate.js
+++ b/electron/store-migrate.js
@@ -512,7 +512,8 @@ function migrateThread(t) {
permissionMode: t.permissionMode != null ? t.permissionMode : "default",
// Older stores lack reasoningEffort; null (not undefined) so the picker is stable.
reasoningEffort:
- t.reasoningEffort !== undefined ? t.reasoningEffort : null,
+ t.reasoningEffort === "ultracode" ? (require("./providers.js").honouredEfforts(require("./providers.js").getProvider(t.provider), t.model).includes("xhigh") ? "xhigh" : null) : t.reasoningEffort !== undefined ? t.reasoningEffort : null,
+ ultracode: (t.ultracode === true || t.reasoningEffort === "ultracode") && require("./providers.js").modelSupportsUltracode(require("./providers.js").getProvider(t.provider), t.model),
// Codex live web search (issue #174). Absent → off.
webSearch: t.webSearch === true,
// Fast tier (#1529). Absent → off.
diff --git a/electron/store-normalize.js b/electron/store-normalize.js
index 9303114f5..b43031c81 100644
--- a/electron/store-normalize.js
+++ b/electron/store-normalize.js
@@ -76,7 +76,10 @@ function parseAgentProfile(item, strict) {
if (model !== null && typeof model !== "string") {
return fail("agentProfiles entry model must be a string or null");
}
- const effort = rec.reasoningEffort;
+ const legacy = rec.reasoningEffort === "ultracode";
+ const eligible = require("./providers.js").modelSupportsUltracode(getProvider(provider.split(":")[0]), model);
+ const effort = legacy ? (eligible ? "xhigh" : null) : rec.reasoningEffort;
+ if (strict && rec.ultracode === true && !eligible) return fail("Ultracode requires an xhigh-capable Claude model");
if (effort !== null && !REASONING_EFFORTS.has(effort)) {
return fail(
"agentProfiles entry reasoningEffort must be one of low, medium, high, xhigh, max, ultra, ultracode, or null",
@@ -144,6 +147,7 @@ function parseAgentProfile(item, strict) {
provider,
model,
reasoningEffort: /** @type {string | null} */ (effort),
+ ...((rec.ultracode === true || legacy) && eligible ? { ultracode: true } : {}),
...plan,
permissionMode: /** @type {string} */ (permissionMode),
...(policy ? { policy } : {}),
diff --git a/electron/test/agent-profiles.test.js b/electron/test/agent-profiles.test.js
index 3e3a3aba5..f0b9e5f1d 100644
--- a/electron/test/agent-profiles.test.js
+++ b/electron/test/agent-profiles.test.js
@@ -207,7 +207,8 @@ describe("setSettings agentProfiles validation", () => {
const next = services.setSettings(store, { agentProfiles: list });
assert.equal(next.agentProfiles[0].reasoningEffort, "ultra");
assert.equal(next.agentProfiles[1].reasoningEffort, "ultra");
- assert.equal(next.agentProfiles[2].reasoningEffort, "ultracode");
+ assert.equal(next.agentProfiles[2].reasoningEffort, "xhigh");
+ assert.equal(next.agentProfiles[2].ultracode, true);
assert.throws(
() =>
services.setSettings(store, {
diff --git a/electron/test/reasoning-effort.test.js b/electron/test/reasoning-effort.test.js
index a18ef2f45..584e6adc4 100644
--- a/electron/test/reasoning-effort.test.js
+++ b/electron/test/reasoning-effort.test.js
@@ -128,7 +128,6 @@ describe("reasoning effort: provider modelInfo + efforts", () => {
"high",
"xhigh",
"max",
- "ultracode",
]);
assert.deepEqual(byId.codex.efforts, [
"low",
@@ -152,7 +151,7 @@ describe("reasoning effort: provider modelInfo + efforts", () => {
const haiku = byId.claude.modelInfo.find((m) => m.id === "claude-haiku-4-5");
assert.deepEqual(haiku.efforts, []);
const opus = byId.claude.modelInfo.find((m) => m.id === "claude-opus-5");
- assert.ok(opus.efforts.includes("ultracode"));
+ assert.ok(!opus.efforts.includes("ultracode"));
const grok45 = byId.grok.modelInfo.find((m) => m.id === "grok-4.5");
assert.deepEqual(grok45.efforts, ["low", "medium", "high"]);
const astra = byId.codex.modelInfo.find((m) => m.id === "gpt-6-astra");
@@ -177,7 +176,7 @@ describe("reasoning effort: provider modelInfo + efforts", () => {
assert.deepEqual(honouredEfforts(claude, null), claude.efforts);
assert.deepEqual(honouredEfforts(claude, "claude-haiku-4-5"), []);
assert.ok(
- honouredEfforts(claude, "claude-opus-5").includes("ultracode"),
+ !honouredEfforts(claude, "claude-opus-5").includes("ultracode"),
);
assert.deepEqual(
honouredEfforts(claude, "custom-not-in-catalog"),
@@ -442,11 +441,14 @@ describe("reasoning effort: buildArgs per provider", () => {
const opus = entry.buildArgs({
permissionMode: "default",
model: "claude-opus-5",
- reasoningEffort: "ultracode",
+ reasoningEffort: "low",
+ ultracode: true,
+ cliSupportsUltracode: true,
});
const fi = opus.indexOf("--effort");
assert.ok(fi >= 0, JSON.stringify(opus));
- assert.equal(opus[fi + 1], "ultracode");
+ assert.equal(opus[fi + 1], "xhigh");
+ assert.equal(JSON.parse(opus[opus.indexOf("--settings") + 1]).ultracode, true);
const haiku = entry.buildArgs({
permissionMode: "default",
@@ -902,11 +904,8 @@ describe("reasoning effort: setReasoningEffort service", () => {
provider: "claude",
model: "claude-opus-5",
});
- services.setReasoningEffort(store, {
- threadId: thread.id,
- effort: "ultracode",
- });
- assert.equal(store.getThread(thread.id).reasoningEffort, "ultracode");
+ services.setUltracode(store, { threadId: thread.id, ultracode: true });
+ assert.equal(store.getThread(thread.id).ultracode, true);
services.setProvider(store, {
threadId: thread.id,
provider: "claude",
@@ -1073,7 +1072,7 @@ describe("reasoning effort: IPC seam + runner wiring", () => {
}
});
- it("runner passes thread.reasoningEffort into claude buildArgs (argv evidence)", async () => {
+ it("runner passes independent Ultracode into claude launch and restores effort on next launch", { skip: process.platform === "win32" }, async () => {
// The claude-stream call site serves claude AND grok, and claude answers a
// missing or unknown effort with a warning and its default. So this is the
// path where losing the argument is invisible, and it had no coverage.
@@ -1084,6 +1083,7 @@ describe("reasoning effort: IPC seam + runner wiring", () => {
`#!/usr/bin/env node
"use strict";
const fs = require("fs");
+if (process.argv.includes("--version")) { console.log("2.1.219 (Claude Code)"); process.exit(0); }
if (process.env.CODER_FAKE_CLAUDE_ARGV_FILE) {
fs.writeFileSync(
process.env.CODER_FAKE_CLAUDE_ARGV_FILE,
@@ -1129,12 +1129,13 @@ emit({
projectId: project.id,
title: "Effort Run Claude",
});
- services.setProvider(store, { threadId: thread.id, provider: "claude" });
+ services.setProvider(store, { threadId: thread.id, provider: "claude", model: "claude-opus-5" });
services.setReasoningEffort(store, {
threadId: thread.id,
- effort: "xhigh",
+ effort: "low",
});
+ services.setUltracode(store, { threadId: thread.id, ultracode: true });
const prompt = "PROMPT_RUNNER_EFFORT_CLAUDE_r30";
await runner.startRun({ threadId: thread.id, prompt });
await waitFor(() => store.getThread(thread.id).status === "done");
@@ -1148,6 +1149,14 @@ emit({
"xhigh",
`--effort must carry the level, got ${JSON.stringify(argv)}`,
);
+ assert.equal(JSON.parse(argv[argv.indexOf("--settings") + 1]).ultracode, true);
+ assert.equal(store.getThread(thread.id).reasoningEffort, "low");
+ services.setUltracode(store, { threadId: thread.id, ultracode: false });
+ await runner.startRun({ threadId: thread.id, prompt: "mode off" });
+ await waitFor(() => store.getThread(thread.id).status === "done");
+ const offArgv = JSON.parse(fs.readFileSync(argvFile, "utf8"));
+ assert.equal(offArgv[offArgv.indexOf("--effort") + 1], "low");
+ assert.ok(!offArgv.includes("--settings"));
assert.ok(
!argv.includes(prompt),
`claude prompt travels over stdin, not argv: ${JSON.stringify(argv)}`,
diff --git a/electron/test/ultracode.test.js b/electron/test/ultracode.test.js
new file mode 100644
index 000000000..7cce9f5bf
--- /dev/null
+++ b/electron/test/ultracode.test.js
@@ -0,0 +1,94 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { getProvider, modelSupportsUltracode, cliSupportsUltracode } = require('../providers');
+const { Store } = require('../store');
+const services = require('../services');
+
+describe('Ultracode independent session mode', () => {
+ it('emits the native session setting at xhigh, retaining ordinary effort when off', () => {
+ const claude = getProvider('claude');
+ const base = { model: 'claude-opus-5', reasoningEffort: 'low', cliSupportsUltracode: true };
+ const on = claude.buildArgs({ ...base, ultracode: true });
+ assert.equal(on[on.indexOf('--effort') + 1], 'xhigh');
+ assert.deepEqual(JSON.parse(on[on.indexOf('--settings') + 1]), { ultracode: true });
+ const off = claude.buildArgs({ ...base, ultracode: false });
+ assert.equal(off[off.indexOf('--effort') + 1], 'low');
+ assert.ok(!off.includes('--settings'));
+ for (const model of ['claude-haiku-4-5', 'unknown', null]) {
+ assert.equal(modelSupportsUltracode(claude, model), false);
+ assert.ok(!claude.buildArgs({ ...base, model, ultracode: true }).includes('--settings'));
+ }
+ assert.ok(!claude.buildArgs({ ...base, cliSupportsUltracode: false, ultracode: true }).includes('--settings'));
+ for (const effort of ['xhigh', 'max']) {
+ const args = claude.buildArgs({ ...base, reasoningEffort: effort });
+ assert.equal(args[args.indexOf('--effort') + 1], effort);
+ }
+ });
+
+ it('gates actual binary versions without starting an agent', { skip: process.platform === 'win32' }, () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ultracode-version-'));
+ try {
+ for (const [version, expected] of [['2.1.218', false], ['2.1.219', true], ['2.2.0', true], ['unknown', false]]) {
+ const bin = path.join(dir, version);
+ fs.writeFileSync(bin, `#!/bin/sh\n[ "$1" = "--version" ] || exit 12\nprintf '%s\\n' '${version} (Claude Code)'\n`, { mode: 0o755 });
+ assert.equal(cliSupportsUltracode(bin), expected, version);
+ }
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+ });
+
+ it('migrates legacy rows safely and persists independent effort through toggles and reload', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ultracode-store-'));
+ try {
+ const file = path.join(dir, 'store.json');
+ const rows = [
+ { id: 'opus', provider: 'claude', model: 'claude-opus-5' },
+ { id: 'haiku', provider: 'claude', model: 'claude-haiku-4-5' },
+ { id: 'codex', provider: 'codex', model: 'gpt-6-astra' },
+ ].map((t) => ({ ...t, projectId: 'p', title: t.id, status: 'idle', reasoningEffort: 'ultracode' }));
+ fs.writeFileSync(file, JSON.stringify({ projects: [], threads: rows, messagesByThread: {} }));
+ const store = new Store(file);
+ assert.equal(store.getThread('opus').ultracode, true);
+ assert.equal(store.getThread('opus').reasoningEffort, 'xhigh');
+ assert.equal(store.getThread('haiku').ultracode, false);
+ assert.equal(store.getThread('codex').ultracode, false);
+ services.setReasoningEffort(store, { threadId: 'opus', effort: 'max' });
+ services.setUltracode(store, { threadId: 'opus', ultracode: false });
+ assert.equal(store.getThread('opus').reasoningEffort, 'max');
+ services.setUltracode(store, { threadId: 'opus', ultracode: true });
+ assert.equal(store.getThread('opus').reasoningEffort, 'max');
+ services.setProvider(store, { threadId: 'opus', model: 'claude-haiku-4-5' });
+ assert.equal(store.getThread('opus').ultracode, false);
+ assert.throws(() => services.setUltracode(store, { threadId: 'haiku', ultracode: true }), /xhigh-capable/);
+ assert.throws(() => services.setUltracode(store, { threadId: 'codex', ultracode: true }), /Claude/);
+ store.saveNow();
+ assert.equal(new Store(file).getThread('opus').ultracode, false);
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+ });
+});
+
+describe('Ultracode saved configurations', () => {
+ it('applies independent profile effort and carries mode only to eligible forks', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ultracode-profile-'));
+ try {
+ require('node:child_process').execFileSync('git', ['init', dir], { stdio: 'ignore' });
+ const store = new Store(path.join(dir, 'store.json'));
+ const project = await services.addProject(store, dir);
+ const thread = services.createThread(store, { projectId: project.id });
+ services.setSettings(store, { agentProfiles: [{ id: 'mode', name: 'Mode', provider: 'claude', model: 'claude-opus-5', reasoningEffort: 'max', ultracode: true, permissionMode: 'default' }] });
+ services.applyAgentProfile(store, { threadId: thread.id, profileId: 'mode' });
+ assert.equal(store.getThread(thread.id).ultracode, true);
+ assert.equal(store.getThread(thread.id).reasoningEffort, 'max');
+ const fork = services.forkThread(store, { threadId: thread.id });
+ assert.equal(fork.ultracode, true);
+ assert.equal(fork.reasoningEffort, 'max');
+ const other = services.forkThread(store, { threadId: thread.id, provider: 'codex', model: 'gpt-6-astra' });
+ assert.equal(other.ultracode, false);
+ services.setProvider(store, { threadId: thread.id, provider: 'codex', model: 'gpt-6-astra' });
+ assert.equal(store.getThread(thread.id).ultracode, false);
+ store.saveNow();
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+ });
+});
diff --git a/electron/webScopes.js b/electron/webScopes.js
index dfe9d56ec..fa4f95157 100644
--- a/electron/webScopes.js
+++ b/electron/webScopes.js
@@ -187,6 +187,7 @@ const BY_SCOPE = {
"threads:setReasoningEffort",
"threads:setPlanModel",
"threads:setFast",
+ "threads:setUltracode",
"threads:setRoute",
"threads:setWebSearch",
"threads:setAgent",
diff --git a/src/App.tsx b/src/App.tsx
index 875dcdfa5..009dba365 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -266,6 +266,7 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {})
applyAgentProfile,
setWebSearch,
setFast,
+ setUltracode,
setPlanModel,
setRoute,
setAgent,
@@ -2617,6 +2618,7 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {})
onApplyAgentProfile={applyAgentProfile}
onSetWebSearch={setWebSearch}
onSetFast={setFast}
+ onSetUltracode={setUltracode}
onSetPlanModel={setPlanModel}
onSetRoute={setRoute}
onSetAgent={setAgent}
diff --git a/src/coder/useCoderThreadActions.ts b/src/coder/useCoderThreadActions.ts
index 4d7d7c5aa..f529bbfe9 100644
--- a/src/coder/useCoderThreadActions.ts
+++ b/src/coder/useCoderThreadActions.ts
@@ -396,6 +396,34 @@ export function useCoderThreadActions({
[api, selectedThreadId, applyThreads],
);
+ const setUltracode = useCallback(
+ async (ultracode: boolean, threadIdArg?: string) => {
+ const threadId = threadIdArg ?? selectedThreadId;
+ if (!threadId) return;
+ try {
+ const thread = await api.threads.setUltracode({
+ threadId,
+ ultracode,
+ });
+ applyThreads(
+ threadsRef.current.map((t) => (t.id === thread.id ? thread : t)),
+ );
+ if (selectedRef.current === threadId) {
+ setDetail((prev) =>
+ prev && prev.thread.id === thread.id
+ ? { ...prev, thread }
+ : prev,
+ );
+ }
+ setError(null);
+ } catch (err) {
+ setError({ scope: "run", message: errorMessage(err) });
+ throw err;
+ }
+ },
+ [api, selectedThreadId, applyThreads],
+ );
+
const setPlanModel = useCallback(
async (model: string | null, threadIdArg?: string) => {
const threadId = threadIdArg ?? selectedThreadId;
@@ -1242,6 +1270,7 @@ export function useCoderThreadActions({
setAgent,
listAgents,
setFast,
+ setUltracode,
setPlanModel,
setRoute,
setArchived,
diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx
index d1a38e52c..ed1c02a4c 100644
--- a/src/components/Composer.tsx
+++ b/src/components/Composer.tsx
@@ -204,6 +204,7 @@ export interface ComposerProps {
webSearch?: boolean;
/** Fast / priority tier (#1529). Shown only on a model whose info lists `fast`. */
fast?: boolean;
+ ultracode?: boolean;
/** CLI custom agent (`--agent`, #172); null = CLI default. */
agent?: string | null;
/** Agents the provider CLI finds for this thread's checkout. */
@@ -233,6 +234,7 @@ export interface ComposerProps {
onApplyAgentProfile: (profileId: string) => void | Promise;
onSetWebSearch?: (webSearch: boolean) => void | Promise;
onSetFast?: (fast: boolean) => void | Promise;
+ onSetUltracode?: (ultracode: boolean) => void | Promise;
/** Plan-mode model (#1716); null = plan on `model`. */
planModel?: string | null;
/** Shown only for providers with `supportsPlanModel`. */
@@ -532,6 +534,7 @@ export const Composer = memo(function Composer({
reasoningEffort,
webSearch = false,
fast = false,
+ ultracode = false,
agent = null,
agents = [],
onSetAgent,
@@ -547,6 +550,7 @@ export const Composer = memo(function Composer({
onApplyAgentProfile,
onSetWebSearch,
onSetFast,
+ onSetUltracode,
route = null,
onSetRoute,
onSaveWorkflow,
@@ -1074,14 +1078,16 @@ export const Composer = memo(function Composer({
// the highlighted picker row.
const efforts = effortsForModel(currentProviderInfo, model);
const reasoningVisible = showReasoningControl(efforts);
- const effortUnavailable = currentProviderInfo?.available === false;
- const effortLabel = effortDisplayLabel(reasoningEffort);
+ const ultracodeRequested = ultracode && currentProviderInfo?.id === "claude" && efforts.includes("xhigh");
+ const effortUnavailable = currentProviderInfo?.available === false || ultracodeRequested;
+ const effortLabel = ultracodeRequested ? "Ultracode requested (xhigh)" : effortDisplayLabel(reasoningEffort);
const fastAvailable = Boolean(
onSetFast &&
currentProviderInfo?.available !== false &&
currentProviderInfo?.modelInfo?.find((m) => m.id === model)?.fast,
);
const fastOn = fast && fastAvailable;
+ const ultracodeAvailable = Boolean(onSetUltracode && currentProviderInfo?.id === "claude" && (currentProviderInfo.supportsUltracode === true || ultracode) && currentProviderInfo.available !== false && currentProviderInfo.modelInfo?.find((m) => m.id === model)?.efforts?.includes("xhigh"));
const honouredModes = providerPermissionModes(currentProviderInfo);
const currentModeHonoured = permissionModeHonoured(
permissionMode,
@@ -2122,7 +2128,7 @@ export const Composer = memo(function Composer({
nextProviderId: string,
nextModel: string | null,
) => {
- if (reasoningEffort != null) return;
+ if (reasoningEffort != null || ultracodeRequested) return;
const want = getLastReasoningEffort();
if (want == null) return;
const currentList = effortsForModel(currentProviderInfo, model);
@@ -3024,15 +3030,15 @@ export const Composer = memo(function Composer({
aria-label="Effort"
data-picker-effort=""
>
- Effort
+ {ultracode && ultracodeAvailable ? "Ultracode requested: xhigh" : "Effort"}
{effortOptions(efforts).map((level) => (
void pickEffort(level)}
>
@@ -3041,6 +3047,12 @@ export const Composer = memo(function Composer({
))}
) : null}
+ {ultracodeAvailable ? (
+
+ Workflow
+ void onSetUltracode?.(!ultracode)}>Ultracode
+
+ ) : null}
{fastAvailable ? (
void | Promise;
onSetWebSearch?: (webSearch: boolean, threadId?: string) => void | Promise;
onSetFast?: (fast: boolean, threadId?: string) => void | Promise;
+ onSetUltracode?: (ultracode: boolean, threadId?: string) => void | Promise;
onSetPlanModel?: (model: string | null, threadId?: string) => void | Promise;
onSetRoute?: (route: ModelRoute | null, threadId?: string) => void | Promise;
/** Run the thread as a CLI custom agent (#172); null = CLI default. */
@@ -966,6 +967,7 @@ export const ThreadView = memo(function ThreadView({
onApplyAgentProfile,
onSetWebSearch,
onSetFast,
+ onSetUltracode,
onSetPlanModel,
onSetRoute,
onSetAgent,
@@ -5877,6 +5879,7 @@ export const ThreadView = memo(function ThreadView({
reasoningEffort={thread.reasoningEffort}
webSearch={thread.webSearch === true}
fast={thread.fast === true}
+ ultracode={thread.ultracode === true}
route={thread.route ?? null}
agent={thread.agent ?? null}
agents={cliAgents}
@@ -5891,6 +5894,7 @@ export const ThreadView = memo(function ThreadView({
onApplyAgentProfile={onApplyAgentProfile}
onSetWebSearch={onSetWebSearch}
onSetFast={onSetFast}
+ onSetUltracode={onSetUltracode}
planModel={thread.planModel ?? null}
onSetPlanModel={onSetPlanModel}
onSetRoute={onSetRoute}
diff --git a/src/components/settings/AgentsPane.tsx b/src/components/settings/AgentsPane.tsx
index b2f566167..92d21ce4d 100644
--- a/src/components/settings/AgentsPane.tsx
+++ b/src/components/settings/AgentsPane.tsx
@@ -42,6 +42,7 @@ export interface ProfileDraft {
provider: string;
model: string | null;
reasoningEffort: ReasoningEffort | null;
+ ultracode?: boolean;
/** Plan-mode model (#1716); only kept for supportsPlanModel providers. */
planModel?: string | null;
/** Not editable here; carried through so an edit does not drop it. */
@@ -145,6 +146,7 @@ function draftFromProfile(
provider: profile.provider,
model: profile.model,
reasoningEffort: profile.reasoningEffort,
+ ultracode: profile.ultracode,
planModel: profile.planModel ?? null,
planReasoningEffort: profile.planReasoningEffort ?? null,
permissionMode: snapToHonouredPermissionMode(
@@ -219,6 +221,7 @@ export function AgentsPane({
provider: draft.provider,
model,
reasoningEffort,
+ ultracode: draft.ultracode === true && selected?.id === "claude" && Boolean(selected.modelInfo.find((m) => m.id === model)?.efforts?.includes("xhigh")),
permissionMode: snapToHonouredPermissionMode(
providerPermissionModes(selected),
draft.permissionMode,
@@ -960,6 +963,9 @@ function ProfileForm({
)}
+ {selected?.id === "claude" && selected.supportsUltracode === true && selected.modelInfo.find((m) => m.id === draft.model)?.efforts?.includes("xhigh") && (
+ onChange({ ...draft, ultracode: e.target.checked })} /> Ultracode (xhigh effort plus dynamic workflows)
+ )}
{selected?.supportsPlanModel && (
diff --git a/src/dev/threads.ts b/src/dev/threads.ts
index 4b84a3c01..cef194847 100644
--- a/src/dev/threads.ts
+++ b/src/dev/threads.ts
@@ -332,6 +332,9 @@ export function createThreads(ctx: DevCtx): Pick {
// seeded-fresh fallback.
...(input.messageId ? { replayContext: true } : {}),
});
+ const forkProvider = (await ctx.api().providers.list()).find((p) => p.id === created.provider);
+ created.reasoningEffort = source.reasoningEffort;
+ created.ultracode = source.ultracode === true && forkProvider?.id === "claude" && Boolean(forkProvider.modelInfo.find((m) => m.id === created.model)?.efforts?.includes("xhigh"));
const registered = registerThread(created);
if (input.messageId) {
const at = sourceDetail.messages.findIndex(
@@ -453,6 +456,7 @@ export function createThreads(ctx: DevCtx): Pick {
providerInstance: null,
model: profile.model,
reasoningEffort: profile.reasoningEffort,
+ ultracode: profile.ultracode === true,
permissionMode: profile.permissionMode,
autoApprove: profile.autoApprove ?? null,
agentProfileId: profile.id,
@@ -1011,6 +1015,13 @@ export function createThreads(ctx: DevCtx): Pick {
agentProfileId: null,
});
},
+ async setUltracode(input: { threadId: string; ultracode: boolean }) {
+ const thread = details.get(input.threadId)?.thread;
+ if (!thread) throw new Error(`Thread not found: ${input.threadId}`);
+ const provider = (await ctx.api().providers.list()).find((p) => p.id === thread.provider);
+ if (input.ultracode && !(provider?.id === "claude" && provider.modelInfo.find((m) => m.id === thread.model)?.efforts?.includes("xhigh"))) throw new Error("Ultracode requires an xhigh-capable Claude model");
+ return patchThread(input.threadId, { ultracode: input.ultracode === true, agentProfileId: null });
+ },
async setFast(input: { threadId: string; fast: boolean }) {
return patchThread(input.threadId, { fast: input.fast === true });
},
@@ -1150,6 +1161,8 @@ export function createThreads(ctx: DevCtx): Pick {
patch.model = input.model ? String(input.model).trim() : null;
}
+ const nextProvider = (await ctx.api().providers.list()).find((p) => p.id === (patch.provider ?? thread.provider));
+ if (!(nextProvider?.id === "claude" && nextProvider.modelInfo.find((m) => m.id === (patch.model === undefined ? thread.model : patch.model))?.efforts?.includes("xhigh"))) patch.ultracode = false;
const next: ThreadInfo = { ...thread, ...patch };
detail.thread = next;
details.set(input.threadId, detail);
diff --git a/src/modelPicker.ts b/src/modelPicker.ts
index e7e8be577..acfdeedf3 100644
--- a/src/modelPicker.ts
+++ b/src/modelPicker.ts
@@ -433,14 +433,14 @@ export function effortsForModel(
): ReasoningEffort[] {
if (!provider) return [];
const providerEfforts = Array.isArray(provider.efforts)
- ? provider.efforts.slice()
+ ? provider.efforts.filter((level) => level !== "ultracode")
: [];
if (modelId == null || modelId === "") return providerEfforts;
const infos = provider.modelInfo;
const hit = Array.isArray(infos)
? infos.find((m) => m.id === modelId)
: undefined;
- if (hit && Array.isArray(hit.efforts)) return hit.efforts.slice();
+ if (hit && Array.isArray(hit.efforts)) return hit.efforts.filter((level) => level !== "ultracode");
return providerEfforts;
}
@@ -697,7 +697,7 @@ export function profileSummary(
? "Default"
: (info?.modelInfo.find((m) => m.id === profile.model)?.label ??
profile.model);
- const effort = effortDisplayLabel(profile.reasoningEffort);
+ const effort = profile.ultracode ? "Ultracode (xhigh)" : effortDisplayLabel(profile.reasoningEffort);
const permission =
PERMISSION_MODE_LABELS[profile.permissionMode] ?? profile.permissionMode;
const ruleCount = profile.policy?.rules.length ?? 0;
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts
index 6b48f65d4..e03976c67 100644
--- a/src/shared/ipc.ts
+++ b/src/shared/ipc.ts
@@ -239,6 +239,7 @@ export interface ProjectThreadDefaults {
/** Model id for `provider`; absent = provider default. */
model?: string;
reasoningEffort?: ReasoningEffort;
+ ultracode?: boolean;
permissionMode?: PermissionMode;
/**
* When true, the defaults follow the last pick the user made on a new
@@ -1198,6 +1199,8 @@ export interface ThreadInfo {
* lists `fast`; absent on older store rows.
*/
fast?: boolean;
+ /** Session mode: xhigh effort plus dynamic workflows on supported Claude models. */
+ ultracode?: boolean;
/**
* Agent-facing browser access (issue #1797). Default true (absent on
* older store rows reads as on). `false` turns off the Browser pane's
@@ -3431,7 +3434,7 @@ export type DiscussionDetailForMentionResult =
*
* These are the levels the installed CLIs actually accept, verified against
* them rather than copied from a design: `claude --effort` takes low, medium,
- * high, xhigh, max, plus the session-only `ultracode` token; Codex coding
+ * high, xhigh, max. Ultracode is a separate session mode; Codex coding
* clients add `ultra` (parallel subagents) above `max`; grok takes low,
* medium, high, xhigh. A provider advertises its own subset through
* ProviderInfo.efforts, and a model may narrow that further via
@@ -3665,6 +3668,8 @@ export interface ProviderInfo {
* The composer hides the Agent picker when this is missing or false.
*/
supportsAgents?: boolean;
+ /** Installed Claude CLI supports the session setting (2.1.219+). */
+ supportsUltracode?: boolean;
/**
* True when plan-mode turns can run on a separate plan model and the same
* session resumes on the act model afterwards (#1716: claude, codex).
@@ -4554,6 +4559,7 @@ export interface AgentProfile {
model: string | null;
/** null = provider default (no --effort flag). */
reasoningEffort: ReasoningEffort | null;
+ ultracode?: boolean;
/** Plan-mode model (#1716); absent = plan on `model`. */
planModel?: string | null;
/** Plan-mode effort; absent/null keeps `reasoningEffort`. */
@@ -6738,6 +6744,7 @@ export interface CoderApi {
* model of the thread's provider has a fast tier.
*/
setFast(input: { threadId: string; fast: boolean }): Promise;
+ setUltracode(input: { threadId: string; ultracode: boolean }): Promise;
/**
* Plan-mode model + effort (#1716). Plan-mode turns run on it; leaving
* plan mode resumes the same session on `model`. null clears both.
diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts
index 0086dec47..7b88082d4 100644
--- a/src/shared/ipcChannels.ts
+++ b/src/shared/ipcChannels.ts
@@ -236,6 +236,7 @@ export const IPC_CHANNELS = [
{ ns: "threads", method: "setAgent" },
{ ns: "threads", method: "listAgents" },
{ ns: "threads", method: "setFast" },
+ { ns: "threads", method: "setUltracode" },
{ ns: "threads", method: "setRoute" },
{ ns: "threads", method: "setVerifyCommand" },
{ ns: "threads", method: "runVerify" },
diff --git a/src/useCoder.ts b/src/useCoder.ts
index 5f6b4f456..609451a58 100644
--- a/src/useCoder.ts
+++ b/src/useCoder.ts
@@ -438,6 +438,7 @@ export interface UseCoderResult {
projectPath?: string | null;
}) => Promise;
setFast: (fast: boolean, threadId?: string) => Promise;
+ setUltracode: (ultracode: boolean, threadId?: string) => Promise;
/** Plan-mode model (#1716); null = plan on the thread's model. */
setPlanModel: (model: string | null, threadId?: string) => Promise;
/** Pending first-turn model route (#246); null = manual pick. */
@@ -1908,6 +1909,7 @@ export function useCoder(): UseCoderResult {
setAgent,
listAgents,
setFast,
+ setUltracode,
setPlanModel,
setRoute,
setArchived,
@@ -2525,6 +2527,7 @@ export function useCoder(): UseCoderResult {
setAgent,
listAgents,
setFast,
+ setUltracode,
setPlanModel,
setRoute,
setArchived,
diff --git a/test/composer.test.tsx b/test/composer.test.tsx
index d7d675088..1575779a1 100644
--- a/test/composer.test.tsx
+++ b/test/composer.test.tsx
@@ -207,6 +207,8 @@ function composer(
reasoningEffort?: ReasoningEffort | null;
webSearch?: boolean;
fast?: boolean;
+ ultracode?: boolean;
+ onSetUltracode?: (value: boolean) => void;
sessionId?: string | null;
workspaceStrip?: ReactNode;
disabled?: boolean;
@@ -249,6 +251,8 @@ function composer(
}
webSearch={over.webSearch === true}
fast={over.fast === true}
+ ultracode={over.ultracode === true}
+ onSetUltracode={over.onSetUltracode}
providers={over.providers ?? PROVIDERS}
onToggleModelFavorite={(providerId, modelId) => {
harness.favoriteToggles.push({ providerId, modelId });
@@ -1922,7 +1926,7 @@ describe("Composer reasoning pill", () => {
assert.ok(shown.query('button[aria-label^="Reasoning:"]'));
const menu = await openEffort(shown);
assert.ok(
- Array.from(menu?.querySelectorAll("button") || []).some((b) =>
+ !Array.from(menu?.querySelectorAll("button") || []).some((b) =>
(b.getAttribute("aria-label") || "").includes("Ultracode"),
),
);
@@ -3907,3 +3911,32 @@ describe("Composer surrounds a selection with a bracket/quote/emphasis pair (#17
m.unmount();
});
});
+
+describe("Composer Ultracode mode", () => {
+ it("shows requested xhigh separately, disables base effort, and restores base effort when off", async () => {
+ const h = makeHarness();
+ const claude: ProviderInfo = {
+ ...CLAUDE_WITH_INFO,
+ supportsUltracode: true,
+ efforts: ["low", "medium", "high", "xhigh", "max"],
+ models: ["claude-opus-5"],
+ modelInfo: [{ id: "claude-opus-5", label: "Opus", description: "", vendor: "Anthropic", efforts: ["low", "medium", "high", "xhigh", "max"] }],
+ };
+ const toggles: boolean[] = [];
+ const m = await mount(composer(h, { provider: "claude", model: "claude-opus-5", providers: [claude], reasoningEffort: "low", ultracode: true, onSetUltracode: (value) => { toggles.push(value); } }));
+ assert.ok(m.query('button[aria-label="Reasoning: Ultracode requested (xhigh)"]'));
+ await m.click(m.query('button[aria-label^="Model:"]'));
+ assert.equal((m.query('[data-effort-chip="low"]') as HTMLButtonElement).disabled, true);
+ assert.equal((m.query('[data-effort-chip="xhigh"]') as HTMLButtonElement).getAttribute("aria-pressed"), "true");
+ const toggle = m.query('[aria-label="Ultracode"] button');
+ assert.equal(toggle?.getAttribute("aria-pressed"), "true");
+ await m.click(toggle);
+ assert.deepEqual(toggles, [false]);
+ m.unmount();
+ const off = await mount(composer(h, { provider: "claude", model: "claude-opus-5", providers: [claude], reasoningEffort: "low", ultracode: false, onSetUltracode: () => {} }));
+ assert.ok(off.query('button[aria-label="Reasoning: Low"]'));
+ await off.click(off.query('button[aria-label^="Model:"]'));
+ assert.equal((off.query('[data-effort-chip="low"]') as HTMLButtonElement).disabled, false);
+ off.unmount();
+ });
+});
From 44e1f27e90036f9c37f1bc5575fd0fa01f6d3cfe Mon Sep 17 00:00:00 2001
From: Willem
Date: Sat, 7 Nov 2026 14:32:53 +0100
Subject: [PATCH 2/9] Add fresh provider context within an existing thread
---
electron/fm-suggest.js | 5 +-
electron/ipc-threads.js | 1 +
electron/preload.js | 1 +
electron/recap.js | 5 +-
electron/runner-ask-runs.js | 5 +-
electron/runner-claude-sessions.js | 2 +
electron/runner-orch-notices.js | 3 +-
electron/runner-provider-codex.js | 12 +-
electron/runner.js | 49 ++++++++
electron/services-notes.js | 7 +-
electron/services-threads.js | 17 ++-
electron/store.js | 12 ++
electron/test/claude.test.js | 20 +++
electron/test/codex.test.js | 26 ++++
electron/test/provider-switch-context.test.js | 116 ++++++++++++++++++
electron/test/recap.test.js | 11 ++
electron/webScopes.js | 1 +
src/App.tsx | 4 +
src/coder/useCoderThreadActions.ts | 13 ++
src/commandPalette.ts | 5 +
src/components/ThreadView.module.css | 8 ++
src/components/thread/messages.tsx | 4 +
src/dev/threads.ts | 17 +++
src/shared/ipc.ts | 7 ++
src/shared/ipcChannels.ts | 1 +
src/useCoder.ts | 3 +
test/threadView.test.tsx | 13 ++
27 files changed, 357 insertions(+), 11 deletions(-)
diff --git a/electron/fm-suggest.js b/electron/fm-suggest.js
index 2d872e43f..8b00fdc49 100644
--- a/electron/fm-suggest.js
+++ b/electron/fm-suggest.js
@@ -96,7 +96,8 @@ async function maybeSuggestNextPrompt(store, threadId, opts = {}) {
}
if (inFlight.has(threadId)) return null;
- const msgs = store.getMessages(threadId) || [];
+ const boundary = store.getThread(threadId)?.contextBoundaryId;
+ const msgs = store.getContextMessages(threadId) || [];
const lastUser = [...msgs]
.reverse()
.find((m) => m && m.role === "user" && String(m.text || "").trim());
@@ -116,7 +117,7 @@ async function maybeSuggestNextPrompt(store, threadId, opts = {}) {
const suggestion = sanitizeSuggestion(raw);
// A newer turn may have landed while fm ran; its own suggestion (or
// the turn-start clear) wins over this now-stale one.
- if ((store.getMessages(threadId) || []).length !== msgs.length) {
+ if (store.getThread(threadId)?.contextBoundaryId !== boundary || (store.getContextMessages(threadId) || []).length !== msgs.length) {
return null;
}
if (!store.getThread(threadId)) return null;
diff --git a/electron/ipc-threads.js b/electron/ipc-threads.js
index f0ba5d859..2a0bf186a 100644
--- a/electron/ipc-threads.js
+++ b/electron/ipc-threads.js
@@ -374,6 +374,7 @@ module.exports = {
// ⌘K "Restart agent session" (#1762): kill the warm/idle provider process
// so the next turn spawns fresh and reloads skills/plugins/MCP servers.
// Pushes its own detail.
+ "threads:freshAgentContext": async (ctx, input) => ctx.runner.freshAgentContext(input),
"threads:restartAgentSession": async (ctx, input) =>
ctx.runner.restartAgentSession(input),
"threads:setPermissionMode": async (ctx, input) => {
diff --git a/electron/preload.js b/electron/preload.js
index dc1bba23a..c0cd07453 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -230,6 +230,7 @@ const IPC_CHANNELS = Object.freeze([
{ ns: "threads", method: "pendingApprovals" },
{ ns: "threads", method: "respondPermission" },
{ ns: "threads", method: "restartAgentSession" },
+ { ns: "threads", method: "freshAgentContext" },
{ ns: "threads", method: "clearQuestion" },
{ ns: "threads", method: "answerSecret" },
{ ns: "threads", method: "setArchived" },
diff --git a/electron/recap.js b/electron/recap.js
index 91cdd974c..38d3cc4c8 100644
--- a/electron/recap.js
+++ b/electron/recap.js
@@ -106,7 +106,8 @@ function buildRecapPrompt(extractive, lastReply) {
*/
async function refreshRecap(store, threadId, opts = {}) {
try {
- const msgs = store.getMessages(threadId) || [];
+ const boundary = store.getThread(threadId)?.contextBoundaryId;
+ const msgs = store.getContextMessages(threadId) || [];
const extractive = buildExtractiveRecap(msgs);
if (!extractive) return null;
const lastReply = [...msgs]
@@ -117,7 +118,7 @@ async function refreshRecap(store, threadId, opts = {}) {
env: opts.env,
});
// A newer turn may have landed while fm ran; its own refresh wins.
- if ((store.getMessages(threadId) || []).length !== msgs.length) return null;
+ if (store.getThread(threadId)?.contextBoundaryId !== boundary || (store.getContextMessages(threadId) || []).length !== msgs.length) return null;
if (!store.getThread(threadId)) return null;
const recap = { text: (fm && fm.trim()) || extractive, at: Date.now() };
// No touch: a recap is not activity (would re-unread and re-sort).
diff --git a/electron/runner-ask-runs.js b/electron/runner-ask-runs.js
index fdacdd073..ef09b803b 100644
--- a/electron/runner-ask-runs.js
+++ b/electron/runner-ask-runs.js
@@ -94,6 +94,7 @@ function createAskRuns(ctx) {
stalledAt: null,
stoppedAt: null,
quotaWaitUntil: null,
+ freshContextPending: false,
quotaWaitResumed: input.fromQuotaWait === true,
pendingWorktree: false,
...services.clearSettledOnActivity(thread),
@@ -122,7 +123,7 @@ function createAskRuns(ctx) {
: null;
const indexNote = services.codeIndexNoteFor(index);
const matchNote = ask.formatMatchingFiles(index, prompt);
- const digestNote = ask.formatThreadDigest(store.getMessages(threadId));
+ const digestNote = ask.formatThreadDigest(store.getContextMessages(threadId));
void (async () => {
let memoryNote = "";
@@ -274,7 +275,7 @@ function createAskRuns(ctx) {
: null;
const indexNote = services.codeIndexNoteFor(index);
const matchNote = ask.formatMatchingFiles(index, card.question);
- const digestNote = ask.formatThreadDigest(store.getMessages(threadId));
+ const digestNote = ask.formatThreadDigest(store.getContextMessages(threadId));
void (async () => {
let memoryNote = "";
diff --git a/electron/runner-claude-sessions.js b/electron/runner-claude-sessions.js
index 3ec06f5bb..002889e0d 100644
--- a/electron/runner-claude-sessions.js
+++ b/electron/runner-claude-sessions.js
@@ -55,6 +55,8 @@ function createClaudeSessions(ctx) {
const sess = claudeSessions.get(threadId);
if (!sess) return;
claudeSessions.delete(threadId);
+ // An idle child can emit queued frames during kill; it no longer owns this thread.
+ sess.dispatch = { onEvent() {}, onExit() {}, onError() {} };
if (sess.idleTimer) clearTimeout(sess.idleTimer);
try {
if (sess.handle) sess.handle.kill();
diff --git a/electron/runner-orch-notices.js b/electron/runner-orch-notices.js
index 79158e7ec..7192eb254 100644
--- a/electron/runner-orch-notices.js
+++ b/electron/runner-orch-notices.js
@@ -398,7 +398,7 @@ function createOrchNotices(ctx) {
return;
}
// Held after a restart until the user resumes the crew (#1426).
- if (thread.heldNotices) return;
+ if (thread.heldNotices || thread.freshContextPending) return;
const park = parkCodexFromNotice(thread);
if (park) {
if (park.wait) {
@@ -699,6 +699,7 @@ function createOrchNotices(ctx) {
noteCodexRelease,
codexSessionHeld,
deliverNotice,
+ dropQueue,
queueOrchNotice,
flushOrchNotices,
sweepCrew,
diff --git a/electron/runner-provider-codex.js b/electron/runner-provider-codex.js
index 119e3e509..a79434b23 100644
--- a/electron/runner-provider-codex.js
+++ b/electron/runner-provider-codex.js
@@ -122,6 +122,14 @@ function createCodexRun(ctx) {
thread,
store.getUsage(threadId),
);
+ // token_count snapshots are provider-session totals. Preserve earlier
+ // sessions' thread totals when a fresh-context boundary starts a new one.
+ let usageBaseline = thread.sessionUsageBaseline || null;
+ if (thread.contextBoundaryId && !resumeId) {
+ const usage = store.getUsage(threadId);
+ usageBaseline = { inputTokens: usage?.inputTokens || 0, outputTokens: usage?.outputTokens || 0 };
+ store.updateThread(threadId, { sessionUsageBaseline: usageBaseline });
+ }
const startedFresh = Boolean(thread.sessionId) && !resumeId;
/** @type {string | null} */
let capturedSessionId = resumeId;
@@ -414,8 +422,8 @@ function createCodexRun(ctx) {
}
const nextUsage = {
model: usageInfo.model || prev.model || thread.model || null,
- inputTokens: snapshot ? inDelta : prev.inputTokens + inDelta,
- outputTokens: snapshot ? outDelta : prev.outputTokens + outDelta,
+ inputTokens: snapshot ? (usageBaseline?.inputTokens || 0) + inDelta : prev.inputTokens + inDelta,
+ outputTokens: snapshot ? (usageBaseline?.outputTokens || 0) + outDelta : prev.outputTokens + outDelta,
costUsd: prev.costUsd + costDelta,
turns: snapshot && prev.turns > 0 ? prev.turns : prev.turns + 1,
};
diff --git a/electron/runner.js b/electron/runner.js
index e8c90f985..eefbc4937 100644
--- a/electron/runner.js
+++ b/electron/runner.js
@@ -1893,6 +1893,9 @@ function createRunner(opts) {
} else if (!input.fromNotice && !isReplayTurn(input)) {
autoTurns.set(threadId, 0);
}
+ if (thread.freshContextPending && (input.fromNotice || input.fromInbound || isReplayTurn(input) || input.fromIdleCompact)) {
+ throw new Error("Fresh context is waiting for a user message");
+ }
cancelQuotaWake(threadId);
// Idle compaction (#1765) stamps idleCompactedAt to claim one idle
@@ -2243,6 +2246,7 @@ function createRunner(opts) {
interruptedBy: null,
...(machineTurn || isReplayTurn(input) ? {} : { autoResumedAt: null }),
quotaWaitUntil: null,
+ freshContextPending: false,
quotaWaitResumed: input.fromQuotaWait === true,
quotaFailoverPending: false,
quotaFailoverTried: isReplayTurn(input)
@@ -2937,6 +2941,49 @@ function createRunner(opts) {
return { restarted: true };
}
+ /** Start a new provider conversation without settling or replacing the thread. */
+ function freshAgentContext(input) {
+ const threadId = input && input.threadId;
+ const thread = threadId ? store.getThread(threadId) : null;
+ if (!thread) throw new Error(`Unknown thread: ${threadId}`);
+ if (active.has(threadId) || restoringThreads.has(threadId) || thread.status === "working" ||
+ thread.pendingPlan || thread.pendingQuestion || getPendingPermission(threadId) || thread.pendingSecret ||
+ thread.awaitingInput || thread.runQueue || thread.queued) {
+ throw new Error("Finish or stop the run and resolve pending input and queued messages before starting fresh context");
+ }
+ if (thread.teleportedTo) throw new Error("Bring the thread back to this machine before starting fresh context");
+ cancelAutoResume(threadId);
+ cancelQuotaWake(threadId);
+ for (const card of thread.btw || []) cancelBtw({ threadId, id: card.id });
+ services.clearRewindRestore(store, threadId);
+ disposeClaudeSession(threadId);
+ memorySeen.delete(threadId);
+ autoTurns.set(threadId, 0);
+ dropOrchNotices(threadId);
+ lastWorkflowByThread.delete(threadId);
+ const contextBoundaryId = appendMessage(threadId, "event",
+ "Fresh agent context. Earlier messages remain visible but are not sent to the agent.");
+ store.updateMessage(threadId, contextBoundaryId, { sessionBoundary: true });
+ store.updateThread(threadId, {
+ contextBoundaryId, freshContextPending: true,
+ sessionId: null, sessionStartModel: null, sessionUsageBaseline: null, forkSessionAt: null,
+ replayContext: false, ejected: false, recap: null, nextPromptSuggestion: null,
+ interruptedAt: null, autoResumedAt: null, quotaWaitUntil: null,
+ quotaWaitResumed: false, quotaFailoverPending: false, quotaFailoverTried: [],
+ idleCompactedAt: null, status: "idle", runStartedAt: null,
+ lastError: null, lastErrorKind: null,
+ });
+ const usage = store.getUsage(threadId);
+ if (usage) {
+ const { contextTokens, contextWindow, contextCompaction, ...totals } = usage;
+ store.setUsage(threadId, { ...totals, model: null });
+ }
+ store.setWorkflowRun(threadId, null);
+ store.saveNow();
+ pushDetail(threadId);
+ pushThreadsChanged();
+ }
+
function stopAll() {
disposeWatchdogs();
stopAllBtw();
@@ -3141,6 +3188,7 @@ function createRunner(opts) {
deliverNotice,
queueOrchNotice,
flushOrchNotices,
+ dropQueue: dropOrchNotices,
sweepCrew,
sweepDoneWorkers,
stopCrew,
@@ -3348,6 +3396,7 @@ function createRunner(opts) {
disposeClaudeSession,
retireClaudeSession,
restartAgentSession,
+ freshAgentContext,
deliverNotice,
appendInbound,
checkStalls,
diff --git a/electron/services-notes.js b/electron/services-notes.js
index b1c2563f0..093bd64c1 100644
--- a/electron/services-notes.js
+++ b/electron/services-notes.js
@@ -426,7 +426,7 @@ function buildHandoffPrefix(thread, getMessages, getThread) {
// replayContext wins: a rewound fork must digest ITS OWN retained tail,
// not walk handoffFrom again (and never a self-handoffFrom).
const sourceId =
- thread.replayContext === true ? thread.id : thread.handoffFrom;
+ thread.replayContext === true ? thread.id : thread.contextBoundaryId ? null : thread.handoffFrom;
if (sourceId == null || sourceId === "") {
return "";
}
@@ -437,6 +437,11 @@ function buildHandoffPrefix(thread, getMessages, getThread) {
return "";
}
if (!Array.isArray(msgs) || msgs.length === 0) return "";
+ const sourceThread = String(sourceId) === String(thread.id) ? thread : getThread ? getThread(String(sourceId)) : null;
+ if (sourceThread && sourceThread.contextBoundaryId) {
+ const at = msgs.findIndex((m) => m.id === sourceThread.contextBoundaryId);
+ msgs = at < 0 ? [] : msgs.slice(at + 1);
+ }
if (
!msgs.some(
diff --git a/electron/services-threads.js b/electron/services-threads.js
index 4a4530c4a..e1bf5bf8d 100644
--- a/electron/services-threads.js
+++ b/electron/services-threads.js
@@ -803,13 +803,19 @@ function forkThread(store, input) {
// Message-level fork (#158): resolve the cut before creating anything.
let prefixMsgs = null;
+ let beforeContextBoundary = false;
if (input.messageId != null) {
const msgs = store.getMessages(source.id);
const at = msgs.findIndex((m) => m && m.id === input.messageId);
if (at < 0) {
throw new Error(`Unknown message: ${input.messageId}`);
}
- prefixMsgs = msgs.slice(0, at + 1);
+ const boundaryAt = source.contextBoundaryId
+ ? msgs.findIndex((m) => m.id === source.contextBoundaryId) : -1;
+ beforeContextBoundary = Boolean(source.contextBoundaryId) && (boundaryAt < 0 || at < boundaryAt);
+ // Selecting an older visible message is deliberate retrieval. A cut in
+ // the current conversation must not silently copy the earlier session.
+ prefixMsgs = msgs.slice(boundaryAt >= 0 && at > boundaryAt ? boundaryAt + 1 : 0, at + 1);
}
const sourceTitle =
@@ -906,6 +912,7 @@ function forkThread(store, input) {
// cannot --resume the source's session, so it replays instead.
if (
cut &&
+ !beforeContextBoundary &&
!forkPatch.pendingWorktree &&
source.sessionId &&
source.provider === "claude" &&
@@ -1673,6 +1680,8 @@ async function rewindThread(store, input, opts) {
artifacts: cloneRewindValue(store.getRunArtifacts(threadId)),
sessionId: thread.sessionId != null ? thread.sessionId : null,
replayContext: thread.replayContext === true,
+ contextBoundaryId: thread.contextBoundaryId || null,
+ freshContextPending: thread.freshContextPending === true,
status: thread.status,
lastError: thread.lastError != null ? thread.lastError : null,
lastErrorKind: thread.lastErrorKind != null ? thread.lastErrorKind : null,
@@ -1681,10 +1690,16 @@ async function rewindThread(store, input, opts) {
retainedCount: at,
});
+ const boundaryAt = thread.contextBoundaryId
+ ? msgs.findIndex((m) => m.id === thread.contextBoundaryId) : -1;
+ const selectsEarlierContext = Boolean(thread.contextBoundaryId) && (boundaryAt < 0 || at <= boundaryAt);
const droppedMessages = store.truncateFromMessage(threadId, messageId);
const updated = store.updateThread(threadId, {
sessionId: null,
replayContext: true,
+ // An explicit edit of an older message selects that earlier prefix.
+ // Ordinary rewinds inside the current conversation keep the divider.
+ ...(selectsEarlierContext ? { contextBoundaryId: null, freshContextPending: false } : {}),
});
let restoredSha = null;
diff --git a/electron/store.js b/electron/store.js
index 62c860759..802753c04 100644
--- a/electron/store.js
+++ b/electron/store.js
@@ -2084,6 +2084,16 @@ class Store {
return list || [];
}
+ /** Visible history stays intact; provider digests only see the current session. */
+ getContextMessages(threadId) {
+ const messages = this.getMessages(threadId);
+ const boundary = this.getThread(threadId)?.contextBoundaryId;
+ if (!boundary) return messages;
+ const at = messages.findIndex((message) => message.id === boundary);
+ // Missing boundary (e.g. an old rewind) must never expose earlier history.
+ return at < 0 ? [] : messages.slice(at + 1);
+ }
+
/**
* A page of a thread's messages, newest-first page ending at `before`
* (exclusive) or at the end of the transcript (issue #1786). Hydration
@@ -2351,6 +2361,8 @@ class Store {
this.updateThread(threadId, {
sessionId: snap.sessionId != null ? snap.sessionId : null,
replayContext: snap.replayContext === true,
+ contextBoundaryId: snap.contextBoundaryId || null,
+ freshContextPending: snap.freshContextPending === true,
...(snap.status != null ? { status: snap.status } : {}),
lastError: snap.lastError != null ? snap.lastError : null,
lastErrorKind: snap.lastErrorKind != null ? snap.lastErrorKind : null,
diff --git a/electron/test/claude.test.js b/electron/test/claude.test.js
index beca4275c..5944d5746 100644
--- a/electron/test/claude.test.js
+++ b/electron/test/claude.test.js
@@ -2879,6 +2879,26 @@ describe("runner claude provider", () => {
assert.equal(argv[resumeAt + 1], "sess-multi");
});
+ it("Fresh agent context disposes the warm process and starts without resume (#1873)", async () => {
+ process.env.CODER_FAKE_CLAUDE_SCENARIO = "multi-turn";
+ const markerDir = path.join(tmpDir, "fresh-context-markers");
+ process.env.CODER_FAKE_CLAUDE_MARKER_DIR = markerDir;
+ const thread = store.getThreads()[0];
+ await runner.startRun({ threadId: thread.id, prompt: "old conversation" });
+ await waitFor(() => store.getThread(thread.id).status === "done");
+ assert.equal(store.getThread(thread.id).sessionId, "sess-multi");
+ runner.freshAgentContext({ threadId: thread.id });
+ assert.equal(store.getThread(thread.id).sessionId, null);
+ const boundary = store.getThread(thread.id).contextBoundaryId;
+ await runner.startRun({ threadId: thread.id, prompt: "new conversation" });
+ await waitFor(() => store.getThread(thread.id).status === "done");
+ assert.equal(fs.readFileSync(path.join(markerDir, "spawns"), "utf8").trim().split("\n").length, 2);
+ assert.ok(!JSON.parse(fs.readFileSync(argvFile, "utf8")).includes("--resume"));
+ assert.equal(store.getThread(thread.id).contextBoundaryId, boundary);
+ assert.ok(store.getMessages(thread.id).some(m => m.text === "old conversation"));
+ assert.ok(store.getContextMessages(thread.id).every(m => m.text !== "old conversation"));
+ });
+
describe("background subagents pin their idle CLI (#1443)", () => {
const keys = [
"CODER_FAKE_CLAUDE_RELEASE_FILE",
diff --git a/electron/test/codex.test.js b/electron/test/codex.test.js
index e671142da..e40255e5f 100644
--- a/electron/test/codex.test.js
+++ b/electron/test/codex.test.js
@@ -663,6 +663,32 @@ describe("runner codex provider", () => {
assert.match(turnPrompt(rpc), /codex please/);
});
+ it("fresh context starts a new app-server thread and resumes only its new identity (#1873)", async () => {
+ process.env.CODER_FAKE_CODEX_SCENARIO = "success";
+ const thread = store.getThreads()[0];
+ store.setMessages(thread.id, [{ id: "old-user", role: "user", text: "OLD PROVIDER HISTORY", createdAt: 1 },
+ { id: "old-agent", role: "assistant", text: "OLD AGENT ANSWER", createdAt: 2 }]);
+ store.updateThread(thread.id, { sessionId: "old-rollout", replayContext: true });
+ store.setUsage(thread.id, { model: null, inputTokens: 100, outputTokens: 20, costUsd: 4, turns: 2, contextTokens: 5000 });
+ runner.freshAgentContext({ threadId: thread.id });
+ await runner.startRun({ threadId: thread.id, prompt: "new request" });
+ await waitFor(() => store.getThread(thread.id).status === "done");
+ const rpc = readRpc(rpcFile);
+ assert.ok(rpc.some(m => m.method === "thread/start"));
+ assert.ok(!rpc.some(m => m.method === "thread/resume"));
+ assert.doesNotMatch(turnPrompt(rpc), /OLD PROVIDER HISTORY|OLD AGENT ANSWER/);
+ assert.equal(store.getThread(thread.id).sessionId, "codex-sess-001");
+ assert.equal(store.getUsage(thread.id).inputTokens, 130);
+ assert.equal(store.getUsage(thread.id).outputTokens, 32);
+ assert.equal(store.getUsage(thread.id).costUsd, 4);
+ store.saveNow();
+ assert.equal(new Store(path.join(tmpDir, "store.json")).getThread(thread.id).sessionId, "codex-sess-001");
+ fs.unlinkSync(rpcFile);
+ await runner.startRun({ threadId: thread.id, prompt: "continue new request" });
+ await waitFor(() => store.getThread(thread.id).status === "done");
+ assert.equal(readRpc(rpcFile).find(m => m.method === "thread/resume").params.threadId, "codex-sess-001");
+ });
+
it("keeps the root sessionId when a v2 sub-agent emits thread/started", async () => {
process.env.CODER_FAKE_CODEX_SCENARIO = "child-spawn";
const thread = store.getThreads()[0];
diff --git a/electron/test/provider-switch-context.test.js b/electron/test/provider-switch-context.test.js
index 73aa34f1b..1e5e03dd3 100644
--- a/electron/test/provider-switch-context.test.js
+++ b/electron/test/provider-switch-context.test.js
@@ -130,6 +130,122 @@ describe("provider switch keeps context (#1493)", () => {
return store.getThread(thread.id);
}
+ it("fresh context retains visible history and identity across reload without old handoff", async () => {
+ const thread = threadWithHistory();
+ store.updateThread(thread.id, { handoffFrom: "old-source", replayContext: true,
+ forkSessionAt: "old-cut", interruptedAt: Date.now(), quotaFailoverPending: true,
+ recap: { text: "OLD RECAP", at: 1 } });
+ store.setUsage(thread.id, { model: "old", inputTokens: 100, outputTokens: 20,
+ costUsd: 3, turns: 2, contextTokens: 9000, contextWindow: 10000, contextCompaction: {} });
+ const before = store.getMessages(thread.id).slice();
+ runner.freshAgentContext({ threadId: thread.id });
+ const fresh = store.getThread(thread.id);
+ assert.equal(fresh.sessionId, null);
+ for (const field of ["projectId", "provider", "providerInstance", "model", "permissionMode", "reasoningEffort", "worktreePath", "branch", "settledOverride"]) {
+ assert.deepEqual(fresh[field], thread[field], `${field} is preserved`);
+ }
+ assert.equal(fresh.forkSessionAt, null);
+ assert.equal(fresh.replayContext, false);
+ assert.equal(fresh.interruptedAt, null);
+ assert.equal(fresh.quotaFailoverPending, false);
+ assert.equal(fresh.handoffFrom, "old-source", "orchestration provenance survives");
+ assert.deepEqual(store.getMessages(thread.id).slice(0, before.length), before);
+ assert.deepEqual(store.getContextMessages(thread.id), []);
+ assert.deepEqual(store.getUsage(thread.id), { model: null, inputTokens: 100, outputTokens: 20, costUsd: 3, turns: 2 });
+ const reloaded = new Store(path.join(tmpDir, "store.json"));
+ assert.equal(reloaded.getThread(thread.id).contextBoundaryId, fresh.contextBoundaryId);
+ assert.equal(reloaded.getMessages(thread.id).at(-1).sessionBoundary, true);
+ assert.deepEqual(reloaded.getContextMessages(thread.id), []);
+ await assert.rejects(runner.startRun({ threadId: thread.id, prompt: "old automatic continuation", fromNotice: true }), /waiting for a user/);
+ process.env.CODER_AGENT_CMD = agentCmd(logFile);
+ await runner.startRun({ threadId: thread.id, prompt: "new task" });
+ await waitFor(() => !runner.isRunning(thread.id));
+ assert.doesNotMatch(readPrompts(logFile)[0], /PARSER HALF DONE|refactor the parser|OLD RECAP|Hand-off context/);
+ assert.equal(store.getThread(thread.id).freshContextPending, false);
+ services.setProvider(store, { threadId: thread.id, provider: "grok" });
+ store.updateThread(thread.id, { replayContext: true });
+ const prefix = services.buildHandoffPrefix(store.getThread(thread.id), id => store.getMessages(id), id => store.getThread(id));
+ assert.match(prefix, /new task/);
+ assert.doesNotMatch(prefix, /PARSER HALF DONE|refactor the parser|OLD RECAP/);
+ const fork = services.forkThread(store, { threadId: thread.id, messageId: store.getContextMessages(thread.id).find(m => m.role === "assistant").id });
+ assert.doesNotMatch(JSON.stringify(store.getMessages(fork.id)), /PARSER HALF DONE|refactor the parser/);
+ const earlierFork = services.forkThread(store, { threadId: thread.id, messageId: "a1" });
+ assert.match(JSON.stringify(store.getMessages(earlierFork.id)), /PARSER HALF DONE/);
+ assert.equal(earlierFork.sessionId, null, "an explicit older cut cannot resume the new session");
+ });
+
+ it("explicit rewind can select earlier context and undo restores the fresh boundary", async () => {
+ const thread = threadWithHistory();
+ runner.freshAgentContext({ threadId: thread.id });
+ const boundary = store.getThread(thread.id).contextBoundaryId;
+ store.appendMessage(thread.id, { id: "new-user", role: "user", text: "NEW REQUEST", createdAt: Date.now() });
+ await services.rewindThread(store, { threadId: thread.id, messageId: "new-user", prompt: "edited" });
+ assert.equal(store.getThread(thread.id).contextBoundaryId, boundary);
+ assert.doesNotMatch(services.buildHandoffPrefix(store.getThread(thread.id), id => store.getMessages(id)), /PARSER HALF DONE/);
+ await services.rewindThread(store, { threadId: thread.id, undo: true });
+ await services.rewindThread(store, { threadId: thread.id, messageId: "u1", prompt: "older edit" });
+ assert.equal(store.getThread(thread.id).contextBoundaryId, null);
+ assert.equal(store.getThread(thread.id).freshContextPending, false);
+ assert.equal(store.getThread(thread.id).sessionId, null);
+ await services.rewindThread(store, { threadId: thread.id, undo: true });
+ assert.equal(store.getThread(thread.id).contextBoundaryId, boundary);
+ assert.equal(store.getThread(thread.id).freshContextPending, true);
+ assert.deepEqual(store.getContextMessages(thread.id).map(m => m.id), ["new-user"]);
+ store.saveNow();
+ assert.equal(new Store(path.join(tmpDir, "store.json")).getThread(thread.id).contextBoundaryId, boundary);
+ });
+
+ it("Ask retrieval keeps project memory but excludes earlier transcript from the prompt", async () => {
+ const thread = threadWithHistory();
+ store.updateThread(thread.id, { ask: true });
+ runner.stopAll();
+ let outbound;
+ runner = createRunner({ store, core: await loadCore(), pushFn() {},
+ askComplete: async ({ prompt }) => { outbound = prompt; return { text: "fresh answer", source: "print" }; },
+ searchMemory: async () => [{ title: "Project rule", body: "PROJECT MEMORY RETAINED", type: "convention" }],
+ });
+ runner.freshAgentContext({ threadId: thread.id });
+ await runner.startRun({ threadId: thread.id, prompt: "new question" });
+ await waitFor(() => !runner.isRunning(thread.id));
+ assert.match(outbound, /new question/);
+ assert.match(outbound, /PROJECT MEMORY RETAINED/);
+ assert.doesNotMatch(outbound, /PARSER HALF DONE|refactor the parser/);
+ assert.equal(store.getThread(thread.id).freshContextPending, false);
+ });
+
+ it("disposed warm Claude callbacks cannot reattach session identity or usage", () => {
+ const { createClaudeSessions } = require("../runner-claude-sessions.js");
+ const thread = threadWithHistory();
+ const sessions = createClaudeSessions({ active: new Map(), store, finishRunningSubagents() {} });
+ let killed = false;
+ const session = { idleTimer: null, handle: { kill() { killed = true; } }, dispatch: {
+ onEvent() { store.updateThread(thread.id, { sessionId: "stale-session" }); },
+ onExit() { store.setUsage(thread.id, { contextTokens: 999 }); },
+ onError() { throw new Error("stale callback"); },
+ } };
+ sessions.claudeSessions.set(thread.id, session);
+ const lateEvent = () => session.dispatch.onEvent({});
+ sessions.disposeClaudeSession(thread.id);
+ store.updateThread(thread.id, { sessionId: null });
+ lateEvent();
+ session.dispatch.onExit({});
+ session.dispatch.onError(new Error("old process"));
+ assert.equal(killed, true);
+ assert.equal(store.getThread(thread.id).sessionId, null);
+ assert.equal(store.getUsage(thread.id), null);
+ });
+
+ it("fresh context refuses pending questions and queued work without mutating history", () => {
+ const thread = threadWithHistory();
+ for (const patch of [{ pendingQuestion: { id: "question" } }, { queued: { prompt: "later" } }, { status: "working" }]) {
+ store.updateThread(thread.id, patch);
+ assert.throws(() => runner.freshAgentContext({ threadId: thread.id }), /Finish or stop/);
+ assert.equal(store.getThread(thread.id).sessionId, "sess-claude");
+ assert.equal(store.getMessages(thread.id).length, 2);
+ store.updateThread(thread.id, { pendingQuestion: null, queued: null, status: "idle" });
+ }
+ });
+
it("manual switch: next run's prompt carries the thread's own tail", async () => {
const thread = threadWithHistory();
const switched = services.setProvider(store, {
diff --git a/electron/test/recap.test.js b/electron/test/recap.test.js
index 47c13c0bb..8b2035919 100644
--- a/electron/test/recap.test.js
+++ b/electron/test/recap.test.js
@@ -97,6 +97,17 @@ describe("thread recap", () => {
assert.equal(store.getThread(thread.id).recap, undefined);
});
+ it("drops a recap computed before a fresh-context boundary", async () => {
+ const recap = await refreshRecap(store, thread.id, { fmRun: async () => {
+ store.appendMessage(thread.id, { id: "fresh-boundary", role: "event", text: "Fresh context", createdAt: 99 });
+ store.updateThread(thread.id, { contextBoundaryId: "fresh-boundary", recap: null });
+ return "STALE RECAP";
+ } });
+ assert.equal(recap, null);
+ assert.equal(store.getThread(thread.id).recap, null);
+ assert.deepEqual(store.getContextMessages(thread.id), []);
+ });
+
it("threads.get shows the recap only after an idle gap", async () => {
await refreshRecap(store, thread.id, { fmRun: noFm });
store.updateThread(thread.id, { lastVisitedAt: Date.now() - 60_000 });
diff --git a/electron/webScopes.js b/electron/webScopes.js
index fa4f95157..6da8d6c8c 100644
--- a/electron/webScopes.js
+++ b/electron/webScopes.js
@@ -143,6 +143,7 @@ const BY_SCOPE = {
"threads:rewind",
"threads:respondPermission",
"threads:restartAgentSession",
+ "threads:freshAgentContext",
"threads:clearQuestion",
"threads:setArchived",
"threads:setSettled",
diff --git a/src/App.tsx b/src/App.tsx
index 009dba365..077d39309 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -259,6 +259,7 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {})
respondPermission,
redirectPermission,
restartAgentSession,
+ freshAgentContext,
clearQuestion,
answerSecret,
setProvider,
@@ -1869,6 +1870,8 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {})
else if (id === "stop-all-threads") openStopAllConfirm();
else if (id === "reopen-pane") {
window.dispatchEvent(new Event(REOPEN_PANE_EVENT));
+ } else if (id === "fresh-agent-context") {
+ void freshAgentContext().catch(() => {});
} else if (id === "restart-agent-session") {
void restartAgentSession().catch(() => {});
}
@@ -1889,6 +1892,7 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {})
toggleAgents,
openStopAllConfirm,
restartAgentSession,
+ freshAgentContext,
],
);
diff --git a/src/coder/useCoderThreadActions.ts b/src/coder/useCoderThreadActions.ts
index f529bbfe9..9e0aaf31b 100644
--- a/src/coder/useCoderThreadActions.ts
+++ b/src/coder/useCoderThreadActions.ts
@@ -193,6 +193,18 @@ export function useCoderThreadActions({
}
}, [api, selectedThreadId]);
+ const freshAgentContext = useCallback(async () => {
+ if (!selectedThreadId) return;
+ const threadId = selectedThreadId;
+ try {
+ await api.threads.freshAgentContext({ threadId });
+ setError(null);
+ } catch (err) {
+ setError({ scope: "run", message: errorMessage(err) });
+ throw err;
+ }
+ }, [api, selectedThreadId]);
+
/**
* Dismiss the persisted question card (issue #647). Answering it is an
* ordinary send, which clears the card in the main process — only the
@@ -1261,6 +1273,7 @@ export function useCoderThreadActions({
respondPermission,
redirectPermission,
restartAgentSession,
+ freshAgentContext,
clearQuestion,
answerSecret,
setProvider,
diff --git a/src/commandPalette.ts b/src/commandPalette.ts
index 304d9b69a..7c2f28d98 100644
--- a/src/commandPalette.ts
+++ b/src/commandPalette.ts
@@ -239,6 +239,11 @@ export const PALETTE_ACTIONS: readonly PaletteAction[] = [
binding: "pane.reopen",
keywords: "restore undo close terminal git",
},
+ {
+ id: "fresh-agent-context",
+ title: "Start fresh agent context",
+ keywords: "new conversation reset context keep transcript worktree",
+ },
{
id: "restart-agent-session",
title: "Restart agent session",
diff --git a/src/components/ThreadView.module.css b/src/components/ThreadView.module.css
index 526af3992..f0af73a60 100644
--- a/src/components/ThreadView.module.css
+++ b/src/components/ThreadView.module.css
@@ -5398,3 +5398,11 @@ button.heroProject:hover {
+ :is(.settledRow:has(> .eventLine), .toolGroup) {
margin-top: -18px;
}
+
+.sessionBoundary {
+ border-block: 1px solid var(--border);
+ padding: 12px 0;
+ margin: 16px 0;
+ color: var(--text-muted);
+ font-size: 12px;
+}
diff --git a/src/components/thread/messages.tsx b/src/components/thread/messages.tsx
index cc430e84a..160227dcb 100644
--- a/src/components/thread/messages.tsx
+++ b/src/components/thread/messages.tsx
@@ -2020,6 +2020,10 @@ export const MessageBlock = memo(function MessageBlock({
);
}
+ if (message.sessionBoundary) {
+ return ;
+ }
+
if (message.role === "event") {
// Quiet one-line step (#1429). Only the subagent kickoff folds its
// phase list behind Show; any other notice body (failures, "Not
diff --git a/src/dev/threads.ts b/src/dev/threads.ts
index cef194847..e7bb7ac5a 100644
--- a/src/dev/threads.ts
+++ b/src/dev/threads.ts
@@ -487,6 +487,23 @@ export function createThreads(ctx: DevCtx): Pick {
// Dev threads never spawn, so the value is simply dropped.
patchThread(input.threadId, { pendingSecret: null });
},
+ async freshAgentContext(input) {
+ const detail = details.get(input.threadId);
+ if (!detail) throw new Error(`Thread not found: ${input.threadId}`);
+ if (detail.thread.status === "working" || detail.thread.pendingQuestion || detail.thread.pendingPlan || detail.thread.pendingSecret || detail.pendingPermission || detail.thread.awaitingInput || detail.thread.runQueue || detail.thread.queued) {
+ throw new Error("Finish the run and resolve pending input before starting fresh context");
+ }
+ const boundary = id("evt");
+ detail.messages.push({ id: boundary, role: "event", sessionBoundary: true,
+ text: "Fresh agent context. Earlier messages remain visible but are not sent to the agent.", createdAt: now() });
+ patchThread(input.threadId, { contextBoundaryId: boundary, freshContextPending: true,
+ sessionId: null, replayContext: false, recap: null });
+ if (detail.usage) {
+ const { contextTokens, contextWindow, contextCompaction, ...totals } = detail.usage;
+ detail.usage = { ...totals, model: null };
+ }
+ emitDetail(detail);
+ },
async restartAgentSession(input) {
const detail = details.get(input.threadId);
if (!detail) throw new Error(`Thread not found: ${input.threadId}`);
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts
index e03976c67..a9d787053 100644
--- a/src/shared/ipc.ts
+++ b/src/shared/ipc.ts
@@ -1155,6 +1155,9 @@ export interface ThreadInfo {
model: string | null;
/** Provider session id, persisted after the first turn so follow-ups resume context. */
sessionId: string | null;
+ /** Last fresh-context divider; earlier visible history is excluded from provider context. */
+ contextBoundaryId?: string | null;
+ freshContextPending?: boolean;
/**
* One-shot context replay (issue #254). Set by `threads.rewind`, which
* clears sessionId because a CLI session cannot be rewound: the next turn
@@ -1741,6 +1744,8 @@ export interface AttachmentInfo {
export type CrossThreadInbound = "accept" | "queue-only" | "refuse";
export interface ChatMessage {
+ /** Persistent divider marking a new provider conversation. */
+ sessionBoundary?: boolean;
id: string;
role: "user" | "assistant" | "event" | "tool";
/** For role "tool" this is a one-line summary, e.g. "Bash: npm test". */
@@ -6292,6 +6297,8 @@ export interface CoderApi {
* event message) arrives via thread:updated.
*/
restartAgentSession(input: { threadId: string }): Promise;
+ /** New provider conversation in this thread; retains transcript, worktree and historical spend. */
+ freshAgentContext(input: { threadId: string }): Promise;
/**
* "Save plan to file" (#1501): write plan markdown to
* docs/plans/-.md in the thread's checkout. Never
diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts
index 7b88082d4..dc3c7ea98 100644
--- a/src/shared/ipcChannels.ts
+++ b/src/shared/ipcChannels.ts
@@ -184,6 +184,7 @@ export const IPC_CHANNELS = [
{ ns: "threads", method: "pendingApprovals" },
{ ns: "threads", method: "respondPermission" },
{ ns: "threads", method: "restartAgentSession" },
+ { ns: "threads", method: "freshAgentContext" },
{ ns: "threads", method: "clearQuestion" },
{ ns: "threads", method: "answerSecret" },
{ ns: "threads", method: "setArchived" },
diff --git a/src/useCoder.ts b/src/useCoder.ts
index 609451a58..1e97eb6e6 100644
--- a/src/useCoder.ts
+++ b/src/useCoder.ts
@@ -397,6 +397,7 @@ export interface UseCoderResult {
* while a run is active.
*/
restartAgentSession: () => Promise;
+ freshAgentContext: () => Promise;
/** Write plan markdown into the thread's checkout; resolves the path (#1501). */
savePlan: (plan: string, threadId?: string) => Promise;
/** Dismiss the selected thread's persisted question card (issue #647). */
@@ -1900,6 +1901,7 @@ export function useCoder(): UseCoderResult {
respondPermission,
redirectPermission,
restartAgentSession,
+ freshAgentContext,
clearQuestion,
answerSecret,
setProvider,
@@ -2518,6 +2520,7 @@ export function useCoder(): UseCoderResult {
respondPermission,
redirectPermission,
restartAgentSession,
+ freshAgentContext,
clearQuestion,
answerSecret,
setProvider,
diff --git a/test/threadView.test.tsx b/test/threadView.test.tsx
index d65824eff..4a62d79ee 100644
--- a/test/threadView.test.tsx
+++ b/test/threadView.test.tsx
@@ -114,6 +114,7 @@ function msg(over: Partial & Pick): C
fromThread: over.fromThread,
attachments: over.attachments,
thinking: over.thinking,
+ sessionBoundary: over.sessionBoundary,
steer: over.steer,
pdfText: over.pdfText,
fromNotice: over.fromNotice,
@@ -634,6 +635,18 @@ describe("ThreadView message roles", () => {
);
});
+ it("renders a durable fresh-context divider while keeping the earlier transcript", () => {
+ const html = render({ detail: detail({ messages: [
+ msg({ id: "before", role: "assistant", text: "Earlier conversation", createdAt: 4 }),
+ msg({ id: "boundary", role: "event", sessionBoundary: true, text: "Fresh agent context", createdAt: 5 }),
+ msg({ id: "after", role: "user", text: "New conversation", createdAt: 6 }),
+ ] }) });
+ assert.match(html, /role="separator"/);
+ assert.match(html, /aria-label="Fresh agent context"/);
+ assert.match(html, /Earlier conversation/);
+ assert.match(html, /New conversation/);
+ });
+
it("renders event messages as event lines, not as cards or user bubbles", () => {
const html = render({
detail: detail({
From b6c10848ab56b201a3479fdba6d7ab61869479a6 Mon Sep 17 00:00:00 2001
From: Willem
Date: Sat, 7 Nov 2026 14:33:43 +0100
Subject: [PATCH 3/9] Probe Ultracode support through portable CLI spawning
---
electron/providers.js | 9 +++++++--
electron/test/reasoning-effort.test.js | 2 +-
electron/test/ultracode.test.js | 15 ++++++++++++---
3 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/electron/providers.js b/electron/providers.js
index b31497049..ddd9e7da0 100644
--- a/electron/providers.js
+++ b/electron/providers.js
@@ -3,6 +3,7 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFile, execFileSync } = require("node:child_process");
+const spawn = require("cross-spawn");
const catalogDivergence = require("./catalogDivergence.js");
const { posixQuote } = require("./ssh.js");
@@ -200,8 +201,12 @@ function cliSupportsUltracode(binary, env = process.env) {
const stat = fs.statSync(resolved);
const key = `${resolved}:${stat.mtimeMs}:${stat.size}`;
if (!ultracodeVersionCache.has(key)) {
- const output = execFileSync(resolved, ["--version"], { encoding: "utf8", timeout: 2000, env, stdio: ["ignore", "pipe", "ignore"] });
- const version = output.match(/(\d+\.\d+\.\d+)/)?.[1];
+ // Match agent launches: Windows npm installs expose claude.cmd and
+ // development fixtures may be shebang scripts, both need cross-spawn.
+ const result = spawn.sync(resolved, ["--version"], { encoding: "utf8", timeout: 2000, env, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
+ const version = !result.error && result.status === 0
+ ? String(result.stdout || "").match(/(\d+\.\d+\.\d+)/)?.[1]
+ : undefined;
ultracodeVersionCache.set(key, Boolean(version && !catalogDivergence.versionBelow(version, "2.1.219")));
}
return ultracodeVersionCache.get(key);
diff --git a/electron/test/reasoning-effort.test.js b/electron/test/reasoning-effort.test.js
index 584e6adc4..bb9bb87d0 100644
--- a/electron/test/reasoning-effort.test.js
+++ b/electron/test/reasoning-effort.test.js
@@ -1072,7 +1072,7 @@ describe("reasoning effort: IPC seam + runner wiring", () => {
}
});
- it("runner passes independent Ultracode into claude launch and restores effort on next launch", { skip: process.platform === "win32" }, async () => {
+ it("runner passes independent Ultracode into claude launch and restores effort on next launch", async () => {
// The claude-stream call site serves claude AND grok, and claude answers a
// missing or unknown effort with a warning and its default. So this is the
// path where losing the argument is invisible, and it had no coverage.
diff --git a/electron/test/ultracode.test.js b/electron/test/ultracode.test.js
index 7cce9f5bf..dafb0cd55 100644
--- a/electron/test/ultracode.test.js
+++ b/electron/test/ultracode.test.js
@@ -6,6 +6,7 @@ const path = require('node:path');
const { getProvider, modelSupportsUltracode, cliSupportsUltracode } = require('../providers');
const { Store } = require('../store');
const services = require('../services');
+const { writeFakeBin } = require('./support/fakeBin');
describe('Ultracode independent session mode', () => {
it('emits the native session setting at xhigh, retaining ordinary effort when off', () => {
@@ -28,17 +29,25 @@ describe('Ultracode independent session mode', () => {
}
});
- it('gates actual binary versions without starting an agent', { skip: process.platform === 'win32' }, () => {
+ it('gates actual binary versions without starting an agent', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ultracode-version-'));
try {
for (const [version, expected] of [['2.1.218', false], ['2.1.219', true], ['2.2.0', true], ['unknown', false]]) {
- const bin = path.join(dir, version);
- fs.writeFileSync(bin, `#!/bin/sh\n[ "$1" = "--version" ] || exit 12\nprintf '%s\\n' '${version} (Claude Code)'\n`, { mode: 0o755 });
+ const bin = writeFakeBin(path.join(dir, version), `if (!process.argv.includes('--version')) process.exit(12); console.log('${version} (Claude Code)');`);
assert.equal(cliSupportsUltracode(bin), expected, version);
}
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
+ it('probes Windows npm cmd shims in paths containing spaces', { skip: process.platform !== 'win32' }, () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ultracode cmd '));
+ try {
+ const bin = path.join(dir, 'claude.cmd');
+ fs.writeFileSync(bin, '@echo 2.1.219 (Claude Code)\r\n');
+ assert.equal(cliSupportsUltracode(bin), true);
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+ });
+
it('migrates legacy rows safely and persists independent effort through toggles and reload', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ultracode-store-'));
try {
From bf52a8e8e1d445759299a06dcf275b973e741d28 Mon Sep 17 00:00:00 2001
From: Willem
Date: Sat, 7 Nov 2026 14:36:18 +0100
Subject: [PATCH 4/9] Migrate legacy Ultracode defaults and honor named
accounts
---
electron/projectCommands.js | 13 +++++--
electron/test/project-thread-defaults.test.js | 35 ++++++++++++++++++-
src/components/Composer.tsx | 4 +--
src/components/settings/AgentsPane.tsx | 4 +--
test/composer.test.tsx | 8 +++--
test/settingsModal.test.tsx | 28 +++++++++++++++
6 files changed, 83 insertions(+), 9 deletions(-)
diff --git a/electron/projectCommands.js b/electron/projectCommands.js
index 065beb6c5..219dc858e 100644
--- a/electron/projectCommands.js
+++ b/electron/projectCommands.js
@@ -121,8 +121,17 @@ function normalizeThreadDefaults(raw) {
if (model) out.model = model;
}
const effort = str(r.reasoningEffort, 32);
- if (effort) out.reasoningEffort = effort;
- if (r.ultracode === true) out.ultracode = true;
+ if (effort === "ultracode") {
+ // Legacy project/last-used defaults stored the session mode as effort.
+ const { getProvider, modelSupportsUltracode } = require("./providers.js");
+ if (modelSupportsUltracode(getProvider(provider.split(":")[0]), out.model)) {
+ out.reasoningEffort = "xhigh";
+ out.ultracode = true;
+ }
+ } else {
+ if (effort) out.reasoningEffort = effort;
+ if (r.ultracode === true) out.ultracode = true;
+ }
if (typeof r.permissionMode === "string" && PERMISSION_MODES.has(r.permissionMode)) {
out.permissionMode = r.permissionMode;
}
diff --git a/electron/test/project-thread-defaults.test.js b/electron/test/project-thread-defaults.test.js
index 8c8ea1bad..bf182f5f0 100644
--- a/electron/test/project-thread-defaults.test.js
+++ b/electron/test/project-thread-defaults.test.js
@@ -15,7 +15,7 @@ const { Store } = require("../store");
const services = require("../services");
const { normalizeThreadDefaults } = require("../projectCommands.js");
-const BIN_ENVS = ["CODER_GROK_BIN", "CODER_KIMI_BIN"];
+const BIN_ENVS = ["CODER_GROK_BIN", "CODER_KIMI_BIN", "CODER_CLAUDE_BIN", "CODER_CODEX_BIN"];
describe("project thread defaults (#1501)", () => {
let tmpDir;
@@ -81,6 +81,39 @@ describe("project thread defaults (#1501)", () => {
assert.equal(cleared.threadDefaults, undefined);
});
+ it("migrates legacy project and last-used Ultracode defaults before creating threads", () => {
+ install("CODER_CLAUDE_BIN");
+ install("CODER_CODEX_BIN");
+ for (const lastUsed of [false, true]) {
+ for (const [provider, model, enabled] of [
+ ["claude", "claude-opus-5", true],
+ ["claude", "claude-haiku-4-5", false],
+ ["codex", "gpt-6-astra", false],
+ ]) {
+ // Bypass the setter to reproduce a persisted pre-migration store.
+ store.setProjects(store.getProjects().map((p) => p.id === project.id ? {
+ ...p,
+ threadDefaults: { provider, model, reasoningEffort: "ultracode", ...(lastUsed ? { lastUsed: true } : {}) },
+ } : p));
+ store.saveNow();
+ const reloaded = new Store(path.join(tmpDir, "store.json"));
+ const defaults = reloaded.getProject(project.id).threadDefaults;
+ assert.equal(defaults.ultracode === true, enabled);
+ assert.equal(defaults.reasoningEffort, enabled ? "xhigh" : undefined);
+ assert.equal(defaults.lastUsed === true, lastUsed);
+ const thread = services.createThread(reloaded, { projectId: project.id, title: "Migrated defaults" });
+ assert.equal(thread.ultracode, enabled);
+ assert.equal(thread.reasoningEffort, enabled ? "xhigh" : null);
+ if (enabled) {
+ services.setUltracode(reloaded, { threadId: thread.id, ultracode: false });
+ assert.equal(reloaded.getThread(thread.id).reasoningEffort, "xhigh");
+ }
+ reloaded.saveNow();
+ assert.equal(new Store(path.join(tmpDir, "store.json")).getThread(thread.id).ultracode, false);
+ }
+ }
+ });
+
it("applies all four fields to a new thread", () => {
setDefaults({
provider: "grok",
diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx
index ed1c02a4c..8f7bcd4e1 100644
--- a/src/components/Composer.tsx
+++ b/src/components/Composer.tsx
@@ -1078,7 +1078,7 @@ export const Composer = memo(function Composer({
// the highlighted picker row.
const efforts = effortsForModel(currentProviderInfo, model);
const reasoningVisible = showReasoningControl(efforts);
- const ultracodeRequested = ultracode && currentProviderInfo?.id === "claude" && efforts.includes("xhigh");
+ const ultracodeRequested = ultracode && (currentProviderInfo?.baseProvider ?? currentProviderInfo?.id) === "claude" && efforts.includes("xhigh");
const effortUnavailable = currentProviderInfo?.available === false || ultracodeRequested;
const effortLabel = ultracodeRequested ? "Ultracode requested (xhigh)" : effortDisplayLabel(reasoningEffort);
const fastAvailable = Boolean(
@@ -1087,7 +1087,7 @@ export const Composer = memo(function Composer({
currentProviderInfo?.modelInfo?.find((m) => m.id === model)?.fast,
);
const fastOn = fast && fastAvailable;
- const ultracodeAvailable = Boolean(onSetUltracode && currentProviderInfo?.id === "claude" && (currentProviderInfo.supportsUltracode === true || ultracode) && currentProviderInfo.available !== false && currentProviderInfo.modelInfo?.find((m) => m.id === model)?.efforts?.includes("xhigh"));
+ const ultracodeAvailable = Boolean(onSetUltracode && (currentProviderInfo?.baseProvider ?? currentProviderInfo?.id) === "claude" && (currentProviderInfo?.supportsUltracode === true || ultracode) && currentProviderInfo?.available !== false && currentProviderInfo?.modelInfo?.find((m) => m.id === model)?.efforts?.includes("xhigh"));
const honouredModes = providerPermissionModes(currentProviderInfo);
const currentModeHonoured = permissionModeHonoured(
permissionMode,
diff --git a/src/components/settings/AgentsPane.tsx b/src/components/settings/AgentsPane.tsx
index 92d21ce4d..555dfec29 100644
--- a/src/components/settings/AgentsPane.tsx
+++ b/src/components/settings/AgentsPane.tsx
@@ -221,7 +221,7 @@ export function AgentsPane({
provider: draft.provider,
model,
reasoningEffort,
- ultracode: draft.ultracode === true && selected?.id === "claude" && Boolean(selected.modelInfo.find((m) => m.id === model)?.efforts?.includes("xhigh")),
+ ultracode: draft.ultracode === true && (selected?.baseProvider ?? selected?.id) === "claude" && Boolean(selected?.modelInfo.find((m) => m.id === model)?.efforts?.includes("xhigh")),
permissionMode: snapToHonouredPermissionMode(
providerPermissionModes(selected),
draft.permissionMode,
@@ -963,7 +963,7 @@ function ProfileForm({
)}
- {selected?.id === "claude" && selected.supportsUltracode === true && selected.modelInfo.find((m) => m.id === draft.model)?.efforts?.includes("xhigh") && (
+ {(selected?.baseProvider ?? selected?.id) === "claude" && selected?.supportsUltracode === true && selected?.modelInfo.find((m) => m.id === draft.model)?.efforts?.includes("xhigh") && (
onChange({ ...draft, ultracode: e.target.checked })} /> Ultracode (xhigh effort plus dynamic workflows)
)}
{selected?.supportsPlanModel && (
diff --git a/test/composer.test.tsx b/test/composer.test.tsx
index 1575779a1..acd479cc9 100644
--- a/test/composer.test.tsx
+++ b/test/composer.test.tsx
@@ -3914,16 +3914,19 @@ describe("Composer surrounds a selection with a bracket/quote/emphasis pair (#17
describe("Composer Ultracode mode", () => {
it("shows requested xhigh separately, disables base effort, and restores base effort when off", async () => {
+ for (const account of ["claude", "claude:work"]) {
const h = makeHarness();
const claude: ProviderInfo = {
...CLAUDE_WITH_INFO,
+ id: account,
+ ...(account !== "claude" ? { baseProvider: "claude" } : {}),
supportsUltracode: true,
efforts: ["low", "medium", "high", "xhigh", "max"],
models: ["claude-opus-5"],
modelInfo: [{ id: "claude-opus-5", label: "Opus", description: "", vendor: "Anthropic", efforts: ["low", "medium", "high", "xhigh", "max"] }],
};
const toggles: boolean[] = [];
- const m = await mount(composer(h, { provider: "claude", model: "claude-opus-5", providers: [claude], reasoningEffort: "low", ultracode: true, onSetUltracode: (value) => { toggles.push(value); } }));
+ const m = await mount(composer(h, { provider: account, model: "claude-opus-5", providers: [claude], reasoningEffort: "low", ultracode: true, onSetUltracode: (value) => { toggles.push(value); } }));
assert.ok(m.query('button[aria-label="Reasoning: Ultracode requested (xhigh)"]'));
await m.click(m.query('button[aria-label^="Model:"]'));
assert.equal((m.query('[data-effort-chip="low"]') as HTMLButtonElement).disabled, true);
@@ -3933,10 +3936,11 @@ describe("Composer Ultracode mode", () => {
await m.click(toggle);
assert.deepEqual(toggles, [false]);
m.unmount();
- const off = await mount(composer(h, { provider: "claude", model: "claude-opus-5", providers: [claude], reasoningEffort: "low", ultracode: false, onSetUltracode: () => {} }));
+ const off = await mount(composer(h, { provider: account, model: "claude-opus-5", providers: [claude], reasoningEffort: "low", ultracode: false, onSetUltracode: () => {} }));
assert.ok(off.query('button[aria-label="Reasoning: Low"]'));
await off.click(off.query('button[aria-label^="Model:"]'));
assert.equal((off.query('[data-effort-chip="low"]') as HTMLButtonElement).disabled, false);
off.unmount();
+ }
});
});
diff --git a/test/settingsModal.test.tsx b/test/settingsModal.test.tsx
index bc0cb6c78..532cccb5d 100644
--- a/test/settingsModal.test.tsx
+++ b/test/settingsModal.test.tsx
@@ -4397,3 +4397,31 @@ describe("SettingsModal agent profile auto-approve (#1721)", () => {
m.unmount();
});
});
+
+describe("SettingsModal named Claude Ultracode profile", () => {
+ it("keeps the separate mode when an existing named-account profile is edited", async () => {
+ const provider: ProviderInfo = {
+ ...KIMI, id: "claude:work", baseProvider: "claude", name: "Claude work",
+ supportsUltracode: true,
+ models: ["claude-opus-5"],
+ modelInfo: [{ id: "claude-opus-5", label: "Opus", vendor: "Anthropic", description: "", efforts: ["low", "medium", "high", "xhigh", "max"] }],
+ efforts: ["low", "medium", "high", "xhigh", "max"],
+ };
+ const settings = {
+ dailyBudgetUsd: null, autoSettleAfterDays: 3,
+ agentProfiles: [{ id: "named", name: "Work", provider: "claude:work", model: "claude-opus-5", reasoningEffort: "low", ultracode: true, permissionMode: "default" }],
+ } as AppSettings;
+ const patches: Partial[] = [];
+ const m = await mount(modal({ initialPane: "agents", providers: [provider], settings, onSaveSettings: async (patch) => { patches.push(patch); return { ...settings, ...patch }; } }));
+ await m.click(Array.from(m.queryAll("button")).find((b) => b.textContent?.trim() === "Edit")!);
+ const label = Array.from(m.queryAll("label")).find((el) => el.textContent?.includes("Ultracode"));
+ const mode = label?.querySelector('input[type="checkbox"]') as HTMLInputElement | undefined;
+ assert.ok(mode);
+ assert.equal(mode.checked, true);
+ await m.click(m.byText("Update"));
+ assert.equal(patches[0].agentProfiles?.[0]?.ultracode, true);
+ assert.equal(patches[0].agentProfiles?.[0]?.reasoningEffort, "low");
+ assert.equal(patches[0].agentProfiles?.[0]?.provider, "claude:work");
+ m.unmount();
+ });
+});
From 051987801cf3e63fb3a4b346a154da32a491d397 Mon Sep 17 00:00:00 2001
From: Willem
Date: Sat, 7 Nov 2026 14:38:12 +0100
Subject: [PATCH 5/9] feat: opt in to stop managed processes when threads
settle
---
electron/devservers.js | 18 ++-
electron/ipc-devserver.js | 9 +-
electron/projectCommands.js | 104 ++++++++++++++++-
electron/repoConfig.js | 32 ++++--
electron/services-projects.js | 5 +
electron/services-thread-state.js | 1 +
electron/store-migrate.js | 1 +
electron/test/stop-on-settle.test.js | 165 +++++++++++++++++++++++++++
site/solenta.schema.json | 4 +-
src/components/EditProjectModal.tsx | 17 +++
src/shared/ipc.ts | 5 +
test/editProject.test.tsx | 21 ++++
12 files changed, 365 insertions(+), 17 deletions(-)
create mode 100644 electron/test/stop-on-settle.test.js
diff --git a/electron/devservers.js b/electron/devservers.js
index d3c3e95c8..14ef4cc37 100644
--- a/electron/devservers.js
+++ b/electron/devservers.js
@@ -227,6 +227,8 @@ async function probeOnce(probe, ctx) {
/**
* @typedef {{
* pid: number,
+ * root?: string,
+ * keepLog?: boolean,
* script: string,
* startedAt: number,
* url: string | null,
@@ -421,6 +423,7 @@ function killProcessGroup(pid, platform = process.platform) {
* env?: NodeJS.ProcessEnv,
* project?: { remoteHost?: string, remotePath?: string, path?: string } | null,
* probeIntervalMs?: number,
+ * onExit?: () => void,
* }} [opts]
*/
function start(threadId, root, script, opts = {}) {
@@ -500,6 +503,7 @@ function start(threadId, root, script, opts = {}) {
/** @type {DevServerRecord} */
const rec = {
pid,
+ root,
script,
startedAt: Date.now(),
url: null,
@@ -526,6 +530,7 @@ function start(threadId, root, script, opts = {}) {
child.on("exit", () => {
const current = records.get(threadId);
if (current === rec) markDead(rec);
+ if (opts.onExit) opts.onExit();
});
if (typeof child.unref === "function") child.unref();
@@ -553,10 +558,16 @@ function start(threadId, root, script, opts = {}) {
*
* @param {string} threadId
*/
-function stop(threadId) {
+function stop(threadId, opts = {}) {
const rec = records.get(threadId);
if (!rec) return { running: false };
- if (rec.pid) killProcessGroup(rec.pid, rec.platform);
+ if (rec.pid && !rec.dead && isAlive(rec.pid)) killProcessGroup(rec.pid, rec.platform);
+ if (opts.preserveLog) {
+ if (!rec.dead) appendLog(rec, "\n[settle] stopped managed dev server\n");
+ markDead(rec);
+ rec.keepLog = true;
+ return toState(rec);
+ }
records.delete(threadId);
return { running: false };
}
@@ -570,7 +581,7 @@ function status(threadId) {
const rec = records.get(threadId);
if (!rec) return { running: false };
const state = toState(rec);
- if (rec.dead && rec.deadAt && Date.now() - rec.deadAt > DEAD_TTL_MS) {
+ if (!rec.keepLog && rec.dead && rec.deadAt && Date.now() - rec.deadAt > DEAD_TTL_MS) {
records.delete(threadId);
}
return state;
@@ -603,4 +614,5 @@ module.exports = {
status,
killAll,
listLive,
+ rootFor: (threadId) => records.get(threadId)?.root,
};
diff --git a/electron/ipc-devserver.js b/electron/ipc-devserver.js
index f95bff6aa..10c5ad522 100644
--- a/electron/ipc-devserver.js
+++ b/electron/ipc-devserver.js
@@ -162,8 +162,9 @@ module.exports = {
lane: n,
});
}
- return devservers.start(threadId, startRoot, script, {
+ const state = devservers.start(threadId, startRoot, script, {
project,
+ onExit: () => require("./projectCommands.js").stopManagedWatcherIfIdle(ctx.store),
env: spawnEnvForDevServer({
project,
thread,
@@ -171,11 +172,15 @@ module.exports = {
extra: spotlightOn ? spotlightEnv(portBase) : laneEnvExtra(thread),
}),
});
+ require("./projectCommands.js").watchManagedOnSettle(ctx.store);
+ return state;
},
"devserver:stop": async (ctx, input) => {
const threadId = input && input.threadId;
resolveDevServerRoot(ctx, threadId);
- return devservers.stop(threadId);
+ const state = devservers.stop(threadId);
+ require("./projectCommands.js").stopManagedWatcherIfIdle(ctx.store);
+ return state;
},
"devserver:status": async (ctx, input) => {
const threadId = input && input.threadId;
diff --git a/electron/projectCommands.js b/electron/projectCommands.js
index 219dc858e..1b1639413 100644
--- a/electron/projectCommands.js
+++ b/electron/projectCommands.js
@@ -41,6 +41,8 @@ const SETUP_TIMEOUT_MS = 30 * 60_000;
const inflight = new Map();
/** Inflight promises that are a new worktree's setup, not a quick action. */
const setupJobs = new WeakSet();
+// Only our managed quick actions, never provider tools or terminal shells.
+const runningActions = new Map();
/** Thrown when a solenta.json command has not been approved (#1506). */
const REPO_CONFIG_UNTRUSTED = "REPO_CONFIG_UNTRUSTED";
const SUBMODULE_COMMAND = "git submodule update --init --recursive";
@@ -87,7 +89,7 @@ function normalizeQuickActions(raw) {
id = randomUUID();
}
seen.add(id);
- out.push({ id, name, command });
+ out.push({ id, name, command, ...(typeof row.stopOnSettle === "boolean" ? { stopOnSettle: row.stopOnSettle } : {}) });
if (out.length >= QUICK_ACTION_MAX) break;
}
return out.length ? out : null;
@@ -288,6 +290,7 @@ function resolveCommand(project, actionId) {
command,
timeoutMs: VERIFY_TIMEOUT_MS,
fromRepo: row.fromRepo,
+ stopOnSettle: row.stopOnSettle,
...trust,
};
}
@@ -393,6 +396,7 @@ function kickWorktreeSetup(opts) {
function runOnSettle(opts) {
try {
const { store, threadId, broadcast } = opts;
+ stopManagedOnSettle(store, threadId);
const thread = store.getThread(threadId);
if (!thread || !thread.worktreePath || thread.onSettleAt != null) {
return Promise.resolve(null);
@@ -426,6 +430,86 @@ function runOnSettle(opts) {
}
}
+/** Settlement check for managed process owners, including inactivity. */
+function managedOwnerSettled(store, thread, now = Date.now()) {
+ if (!thread || thread.status === "working" || thread.status === "quota-wait") return false;
+ if (thread.archived) return true;
+ if (thread.pinnedAt != null && Number.isFinite(thread.pinnedAt)) return false;
+ if (thread.settledOverride === "settled") return true;
+ if (thread.settledOverride === "active") return false;
+ const settings = store.getSettings();
+ const links = require("./prLinks.js").normalizePrLinks(thread);
+ const states = links.length ? links.map((p) => p.state) : [thread.prState];
+ if (states.every((s) => s === "CLOSED" || (s === "MERGED" && settings.autoSettleOnMerge !== false))) return true;
+ if (states.includes("OPEN") || thread.prState === "OPEN") return false;
+ const days = settings.autoSettleAfterDays === undefined ? require("./store-normalize.js").DEFAULT_AUTO_SETTLE_AFTER_DAYS : settings.autoSettleAfterDays;
+ return days != null && Number.isFinite(days) && days >= 0 && Number.isFinite(thread.updatedAt) && thread.updatedAt < now - days * 86400000;
+}
+
+/** Shared checkout processes stay alive while another project thread needs them. */
+function canStopManaged(store, threadId, cwd) {
+ const thread = store.getThread(threadId);
+ const project = thread && store.getProject(thread.projectId);
+ if (!thread || !project) return false;
+ // The captured launch root survives merge cleanup clearing worktreePath.
+ if (project.path && path.resolve(cwd) !== path.resolve(project.path)) return true;
+ return !store.getThreads().some((other) => other.id !== threadId && other.projectId === thread.projectId && !managedOwnerSettled(store, other));
+}
+
+function stopManagedOnSettle(store, threadId) {
+ const thread = store.getThread(threadId);
+ const project = thread && store.getProject(thread.projectId);
+ if (!project) return;
+ const eff = effectiveCommands(project);
+ const action = runningActions.get(threadId);
+ if (action && !action.controller.signal.aborted && (typeof action.stopOnSettle === "boolean" ? action.stopOnSettle : project.stopOnSettle === true) && canStopManaged(store, threadId, action.cwd)) action.controller.abort();
+ const servers = require("./devservers.js");
+ const root = servers.rootFor(threadId);
+ const enabled = typeof project.stopOnSettle === "boolean" ? project.stopOnSettle : eff.trusted && eff.devServerStopOnSettle === true;
+ if (enabled && root && canStopManaged(store, threadId, root)) servers.stop(threadId, { preserveLog: true });
+}
+
+/** Scan only managed owners, so idle ticks never walk the whole transcript store. */
+const managedSettleTimers = new WeakMap();
+
+function optedInManagedOwners(store) {
+ const servers = require("./devservers.js");
+ const liveServers = new Set(servers.listLive());
+ const ids = new Set([...runningActions.keys(), ...liveServers]);
+ return [...ids].filter((id) => {
+ const thread = store.getThread(id);
+ const project = thread && store.getProject(thread.projectId);
+ if (!project) return false;
+ const action = runningActions.get(id);
+ const eff = effectiveCommands(project);
+ const actionEnabled = action && !action.controller.signal.aborted && (typeof action.stopOnSettle === "boolean" ? action.stopOnSettle : project.stopOnSettle === true);
+ const serverEnabled = liveServers.has(id) && (typeof project.stopOnSettle === "boolean" ? project.stopOnSettle : eff.trusted && eff.devServerStopOnSettle === true);
+ return actionEnabled || serverEnabled;
+ });
+}
+
+function stopManagedWatcherIfIdle(store) {
+ if (optedInManagedOwners(store).length) return;
+ clearInterval(managedSettleTimers.get(store));
+ managedSettleTimers.delete(store);
+}
+
+function watchManagedOnSettle(store) {
+ if (managedSettleTimers.has(store) || !optedInManagedOwners(store).length) return;
+ const timer = setInterval(() => sweepManagedOnSettle(store), 30_000);
+ timer.unref();
+ managedSettleTimers.set(store, timer);
+}
+
+function sweepManagedOnSettle(store) {
+ for (const id of optedInManagedOwners(store)) {
+ if (managedOwnerSettled(store, store.getThread(id))) stopManagedOnSettle(store, id);
+ }
+ const remaining = optedInManagedOwners(store).length;
+ if (!remaining) stopManagedWatcherIfIdle(store);
+ return remaining;
+}
+
/** First one present in the worktree is the one compared (#396). */
const LOCKFILES = [
"package-lock.json",
@@ -613,6 +697,13 @@ async function runOne(store, args) {
}
: undefined;
+ const controller = new AbortController();
+ const managed = resolved.id !== SETUP_ID && resolved.id !== ON_SETTLE_ID && resolved.id !== SHELL_ID;
+ const owner = { controller, cwd, projectId: project && project.id, stopOnSettle: resolved.stopOnSettle };
+ if (managed) {
+ runningActions.set(threadId, owner);
+ watchManagedOnSettle(store);
+ }
let ran;
try {
ran = await runFn({
@@ -620,6 +711,7 @@ async function runOne(store, args) {
cwd,
project,
timeoutMs: resolved.timeoutMs,
+ signal: controller.signal,
...(env ? { env } : {}),
...(onOutput ? { onOutput } : {}),
});
@@ -633,6 +725,8 @@ async function runOne(store, args) {
};
}
+ if (runningActions.get(threadId) === owner) runningActions.delete(threadId);
+ if (managed) stopManagedWatcherIfIdle(store);
/** @type {import("../src/shared/ipc").CommandRunResult} */
const result = {
name: resolved.name,
@@ -641,7 +735,7 @@ async function runOne(store, args) {
exitCode: ran && ran.exitCode != null ? ran.exitCode : null,
timedOut: Boolean(ran && ran.timedOut),
// Re-bounded here too: a swapped runner or a thrown error is not tailed.
- log: ran && ran.log ? tailLog(String(ran.log)) : "",
+ log: tailLog(`${ran && ran.log ? String(ran.log) : ""}${controller.signal.aborted ? "\n[settle] stopped managed action" : ""}`),
durationMs: ran && typeof ran.durationMs === "number" ? ran.durationMs : 0,
at: Date.now(),
cwd,
@@ -765,6 +859,12 @@ module.exports = {
SUBMODULE_COMMAND,
kickWorktreeSetup,
runOnSettle,
+ stopManagedOnSettle,
+ sweepManagedOnSettle,
+ watchManagedOnSettle,
+ stopManagedWatcherIfIdle,
+ managedOwnerSettled,
+ canStopManaged,
waitForCommand,
pendingSetup,
runCommand,
diff --git a/electron/repoConfig.js b/electron/repoConfig.js
index 20af67b1e..16946c390 100644
--- a/electron/repoConfig.js
+++ b/electron/repoConfig.js
@@ -48,7 +48,7 @@ const REPO_ACTION_ID_PREFIX = "repo:";
*/
const REPO_KEYS = new Set(["$schema", "setup", "quickActions", "onSettle", "automations", "devServer", "iconPath"]);
/** solenta.local.json only overrides commands. */
-const LOCAL_KEYS = new Set(["$schema", "setup", "quickActions", "onSettle"]);
+const LOCAL_KEYS = new Set(["$schema", "setup", "quickActions", "onSettle", "devServer"]);
/** @type {Map} */
const cache = new Map();
@@ -58,7 +58,8 @@ const cache = new Map();
* setup?: string,
* setupSource?: string,
* onSettle?: string,
- * quickActions?: Array<{ id: string, name: string, command: string, source?: string }>,
+ * devServerStopOnSettle?: boolean,
+ * quickActions?: Array<{ id: string, name: string, command: string, stopOnSettle?: boolean, source?: string }>,
* sources?: string[],
* env?: Record,
* hash?: string,
@@ -67,7 +68,7 @@ const cache = new Map();
*/
/**
- * @typedef {{ key: string, id?: string, name: string, command: string, hidden?: boolean, source?: string }} ActionRow
+ * @typedef {{ key: string, id?: string, name: string, command: string, stopOnSettle?: boolean, hidden?: boolean, source?: string }} ActionRow
*/
/**
@@ -94,12 +95,18 @@ function parseLayer(data, file) {
const local = file === LOCAL_CONFIG_FILE;
const bad = unknownKey(d, local ? LOCAL_KEYS : REPO_KEYS);
if (bad) return { error: `${file}: unknown key "${bad}"` };
- if (!local && d.devServer && typeof d.devServer === "object" && !Array.isArray(d.devServer)) {
- const devBad = unknownKey(/** @type {Record} */ (d.devServer), new Set(["ready"]));
+ if (d.devServer && typeof d.devServer === "object" && !Array.isArray(d.devServer)) {
+ const devBad = unknownKey(/** @type {Record} */ (d.devServer), new Set(local ? ["stopOnSettle"] : ["ready", "stopOnSettle"]));
if (devBad) return { error: `${file}: unknown key "devServer.${devBad}"` };
}
/** @type {{ setup?: string, onSettle?: unknown, quickActions?: ActionRow[] }} */
const out = {};
+ if (d.devServer !== undefined) {
+ if (!d.devServer || typeof d.devServer !== "object" || Array.isArray(d.devServer)) return { error: `${file}: devServer must be an object` };
+ const value = d.devServer.stopOnSettle;
+ if (value !== undefined && typeof value !== "boolean") return { error: `${file}: devServer.stopOnSettle must be boolean` };
+ if (typeof value === "boolean") out.devServerStopOnSettle = value;
+ }
if (d.setup !== undefined) {
const setup = normalizeCommand(d.setup);
if (!setup) {
@@ -114,7 +121,7 @@ function parseLayer(data, file) {
if (d.quickActions.length > QUICK_ACTION_MAX) {
return { error: `${file}: at most ${QUICK_ACTION_MAX} quickActions` };
}
- const rowKeys = new Set(local ? ["id", "name", "command", "hidden"] : ["id", "name", "command"]);
+ const rowKeys = new Set(local ? ["id", "name", "command", "hidden", "stopOnSettle"] : ["id", "name", "command", "stopOnSettle"]);
/** @type {ActionRow[]} */
const rows = [];
for (const [i, row] of d.quickActions.entries()) {
@@ -140,7 +147,8 @@ function parseLayer(data, file) {
if (!command) {
return { error: `${at}.command must be a non-empty command string` };
}
- rows.push({ key: id || name, ...(id ? { id } : {}), name, command });
+ if (r.stopOnSettle !== undefined && typeof r.stopOnSettle !== "boolean") return { error: `${at}.stopOnSettle must be boolean` };
+ rows.push({ key: id || name, ...(id ? { id } : {}), name, command, ...(typeof r.stopOnSettle === "boolean" ? { stopOnSettle: r.stopOnSettle } : {}) });
}
const keys = rows.map((r) => r.key);
const dup = keys.find((k, i) => keys.indexOf(k) !== i);
@@ -199,6 +207,7 @@ function parseRepoConfig(data, localData) {
id: `${REPO_ACTION_ID_PREFIX}${r.id || i}`,
name: r.name,
command: r.command,
+ ...(typeof r.stopOnSettle === "boolean" ? { stopOnSettle: r.stopOnSettle } : {}),
...(r.source ? { source: r.source } : {}),
}));
}
@@ -214,7 +223,9 @@ function parseRepoConfig(data, localData) {
}
out.onSettle = onSettle;
}
- if (!out.setup && !out.quickActions && !out.onSettle) return null;
+ const devStop = local.devServerStopOnSettle ?? shared.devServerStopOnSettle;
+ if (typeof devStop === "boolean") out.devServerStopOnSettle = devStop;
+ if (!out.setup && !out.quickActions && !out.onSettle && devStop === undefined) return null;
out.hash = commandsHash(out);
return out;
}
@@ -228,7 +239,8 @@ function parseRepoConfig(data, localData) {
function commandsHash(cfg) {
const canonical = JSON.stringify({
setup: cfg.setup || null,
- quickActions: (cfg.quickActions || []).map((a) => [a.name, a.command]),
+ quickActions: (cfg.quickActions || []).map((a) => [a.name, a.command, ...(typeof a.stopOnSettle === "boolean" ? [a.stopOnSettle] : [])]),
+ ...(typeof cfg.devServerStopOnSettle === "boolean" ? { devServerStopOnSettle: cfg.devServerStopOnSettle } : {}),
...(cfg.onSettle ? { onSettle: cfg.onSettle } : {}),
...(cfg.env ? { env: cfg.env } : {}),
});
@@ -376,6 +388,7 @@ function effectiveCommands(project) {
: ok && ok.quickActions
? ok.quickActions.map((a) => ({ ...a, fromRepo: true }))
: [],
+ devServerStopOnSettle: ok && ok.devServerStopOnSettle,
onSettle: ok && ok.onSettle ? { command: ok.onSettle, fromRepo: true } : null,
env: ok && ok.env ? ok.env : null,
hash: ok ? ok.hash || null : null,
@@ -392,6 +405,7 @@ function presentRepoConfig(project) {
if (!cfg) return null;
if (cfg.error) return { error: cfg.error, trusted: false };
return {
+ ...(typeof cfg.devServerStopOnSettle === "boolean" ? { devServerStopOnSettle: cfg.devServerStopOnSettle } : {}),
...(cfg.setup ? { setupCommand: cfg.setup } : {}),
...(cfg.setupSource ? { setupSource: cfg.setupSource } : {}),
...(cfg.quickActions ? { quickActions: cfg.quickActions } : {}),
diff --git a/electron/services-projects.js b/electron/services-projects.js
index 1536b56d5..9118b4b2e 100644
--- a/electron/services-projects.js
+++ b/electron/services-projects.js
@@ -495,6 +495,8 @@ function updateProject(store, projectId, patch) {
delete next.waitForSetup;
}
// #396 setup cache opt-out; same false/null-deletes rule.
+ if (typeof input.stopOnSettle === "boolean") next.stopOnSettle = input.stopOnSettle;
+ else if (input.stopOnSettle === null) delete next.stopOnSettle;
if (input.setupCacheOff === true) next.setupCacheOff = true;
else if (input.setupCacheOff === false || input.setupCacheOff === null) {
delete next.setupCacheOff;
@@ -543,6 +545,9 @@ function updateProject(store, projectId, patch) {
projects[idx] = next;
store.setProjects(projects);
store.save();
+ if (Object.prototype.hasOwnProperty.call(input, "stopOnSettle") || Object.prototype.hasOwnProperty.call(input, "quickActions")) {
+ require("./projectCommands.js").watchManagedOnSettle(store);
+ }
return presentProject(next);
}
diff --git a/electron/services-thread-state.js b/electron/services-thread-state.js
index 300802ebf..eb6621343 100644
--- a/electron/services-thread-state.js
+++ b/electron/services-thread-state.js
@@ -49,6 +49,7 @@ function setArchived(store, input, opts) {
});
store.save();
if (Boolean(archived)) {
+ require("./projectCommands.js").stopManagedOnSettle(store, threadId);
void scheduleImagePruneFromStore(store);
void scheduleSimulatorRelease(opts, "releaseThread", { threadId });
}
diff --git a/electron/store-migrate.js b/electron/store-migrate.js
index 43c70cf73..2b9fb4fa9 100644
--- a/electron/store-migrate.js
+++ b/electron/store-migrate.js
@@ -299,6 +299,7 @@ function migrateProject(p) {
// #1506: strictly boolean; a bad prefix falls back to the default.
if (next.waitForSetup === true) next.waitForSetup = true;
else delete next.waitForSetup;
+ if (typeof next.stopOnSettle !== "boolean") delete next.stopOnSettle;
if (next.setupCacheOff !== true) delete next.setupCacheOff;
if (typeof next.branchPrefix !== "string" || branchPrefixError(next.branchPrefix)) {
delete next.branchPrefix;
diff --git a/electron/test/stop-on-settle.test.js b/electron/test/stop-on-settle.test.js
new file mode 100644
index 000000000..c80ce3f02
--- /dev/null
+++ b/electron/test/stop-on-settle.test.js
@@ -0,0 +1,165 @@
+"use strict";
+const { test, afterEach } = require("node:test");
+const assert = require("node:assert/strict");
+const { EventEmitter } = require("node:events");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const commands = require("../projectCommands.js");
+const servers = require("../devservers.js");
+const { parseRepoConfig, effectiveCommands } = require("../repoConfig.js");
+const { setArchived, clearSettledOnActivity } = require("../services-thread-state.js");
+const temps = [];
+afterEach(() => { commands.setRunCommandFn(null); servers.killAll(); for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
+function fixture(options = {}) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "settle-process-")); temps.push(root);
+ const project = { id: "p", path: root, ...options };
+ const thread = { id: "t", projectId: "p", status: "idle", updatedAt: Date.now(), worktreePath: path.join(root, "worktree") };
+ fs.mkdirSync(thread.worktreePath);
+ const threads = [thread]; const messages = new Map();
+ return { project, thread, threads, store: {
+ getThread: (id) => threads.find((t) => t.id === id), getProject: () => project,
+ getThreads: () => threads, getSettings: () => ({ autoSettleAfterDays: 3 }),
+ getMessages: (id) => messages.get(id) || [], setMessages: (id, rows) => messages.set(id, rows),
+ updateThread: (id, patch) => Object.assign(threads.find((t) => t.id === id), patch), save() {},
+ } };
+}
+function startServer(id, root, platform) {
+ const child = new EventEmitter(); child.pid = 0;
+ child.stdout = new EventEmitter(); child.stdout.setEncoding = () => {};
+ child.stderr = new EventEmitter(); child.stderr.setEncoding = () => {};
+ servers.start(id, root, "dev", { platform, spawn: () => child });
+ child.stdout.emit("data", "original output\n");
+ return child;
+}
+test("config opt-ins are boolean, hashed and preserve explicit false in local overrides", () => {
+ const base = { devServer: { stopOnSettle: true }, quickActions: [{ id: "dev", name: "Dev", command: "npm run dev", stopOnSettle: true }] };
+ const shared = parseRepoConfig(base);
+ const local = parseRepoConfig(base, { devServer: { stopOnSettle: false }, quickActions: [{ id: "dev", name: "Dev", command: "npm run dev", stopOnSettle: false }] });
+ assert.equal(local.devServerStopOnSettle, false); assert.equal(local.quickActions[0].stopOnSettle, false);
+ assert.notEqual(local.hash, shared.hash);
+ assert.match(parseRepoConfig({ devServer: { stopOnSettle: "true" } }).error, /boolean/);
+ assert.match(parseRepoConfig({ quickActions: [{ name: "Dev", command: "dev", stopOnSettle: 1 }] }).error, /boolean/);
+ assert.equal(parseRepoConfig({ quickActions: [{ name: "Dev", command: "dev" }] }).quickActions[0].stopOnSettle, undefined);
+ assert.equal(commands.normalizeQuickActions([{ name: "Dev", command: "dev", stopOnSettle: false }])[0].stopOnSettle, false);
+});
+test("managed action cancellation is opt in, preserves output, runs once and rearms with a new invocation", async () => {
+ const f = fixture({ quickActions: [{ id: "dev", name: "Dev", command: "dev" }] });
+ let aborts = 0; let finish;
+ commands.setRunCommandFn(({ signal }) => new Promise((resolve) => {
+ finish = () => resolve({ ok: false, log: "kept output", exitCode: null });
+ signal.addEventListener("abort", () => { aborts++; finish(); });
+ }));
+ let run = commands.runCommand(f.store, { threadId: "t", actionId: "dev" });
+ commands.stopManagedOnSettle(f.store, "t"); assert.equal(aborts, 0);
+ f.project.stopOnSettle = true;
+ commands.stopManagedOnSettle(f.store, "t"); commands.stopManagedOnSettle(f.store, "t");
+ const result = await run; assert.equal(aborts, 1); assert.match(result.log, /kept output[\s\S]*stopped managed action/);
+ Object.assign(f.thread, clearSettledOnActivity({ onSettleAt: 1, settledOverride: "settled" }));
+ run = commands.runCommand(f.store, { threadId: "t", actionId: "dev" });
+ commands.stopManagedOnSettle(f.store, "t"); await run; assert.equal(aborts, 2);
+ f.project.quickActions[0].stopOnSettle = false;
+ run = commands.runCommand(f.store, { threadId: "t", actionId: "dev" });
+ commands.stopManagedOnSettle(f.store, "t"); assert.equal(aborts, 2); finish(); await run;
+});
+test("shared checkout action waits for other active project threads; worktree action does not", async () => {
+ const f = fixture({ stopOnSettle: true, quickActions: [{ id: "dev", name: "Dev", command: "dev" }] });
+ const ownWorktree = f.thread.worktreePath;
+ f.thread.worktreePath = null; f.thread.archived = true;
+ f.threads.push({ id: "other", projectId: "p", status: "idle", updatedAt: Date.now() });
+ let aborts = 0;
+ commands.setRunCommandFn(({ signal }) => new Promise((resolve) => signal.addEventListener("abort", () => { aborts++; resolve({ log: "shared" }); })));
+ let run = commands.runCommand(f.store, { threadId: "t", actionId: "dev" });
+ commands.sweepManagedOnSettle(f.store); assert.equal(aborts, 0);
+ f.threads[1].archived = true; commands.sweepManagedOnSettle(f.store); await run; assert.equal(aborts, 1);
+ f.thread.worktreePath = ownWorktree; f.threads[1].archived = false;
+ run = commands.runCommand(f.store, { threadId: "t", actionId: "dev" });
+ // Merge cleanup clears the mutable worktree field before the settle hook.
+ f.thread.worktreePath = null;
+ assert.equal(commands.sweepManagedOnSettle(f.store), 0); await run; assert.equal(aborts, 2);
+});
+test("archive and PR/explicit settle hook stop only managed resources and keep already-exited server logs", async () => {
+ const f = fixture({ stopOnSettle: true, quickActions: [{ id: "dev", name: "Dev", command: "dev" }] });
+ commands.setRunCommandFn(({ signal }) => new Promise((resolve) => signal.addEventListener("abort", () => resolve({ log: "archive output" }))));
+ const run = commands.runCommand(f.store, { threadId: "t", actionId: "dev" });
+ setArchived(f.store, { threadId: "t", archived: true }); await run;
+ startServer("t", f.thread.worktreePath, "win32");
+ await commands.runOnSettle({ store: f.store, threadId: "t" });
+ assert.deepEqual(servers.status("t").lastLines, ["original output"]);
+ await commands.runOnSettle({ store: f.store, threadId: "t" });
+ assert.deepEqual(servers.status("t").lastLines, ["original output"]);
+});
+test("repo server opt-in requires trust and project false survives repo true", () => {
+ const f = fixture(); fs.writeFileSync(path.join(f.project.path, "solenta.json"), JSON.stringify({ devServer: { stopOnSettle: true } }));
+ assert.equal(effectiveCommands(f.project).trusted, false);
+ f.project.repoConfigTrust = parseRepoConfig({ devServer: { stopOnSettle: true } }).hash;
+ assert.equal(effectiveCommands(f.project).trusted, true);
+ f.project.stopOnSettle = false;
+ assert.equal(commands.sweepManagedOnSettle(f.store), 0);
+});
+test("inactivity respects active runs, pin, override, open/linked PRs and merge setting", () => {
+ const f = fixture(); const old = Date.now() - 4 * 86400000;
+ f.thread.updatedAt = old; assert.equal(commands.managedOwnerSettled(f.store, f.thread), true);
+ for (const patch of [{ status: "working" }, { pinnedAt: 1 }, { settledOverride: "active" }, { prState: "OPEN" }, { prLinks: [{ number: 1, state: "OPEN" }, { number: 2, state: "MERGED" }] }, { prState: "OPEN", prLinks: [{ number: 1, state: "UNKNOWN" }] }]) {
+ assert.equal(commands.managedOwnerSettled(f.store, { ...f.thread, ...patch }), false);
+ }
+ f.store.getSettings = () => ({ autoSettleAfterDays: null, autoSettleOnMerge: false });
+ assert.equal(commands.managedOwnerSettled(f.store, { ...f.thread, prState: "MERGED" }), false);
+ assert.equal(commands.managedOwnerSettled(f.store, { ...f.thread, prState: "CLOSED" }), true);
+});
+
+test("missing inactivity setting uses the application default", () => {
+ const f = fixture(); f.store.getSettings = () => ({});
+ assert.equal(commands.managedOwnerSettled(f.store, { ...f.thread, updatedAt: Date.now() - 4 * 86400000 }), true);
+ assert.equal(commands.managedOwnerSettled(f.store, { ...f.thread, updatedAt: Date.now() - 2 * 86400000 }), false);
+});
+
+test("live server stop keeps logs, stops once, and uses captured launch ownership on Windows", (t) => {
+ const f = fixture({ stopOnSettle: true });
+ f.threads.push({ id: "other", projectId: "p", status: "working" });
+ const signals = []; let alive = true;
+ t.mock.method(process, "kill", (pid, signal) => {
+ if (signal === 0) { if (!alive) throw new Error("gone"); return true; }
+ signals.push([pid, signal]); alive = false; return true;
+ });
+ const child = new EventEmitter(); child.pid = 987654;
+ child.stdout = new EventEmitter(); child.stdout.setEncoding = () => {};
+ child.stderr = new EventEmitter(); child.stderr.setEncoding = () => {};
+ servers.start("t", f.thread.worktreePath, "dev", { platform: "win32", spawn: () => child });
+ child.stdout.emit("data", "server output\n");
+ f.thread.worktreePath = null;
+ commands.stopManagedOnSettle(f.store, "t"); child.emit("exit", 0);
+ commands.stopManagedOnSettle(f.store, "t");
+ assert.deepEqual(signals, [[987654, "SIGTERM"]]);
+ assert.deepEqual(servers.status("t").lastLines, ["server output", "", "[settle] stopped managed dev server"]);
+});
+
+test("watch is lazy, unref'd, and removed as soon as the final opted-in action exits", async (t) => {
+ const f = fixture({ quickActions: [{ id: "dev", name: "Dev", command: "dev" }] });
+ let started = 0; let unref = 0; let cleared = 0; let finish;
+ const timer = { unref: () => { unref++; } };
+ t.mock.method(global, "setInterval", () => { started++; return timer; });
+ t.mock.method(global, "clearInterval", (value) => { if (value === timer) cleared++; });
+ commands.watchManagedOnSettle(f.store); assert.equal(started, 0);
+ f.project.stopOnSettle = true;
+ commands.setRunCommandFn(() => new Promise((resolve) => { finish = () => resolve({ log: "finished" }); }));
+ const run = commands.runCommand(f.store, { threadId: "t", actionId: "dev" });
+ assert.equal(started, 1); assert.equal(unref, 1);
+ finish(); await run; assert.equal(cleared, 1);
+ commands.watchManagedOnSettle(f.store); assert.equal(started, 1);
+});
+
+test("server policy needs repo trust, and explicit project false overrides repo true", (t) => {
+ const f = fixture();
+ fs.writeFileSync(path.join(f.project.path, "solenta.json"), JSON.stringify({ devServer: { stopOnSettle: true } }));
+ let signals = 0;
+ t.mock.method(process, "kill", (_pid, signal) => { if (signal !== 0) signals++; return true; });
+ const child = new EventEmitter(); child.pid = 987655;
+ servers.start("t", f.thread.worktreePath, "dev", { spawn: () => child });
+ commands.stopManagedOnSettle(f.store, "t"); assert.equal(signals, 0);
+ f.project.repoConfigTrust = parseRepoConfig({ devServer: { stopOnSettle: true } }).hash;
+ f.project.stopOnSettle = false;
+ commands.stopManagedOnSettle(f.store, "t"); assert.equal(signals, 0);
+ delete f.project.stopOnSettle;
+ commands.stopManagedOnSettle(f.store, "t"); assert.equal(signals, 1);
+});
diff --git a/site/solenta.schema.json b/site/solenta.schema.json
index 373836f98..a33cb1ce0 100644
--- a/site/solenta.schema.json
+++ b/site/solenta.schema.json
@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://solenta.app/solenta.schema.json",
"title": "Solenta project config",
- "description": "solenta.json (committed, shared) and solenta.local.json (gitignored, this checkout only). solenta.local.json may only hold setup, quickActions and onSettle. See https://solenta.app/docs#project-config",
+ "description": "solenta.json (committed, shared) and solenta.local.json (gitignored, this checkout only). solenta.local.json may only hold setup, quickActions, onSettle and devServer. See https://solenta.app/docs#project-config",
"type": "object",
"additionalProperties": false,
"properties": {
@@ -28,6 +28,7 @@
},
"name": { "type": "string", "minLength": 1, "maxLength": 32 },
"command": { "type": "string", "minLength": 1, "maxLength": 500 },
+ "stopOnSettle": { "type": "boolean", "default": false, "description": "Opt in to stop this managed action when its thread settles." },
"hidden": {
"description": "solenta.local.json only: hide the solenta.json entry with this id or name.",
"const": true
@@ -44,6 +45,7 @@
"type": "object",
"additionalProperties": false,
"properties": {
+ "stopOnSettle": { "type": "boolean", "default": false, "description": "Opt in to stop managed dev servers when their thread settles." },
"ready": {
"description": "Readiness probe: exactly one of tcp, http, exec.",
"type": "object",
diff --git a/src/components/EditProjectModal.tsx b/src/components/EditProjectModal.tsx
index 698db7ede..3dbb47424 100644
--- a/src/components/EditProjectModal.tsx
+++ b/src/components/EditProjectModal.tsx
@@ -128,6 +128,8 @@ export function EditProjectModal({
const [iconDirty, setIconDirty] = useState(false);
const [setupCommand, setSetupCommand] = useState(project.setupCommand ?? "");
const [waitForSetup, setWaitForSetup] = useState(project.waitForSetup === true);
+ const [stopOnSettleOverride, setStopOnSettle] = useState(project.stopOnSettle);
+ const stopOnSettle = stopOnSettleOverride ?? (project.repoConfig?.trusted && project.repoConfig?.devServerStopOnSettle === true);
const [setupCache, setSetupCache] = useState(project.setupCacheOff !== true);
const [branchPrefix, setBranchPrefix] = useState(project.branchPrefix ?? "");
const repo = project.repoConfig;
@@ -245,12 +247,14 @@ export function EditProjectModal({
setupCommand: setupCommand.trim() || null,
waitForSetup,
setupCacheOff: !setupCache,
+ ...(stopOnSettleOverride !== undefined ? { stopOnSettle: stopOnSettleOverride } : {}),
branchPrefix: branchPrefix.trim() || null,
quickActions: quickActions
.map((a) => ({
id: a.id,
name: a.name.trim(),
command: a.command.trim(),
+ ...(typeof a.stopOnSettle === "boolean" ? { stopOnSettle: a.stopOnSettle } : {}),
}))
.filter((a) => a.name && a.command),
env,
@@ -887,6 +891,14 @@ export function EditProjectModal({
setup runs.
+
+
+ setStopOnSettle(e.target.checked)} />
+ Stop managed servers and actions when a thread settles
+
+
Off by default. Stops only processes started by Solenta. Shared checkout processes stay running while another project thread is active. Output stays available.
+
Quick actions
@@ -939,6 +951,11 @@ export function EditProjectModal({
spellCheck={false}
disabled={pending}
/>
+
+ setQuickActions((rows) => rows.map((row, i) => i === index ? { ...row, stopOnSettle: e.target.checked } : row))} />
+ Stop on settle
+
{
m.unmount();
});
});
+
+
+describe("managed process stop-on-settle policy", () => {
+ it("preserves missing/inherited policy on unrelated Save and persists an explicit false", async () => {
+ for (const inherited of [undefined, true]) {
+ const calls: ProjectUpdateInput[] = [];
+ const p = project({ id: "stop-policy", ...(inherited ? { repoConfig: { trusted: true, devServerStopOnSettle: true } } : {}) });
+ const m = await mount( {}} onSubmit={async (input) => { calls.push(input); return input; }} />);
+ const toggle = m.query("[data-edit-project-stop-on-settle]") as HTMLInputElement;
+ assert.equal(toggle.checked, inherited === true);
+ await m.click(m.query("[data-edit-project-submit]")); await m.flush();
+ assert.equal(Object.hasOwn(calls[0]!, "stopOnSettle"), false);
+ if (inherited) {
+ await m.click(toggle);
+ await m.click(m.query("[data-edit-project-submit]")); await m.flush();
+ assert.equal(calls[1]!.stopOnSettle, false);
+ }
+ m.unmount();
+ }
+ });
+});
From 4b605bb504988048f3b6a67e6f47aea05649885e Mon Sep 17 00:00:00 2001
From: Willem
Date: Sat, 7 Nov 2026 15:55:58 +0100
Subject: [PATCH 6/9] Run new workflow regressions in Windows CI
---
scripts/test-electron.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/scripts/test-electron.js b/scripts/test-electron.js
index f4573bad2..e2bddcd0c 100644
--- a/scripts/test-electron.js
+++ b/scripts/test-electron.js
@@ -78,6 +78,8 @@ const WIN32_FILES = [
"electron/test/providers-set.test.js",
"electron/test/catalog-divergence.test.js",
"electron/test/reasoning-effort.test.js",
+ "electron/test/ultracode.test.js",
+ "electron/test/stop-on-settle.test.js",
"electron/test/rewind.test.js",
"electron/test/session-record.test.js",
"electron/test/secrets.test.js",
From 2e00ebba77cbe8c803ffd3d35a90a8a5937d5b3c Mon Sep 17 00:00:00 2001
From: Willem
Date: Sat, 7 Nov 2026 15:58:13 +0100
Subject: [PATCH 7/9] Preserve active Claude retry callbacks during session
disposal
---
electron/runner-claude-sessions.js | 5 ++++-
electron/test/provider-switch-context.test.js | 15 +++++++++++++++
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/electron/runner-claude-sessions.js b/electron/runner-claude-sessions.js
index 002889e0d..788fc8bd1 100644
--- a/electron/runner-claude-sessions.js
+++ b/electron/runner-claude-sessions.js
@@ -56,7 +56,10 @@ function createClaudeSessions(ctx) {
if (!sess) return;
claudeSessions.delete(threadId);
// An idle child can emit queued frames during kill; it no longer owns this thread.
- sess.dispatch = { onEvent() {}, onExit() {}, onError() {} };
+ // Active disposal also drives deaf-reuse recovery: its onExit must respawn the turn.
+ if (!active.has(threadId)) {
+ sess.dispatch = { onEvent() {}, onExit() {}, onError() {} };
+ }
if (sess.idleTimer) clearTimeout(sess.idleTimer);
try {
if (sess.handle) sess.handle.kill();
diff --git a/electron/test/provider-switch-context.test.js b/electron/test/provider-switch-context.test.js
index 1e5e03dd3..50c30e896 100644
--- a/electron/test/provider-switch-context.test.js
+++ b/electron/test/provider-switch-context.test.js
@@ -235,6 +235,21 @@ describe("provider switch keeps context (#1493)", () => {
assert.equal(store.getUsage(thread.id), null);
});
+ it("active warm-session disposal preserves the exit callback used to retry a deaf CLI", () => {
+ const { createClaudeSessions } = require("../runner-claude-sessions.js");
+ const thread = threadWithHistory();
+ const active = new Map([[thread.id, { runId: "retry-turn" }]]);
+ const sessions = createClaudeSessions({ active, store, finishRunningSubagents() {} });
+ let exited = false;
+ const session = { idleTimer: null, handle: { kill() {} }, dispatch: {
+ onEvent() {}, onExit() { exited = true; }, onError() {},
+ } };
+ sessions.claudeSessions.set(thread.id, session);
+ sessions.disposeClaudeSession(thread.id);
+ session.dispatch.onExit({});
+ assert.equal(exited, true, "active ACK-timeout retirement must reach the respawn callback");
+ });
+
it("fresh context refuses pending questions and queued work without mutating history", () => {
const thread = threadWithHistory();
for (const patch of [{ pendingQuestion: { id: "question" } }, { queued: { prompt: "later" } }, { status: "working" }]) {
From e7167fe39a6fb936ed73a4a6ff3c223e67645a42 Mon Sep 17 00:00:00 2001
From: Willem
Date: Sat, 7 Nov 2026 15:58:27 +0100
Subject: [PATCH 8/9] Update schema and thread defaults fixtures for workflow
controls
---
electron/test/layered-config.test.js | 2 +-
electron/test/store.test.js | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/electron/test/layered-config.test.js b/electron/test/layered-config.test.js
index 4e2d7a02e..55fcdd615 100644
--- a/electron/test/layered-config.test.js
+++ b/electron/test/layered-config.test.js
@@ -58,7 +58,7 @@ describe("strict keys and schema", () => {
assert.deepEqual(Object.keys(schema.properties).sort(), [...REPO_KEYS].sort());
assert.deepEqual(
Object.keys(schema.properties.quickActions.items.properties).sort(),
- ["command", "hidden", "id", "name"],
+ ["command", "hidden", "id", "name", "stopOnSettle"],
);
});
});
diff --git a/electron/test/store.test.js b/electron/test/store.test.js
index 57c827ad5..4ef937ca9 100644
--- a/electron/test/store.test.js
+++ b/electron/test/store.test.js
@@ -260,6 +260,7 @@ describe("Store", () => {
reasoningEffort: null,
webSearch: false,
fast: false,
+ ultracode: false,
worktreePath: null,
handoffFrom: null,
feltEstimate: null,
From e701e8481154505bbd289988894c528841cda72b Mon Sep 17 00:00:00 2001
From: Willem
Date: Sat, 7 Nov 2026 16:02:01 +0100
Subject: [PATCH 9/9] Bill fresh Codex snapshot deltas against adjusted usage
totals
---
electron/runner-provider-codex.js | 4 +--
electron/test/codex.test.js | 50 +++++++++++++++++++++++++++++++
2 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/electron/runner-provider-codex.js b/electron/runner-provider-codex.js
index a79434b23..e01e41403 100644
--- a/electron/runner-provider-codex.js
+++ b/electron/runner-provider-codex.js
@@ -438,10 +438,10 @@ function createCodexRun(ctx) {
store.recordSpend(costDelta);
}
const billedIn = snapshot
- ? Math.max(0, inDelta - prev.inputTokens)
+ ? Math.max(0, nextUsage.inputTokens - prev.inputTokens)
: inDelta;
const billedOut = snapshot
- ? Math.max(0, outDelta - prev.outputTokens)
+ ? Math.max(0, nextUsage.outputTokens - prev.outputTokens)
: outDelta;
store.recordUsage({
provider: threadProviderRef(thread),
diff --git a/electron/test/codex.test.js b/electron/test/codex.test.js
index e40255e5f..c14350338 100644
--- a/electron/test/codex.test.js
+++ b/electron/test/codex.test.js
@@ -689,6 +689,56 @@ describe("runner codex provider", () => {
assert.equal(readRpc(rpcFile).find(m => m.method === "thread/resume").params.threadId, "codex-sess-001");
});
+ it("bills cumulative snapshots after fresh context and resume against adjusted thread totals", async () => {
+ const thread = store.getThreads()[0];
+ store.setUsage(thread.id, { model: null, inputTokens: 100, outputTokens: 20, costUsd: 4, turns: 2 });
+ store.recordUsage({ provider: "codex", inputTokens: 100, outputTokens: 20, costUsd: 4, threadId: thread.id });
+ store.recordSpend(4);
+ let dispatch;
+ const makeRunner = () => createRunner({ store, core, pushFn() {},
+ runCodexFn(opts) { dispatch = opts; return { kill() {} }; },
+ });
+ runner.stopAll();
+ runner = makeRunner();
+ runner.freshAgentContext({ threadId: thread.id });
+ const snapshot = (inputTokens, outputTokens) => dispatch.onEvent({ type: "token_count",
+ info: { total_token_usage: { input_tokens: inputTokens, output_tokens: outputTokens } } });
+ const checkTokens = (inputTokens, outputTokens) => {
+ assert.equal(store.getUsage(thread.id).inputTokens, inputTokens);
+ assert.equal(store.getUsage(thread.id).outputTokens, outputTokens);
+ const daily = Object.values(store.data.usageByDay)[0].codex.unknown;
+ const perThread = Object.values(store.data.usageThreadsByDay)[0][thread.id];
+ assert.equal(daily.inputTokens, inputTokens);
+ assert.equal(daily.outputTokens, outputTokens);
+ assert.equal(perThread.inputTokens, inputTokens);
+ assert.equal(perThread.outputTokens, outputTokens);
+ };
+ await runner.startRun({ threadId: thread.id, prompt: "fresh request" });
+ assert.equal(dispatch.sessionId, null);
+ dispatch.onEvent({ type: "thread.started", thread_id: "new-ledger-session" });
+ snapshot(30, 3);
+ checkTokens(130, 23);
+ snapshot(60, 6);
+ checkTokens(160, 26);
+ dispatch.onEvent({ type: "result", usage: { input_tokens: 0, output_tokens: 0, cost_usd: 2 } });
+ dispatch.onExit({ code: 0, stderr: "" });
+ assert.equal(store.getUsage(thread.id).costUsd, 6);
+ store.saveNow();
+ runner.stopAll();
+ store = new Store(path.join(tmpDir, "store.json"));
+ runner = makeRunner();
+ await runner.startRun({ threadId: thread.id, prompt: "continue fresh request" });
+ assert.equal(dispatch.sessionId, "new-ledger-session");
+ snapshot(90, 9);
+ checkTokens(190, 29);
+ dispatch.onEvent({ type: "result", usage: { input_tokens: 0, output_tokens: 0, cost_usd: 1 } });
+ dispatch.onExit({ code: 0, stderr: "" });
+ assert.equal(store.getUsage(thread.id).costUsd, 7);
+ assert.equal(Object.values(store.data.usageByDay)[0].codex.unknown.costUsd, 7);
+ assert.equal(Object.values(store.data.usageThreadsByDay)[0][thread.id].costUsd, 7);
+ assert.equal(store.getSpendToday(), 7);
+ });
+
it("keeps the root sessionId when a v2 sub-agent emits thread/started", async () => {
process.env.CODER_FAKE_CODEX_SCENARIO = "child-spawn";
const thread = store.getThreads()[0];