diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md
index f90af54f..cf099a04 100644
--- a/docs/guides/MODULE_MAP.md
+++ b/docs/guides/MODULE_MAP.md
@@ -80,6 +80,9 @@ YOKE is the vendor-agnostic interface layer. Each adapter implements the same co
| `yoke/index.js` | Adapter factory, wraps createQuery with project instructions |
| `yoke/interface.js` | YOKE interface contract definition |
| `yoke/adapters/claude.js` | Claude adapter using `@anthropic-ai/claude-agent-sdk`. In-process + worker (OS user isolation) paths |
+| `yoke/adapters/permission-relay.js` | The wire shape of a tool-permission ask between a Claude worker and the daemon, including the SDK's presentation hints (`suppressAlwaysAllowRule`, `defaultToNo`, `matchedAskRule`); both sides build and read it here |
+| `yoke/adapters/effort-control.js` | Changing a running Claude session's effort through `applyFlagSettings({effortLevel})`; shared by the in-process handle and the worker |
+| `yoke/adapters/claude-capabilities.js` | What a Claude session can do, sent to the client (including the `permissionModes` the mode picker offers) |
| `yoke/adapters/codex.js` | Codex adapter using `codex app-server` JSON-RPC protocol. Handles approval events, skill injection, MCP bridge config |
| `yoke/codex-app-server.js` | Codex `app-server` child process manager. JSON-RPC 2.0 over stdin/stdout, request ID tracking, event routing |
| `yoke/mcp-bridge-server.js` | Stdio MCP server spawned by Codex. Proxies tool list/call to the daemon via HTTP at `/api/mcp-bridge` |
@@ -144,7 +147,8 @@ Bootstraps UI, initializes store, wires remaining Tier 3 modules. All business l
| `prompts.js` | The client's single owner of operator-prompt cards: every prompt message, of every kind and recorded shape, applies through `apply()`; a card is a function of the server's state for its requestId |
| `prompt-state.js` | Per-requestId mirror of the server's prompt state (terminal states are final) and the mapping of older recorded message shapes onto the one prompt shape |
| `prompt-card.js` | The card shell every kind shares: pending/sending/confirmed state machine, terminal outcome rendering |
-| `prompt-kinds/` | One renderer per prompt kind (permission, plan, ask-user, elicitation): body and answer controls, outcome labels |
+| `prompt-kinds/` | One renderer per prompt kind (permission, plan, ask-user, elicitation): body and answer controls, outcome labels. `permission-choices.js` decides which answers a tool-permission ask offers, and in what order, from the SDK's hints; the card and the banner both read it |
+| `mode-options.js` | The permission modes the config chip offers: the three every session has, Don't ask and Auto only for a vendor that declares them, Full auto when the daemon allows it |
| `app-skills-install.js` | Skill install dialog, requireSkills |
| `app-favicon.js` | Dynamic favicon, IO blink, urgent blink, send button mode, activity indicator |
| `app-header.js` | Session rename, session info popover, progressive history loading |
diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md
index e6872721..0645c17c 100644
--- a/docs/guides/architecture.md
+++ b/docs/guides/architecture.md
@@ -155,6 +155,8 @@ graph LR
For deeper Codex integration patterns and gotchas, see [CODEX-INTEGRATION.md](./CODEX-INTEGRATION.md).
+**Mid-session controls.** The Claude SDK's `Query` has no `setEffort`; the Claude adapter changes a running session's effort through `applyFlagSettings({effortLevel})` (`yoke/adapters/effort-control.js`, shared by the in-process handle and the worker). A QueryHandle's `setEffort` settles on the real outcome and rejects when the change did not take effect, so the bridge announces a new effort only after the query has taken it and shows the operator the failure otherwise; Codex reads its effort on the next turn. Which permission modes a session accepts is a capability: the Claude adapter lists `permissionModes` (including `dontAsk` and `auto`, which are Claude Code modes) in the capabilities it sends with `session_switched`, the mode picker (`public/modules/mode-options.js`) offers those two only when the session's vendor declares them, and `set_permission_mode` refuses them for a session whose vendor does not. A vendor whose capabilities are not known is treated as declaring nothing.
+
## Multi-User and OS-Level Isolation
Clagentic:Console supports two modes:
@@ -248,6 +250,18 @@ A prompt is `pending` until exactly one transition settles it — an answer (`re
Ownership decides scope. A top-level prompt ends with its turn or its query. A prompt whose tool call belongs to a tracked sub-agent (a running Task, or one that reported completion while that prompt was still open) survives the parent's turn and query boundaries, so the operator can still answer a backgrounded sub-agent after its parent finished; it ends only on an answer, its own abort, or the session or daemon ending. The abort is the vendor's word that the callback is gone: the Claude SDK aborts every callback still waiting when it closes a query, the worker relay does the same when the worker's query ends or the worker cancels a callback, and the Codex adapter when the app-server clears a request or the query ends. The registry never decides on its own that a sub-agent is dead. "Allow for Session" grants are written and consulted only through the registry, keyed by `permissionGrantKey()`, flushed to the session file immediately, and granted only from the authenticated in-app card: the HTTP push route answers tool approvals with allow or deny only.
+#### Presentation hints on a tool-permission ask
+
+The Claude Agent SDK may attach three hints to an ask, and the console holds to each wherever it carries, shows or answers one. They travel from the SDK's `canUseTool` options into the prompt record on both Claude paths: in process, and over the worker IPC (`lib/yoke/adapters/permission-relay.js` builds the message in the worker and the options in the daemon, so neither side can drop a field the other carries). Codex approvals arrive through the same callback with no hints and are unchanged.
+
+- `suppressAlwaysAllowRule`: the ask must not offer a persistent choice. The permission kind refuses an `allow_always` answer to it as `invalid` on every response path (the card and its older aliases, the notification banner), which settles nothing, writes no grant and re-offers the prompt to the responder; the HTTP push route answers allow or deny only. Leaving the button off the card and the banner is the presentation of that rule, not its enforcement.
+- `defaultToNo`: the ask must not open on an approve choice. The card and the banner lead with the decline choice, mark it the default and mark no approve choice; there is no single-key approve shortcut.
+- `matchedAskRule`: a rule the user wrote forced the prompt, so the user's stated intent is a human prompt. The sdk bridge's host-side auto-approvals (full auto, an autonomous loop, the read-only whitelist, an earlier Allow for session) never answer such an ask. They do not answer an ask marked `suppressAlwaysAllowRule` or `defaultToNo` with a standing approval either, since a standing approval grants more than the ask itself. The rule (bounded text, whatever shape it arrives in) is recorded on the prompt record and on the `prompt_resolved` event, and the card shows it.
+
+The hints are request fields, so the recorded `prompt_request`, the `prompt_pending` snapshot sent on connect and the history pages all carry them, and a replayed card renders the same restrictions. The banner reads them from the notification's `meta`.
+
+A browser-extension command (kind `extension`) is answered by the socket it was sent to and no other: `extension_result` is checked against that socket, and a `prompt_response` naming an extension command is no operator answer on any path.
+
The registry's state lives where readers already looked for it: one pending map per store (`session.pendingPermissions` for tool and plan approvals, `session.pendingAskUser`, `session.pendingElicitations`), `session.subagentToolOwners`, `session.activeTaskToolIds` (Task tracking), and the session manager's `permissionRequestIndex`. A requestId, message type, decision, cancel reason or kind is whatever a client sent, so every map keyed by one, on the server and in the client, is read through `own-key.js` (`ownValue()` / `hasOwnKey()`: own properties only, strings only) and written through `putOwn()`. A name every object inherits (`constructor`, `__proto__`, `toString`, ...) is no prompt and no table entry: on every answer path it gets the not-found reply (`test/prompt-hostile-request-id.test.js`).
#### Supported elicitation schemas
diff --git a/lib/project-notifications.js b/lib/project-notifications.js
index 3722dbbd..d83a8e96 100644
--- a/lib/project-notifications.js
+++ b/lib/project-notifications.js
@@ -65,7 +65,15 @@ var formatters = {
type: "permission_request",
title: data.title || "Permission requested",
body: data.body || "",
- meta: { requestId: data.requestId, kind: data.kind || "permission", toolName: data.toolName, toolInput: data.toolInput || null },
+ meta: {
+ requestId: data.requestId,
+ kind: data.kind || "permission",
+ toolName: data.toolName,
+ toolInput: data.toolInput || null,
+ // So the banner offers the answers the card does (see prompt-banner.js).
+ suppressAlwaysAllowRule: data.suppressAlwaysAllowRule === true ? true : undefined,
+ defaultToNo: data.defaultToNo === true ? true : undefined,
+ },
};
},
diff --git a/lib/project-prompt-responses.js b/lib/project-prompt-responses.js
index fe45209d..7a0d89eb 100644
--- a/lib/project-prompt-responses.js
+++ b/lib/project-prompt-responses.js
@@ -12,6 +12,7 @@
// upgrade can still answer; each alias is declared in lib/ws-schema.js.
var { promptsFor } = require("./prompt-registry");
+var promptKinds = require("./prompt-kinds");
var { mayAnswerPrompt } = require("./prompt-access");
var { ownValue } = require("./prompt-kinds/codecs");
@@ -170,8 +171,12 @@ function attachPromptResponses(ctx) {
}
});
+ // A browser-extension command belongs to the socket it was sent to, which
+ // answers with extension_result (project-user-message.js): a prompt_response
+ // naming one is no answer from anyone who could give it.
function answer(ws, msg, kinds, staleReplyType) {
- var outcome = mayAnswer(ws, msg.requestId)
+ var answerable = kinds.every(promptKinds.operatorMayAnswer) && mayAnswer(ws, msg.requestId);
+ var outcome = answerable
? promptsFor(sm).respond(msg.requestId, msg, { session: getSessionForWs(ws), kinds: kinds, responder: ws })
: { status: "stale" };
if (outcome.status === "resolved") return;
diff --git a/lib/project-sessions.js b/lib/project-sessions.js
index 2779289e..be4dc8aa 100644
--- a/lib/project-sessions.js
+++ b/lib/project-sessions.js
@@ -77,6 +77,12 @@ var PROCESS_STATS_BASE_FIELD_KEYS = [
"memAvailableMB",
];
+// auto and dontAsk exist only in Claude Code. A session whose vendor does not
+// declare them (Codex, or a vendor whose capabilities are not known yet) cannot
+// run them, so a request for one is refused rather than handed to a vendor
+// that would reject or ignore it.
+var CAPABILITY_GATED_MODES = { auto: true, dontAsk: true };
+
function attachSessions(ctx) {
var cwd = ctx.cwd;
var slug = ctx.slug;
@@ -135,6 +141,13 @@ function attachSessions(ctx) {
ensureProjectAccessForSession: ensureProjectAccessForSession,
});
+ function sessionAcceptsMode(session, mode) {
+ if (!ownValue(CAPABILITY_GATED_MODES, mode)) return true;
+ var vendor = session.vendor || sm.defaultVendor || "claude";
+ var caps = ownValue(sm.capabilitiesByVendor || Object.create(null), vendor);
+ return !!caps && Array.isArray(caps.permissionModes) && caps.permissionModes.indexOf(mode) !== -1;
+ }
+
function handleSessionsMessage(ws, msg) {
if (promptResponses.handlePromptMessage(ws, msg)) return true;
@@ -954,6 +967,11 @@ function attachSessions(ctx) {
if (msg.type === "set_permission_mode" && msg.mode) {
var session = getSessionForWs(ws);
+ if (session && !sessionAcceptsMode(session, msg.mode)) {
+ sendTo(ws, { type: "error", text: "This session's agent does not support the " + String(msg.mode).slice(0, 40) + " permission mode" });
+ sendTo(ws, { type: "config_state", model: sm.effectiveSessionModel(session), mode: session.permissionMode || sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
+ return true;
+ }
if (session) {
sdk.setPermissionMode(session, msg.mode).then(function () {
// Targeted reply — only update the requesting client's chip.
@@ -992,12 +1010,23 @@ function attachSessions(ctx) {
}
if (msg.type === "set_effort" && msg.effort) {
- sm.currentEffort = msg.effort;
var session = getSessionForWs(ws);
- if (session) {
- sdk.setEffort(session, msg.effort);
+ if (!session) {
+ sm.currentEffort = msg.effort;
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
+ return true;
}
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
+ // The effort is recorded only once the running query has taken it
+ // (sdk.setEffort announces it then). A refused change is reported by the
+ // bridge as an error; the requester's picker is put back on the effort
+ // the session still runs at.
+ sdk.setEffort(session, msg.effort).then(function (result) {
+ var state = { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 };
+ if (result && result.ok === false) sendTo(ws, state);
+ else send(state);
+ }).catch(function (err) {
+ console.error("[project-sessions] set_effort failed:", (err && err.message) || err);
+ });
return true;
}
diff --git a/lib/prompt-kinds/codecs.js b/lib/prompt-kinds/codecs.js
index 452bfe20..6f1a7217 100644
--- a/lib/prompt-kinds/codecs.js
+++ b/lib/prompt-kinds/codecs.js
@@ -35,7 +35,6 @@ function sourcePath(name) {
return path.join(SOURCE_DIR, FILES[name]);
}
-/** The file: URL of a shared module, as import() resolves it. */
function sourceUrl(name) {
return pathToFileURL(sourcePath(name)).href;
}
diff --git a/lib/prompt-kinds/extension.js b/lib/prompt-kinds/extension.js
index f6a551a0..9b77801a 100644
--- a/lib/prompt-kinds/extension.js
+++ b/lib/prompt-kinds/extension.js
@@ -10,6 +10,7 @@ module.exports = {
name: "extension",
scope: "project",
journal: false,
+ operatorAnswerable: false,
defaultTimeoutMs: 3000,
fields: function (req) {
diff --git a/lib/prompt-kinds/index.js b/lib/prompt-kinds/index.js
index 68f790d3..ca2ebff0 100644
--- a/lib/prompt-kinds/index.js
+++ b/lib/prompt-kinds/index.js
@@ -23,7 +23,17 @@ function toolApprovalKind(toolName) {
return toolName === "ExitPlanMode" ? "plan" : "permission";
}
-/** Session fields that hold pending prompts, one per store. */
+/**
+ * Whether an operator's answer may settle prompts of this kind. A browser
+ * extension's command is answered by the extension's own socket and no other
+ * (lib/extension-commands.js), so no operator-facing answer path reaches it.
+ * An unknown kind is not answerable.
+ */
+function operatorMayAnswer(kindName) {
+ var kind = ownValue(KINDS, kindName);
+ return !!kind && kind.operatorAnswerable !== false;
+}
+
function sessionStores() {
var seen = {};
var out = [];
@@ -52,6 +62,7 @@ function kindOfRecord(record, store) {
module.exports = {
KINDS: KINDS,
toolApprovalKind: toolApprovalKind,
+ operatorMayAnswer: operatorMayAnswer,
sessionStores: sessionStores,
kindOfRecord: kindOfRecord,
};
diff --git a/lib/prompt-kinds/permission.js b/lib/prompt-kinds/permission.js
index 59c5e0d2..5968758e 100644
--- a/lib/prompt-kinds/permission.js
+++ b/lib/prompt-kinds/permission.js
@@ -3,12 +3,51 @@
// Tool-call approval: allow this call, allow the tool for the rest of the
// session, or deny. Codex command, file-change and MCP approvals arrive here
// through the same canUseTool callback as Claude's.
+//
+// The Claude Agent SDK may attach presentation hints to an ask, and the
+// console holds to them wherever it shows or answers one:
+// suppressAlwaysAllowRule the ask must not offer a persistent choice, so
+// an Allow-for-session answer is refused here, not
+// just left off the card;
+// defaultToNo the ask must not open on an approve choice;
+// matchedAskRule a user-written ask rule forced the prompt, so no
+// host-side auto-approval may answer it.
+// An ask without hints (Codex approvals, older CLIs) is unchanged.
var toolCall = require("./tool-call");
var { hasOwnKey } = require("./codecs");
var DECISIONS = { allow: true, allow_always: true, deny: true };
+// A rule's source and text are authored outside the console and shown to the
+// operator, so they are held to a bound and to strings.
+var RULE_TEXT_MAX = 200;
+
+function ruleText(value) {
+ return typeof value === "string" ? value.slice(0, RULE_TEXT_MAX) : "";
+}
+
+// A present rule that is not the expected shape is still a rule-forced ask:
+// the hint fails closed.
+function matchedRuleOf(raw) {
+ if (!raw) return undefined;
+ var rule = typeof raw === "object" ? raw : {};
+ var out = { source: ruleText(rule.source), toolName: ruleText(rule.toolName) };
+ if (typeof rule.ruleContent === "string") out.ruleContent = ruleText(rule.ruleContent);
+ return out;
+}
+
+// The hints present on a request or a stored record, normalized; absent ones
+// are left out so an ask without hints is described exactly as before.
+function hintsOf(source) {
+ var hints = {};
+ if (source.suppressAlwaysAllowRule === true) hints.suppressAlwaysAllowRule = true;
+ if (source.defaultToNo === true) hints.defaultToNo = true;
+ var rule = matchedRuleOf(source.matchedAskRule);
+ if (rule) hints.matchedAskRule = rule;
+ return hints;
+}
+
module.exports = {
name: "permission",
scope: "session",
@@ -16,28 +55,43 @@ module.exports = {
journal: true,
fields: function (req) {
- return {
+ return Object.assign({
toolName: req.toolName,
toolInput: req.toolInput,
decisionReason: req.decisionReason || "",
vendor: req.vendor,
- };
+ }, hintsOf(req));
},
requestFields: function (record) {
- return {
+ return Object.assign({
toolName: record.toolName,
toolInput: record.toolInput,
decisionReason: record.decisionReason || "",
vendor: record.vendor,
+ }, hintsOf(record));
+ },
+
+ // What a banner needs to offer the same answers as the card.
+ notificationFields: function (record) {
+ var hints = hintsOf(record);
+ return {
+ suppressAlwaysAllowRule: hints.suppressAlwaysAllowRule,
+ defaultToNo: hints.defaultToNo,
};
},
decisions: Object.keys(DECISIONS),
- // Anything but a known decision fails closed.
- parse: function (msg) {
- return { decision: hasOwnKey(DECISIONS, msg.decision) ? msg.decision : "deny" };
+ // Anything but a known decision fails closed. An Allow-for-session answer to
+ // an ask that must not offer one settles nothing: the prompt stays pending
+ // and is offered again to the responder.
+ parse: function (msg, record) {
+ var decision = hasOwnKey(DECISIONS, msg.decision) ? msg.decision : "deny";
+ if (decision === "allow_always" && record && record.suppressAlwaysAllowRule === true) {
+ return { invalid: "always_allow_not_offered" };
+ }
+ return { decision: decision };
},
outcome: function (record, response, opts) {
@@ -49,8 +103,11 @@ module.exports = {
return response.decision === "allow_always";
},
- resolvedFields: function (response) {
- return { decision: response.decision };
+ resolvedFields: function (response, record) {
+ var hints = record ? hintsOf(record) : {};
+ var fields = { decision: response.decision };
+ if (hints.matchedAskRule) fields.matchedAskRule = hints.matchedAskRule;
+ return fields;
},
cancelled: toolCall.cancelled,
diff --git a/lib/prompt-registry.js b/lib/prompt-registry.js
index 1ba9c09a..41b995c4 100644
--- a/lib/prompt-registry.js
+++ b/lib/prompt-registry.js
@@ -45,7 +45,7 @@ var crypto = require("crypto");
var utils = require("./utils");
var promptKinds = require("./prompt-kinds");
var codecs = require("./prompt-kinds/codecs");
-var { ownValue, putOwn } = codecs;
+var { ownValue, putOwn, hasOwnKey } = codecs;
var KINDS = promptKinds.KINDS;
var STORES = promptKinds.sessionStores();
@@ -88,6 +88,8 @@ function terminalOf(e) {
case "ask_user_answered":
if (!e.toolId) return null;
if (e.answers && Object.keys(e.answers).length) return { id: e.toolId, state: { state: "resolved", answers: e.answers } };
+ // A recorded skip is an answer, not an ending nobody chose.
+ if (e.skipped === true) return { id: e.toolId, state: { state: "resolved", skipped: true } };
return { id: e.toolId, state: { state: "cancelled", reason: null } };
default:
return null;
@@ -207,7 +209,11 @@ function createPromptRegistry(deps) {
pruneToolOwners(session, taskId);
}
- // Forget every Task that no longer owns a pending prompt.
+ // Forget every Task that no longer owns a pending prompt. Only the owning
+ // query's end and a new query's start call this, and a closed query takes
+ // its CLI process, and every sub-agent in it, down: a Task tracked without
+ // a prompt then has nothing left to ask. A replacement query's Tasks are
+ // never touched (endQuery gates on ownsSession).
function pruneTasks(session) {
var tasks = trackedTasks(session);
Object.keys(tasks).forEach(function (taskId) {
@@ -251,8 +257,12 @@ function createPromptRegistry(deps) {
});
}
+ // The request index is shared by every session of the project, so a
+ // tool-use id is reused as a request id only while no session holds a prompt
+ // under it; otherwise the index entry that routes the other session's
+ // answers would be overwritten.
function newRequestId(session, kind, toolUseId) {
- if (kind.idFromToolUse && toolUseId && !findInSession(session, toolUseId)) return toolUseId;
+ if (kind.idFromToolUse && toolUseId && !findInSession(session, toolUseId) && !hasOwnKey(index, toolUseId)) return toolUseId;
return crypto.randomUUID();
}
@@ -337,7 +347,8 @@ function createPromptRegistry(deps) {
if (kind.journal && opts.notification) {
var nm = getNotificationsModule();
if (nm && typeof nm.notify === "function") {
- nm.notify("permission_request", Object.assign({}, opts.notification, { requestId: requestId, kind: kind.name }));
+ var kindFields = typeof kind.notificationFields === "function" ? kind.notificationFields(record) : {};
+ nm.notify("permission_request", Object.assign({}, opts.notification, kindFields, { requestId: requestId, kind: kind.name }));
}
}
return { requestId: requestId, answer: answer, pending: true };
@@ -418,7 +429,7 @@ function createPromptRegistry(deps) {
}
if (kind.grantsSession(response)) grant(found.session, found.record.toolName, found.record.toolInput);
var event = kind.journal
- ? Object.assign({ type: "prompt_resolved", requestId: requestId, kind: kind.name }, kind.resolvedFields(response))
+ ? Object.assign({ type: "prompt_resolved", requestId: requestId, kind: kind.name }, kind.resolvedFields(response, found.record))
: null;
var prompt = publicView(requestId, found);
settle(found.session, requestId, found, kind.outcome(found.record, response, opts), event);
diff --git a/lib/public/css/rewind.css b/lib/public/css/rewind.css
index 18a01d2f..2462cf47 100644
--- a/lib/public/css/rewind.css
+++ b/lib/public/css/rewind.css
@@ -616,6 +616,23 @@
.permission-deny { background: transparent; border-color: var(--border); color: var(--text-muted); }
.permission-deny:hover { background: var(--error-8); border-color: var(--error); color: var(--error); opacity: 1; }
+/* The user's own ask rule that forced the prompt. */
+.permission-rule { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
+
+/* An ask that must open on No: the approve choices lose their primary
+ styling and the decline choice leads. */
+.permission-container[data-default-to-no="true"] .permission-allow,
+.permission-container[data-default-to-no="true"] .perm-allow,
+.permission-container[data-default-to-no="true"] .perm-allow:hover {
+ background: transparent;
+ color: var(--text-muted);
+ border-color: var(--border);
+}
+.permission-container[data-default-to-no="true"] [data-default-choice="true"] {
+ border-color: var(--text-dimmer);
+ color: var(--text);
+}
+
/* Resolved states */
.permission-container.resolved .permission-btn { display: none; }
.permission-decision-label { font-size: 13px; font-weight: 500; }
diff --git a/lib/public/modules/app-notifications.js b/lib/public/modules/app-notifications.js
index e4212369..e1b98c40 100644
--- a/lib/public/modules/app-notifications.js
+++ b/lib/public/modules/app-notifications.js
@@ -12,23 +12,10 @@ import { switchProject } from './app-projects.js';
import { userAvatarUrl } from './avatar.js';
import { openTerminal } from './terminal.js';
import { askWording } from './prompt-kinds/index.js';
-import { ownValue } from './prompt-kinds/own-key.js';
-import { wirePromptBanner, restoreUnconfirmedBanners } from './prompt-banner.js';
-
-// What a prompt banner offers, by prompt kind. Kinds without an entry offer
-// only "Go to session". The server dismisses the banner once the prompt
-// settles, however it was answered.
-var BANNER_ACTIONS = {
- permission: [
- { decision: "allow", label: "Sure", className: "notif-banner-allow" },
- { decision: "allow_always", label: "Allow for session", className: "notif-banner-always" },
- { decision: "deny", label: "No", className: "notif-banner-deny" },
- ],
- plan: [
- { decision: "allow", label: "Approve", className: "notif-banner-allow" },
- { decision: "deny", label: "Reject", className: "notif-banner-deny" },
- ],
-};
+import { wirePromptBanner, restoreUnconfirmedBanners, bannerActions } from './prompt-banner.js';
+
+// The server dismisses a prompt's banner once the prompt settles, however it
+// was answered.
var notifications = [];
var unreadCount = 0;
var bannerContainer = null;
@@ -139,7 +126,7 @@ function showBanner(notif, autoDismissMs) {
if (isPermission) {
actionsHtml =
'
' +
- (ownValue(BANNER_ACTIONS, promptKind) || []).map(function (a) {
+ bannerActions(promptKind, notif.meta).map(function (a) {
return '';
}).join("") +
'' +
diff --git a/lib/public/modules/app-panels.js b/lib/public/modules/app-panels.js
index a027893c..caf5444d 100644
--- a/lib/public/modules/app-panels.js
+++ b/lib/public/modules/app-panels.js
@@ -8,6 +8,7 @@ import { getWs } from './ws-ref.js';
import { VENDOR_NAMES } from './app-rendering.js';
import { splitModelsByTier } from './settings-defaults.js';
import { isSonnetModel } from './model-families.js';
+import { modeOptions } from './mode-options.js';
// --- Module-owned state (not in store) ---
// sessionUsage.context = last-turn context fill (lastStreamInputTokens) — not cumulative,
@@ -82,12 +83,6 @@ var contextMiniFill = null;
var contextMiniLabel = null;
// --- Constants ---
-var MODE_OPTIONS = [
- { value: "default", label: "Default" },
- { value: "plan", label: "Plan" },
- { value: "acceptEdits", label: "Auto-accept edits" },
-];
-var MODE_FULL_AUTO = { value: "bypassPermissions", label: "Full auto" };
var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
var EFFORT_LEVELS_BY_VENDOR = {
claude: ["low", "medium", "high", "xhigh", "max"],
@@ -132,15 +127,6 @@ function modelDisplayName(value, models) {
return value;
}
-function modeDisplayName(value) {
- for (var i = 0; i < MODE_OPTIONS.length; i++) {
- if (MODE_OPTIONS[i].value === value) return MODE_OPTIONS[i].label;
- }
- if (value === "bypassPermissions") return "Full auto";
- if (value === "dontAsk") return "Don\u2019t ask";
- return value;
-}
-
function effortDisplayName(value) {
if (!value) return "";
if (value === "xhigh") return "X-High";
@@ -266,10 +252,7 @@ function rebuildModelList() {
function rebuildModeList() {
if (!configModeList) return;
configModeList.innerHTML = "";
- var options = MODE_OPTIONS.slice();
- if (store.get('skipPermsEnabled')) {
- options.push(MODE_FULL_AUTO);
- }
+ var options = modeOptions(store.get('vendorCapabilities'), store.get('skipPermsEnabled'));
for (var i = 0; i < options.length; i++) {
var opt = options[i];
var btn = document.createElement("button");
@@ -480,7 +463,7 @@ export function initPanels() {
if (vendorBtnCodex) vendorBtnCodex.addEventListener("click", function() { onVendorClick("codex"); });
// --- Reactive UI sync ---
- store.subscribe(['availableVendors', 'installedVendors', 'currentVendor', 'richContextUsage', 'ctxPopoverVisible', 'currentModel', 'currentModels', 'currentMode', 'currentEffort', 'currentBetas', 'currentThinking'], function (state, prev) {
+ store.subscribe(['availableVendors', 'installedVendors', 'currentVendor', 'richContextUsage', 'ctxPopoverVisible', 'currentModel', 'currentModels', 'currentMode', 'currentEffort', 'currentBetas', 'currentThinking', 'vendorCapabilities'], function (state, prev) {
// Vendor toggle state
if (state.availableVendors !== prev.availableVendors ||
state.installedVendors !== prev.installedVendors ||
@@ -513,6 +496,7 @@ export function initPanels() {
state.currentBetas !== prev.currentBetas ||
state.currentThinking !== prev.currentThinking ||
state.currentVendor !== prev.currentVendor ||
+ state.vendorCapabilities !== prev.vendorCapabilities ||
state.codexApproval !== prev.codexApproval ||
state.codexSandbox !== prev.codexSandbox ||
state.codexWebSearch !== prev.codexWebSearch) {
diff --git a/lib/public/modules/mode-options.js b/lib/public/modules/mode-options.js
new file mode 100644
index 00000000..dd2f7f88
--- /dev/null
+++ b/lib/public/modules/mode-options.js
@@ -0,0 +1,38 @@
+// mode-options.js - the permission modes the config chip offers, and their
+// labels. Import-free so it can be exercised without the frontend module
+// graph.
+//
+// Default, Plan and Auto-accept edits are offered for every session. Don't
+// ask and Auto exist only in Claude Code: they are offered for a session whose
+// vendor declares them in capabilities.permissionModes, so a vendor that does
+// not (Codex, or one whose capabilities are not known yet) never shows them.
+// Full auto is offered when the daemon allows it. Labels follow the Claude
+// Code CLI's wording.
+
+var BASE_MODES = [
+ { value: "default", label: "Default" },
+ { value: "plan", label: "Plan" },
+ { value: "acceptEdits", label: "Auto-accept edits" },
+];
+
+var CAPABILITY_MODES = [
+ { value: "dontAsk", label: "Don’t ask" },
+ { value: "auto", label: "Auto" },
+];
+
+var FULL_AUTO = { value: "bypassPermissions", label: "Full auto" };
+
+/**
+ * @param {?object} capabilities - the session vendor's capabilities.
+ * @param {boolean} fullAutoEnabled
+ * @returns {{value: string, label: string}[]}
+ */
+export function modeOptions(capabilities, fullAutoEnabled) {
+ var declared = capabilities && Array.isArray(capabilities.permissionModes) ? capabilities.permissionModes : [];
+ var options = BASE_MODES.slice();
+ CAPABILITY_MODES.forEach(function (m) {
+ if (declared.indexOf(m.value) !== -1) options.push(m);
+ });
+ if (fullAutoEnabled) options.push(FULL_AUTO);
+ return options;
+}
diff --git a/lib/public/modules/prompt-banner.js b/lib/public/modules/prompt-banner.js
index 7fb3bed8..c8116e66 100644
--- a/lib/public/modules/prompt-banner.js
+++ b/lib/public/modules/prompt-banner.js
@@ -10,10 +10,41 @@
// it can be exercised without the rest of the frontend module graph.
import { definePromptSurface, submitPromptResponse, restoreUnconfirmedUnder } from './prompt-card.js';
+import { permissionChoices } from './prompt-kinds/permission-choices.js';
var DECISION_BUTTONS = "button[data-decision]";
var ACTIONS = ".notif-banner-actions";
+var PERMISSION_BUTTONS = {
+ allow: { label: "Sure", className: "notif-banner-allow" },
+ allow_always: { label: "Allow for session", className: "notif-banner-always" },
+ deny: { label: "No", className: "notif-banner-deny" },
+};
+
+var PLAN_BUTTONS = [
+ { decision: "allow", label: "Approve", className: "notif-banner-allow" },
+ { decision: "deny", label: "Reject", className: "notif-banner-deny" },
+];
+
+/**
+ * The answer buttons a prompt's banner offers; a kind with none offers only
+ * "Go to session". A tool-permission banner offers the card's answers, in the
+ * card's order: no Allow for session for an ask that may not offer it, and
+ * No first for an ask that must open on No.
+ * @param {string} kind
+ * @param {object} [meta] - the notification's meta (the ask's hints).
+ * @returns {{decision: string, label: string, className: string}[]}
+ */
+export function bannerActions(kind, meta) {
+ if (kind === "permission") {
+ return permissionChoices(meta).map(function (decision) {
+ return Object.assign({ decision: decision }, PERMISSION_BUTTONS[decision]);
+ });
+ }
+ if (kind === "plan") return PLAN_BUTTONS.map(function (a) { return Object.assign({}, a); });
+ return [];
+}
+
/**
* Wire a banner's decision buttons to answer its prompt.
* @param {Element} banner
diff --git a/lib/public/modules/prompt-card.js b/lib/public/modules/prompt-card.js
index 21e3ac7f..a15bbd38 100644
--- a/lib/public/modules/prompt-card.js
+++ b/lib/public/modules/prompt-card.js
@@ -120,7 +120,15 @@ export function submitPromptResponse(container, trySend, opts) {
if (isSettled(container) || isUnconfirmed(container)) return false;
var sent = false;
- try { sent = !!trySend(); } catch (e) { sent = false; }
+ try {
+ sent = !!trySend();
+ } catch (e) {
+ // A failure of the send itself is not a dropped connection, and saying so
+ // would send the operator to reconnect for nothing.
+ console.error("[prompt-card] sending an answer failed:", e);
+ addNote(container, "Could not send the answer (" + ((e && e.message) || "unexpected error") + "). Choose again to retry.", true);
+ return false;
+ }
if (!sent) {
addNote(container, "Not connected. Reconnect, then choose again.", true);
return false;
diff --git a/lib/public/modules/prompt-kinds/permission-choices.js b/lib/public/modules/prompt-kinds/permission-choices.js
new file mode 100644
index 00000000..dad74d7f
--- /dev/null
+++ b/lib/public/modules/prompt-kinds/permission-choices.js
@@ -0,0 +1,31 @@
+// permission-choices.js - which answers a tool-permission ask offers, and in
+// what order. The card (permission.js) and the notification banner
+// (../prompt-banner.js) both read it, so they cannot offer different answers
+// to one ask.
+//
+// The Claude Agent SDK may attach presentation hints to an ask:
+// suppressAlwaysAllowRule no persistent "allow for session" choice;
+// defaultToNo the ask opens on its decline choice, so a stray
+// keystroke or tap cannot approve it.
+// The server refuses an Allow-for-session answer to a suppressed ask whatever
+// a client sends (lib/prompt-kinds/permission.js); omitting the choice here is
+// the presentation of that rule, not its enforcement.
+//
+// Deliberately free of imports so the banner can load it without the rest of
+// the frontend module graph.
+
+/**
+ * @param {object} [hints] - the ask's request fields.
+ * @returns {string[]} decisions in the order they are shown; the first is the
+ * default choice when the ask is defaultToNo.
+ */
+export function permissionChoices(hints) {
+ var h = hints || {};
+ var approve = h.suppressAlwaysAllowRule === true ? ["allow"] : ["allow", "allow_always"];
+ return h.defaultToNo === true ? ["deny"].concat(approve) : approve.concat(["deny"]);
+}
+
+/** Whether an ask must open on its decline choice. */
+export function opensOnNo(hints) {
+ return !!hints && hints.defaultToNo === true;
+}
diff --git a/lib/public/modules/prompt-kinds/permission.js b/lib/public/modules/prompt-kinds/permission.js
index dedc258c..9b660e96 100644
--- a/lib/public/modules/prompt-kinds/permission.js
+++ b/lib/public/modules/prompt-kinds/permission.js
@@ -1,9 +1,50 @@
// Tool-call approval card: Allow Once / Allow for Session / Deny, drawn as a
// formal dialog (bubble layout) or a conversational "Can I ...?" (channel
-// layout). The shell (prompts.js, prompt-card.js) owns its state.
+// layout). The shell (prompts.js, prompt-card.js) owns its state. Which
+// answers it offers, and which leads, follows the ask's SDK hints
+// (permission-choices.js).
import { escapeHtml } from '../utils.js';
import { iconHtml } from '../icons.js';
+import { permissionChoices, opensOnNo } from './permission-choices.js';
+
+var FORMAL_CHOICES = {
+ allow: { className: "permission-btn permission-allow", label: "Allow Once" },
+ allow_always: { className: "permission-btn permission-allow-session", label: "Allow for Session" },
+ deny: { className: "permission-btn permission-deny", label: "Deny" },
+};
+
+var CONVERSATIONAL_CHOICES = {
+ allow: { className: "perm-reply perm-allow", label: "Sure" },
+ allow_always: { className: "perm-reply perm-always", label: "Allow for session" },
+ deny: { className: "perm-reply perm-deny", label: "No" },
+};
+
+// The buttons for an ask's answers, in the order the ask allows. An ask that
+// must open on No leads with its decline button and marks it the default;
+// no approve button is ever marked.
+function choiceButtons(fields, table, container, host) {
+ var leadsWithNo = opensOnNo(fields);
+ return permissionChoices(fields).map(function (decision, i) {
+ var spec = table[decision];
+ var btn = button(spec.className, spec.label, function () {
+ host.respond(container, { decision: decision });
+ });
+ if (leadsWithNo && i === 0) btn.dataset.defaultChoice = "true";
+ return btn;
+ });
+}
+
+// The user's own ask rule that forced this prompt, when the SDK says so.
+function ruleNote(fields) {
+ var rule = fields.matchedAskRule;
+ if (!rule || typeof rule !== "object") return null;
+ var rendered = (rule.toolName || "") + (rule.ruleContent ? "(" + rule.ruleContent + ")" : "");
+ var note = document.createElement("div");
+ note.className = "permission-rule";
+ note.textContent = "Asked because of your rule" + (rendered ? " " + rendered : "") + (rule.source ? " (" + rule.source + ")" : "");
+ return note;
+}
function shortPath(p) {
if (!p) return "";
@@ -95,19 +136,14 @@ function drawFormal(fields, requestId, host) {
reason.textContent = fields.decisionReason;
body.appendChild(reason);
}
+ var rule = ruleNote(fields);
+ if (rule) body.appendChild(rule);
body.appendChild(detailsElement("permission-details", fields.toolInput));
var actions = document.createElement("div");
actions.className = "permission-actions";
- actions.appendChild(button("permission-btn permission-allow", "Allow Once", function () {
- host.respond(container, { decision: "allow" });
- }));
- actions.appendChild(button("permission-btn permission-allow-session", "Allow for Session", function () {
- host.respond(container, { decision: "allow_always" });
- }));
- actions.appendChild(button("permission-btn permission-deny", "Deny", function () {
- host.respond(container, { decision: "deny" });
- }));
+ choiceButtons(fields, FORMAL_CHOICES, container, host).forEach(function (btn) { actions.appendChild(btn); });
+ if (opensOnNo(fields)) container.dataset.defaultToNo = "true";
container.appendChild(header);
container.appendChild(body);
@@ -143,19 +179,14 @@ function drawConversational(fields, requestId, host) {
askEl.className = "perm-ask";
askEl.textContent = "Can I " + wording.verb + (wording.target ? " " + wording.target : "") + "?";
content.appendChild(askEl);
+ var rule = ruleNote(fields);
+ if (rule) content.appendChild(rule);
content.appendChild(detailsElement("perm-details", fields.toolInput));
var actions = document.createElement("div");
actions.className = "permission-actions";
- actions.appendChild(button("perm-reply perm-allow", "Sure", function () {
- host.respond(container, { decision: "allow" });
- }));
- actions.appendChild(button("perm-reply perm-always", "Allow for session", function () {
- host.respond(container, { decision: "allow_always" });
- }));
- actions.appendChild(button("perm-reply perm-deny", "No", function () {
- host.respond(container, { decision: "deny" });
- }));
+ choiceButtons(fields, CONVERSATIONAL_CHOICES, container, host).forEach(function (btn) { actions.appendChild(btn); });
+ if (opensOnNo(fields)) container.dataset.defaultToNo = "true";
content.appendChild(actions);
container.appendChild(content);
return container;
diff --git a/lib/public/modules/prompt-state.js b/lib/public/modules/prompt-state.js
index 951d9832..eb04b09f 100644
--- a/lib/public/modules/prompt-state.js
+++ b/lib/public/modules/prompt-state.js
@@ -128,6 +128,10 @@ export function normalizePromptMessage(msg) {
if (msg.answers && Object.keys(msg.answers).length) {
return { phase: "resolved", requestId: msg.toolId, kind: "ask_user", fields: { answers: msg.answers }, serverState: null };
}
+ // A recorded skip is an answer, not an ending nobody chose.
+ if (msg.skipped === true) {
+ return { phase: "resolved", requestId: msg.toolId, kind: "ask_user", fields: { skipped: true }, serverState: null };
+ }
return { phase: "cancel", requestId: msg.toolId, kind: "ask_user", fields: { reason: null }, serverState: null };
case "prompt_cancel":
case "permission_cancel":
diff --git a/lib/sdk-bridge.js b/lib/sdk-bridge.js
index de113d68..1a3f58b5 100644
--- a/lib/sdk-bridge.js
+++ b/lib/sdk-bridge.js
@@ -1013,15 +1013,22 @@ function createSDKBridge(opts) {
}
function handleCanUseTool(session, toolName, input, opts) {
+ opts = opts || {};
+ // An ask the user's own ask rule forced is a human prompt by the user's
+ // stated intent (the SDK's matchedAskRule): none of the host-side
+ // auto-approvals below - full auto, an autonomous loop, the read-only
+ // whitelist, an earlier Allow for session - may answer it.
+ var ruleForced = !!opts.matchedAskRule;
+
// Full-auto mode: auto-approve everything except AskUserQuestion
// (which still needs to go through the user interaction flow).
- if (sm.currentPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") {
+ if (!ruleForced && sm.currentPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") {
return Promise.resolve({ behavior: "allow", updatedInput: input });
}
// Ralph Loop execution: auto-approve all tools, deny interactive ones.
// Crafting sessions are interactive — user and Claude collaborate to build PROMPT.md / JUDGE.md.
- if (session.loop && session.loop.active && session.loop.role !== "crafting") {
+ if (!ruleForced && session.loop && session.loop.active && session.loop.role !== "crafting") {
if (toolName === "AskUserQuestion") {
return Promise.resolve({ behavior: "deny", message: "Autonomous mode. Make your own decision." });
}
@@ -1032,7 +1039,7 @@ function createSDKBridge(opts) {
}
// Check shared whitelist (read-only tools, safe browser tools, safe bash commands)
- var whitelisted = checkToolWhitelist(toolName, input);
+ var whitelisted = ruleForced ? null : checkToolWhitelist(toolName, input);
if (whitelisted) {
return Promise.resolve(whitelisted);
}
@@ -1052,7 +1059,16 @@ function createSDKBridge(opts) {
// Auto-approve if tool was previously allowed for session (keyed on
// toolName + input discriminator, not bare toolName — see
// permissionGrantKey's doc comment for why the Skill tool needs this).
- if (kind === "permission" && prompts.isGranted(session, toolName, input)) {
+ // An Allow-for-session grant is a standing rule for every later call with
+ // the same key. The SDK forbids that for an ask marked
+ // suppressAlwaysAllowRule (a rule would grant more than this ask's own
+ // action), so such an ask is never answered by an earlier grant. An ask
+ // marked defaultToNo must open on No, which a silent approval would
+ // bypass; that is the stricter, safe reading of the hint. A rule-forced ask
+ // (matched_ask_rule) was put to the operator on purpose and is never
+ // auto-approved either.
+ var needsOwnAnswer = ruleForced || opts.suppressAlwaysAllowRule === true || opts.defaultToNo === true;
+ if (kind === "permission" && !needsOwnAnswer && prompts.isGranted(session, toolName, input)) {
return Promise.resolve({ behavior: "allow", updatedInput: input });
}
@@ -1074,6 +1090,9 @@ function createSDKBridge(opts) {
toolInput: input,
decisionReason: opts.decisionReason || "",
vendor: session.vendor || (adapter && adapter.vendor) || "claude",
+ suppressAlwaysAllowRule: opts.suppressAlwaysAllowRule,
+ defaultToNo: opts.defaultToNo,
+ matchedAskRule: opts.matchedAskRule,
}, {
toolUseId: opts.toolUseID,
signal: opts.signal,
@@ -2417,18 +2436,26 @@ function createSDKBridge(opts) {
}
}
+ // Returns { ok: true } or { ok: false, error } like setModel: the effort is
+ // recorded and announced only once the running query has taken it, and a
+ // failure is shown to the operator instead of reading as a change.
async function setEffort(session, effort) {
if (!session.queryInstance) {
sm.currentEffort = effort;
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
- return;
+ return { ok: true };
}
- // Route through QueryHandle (works for both in-process and worker paths)
- if (typeof session.queryInstance.setEffort === "function") {
+ try {
+ // Route through QueryHandle (works for both in-process and worker paths)
await session.queryInstance.setEffort(effort);
+ } catch (e) {
+ var errMsg = (e && e.message) || String(e);
+ send({ type: "error", text: "Failed to change effort: " + errMsg });
+ return { ok: false, error: errMsg };
}
sm.currentEffort = effort;
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
+ return { ok: true };
}
async function setPermissionMode(session, mode) {
diff --git a/lib/ws-schema.js b/lib/ws-schema.js
index 6ab7e171..209217a1 100644
--- a/lib/ws-schema.js
+++ b/lib/ws-schema.js
@@ -116,13 +116,14 @@ var schema = {
// Operator prompts: one shape for every kind (lib/prompt-registry.js).
// kind is permission, plan, ask_user or elicitation; the kind's own fields
// ride flat on the message (permission/plan: toolName, toolInput,
- // decisionReason, vendor; ask_user: input; elicitation: serverName,
+ // decisionReason, vendor, and for permission the SDK's hints when set:
+ // suppressAlwaysAllowRule, defaultToNo, matchedAskRule; ask_user: input; elicitation: serverName,
// message, mode, url, elicitationId, requestedSchema).
// -----------------------------------------------------------------------
"prompt_request": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt awaits the operator: {requestId, kind, toolUseId, ...kind fields}. On history replay it carries promptState {state: pending|resolved|cancelled, decision|answers|skipped|action, reason}, the server's authoritative state for the prompt at replay time" },
"prompt_pending": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt still awaiting the operator, re-presented on connect and session switch; same fields as prompt_request" },
- "prompt_response": { direction: "c2s", handler: "lib/project-prompt-responses.js", description: "The operator's answer: {requestId, kind, ...} with decision (+ feedback, planContent) for permission/plan, answers or decision:\"skip\" for ask_user (answers keyed by question index; each a label or text, or for a multi-select question the list of chosen labels), action (+ content, held to the requested schema) for elicitation. The kind is required and must match the prompt's" },
- "prompt_resolved": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt was answered: {requestId, kind, decision|answers|skipped|action}" },
+ "prompt_response": { direction: "c2s", handler: "lib/project-prompt-responses.js", description: "The operator's answer: {requestId, kind, ...} with decision (+ feedback, planContent) for permission/plan, answers or decision:\"skip\" for ask_user (answers keyed by question index; each a label or text, or for a multi-select question the list of chosen labels), action (+ content, held to the requested schema) for elicitation. The kind is required and must match the prompt's. Decision allow_always to a permission prompt that carries suppressAlwaysAllowRule settles nothing: the prompt is sent again as prompt_pending. A prompt of the extension kind is never answered this way" },
+ "prompt_resolved": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt was answered: {requestId, kind, decision|answers|skipped|action}; a permission prompt forced by a user ask rule also carries matchedAskRule" },
"prompt_cancel": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt ended without an answer: {requestId, kind, reason}; reason is aborted, expired, turn_ended, query_ended, rewind, cleared or shutdown. Reason \"stale\" is sent only to a client whose prompt_response named a prompt the server no longer holds, and is not recorded in history" },
// Aliases kept for compatibility: the s2c types appear in history recorded
diff --git a/lib/yoke/adapters/claude-capabilities.js b/lib/yoke/adapters/claude-capabilities.js
new file mode 100644
index 00000000..c2a17f1c
--- /dev/null
+++ b/lib/yoke/adapters/claude-capabilities.js
@@ -0,0 +1,26 @@
+"use strict";
+
+// What a Claude session can do, as the client sees it (sent with
+// session_switched). permissionModes names the SDK permission modes the
+// session accepts through setPermissionMode: the mode picker offers dontAsk
+// and auto only for a vendor that lists them, so a vendor that cannot run one
+// never shows it.
+
+var PERMISSION_MODES = ["default", "plan", "acceptEdits", "dontAsk", "auto"];
+
+function claudeCapabilities() {
+ return {
+ thinking: true,
+ betas: true,
+ rewind: true,
+ sessionResume: true,
+ promptSuggestions: true,
+ elicitation: true,
+ fileCheckpointing: true,
+ contextCompacting: true,
+ toolPolicy: ["ask", "allow-all"],
+ permissionModes: PERMISSION_MODES.slice(),
+ };
+}
+
+module.exports = { claudeCapabilities: claudeCapabilities, PERMISSION_MODES: PERMISSION_MODES };
diff --git a/lib/yoke/adapters/claude-worker.js b/lib/yoke/adapters/claude-worker.js
index f7157912..33017827 100644
--- a/lib/yoke/adapters/claude-worker.js
+++ b/lib/yoke/adapters/claude-worker.js
@@ -19,6 +19,8 @@ var path = require("path");
var fs = require("fs");
var os = require("os");
var parseDiagnosticLine = require("./diagnostic-patterns").parseDiagnosticLine;
+var permissionRelay = require("./permission-relay");
+var effortControl = require("./effort-control");
var socketPath = process.argv[2];
if (!socketPath) {
@@ -305,14 +307,7 @@ function handleMessage(msg) {
// --- canUseTool: delegates to daemon via IPC ---
function canUseTool(toolName, input, opts) {
var requestId = crypto.randomUUID();
- sendToDaemon({
- type: "permission_request",
- requestId: requestId,
- toolName: toolName,
- input: input,
- toolUseId: opts.toolUseID || "",
- decisionReason: opts.decisionReason || "",
- });
+ sendToDaemon(permissionRelay.toRequestMessage(requestId, toolName, input, opts));
return new Promise(function(resolve) {
var timeoutId = setTimeout(function() {
if (pendingPermissions[requestId]) {
@@ -643,13 +638,18 @@ async function handleSetModel(msg) {
}
}
+// requestId is echoed on both outcomes so the daemon's pending setEffort()
+// resolves or rejects on the real result.
async function handleSetEffort(msg) {
- if (!queryInstance) return;
+ if (!queryInstance) {
+ sendToDaemon({ type: "worker_error", error: "Failed to set effort: no running query", requestId: msg.requestId });
+ return;
+ }
try {
- await queryInstance.setEffort(msg.effort);
- sendToDaemon({ type: "effort_changed", effort: msg.effort });
+ await effortControl.applyEffort(queryInstance, msg.effort);
+ sendToDaemon({ type: "effort_changed", effort: msg.effort, requestId: msg.requestId });
} catch (e) {
- sendToDaemon({ type: "worker_error", error: "Failed to set effort: " + (e.message || e) });
+ sendToDaemon({ type: "worker_error", error: "Failed to set effort: " + (e.message || e), requestId: msg.requestId });
}
}
diff --git a/lib/yoke/adapters/claude.js b/lib/yoke/adapters/claude.js
index 2c3594fd..097a66d4 100644
--- a/lib/yoke/adapters/claude.js
+++ b/lib/yoke/adapters/claude.js
@@ -13,6 +13,9 @@ var { spawn } = require("child_process");
var { resolveOsUserInfo } = require("../../os-users");
var modelFamilies = require("../../model-families");
var modelCatalog = require("../../model-catalog");
+var permissionRelay = require("./permission-relay");
+var effortControl = require("./effort-control");
+var claudeCapabilities = require("./claude-capabilities").claudeCapabilities;
// --- SDK loading ---
// Async loader (ESM dynamic import, same pattern as current project.js getSDK)
@@ -380,10 +383,11 @@ function createQueryHandle(rawQuery, messageQueue, abortController) {
return Promise.resolve();
},
+ // The SDK's Query has no setEffort; applyFlagSettings is the live control.
+ // Rejects when the change did not take effect, so the caller never
+ // reports an effort the session is not running at.
setEffort: function(effort) {
- // Claude SDK has no setEffort on active query.
- // Stored at the console level for next query.
- return Promise.resolve();
+ return effortControl.applyEffort(rawQuery, effort);
},
setToolPolicy: function(policy) {
@@ -806,6 +810,27 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool)
// then project-sessions.js) to surface to the user.
var pendingSetModel = Object.create(null);
+ // Same correlation for setEffort: the returned promise settles on the
+ // worker's real outcome, or rejects when the worker never answers.
+ var pendingSetEffort = Object.create(null);
+ var SET_EFFORT_TIMEOUT_MS = 15000;
+
+ function settleSetEffort(requestId, error) {
+ var pending = pendingSetEffort[requestId];
+ if (!pending) return;
+ delete pendingSetEffort[requestId];
+ clearTimeout(pending.timer);
+ if (error) pending.reject(error);
+ else pending.resolve();
+ }
+
+ // The worker's query is over: no pending effort change can still land.
+ function failPendingEfforts() {
+ Object.keys(pendingSetEffort).forEach(function (requestId) {
+ settleSetEffort(requestId, new Error("The Claude query ended before the effort change took effect"));
+ });
+ }
+
// ":" -> AbortController whose signal is handed to
// the daemon-side callback, so the daemon-side prompt ends when the worker
// stops waiting for it: its own timeout, the SDK cancelling the callback,
@@ -886,11 +911,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool)
case "permission_request":
if (canUseTool) {
relayPrompt("permission:" + msg.requestId, function (signal) {
- return canUseTool(msg.toolName, msg.input, {
- toolUseID: msg.toolUseId,
- decisionReason: msg.decisionReason,
- signal: signal,
- });
+ return canUseTool(msg.toolName, msg.input, permissionRelay.toCanUseToolOptions(msg, signal));
}, function (result) {
worker.send({ type: "permission_response", requestId: msg.requestId, result: result });
}, "permission_response");
@@ -952,12 +973,16 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool)
break;
case "context_usage":
- case "effort_changed":
case "permission_mode_changed":
// Yield these as _worker_meta events so processQueryStream can handle them
pushToIter({ type: "_worker_meta", subtype: msg.type, data: msg });
break;
+ case "effort_changed":
+ pushToIter({ type: "_worker_meta", subtype: msg.type, data: msg });
+ settleSetEffort(msg.requestId, null);
+ break;
+
case "model_changed":
// lr-f22787: still yielded as a _worker_meta event (unchanged
// consumer contract for sdk-bridge.js), AND resolves the pending
@@ -973,11 +998,18 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool)
break;
case "worker_error":
+ // A failure of an in-flight set_effort request is reported by the
+ // rejection of setEffort() alone: also yielding it as a _worker_meta
+ // event would show the operator the same error twice.
+ if (msg.requestId && pendingSetEffort[msg.requestId]) {
+ settleSetEffort(msg.requestId, new Error(msg.error || "Failed to set effort"));
+ break;
+ }
// Yielded as a _worker_meta event (unchanged consumer contract), AND
// rejects the pending setModel() promise when this worker_error
- // belongs to an in-flight set_model request (lr-f22787). worker_error
- // is also used for effort/permission-mode failures, which carry no
- // requestId and so never match a pendingSetModel entry.
+ // belongs to an in-flight set_model request (lr-f22787). A
+ // permission-mode failure carries no requestId and so never matches
+ // a pendingSetModel entry.
pushToIter({ type: "_worker_meta", subtype: msg.type, data: msg });
if (msg.requestId && pendingSetModel[msg.requestId]) {
var smErr = pendingSetModel[msg.requestId];
@@ -1001,6 +1033,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool)
worker._queryEnded = true;
// The worker's query is over; nothing in it waits for an answer.
abortAllPrompts();
+ failPendingEfforts();
endIter();
break;
@@ -1010,6 +1043,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool)
if (msg._fallback && worker._queryEnded) break;
worker._queryEnded = true;
abortAllPrompts();
+ failPendingEfforts();
var err = new Error(msg.error || "Worker query error");
err.exitCode = msg.exitCode;
err.stderr = msg.stderr;
@@ -1095,8 +1129,18 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool)
},
setEffort: function(effort) {
- worker.send({ type: "set_effort", effort: effort });
- return Promise.resolve();
+ var requestId = crypto.randomUUID();
+ return new Promise(function(resolve, reject) {
+ var timer = setTimeout(function() {
+ settleSetEffort(requestId, new Error("The Claude worker did not confirm the effort change"));
+ }, SET_EFFORT_TIMEOUT_MS);
+ pendingSetEffort[requestId] = { resolve: resolve, reject: reject, timer: timer };
+ try {
+ worker.send({ type: "set_effort", requestId: requestId, effort: effort });
+ } catch (e) {
+ settleSetEffort(requestId, e);
+ }
+ });
},
setToolPolicy: function(policy) {
@@ -1199,7 +1243,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool)
// vendor-reported list always wins outright when present (see
// enrichClaudeModel below) — this table exists purely to cover the case
// where the vendor reports no levels at all, not as the primary source.
-var CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
+var CLAUDE_EFFORT_LEVELS = effortControl.EFFORT_LEVELS;
// Display name, thinking/effort capability heuristics, and family/version
// parsing all live in ../../model-families.js (lr-d91ecf) — the single
@@ -1825,17 +1869,7 @@ function createClaudeAdapter(opts) {
skills: [],
slashCommands: [],
fastModeState: null,
- capabilities: {
- thinking: true,
- betas: true,
- rewind: true,
- sessionResume: true,
- promptSuggestions: true,
- elicitation: true,
- fileCheckpointing: true,
- contextCompacting: true,
- toolPolicy: ["ask", "allow-all"],
- },
+ capabilities: claudeCapabilities(),
};
try {
@@ -1915,17 +1949,7 @@ function createClaudeAdapter(opts) {
skills: r.skills || [],
slashCommands: r.slashCommands || [],
fastModeState: r.fastModeState || null,
- capabilities: {
- thinking: true,
- betas: true,
- rewind: true,
- sessionResume: true,
- promptSuggestions: true,
- elicitation: true,
- fileCheckpointing: true,
- contextCompacting: true,
- toolPolicy: ["ask", "allow-all"],
- },
+ capabilities: claudeCapabilities(),
});
worker.kill();
} else if (msg.type === "warmup_error" && !warmupDone) {
@@ -1962,7 +1986,9 @@ module.exports = {
// alias untouched, not a substituted concrete ID.)
_test_mergeStaticCatalog: mergeStaticCatalog,
_test_enrichClaudeModelsWithCatalog: enrichClaudeModelsWithCatalog,
- // Test seam: drives the worker IPC prompt relay over a fake worker.
+ // Test seams: drive the in-process query handle over a fake SDK Query and
+ // the worker IPC relay over a fake worker.
+ _test_createQueryHandle: createQueryHandle,
_test_createWorkerQueryHandle: createWorkerQueryHandle,
};
diff --git a/lib/yoke/adapters/effort-control.js b/lib/yoke/adapters/effort-control.js
new file mode 100644
index 00000000..3060c045
--- /dev/null
+++ b/lib/yoke/adapters/effort-control.js
@@ -0,0 +1,34 @@
+"use strict";
+
+// Changing the reasoning effort of a Claude session that is already running.
+// The SDK's Query has no setEffort; the live control is applyFlagSettings
+// with an effortLevel, which takes effect for the rest of the session.
+// 'max' is session-only and runs as 'high' on a model without it, which the
+// SDK decides, so the console passes it through.
+//
+// Shared by the in-process query handle and the worker process, so the two
+// paths cannot disagree about what a valid level is or what a failure is.
+
+var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
+
+/**
+ * @param {object} query - the SDK Query.
+ * @param {string} effort - one of EFFORT_LEVELS.
+ * @returns {Promise} rejects with a message fit to show the operator.
+ */
+function applyEffort(query, effort) {
+ if (EFFORT_LEVELS.indexOf(effort) === -1) {
+ return Promise.reject(new Error("Unknown effort level: " + String(effort).slice(0, 40)));
+ }
+ if (!query || typeof query.applyFlagSettings !== "function") {
+ return Promise.reject(new Error("This Claude Code version cannot change effort in a running session"));
+ }
+ return Promise.resolve().then(function () {
+ return query.applyFlagSettings({ effortLevel: effort });
+ });
+}
+
+module.exports = {
+ EFFORT_LEVELS: EFFORT_LEVELS,
+ applyEffort: applyEffort,
+};
diff --git a/lib/yoke/adapters/permission-relay.js b/lib/yoke/adapters/permission-relay.js
new file mode 100644
index 00000000..1ff2d45b
--- /dev/null
+++ b/lib/yoke/adapters/permission-relay.js
@@ -0,0 +1,58 @@
+"use strict";
+
+// The wire shape of a tool-permission ask between a Claude worker process and
+// the daemon. The worker builds the message from the options the SDK handed
+// its canUseTool callback; the daemon turns it back into the options its own
+// canUseTool callback takes. Both sides use these two functions so a field the
+// SDK adds to an ask cannot be carried by one and dropped by the other.
+//
+// The presentation hints travel with the ask: suppressAlwaysAllowRule (no
+// persistent "don't ask again" choice), defaultToNo (the ask must not open on
+// an approve choice) and matchedAskRule (a user-written ask rule forced the
+// prompt, so no host-side auto-approval may answer it). Only a literal true
+// restricts, and a rule is data, not a flag: anything present but not an object
+// is still a rule-forced ask.
+
+/**
+ * @param {string} requestId - the worker's id for this ask.
+ * @param {string} toolName
+ * @param {object} input
+ * @param {object} opts - the SDK's canUseTool options.
+ * @returns {object} the IPC message.
+ */
+function toRequestMessage(requestId, toolName, input, opts) {
+ var msg = {
+ type: "permission_request",
+ requestId: requestId,
+ toolName: toolName,
+ input: input,
+ toolUseId: opts.toolUseID || "",
+ decisionReason: opts.decisionReason || "",
+ };
+ if (opts.suppressAlwaysAllowRule === true) msg.suppressAlwaysAllowRule = true;
+ if (opts.defaultToNo === true) msg.defaultToNo = true;
+ if (opts.matchedAskRule) msg.matchedAskRule = opts.matchedAskRule;
+ return msg;
+}
+
+/**
+ * @param {object} msg - a permission_request IPC message from a worker.
+ * @param {AbortSignal} signal - ends the ask when the worker stops waiting.
+ * @returns {object} canUseTool options for the daemon-side callback.
+ */
+function toCanUseToolOptions(msg, signal) {
+ var opts = {
+ toolUseID: msg.toolUseId,
+ decisionReason: msg.decisionReason,
+ signal: signal,
+ };
+ if (msg.suppressAlwaysAllowRule === true) opts.suppressAlwaysAllowRule = true;
+ if (msg.defaultToNo === true) opts.defaultToNo = true;
+ if (msg.matchedAskRule) opts.matchedAskRule = typeof msg.matchedAskRule === "object" ? msg.matchedAskRule : {};
+ return opts;
+}
+
+module.exports = {
+ toRequestMessage: toRequestMessage,
+ toCanUseToolOptions: toCanUseToolOptions,
+};
diff --git a/lib/yoke/interface.js b/lib/yoke/interface.js
index ae13470e..c6b60e64 100644
--- a/lib/yoke/interface.js
+++ b/lib/yoke/interface.js
@@ -56,7 +56,12 @@ var TOOL_POLICIES = ["ask", "allow-all"];
* [Symbol.asyncIterator]() - yields SDK events (raw in Phase 3, normalized later)
* .pushMessage(text, images)
* .setModel(model)
- * .setEffort(effort)
+ * .setEffort(effort) - Promise; settles once the running query has
+ * taken the change and rejects (with a message
+ * fit to show the operator) when it did not. The
+ * Claude SDK's Query has no setEffort: the Claude
+ * adapter applies it through applyFlagSettings
+ * ({effortLevel}); Codex reads it on the next turn.
* .setToolPolicy(policy) - "ask" | "allow-all"
* .stopTask(taskId)
* .getContextUsage() - Promise