diff --git a/package.json b/package.json index 2667bb74f4a..3f61b26815a 100644 --- a/package.json +++ b/package.json @@ -358,6 +358,7 @@ "@mariozechner/pi-tui": "0.57.1", "@modelcontextprotocol/sdk": "1.27.1", "@mozilla/readability": "^0.6.0", + "@pierre/diffs": "1.1.0", "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9477cdd9b2..f0dd7a5cf43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: '@napi-rs/canvas': specifier: ^0.1.89 version: 0.1.95 + '@pierre/diffs': + specifier: 1.1.0 + version: 1.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index a1ffdbc55bd..bcec9d5a8cf 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -150,22 +150,23 @@ export async function getReplyFromConfig( typeof btwQuestion === "string" && shouldHandleTextCommands({ cfg, - surface: finalized.Surface, + surface: finalized.Surface ?? "", commandSource: finalized.CommandSource, }); const useBtwSideTurn = allowBtwSideTurn && typeof btwQuestion === "string"; if (useBtwSideTurn && !commandAuth.isAuthorizedSender) { return undefined; } - if (useBtwSideTurn && btwQuestion.length === 0) { - return { text: "⚙️ Usage: /btw " }; - } if (useBtwSideTurn) { - finalized.Body = btwQuestion; - finalized.BodyForAgent = btwQuestion; - finalized.RawBody = btwQuestion; - finalized.CommandBody = btwQuestion; - finalized.BodyForCommands = btwQuestion; + const btwQuestionText = btwQuestion ?? ""; + if (btwQuestionText.length === 0) { + return { text: "⚙️ Usage: /btw " }; + } + finalized.Body = btwQuestionText; + finalized.BodyForAgent = btwQuestionText; + finalized.RawBody = btwQuestionText; + finalized.CommandBody = btwQuestionText; + finalized.BodyForCommands = btwQuestionText; } if (!isFastTestEnv) { diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 48a3217ac76..a0c82641e3c 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -20,6 +20,7 @@ import { logsHandlers } from "./server-methods/logs.js"; import { modelsHandlers } from "./server-methods/models.js"; import { nodePendingHandlers } from "./server-methods/nodes-pending.js"; import { nodeHandlers } from "./server-methods/nodes.js"; +import { ptyHandlers } from "./server-methods/pty.js"; import { pushHandlers } from "./server-methods/push.js"; import { sendHandlers } from "./server-methods/send.js"; import { sessionsHandlers } from "./server-methods/sessions.js"; diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 471a719c603..33edf14eb06 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -9,6 +9,7 @@ type GatewayClientMock = { start: ReturnType; stop: ReturnType; options: { clientVersion?: string }; + emitHello: (hello: Record) => void; emitClose: (info: { code: number; reason?: string; @@ -39,6 +40,7 @@ vi.mock("./gateway.ts", () => { constructor( private opts: { clientVersion?: string; + onHello?: (hello: Record) => void; onClose?: (info: { code: number; reason: string; @@ -52,6 +54,9 @@ vi.mock("./gateway.ts", () => { start: this.start, stop: this.stop, options: { clientVersion: this.opts.clientVersion }, + emitHello: (hello) => { + this.opts.onHello?.(hello as never); + }, emitClose: (info) => { this.opts.onClose?.({ code: info.code, @@ -158,6 +163,31 @@ describe("connectGateway", () => { ); }); + it("falls back to the main session when persisted session keys point at cron chats", () => { + const host = createHost(); + host.sessionKey = "agent:main:cron:nightly-brief"; + host.settings.sessionKey = "agent:main:cron:nightly-brief"; + host.settings.lastActiveSessionKey = "cron:nightly-brief"; + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitHello({ + snapshot: { + sessionDefaults: { + mainSessionKey: "agent:main:main", + mainKey: "main", + defaultAgentId: "main", + }, + }, + }); + + expect(host.sessionKey).toBe("agent:main:main"); + expect(host.settings.sessionKey).toBe("agent:main:main"); + expect(host.settings.lastActiveSessionKey).toBe("agent:main:main"); + }); + it("ignores stale client onEvent callbacks after reconnect", () => { const host = createHost(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index bcd8a866e4e..c97239c3a4c 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -118,6 +118,24 @@ export function resolveControlUiClientVersion(params: { } } +function isCronSessionKey(value: string | undefined): boolean { + const normalized = (value ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + if (normalized.startsWith("cron:")) { + return true; + } + if (!normalized.startsWith("agent:")) { + return false; + } + const parts = normalized.split(":").filter(Boolean); + if (parts.length < 3) { + return false; + } + return parts.slice(2).join(":").startsWith("cron:"); +} + function normalizeSessionKeyForDefaults( value: string | undefined, defaults: SessionDefaultsSnapshot, @@ -130,6 +148,9 @@ function normalizeSessionKeyForDefaults( if (!raw) { return mainSessionKey; } + if (isCronSessionKey(raw)) { + return mainSessionKey; + } const mainKey = defaults.mainKey?.trim() || "main"; const defaultAgentId = defaults.defaultAgentId?.trim(); const isAlias = diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 4a46b8d0703..b4579295ce1 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,6 +1,7 @@ const KEY = "openclaw.control.settings.v1"; const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1"; const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:"; +const TOKEN_LOCAL_KEY_PREFIX = "openclaw.control.token.persisted.v1:"; type PersistedUiSettings = Omit & { token?: never }; @@ -11,6 +12,7 @@ import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts" export type UiSettings = { gatewayUrl: string; token: string; + rememberGatewayAuth: boolean; sessionKey: string; lastActiveSessionKey: string; theme: ThemeName; @@ -86,6 +88,10 @@ function tokenSessionKeyForGateway(gatewayUrl: string): string { return `${TOKEN_SESSION_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`; } +function tokenLocalKeyForGateway(gatewayUrl: string): string { + return `${TOKEN_LOCAL_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`; +} + function loadSessionToken(gatewayUrl: string): string { try { const storage = getSessionStorage(); @@ -119,12 +125,37 @@ function persistSessionToken(gatewayUrl: string, token: string) { } } +function loadRememberedToken(gatewayUrl: string): string { + try { + const token = localStorage.getItem(tokenLocalKeyForGateway(gatewayUrl)) ?? ""; + return token.trim(); + } catch { + return ""; + } +} + +function persistGatewayToken(gatewayUrl: string, token: string, remember: boolean) { + try { + const normalized = token.trim(); + persistSessionToken(gatewayUrl, remember ? "" : normalized); + const localKey = tokenLocalKeyForGateway(gatewayUrl); + if (remember && normalized) { + localStorage.setItem(localKey, normalized); + } else { + localStorage.removeItem(localKey); + } + } catch { + // best-effort + } +} + export function loadSettings(): UiSettings { const { pageUrl: pageDerivedUrl, effectiveUrl: defaultUrl } = deriveDefaultGatewayUrl(); const defaults: UiSettings = { gatewayUrl: defaultUrl, token: loadSessionToken(defaultUrl), + rememberGatewayAuth: false, sessionKey: "main", lastActiveSessionKey: "main", theme: "claw", @@ -152,10 +183,12 @@ export function loadSettings(): UiSettings { (parsed as { theme?: unknown }).theme, (parsed as { themeMode?: unknown }).themeMode, ); + const rememberGatewayAuth = + typeof parsed.rememberGatewayAuth === "boolean" ? parsed.rememberGatewayAuth : false; const settings = { gatewayUrl, - // Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load. - token: loadSessionToken(gatewayUrl), + token: rememberGatewayAuth ? loadRememberedToken(gatewayUrl) : loadSessionToken(gatewayUrl), + rememberGatewayAuth, sessionKey: typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() ? parsed.sessionKey.trim() @@ -205,9 +238,10 @@ export function saveSettings(next: UiSettings) { } function persistSettings(next: UiSettings) { - persistSessionToken(next.gatewayUrl, next.token); + persistGatewayToken(next.gatewayUrl, next.token, next.rememberGatewayAuth); const persisted: PersistedUiSettings = { gatewayUrl: next.gatewayUrl, + rememberGatewayAuth: next.rememberGatewayAuth, sessionKey: next.sessionKey, lastActiveSessionKey: next.lastActiveSessionKey, theme: next.theme, diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts index 77613822cdf..8177722f852 100644 --- a/ui/src/ui/views/login-gate.ts +++ b/ui/src/ui/views/login-gate.ts @@ -97,6 +97,17 @@ export function renderLoginGate(state: AppViewState) { +