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) => ( + + ) : 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") && ( + + )} {selected?.supportsPlanModel && (
)} - {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") && ( )} {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.

+
+ +

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} /> +