Compare commits

..
33 changed files with 276 additions and 410 deletions

No files matched your search

+2 -5
View File
@@ -2,15 +2,12 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.22 (unreleased) ## 2026.1.22 (Unreleased)
### 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.
- Auto-reply: keep cached context token count in sync after compaction. (#1440) Thanks @robbyczgw-cla. - Node: launch node host service with `node run` (not `node start`) and align node host hints/docs. (#1461) Thanks @ameno-.
- 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
-1
View File
@@ -59,7 +59,6 @@ Manage the service:
```bash ```bash
clawdbot node status clawdbot node status
clawdbot node run
clawdbot node stop clawdbot node stop
clawdbot node restart clawdbot node restart
clawdbot node uninstall clawdbot node uninstall
+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.
+15 -17
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)
@@ -61,9 +57,11 @@ clawdbot node run --host <gateway-host> --port 18789 --display-name "Build Node"
```bash ```bash
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node" clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
clawdbot node start clawdbot node status
``` ```
Install starts the service. Use `clawdbot node restart` if you need to re-launch it.
### Pair + name ### Pair + name
On the gateway host: On the gateway host:
@@ -279,7 +277,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 +294,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;
+1 -27
View File
@@ -1,12 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import { import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
createAgentSession,
estimateTokens,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
@@ -375,26 +370,6 @@ export async function compactEmbeddedPiSession(params: {
session.agent.replaceMessages(limited); session.agent.replaceMessages(limited);
} }
const result = await session.compact(params.customInstructions); const result = await session.compact(params.customInstructions);
// Estimate tokens after compaction with the same context-usage heuristics.
let tokensAfter: number | undefined;
try {
const usage =
typeof session.getContextUsage === "function"
? session.getContextUsage()
: undefined;
let estimate = usage?.tokens;
if (!Number.isFinite(estimate) || !estimate || estimate <= 0) {
estimate = 0;
for (const message of session.messages) {
estimate += estimateTokens(message);
}
}
if (Number.isFinite(estimate) && estimate > 0 && estimate <= result.tokensBefore) {
tokensAfter = estimate;
}
} catch {
tokensAfter = undefined;
}
return { return {
ok: true, ok: true,
compacted: true, compacted: true,
@@ -402,7 +377,6 @@ export async function compactEmbeddedPiSession(params: {
summary: result.summary, summary: result.summary,
firstKeptEntryId: result.firstKeptEntryId, firstKeptEntryId: result.firstKeptEntryId,
tokensBefore: result.tokensBefore, tokensBefore: result.tokensBefore,
tokensAfter,
details: result.details, details: result.details,
}, },
}; };
@@ -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
View File
@@ -59,7 +59,6 @@ export type EmbeddedPiCompactResult = {
summary: string; summary: string;
firstKeptEntryId: string; firstKeptEntryId: string;
tokensBefore: number; tokensBefore: number;
tokensAfter?: number;
details?: unknown; details?: unknown;
}; };
}; };
+1 -2
View File
@@ -31,8 +31,7 @@ const subagentRuns = new Map<string, SubagentRunRecord>();
let sweeper: NodeJS.Timeout | null = null; let sweeper: NodeJS.Timeout | null = null;
let listenerStarted = false; let listenerStarted = false;
let listenerStop: (() => void) | null = null; let listenerStop: (() => void) | null = null;
// Use var to avoid TDZ on circular init paths that can call restoreSubagentRunsOnce early. let restoreAttempted = false;
var restoreAttempted = false;
function persistSubagentRuns() { function persistSubagentRuns() {
try { try {
+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>;
+1 -1
View File
@@ -42,7 +42,7 @@ describe("block streaming", () => {
}); });
async function waitForCalls(fn: () => number, calls: number) { async function waitForCalls(fn: () => number, calls: number) {
const deadline = Date.now() + 15000; const deadline = Date.now() + 5000;
while (fn() < calls) { while (fn() < calls) {
if (Date.now() > deadline) { if (Date.now() > deadline) {
throw new Error(`Expected ${calls} call(s), got ${fn()}`); throw new Error(`Expected ${calls} call(s), got ${fn()}`);
+10 -17
View File
@@ -83,13 +83,18 @@ export const handleCompactCommand: CommandHandler = async (params) => {
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined, ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
}); });
const totalTokens =
params.sessionEntry.totalTokens ??
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
totalTokens > 0 ? totalTokens : null,
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
);
const compactLabel = result.ok const compactLabel = result.ok
? result.compacted ? result.compacted
? result.result?.tokensBefore != null && result.result?.tokensAfter != null ? result.result?.tokensBefore
? `Compacted (${formatTokenCount(result.result.tokensBefore)} → ${formatTokenCount(result.result.tokensAfter)})` ? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
: result.result?.tokensBefore : "Compacted"
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
: "Compacted"
: "Compaction skipped" : "Compaction skipped"
: "Compaction failed"; : "Compaction failed";
if (result.ok && result.compacted) { if (result.ok && result.compacted) {
@@ -98,20 +103,8 @@ export const handleCompactCommand: CommandHandler = async (params) => {
sessionStore: params.sessionStore, sessionStore: params.sessionStore,
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
storePath: params.storePath, storePath: params.storePath,
// Update token counts after compaction
tokensAfter: result.result?.tokensAfter,
}); });
} }
// Use the post-compaction token count for context summary if available
const tokensAfterCompaction = result.result?.tokensAfter;
const totalTokens =
tokensAfterCompaction ??
params.sessionEntry.totalTokens ??
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
totalTokens > 0 ? totalTokens : null,
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
);
const reason = result.reason?.trim(); const reason = result.reason?.trim();
const line = reason const line = reason
? `${compactLabel}: ${reason} • ${contextSummary}` ? `${compactLabel}: ${reason} • ${contextSummary}`
@@ -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 -35
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { incrementCompactionCount, prependSystemEvents } from "./session-updates.js"; import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => { describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events by default", async () => { it("adds a local timestamp to queued system events by default", async () => {
@@ -29,37 +29,3 @@ describe("prependSystemEvents", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
describe("incrementCompactionCount", () => {
it("updates cached total tokens after compaction without clearing input/output", async () => {
const sessionKey = "agent:main:main";
const sessionStore = {
[sessionKey]: {
sessionId: "s1",
updatedAt: 10,
compactionCount: 1,
totalTokens: 9_000,
inputTokens: 111,
outputTokens: 222,
},
};
const now = 1234;
const nextCount = await incrementCompactionCount({
sessionEntry: sessionStore[sessionKey],
sessionStore,
sessionKey,
now,
tokensAfter: 2_000,
});
expect(nextCount).toBe(2);
expect(sessionStore[sessionKey]).toMatchObject({
compactionCount: 2,
totalTokens: 2_000,
inputTokens: 111,
outputTokens: 222,
updatedAt: now,
});
});
});
+5 -21
View File
@@ -237,39 +237,23 @@ export async function incrementCompactionCount(params: {
sessionKey?: string; sessionKey?: string;
storePath?: string; storePath?: string;
now?: number; now?: number;
/** Token count after compaction - if provided, updates cached context usage */
tokensAfter?: number;
}): Promise<number | undefined> { }): Promise<number | undefined> {
const { const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now() } = params;
sessionEntry,
sessionStore,
sessionKey,
storePath,
now = Date.now(),
tokensAfter,
} = params;
if (!sessionStore || !sessionKey) return undefined; if (!sessionStore || !sessionKey) return undefined;
const entry = sessionStore[sessionKey] ?? sessionEntry; const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) return undefined; if (!entry) return undefined;
const nextCount = (entry.compactionCount ?? 0) + 1; const nextCount = (entry.compactionCount ?? 0) + 1;
// Build update payload with compaction count and optionally updated context usage.
const updates: Partial<SessionEntry> = {
compactionCount: nextCount,
updatedAt: now,
};
// If tokensAfter is provided, update the cached total to reflect post-compaction context size.
if (tokensAfter != null && tokensAfter > 0) {
updates.totalTokens = tokensAfter;
}
sessionStore[sessionKey] = { sessionStore[sessionKey] = {
...entry, ...entry,
...updates, compactionCount: nextCount,
updatedAt: now,
}; };
if (storePath) { if (storePath) {
await updateSessionStore(storePath, (store) => { await updateSessionStore(storePath, (store) => {
store[sessionKey] = { store[sessionKey] = {
...store[sessionKey], ...store[sessionKey],
...updates, compactionCount: nextCount,
updatedAt: now,
}; };
}); });
} }
+1 -1
View File
@@ -47,7 +47,7 @@ type NodeDaemonStatusOptions = {
}; };
function renderNodeServiceStartHints(): string[] { function renderNodeServiceStartHints(): string[] {
const base = [formatCliCommand("clawdbot node install"), formatCliCommand("clawdbot node start")]; const base = [formatCliCommand("clawdbot node install"), formatCliCommand("clawdbot node run")];
switch (process.platform) { switch (process.platform) {
case "darwin": case "darwin":
return [ return [
+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");
}); });
}); });
-66
View File
@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveAssistantAvatarUrl } from "./control-ui.js";
describe("resolveAssistantAvatarUrl", () => {
it("keeps remote and data URLs", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "https://example.com/avatar.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("https://example.com/avatar.png");
expect(
resolveAssistantAvatarUrl({
avatar: "data:image/png;base64,abc",
agentId: "main",
basePath: "/ui",
}),
).toBe("data:image/png;base64,abc");
});
it("prefixes basePath for /avatar endpoints", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "/ui/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("maps local avatar paths to the avatar endpoint", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/me.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/profile",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("keeps short text avatars", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "PS",
agentId: "main",
basePath: "/ui",
}),
).toBe("PS");
});
});
+2 -40
View File
@@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body)); res.end(JSON.stringify(body));
} }
export function buildAvatarUrl(basePath: string, agentId: string): string { function buildAvatarUrl(basePath: string, agentId: string): string {
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`; return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
} }
@@ -206,49 +206,11 @@ interface ServeIndexHtmlOpts {
agentId?: string; agentId?: string;
} }
function looksLikeLocalAvatarPath(value: string): boolean {
if (/[\\/]/.test(value)) return true;
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
}
export function resolveAssistantAvatarUrl(params: {
avatar?: string | null;
agentId?: string | null;
basePath?: string;
}): string | undefined {
const avatar = params.avatar?.trim();
if (!avatar) return undefined;
if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar;
const basePath = normalizeControlUiBasePath(params.basePath);
const baseAvatarPrefix = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
if (basePath && avatar.startsWith(`${AVATAR_PREFIX}/`)) {
return `${basePath}${avatar}`;
}
if (avatar.startsWith(baseAvatarPrefix)) return avatar;
if (!params.agentId) return avatar;
if (looksLikeLocalAvatarPath(avatar)) {
return buildAvatarUrl(basePath, params.agentId);
}
return avatar;
}
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) { function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts; const { basePath, config, agentId } = opts;
const identity = config const identity = config
? resolveAssistantIdentity({ cfg: config, agentId }) ? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY; : DEFAULT_ASSISTANT_IDENTITY;
const resolvedAgentId =
typeof (identity as { agentId?: string }).agentId === "string"
? (identity as { agentId?: string }).agentId
: agentId;
const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: resolvedAgentId,
basePath,
}) ?? identity.avatar;
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8"); const raw = fs.readFileSync(indexPath, "utf8");
@@ -256,7 +218,7 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
injectControlUiConfig(raw, { injectControlUiConfig(raw, {
basePath, basePath,
assistantName: identity.name, assistantName: identity.name,
assistantAvatar: avatarValue, assistantAvatar: identity.avatar,
}), }),
); );
} }
+1 -8
View File
@@ -38,7 +38,6 @@ import {
import { loadSessionEntry } from "../session-utils.js"; import { loadSessionEntry } from "../session-utils.js";
import { formatForLog } from "../ws-log.js"; import { formatForLog } from "../ws-log.js";
import { resolveAssistantIdentity } from "../assistant-identity.js"; import { resolveAssistantIdentity } from "../assistant-identity.js";
import { resolveAssistantAvatarUrl } from "../control-ui.js";
import { waitForAgentJob } from "./agent-job.js"; import { waitForAgentJob } from "./agent-job.js";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
@@ -408,13 +407,7 @@ export const agentHandlers: GatewayRequestHandlers = {
} }
const cfg = loadConfig(); const cfg = loadConfig();
const identity = resolveAssistantIdentity({ cfg, agentId }); const identity = resolveAssistantIdentity({ cfg, agentId });
const avatarValue = respond(true, identity, undefined);
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: identity.agentId,
basePath: cfg.gateway?.controlUi?.basePath,
}) ?? identity.avatar;
respond(true, { ...identity, avatar: avatarValue }, undefined);
}, },
"agent.wait": async ({ params, respond }) => { "agent.wait": async ({ params, respond }) => {
if (!validateAgentWaitParams(params)) { if (!validateAgentWaitParams(params)) {
+1 -2
View File
@@ -158,8 +158,7 @@ function renderAvatar(
function isAvatarUrl(value: string): boolean { function isAvatarUrl(value: string): boolean {
return ( return (
/^https?:\/\//i.test(value) || /^https?:\/\//i.test(value) ||
/^data:image\//i.test(value) || /^data:image\//i.test(value)
/^\//.test(value) // Relative paths from avatar endpoint
); );
} }