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/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-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/ipc-threads.js b/electron/ipc-threads.js index bb5110264..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) => { @@ -738,6 +739,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..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" }, @@ -282,6 +283,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..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; @@ -101,7 +103,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 +113,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); @@ -121,7 +123,17 @@ function normalizeThreadDefaults(raw) { if (model) out.model = model; } const effort = str(r.reasoningEffort, 32); - if (effort) out.reasoningEffort = effort; + 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; } @@ -278,6 +290,7 @@ function resolveCommand(project, actionId) { command, timeoutMs: VERIFY_TIMEOUT_MS, fromRepo: row.fromRepo, + stopOnSettle: row.stopOnSettle, ...trust, }; } @@ -383,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); @@ -416,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", @@ -603,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({ @@ -610,6 +711,7 @@ async function runOne(store, args) { cwd, project, timeoutMs: resolved.timeoutMs, + signal: controller.signal, ...(env ? { env } : {}), ...(onOutput ? { onOutput } : {}), }); @@ -623,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, @@ -631,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, @@ -755,6 +859,12 @@ module.exports = { SUBMODULE_COMMAND, kickWorktreeSetup, runOnSettle, + stopManagedOnSettle, + sweepManagedOnSettle, + watchManagedOnSettle, + stopManagedWatcherIfIdle, + managedOwnerSettled, + canStopManaged, waitForCommand, pendingSetup, runCommand, diff --git a/electron/providers.js b/electron/providers.js index 846f23122..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"); @@ -10,8 +11,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 +133,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 +192,48 @@ 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)) { + // 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); + } 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 +432,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 +444,8 @@ const PROVIDERS = [ permissionMode, model, reasoningEffort, + ultracode, + cliSupportsUltracode: ultracodeCli = false, fast, agent, forkSessionAt, @@ -452,7 +497,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 +509,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 +2171,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 +2227,9 @@ module.exports = { listProviders, honouredEfforts, modelSupportsFast, + modelSupportsUltracode, + cliSupportsUltracode, + remoteCliSupportsUltracode, codexModelAcceptsImages, probeCatalogCli, catalogCliProbeStarted, 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/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/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..788fc8bd1 100644 --- a/electron/runner-claude-sessions.js +++ b/electron/runner-claude-sessions.js @@ -55,6 +55,11 @@ 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. + // 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/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-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/runner-provider-codex.js b/electron/runner-provider-codex.js index 119e3e509..e01e41403 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, }; @@ -430,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/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-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/services-threads.js b/electron/services-threads.js index 26be03171..e1bf5bf8d 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 @@ -789,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 = @@ -817,6 +837,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, @@ -890,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" && @@ -1216,7 +1239,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 +1325,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), @@ -1655,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, @@ -1663,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; @@ -2193,6 +2226,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..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; @@ -512,7 +513,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/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/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/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..c14350338 100644 --- a/electron/test/codex.test.js +++ b/electron/test/codex.test.js @@ -663,6 +663,82 @@ 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("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]; 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/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/electron/test/provider-switch-context.test.js b/electron/test/provider-switch-context.test.js index 73aa34f1b..50c30e896 100644 --- a/electron/test/provider-switch-context.test.js +++ b/electron/test/provider-switch-context.test.js @@ -130,6 +130,137 @@ 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("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" }]) { + 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/reasoning-effort.test.js b/electron/test/reasoning-effort.test.js index a18ef2f45..bb9bb87d0 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", 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/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/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/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, diff --git a/electron/test/ultracode.test.js b/electron/test/ultracode.test.js new file mode 100644 index 000000000..dafb0cd55 --- /dev/null +++ b/electron/test/ultracode.test.js @@ -0,0 +1,103 @@ +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'); +const { writeFakeBin } = require('./support/fakeBin'); + +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', () => { + 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 = 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 { + 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..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", @@ -187,6 +188,7 @@ const BY_SCOPE = { "threads:setReasoningEffort", "threads:setPlanModel", "threads:setFast", + "threads:setUltracode", "threads:setRoute", "threads:setWebSearch", "threads:setAgent", 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", 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/App.tsx b/src/App.tsx index 875dcdfa5..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, @@ -266,6 +267,7 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {}) applyAgentProfile, setWebSearch, setFast, + setUltracode, setPlanModel, setRoute, setAgent, @@ -1868,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(() => {}); } @@ -1888,6 +1892,7 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {}) toggleAgents, openStopAllConfirm, restartAgentSession, + freshAgentContext, ], ); @@ -2617,6 +2622,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..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 @@ -396,6 +408,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; @@ -1233,6 +1273,7 @@ export function useCoderThreadActions({ respondPermission, redirectPermission, restartAgentSession, + freshAgentContext, clearQuestion, answerSecret, setProvider, @@ -1242,6 +1283,7 @@ export function useCoderThreadActions({ setAgent, listAgents, setFast, + setUltracode, setPlanModel, setRoute, setArchived, 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/Composer.tsx b/src/components/Composer.tsx index d1a38e52c..8f7bcd4e1 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?.baseProvider ?? 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?.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, @@ -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 ? (
(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} /> +
)} + {(selected?.baseProvider ?? selected?.id) === "claude" && selected?.supportsUltracode === true && selected?.modelInfo.find((m) => m.id === draft.model)?.efforts?.includes("xhigh") && ( + + )} {selected?.supportsPlanModel && (