Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions electron/runner-permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -847,10 +847,9 @@ function createPermissions(ctx) {
const switchNote = before
? planActSwitchNote(getProvider(before.provider), before)
: null;
if (switchNote && typeof e.handle.setModel === "function") {
e.handle.setModel(before.model || null);
e.handle.modelSwitchedTo = before.model || null;
}
const modelSwitched = switchNote && typeof e.handle.setModel === "function"
? e.handle.setModel(before.model || null) !== false : false;
if (modelSwitched) e.handle.modelSwitchedTo = before.model || null;
e.handle.respond(pending.id, response);
if (isPlan) planPromptHandled.add(threadId);
if (isPlan && decision !== "deny") {
Expand Down Expand Up @@ -887,7 +886,15 @@ function createPermissions(ctx) {
? `Allowed for session: ${pending.summary}${ruleNote}`
: `Allowed: ${pending.summary}`;
appendMessage(threadId, "event", label, e.runId);
if (switchNote) appendMessage(threadId, "event", switchNote, e.runId);
if (switchNote && modelSwitched) {
// set_model changes this live run without creating a new runId. Keep
// its original effort: the control message only changes the model.
const configuration = e.runConfiguration
? { ...e.runConfiguration, model: before.model || null } : null;
appendMessage(threadId, "event", switchNote, e.runId, null, null,
configuration ? { runConfiguration: configuration } : null);
if (configuration) e.runConfiguration = configuration;
}
if (redirectable) {
if (redirectInline) {
// Same turn, same runId: the model reads the redirect off the
Expand Down Expand Up @@ -1185,10 +1192,6 @@ function createPermissions(ctx) {
"event",
approved ? "Plan approved" : "Plan rejected",
);
const switchNote = approved
? planActSwitchNote(getProvider(thread.provider), thread)
: null;
if (switchNote) appendMessage(threadId, "event", switchNote);
// No live CLI to hear the notes: they go first in line as the next
// turn, still in plan mode, and the drain below sends them.
if (!approved && feedback) {
Expand Down Expand Up @@ -1249,11 +1252,6 @@ function createPermissions(ctx) {
? approved ? "Plan approved" : "Plan rejected"
: `${approved ? "Allowed" : "Denied"} after restart: ${card.summary}`,
);
const switchNote =
approved && card.plan
? planActSwitchNote(getProvider(thread.provider), thread)
: null;
if (switchNote) appendMessage(threadId, "event", switchNote);
if (line) {
services.restoreQueuedHead(store, { threadId, taken: { prompt: line, items: [line] } });
}
Expand Down
10 changes: 10 additions & 0 deletions electron/runner-provider-claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,16 @@ function createClaudeRun(ctx) {
if (abortIfCancelled(threadId, runId)) return { runId };
const entry = claimPreparingRun(threadId, runId, {
kind: "claude",
// Use the emitted effort after model/version gates. Ultracode can
// override the picker's stored effort; the same argv goes to spawn.
...(entryDef.id === "claude" ? {
runConfiguration: {
provider: entryDef.id,
model: thread.model || null,
reasoningEffort: args.includes("--effort")
? args[args.indexOf("--effort") + 1] : null,
},
} : {}),
startingId,
workingId,
claudeState,
Expand Down
147 changes: 103 additions & 44 deletions electron/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const {
isBinAvailable,
listProviders,
codexModelAcceptsImages,
turnModelFor,
honouredEfforts,
} = require("./providers.js");
const orchcommands = require("./orchcommands.js");
const cliCommands = require("./cliCommands.js");
Expand Down Expand Up @@ -1477,12 +1479,18 @@ function createRunner(opts) {
createdAt: Date.now(),
};
if (runId) msg.runId = runId;
// Keep attribution on output too: a paged tail can omit its start event.
const live = runId && active.get(threadId);
if (role === "assistant" && live && live.runId === runId && live.runConfiguration) {
msg.runConfiguration = { ...live.runConfiguration };
}
if (tool) msg.tool = tool;
if (attachments && attachments.length) msg.attachments = attachments;
if (extra && extra.thinking) msg.thinking = true;
if (extra && extra.fromNotice === true) msg.fromNotice = true;
if (extra && extra.steer === true) msg.steer = true;
if (extra && extra.pdfText === true) msg.pdfText = true;
if (extra && extra.runConfiguration) msg.runConfiguration = extra.runConfiguration;
if (extra && extra.fromThread && extra.fromThread.id) {
msg.fromThread = {
id: String(extra.fromThread.id),
Expand Down Expand Up @@ -1552,6 +1560,15 @@ function createRunner(opts) {
}

function markRunFailed(threadId, errText, runId, extraPatch) {
// A refused launch/auth/transport can fail asynchronously after dispatch
// returned a handle. With no agent output there is no conversation portion
// to attribute; keep its error, but discard the provisional model divider.
const messages = store.getMessages(threadId);
if (!messages.some((m) => m.runId === runId &&
(m.role === "assistant" || m.role === "tool" || m.thinking))) {
const kept = messages.filter((m) => !(m.runId === runId && m.role === "event" && m.runConfiguration));
if (kept.length !== messages.length) store.setMessages(threadId, kept);
}
const overflow = classifyContextOverflow(errText);
const writerLock = overflow ? null : classifyWriterLock(errText);
const upgrade = overflow || writerLock ? null : classifyCliUpgrade(errText);
Expand Down Expand Up @@ -1788,9 +1805,23 @@ function createRunner(opts) {
const existing = active.get(threadId);
if (!existing || existing.runId !== runId) return null;
Object.assign(existing, fields);
// Adapters can refine the provisional picker snapshot from their resolved
// launch arguments (for example version-gated Claude Ultracode effort).
if (fields.runConfiguration) {
const boundary = store.getMessages(threadId).find((m) =>
m.runId === runId && m.role === "event" && m.runConfiguration);
if (boundary) store.updateMessage(threadId, boundary.id, {
runConfiguration: fields.runConfiguration,
text: runConfigurationText(fields.runConfiguration),
});
}
return existing;
}

function runConfigurationText(configuration) {
return `${configuration.provider} · ${configuration.model || "default model"} · ${configuration.reasoningEffort ? `${configuration.reasoningEffort} effort` : "default effort"}`;
}

function stampPreparingSteps(threadId, runId, steps) {
const pending = active.get(threadId);
if (pending && pending.runId === runId) Object.assign(pending, steps);
Expand Down Expand Up @@ -2549,57 +2580,85 @@ function createRunner(opts) {

const name = workflowNameFromThreadId(threadId);

if (provider === "simulate") {
return await startSimulatedRun(threadId, dispatchPrompt, runId, name);
}
if (provider === "generic") {
return await startGenericRun(threadId, dispatchPrompt, runId, name);
}
// Snapshot what the adapter is about to dispatch, including plan-mode
// overrides. Picker changes during a live turn only affect its next run.
const providerEntry = getProvider(provider);
const selected = turnModelFor(providerEntry, store.getThread(threadId));
const configuration = {
provider,
model: selected.model,
reasoningEffort: honouredEfforts(providerEntry, selected.model).includes(selected.reasoningEffort)
? selected.reasoningEffort : null,
};
pendingEntry.runConfiguration = configuration;
const launch = async () => {
if (provider === "simulate") {
return await startSimulatedRun(threadId, dispatchPrompt, runId, name);
}
if (provider === "generic") {
return await startGenericRun(threadId, dispatchPrompt, runId, name);
}

const entryDef = getProvider(provider) || getProvider("claude");
if (entryDef.kind === "claude-stream") {
return await startClaudeRun(threadId, dispatchPrompt, runId, entryDef);
}
if (entryDef.kind === "codex-json") {
return await startCodexRun(
threadId,
dispatchPrompt,
runId,
entryDef,
nativeImages,
);
}
if (entryDef.kind === "kimi-stream") {
return await startKimiRun(threadId, dispatchPrompt, runId, entryDef);
}
if (entryDef.kind === "opencode-json") {
return await startOpencodeRun(
const entryDef = getProvider(provider) || getProvider("claude");
if (entryDef.kind === "claude-stream") {
return await startClaudeRun(threadId, dispatchPrompt, runId, entryDef);
}
if (entryDef.kind === "codex-json") {
return await startCodexRun(
threadId,
dispatchPrompt,
runId,
entryDef,
nativeImages,
);
}
if (entryDef.kind === "kimi-stream") {
return await startKimiRun(threadId, dispatchPrompt, runId, entryDef);
}
if (entryDef.kind === "opencode-json") {
return await startOpencodeRun(
threadId,
dispatchPrompt,
runId,
entryDef,
nativeFiles,
);
}
if (entryDef.kind === "cursor-stream") {
return await startCursorRun(threadId, dispatchPrompt, runId, entryDef);
}
if (entryDef.kind === "muse-json") {
return await startMuseRun(threadId, dispatchPrompt, runId, entryDef);
}
return await startClaudeRun(
threadId,
dispatchPrompt,
runId,
entryDef,
nativeFiles,
getProvider("claude"),
);
};
// A separate boundary also covers queued/replayed starts with no new
// user row. Stage it immediately before dispatch and roll it back if
// launch is refused, throws, or Stop cancels preparation.
const boundaryText = runConfigurationText(configuration);
const boundaryId = appendMessage(threadId, "event", boundaryText, runId,
null, null, { runConfiguration: configuration });
let accepted = false;
try {
const result = await launch();
accepted = pendingEntry.kind !== "preparing" && !pendingEntry.stopping;
return result;
} finally {
if (!accepted) {
store.setMessages(threadId, store.getMessages(threadId).filter((m) => m.id !== boundaryId));
}
store.save();
pushDetail(threadId);
}
if (entryDef.kind === "cursor-stream") {
return await startCursorRun(threadId, dispatchPrompt, runId, entryDef);
}
if (entryDef.kind === "muse-json") {
return await startMuseRun(threadId, dispatchPrompt, runId, entryDef);
}
return await startClaudeRun(
threadId,
dispatchPrompt,
runId,
getProvider("claude"),
);
} catch (err) {
// A throw before a provider claimed the slot would leak it and the
// thread would read "already active" forever (#1430 R5).
if (
active.get(threadId) === pendingEntry &&
pendingEntry.kind === "preparing"
) {
// A provider can throw after claiming preparation but before returning
// a handle. Release that same entry too, so Retry can start again.
if (active.get(threadId) === pendingEntry) {
clearRun(threadId);
store.updateThread(
threadId,
Expand Down
14 changes: 2 additions & 12 deletions electron/services-threads.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const {
catalogCliProbeStarted,
honouredPermissionModes,
snapPermissionMode,
planActSwitchNote,
} = require("./providers.js");
const providerAuth = require("./providerAuth.js");
const {
Expand Down Expand Up @@ -489,17 +488,8 @@ function setPermissionMode(store, input) {
if (!thread.pendingQuestion) patch.awaitingInput = false;
}
const updated = store.updateThread(threadId, patch);
// Leaving plan mode hands the next turn to the act model (#1716).
const switchNote =
mode !== "plan" ? planActSwitchNote(entry, thread) : null;
if (switchNote) {
store.appendMessage(threadId, {
id: randomUUID(),
role: "event",
text: switchNote,
createdAt: Date.now(),
});
}
// Model/effort history is written by the runner when the next turn
// actually dispatches, including a queued or rejected start (#1927).
store.save();
const row = updated || { ...thread, ...patch };
return decorateThread(store, row);
Expand Down
4 changes: 3 additions & 1 deletion electron/test/claude.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2516,7 +2516,9 @@ describe("runner claude provider", () => {
"the real turn must land",
);
assert.deepEqual(
msgs.filter((m) => m.role === "event").map((m) => m.text),
msgs
.filter((m) => m.role === "event" && !m.runConfiguration)
.map((m) => m.text),
[],
"no fabricated failure while the CLI was still working",
);
Expand Down
Loading
Loading