Compare commits

..
Author SHA1 Message Date
Peter Steinberger ddfa51ba31 fix: resolve control UI avatar URLs (#1457) (thanks @dlauer) 2026-01-22 21:57:02 +00:00
Dave Lauer 467d32ae45 fix(gateway): resolve local avatars to URL in HTML injection and RPC
The frontend fix alone wasn't enough because:
1. serveIndexHtml() was injecting the raw avatar filename into HTML
2. agent.identity.get RPC was returning raw filename, overwriting the
   HTML-injected value

Now both paths resolve local file avatars (*.png, *.jpg, etc.) to the
/avatar/{agentId} endpoint URL.
2026-01-22 21:43:42 +00:00
Dave Lauer e54b9ac743 fix(ui): allow relative URLs in avatar validation
The isAvatarUrl check only accepted http://, https://, or data: URLs,
but the /avatar/{agentId} endpoint returns relative paths like /avatar/main.
This caused local file avatars to display as text instead of images.

Fixes avatar display for locally configured avatar files.
2026-01-22 21:43:42 +00:00
21 changed files with 249 additions and 186 deletions

No files matched your search

+1 -3
View File
@@ -2,14 +2,12 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.22 (unreleased) ## 2026.1.22
### Fixes ### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell. - BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla. - Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. - Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: surface concrete API error details instead of generic AI service errors.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
## 2026.1.21-2 ## 2026.1.21-2
+2 -3
View File
@@ -5,7 +5,7 @@ read_when:
--- ---
# Gateway architecture # Gateway architecture
Last updated: 2026-01-22 Last updated: 2026-01-19
## Overview ## Overview
@@ -34,8 +34,7 @@ Last updated: 2026-01-22
### Nodes (macOS / iOS / Android / headless) ### Nodes (macOS / iOS / Android / headless)
- Connect to the **same WS server** with `role: node`. - Connect to the **same WS server** with `role: node`.
- Provide a device identity in `connect`; pairing is **device‑based** (role `node`) and - Pair with the Gateway to receive a token.
approval lives in the device pairing store.
- Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`. - Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`.
Protocol details: Protocol details:
+3 -3
View File
@@ -52,10 +52,10 @@ Instances list, `client.mode === "cli"` is **not** turned into a presence entry.
Clients can send richer periodic beacons via the `system-event` method. The mac Clients can send richer periodic beacons via the `system-event` method. The mac
app uses this to report host name, IP, and `lastInputSeconds`. app uses this to report host name, IP, and `lastInputSeconds`.
### 4) Node connects (role: node) ### 4) Node bridge beacons
When a node connects over the Gateway WebSocket with `role: node`, the Gateway When a node bridge connection authenticates, the Gateway emits a presence entry
upserts a presence entry for that node (same flow as other WS clients). for that node and refreshes it periodically so it doesn’t expire.
## Merge + dedupe rules (why `instanceId` matters) ## Merge + dedupe rules (why `instanceId` matters)
-4
View File
@@ -11,10 +11,6 @@ In Gateway-owned pairing, the **Gateway** is the source of truth for which nodes
are allowed to join. UIs (macOS app, future clients) are just frontends that are allowed to join. UIs (macOS app, future clients) are just frontends that
approve or reject pending requests. approve or reject pending requests.
**Important:** WS nodes use **device pairing** (role `node`) during `connect`.
`node.pair.*` is a separate pairing store and does **not** gate the WS handshake.
Only clients that explicitly call `node.pair.*` use this flow.
## Concepts ## Concepts
- **Pending request**: a node asked to join; requires approval. - **Pending request**: a node asked to join; requires approval.
+12 -16
View File
@@ -8,11 +8,9 @@ read_when:
# Nodes # Nodes
A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol). A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Bridge protocol details: [Bridge protocol](/gateway/bridge-protocol).
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; for older node clients only). macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
macOS can also run in **node mode**: the menubar app connects to the Gateway’s WS server and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
Notes: Notes:
- Nodes are **peripherals**, not gateways. They don’t run the gateway service. - Nodes are **peripherals**, not gateways. They don’t run the gateway service.
@@ -20,23 +18,21 @@ Notes:
## Pairing + status ## Pairing + status
**WS nodes use device pairing.** Nodes present a device identity during `connect`; the Gateway Pairing is gateway-owned and approval-based. See [Gateway pairing](/gateway/pairing) for the full flow.
creates a device pairing request for `role: node`. Approve via the devices CLI (or UI).
Quick CLI: Quick CLI:
```bash ```bash
clawdbot devices list clawdbot nodes pending
clawdbot devices approve <requestId> clawdbot nodes approve <requestId>
clawdbot devices reject <requestId> clawdbot nodes reject <requestId>
clawdbot nodes status clawdbot nodes status
clawdbot nodes describe --node <idOrNameOrIp> clawdbot nodes describe --node <idOrNameOrIp>
clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
``` ```
Notes: Notes:
- `nodes status` marks a node as **paired** when its device pairing role includes `node`. - `nodes rename` stores a display name override in the gateway pairing store.
- `node.pair.*` (CLI: `clawdbot nodes pending/approve/reject`) is a separate gateway-owned
node pairing store; it does **not** gate the WS `connect` handshake.
## Remote node host (system.run) ## Remote node host (system.run)
@@ -279,7 +275,7 @@ Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by
## Headless node host (cross-platform) ## Headless node host (cross-platform)
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
WebSocket and exposes `system.run` / `system.which`. This is useful on Linux/Windows bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
or for running a minimal node alongside a server. or for running a minimal node alongside a server.
Start it: Start it:
@@ -296,9 +292,9 @@ Notes:
- On macOS, the headless node host prefers the companion app exec host when reachable and falls - On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback. the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
- Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS. - Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode ## Mac node mode
- The macOS menubar app connects to the Gateway WS server as a node (so `clawdbot nodes …` works against this Mac). - The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
- In remote mode, the app opens an SSH tunnel for the Gateway port and connects to `localhost`. - In remote mode, the app opens an SSH tunnel for the bridge port and connects to `localhost`.
+10 -12
View File
@@ -45,29 +45,27 @@ Stored under `~/.clawdbot/credentials/`:
Treat these as sensitive (they gate access to your assistant). Treat these as sensitive (they gate access to your assistant).
## 2) Node device pairing (iOS/Android/macOS/headless nodes) ## 2) Node pairing (iOS/Android nodes joining the gateway)
Nodes connect to the Gateway as **devices** with `role: node`. The Gateway Nodes (iOS/Android, future hardware, etc.) connect to the Gateway and request to join.
creates a device pairing request that must be approved. The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject.
### Approve a node device ### Approve a node
```bash ```bash
clawdbot devices list clawdbot nodes pending
clawdbot devices approve <requestId> clawdbot nodes approve <requestId>
clawdbot devices reject <requestId>
``` ```
### Where the state lives ### Where the state lives
Stored under `~/.clawdbot/devices/`: Stored under `~/.clawdbot/nodes/`:
- `pending.json` (short-lived; pending requests expire) - `pending.json` (short-lived; pending requests expire)
- `paired.json` (paired devices + tokens) - `paired.json` (paired nodes + tokens)
### Notes ### Details
- The legacy `node.pair.*` API (CLI: `clawdbot nodes pending/approve`) is a Full protocol + design notes: [Gateway pairing](/gateway/pairing)
separate gateway-owned pairing store. WS nodes still require device pairing.
## Related docs ## Related docs
+1 -1
View File
@@ -11,7 +11,7 @@ Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
Setup (once) Setup (once)
- `gog auth credentials /path/to/client_secret.json` - `gog auth credentials /path/to/client_secret.json`
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets` - `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,sheets,docs`
- `gog auth list` - `gog auth list`
Common commands Common commands
@@ -43,6 +43,8 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError( const msg = makeAssistantError(
'{"type":"error","error":{"message":"Something exploded","type":"server_error"}}', '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}',
); );
expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded"); expect(formatAssistantErrorText(msg)).toBe(
"The AI service returned an error. Please try again.",
);
}); });
}); });
@@ -17,10 +17,4 @@ describe("formatRawAssistantErrorForUi", () => {
it("renders a generic unknown error message when raw is empty", () => { it("renders a generic unknown error message when raw is empty", () => {
expect(formatRawAssistantErrorForUi("")).toContain("unknown error"); expect(formatRawAssistantErrorForUi("")).toContain("unknown error");
}); });
it("formats plain HTTP status lines", () => {
expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe(
"HTTP 500: Internal Server Error",
);
});
}); });
@@ -19,12 +19,12 @@ describe("sanitizeUserFacingText", () => {
it("sanitizes HTTP status errors with error hints", () => { it("sanitizes HTTP status errors with error hints", () => {
expect(sanitizeUserFacingText("500 Internal Server Error")).toBe( expect(sanitizeUserFacingText("500 Internal Server Error")).toBe(
"HTTP 500: Internal Server Error", "The AI service returned an error. Please try again.",
); );
}); });
it("sanitizes raw API error payloads", () => { it("sanitizes raw API error payloads", () => {
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}'; const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded"); expect(sanitizeUserFacingText(raw)).toBe("The AI service returned an error. Please try again.");
}); });
}); });
+4 -12
View File
@@ -201,14 +201,6 @@ export function formatRawAssistantErrorForUi(raw?: string): string {
const trimmed = (raw ?? "").trim(); const trimmed = (raw ?? "").trim();
if (!trimmed) return "LLM request failed with an unknown error."; if (!trimmed) return "LLM request failed with an unknown error.";
const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
if (httpMatch) {
const rest = httpMatch[2].trim();
if (!rest.startsWith("{")) {
return `HTTP ${httpMatch[1]}: ${rest}`;
}
}
const info = parseApiErrorInfo(trimmed); const info = parseApiErrorInfo(trimmed);
if (info?.message) { if (info?.message) {
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error"; const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
@@ -269,8 +261,8 @@ export function formatAssistantErrorText(
return "The AI service is temporarily overloaded. Please try again in a moment."; return "The AI service is temporarily overloaded. Please try again in a moment.";
} }
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) { if (isRawApiErrorPayload(raw)) {
return formatRawAssistantErrorForUi(raw); return "The AI service returned an error. Please try again.";
} }
// Never return raw unhandled errors - log for debugging but return safe message // Never return raw unhandled errors - log for debugging but return safe message
@@ -301,7 +293,7 @@ export function sanitizeUserFacingText(text: string): string {
} }
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) { if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
return formatRawAssistantErrorForUi(trimmed); return "The AI service returned an error. Please try again.";
} }
if (ERROR_PREFIX_RE.test(trimmed)) { if (ERROR_PREFIX_RE.test(trimmed)) {
@@ -311,7 +303,7 @@ export function sanitizeUserFacingText(text: string): string {
if (isTimeoutErrorMessage(trimmed)) { if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out."; return "LLM request timed out.";
} }
return formatRawAssistantErrorForUi(trimmed); return "The AI service returned an error. Please try again.";
} }
return stripped; return stripped;
@@ -6,7 +6,6 @@ import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
import type { ClawdbotConfig } from "../../../config/config.js"; import type { ClawdbotConfig } from "../../../config/config.js";
import { import {
formatAssistantErrorText, formatAssistantErrorText,
formatRawAssistantErrorForUi,
getApiErrorPayloadFingerprint, getApiErrorPayloadFingerprint,
isRawApiErrorPayload, isRawApiErrorPayload,
normalizeTextForComparison, normalizeTextForComparison,
@@ -65,12 +64,6 @@ export function buildEmbeddedRunPayloads(params: {
const rawErrorFingerprint = rawErrorMessage const rawErrorFingerprint = rawErrorMessage
? getApiErrorPayloadFingerprint(rawErrorMessage) ? getApiErrorPayloadFingerprint(rawErrorMessage)
: null; : null;
const formattedRawErrorMessage = rawErrorMessage
? formatRawAssistantErrorForUi(rawErrorMessage)
: null;
const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
? normalizeTextForComparison(formattedRawErrorMessage)
: null;
const normalizedRawErrorText = rawErrorMessage const normalizedRawErrorText = rawErrorMessage
? normalizeTextForComparison(rawErrorMessage) ? normalizeTextForComparison(rawErrorMessage)
: null; : null;
@@ -123,15 +116,10 @@ export function buildEmbeddedRunPayloads(params: {
if (trimmed === genericErrorText) return true; if (trimmed === genericErrorText) return true;
} }
if (rawErrorMessage && trimmed === rawErrorMessage) return true; if (rawErrorMessage && trimmed === rawErrorMessage) return true;
if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) return true;
if (normalizedRawErrorText) { if (normalizedRawErrorText) {
const normalized = normalizeTextForComparison(trimmed); const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedRawErrorText) return true; if (normalized && normalized === normalizedRawErrorText) return true;
} }
if (normalizedFormattedRawErrorMessage) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedFormattedRawErrorMessage) return true;
}
if (rawErrorFingerprint) { if (rawErrorFingerprint) {
const fingerprint = getApiErrorPayloadFingerprint(trimmed); const fingerprint = getApiErrorPayloadFingerprint(trimmed);
if (fingerprint && fingerprint === rawErrorFingerprint) return true; if (fingerprint && fingerprint === rawErrorFingerprint) return true;
+1 -1
View File
@@ -212,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
sessions_send: "Send a message to another session/sub-agent", sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session", sessions_spawn: "Spawn a sub-agent session",
session_status: session_status:
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override", "Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override",
image: "Analyze an image with the configured image model", image: "Analyze an image with the configured image model",
}; };
+1 -1
View File
@@ -215,7 +215,7 @@ export function createSessionStatusTool(opts?: {
label: "Session Status", label: "Session Status",
name: "session_status", name: "session_status",
description: description:
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).", "Show a /status-equivalent session status card. Optional: set per-session model override (model=default resets overrides). Includes usage + cost when available.",
parameters: SessionStatusToolSchema, parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
agentId?: string; agentId?: string;
isGroup: boolean; isGroup: boolean;
sessionEntry: SessionEntry; sessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
elevatedEnabled: boolean; elevatedEnabled: boolean;
@@ -77,6 +77,100 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(result?.text).not.toContain("failed"); expect(result?.text).not.toContain("failed");
}); });
it("shows error message when sessionEntry is missing", async () => {
const directives = parseInlineDirectives("/model openai/gpt-4o");
const sessionStore = {};
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry: undefined, // Missing!
sessionStore,
sessionKey: "agent:main:dm:1",
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
expect(result?.text).toContain("failed");
expect(result?.text).toContain("session state unavailable");
});
it("shows error message when sessionStore is missing", async () => {
const directives = parseInlineDirectives("/model openai/gpt-4o");
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry,
sessionStore: undefined, // Missing!
sessionKey: "agent:main:dm:1",
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
expect(result?.text).toContain("failed");
expect(result?.text).toContain("session state unavailable");
});
it("shows error message when sessionKey is missing", async () => {
const directives = parseInlineDirectives("/model openai/gpt-4o");
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry,
sessionStore,
sessionKey: undefined, // Missing!
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
expect(result?.text).toContain("failed");
expect(result?.text).toContain("session state unavailable");
});
it("shows no model message when no /model directive", async () => { it("shows no model message when no /model directive", async () => {
const directives = parseInlineDirectives("hello world"); const directives = parseInlineDirectives("hello world");
const sessionEntry: SessionEntry = { const sessionEntry: SessionEntry = {
+105 -97
View File
@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
export async function handleDirectiveOnly(params: { export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
directives: InlineDirectives; directives: InlineDirectives;
sessionEntry: SessionEntry; sessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
elevatedEnabled: boolean; elevatedEnabled: boolean;
@@ -288,110 +288,114 @@ export async function handleDirectiveOnly(params: {
nextThinkLevel === "xhigh" && nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel); !supportsXHighThinking(resolvedProvider, resolvedModel);
const prevElevatedLevel = let didPersistModel = false;
currentElevatedLevel ?? if (sessionEntry && sessionStore && sessionKey) {
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ?? const prevElevatedLevel =
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel)); currentElevatedLevel ??
const prevReasoningLevel = (sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off"; (elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
let elevatedChanged = const prevReasoningLevel =
directives.hasElevatedDirective && currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
directives.elevatedLevel !== undefined && let elevatedChanged =
elevatedEnabled && directives.hasElevatedDirective &&
elevatedAllowed; directives.elevatedLevel !== undefined &&
let reasoningChanged = elevatedEnabled &&
directives.hasReasoningDirective && directives.reasoningLevel !== undefined; elevatedAllowed;
if (directives.hasThinkDirective && directives.thinkLevel) { let reasoningChanged =
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel; directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
else sessionEntry.thinkingLevel = directives.thinkLevel; if (directives.hasThinkDirective && directives.thinkLevel) {
} if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
if (shouldDowngradeXHigh) { else sessionEntry.thinkingLevel = directives.thinkLevel;
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
} }
if (directives.execSecurity) { if (shouldDowngradeXHigh) {
sessionEntry.execSecurity = directives.execSecurity; sessionEntry.thinkingLevel = "high";
} }
if (directives.execAsk) { if (directives.hasVerboseDirective && directives.verboseLevel) {
sessionEntry.execAsk = directives.execAsk; applyVerboseOverride(sessionEntry, directives.verboseLevel);
} }
if (directives.execNode) { if (directives.hasReasoningDirective && directives.reasoningLevel) {
sessionEntry.execNode = directives.execNode; if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
} }
} if (directives.hasElevatedDirective && directives.elevatedLevel) {
if (modelSelection) { // Unlike other toggles, elevated defaults can be "on".
applyModelOverrideToSessionEntry({ // Persist "off" explicitly so `/elevated off` actually overrides defaults.
entry: sessionEntry, sessionEntry.elevatedLevel = directives.elevatedLevel;
selection: modelSelection, elevatedChanged =
profileOverride, elevatedChanged ||
}); (directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
} }
if (typeof directives.cap === "number") { if (directives.hasExecDirective && directives.hasExecOptions) {
sessionEntry.queueCap = directives.cap; if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
} }
if (directives.dropPolicy) { if (modelSelection) {
sessionEntry.queueDrop = directives.dropPolicy; applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
didPersistModel = true;
} }
} if (directives.hasQueueDirective && directives.queueReset) {
sessionEntry.updatedAt = Date.now(); delete sessionEntry.queueMode;
sessionStore[sessionKey] = sessionEntry; delete sessionEntry.queueDebounceMs;
if (storePath) { delete sessionEntry.queueCap;
await updateSessionStore(storePath, (store) => { delete sessionEntry.queueDrop;
store[sessionKey] = sessionEntry; } else if (directives.hasQueueDirective) {
}); if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
} if (typeof directives.debounceMs === "number") {
if (modelSelection) { sessionEntry.queueDebounceMs = directives.debounceMs;
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; }
if (nextLabel !== initialModelLabel) { if (typeof directives.cap === "number") {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), { sessionEntry.queueCap = directives.cap;
}
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
}
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
sessionKey,
contextKey: `model:${nextLabel}`,
});
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey, sessionKey,
contextKey: `model:${nextLabel}`, contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
}); });
} }
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
});
} }
const parts: string[] = []; const parts: string[] = [];
@@ -445,7 +449,7 @@ export async function handleDirectiveOnly(params: {
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`, `Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
); );
} }
if (modelSelection) { if (modelSelection && didPersistModel) {
const label = `${modelSelection.provider}/${modelSelection.model}`; const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label; const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
parts.push( parts.push(
@@ -456,6 +460,10 @@ export async function handleDirectiveOnly(params: {
if (profileOverride) { if (profileOverride) {
parts.push(`Auth profile set to ${profileOverride}.`); parts.push(`Auth profile set to ${profileOverride}.`);
} }
} else if (modelSelection && !didPersistModel) {
parts.push(
`Model switch to ${modelSelection.provider}/${modelSelection.model} failed (session state unavailable).`,
);
} }
if (directives.hasQueueDirective && directives.queueMode) { if (directives.hasQueueDirective && directives.queueMode) {
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`)); parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
agentId: string; agentId: string;
agentDir: string; agentDir: string;
agentCfg: AgentDefaults; agentCfg: AgentDefaults;
sessionEntry: SessionEntry; sessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"]; sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
+2 -2
View File
@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
workspaceDir: string; workspaceDir: string;
agentCfg: AgentDefaults; agentCfg: AgentDefaults;
sessionCtx: TemplateContext; sessionCtx: TemplateContext;
sessionEntry: SessionEntry; sessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"]; sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
+1 -1
View File
@@ -237,7 +237,7 @@ export async function resolveNodeProgramArguments(params: {
runtime?: GatewayRuntimePreference; runtime?: GatewayRuntimePreference;
nodePath?: string; nodePath?: string;
}): Promise<GatewayProgramArgs> { }): Promise<GatewayProgramArgs> {
const args = ["node", "run", "--host", params.host, "--port", String(params.port)]; const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
if (params.tls || params.tlsFingerprint) args.push("--tls"); if (params.tls || params.tlsFingerprint) args.push("--tls");
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint); if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
if (params.nodeId) args.push("--node-id", params.nodeId); if (params.nodeId) args.push("--node-id", params.nodeId);
+3 -5
View File
@@ -13,9 +13,7 @@ describe("resolveAssistantIdentity avatar normalization", () => {
}, },
}; };
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe( expect(resolveAssistantIdentity({ cfg }).avatar).toBe(DEFAULT_ASSISTANT_IDENTITY.avatar);
DEFAULT_ASSISTANT_IDENTITY.avatar,
);
}); });
it("keeps short text avatars", () => { it("keeps short text avatars", () => {
@@ -27,7 +25,7 @@ describe("resolveAssistantIdentity avatar normalization", () => {
}, },
}; };
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("PS"); expect(resolveAssistantIdentity({ cfg }).avatar).toBe("PS");
}); });
it("keeps path avatars", () => { it("keeps path avatars", () => {
@@ -39,6 +37,6 @@ describe("resolveAssistantIdentity avatar normalization", () => {
}, },
}; };
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/clawd.png"); expect(resolveAssistantIdentity({ cfg }).avatar).toBe("avatars/clawd.png");
}); });
}); });