Skip to content
Merged
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
6 changes: 5 additions & 1 deletion docs/guides/MODULE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 |
Expand Down
14 changes: 14 additions & 0 deletions docs/guides/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion lib/project-notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
},

Expand Down
7 changes: 6 additions & 1 deletion lib/project-prompt-responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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;
Expand Down
37 changes: 33 additions & 4 deletions lib/project-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 0 additions & 1 deletion lib/prompt-kinds/codecs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions lib/prompt-kinds/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module.exports = {
name: "extension",
scope: "project",
journal: false,
operatorAnswerable: false,
defaultTimeoutMs: 3000,

fields: function (req) {
Expand Down
13 changes: 12 additions & 1 deletion lib/prompt-kinds/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -52,6 +62,7 @@ function kindOfRecord(record, store) {
module.exports = {
KINDS: KINDS,
toolApprovalKind: toolApprovalKind,
operatorMayAnswer: operatorMayAnswer,
sessionStores: sessionStores,
kindOfRecord: kindOfRecord,
};
73 changes: 65 additions & 8 deletions lib/prompt-kinds/permission.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,95 @@
// 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",
store: "pendingPermissions",
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) {
Expand All @@ -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,
Expand Down
Loading
Loading