From 147fccd967e14c9a507ad57234a4bcd23a9ba29c Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 17 Jan 2026 19:00:58 -0800 Subject: [PATCH 001/171] Add lobster tool for running local Lobster pipelines --- docs/tools/lobster.md | 64 +++++++ src/agents/clawdbot-tools.ts | 2 + src/agents/tools/lobster-tool.test.ts | 122 ++++++++++++++ src/agents/tools/lobster-tool.ts | 231 ++++++++++++++++++++++++++ 4 files changed, 419 insertions(+) create mode 100644 docs/tools/lobster.md create mode 100644 src/agents/tools/lobster-tool.test.ts create mode 100644 src/agents/tools/lobster-tool.ts diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md new file mode 100644 index 000000000..f54fc3da2 --- /dev/null +++ b/docs/tools/lobster.md @@ -0,0 +1,64 @@ +--- +title: Lobster +description: Run Lobster pipelines (typed workflows) as a first-class Clawdbot tool. +--- + +# Lobster + +The `lobster` tool lets Clawdbot run Lobster pipelines as a **local-first, typed workflow runtime**. + +This is designed for: +- Deterministic orchestration (move multi-step tool workflows out of the LLM) +- Human-in-the-loop approvals that **halt and resume** +- Lower token usage (one `lobster.run` call instead of many tool calls) + +## Security model + +- Lobster runs as a **local subprocess**. +- Lobster does **not** manage OAuth or secrets. +- Side effects still go through Clawdbot tools (messaging, files, etc.). + +Recommendations: +- Prefer configuring `lobsterPath` as an **absolute path** to avoid PATH hijack. +- Use Lobster approvals (`approve`) for any side-effectful step. + +## Actions + +### `run` + +Run a pipeline in tool mode. + +Example: + +```json +{ + "action": "run", + "pipeline": "exec --json \"echo [1]\" | approve --prompt 'ok?'", + "lobsterPath": "/absolute/path/to/lobster", + "timeoutMs": 20000 +} +``` + +### `resume` + +Resume a halted pipeline. + +Example: + +```json +{ + "action": "resume", + "token": "", + "approve": true, + "lobsterPath": "/absolute/path/to/lobster" +} +``` + +## Output + +Lobster returns a JSON envelope: + +- `ok`: boolean +- `status`: `ok` | `needs_approval` | `cancelled` +- `output`: array of items +- `requiresApproval`: approval request object (when `status=needs_approval`) diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 5ae4891dc..c95636fb3 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -9,6 +9,7 @@ import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { createImageTool } from "./tools/image-tool.js"; +import { createLobsterTool } from "./tools/lobster-tool.js"; import { createMessageTool } from "./tools/message-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js"; import { createSessionStatusTool } from "./tools/session-status-tool.js"; @@ -111,6 +112,7 @@ export function createClawdbotTools(options?: { agentSessionKey: options?.agentSessionKey, config: options?.config, }), + createLobsterTool({ sandboxed: options?.sandboxed }), ...(webSearchTool ? [webSearchTool] : []), ...(webFetchTool ? [webFetchTool] : []), ...(imageTool ? [imageTool] : []), diff --git a/src/agents/tools/lobster-tool.test.ts b/src/agents/tools/lobster-tool.test.ts new file mode 100644 index 000000000..26441f2d6 --- /dev/null +++ b/src/agents/tools/lobster-tool.test.ts @@ -0,0 +1,122 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createLobsterTool } from "./lobster-tool.js"; + +async function writeFakeLobster(params: { + script: (args: string[]) => unknown; +}) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-")); + const binPath = path.join(dir, "lobster"); + + const file = `#!/usr/bin/env node\n` + + `const args = process.argv.slice(2);\n` + + `const payload = (${params.script.toString()})(args);\n` + + `process.stdout.write(JSON.stringify(payload));\n`; + + await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); + return { dir, binPath }; +} + +describe("lobster tool", () => { + it("runs lobster in tool mode and returns envelope", async () => { + const fake = await writeFakeLobster({ + script: (args) => { + if (args[0] !== "run") throw new Error("expected run"); + return { + ok: true, + status: "ok", + output: [{ hello: "world" }], + requiresApproval: null, + }; + }, + }); + + const tool = createLobsterTool(); + const res = await tool.execute("call1", { + action: "run", + pipeline: "exec --json \"echo [1]\"", + lobsterPath: fake.binPath, + timeoutMs: 1000, + }); + + expect(res.details).toMatchObject({ + ok: true, + status: "ok", + output: [{ hello: "world" }], + requiresApproval: null, + }); + }); + + it("supports resume action", async () => { + const fake = await writeFakeLobster({ + script: (args) => { + if (args[0] !== "resume") throw new Error("expected resume"); + return { + ok: true, + status: "ok", + output: ["resumed"], + requiresApproval: null, + }; + }, + }); + + const tool = createLobsterTool(); + const res = await tool.execute("call2", { + action: "resume", + token: "tok", + approve: true, + lobsterPath: fake.binPath, + timeoutMs: 1000, + }); + + expect(res.details).toMatchObject({ ok: true, status: "ok" }); + }); + + it("rejects non-absolute lobsterPath", async () => { + const tool = createLobsterTool(); + await expect( + tool.execute("call3", { + action: "run", + pipeline: "json", + lobsterPath: "./lobster", + }), + ).rejects.toThrow(/absolute path/); + }); + + it("blocks tool in sandboxed mode", async () => { + const tool = createLobsterTool({ sandboxed: true }); + await expect( + tool.execute("call4", { + action: "run", + pipeline: "json", + lobsterPath: "/usr/bin/true", + }), + ).rejects.toThrow(/not available in sandboxed/); + }); + + it("rejects invalid JSON", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-bad-")); + const binPath = path.join(dir, "lobster"); + await fs.writeFile( + binPath, + `#!/usr/bin/env node\nprocess.stdout.write('not-json');\n`, + { + encoding: "utf8", + mode: 0o755, + }, + ); + + const tool = createLobsterTool(); + await expect( + tool.execute("call5", { + action: "run", + pipeline: "json", + lobsterPath: binPath, + }), + ).rejects.toThrow(/invalid JSON/); + }); +}); diff --git a/src/agents/tools/lobster-tool.ts b/src/agents/tools/lobster-tool.ts new file mode 100644 index 000000000..c01cab81a --- /dev/null +++ b/src/agents/tools/lobster-tool.ts @@ -0,0 +1,231 @@ +import { Type } from "@sinclair/typebox"; +import { spawn } from "node:child_process"; +import path from "node:path"; + +import { stringEnum } from "../schema/typebox.js"; +import type { AnyAgentTool } from "./common.js"; +import { jsonResult, readNumberParam, readStringParam } from "./common.js"; + +const LobsterActions = ["run", "resume"] as const; + +type LobsterToolParams = { + action: (typeof LobsterActions)[number]; + pipeline?: string; + token?: string; + approve?: boolean; + lobsterPath?: string; + cwd?: string; + timeoutMs?: number; + maxStdoutBytes?: number; +}; + +type LobsterEnvelope = + | { + ok: true; + status: "ok" | "needs_approval" | "cancelled"; + output: unknown[]; + requiresApproval: null | { + type: "approval_request"; + prompt: string; + items: unknown[]; + resumeToken?: string; + }; + } + | { + ok: false; + error: { type?: string; message: string }; + }; + +function buildSchema() { + return Type.Object({ + action: stringEnum(LobsterActions), + pipeline: Type.Optional(Type.String({ description: "Lobster pipeline string." })), + token: Type.Optional(Type.String({ description: "Resume token from lobster tool mode." })), + approve: Type.Optional(Type.Boolean({ description: "Approval decision for resume." })), + lobsterPath: Type.Optional( + Type.String({ + description: + "Path to lobster executable. Prefer an absolute path to avoid PATH hijack. Defaults to 'lobster'.", + }), + ), + cwd: Type.Optional( + Type.String({ + description: "Working directory for lobster subprocess.", + }), + ), + timeoutMs: Type.Optional( + Type.Number({ + description: "Subprocess timeout (ms).", + }), + ), + maxStdoutBytes: Type.Optional( + Type.Number({ + description: "Max stdout bytes to read before aborting.", + }), + ), + }); +} + +function resolveExecutablePath(lobsterPathRaw: string | undefined) { + const lobsterPath = lobsterPathRaw?.trim() || "lobster"; + if (lobsterPath !== "lobster" && !path.isAbsolute(lobsterPath)) { + throw new Error("lobsterPath must be an absolute path (or omit to use PATH)"); + } + return lobsterPath; +} + +async function runLobsterSubprocess(params: { + execPath: string; + argv: string[]; + cwd: string; + timeoutMs: number; + maxStdoutBytes: number; +}) { + const { execPath, argv, cwd } = params; + const timeoutMs = Math.max(200, params.timeoutMs); + const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); + + return await new Promise<{ stdout: string; exitCode: number | null }>((resolve, reject) => { + const child = spawn(execPath, argv, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + // Ensure lobster never tries to be interactive. + LOBSTER_MODE: "tool", + }, + }); + + let stdout = ""; + let stdoutBytes = 0; + let stderr = ""; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + child.stdout?.on("data", (chunk) => { + const str = String(chunk); + stdoutBytes += Buffer.byteLength(str, "utf8"); + if (stdoutBytes > maxStdoutBytes) { + try { + child.kill("SIGKILL"); + } finally { + reject(new Error("lobster output exceeded maxStdoutBytes")); + } + return; + } + stdout += str; + }); + + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } finally { + reject(new Error("lobster subprocess timed out")); + } + }, timeoutMs); + + child.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + + child.once("exit", (code) => { + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)); + return; + } + resolve({ stdout, exitCode: code }); + }); + }); +} + +function parseEnvelope(stdout: string): LobsterEnvelope { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error("lobster returned invalid JSON"); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("lobster returned invalid JSON envelope"); + } + + const ok = (parsed as { ok?: unknown }).ok; + if (ok === true) { + const env = parsed as LobsterEnvelope; + if (!Array.isArray((env as any).output)) { + throw new Error("lobster tool output must include output[]"); + } + return env; + } + + if (ok === false) { + const env = parsed as LobsterEnvelope; + const msg = (env as any)?.error?.message; + if (typeof msg !== "string" || !msg.trim()) { + throw new Error("lobster error envelope missing error.message"); + } + return env; + } + + throw new Error("lobster returned invalid JSON envelope"); +} + +export function createLobsterTool(options: { sandboxed?: boolean } = {}): AnyAgentTool { + const parameters = buildSchema(); + + return { + label: "Lobster", + name: "lobster", + description: + "Run Lobster pipelines as a local-first, typed workflow runtime (tool mode JSON envelope, resumable approvals).", + parameters, + async execute(_callId, paramsRaw) { + if (options.sandboxed) { + throw new Error("lobster tool is not available in sandboxed mode"); + } + + const params = paramsRaw as Record; + const action = readStringParam(params, "action", { required: true }) as LobsterToolParams["action"]; + + const execPath = resolveExecutablePath(readStringParam(params, "lobsterPath")); + const cwd = readStringParam(params, "cwd", { allowEmpty: false }) || process.cwd(); + + const timeoutMs = readNumberParam(params, "timeoutMs", { integer: true }) ?? 20_000; + const maxStdoutBytes = readNumberParam(params, "maxStdoutBytes", { integer: true }) ?? 512_000; + + let argv: string[]; + if (action === "run") { + const pipeline = readStringParam(params, "pipeline", { required: true, label: "pipeline" })!; + argv = ["run", "--mode", "tool", pipeline]; + } else if (action === "resume") { + const token = readStringParam(params, "token", { required: true, label: "token" })!; + const approve = params["approve"]; + if (typeof approve !== "boolean") { + throw new Error("approve required"); + } + argv = ["resume", "--token", token, "--approve", approve ? "yes" : "no"]; + } else { + throw new Error(`Unknown action: ${action}`); + } + + const { stdout } = await runLobsterSubprocess({ + execPath, + argv, + cwd, + timeoutMs, + maxStdoutBytes, + }); + + const envelope = parseEnvelope(stdout); + return jsonResult(envelope); + }, + }; +} From b2650ba6725f11b936a91e6c008e099d32f2aa47 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 17 Jan 2026 20:18:54 -0800 Subject: [PATCH 002/171] Move lobster integration to optional plugin tool --- docs/tools/lobster.md | 64 ------ extensions/lobster/README.md | 38 ++++ extensions/lobster/index.ts | 7 + extensions/lobster/package.json | 9 + extensions/lobster/src/lobster-tool.test.ts | 87 ++++++++ extensions/lobster/src/lobster-tool.ts | 185 ++++++++++++++++ src/agents/clawdbot-tools.ts | 2 - src/agents/tools/lobster-tool.test.ts | 122 ----------- src/agents/tools/lobster-tool.ts | 231 -------------------- 9 files changed, 326 insertions(+), 419 deletions(-) delete mode 100644 docs/tools/lobster.md create mode 100644 extensions/lobster/README.md create mode 100644 extensions/lobster/index.ts create mode 100644 extensions/lobster/package.json create mode 100644 extensions/lobster/src/lobster-tool.test.ts create mode 100644 extensions/lobster/src/lobster-tool.ts delete mode 100644 src/agents/tools/lobster-tool.test.ts delete mode 100644 src/agents/tools/lobster-tool.ts diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md deleted file mode 100644 index f54fc3da2..000000000 --- a/docs/tools/lobster.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Lobster -description: Run Lobster pipelines (typed workflows) as a first-class Clawdbot tool. ---- - -# Lobster - -The `lobster` tool lets Clawdbot run Lobster pipelines as a **local-first, typed workflow runtime**. - -This is designed for: -- Deterministic orchestration (move multi-step tool workflows out of the LLM) -- Human-in-the-loop approvals that **halt and resume** -- Lower token usage (one `lobster.run` call instead of many tool calls) - -## Security model - -- Lobster runs as a **local subprocess**. -- Lobster does **not** manage OAuth or secrets. -- Side effects still go through Clawdbot tools (messaging, files, etc.). - -Recommendations: -- Prefer configuring `lobsterPath` as an **absolute path** to avoid PATH hijack. -- Use Lobster approvals (`approve`) for any side-effectful step. - -## Actions - -### `run` - -Run a pipeline in tool mode. - -Example: - -```json -{ - "action": "run", - "pipeline": "exec --json \"echo [1]\" | approve --prompt 'ok?'", - "lobsterPath": "/absolute/path/to/lobster", - "timeoutMs": 20000 -} -``` - -### `resume` - -Resume a halted pipeline. - -Example: - -```json -{ - "action": "resume", - "token": "", - "approve": true, - "lobsterPath": "/absolute/path/to/lobster" -} -``` - -## Output - -Lobster returns a JSON envelope: - -- `ok`: boolean -- `status`: `ok` | `needs_approval` | `cancelled` -- `output`: array of items -- `requiresApproval`: approval request object (when `status=needs_approval`) diff --git a/extensions/lobster/README.md b/extensions/lobster/README.md new file mode 100644 index 000000000..13e675b45 --- /dev/null +++ b/extensions/lobster/README.md @@ -0,0 +1,38 @@ +# Lobster (plugin) + +Adds the `lobster` agent tool as an **optional** plugin tool. + +## What this is + +- Lobster is a standalone workflow shell (typed JSON-first pipelines + approvals/resume). +- This plugin integrates Lobster with Clawdbot *without core changes*. + +## Enable + +Because this tool can trigger side effects (via workflows), it is registered with `optional: true`. + +Enable it in an agent allowlist: + +```json +{ + "agents": { + "list": [ + { + "id": "main", + "tools": { + "allow": [ + "lobster" // plugin id (enables all tools from this plugin) + ] + } + } + ] + } +} +``` + +## Security + +- Runs the `lobster` executable as a local subprocess. +- Does not manage OAuth/tokens. +- Uses timeouts, stdout caps, and strict JSON envelope parsing. +- Prefer an absolute `lobsterPath` in production to avoid PATH hijack. diff --git a/extensions/lobster/index.ts b/extensions/lobster/index.ts new file mode 100644 index 000000000..f1c06c554 --- /dev/null +++ b/extensions/lobster/index.ts @@ -0,0 +1,7 @@ +import type { ClawdbotPluginApi } from "../../src/plugins/types.js"; + +import { createLobsterTool } from "./src/lobster-tool.js"; + +export default function register(api: ClawdbotPluginApi) { + api.registerTool(createLobsterTool(api), { optional: true }); +} diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json new file mode 100644 index 000000000..606975434 --- /dev/null +++ b/extensions/lobster/package.json @@ -0,0 +1,9 @@ +{ + "name": "@clawdbot/lobster", + "version": "2026.1.17-1", + "type": "module", + "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", + "clawdbot": { + "extensions": ["./index.ts"] + } +} diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts new file mode 100644 index 000000000..3b1dae859 --- /dev/null +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { ClawdbotPluginApi } from "../../../src/plugins/types.js"; +import { createLobsterTool } from "./lobster-tool.js"; + +async function writeFakeLobster(params: { + payload: unknown; +}) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-")); + const binPath = path.join(dir, "lobster"); + + const file = `#!/usr/bin/env node\n` + + `process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`; + + await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); + return { dir, binPath }; +} + +function fakeApi(): ClawdbotPluginApi { + return { + id: "lobster", + name: "lobster", + source: "test", + config: {} as any, + runtime: { version: "test" } as any, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + registerTool() {}, + registerHttpHandler() {}, + registerChannel() {}, + registerGatewayMethod() {}, + registerCli() {}, + registerService() {}, + registerProvider() {}, + resolvePath: (p) => p, + }; +} + +describe("lobster plugin tool", () => { + it("runs lobster and returns parsed envelope in details", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null }, + }); + + const tool = createLobsterTool(fakeApi()); + const res = await tool.execute("call1", { + action: "run", + pipeline: "noop", + lobsterPath: fake.binPath, + timeoutMs: 1000, + }); + + expect(res.details).toMatchObject({ ok: true, status: "ok" }); + }); + + it("requires absolute lobsterPath when provided", async () => { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call2", { + action: "run", + pipeline: "noop", + lobsterPath: "./lobster", + }), + ).rejects.toThrow(/absolute path/); + }); + + it("rejects invalid JSON from lobster", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-")); + const binPath = path.join(dir, "lobster"); + await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, { + encoding: "utf8", + mode: 0o755, + }); + + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call3", { + action: "run", + pipeline: "noop", + lobsterPath: binPath, + }), + ).rejects.toThrow(/invalid JSON/); + }); +}); diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts new file mode 100644 index 000000000..2d76e0821 --- /dev/null +++ b/extensions/lobster/src/lobster-tool.ts @@ -0,0 +1,185 @@ +import { Type } from "@sinclair/typebox"; +import { spawn } from "node:child_process"; +import path from "node:path"; + +import type { ClawdbotPluginApi } from "../../../src/plugins/types.js"; + +type LobsterEnvelope = + | { + ok: true; + status: "ok" | "needs_approval" | "cancelled"; + output: unknown[]; + requiresApproval: null | { + type: "approval_request"; + prompt: string; + items: unknown[]; + resumeToken?: string; + }; + } + | { + ok: false; + error: { type?: string; message: string }; + }; + +function resolveExecutablePath(lobsterPathRaw: string | undefined) { + const lobsterPath = lobsterPathRaw?.trim() || "lobster"; + if (lobsterPath !== "lobster" && !path.isAbsolute(lobsterPath)) { + throw new Error("lobsterPath must be an absolute path (or omit to use PATH)"); + } + return lobsterPath; +} + +async function runLobsterSubprocess(params: { + execPath: string; + argv: string[]; + cwd: string; + timeoutMs: number; + maxStdoutBytes: number; +}) { + const { execPath, argv, cwd } = params; + const timeoutMs = Math.max(200, params.timeoutMs); + const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); + + return await new Promise<{ stdout: string }>((resolve, reject) => { + const child = spawn(execPath, argv, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + LOBSTER_MODE: "tool", + }, + }); + + let stdout = ""; + let stdoutBytes = 0; + let stderr = ""; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + child.stdout?.on("data", (chunk) => { + const str = String(chunk); + stdoutBytes += Buffer.byteLength(str, "utf8"); + if (stdoutBytes > maxStdoutBytes) { + try { + child.kill("SIGKILL"); + } finally { + reject(new Error("lobster output exceeded maxStdoutBytes")); + } + return; + } + stdout += str; + }); + + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } finally { + reject(new Error("lobster subprocess timed out")); + } + }, timeoutMs); + + child.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + + child.once("exit", (code) => { + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)); + return; + } + resolve({ stdout }); + }); + }); +} + +function parseEnvelope(stdout: string): LobsterEnvelope { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error("lobster returned invalid JSON"); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("lobster returned invalid JSON envelope"); + } + + const ok = (parsed as { ok?: unknown }).ok; + if (ok === true || ok === false) { + return parsed as LobsterEnvelope; + } + + throw new Error("lobster returned invalid JSON envelope"); +} + +export function createLobsterTool(api: ClawdbotPluginApi) { + return { + name: "lobster", + description: + "Run Lobster pipelines as a local-first workflow runtime (typed JSON envelope + resumable approvals).", + parameters: Type.Object({ + // NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf. + action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }), + pipeline: Type.Optional(Type.String()), + token: Type.Optional(Type.String()), + approve: Type.Optional(Type.Boolean()), + lobsterPath: Type.Optional(Type.String()), + cwd: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), + maxStdoutBytes: Type.Optional(Type.Number()), + }), + async execute(_id: string, params: Record) { + const action = String(params.action || "").trim(); + if (!action) throw new Error("action required"); + + const execPath = resolveExecutablePath( + typeof params.lobsterPath === "string" ? params.lobsterPath : undefined, + ); + const cwd = typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : process.cwd(); + const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000; + const maxStdoutBytes = typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000; + + const argv = (() => { + if (action === "run") { + const pipeline = typeof params.pipeline === "string" ? params.pipeline : ""; + if (!pipeline.trim()) throw new Error("pipeline required"); + return ["run", "--mode", "tool", pipeline]; + } + if (action === "resume") { + const token = typeof params.token === "string" ? params.token : ""; + if (!token.trim()) throw new Error("token required"); + const approve = params.approve; + if (typeof approve !== "boolean") throw new Error("approve required"); + return ["resume", "--token", token, "--approve", approve ? "yes" : "no"]; + } + throw new Error(`Unknown action: ${action}`); + })(); + + if (api.runtime?.version && api.logger?.debug) { + api.logger.debug(`lobster plugin runtime=${api.runtime.version}`); + } + + const { stdout } = await runLobsterSubprocess({ + execPath, + argv, + cwd, + timeoutMs, + maxStdoutBytes, + }); + + const envelope = parseEnvelope(stdout); + + return { + content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }], + details: envelope, + }; + }, + }; +} diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index c95636fb3..5ae4891dc 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -9,7 +9,6 @@ import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { createImageTool } from "./tools/image-tool.js"; -import { createLobsterTool } from "./tools/lobster-tool.js"; import { createMessageTool } from "./tools/message-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js"; import { createSessionStatusTool } from "./tools/session-status-tool.js"; @@ -112,7 +111,6 @@ export function createClawdbotTools(options?: { agentSessionKey: options?.agentSessionKey, config: options?.config, }), - createLobsterTool({ sandboxed: options?.sandboxed }), ...(webSearchTool ? [webSearchTool] : []), ...(webFetchTool ? [webFetchTool] : []), ...(imageTool ? [imageTool] : []), diff --git a/src/agents/tools/lobster-tool.test.ts b/src/agents/tools/lobster-tool.test.ts deleted file mode 100644 index 26441f2d6..000000000 --- a/src/agents/tools/lobster-tool.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { createLobsterTool } from "./lobster-tool.js"; - -async function writeFakeLobster(params: { - script: (args: string[]) => unknown; -}) { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-")); - const binPath = path.join(dir, "lobster"); - - const file = `#!/usr/bin/env node\n` + - `const args = process.argv.slice(2);\n` + - `const payload = (${params.script.toString()})(args);\n` + - `process.stdout.write(JSON.stringify(payload));\n`; - - await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); - return { dir, binPath }; -} - -describe("lobster tool", () => { - it("runs lobster in tool mode and returns envelope", async () => { - const fake = await writeFakeLobster({ - script: (args) => { - if (args[0] !== "run") throw new Error("expected run"); - return { - ok: true, - status: "ok", - output: [{ hello: "world" }], - requiresApproval: null, - }; - }, - }); - - const tool = createLobsterTool(); - const res = await tool.execute("call1", { - action: "run", - pipeline: "exec --json \"echo [1]\"", - lobsterPath: fake.binPath, - timeoutMs: 1000, - }); - - expect(res.details).toMatchObject({ - ok: true, - status: "ok", - output: [{ hello: "world" }], - requiresApproval: null, - }); - }); - - it("supports resume action", async () => { - const fake = await writeFakeLobster({ - script: (args) => { - if (args[0] !== "resume") throw new Error("expected resume"); - return { - ok: true, - status: "ok", - output: ["resumed"], - requiresApproval: null, - }; - }, - }); - - const tool = createLobsterTool(); - const res = await tool.execute("call2", { - action: "resume", - token: "tok", - approve: true, - lobsterPath: fake.binPath, - timeoutMs: 1000, - }); - - expect(res.details).toMatchObject({ ok: true, status: "ok" }); - }); - - it("rejects non-absolute lobsterPath", async () => { - const tool = createLobsterTool(); - await expect( - tool.execute("call3", { - action: "run", - pipeline: "json", - lobsterPath: "./lobster", - }), - ).rejects.toThrow(/absolute path/); - }); - - it("blocks tool in sandboxed mode", async () => { - const tool = createLobsterTool({ sandboxed: true }); - await expect( - tool.execute("call4", { - action: "run", - pipeline: "json", - lobsterPath: "/usr/bin/true", - }), - ).rejects.toThrow(/not available in sandboxed/); - }); - - it("rejects invalid JSON", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-bad-")); - const binPath = path.join(dir, "lobster"); - await fs.writeFile( - binPath, - `#!/usr/bin/env node\nprocess.stdout.write('not-json');\n`, - { - encoding: "utf8", - mode: 0o755, - }, - ); - - const tool = createLobsterTool(); - await expect( - tool.execute("call5", { - action: "run", - pipeline: "json", - lobsterPath: binPath, - }), - ).rejects.toThrow(/invalid JSON/); - }); -}); diff --git a/src/agents/tools/lobster-tool.ts b/src/agents/tools/lobster-tool.ts deleted file mode 100644 index c01cab81a..000000000 --- a/src/agents/tools/lobster-tool.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import { spawn } from "node:child_process"; -import path from "node:path"; - -import { stringEnum } from "../schema/typebox.js"; -import type { AnyAgentTool } from "./common.js"; -import { jsonResult, readNumberParam, readStringParam } from "./common.js"; - -const LobsterActions = ["run", "resume"] as const; - -type LobsterToolParams = { - action: (typeof LobsterActions)[number]; - pipeline?: string; - token?: string; - approve?: boolean; - lobsterPath?: string; - cwd?: string; - timeoutMs?: number; - maxStdoutBytes?: number; -}; - -type LobsterEnvelope = - | { - ok: true; - status: "ok" | "needs_approval" | "cancelled"; - output: unknown[]; - requiresApproval: null | { - type: "approval_request"; - prompt: string; - items: unknown[]; - resumeToken?: string; - }; - } - | { - ok: false; - error: { type?: string; message: string }; - }; - -function buildSchema() { - return Type.Object({ - action: stringEnum(LobsterActions), - pipeline: Type.Optional(Type.String({ description: "Lobster pipeline string." })), - token: Type.Optional(Type.String({ description: "Resume token from lobster tool mode." })), - approve: Type.Optional(Type.Boolean({ description: "Approval decision for resume." })), - lobsterPath: Type.Optional( - Type.String({ - description: - "Path to lobster executable. Prefer an absolute path to avoid PATH hijack. Defaults to 'lobster'.", - }), - ), - cwd: Type.Optional( - Type.String({ - description: "Working directory for lobster subprocess.", - }), - ), - timeoutMs: Type.Optional( - Type.Number({ - description: "Subprocess timeout (ms).", - }), - ), - maxStdoutBytes: Type.Optional( - Type.Number({ - description: "Max stdout bytes to read before aborting.", - }), - ), - }); -} - -function resolveExecutablePath(lobsterPathRaw: string | undefined) { - const lobsterPath = lobsterPathRaw?.trim() || "lobster"; - if (lobsterPath !== "lobster" && !path.isAbsolute(lobsterPath)) { - throw new Error("lobsterPath must be an absolute path (or omit to use PATH)"); - } - return lobsterPath; -} - -async function runLobsterSubprocess(params: { - execPath: string; - argv: string[]; - cwd: string; - timeoutMs: number; - maxStdoutBytes: number; -}) { - const { execPath, argv, cwd } = params; - const timeoutMs = Math.max(200, params.timeoutMs); - const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); - - return await new Promise<{ stdout: string; exitCode: number | null }>((resolve, reject) => { - const child = spawn(execPath, argv, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - // Ensure lobster never tries to be interactive. - LOBSTER_MODE: "tool", - }, - }); - - let stdout = ""; - let stdoutBytes = 0; - let stderr = ""; - - child.stdout?.setEncoding("utf8"); - child.stderr?.setEncoding("utf8"); - - child.stdout?.on("data", (chunk) => { - const str = String(chunk); - stdoutBytes += Buffer.byteLength(str, "utf8"); - if (stdoutBytes > maxStdoutBytes) { - try { - child.kill("SIGKILL"); - } finally { - reject(new Error("lobster output exceeded maxStdoutBytes")); - } - return; - } - stdout += str; - }); - - child.stderr?.on("data", (chunk) => { - stderr += String(chunk); - }); - - const timer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } finally { - reject(new Error("lobster subprocess timed out")); - } - }, timeoutMs); - - child.once("error", (err) => { - clearTimeout(timer); - reject(err); - }); - - child.once("exit", (code) => { - clearTimeout(timer); - if (code !== 0) { - reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)); - return; - } - resolve({ stdout, exitCode: code }); - }); - }); -} - -function parseEnvelope(stdout: string): LobsterEnvelope { - let parsed: unknown; - try { - parsed = JSON.parse(stdout); - } catch { - throw new Error("lobster returned invalid JSON"); - } - - if (!parsed || typeof parsed !== "object") { - throw new Error("lobster returned invalid JSON envelope"); - } - - const ok = (parsed as { ok?: unknown }).ok; - if (ok === true) { - const env = parsed as LobsterEnvelope; - if (!Array.isArray((env as any).output)) { - throw new Error("lobster tool output must include output[]"); - } - return env; - } - - if (ok === false) { - const env = parsed as LobsterEnvelope; - const msg = (env as any)?.error?.message; - if (typeof msg !== "string" || !msg.trim()) { - throw new Error("lobster error envelope missing error.message"); - } - return env; - } - - throw new Error("lobster returned invalid JSON envelope"); -} - -export function createLobsterTool(options: { sandboxed?: boolean } = {}): AnyAgentTool { - const parameters = buildSchema(); - - return { - label: "Lobster", - name: "lobster", - description: - "Run Lobster pipelines as a local-first, typed workflow runtime (tool mode JSON envelope, resumable approvals).", - parameters, - async execute(_callId, paramsRaw) { - if (options.sandboxed) { - throw new Error("lobster tool is not available in sandboxed mode"); - } - - const params = paramsRaw as Record; - const action = readStringParam(params, "action", { required: true }) as LobsterToolParams["action"]; - - const execPath = resolveExecutablePath(readStringParam(params, "lobsterPath")); - const cwd = readStringParam(params, "cwd", { allowEmpty: false }) || process.cwd(); - - const timeoutMs = readNumberParam(params, "timeoutMs", { integer: true }) ?? 20_000; - const maxStdoutBytes = readNumberParam(params, "maxStdoutBytes", { integer: true }) ?? 512_000; - - let argv: string[]; - if (action === "run") { - const pipeline = readStringParam(params, "pipeline", { required: true, label: "pipeline" })!; - argv = ["run", "--mode", "tool", pipeline]; - } else if (action === "resume") { - const token = readStringParam(params, "token", { required: true, label: "token" })!; - const approve = params["approve"]; - if (typeof approve !== "boolean") { - throw new Error("approve required"); - } - argv = ["resume", "--token", token, "--approve", approve ? "yes" : "no"]; - } else { - throw new Error(`Unknown action: ${action}`); - } - - const { stdout } = await runLobsterSubprocess({ - execPath, - argv, - cwd, - timeoutMs, - maxStdoutBytes, - }); - - const envelope = parseEnvelope(stdout); - return jsonResult(envelope); - }, - }; -} From e011c764a7adbef8f1a8993b5b302437e776f112 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 17 Jan 2026 20:33:31 -0800 Subject: [PATCH 003/171] Gate lobster plugin tool in sandboxed contexts --- extensions/lobster/index.ts | 8 +++++- extensions/lobster/src/lobster-tool.test.ts | 27 ++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/extensions/lobster/index.ts b/extensions/lobster/index.ts index f1c06c554..5f459c939 100644 --- a/extensions/lobster/index.ts +++ b/extensions/lobster/index.ts @@ -3,5 +3,11 @@ import type { ClawdbotPluginApi } from "../../src/plugins/types.js"; import { createLobsterTool } from "./src/lobster-tool.js"; export default function register(api: ClawdbotPluginApi) { - api.registerTool(createLobsterTool(api), { optional: true }); + api.registerTool( + (ctx) => { + if (ctx.sandboxed) return null; + return createLobsterTool(api); + }, + { optional: true }, + ); } diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 3b1dae859..1c69b3280 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import type { ClawdbotPluginApi } from "../../../src/plugins/types.js"; +import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js"; import { createLobsterTool } from "./lobster-tool.js"; async function writeFakeLobster(params: { @@ -39,6 +39,20 @@ function fakeApi(): ClawdbotPluginApi { }; } +function fakeCtx(overrides: Partial = {}): ClawdbotPluginToolContext { + return { + config: {} as any, + workspaceDir: "/tmp", + agentDir: "/tmp", + agentId: "main", + sessionKey: "main", + messageChannel: undefined, + agentAccountId: undefined, + sandboxed: false, + ...overrides, + }; +} + describe("lobster plugin tool", () => { it("runs lobster and returns parsed envelope in details", async () => { const fake = await writeFakeLobster({ @@ -84,4 +98,15 @@ describe("lobster plugin tool", () => { }), ).rejects.toThrow(/invalid JSON/); }); + + it("can be gated off in sandboxed contexts", async () => { + const api = fakeApi(); + const factoryTool = (ctx: ClawdbotPluginToolContext) => { + if (ctx.sandboxed) return null; + return createLobsterTool(api); + }; + + expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull(); + expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster"); + }); }); From 032c780a797e0e226cc622533c14f7fc731aeeda Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 11:07:47 -0800 Subject: [PATCH 004/171] Add lobster.md documentation --- docs/tools/lobster.md | 109 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/tools/lobster.md diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md new file mode 100644 index 000000000..bb3f4d130 --- /dev/null +++ b/docs/tools/lobster.md @@ -0,0 +1,109 @@ +--- +title: Lobster +description: Typed workflow runtime for Clawdbot — composable pipelines with approval gates. +--- + +# Lobster + +Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints. + +## Why + +Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime: + +- **One call instead of many**: Clawdbot calls `lobster.run(...)` once and gets a structured result. +- **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved. +- **Resumable**: Halted workflows return a token; approve and resume without re-running everything. + +## Example: Email triage + +Without Lobster: +``` +User: "Check my email and draft replies" +→ clawd calls gmail.list +→ LLM summarizes +→ User: "draft replies to #2 and #5" +→ LLM drafts +→ User: "send #2" +→ clawd calls gmail.send +(repeat daily, no memory of what was triaged) +``` + +With Lobster: +``` +clawd calls: lobster.run("email.triage --limit 20") + +Returns: +{ + "status": "needs_approval", + "output": { + "summary": "5 need replies, 2 need action", + "drafts": [...] + }, + "requiresApproval": { + "prompt": "Send 2 draft replies?", + "resumeToken": "..." + } +} + +User approves → clawd calls: lobster.resume(token, approve: true) +→ Emails sent +``` + +One workflow. Deterministic. Safe. + +## Enable + +Lobster is an **optional** plugin tool. Enable it in your agent config: + +```json +{ + "agents": { + "list": [{ + "id": "main", + "tools": { + "allow": ["lobster"] + } + }] + } +} +``` + +You also need the `lobster` CLI installed locally. + +## Actions + +### `run` + +Execute a Lobster pipeline in tool mode. + +```json +{ + "action": "run", + "pipeline": "gog.gmail.search --query 'newer_than:1d' | email.triage", + "timeoutMs": 30000 +} +``` + +### `resume` + +Continue a halted workflow after approval. + +```json +{ + "action": "resume", + "token": "", + "approve": true +} +``` + +## Security + +- **Local subprocess only** — no network calls from the plugin itself. +- **No secrets** — Lobster doesn't manage OAuth; it calls clawd tools that do. +- **Sandbox-aware** — disabled when `ctx.sandboxed` is true. +- **Hardened** — `lobsterPath` must be absolute if specified; timeouts and output caps enforced. + +## Learn more + +- [Lobster repo](https://github.com/vignesh07/lobster) — runtime, commands, and workflow examples. From 9497ffcc5050d5a636c83c57a183598f12076d9d Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 12:11:25 -0800 Subject: [PATCH 005/171] Add SKILL.md to teach Clawdbot when/how to use Lobster --- extensions/lobster/SKILL.md | 90 +++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 extensions/lobster/SKILL.md diff --git a/extensions/lobster/SKILL.md b/extensions/lobster/SKILL.md new file mode 100644 index 000000000..12c4f77ec --- /dev/null +++ b/extensions/lobster/SKILL.md @@ -0,0 +1,90 @@ +# Lobster + +Lobster executes multi-step workflows with approval checkpoints. Use it when: + +- User wants a repeatable automation (triage, monitor, sync) +- Actions need human approval before executing (send, post, delete) +- Multiple tool calls should run as one deterministic operation + +## When to use Lobster + +| User intent | Use Lobster? | +|-------------|--------------| +| "Triage my email" | Yes — multi-step, may send replies | +| "Send a message" | No — single action, use message tool directly | +| "Check my email every morning and ask before replying" | Yes — scheduled workflow with approval | +| "What's the weather?" | No — simple query | +| "Monitor this PR and notify me of changes" | Yes — stateful, recurring | + +## Basic usage + +### Run a pipeline + +```json +{ + "action": "run", + "pipeline": "gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage" +} +``` + +Returns structured result: +```json +{ + "protocolVersion": 1, + "ok": true, + "status": "ok", + "output": [{ "summary": {...}, "items": [...] }], + "requiresApproval": null +} +``` + +### Handle approval + +If the workflow needs approval: +```json +{ + "status": "needs_approval", + "output": [], + "requiresApproval": { + "prompt": "Send 3 draft replies?", + "items": [...], + "resumeToken": "..." + } +} +``` + +Present the prompt to the user. If they approve: +```json +{ + "action": "resume", + "token": "", + "approve": true +} +``` + +## Example workflows + +### Email triage +``` +gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage +``` +Fetches recent emails, classifies into buckets (needs_reply, needs_action, fyi). + +### Email triage with approval gate +``` +gog.gmail.search --query 'newer_than:1d' | email.triage | approve --prompt 'Process these?' +``` +Same as above, but halts for approval before returning. + +## Key behaviors + +- **Deterministic**: Same input → same output (no LLM variance in pipeline execution) +- **Approval gates**: `approve` command halts execution, returns token +- **Resumable**: Use `resume` action with token to continue +- **Structured output**: Always returns JSON envelope with `protocolVersion` + +## Don't use Lobster for + +- Simple single-action requests (just use the tool directly) +- Queries that need LLM interpretation mid-flow +- One-off tasks that won't be repeated From ed909d6013b5c6c6e1af86f9c545708fde67317f Mon Sep 17 00:00:00 2001 From: cpojer Date: Mon, 19 Jan 2026 10:42:21 +0900 Subject: [PATCH 006/171] Improve `cron` reminder tool description. --- src/agents/system-prompt.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 22fd92f83..951749dcc 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -195,7 +195,7 @@ export function buildAgentSystemPrompt(params: { browser: "Control web browser", canvas: "Present/eval/snapshot the Canvas", nodes: "List/describe/notify/camera/screen on paired nodes", - cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", + cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", message: "Send messages and channel actions", gateway: "Restart, apply config, or run updates on the running Clawdbot process", agents_list: "List agent ids allowed for sessions_spawn", @@ -346,7 +346,7 @@ export function buildAgentSystemPrompt(params: { "- browser: control clawd's dedicated browser", "- canvas: present/eval/snapshot the Canvas", "- nodes: list/describe/notify/camera/screen on paired nodes", - "- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", + "- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", "- sessions_list: list sessions", "- sessions_history: fetch session history", "- sessions_send: send to another session", From 6402a48482b70ff1d4d7f5b659c8f876d1754118 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Tue, 20 Jan 2026 14:45:58 -0500 Subject: [PATCH 007/171] feat: add avatar support for agent identity - Add avatar field to IdentityConfig type - Add avatar parsing in AgentIdentity from IDENTITY.md - Add renderAvatar support for image avatars in webchat - Add CSS styling for image avatars Users can now configure a custom avatar for the assistant in the webchat by setting 'identity.avatar' in the agent config or adding 'Avatar: path' to IDENTITY.md. The avatar can be served from the assets folder. Closes #TBD --- src/commands/agents.config.ts | 4 +++- src/config/types.base.ts | 2 ++ ui/src/styles/chat/grouped.css | 6 ++++++ ui/src/ui/chat/grouped-render.ts | 8 +++++++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index 26c70932f..3aca27d70 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -34,6 +34,7 @@ export type AgentIdentity = { creature?: string; vibe?: string; theme?: string; + avatar?: string; }; export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] { @@ -90,6 +91,7 @@ export function parseIdentityMarkdown(content: string): AgentIdentity { if (label === "creature") identity.creature = value; if (label === "vibe") identity.vibe = value; if (label === "theme") identity.theme = value; + if (label === "avatar") identity.avatar = value; } return identity; } @@ -99,7 +101,7 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null { try { const content = fs.readFileSync(identityPath, "utf-8"); const parsed = parseIdentityMarkdown(content); - if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) { + if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe && !parsed.avatar) { return null; } return parsed; diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 827ec5abb..65b9cf68c 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -144,4 +144,6 @@ export type IdentityConfig = { name?: string; theme?: string; emoji?: string; + /** Path to a custom avatar image (relative to workspace or absolute). */ + avatar?: string; }; diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index d0e05e508..158ad2e0a 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -89,6 +89,12 @@ color: rgba(134, 142, 150, 1); } +/* Image avatar support */ +img.chat-avatar { + object-fit: cover; + object-position: center; +} + /* Minimal Bubble Design - dynamic width based on content */ .chat-bubble { display: inline-block; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index e84d2b7f1..b9f1ef234 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -104,7 +104,7 @@ export function renderMessageGroup( `; } -function renderAvatar(role: string) { +function renderAvatar(role: string, avatarUrl?: string) { const normalized = normalizeRoleForGrouping(role); const initial = normalized === "user" @@ -122,6 +122,12 @@ function renderAvatar(role: string) { : normalized === "tool" ? "tool" : "other"; + + // If avatar URL is provided for assistant, show image + if (avatarUrl && normalized === "assistant") { + return html`Assistant`; + } + return html`
${initial}
`; } From 056b3e40d6d1896d0dcc0f5a00ee7db1b2e69439 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Tue, 20 Jan 2026 16:02:55 -0500 Subject: [PATCH 008/171] chore: fix formatting --- src/commands/agents.config.ts | 9 +- src/gateway/protocol/schema/sessions.ts | 146 ++++++++++++------------ src/tui/components/fuzzy-filter.ts | 138 +++++++++++----------- src/tui/components/selectors.ts | 5 +- src/utils/time-format.ts | 24 ++-- 5 files changed, 163 insertions(+), 159 deletions(-) diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index 3aca27d70..cd778740e 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -101,7 +101,14 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null { try { const content = fs.readFileSync(identityPath, "utf-8"); const parsed = parseIdentityMarkdown(content); - if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe && !parsed.avatar) { + if ( + !parsed.name && + !parsed.emoji && + !parsed.theme && + !parsed.creature && + !parsed.vibe && + !parsed.avatar + ) { return null; } return parsed; diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 217981bb2..42fa83ff6 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -3,92 +3,92 @@ import { Type } from "@sinclair/typebox"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; export const SessionsListParamsSchema = Type.Object( - { - limit: Type.Optional(Type.Integer({ minimum: 1 })), - activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), - includeGlobal: Type.Optional(Type.Boolean()), - includeUnknown: Type.Optional(Type.Boolean()), - /** - * Read first 8KB of each session transcript to derive title from first user message. - * Performs a file read per session - use `limit` to bound result set on large stores. - */ - includeDerivedTitles: Type.Optional(Type.Boolean()), - /** - * Read last 16KB of each session transcript to extract most recent message preview. - * Performs a file read per session - use `limit` to bound result set on large stores. - */ - includeLastMessage: Type.Optional(Type.Boolean()), - label: Type.Optional(SessionLabelString), - spawnedBy: Type.Optional(NonEmptyString), - agentId: Type.Optional(NonEmptyString), - search: Type.Optional(Type.String()), - }, - { additionalProperties: false }, + { + limit: Type.Optional(Type.Integer({ minimum: 1 })), + activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), + includeGlobal: Type.Optional(Type.Boolean()), + includeUnknown: Type.Optional(Type.Boolean()), + /** + * Read first 8KB of each session transcript to derive title from first user message. + * Performs a file read per session - use `limit` to bound result set on large stores. + */ + includeDerivedTitles: Type.Optional(Type.Boolean()), + /** + * Read last 16KB of each session transcript to extract most recent message preview. + * Performs a file read per session - use `limit` to bound result set on large stores. + */ + includeLastMessage: Type.Optional(Type.Boolean()), + label: Type.Optional(SessionLabelString), + spawnedBy: Type.Optional(NonEmptyString), + agentId: Type.Optional(NonEmptyString), + search: Type.Optional(Type.String()), + }, + { additionalProperties: false }, ); export const SessionsResolveParamsSchema = Type.Object( - { - key: Type.Optional(NonEmptyString), - label: Type.Optional(SessionLabelString), - agentId: Type.Optional(NonEmptyString), - spawnedBy: Type.Optional(NonEmptyString), - includeGlobal: Type.Optional(Type.Boolean()), - includeUnknown: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, + { + key: Type.Optional(NonEmptyString), + label: Type.Optional(SessionLabelString), + agentId: Type.Optional(NonEmptyString), + spawnedBy: Type.Optional(NonEmptyString), + includeGlobal: Type.Optional(Type.Boolean()), + includeUnknown: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, ); export const SessionsPatchParamsSchema = Type.Object( - { - key: NonEmptyString, - label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), - thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - responseUsage: Type.Optional( - Type.Union([ - Type.Literal("off"), - Type.Literal("tokens"), - Type.Literal("full"), - // Backward compat with older clients/stores. - Type.Literal("on"), - Type.Null(), - ]), - ), - elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - sendPolicy: Type.Optional( - Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), - ), - groupActivation: Type.Optional( - Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), - ), - }, - { additionalProperties: false }, + { + key: NonEmptyString, + label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), + thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + responseUsage: Type.Optional( + Type.Union([ + Type.Literal("off"), + Type.Literal("tokens"), + Type.Literal("full"), + // Backward compat with older clients/stores. + Type.Literal("on"), + Type.Null(), + ]), + ), + elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + sendPolicy: Type.Optional( + Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), + ), + groupActivation: Type.Optional( + Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), + ), + }, + { additionalProperties: false }, ); export const SessionsResetParamsSchema = Type.Object( - { key: NonEmptyString }, - { additionalProperties: false }, + { key: NonEmptyString }, + { additionalProperties: false }, ); export const SessionsDeleteParamsSchema = Type.Object( - { - key: NonEmptyString, - deleteTranscript: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, + { + key: NonEmptyString, + deleteTranscript: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, ); export const SessionsCompactParamsSchema = Type.Object( - { - key: NonEmptyString, - maxLines: Type.Optional(Type.Integer({ minimum: 1 })), - }, - { additionalProperties: false }, + { + key: NonEmptyString, + maxLines: Type.Optional(Type.Integer({ minimum: 1 })), + }, + { additionalProperties: false }, ); diff --git a/src/tui/components/fuzzy-filter.ts b/src/tui/components/fuzzy-filter.ts index 76a688d3b..fb6e2acf2 100644 --- a/src/tui/components/fuzzy-filter.ts +++ b/src/tui/components/fuzzy-filter.ts @@ -11,7 +11,7 @@ const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/; * Check if position is at a word boundary. */ export function isWordBoundary(text: string, index: number): boolean { - return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? ""); + return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? ""); } /** @@ -19,17 +19,17 @@ export function isWordBoundary(text: string, index: number): boolean { * Returns null if no match. */ export function findWordBoundaryIndex(text: string, query: string): number | null { - if (!query) return null; - const textLower = text.toLowerCase(); - const queryLower = query.toLowerCase(); - const maxIndex = textLower.length - queryLower.length; - if (maxIndex < 0) return null; - for (let i = 0; i <= maxIndex; i++) { - if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) { - return i; - } - } - return null; + if (!query) return null; + const textLower = text.toLowerCase(); + const queryLower = query.toLowerCase(); + const maxIndex = textLower.length - queryLower.length; + if (maxIndex < 0) return null; + for (let i = 0; i <= maxIndex; i++) { + if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) { + return i; + } + } + return null; } /** @@ -37,31 +37,31 @@ export function findWordBoundaryIndex(text: string, query: string): number | nul * Returns score (lower = better) or null if no match. */ export function fuzzyMatchLower(queryLower: string, textLower: string): number | null { - if (queryLower.length === 0) return 0; - if (queryLower.length > textLower.length) return null; + if (queryLower.length === 0) return 0; + if (queryLower.length > textLower.length) return null; - let queryIndex = 0; - let score = 0; - let lastMatchIndex = -1; - let consecutiveMatches = 0; + let queryIndex = 0; + let score = 0; + let lastMatchIndex = -1; + let consecutiveMatches = 0; - for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { - if (textLower[i] === queryLower[queryIndex]) { - const isAtWordBoundary = isWordBoundary(textLower, i); - if (lastMatchIndex === i - 1) { - consecutiveMatches++; - score -= consecutiveMatches * 5; // Reward consecutive matches - } else { - consecutiveMatches = 0; - if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps - } - if (isAtWordBoundary) score -= 10; // Reward word boundary matches - score += i * 0.1; // Slight penalty for later matches - lastMatchIndex = i; - queryIndex++; - } - } - return queryIndex < queryLower.length ? null : score; + for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { + if (textLower[i] === queryLower[queryIndex]) { + const isAtWordBoundary = isWordBoundary(textLower, i); + if (lastMatchIndex === i - 1) { + consecutiveMatches++; + score -= consecutiveMatches * 5; // Reward consecutive matches + } else { + consecutiveMatches = 0; + if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps + } + if (isAtWordBoundary) score -= 10; // Reward word boundary matches + score += i * 0.1; // Slight penalty for later matches + lastMatchIndex = i; + queryIndex++; + } + } + return queryIndex < queryLower.length ? null : score; } /** @@ -69,46 +69,46 @@ export function fuzzyMatchLower(queryLower: string, textLower: string): number | * Supports space-separated tokens (all must match). */ export function fuzzyFilterLower( - items: T[], - queryLower: string, + items: T[], + queryLower: string, ): T[] { - const trimmed = queryLower.trim(); - if (!trimmed) return items; + const trimmed = queryLower.trim(); + if (!trimmed) return items; - const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); - if (tokens.length === 0) return items; + const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); + if (tokens.length === 0) return items; - const results: { item: T; score: number }[] = []; - for (const item of items) { - const text = item.searchTextLower ?? ""; - let totalScore = 0; - let allMatch = true; - for (const token of tokens) { - const score = fuzzyMatchLower(token, text); - if (score !== null) { - totalScore += score; - } else { - allMatch = false; - break; - } - } - if (allMatch) results.push({ item, score: totalScore }); - } - results.sort((a, b) => a.score - b.score); - return results.map((r) => r.item); + const results: { item: T; score: number }[] = []; + for (const item of items) { + const text = item.searchTextLower ?? ""; + let totalScore = 0; + let allMatch = true; + for (const token of tokens) { + const score = fuzzyMatchLower(token, text); + if (score !== null) { + totalScore += score; + } else { + allMatch = false; + break; + } + } + if (allMatch) results.push({ item, score: totalScore }); + } + results.sort((a, b) => a.score - b.score); + return results.map((r) => r.item); } /** * Prepare items for fuzzy filtering by pre-computing lowercase search text. */ -export function prepareSearchItems( - items: T[], -): (T & { searchTextLower: string })[] { - return items.map((item) => { - const parts: string[] = []; - if (item.label) parts.push(item.label); - if (item.description) parts.push(item.description); - if (item.searchText) parts.push(item.searchText); - return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; - }); +export function prepareSearchItems< + T extends { label?: string; description?: string; searchText?: string }, +>(items: T[]): (T & { searchTextLower: string })[] { + return items.map((item) => { + const parts: string[] = []; + if (item.label) parts.push(item.label); + if (item.description) parts.push(item.description); + if (item.searchText) parts.push(item.searchText); + return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; + }); } diff --git a/src/tui/components/selectors.ts b/src/tui/components/selectors.ts index ba37ff7c9..46073fbca 100644 --- a/src/tui/components/selectors.ts +++ b/src/tui/components/selectors.ts @@ -5,10 +5,7 @@ import { selectListTheme, settingsListTheme, } from "../theme/theme.js"; -import { - FilterableSelectList, - type FilterableSelectItem, -} from "./filterable-select-list.js"; +import { FilterableSelectList, type FilterableSelectItem } from "./filterable-select-list.js"; import { SearchableSelectList } from "./searchable-select-list.js"; export function createSelectList(items: SelectItem[], maxVisible = 7) { diff --git a/src/utils/time-format.ts b/src/utils/time-format.ts index f5d4ee81b..bd473e4f6 100644 --- a/src/utils/time-format.ts +++ b/src/utils/time-format.ts @@ -1,15 +1,15 @@ export function formatRelativeTime(timestamp: number): string { - const now = Date.now(); - const diff = now - timestamp; - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); + const now = Date.now(); + const diff = now - timestamp; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); - if (seconds < 60) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - if (days === 1) return "Yesterday"; - if (days < 7) return `${days}d ago`; - return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + if (seconds < 60) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days === 1) return "Yesterday"; + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } From 2af497495f102bccbc25958f48e44aaeb14fc425 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Tue, 20 Jan 2026 16:14:29 -0500 Subject: [PATCH 009/171] chore: regenerate protocol files --- .../ClawdbotProtocol/GatewayModels.swift | 18 ++++++++++- .../ClawdbotProtocol/GatewayModels.swift | 30 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 85696eb6a..04c3bab09 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable { public let replychannel: String? public let accountid: String? public let replyaccountid: String? + public let threadid: String? public let timeout: Int? public let lane: String? public let extrasystemprompt: String? @@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable { replychannel: String?, accountid: String?, replyaccountid: String?, + threadid: String?, timeout: Int?, lane: String?, extrasystemprompt: String?, @@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable { self.replychannel = replychannel self.accountid = accountid self.replyaccountid = replyaccountid + self.threadid = threadid self.timeout = timeout self.lane = lane self.extrasystemprompt = extrasystemprompt @@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable { case replychannel = "replyChannel" case accountid = "accountId" case replyaccountid = "replyAccountId" + case threadid = "threadId" case timeout case lane case extrasystemprompt = "extraSystemPrompt" @@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable { public let activeminutes: Int? public let includeglobal: Bool? public let includeunknown: Bool? + public let includederivedtitles: Bool? + public let includelastmessage: Bool? public let label: String? public let spawnedby: String? public let agentid: String? + public let search: String? public init( limit: Int?, activeminutes: Int?, includeglobal: Bool?, includeunknown: Bool?, + includederivedtitles: Bool?, + includelastmessage: Bool?, label: String?, spawnedby: String?, - agentid: String? + agentid: String?, + search: String? ) { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown + self.includederivedtitles = includederivedtitles + self.includelastmessage = includelastmessage self.label = label self.spawnedby = spawnedby self.agentid = agentid + self.search = search } private enum CodingKeys: String, CodingKey { case limit case activeminutes = "activeMinutes" case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" + case includederivedtitles = "includeDerivedTitles" + case includelastmessage = "includeLastMessage" case label case spawnedby = "spawnedBy" case agentid = "agentId" + case search } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift index dd01ffe70..04c3bab09 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift @@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable { public let replychannel: String? public let accountid: String? public let replyaccountid: String? + public let threadid: String? public let timeout: Int? public let lane: String? public let extrasystemprompt: String? @@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable { replychannel: String?, accountid: String?, replyaccountid: String?, + threadid: String?, timeout: Int?, lane: String?, extrasystemprompt: String?, @@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable { self.replychannel = replychannel self.accountid = accountid self.replyaccountid = replyaccountid + self.threadid = threadid self.timeout = timeout self.lane = lane self.extrasystemprompt = extrasystemprompt @@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable { case replychannel = "replyChannel" case accountid = "accountId" case replyaccountid = "replyAccountId" + case threadid = "threadId" case timeout case lane case extrasystemprompt = "extraSystemPrompt" @@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable { public let activeminutes: Int? public let includeglobal: Bool? public let includeunknown: Bool? + public let includederivedtitles: Bool? + public let includelastmessage: Bool? public let label: String? public let spawnedby: String? public let agentid: String? + public let search: String? public init( limit: Int?, activeminutes: Int?, includeglobal: Bool?, includeunknown: Bool?, + includederivedtitles: Bool?, + includelastmessage: Bool?, label: String?, spawnedby: String?, - agentid: String? + agentid: String?, + search: String? ) { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown + self.includederivedtitles = includederivedtitles + self.includelastmessage = includelastmessage self.label = label self.spawnedby = spawnedby self.agentid = agentid + self.search = search } private enum CodingKeys: String, CodingKey { case limit case activeminutes = "activeMinutes" case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" + case includederivedtitles = "includeDerivedTitles" + case includelastmessage = "includeLastMessage" case label case spawnedby = "spawnedBy" case agentid = "agentId" + case search } } @@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable { public let ts: Int public let channelorder: [String] public let channellabels: [String: AnyCodable] + public let channeldetaillabels: [String: AnyCodable]? + public let channelsystemimages: [String: AnyCodable]? + public let channelmeta: [[String: AnyCodable]]? public let channels: [String: AnyCodable] public let channelaccounts: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable] @@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable { ts: Int, channelorder: [String], channellabels: [String: AnyCodable], + channeldetaillabels: [String: AnyCodable]?, + channelsystemimages: [String: AnyCodable]?, + channelmeta: [[String: AnyCodable]]?, channels: [String: AnyCodable], channelaccounts: [String: AnyCodable], channeldefaultaccountid: [String: AnyCodable] @@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable { self.ts = ts self.channelorder = channelorder self.channellabels = channellabels + self.channeldetaillabels = channeldetaillabels + self.channelsystemimages = channelsystemimages + self.channelmeta = channelmeta self.channels = channels self.channelaccounts = channelaccounts self.channeldefaultaccountid = channeldefaultaccountid @@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable { case ts case channelorder = "channelOrder" case channellabels = "channelLabels" + case channeldetaillabels = "channelDetailLabels" + case channelsystemimages = "channelSystemImages" + case channelmeta = "channelMeta" case channels case channelaccounts = "channelAccounts" case channeldefaultaccountid = "channelDefaultAccountId" From 2f0dd9c4ee5ef51e8aca9f8f23d977361aa2be64 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Tue, 20 Jan 2026 16:38:37 -0500 Subject: [PATCH 010/171] chore: fix swift formatting --- .../Sources/Clawdbot/ExecApprovalsSocket.swift | 2 +- .../Sources/ClawdbotMacCLI/ConnectCommand.swift | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift index bf2ffc149..268d155a0 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift @@ -319,7 +319,7 @@ private enum ExecHostExecutor { security: context.security, allowlistMatch: context.allowlistMatch, skillAllow: context.skillAllow), - approvalDecision == nil + approvalDecision == nil { let decision = ExecApprovalsPromptPresenter.prompt( ExecApprovalPromptRequest( diff --git a/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift b/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift index 08e8cdde6..ac4938fb8 100644 --- a/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift +++ b/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift @@ -7,7 +7,7 @@ struct ConnectOptions { var token: String? var password: String? var mode: String? - var timeoutMs: Int = 15_000 + var timeoutMs: Int = 15000 var json: Bool = false var probe: Bool = false var clientId: String = "clawdbot-macos" @@ -254,8 +254,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) if resolvedMode == "remote" { guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty else { - throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) + !raw.isEmpty + else { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) } guard let url = URL(string: raw) else { throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) @@ -270,7 +274,10 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) let port = config.port ?? 18789 let host = "127.0.0.1" guard let url = URL(string: "ws://\(host):\(port)") else { - throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"]) + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"]) } return GatewayEndpoint( url: url, @@ -280,7 +287,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) } private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? { - return try? resolveGatewayEndpoint(opts: opts, config: config) + try? resolveGatewayEndpoint(opts: opts, config: config) } private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { From b073deee20be18c107cfb7310edb8f41e8a178f1 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Wed, 21 Jan 2026 00:14:55 -0800 Subject: [PATCH 011/171] feat: implement short ID mapping for BlueBubbles messages and enhance reply context caching - Added functionality to resolve short message IDs to full UUIDs and vice versa, optimizing token usage. - Introduced a reply cache to store message context for replies when metadata is omitted in webhook payloads. - Updated message handling to utilize short IDs for outbound messages and replies, improving efficiency. - Enhanced error messages to clarify required parameters for actions like react, edit, and unsend. - Added tests to ensure correct behavior of new features and maintain existing functionality. --- extensions/bluebubbles/src/actions.ts | 59 ++-- extensions/bluebubbles/src/monitor.test.ts | 257 ++++++++++++++++- extensions/bluebubbles/src/monitor.ts | 317 +++++++++++++++++++-- extensions/bluebubbles/src/reactions.ts | 71 ++++- extensions/bluebubbles/src/send.test.ts | 27 ++ extensions/bluebubbles/src/send.ts | 20 +- src/auto-reply/reply/agent-runner-utils.ts | 24 +- src/auto-reply/reply/typing-mode.test.ts | 7 +- src/auto-reply/reply/typing-mode.ts | 4 +- src/infra/outbound/message-action-spec.ts | 1 + 10 files changed, 720 insertions(+), 67 deletions(-) diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 1e69fbfa8..3630f91fa 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -14,6 +14,7 @@ import { } from "clawdbot/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { resolveBlueBubblesMessageId } from "./monitor.js"; import { isMacOS26OrHigher } from "./probe.js"; import { sendBlueBubblesReaction } from "./reactions.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; @@ -77,7 +78,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; return { to, accountId }; }, - handleAction: async ({ action, params, cfg, accountId }) => { + handleAction: async ({ action, params, cfg, accountId, toolContext }) => { const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined, @@ -86,7 +87,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { const password = account.config.password?.trim(); const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined }; - // Helper to resolve chatGuid from various params + // Helper to resolve chatGuid from various params or session context const resolveChatGuid = async (): Promise => { const chatGuid = readStringParam(params, "chatGuid"); if (chatGuid?.trim()) return chatGuid.trim(); @@ -94,6 +95,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { const chatIdentifier = readStringParam(params, "chatIdentifier"); const chatId = readNumberParam(params, "chatId", { integer: true }); const to = readStringParam(params, "to"); + // Fall back to session context if no explicit target provided + const contextTarget = toolContext?.currentChannelId?.trim(); const target = chatIdentifier?.trim() ? ({ @@ -104,7 +107,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget) : to ? mapTarget(to) - : null; + : contextTarget + ? mapTarget(contextTarget) + : null; if (!target) { throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`); @@ -127,16 +132,18 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { }); if (isEmpty && !remove) { throw new Error( - "BlueBubbles react requires emoji parameter. Use action=react with emoji= and messageId=.", + "BlueBubbles react requires emoji parameter. Use action=react with emoji= and messageId=.", ); } - const messageId = readStringParam(params, "messageId"); - if (!messageId) { + const rawMessageId = readStringParam(params, "messageId"); + if (!rawMessageId) { throw new Error( - "BlueBubbles react requires messageId parameter (the message GUID to react to). " + - "Use action=react with messageId=, emoji=, and to/chatGuid to identify the chat.", + "BlueBubbles react requires messageId parameter (the message ID to react to). " + + "Use action=react with messageId=, emoji=, and to/chatGuid to identify the chat.", ); } + // Resolve short ID (e.g., "1", "2") to full UUID + const messageId = resolveBlueBubblesMessageId(rawMessageId); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const resolvedChatGuid = await resolveChatGuid(); @@ -161,20 +168,22 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { "Apple removed the ability to edit iMessages in this version.", ); } - const messageId = readStringParam(params, "messageId"); + const rawMessageId = readStringParam(params, "messageId"); const newText = readStringParam(params, "text") ?? readStringParam(params, "newText") ?? readStringParam(params, "message"); - if (!messageId || !newText) { + if (!rawMessageId || !newText) { const missing: string[] = []; - if (!messageId) missing.push("messageId (the message GUID to edit)"); + if (!rawMessageId) missing.push("messageId (the message ID to edit)"); if (!newText) missing.push("text (the new message content)"); throw new Error( `BlueBubbles edit requires: ${missing.join(", ")}. ` + - `Use action=edit with messageId=, text=.`, + `Use action=edit with messageId=, text=.`, ); } + // Resolve short ID (e.g., "1", "2") to full UUID + const messageId = resolveBlueBubblesMessageId(rawMessageId); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); @@ -184,18 +193,20 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { backwardsCompatMessage: backwardsCompatMessage ?? undefined, }); - return jsonResult({ ok: true, edited: messageId }); + return jsonResult({ ok: true, edited: rawMessageId }); } // Handle unsend action if (action === "unsend") { - const messageId = readStringParam(params, "messageId"); - if (!messageId) { + const rawMessageId = readStringParam(params, "messageId"); + if (!rawMessageId) { throw new Error( - "BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " + - "Use action=unsend with messageId=.", + "BlueBubbles unsend requires messageId parameter (the message ID to unsend). " + + "Use action=unsend with messageId=.", ); } + // Resolve short ID (e.g., "1", "2") to full UUID + const messageId = resolveBlueBubblesMessageId(rawMessageId); const partIndex = readNumberParam(params, "partIndex", { integer: true }); await unsendBlueBubblesMessage(messageId, { @@ -203,24 +214,26 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { partIndex: typeof partIndex === "number" ? partIndex : undefined, }); - return jsonResult({ ok: true, unsent: messageId }); + return jsonResult({ ok: true, unsent: rawMessageId }); } // Handle reply action if (action === "reply") { - const messageId = readStringParam(params, "messageId"); + const rawMessageId = readStringParam(params, "messageId"); const text = readMessageText(params); const to = readStringParam(params, "to") ?? readStringParam(params, "target"); - if (!messageId || !text || !to) { + if (!rawMessageId || !text || !to) { const missing: string[] = []; - if (!messageId) missing.push("messageId (the message GUID to reply to)"); + if (!rawMessageId) missing.push("messageId (the message ID to reply to)"); if (!text) missing.push("text or message (the reply message content)"); if (!to) missing.push("to or target (the chat target)"); throw new Error( `BlueBubbles reply requires: ${missing.join(", ")}. ` + - `Use action=reply with messageId=, message=, target=.`, + `Use action=reply with messageId=, message=, target=.`, ); } + // Resolve short ID (e.g., "1", "2") to full UUID + const messageId = resolveBlueBubblesMessageId(rawMessageId); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const result = await sendMessageBlueBubbles(to, text, { @@ -229,7 +242,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined, }); - return jsonResult({ ok: true, messageId: result.messageId, repliedTo: messageId }); + return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId }); } // Handle sendWithEffect action diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index e91e88611..14c896427 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -6,6 +6,8 @@ import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; import { handleBlueBubblesWebhookRequest, registerBlueBubblesWebhookTarget, + resolveBlueBubblesMessageId, + _resetBlueBubblesShortIdState, } from "./monitor.js"; import { setBlueBubblesRuntime } from "./runtime.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; @@ -223,6 +225,8 @@ describe("BlueBubbles webhook monitor", () => { beforeEach(() => { vi.clearAllMocks(); + // Reset short ID state between tests for predictable behavior + _resetBlueBubblesShortIdState(); mockReadAllowFromStore.mockResolvedValue([]); mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true }); mockResolveRequireMention.mockReturnValue(false); @@ -467,6 +471,98 @@ describe("BlueBubbles webhook monitor", () => { expect(handled).toBe(false); }); + + it("parses chatId when provided as a string (webhook variant)", async () => { + const { resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(resolveChatGuidForTarget).mockClear(); + + const account = createMockAccount({ groupPolicy: "open" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatId: "123", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(resolveChatGuidForTarget).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "chat_id", chatId: 123 }, + }), + ); + }); + + it("extracts chatGuid from nested chat object fields (webhook variant)", async () => { + const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(sendMessageBlueBubbles).mockClear(); + vi.mocked(resolveChatGuidForTarget).mockClear(); + + mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { + await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); + }); + + const account = createMockAccount({ groupPolicy: "open" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chat: { chatGuid: "iMessage;+;chat123456" }, + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); + expect(sendMessageBlueBubbles).toHaveBeenCalledWith( + "chat_guid:iMessage;+;chat123456", + expect.any(String), + expect.any(Object), + ); + }); }); describe("DM pairing behavior vs allowFrom", () => { @@ -1075,13 +1171,85 @@ describe("BlueBubbles webhook monitor", () => { expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + // ReplyToId is the full UUID since it wasn't previously cached expect(callArgs.ctx.ReplyToId).toBe("msg-0"); expect(callArgs.ctx.ReplyToBody).toBe("original message"); expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); + // Body still uses the full UUID since it wasn't cached expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]"); expect(callArgs.ctx.Body).toContain("original message"); }); + it("hydrates missing reply sender/body from the recent-message cache", async () => { + const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const chatGuid = "iMessage;+;chat-reply-cache"; + + const originalPayload = { + type: "new-message", + data: { + text: "original message (cached)", + handle: { address: "+15550000000" }, + isGroup: true, + isFromMe: false, + guid: "cache-msg-0", + chatGuid, + date: Date.now(), + }, + }; + + const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload); + const originalRes = createMockResponse(); + + await handleBlueBubblesWebhookRequest(originalReq, originalRes); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Only assert the reply message behavior below. + mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); + + const replyPayload = { + type: "new-message", + data: { + text: "replying now", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "cache-msg-1", + chatGuid, + // Only the GUID is provided; sender/body must be hydrated. + replyToMessageGuid: "cache-msg-0", + date: Date.now(), + }, + }; + + const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload); + const replyRes = createMockResponse(); + + await handleBlueBubblesWebhookRequest(replyReq, replyRes); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + // ReplyToId uses short ID "1" (first cached message) for token savings + expect(callArgs.ctx.ReplyToId).toBe("1"); + expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)"); + expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); + // Body uses short ID for token savings + expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:1]"); + expect(callArgs.ctx.Body).toContain("original message (cached)"); + }); + it("falls back to threadOriginatorGuid when reply metadata is absent", async () => { const account = createMockAccount({ dmPolicy: "open" }); const config: ClawdbotConfig = {}; @@ -1436,8 +1604,9 @@ describe("BlueBubbles webhook monitor", () => { await handleBlueBubblesWebhookRequest(req, res); await new Promise((resolve) => setTimeout(resolve, 50)); + // Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2") expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( - "BlueBubbles sent message id: msg-123", + 'Assistant sent "replying now" [message_id:2]', expect.objectContaining({ sessionKey: "agent:main:bluebubbles:dm:+15551234567", }), @@ -1605,6 +1774,92 @@ describe("BlueBubbles webhook monitor", () => { }); }); + describe("short message ID mapping", () => { + it("assigns sequential short IDs to messages", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-uuid-12345", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + // MessageSid should be short ID "1" instead of full UUID + expect(callArgs.ctx.MessageSid).toBe("1"); + }); + + it("resolves short ID back to UUID", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-uuid-12345", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // The short ID "1" should resolve back to the full UUID + expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345"); + }); + + it("returns UUID unchanged when not in cache", () => { + expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached"); + }); + + it("returns short ID unchanged when numeric but not in cache", () => { + expect(resolveBlueBubblesMessageId("999")).toBe("999"); + }); + }); + describe("fromMe messages", () => { it("ignores messages from self (fromMe=true)", async () => { const account = createMockAccount(); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 7133107cc..4c2b4a225 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -31,6 +31,165 @@ const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; const DEFAULT_TEXT_LIMIT = 4000; const invalidAckReactions = new Set(); +const REPLY_CACHE_MAX = 2000; +const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000; + +type BlueBubblesReplyCacheEntry = { + accountId: string; + messageId: string; + shortId: string; + chatGuid?: string; + chatIdentifier?: string; + chatId?: number; + senderLabel?: string; + body?: string; + timestamp: number; +}; + +// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body. +const blueBubblesReplyCacheByMessageId = new Map(); + +// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization) +const blueBubblesShortIdToUuid = new Map(); +const blueBubblesUuidToShortId = new Map(); +let blueBubblesShortIdCounter = 0; + +function trimOrUndefined(value?: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function generateShortId(): string { + blueBubblesShortIdCounter += 1; + return String(blueBubblesShortIdCounter); +} + +function rememberBlueBubblesReplyCache( + entry: Omit, +): BlueBubblesReplyCacheEntry { + const messageId = entry.messageId.trim(); + if (!messageId) { + return { ...entry, shortId: "" }; + } + + // Check if we already have a short ID for this UUID + let shortId = blueBubblesUuidToShortId.get(messageId); + if (!shortId) { + shortId = generateShortId(); + blueBubblesShortIdToUuid.set(shortId, messageId); + blueBubblesUuidToShortId.set(messageId, shortId); + } + + const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId }; + + // Refresh insertion order. + blueBubblesReplyCacheByMessageId.delete(messageId); + blueBubblesReplyCacheByMessageId.set(messageId, fullEntry); + + // Opportunistic prune. + const cutoff = Date.now() - REPLY_CACHE_TTL_MS; + for (const [key, value] of blueBubblesReplyCacheByMessageId) { + if (value.timestamp < cutoff) { + blueBubblesReplyCacheByMessageId.delete(key); + // Clean up short ID mappings for expired entries + if (value.shortId) { + blueBubblesShortIdToUuid.delete(value.shortId); + blueBubblesUuidToShortId.delete(key); + } + continue; + } + break; + } + while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) { + const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined; + if (!oldest) break; + const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest); + blueBubblesReplyCacheByMessageId.delete(oldest); + // Clean up short ID mappings for evicted entries + if (oldEntry?.shortId) { + blueBubblesShortIdToUuid.delete(oldEntry.shortId); + blueBubblesUuidToShortId.delete(oldest); + } + } + + return fullEntry; +} + +/** + * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID. + * Returns the input unchanged if it's already a UUID or not found in the mapping. + */ +export function resolveBlueBubblesMessageId(shortOrUuid: string): string { + const trimmed = shortOrUuid.trim(); + if (!trimmed) return trimmed; + + // If it looks like a short ID (numeric), try to resolve it + if (/^\d+$/.test(trimmed)) { + const uuid = blueBubblesShortIdToUuid.get(trimmed); + if (uuid) return uuid; + } + + // Return as-is (either already a UUID or not found) + return trimmed; +} + +/** + * Resets the short ID state. Only use in tests. + * @internal + */ +export function _resetBlueBubblesShortIdState(): void { + blueBubblesShortIdToUuid.clear(); + blueBubblesUuidToShortId.clear(); + blueBubblesReplyCacheByMessageId.clear(); + blueBubblesShortIdCounter = 0; +} + +/** + * Gets the short ID for a UUID, if one exists. + */ +function getShortIdForUuid(uuid: string): string | undefined { + return blueBubblesUuidToShortId.get(uuid.trim()); +} + +function resolveReplyContextFromCache(params: { + accountId: string; + replyToId: string; + chatGuid?: string; + chatIdentifier?: string; + chatId?: number; +}): BlueBubblesReplyCacheEntry | null { + const replyToId = params.replyToId.trim(); + if (!replyToId) return null; + + const cached = blueBubblesReplyCacheByMessageId.get(replyToId); + if (!cached) return null; + if (cached.accountId !== params.accountId) return null; + + const cutoff = Date.now() - REPLY_CACHE_TTL_MS; + if (cached.timestamp < cutoff) { + blueBubblesReplyCacheByMessageId.delete(replyToId); + return null; + } + + const chatGuid = trimOrUndefined(params.chatGuid); + const chatIdentifier = trimOrUndefined(params.chatIdentifier); + const cachedChatGuid = trimOrUndefined(cached.chatGuid); + const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier); + const chatId = typeof params.chatId === "number" ? params.chatId : undefined; + const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined; + + // Avoid cross-chat collisions if we have identifiers. + if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) return null; + if (!chatGuid && chatIdentifier && cachedChatIdentifier && chatIdentifier !== cachedChatIdentifier) { + return null; + } + if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) { + return null; + } + + return cached; +} + type BlueBubblesCoreRuntime = ReturnType; function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void { @@ -219,12 +378,15 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string { function formatReplyContext(message: { replyToId?: string; + replyToShortId?: string; replyToBody?: string; replyToSender?: string; }): string | null { if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null; const sender = message.replyToSender?.trim() || "unknown sender"; - const idPart = message.replyToId ? ` id:${message.replyToId}` : ""; + // Prefer short ID for token savings + const displayId = message.replyToShortId || message.replyToId; + const idPart = displayId ? ` id:${displayId}` : ""; const body = message.replyToBody?.trim(); if (!body) { return `[Replying to ${sender}${idPart}]\n[/Replying]`; @@ -404,6 +566,15 @@ function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undef return undefined; } +function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined { + const guid = chatGuid?.trim(); + if (!guid) return undefined; + const parts = guid.split(";"); + if (parts.length < 3) return undefined; + const identifier = parts[2]?.trim(); + return identifier || undefined; +} + function formatGroupAllowlistEntry(params: { chatGuid?: string; chatId?: number; @@ -550,20 +721,31 @@ function normalizeWebhookMessage(payload: Record): NormalizedWe const chatGuid = readString(message, "chatGuid") ?? readString(message, "chat_guid") ?? + readString(chat, "chatGuid") ?? + readString(chat, "chat_guid") ?? readString(chat, "guid") ?? + readString(chatFromList, "chatGuid") ?? + readString(chatFromList, "chat_guid") ?? readString(chatFromList, "guid"); const chatIdentifier = readString(message, "chatIdentifier") ?? readString(message, "chat_identifier") ?? + readString(chat, "chatIdentifier") ?? + readString(chat, "chat_identifier") ?? readString(chat, "identifier") ?? readString(chatFromList, "chatIdentifier") ?? readString(chatFromList, "chat_identifier") ?? - readString(chatFromList, "identifier"); + readString(chatFromList, "identifier") ?? + extractChatIdentifierFromChatGuid(chatGuid); const chatId = - readNumber(message, "chatId") ?? - readNumber(message, "chat_id") ?? - readNumber(chat, "id") ?? - readNumber(chatFromList, "id"); + readNumberLike(message, "chatId") ?? + readNumberLike(message, "chat_id") ?? + readNumberLike(chat, "chatId") ?? + readNumberLike(chat, "chat_id") ?? + readNumberLike(chat, "id") ?? + readNumberLike(chatFromList, "chatId") ?? + readNumberLike(chatFromList, "chat_id") ?? + readNumberLike(chatFromList, "id"); const chatName = readString(message, "chatName") ?? readString(chat, "displayName") ?? @@ -679,19 +861,30 @@ function normalizeWebhookReaction(payload: Record): NormalizedW const chatGuid = readString(message, "chatGuid") ?? readString(message, "chat_guid") ?? + readString(chat, "chatGuid") ?? + readString(chat, "chat_guid") ?? readString(chat, "guid") ?? + readString(chatFromList, "chatGuid") ?? + readString(chatFromList, "chat_guid") ?? readString(chatFromList, "guid"); const chatIdentifier = readString(message, "chatIdentifier") ?? readString(message, "chat_identifier") ?? + readString(chat, "chatIdentifier") ?? + readString(chat, "chat_identifier") ?? readString(chat, "identifier") ?? readString(chatFromList, "chatIdentifier") ?? readString(chatFromList, "chat_identifier") ?? - readString(chatFromList, "identifier"); + readString(chatFromList, "identifier") ?? + extractChatIdentifierFromChatGuid(chatGuid); const chatId = readNumberLike(message, "chatId") ?? readNumberLike(message, "chat_id") ?? + readNumberLike(chat, "chatId") ?? + readNumberLike(chat, "chat_id") ?? readNumberLike(chat, "id") ?? + readNumberLike(chatFromList, "chatId") ?? + readNumberLike(chatFromList, "chat_id") ?? readNumberLike(chatFromList, "id"); const chatName = readString(message, "chatName") ?? @@ -901,14 +1094,36 @@ async function processMessage( target: WebhookTarget, ): Promise { const { account, config, runtime, core, statusSink } = target; - if (message.fromMe) return; + const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; const text = message.text.trim(); const attachments = message.attachments ?? []; const placeholder = buildMessagePlaceholder(message); - if (!text && !placeholder) { + const rawBody = text || placeholder; + + // Cache messages (including fromMe) so later replies can resolve sender/body even when + // BlueBubbles webhook payloads omit nested reply metadata. + const cacheMessageId = message.messageId?.trim(); + let messageShortId: string | undefined; + if (cacheMessageId) { + const cacheEntry = rememberBlueBubblesReplyCache({ + accountId: account.accountId, + messageId: cacheMessageId, + chatGuid: message.chatGuid, + chatIdentifier: message.chatIdentifier, + chatId: message.chatId, + senderLabel: message.fromMe ? "me" : message.senderId, + body: rawBody, + timestamp: message.timestamp ?? Date.now(), + }); + messageShortId = cacheEntry.shortId; + } + + if (message.fromMe) return; + + if (!rawBody) { logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`); return; } @@ -1199,12 +1414,42 @@ async function processMessage( } } } - const rawBody = text.trim() || placeholder; - const replyContext = formatReplyContext(message); + let replyToId = message.replyToId; + let replyToBody = message.replyToBody; + let replyToSender = message.replyToSender; + let replyToShortId: string | undefined; + + if (replyToId && (!replyToBody || !replyToSender)) { + const cached = resolveReplyContextFromCache({ + accountId: account.accountId, + replyToId, + chatGuid: message.chatGuid, + chatIdentifier: message.chatIdentifier, + chatId: message.chatId, + }); + if (cached) { + if (!replyToBody && cached.body) replyToBody = cached.body; + if (!replyToSender && cached.senderLabel) replyToSender = cached.senderLabel; + replyToShortId = cached.shortId; + if (core.logging.shouldLogVerbose()) { + const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120); + logVerbose( + core, + runtime, + `reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`, + ); + } + } + } + + // If no cached short ID, try to get one from the UUID directly + if (replyToId && !replyToShortId) { + replyToShortId = getShortIdForUuid(replyToId); + } + + const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender }); const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody; - const fromLabel = isGroup - ? `group:${peerId}` - : message.senderName || `user:${message.senderId}`; + const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`; const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined; const groupMembers = isGroup ? formatGroupMembers({ @@ -1230,12 +1475,12 @@ async function processMessage( }); let chatGuidForActions = chatGuid; if (!chatGuidForActions && baseUrl && password) { - const target = + const target = isGroup && (chatId || chatIdentifier) ? chatId - ? { kind: "chat_id", chatId } - : { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } - : { kind: "handle", address: message.senderId }; + ? ({ kind: "chat_id", chatId } as const) + : ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const) + : ({ kind: "handle", address: message.senderId } as const); if (target.kind !== "chat_identifier" || target.chatIdentifier) { chatGuidForActions = (await resolveChatGuidForTarget({ @@ -1316,10 +1561,23 @@ async function processMessage( ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions }) : message.senderId; - const maybeEnqueueOutboundMessageId = (messageId?: string) => { + const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => { const trimmed = messageId?.trim(); if (!trimmed || trimmed === "ok" || trimmed === "unknown") return; - core.system.enqueueSystemEvent(`BlueBubbles sent message id: ${trimmed}`, { + // Cache outbound message to get short ID + const cacheEntry = rememberBlueBubblesReplyCache({ + accountId: account.accountId, + messageId: trimmed, + chatGuid: chatGuidForActions ?? chatGuid, + chatIdentifier, + chatId, + senderLabel: "me", + body: snippet ?? "", + timestamp: Date.now(), + }); + const displayId = cacheEntry.shortId || trimmed; + const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : ""; + core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, { sessionKey: route.sessionKey, contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`, }); @@ -1343,16 +1601,18 @@ async function processMessage( AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", ConversationLabel: fromLabel, - ReplyToId: message.replyToId, - ReplyToBody: message.replyToBody, - ReplyToSender: message.replyToSender, + // Use short ID for token savings (agent can use this to reference the message) + ReplyToId: replyToShortId || replyToId, + ReplyToBody: replyToBody, + ReplyToSender: replyToSender, GroupSubject: groupSubject, GroupMembers: groupMembers, SenderName: message.senderName || undefined, SenderId: message.senderId, Provider: "bluebubbles", Surface: "bluebubbles", - MessageSid: message.messageId, + // Use short ID for token savings (agent can use this to reference the message) + MessageSid: messageShortId || message.messageId, Timestamp: message.timestamp, OriginatingChannel: "bluebubbles", OriginatingTo: `bluebubbles:${outboundTarget}`, @@ -1385,7 +1645,8 @@ async function processMessage( replyToId: payload.replyToId ?? null, accountId: account.accountId, }); - maybeEnqueueOutboundMessageId(result.messageId); + const cachedBody = (caption ?? "").trim() || ""; + maybeEnqueueOutboundMessageId(result.messageId, cachedBody); sentMessage = true; statusSink?.({ lastOutboundAt: Date.now() }); } @@ -1407,7 +1668,7 @@ async function processMessage( accountId: account.accountId, replyToMessageGuid: replyToMessageGuid || undefined, }); - maybeEnqueueOutboundMessageId(result.messageId); + maybeEnqueueOutboundMessageId(result.messageId, chunk); sentMessage = true; statusSink?.({ lastOutboundAt: Date.now() }); } @@ -1541,7 +1802,9 @@ async function processReaction( const senderLabel = reaction.senderName || reaction.senderId; const chatLabel = reaction.isGroup ? ` in group:${peerId}` : ""; - const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`; + // Use short ID for token savings + const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId; + const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`; core.system.enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`, diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts index d7a2beaa8..09176ef14 100644 --- a/extensions/bluebubbles/src/reactions.ts +++ b/extensions/bluebubbles/src/reactions.ts @@ -20,32 +20,101 @@ const REACTION_TYPES = new Set([ ]); const REACTION_ALIASES = new Map([ + // General ["heart", "love"], + ["love", "love"], + ["❤", "love"], + ["❤️", "love"], + ["red_heart", "love"], ["thumbs_up", "like"], - ["thumbs-down", "dislike"], + ["thumbsup", "like"], + ["thumbs-up", "like"], + ["thumbsup", "like"], + ["like", "like"], + ["thumb", "like"], + ["ok", "like"], ["thumbs_down", "dislike"], + ["thumbsdown", "dislike"], + ["thumbs-down", "dislike"], + ["dislike", "dislike"], + ["boo", "dislike"], + ["no", "dislike"], + // Laugh ["haha", "laugh"], ["lol", "laugh"], + ["lmao", "laugh"], + ["rofl", "laugh"], + ["😂", "laugh"], + ["🤣", "laugh"], + ["xd", "laugh"], + ["laugh", "laugh"], + // Emphasize / exclaim ["emphasis", "emphasize"], + ["emphasize", "emphasize"], ["exclaim", "emphasize"], + ["!!", "emphasize"], + ["‼", "emphasize"], + ["‼️", "emphasize"], + ["❗", "emphasize"], + ["important", "emphasize"], + ["bang", "emphasize"], + // Question ["question", "question"], + ["?", "question"], + ["❓", "question"], + ["❔", "question"], + ["ask", "question"], + // Apple/Messages names + ["loved", "love"], + ["liked", "like"], + ["disliked", "dislike"], + ["laughed", "laugh"], + ["emphasized", "emphasize"], + ["questioned", "question"], + // Colloquial / informal + ["fire", "love"], + ["🔥", "love"], + ["wow", "emphasize"], + ["!", "emphasize"], + // Edge: generic emoji name forms + ["heart_eyes", "love"], + ["smile", "laugh"], + ["smiley", "laugh"], + ["happy", "laugh"], + ["joy", "laugh"], ]); const REACTION_EMOJIS = new Map([ + // Love ["❤️", "love"], ["❤", "love"], ["♥️", "love"], + ["♥", "love"], ["😍", "love"], + ["💕", "love"], + // Like ["👍", "like"], + ["👌", "like"], + // Dislike ["👎", "dislike"], + ["🙅", "dislike"], + // Laugh ["😂", "laugh"], ["🤣", "laugh"], ["😆", "laugh"], + ["😁", "laugh"], + ["😹", "laugh"], + // Emphasize ["‼️", "emphasize"], ["‼", "emphasize"], + ["!!", "emphasize"], ["❗", "emphasize"], + ["❕", "emphasize"], + ["!", "emphasize"], + // Question ["❓", "question"], ["❔", "question"], + ["?", "question"], ]); function resolveAccount(params: BlueBubblesReactionOpts) { diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index f39abdd5e..0b8b77a1f 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -96,6 +96,33 @@ describe("send", () => { expect(result).toBe("iMessage;-;chat123"); }); + it("matches chat_identifier against the 3rd component of chat GUID", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;+;chat660250192681427962", + participants: [], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { + kind: "chat_identifier", + chatIdentifier: "chat660250192681427962", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;+;chat660250192681427962"); + }); + it("resolves handle target by matching participant", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 868184c42..675063d6d 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -133,6 +133,13 @@ function extractChatId(chat: BlueBubblesChatRecord): number | null { return null; } +function extractChatIdentifierFromChatGuid(chatGuid: string): string | null { + const parts = chatGuid.split(";"); + if (parts.length < 3) return null; + const identifier = parts[2]?.trim(); + return identifier ? identifier : null; +} + function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] { const raw = (Array.isArray(chat.participants) ? chat.participants : null) ?? @@ -223,7 +230,16 @@ export async function resolveChatGuidForTarget(params: { } if (targetChatIdentifier) { const guid = extractChatGuid(chat); - if (guid && guid === targetChatIdentifier) return guid; + if (guid) { + // Back-compat: some callers might pass a full chat GUID. + if (guid === targetChatIdentifier) return guid; + + // Primary match: BlueBubbles `chat_identifier:*` targets correspond to the + // third component of the chat GUID: `service;(+|-) ;identifier`. + const guidIdentifier = extractChatIdentifierFromChatGuid(guid); + if (guidIdentifier && guidIdentifier === targetChatIdentifier) return guid; + } + const identifier = typeof chat.identifier === "string" ? chat.identifier @@ -232,7 +248,7 @@ export async function resolveChatGuidForTarget(params: { : typeof chat.chat_identifier === "string" ? chat.chat_identifier : ""; - if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat); + if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat); } if (normalizedHandle) { const guid = extractChatGuid(chat); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 62ce6ff71..b1b3ae890 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,6 +1,6 @@ import type { NormalizedUsage } from "../../agents/usage.js"; import { getChannelDock } from "../../channels/dock.js"; -import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; +import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import { normalizeChannelId } from "../../channels/registry.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; @@ -21,17 +21,25 @@ export function buildThreadingToolContext(params: { }): ChannelThreadingToolContext { const { sessionCtx, config, hasRepliedRef } = params; if (!config) return {}; - const provider = normalizeChannelId(sessionCtx.Provider); - if (!provider) return {}; - const dock = getChannelDock(provider); - if (!dock?.threading?.buildToolContext) return {}; + const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); + if (!rawProvider) return {}; + const provider = normalizeChannelId(rawProvider); // WhatsApp context isolation keys off conversation id, not the bot's own number. const threadingTo = - provider === "whatsapp" + rawProvider === "whatsapp" ? (sessionCtx.From ?? sessionCtx.To) - : provider === "imessage" && sessionCtx.ChatType === "direct" + : rawProvider === "imessage" && sessionCtx.ChatType === "direct" ? (sessionCtx.From ?? sessionCtx.To) : sessionCtx.To; + // Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init) + const dock = provider ? getChannelDock(provider) : undefined; + if (!dock?.threading?.buildToolContext) { + return { + currentChannelId: threadingTo?.trim() || undefined, + currentChannelProvider: provider ?? (rawProvider as ChannelId), + hasRepliedRef, + }; + } const context = dock.threading.buildToolContext({ cfg: config, @@ -47,7 +55,7 @@ export function buildThreadingToolContext(params: { }) ?? {}; return { ...context, - currentChannelProvider: provider, + currentChannelProvider: provider!, // guaranteed non-null since dock exists }; } diff --git a/src/auto-reply/reply/typing-mode.test.ts b/src/auto-reply/reply/typing-mode.test.ts index 766cbe803..064e58adf 100644 --- a/src/auto-reply/reply/typing-mode.test.ts +++ b/src/auto-reply/reply/typing-mode.test.ts @@ -140,7 +140,7 @@ describe("createTypingSignaler", () => { expect(typing.startTypingOnText).not.toHaveBeenCalled(); }); - it("does not start typing on tool start before text", async () => { + it("starts typing on tool start before text", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, @@ -150,8 +150,9 @@ describe("createTypingSignaler", () => { await signaler.signalToolStart(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - expect(typing.refreshTypingTtl).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); }); it("refreshes ttl on tool start when active after text", async () => { diff --git a/src/auto-reply/reply/typing-mode.ts b/src/auto-reply/reply/typing-mode.ts index b2e62d8c4..0d73a3794 100644 --- a/src/auto-reply/reply/typing-mode.ts +++ b/src/auto-reply/reply/typing-mode.ts @@ -95,13 +95,13 @@ export function createTypingSignaler(params: { const signalToolStart = async () => { if (disabled) return; - if (!hasRenderableText) return; + // Start typing as soon as tools begin executing, even before the first text delta. if (!typing.isActive()) { await typing.startTypingLoop(); typing.refreshTypingTtl(); return; } - // Keep typing indicator alive during tool execution without changing mode semantics. + // Keep typing indicator alive during tool execution. typing.refreshTypingTtl(); }; diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index 782f8dc8b..c4f712e0f 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -57,6 +57,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = { unsend: ["messageId"], edit: ["messageId"], + react: ["chatGuid", "chatIdentifier", "chatId"], renameGroup: ["chatGuid", "chatIdentifier", "chatId"], setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"], addParticipant: ["chatGuid", "chatIdentifier", "chatId"], From 7bfc32fe338b224ac2c419f86e2c29bfc7d0cd67 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Wed, 21 Jan 2026 00:33:38 -0800 Subject: [PATCH 012/171] feat: enhance message handling with short ID resolution and reply context improvements - Implemented resolution of short message IDs to full UUIDs in both text and media sending functions. - Updated reply context formatting to optimize token usage by including only necessary information. - Introduced truncation for long reply bodies to further reduce token consumption. - Adjusted tests to reflect changes in reply context handling and message ID resolution. --- extensions/bluebubbles/src/channel.ts | 5 ++++- extensions/bluebubbles/src/media-send.ts | 12 ++++++++--- extensions/bluebubbles/src/monitor.test.ts | 8 +++---- extensions/bluebubbles/src/monitor.ts | 25 ++++++++++++++++------ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index b78f41659..da594e763 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -20,6 +20,7 @@ import { resolveDefaultBlueBubblesAccountId, } from "./accounts.js"; import { BlueBubblesConfigSchema } from "./config-schema.js"; +import { resolveBlueBubblesMessageId } from "./monitor.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { sendMessageBlueBubbles } from "./send.js"; import { @@ -237,7 +238,9 @@ export const bluebubblesPlugin: ChannelPlugin = { return { ok: true, to: trimmed }; }, sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const replyToMessageGuid = typeof replyToId === "string" ? replyToId.trim() : ""; + const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; + // Resolve short ID (e.g., "5") to full UUID + const replyToMessageGuid = rawReplyToId ? resolveBlueBubblesMessageId(rawReplyToId) : ""; const result = await sendMessageBlueBubbles(to, text, { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined, diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts index 254cecafd..5fc9895e3 100644 --- a/extensions/bluebubbles/src/media-send.ts +++ b/extensions/bluebubbles/src/media-send.ts @@ -4,8 +4,9 @@ import { fileURLToPath } from "node:url"; import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk"; import { sendBlueBubblesAttachment } from "./attachments.js"; -import { sendMessageBlueBubbles } from "./send.js"; +import { resolveBlueBubblesMessageId } from "./monitor.js"; import { getBlueBubblesRuntime } from "./runtime.js"; +import { sendMessageBlueBubbles } from "./send.js"; const HTTP_URL_RE = /^https?:\/\//i; const MB = 1024 * 1024; @@ -134,12 +135,17 @@ export async function sendBlueBubblesMedia(params: { } } + // Resolve short ID (e.g., "5") to full UUID + const replyToMessageGuid = replyToId?.trim() + ? resolveBlueBubblesMessageId(replyToId.trim()) + : undefined; + const attachmentResult = await sendBlueBubblesAttachment({ to, buffer, filename: resolvedFilename ?? "attachment", contentType: resolvedContentType ?? undefined, - replyToMessageGuid: replyToId?.trim() || undefined, + replyToMessageGuid, opts: { cfg, accountId, @@ -151,7 +157,7 @@ export async function sendBlueBubblesMedia(params: { await sendMessageBlueBubbles(to, trimmedCaption, { cfg, accountId, - replyToMessageGuid: replyToId?.trim() || undefined, + replyToMessageGuid, }); } diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 14c896427..f3be7d6ed 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -1175,8 +1175,8 @@ describe("BlueBubbles webhook monitor", () => { expect(callArgs.ctx.ReplyToId).toBe("msg-0"); expect(callArgs.ctx.ReplyToBody).toBe("original message"); expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); - // Body still uses the full UUID since it wasn't cached - expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]"); + // Body uses just the ID (no sender) for token savings + expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]"); expect(callArgs.ctx.Body).toContain("original message"); }); @@ -1245,8 +1245,8 @@ describe("BlueBubbles webhook monitor", () => { expect(callArgs.ctx.ReplyToId).toBe("1"); expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)"); expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); - // Body uses short ID for token savings - expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:1]"); + // Body uses just the short ID (no sender) for token savings + expect(callArgs.ctx.Body).toContain("[Replying to id:1]"); expect(callArgs.ctx.Body).toContain("original message (cached)"); }); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 4c2b4a225..c7a4188c0 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -376,6 +376,8 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string { return ""; } +const REPLY_BODY_TRUNCATE_LENGTH = 60; + function formatReplyContext(message: { replyToId?: string; replyToShortId?: string; @@ -383,15 +385,20 @@ function formatReplyContext(message: { replyToSender?: string; }): string | null { if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null; - const sender = message.replyToSender?.trim() || "unknown sender"; // Prefer short ID for token savings const displayId = message.replyToShortId || message.replyToId; - const idPart = displayId ? ` id:${displayId}` : ""; - const body = message.replyToBody?.trim(); - if (!body) { - return `[Replying to ${sender}${idPart}]\n[/Replying]`; + // Only include sender if we don't have an ID (fallback) + const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown"); + const rawBody = message.replyToBody?.trim(); + if (!rawBody) { + return `[Replying to ${label}]\n[/Replying]`; } - return `[Replying to ${sender}${idPart}]\n${body}\n[/Replying]`; + // Truncate long reply bodies for token savings + const body = + rawBody.length > REPLY_BODY_TRUNCATE_LENGTH + ? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…` + : rawBody; + return `[Replying to ${label}]\n${body}\n[/Replying]`; } function readNumberLike(record: Record | null, key: string): number | undefined { @@ -1661,8 +1668,12 @@ async function processMessage( if (!chunks.length && payload.text) chunks.push(payload.text); if (!chunks.length) return; for (const chunk of chunks) { - const replyToMessageGuid = + const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; + // Resolve short ID (e.g., "5") to full UUID + const replyToMessageGuid = rawReplyToId + ? resolveBlueBubblesMessageId(rawReplyToId) + : ""; const result = await sendMessageBlueBubbles(outboundTarget, chunk, { cfg: config, accountId: account.accountId, From 9b9bbae5010111ef77d66b73b918237ac1051076 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Wed, 21 Jan 2026 00:39:39 -0800 Subject: [PATCH 013/171] feat: enhance message context with full ID support for replies and caching - Updated message processing to include full message IDs alongside short IDs for better context resolution. - Improved reply handling by caching inbound messages, allowing for accurate sender and body resolution without exposing dropped content. - Adjusted tests to validate the new full ID properties and their integration into the message handling workflow. --- extensions/bluebubbles/src/channel.ts | 2 +- extensions/bluebubbles/src/monitor.test.ts | 2 ++ extensions/bluebubbles/src/monitor.ts | 32 ++++++++++++------- .../reply/agent-runner-execution.ts | 6 ++-- src/auto-reply/reply/agent-runner.ts | 2 +- src/auto-reply/reply/get-reply-run.ts | 2 +- src/auto-reply/templating.ts | 4 +++ src/channels/plugins/types.core.ts | 1 + 8 files changed, 34 insertions(+), 17 deletions(-) diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index da594e763..f316b499f 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -66,7 +66,7 @@ export const bluebubblesPlugin: ChannelPlugin = { threading: { buildToolContext: ({ context, hasRepliedRef }) => ({ currentChannelId: context.To?.trim() || undefined, - currentThreadTs: context.ReplyToId, + currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId, hasRepliedRef, }), }, diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index f3be7d6ed..2806bf82f 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -1243,6 +1243,7 @@ describe("BlueBubbles webhook monitor", () => { const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; // ReplyToId uses short ID "1" (first cached message) for token savings expect(callArgs.ctx.ReplyToId).toBe("1"); + expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0"); expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)"); expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); // Body uses just the short ID (no sender) for token savings @@ -1812,6 +1813,7 @@ describe("BlueBubbles webhook monitor", () => { const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; // MessageSid should be short ID "1" instead of full UUID expect(callArgs.ctx.MessageSid).toBe("1"); + expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345"); }); it("resolves short ID back to UUID", async () => { diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index c7a4188c0..083587409 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1110,11 +1110,10 @@ async function processMessage( const placeholder = buildMessagePlaceholder(message); const rawBody = text || placeholder; - // Cache messages (including fromMe) so later replies can resolve sender/body even when - // BlueBubbles webhook payloads omit nested reply metadata. const cacheMessageId = message.messageId?.trim(); let messageShortId: string | undefined; - if (cacheMessageId) { + const cacheInboundMessage = () => { + if (!cacheMessageId) return; const cacheEntry = rememberBlueBubblesReplyCache({ accountId: account.accountId, messageId: cacheMessageId, @@ -1126,9 +1125,13 @@ async function processMessage( timestamp: message.timestamp ?? Date.now(), }); messageShortId = cacheEntry.shortId; - } + }; - if (message.fromMe) return; + if (message.fromMe) { + // Cache from-me messages so reply context can resolve sender/body. + cacheInboundMessage(); + return; + } if (!rawBody) { logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`); @@ -1370,6 +1373,10 @@ async function processMessage( return; } + // Cache allowed inbound messages so later replies can resolve sender/body without + // surfacing dropped content (allowlist/mention/command gating). + cacheInboundMessage(); + const baseUrl = account.config.serverUrl?.trim(); const password = account.config.password?.trim(); const maxBytes = @@ -1610,6 +1617,7 @@ async function processMessage( ConversationLabel: fromLabel, // Use short ID for token savings (agent can use this to reference the message) ReplyToId: replyToShortId || replyToId, + ReplyToIdFull: replyToId, ReplyToBody: replyToBody, ReplyToSender: replyToSender, GroupSubject: groupSubject, @@ -1620,6 +1628,7 @@ async function processMessage( Surface: "bluebubbles", // Use short ID for token savings (agent can use this to reference the message) MessageSid: messageShortId || message.messageId, + MessageSidFull: message.messageId, Timestamp: message.timestamp, OriginatingChannel: "bluebubbles", OriginatingTo: `bluebubbles:${outboundTarget}`, @@ -1634,6 +1643,11 @@ async function processMessage( cfg: config, dispatcherOptions: { deliver: async (payload) => { + const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; + // Resolve short ID (e.g., "5") to full UUID + const replyToMessageGuid = rawReplyToId + ? resolveBlueBubblesMessageId(rawReplyToId) + : ""; const mediaList = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl @@ -1649,7 +1663,7 @@ async function processMessage( to: outboundTarget, mediaUrl, caption: caption ?? undefined, - replyToId: payload.replyToId ?? null, + replyToId: replyToMessageGuid || null, accountId: account.accountId, }); const cachedBody = (caption ?? "").trim() || ""; @@ -1668,12 +1682,6 @@ async function processMessage( if (!chunks.length && payload.text) chunks.push(payload.text); if (!chunks.length) return; for (const chunk of chunks) { - const rawReplyToId = - typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; - // Resolve short ID (e.g., "5") to full UUID - const replyToMessageGuid = rawReplyToId - ? resolveBlueBubblesMessageId(rawReplyToId) - : ""; const result = await sendMessageBlueBubbles(outboundTarget, chunk, { cfg: config, accountId: account.accountId, diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index bbdf6df0d..0e7dfa233 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -299,6 +299,8 @@ export async function runAgentTurnWithFallback(params: { const { text, skip } = normalizeStreamingText(payload); const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0; if (skip && !hasPayloadMedia) return; + const currentMessageId = + params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid; const taggedPayload = applyReplyTagsToPayload( { text, @@ -308,12 +310,12 @@ export async function runAgentTurnWithFallback(params: { replyToTag: payload.replyToTag, replyToCurrent: payload.replyToCurrent, }, - params.sessionCtx.MessageSid, + currentMessageId, ); // Let through payloads with audioAsVoice flag even if empty (need to track it) if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return; const parsed = parseReplyDirectives(taggedPayload.text ?? "", { - currentMessageId: params.sessionCtx.MessageSid, + currentMessageId, silentToken: SILENT_REPLY_TOKEN, }); const cleaned = parsed.text || undefined; diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index da73bf9de..1da6d3957 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -377,7 +377,7 @@ export async function runReplyAgent(params: { directlySentBlockKeys, replyToMode, replyToChannel, - currentMessageId: sessionCtx.MessageSid, + currentMessageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid, messageProvider: followupRun.run.messageProvider, messagingToolSentTexts: runResult.messagingToolSentTexts, messagingToolSentTargets: runResult.messagingToolSentTargets, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index aa2281de6..1b0bfd4eb 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -350,7 +350,7 @@ export async function runPreparedReply( const authProfileIdSource = sessionEntry?.authProfileOverrideSource; const followupRun = { prompt: queuedBody, - messageId: sessionCtx.MessageSid, + messageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid, summaryLine: baseBodyTrimmedRaw, enqueuedAt: Date.now(), // Originating channel for reply routing. diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 375ba6759..605a0fa69 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -38,10 +38,14 @@ export type MsgContext = { AccountId?: string; ParentSessionKey?: string; MessageSid?: string; + /** Provider-specific full message id when MessageSid is a shortened alias. */ + MessageSidFull?: string; MessageSids?: string[]; MessageSidFirst?: string; MessageSidLast?: string; ReplyToId?: string; + /** Provider-specific full reply-to id when ReplyToId is a shortened alias. */ + ReplyToIdFull?: string; ReplyToBody?: string; ReplyToSender?: string; ForwardedFrom?: string; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 3f6600620..dced4e14c 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -212,6 +212,7 @@ export type ChannelThreadingContext = { Channel?: string; To?: string; ReplyToId?: string; + ReplyToIdFull?: string; ThreadLabel?: string; MessageThreadId?: string | number; }; From a90fe1b245c181b5fc84f402d78e91538421cc26 Mon Sep 17 00:00:00 2001 From: Pham Nam Date: Wed, 21 Jan 2026 19:48:21 +0700 Subject: [PATCH 014/171] Refs #1378: scaffold zalouser extension --- extensions/zalouser/src/zca.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/extensions/zalouser/src/zca.ts b/extensions/zalouser/src/zca.ts index 83849835f..427e20024 100644 --- a/extensions/zalouser/src/zca.ts +++ b/extensions/zalouser/src/zca.ts @@ -114,11 +114,36 @@ export function runZcaInteractive( }); } +function stripAnsi(str: string): string { + return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ""); +} + export function parseJsonOutput(stdout: string): T | null { try { return JSON.parse(stdout) as T; } catch { - return null; + const cleaned = stripAnsi(stdout); + + try { + return JSON.parse(cleaned) as T; + } catch { + // zca may prefix output with INFO/log lines, try to find JSON + const lines = cleaned.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.startsWith("{") || line.startsWith("[")) { + // Try parsing from this line to the end + const jsonCandidate = lines.slice(i).join("\n").trim(); + try { + return JSON.parse(jsonCandidate) as T; + } catch { + continue; + } + } + } + return null; + } } } From 0e003cb7f16dee56cbb86c09d9176dcfa19a0bf7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 16:46:49 +0000 Subject: [PATCH 015/171] fix: normalize abort signals for telegram fetch --- src/infra/fetch.test.ts | 36 +++++++++++++++++++ src/infra/fetch.ts | 29 +++++++++++++++ ...gram-bot.installs-grammy-throttler.test.ts | 13 ++++--- src/telegram/bot.test.ts | 13 ++++--- src/telegram/fetch.ts | 6 ++-- src/telegram/proxy.ts | 5 +-- ...send.returns-undefined-empty-input.test.ts | 10 +++--- 7 files changed, 88 insertions(+), 24 deletions(-) create mode 100644 src/infra/fetch.test.ts create mode 100644 src/infra/fetch.ts diff --git a/src/infra/fetch.test.ts b/src/infra/fetch.test.ts new file mode 100644 index 000000000..9c286d4be --- /dev/null +++ b/src/infra/fetch.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; + +import { wrapFetchWithAbortSignal } from "./fetch.js"; + +describe("wrapFetchWithAbortSignal", () => { + it("converts foreign abort signals to native controllers", async () => { + let seenSignal: AbortSignal | undefined; + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + seenSignal = init?.signal as AbortSignal | undefined; + return {} as Response; + }); + + const wrapped = wrapFetchWithAbortSignal(fetchImpl); + + let abortHandler: (() => void) | null = null; + const fakeSignal = { + aborted: false, + addEventListener: (event: string, handler: () => void) => { + if (event === "abort") abortHandler = handler; + }, + removeEventListener: (event: string, handler: () => void) => { + if (event === "abort" && abortHandler === handler) abortHandler = null; + }, + } as AbortSignal; + + const promise = wrapped("https://example.com", { signal: fakeSignal }); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(seenSignal).toBeInstanceOf(AbortSignal); + expect(seenSignal).not.toBe(fakeSignal); + + abortHandler?.(); + expect(seenSignal?.aborted).toBe(true); + + await promise; + }); +}); diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts new file mode 100644 index 000000000..5cd0d94e6 --- /dev/null +++ b/src/infra/fetch.ts @@ -0,0 +1,29 @@ +export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch { + return (input: RequestInfo | URL, init?: RequestInit) => { + const signal = init?.signal; + if (!signal) return fetchImpl(input, init); + if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) { + return fetchImpl(input, init); + } + if (typeof AbortController === "undefined") { + return fetchImpl(input, init); + } + if (typeof signal.addEventListener !== "function") { + return fetchImpl(input, init); + } + const controller = new AbortController(); + const onAbort = () => controller.abort(); + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + const response = fetchImpl(input, { ...init, signal: controller.signal }); + if (typeof signal.removeEventListener === "function") { + void response.finally(() => { + signal.removeEventListener("abort", onAbort); + }); + } + return response; + }; +} diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index fc34477c1..de7f6b62b 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -157,13 +157,12 @@ describe("createTelegramBot", () => { (globalThis as { Bun?: unknown }).Bun = {}; createTelegramBot({ token: "tok" }); const fetchImpl = resolveTelegramFetch(); - expect(fetchImpl).toBe(fetchSpy); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchSpy }), - }), - ); + expect(fetchImpl).toBeTypeOf("function"); + expect(fetchImpl).not.toBe(fetchSpy); + const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch; + expect(clientFetch).toBeTypeOf("function"); + expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; if (originalBun === undefined) { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 4166ddb19..77f50b41f 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -284,13 +284,12 @@ describe("createTelegramBot", () => { (globalThis as { Bun?: unknown }).Bun = {}; createTelegramBot({ token: "tok" }); const fetchImpl = resolveTelegramFetch(); - expect(fetchImpl).toBe(fetchSpy); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchSpy }), - }), - ); + expect(fetchImpl).toBeTypeOf("function"); + expect(fetchImpl).not.toBe(fetchSpy); + const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch; + expect(clientFetch).toBeTypeOf("function"); + expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; if (originalBun === undefined) { diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 21d85e2e9..1c4a288d0 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,11 +1,13 @@ +import { wrapFetchWithAbortSignal } from "../infra/fetch.js"; + // Bun-only: force native fetch to avoid grammY's Node shim under Bun. export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined { - if (proxyFetch) return proxyFetch; + if (proxyFetch) return wrapFetchWithAbortSignal(proxyFetch); const fetchImpl = globalThis.fetch; const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun); if (!isBun) return undefined; if (!fetchImpl) { throw new Error("fetch is not available; set channels.telegram.proxy in config"); } - return fetchImpl; + return wrapFetchWithAbortSignal(fetchImpl); } diff --git a/src/telegram/proxy.ts b/src/telegram/proxy.ts index 7217db477..19d53d569 100644 --- a/src/telegram/proxy.ts +++ b/src/telegram/proxy.ts @@ -1,10 +1,11 @@ // @ts-nocheck import { ProxyAgent } from "undici"; +import { wrapFetchWithAbortSignal } from "../infra/fetch.js"; export function makeProxyFetch(proxyUrl: string): typeof fetch { const agent = new ProxyAgent(proxyUrl); - return (input: RequestInfo | URL, init?: RequestInit) => { + return wrapFetchWithAbortSignal((input: RequestInfo | URL, init?: RequestInit) => { const base = init ? { ...init } : {}; return fetch(input, { ...base, dispatcher: agent }); - }; + }); } diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts index f5dff070b..22a85eb3d 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.returns-undefined-empty-input.test.ts @@ -164,12 +164,10 @@ describe("sendMessageTelegram", () => { }); try { await sendMessageTelegram("123", "hi", { token: "tok" }); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchSpy }), - }), - ); + const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch; + expect(clientFetch).toBeTypeOf("function"); + expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; if (originalBun === undefined) { From fa1bc589e4feaa18c0de6dfe187a2df42f26fe47 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 16:48:26 +0000 Subject: [PATCH 016/171] feat: flatten node CLI commands --- src/cli/node-cli/daemon.ts | 9 +- src/cli/node-cli/register.ts | 112 +++++++---------- src/cli/program/register.subclis.ts | 8 -- src/cli/service-cli.coverage.test.ts | 59 --------- src/cli/service-cli.ts | 181 --------------------------- 5 files changed, 48 insertions(+), 321 deletions(-) delete mode 100644 src/cli/service-cli.coverage.test.ts delete mode 100644 src/cli/service-cli.ts diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 111ac510e..4deaa49ca 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -47,10 +47,7 @@ type NodeDaemonStatusOptions = { }; function renderNodeServiceStartHints(): string[] { - const base = [ - formatCliCommand("clawdbot node service install"), - formatCliCommand("clawdbot node start"), - ]; + const base = [formatCliCommand("clawdbot node install"), formatCliCommand("clawdbot node start")]; switch (process.platform) { case "darwin": return [ @@ -172,9 +169,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { }); if (!json) { defaultRuntime.log(`Node service already ${service.loadedText}.`); - defaultRuntime.log( - `Reinstall with: ${formatCliCommand("clawdbot node service install --force")}`, - ); + defaultRuntime.log(`Reinstall with: ${formatCliCommand("clawdbot node install --force")}`); } return; } diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index 6717d001a..9a7412111 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -28,13 +28,13 @@ export function registerNodeCli(program: Command) { ); node - .command("start") - .description("Start the headless node host (foreground)") + .command("run") + .description("Run the headless node host (foreground)") .option("--host ", "Gateway host") .option("--port ", "Gateway port") .option("--tls", "Use TLS for the gateway connection", false) .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") - .option("--node-id ", "Override node id") + .option("--node-id ", "Override node id (clears pairing token)") .option("--display-name ", "Override node display name") .action(async (opts) => { const existing = await loadNodeHostConfig(); @@ -51,71 +51,51 @@ export function registerNodeCli(program: Command) { }); }); - const registerNodeServiceCommands = (cmd: Command) => { - cmd - .command("status") - .description("Show node service status") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonStatus(opts); - }); + node + .command("status") + .description("Show node host status") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runNodeDaemonStatus(opts); + }); - cmd - .command("install") - .description("Install the node service (launchd/systemd/schtasks)") - .option("--host ", "Gateway host") - .option("--port ", "Gateway port") - .option("--tls", "Use TLS for the gateway connection", false) - .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") - .option("--node-id ", "Override node id") - .option("--display-name ", "Override node display name") - .option("--runtime ", "Service runtime (node|bun). Default: node") - .option("--force", "Reinstall/overwrite if already installed", false) - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonInstall(opts); - }); + node + .command("install") + .description("Install the node host service (launchd/systemd/schtasks)") + .option("--host ", "Gateway host") + .option("--port ", "Gateway port") + .option("--tls", "Use TLS for the gateway connection", false) + .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") + .option("--node-id ", "Override node id (clears pairing token)") + .option("--display-name ", "Override node display name") + .option("--runtime ", "Service runtime (node|bun). Default: node") + .option("--force", "Reinstall/overwrite if already installed", false) + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runNodeDaemonInstall(opts); + }); - cmd - .command("uninstall") - .description("Uninstall the node service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonUninstall(opts); - }); + node + .command("uninstall") + .description("Uninstall the node host service (launchd/systemd/schtasks)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runNodeDaemonUninstall(opts); + }); - cmd - .command("start") - .description("Start the node service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonStart(opts); - }); + node + .command("stop") + .description("Stop the node host service (launchd/systemd/schtasks)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runNodeDaemonStop(opts); + }); - cmd - .command("stop") - .description("Stop the node service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonStop(opts); - }); - - cmd - .command("restart") - .description("Restart the node service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonRestart(opts); - }); - }; - - const service = node - .command("service") - .description("Manage the headless node service (launchd/systemd/schtasks)"); - registerNodeServiceCommands(service); - - const daemon = node - .command("daemon", { hidden: true }) - .description("Legacy alias for node service commands"); - registerNodeServiceCommands(daemon); + node + .command("restart") + .description("Restart the node host service (launchd/systemd/schtasks)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runNodeDaemonRestart(opts); + }); } diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 74b075af0..b13a4c76f 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -52,14 +52,6 @@ const entries: SubCliEntry[] = [ mod.registerGatewayCli(program); }, }, - { - name: "service", - description: "Service helpers", - register: async (program) => { - const mod = await import("../service-cli.js"); - mod.registerServiceCli(program); - }, - }, { name: "logs", description: "Gateway logs", diff --git a/src/cli/service-cli.coverage.test.ts b/src/cli/service-cli.coverage.test.ts deleted file mode 100644 index e0bb6604a..000000000 --- a/src/cli/service-cli.coverage.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Command } from "commander"; -import { describe, expect, it, vi } from "vitest"; - -const runDaemonStatus = vi.fn(async () => {}); -const runNodeDaemonStatus = vi.fn(async () => {}); - -vi.mock("./daemon-cli/runners.js", () => ({ - runDaemonInstall: vi.fn(async () => {}), - runDaemonRestart: vi.fn(async () => {}), - runDaemonStart: vi.fn(async () => {}), - runDaemonStatus: (opts: unknown) => runDaemonStatus(opts), - runDaemonStop: vi.fn(async () => {}), - runDaemonUninstall: vi.fn(async () => {}), -})); - -vi.mock("./node-cli/daemon.js", () => ({ - runNodeDaemonInstall: vi.fn(async () => {}), - runNodeDaemonRestart: vi.fn(async () => {}), - runNodeDaemonStart: vi.fn(async () => {}), - runNodeDaemonStatus: (opts: unknown) => runNodeDaemonStatus(opts), - runNodeDaemonStop: vi.fn(async () => {}), - runNodeDaemonUninstall: vi.fn(async () => {}), -})); - -vi.mock("./deps.js", () => ({ - createDefaultDeps: vi.fn(), -})); - -describe("service CLI coverage", () => { - it("routes service gateway status to daemon status", async () => { - runDaemonStatus.mockClear(); - runNodeDaemonStatus.mockClear(); - - const { registerServiceCli } = await import("./service-cli.js"); - const program = new Command(); - program.exitOverride(); - registerServiceCli(program); - - await program.parseAsync(["service", "gateway", "status"], { from: "user" }); - - expect(runDaemonStatus).toHaveBeenCalledTimes(1); - expect(runNodeDaemonStatus).toHaveBeenCalledTimes(0); - }); - - it("routes service node status to node daemon status", async () => { - runDaemonStatus.mockClear(); - runNodeDaemonStatus.mockClear(); - - const { registerServiceCli } = await import("./service-cli.js"); - const program = new Command(); - program.exitOverride(); - registerServiceCli(program); - - await program.parseAsync(["service", "node", "status"], { from: "user" }); - - expect(runNodeDaemonStatus).toHaveBeenCalledTimes(1); - expect(runDaemonStatus).toHaveBeenCalledTimes(0); - }); -}); diff --git a/src/cli/service-cli.ts b/src/cli/service-cli.ts deleted file mode 100644 index fa2ac3ffb..000000000 --- a/src/cli/service-cli.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { Command } from "commander"; -import { formatDocsLink } from "../terminal/links.js"; -import { theme } from "../terminal/theme.js"; -import { formatHelpExampleGroup } from "./help-format.js"; -import { createDefaultDeps } from "./deps.js"; -import { - runDaemonInstall, - runDaemonRestart, - runDaemonStart, - runDaemonStatus, - runDaemonStop, - runDaemonUninstall, -} from "./daemon-cli/runners.js"; -import { - runNodeDaemonInstall, - runNodeDaemonRestart, - runNodeDaemonStart, - runNodeDaemonStatus, - runNodeDaemonStop, - runNodeDaemonUninstall, -} from "./node-cli/daemon.js"; - -export function registerServiceCli(program: Command) { - const gatewayExamples: Array<[string, string]> = [ - ["clawdbot service gateway status", "Show gateway service status + probe."], - [ - "clawdbot service gateway install --port 18789 --token ", - "Install the Gateway service on port 18789.", - ], - ["clawdbot service gateway restart", "Restart the Gateway service."], - ]; - - const nodeExamples: Array<[string, string]> = [ - ["clawdbot service node status", "Show node host service status."], - [ - "clawdbot service node install --host gateway.local --port 18789 --tls", - "Install the node host service with TLS.", - ], - ["clawdbot service node restart", "Restart the node host service."], - ]; - - const service = program - .command("service") - .description("Manage Gateway and node host services (launchd/systemd/schtasks)") - .addHelpText( - "after", - () => - `\n${theme.heading("Examples:")}\n${formatHelpExampleGroup( - "Gateway:", - gatewayExamples, - )}\n\n${formatHelpExampleGroup("Node:", nodeExamples)}\n\n${theme.muted( - "Docs:", - )} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`, - ); - - const gateway = service.command("gateway").description("Manage the Gateway service"); - - gateway - .command("status") - .description("Show gateway service status + probe the Gateway") - .option("--url ", "Gateway WebSocket URL (defaults to config/remote/local)") - .option("--token ", "Gateway token (if required)") - .option("--password ", "Gateway password (password auth)") - .option("--timeout ", "Timeout in ms", "10000") - .option("--no-probe", "Skip RPC probe") - .option("--deep", "Scan system-level services", false) - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runDaemonStatus({ - rpc: opts, - probe: Boolean(opts.probe), - deep: Boolean(opts.deep), - json: Boolean(opts.json), - }); - }); - - gateway - .command("install") - .description("Install the Gateway service (launchd/systemd/schtasks)") - .option("--port ", "Gateway port") - .option("--runtime ", "Service runtime (node|bun). Default: node") - .option("--token ", "Gateway token (token auth)") - .option("--force", "Reinstall/overwrite if already installed", false) - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runDaemonInstall(opts); - }); - - gateway - .command("uninstall") - .description("Uninstall the Gateway service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runDaemonUninstall(opts); - }); - - gateway - .command("start") - .description("Start the Gateway service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runDaemonStart(opts); - }); - - gateway - .command("stop") - .description("Stop the Gateway service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runDaemonStop(opts); - }); - - gateway - .command("restart") - .description("Restart the Gateway service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runDaemonRestart(opts); - }); - - const node = service.command("node").description("Manage the node host service"); - - node - .command("status") - .description("Show node host service status") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonStatus(opts); - }); - - node - .command("install") - .description("Install the node host service (launchd/systemd/schtasks)") - .option("--host ", "Gateway host") - .option("--port ", "Gateway port") - .option("--tls", "Use TLS for the Gateway connection", false) - .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") - .option("--node-id ", "Override node id (clears pairing token)") - .option("--display-name ", "Override node display name") - .option("--runtime ", "Service runtime (node|bun). Default: node") - .option("--force", "Reinstall/overwrite if already installed", false) - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonInstall(opts); - }); - - node - .command("uninstall") - .description("Uninstall the node host service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonUninstall(opts); - }); - - node - .command("start") - .description("Start the node host service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonStart(opts); - }); - - node - .command("stop") - .description("Stop the node host service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonStop(opts); - }); - - node - .command("restart") - .description("Restart the node host service (launchd/systemd/schtasks)") - .option("--json", "Output JSON", false) - .action(async (opts) => { - await runNodeDaemonRestart(opts); - }); - - // Build default deps (parity with daemon CLI). - void createDefaultDeps(); -} From 39e24c9937552cc68a77973b77792279d217214e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 16:48:31 +0000 Subject: [PATCH 017/171] docs: update node CLI references --- CHANGELOG.md | 1 + docs/cli/daemon.md | 2 +- docs/cli/index.md | 35 ++++++---------------- docs/cli/node.md | 24 +++++----------- docs/cli/service.md | 51 --------------------------------- docs/cli/status.md | 2 +- docs/gateway/configuration.md | 2 +- docs/gateway/troubleshooting.md | 2 +- docs/nodes/index.md | 10 +++---- docs/platforms/mac/xpc.md | 4 +-- docs/platforms/macos.md | 2 +- docs/refactor/exec-host.md | 2 +- docs/refactor/strict-config.md | 2 +- docs/tools/exec-approvals.md | 2 +- docs/tools/index.md | 2 +- 15 files changed, 32 insertions(+), 111 deletions(-) delete mode 100644 docs/cli/service.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a9585be95..46f4ac09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot - CLI: exec approvals mutations render tables instead of raw JSON. - Exec approvals: support wildcard agent allowlists (`*`) across all agents. - Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. +- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. ### Fixes - Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 71c43d1f8..d1e8753ed 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -9,7 +9,7 @@ read_when: Manage the Gateway daemon (background service). -Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains +Note: `clawdbot daemon …` is the preferred surface for Gateway service management; `daemon` remains as a legacy alias for compatibility. Related: diff --git a/docs/cli/index.md b/docs/cli/index.md index d12b5e9f8..fdf730e2b 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -29,7 +29,6 @@ This page describes the current CLI behavior. If commands change, update this do - [`sessions`](/cli/sessions) - [`gateway`](/cli/gateway) - [`daemon`](/cli/daemon) -- [`service`](/cli/service) - [`logs`](/cli/logs) - [`models`](/cli/models) - [`memory`](/cli/memory) @@ -146,21 +145,6 @@ clawdbot [--dev] [--profile ] start stop restart - service - gateway - status - install - uninstall - start - stop - restart - node - status - install - uninstall - start - stop - restart logs models list @@ -544,7 +528,7 @@ Options: - `--debug` (alias for `--verbose`) Notes: -- Overview includes Gateway + Node service status when available. +- Overview includes Gateway + node host service status when available. ### Usage tracking Clawdbot can surface provider usage/quota when OAuth/API creds are available. @@ -806,16 +790,13 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`. [`clawdbot node`](/cli/node). Subcommands: -- `node start --host --port 18790` -- `node service status` -- `node service install [--host ] [--port ] [--tls] [--tls-fingerprint ] [--node-id ] [--display-name ] [--runtime ] [--force]` -- `node service uninstall` -- `node service start` -- `node service stop` -- `node service restart` - -Legacy alias: -- `node daemon …` (same as `node service …`) +- `node run --host --port 18790` +- `node status` +- `node install [--host ] [--port ] [--tls] [--tls-fingerprint ] [--node-id ] [--display-name ] [--runtime ] [--force]` +- `node uninstall` +- `node run` +- `node stop` +- `node restart` ## Nodes diff --git a/docs/cli/node.md b/docs/cli/node.md index c6a070376..ee9893f87 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -23,10 +23,10 @@ Common use cases: Execution is still guarded by **exec approvals** and per‑agent allowlists on the node host, so you can keep command access scoped and explicit. -## Start (foreground) +## Run (foreground) ```bash -clawdbot node start --host --port 18790 +clawdbot node run --host --port 18790 ``` Options: @@ -42,9 +42,7 @@ Options: Install a headless node host as a user service. ```bash -clawdbot node service install --host --port 18790 -# or -clawdbot service node install --host --port 18790 +clawdbot node install --host --port 18790 ``` Options: @@ -61,18 +59,10 @@ Manage the service: ```bash clawdbot node status -clawdbot service node status -clawdbot node service status -clawdbot node service start -clawdbot node service stop -clawdbot node service restart -clawdbot node service uninstall -``` - -Legacy alias: - -```bash -clawdbot node daemon status +clawdbot node run +clawdbot node stop +clawdbot node restart +clawdbot node uninstall ``` ## Pairing diff --git a/docs/cli/service.md b/docs/cli/service.md deleted file mode 100644 index a3cf536a2..000000000 --- a/docs/cli/service.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -summary: "CLI reference for `clawdbot service` (manage gateway + node services)" -read_when: - - You want to manage Gateway or node services cross-platform - - You want a single surface for start/stop/install/uninstall ---- - -# `clawdbot service` - -Manage the **Gateway** service and **node host** services. - -Related: -- Gateway daemon (legacy alias): [Daemon](/cli/daemon) -- Node host: [Node](/cli/node) - -## Gateway service - -```bash -clawdbot service gateway status -clawdbot service gateway install --port 18789 -clawdbot service gateway start -clawdbot service gateway stop -clawdbot service gateway restart -clawdbot service gateway uninstall -``` - -Notes: -- `service gateway status` supports `--json` and `--deep` for system checks. -- `service gateway install` supports `--runtime node|bun` and `--token`. - -## Node host service - -```bash -clawdbot service node status -clawdbot service node install --host --port 18790 -clawdbot service node start -clawdbot service node stop -clawdbot service node restart -clawdbot service node uninstall -``` - -Notes: -- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`, - and TLS options (`--tls`, `--tls-fingerprint`). - -## Aliases - -- `clawdbot daemon …` → `clawdbot service gateway …` -- `clawdbot node service …` → `clawdbot service node …` -- `clawdbot node status` → `clawdbot service node status` -- `clawdbot node daemon …` → `clawdbot service node …` (legacy) diff --git a/docs/cli/status.md b/docs/cli/status.md index a66dd958a..f2131cbb8 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -19,6 +19,6 @@ clawdbot status --usage Notes: - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - Output includes per-agent session stores when multiple agents are configured. -- Overview includes Gateway + Node service install/runtime status when available. +- Overview includes Gateway + node host service install/runtime status when available. - Overview includes update channel + git SHA (for source checkouts). - Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 8ebcef47a..acf4ee219 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t When validation fails: - The Gateway does not boot. -- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`). +- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot daemon`, `clawdbot help`). - Run `clawdbot doctor` to see the exact issues. - Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 3065d5754..ccd9c9ea4 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -354,7 +354,7 @@ clawdbot doctor --fix Notes: - `clawdbot doctor` reports every invalid entry. - `clawdbot doctor --fix` applies migrations/repairs and rewrites the config. -- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid. +- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot daemon` still run even if the config is invalid. ### “All models failed” — what should I check first? diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 711049e6b..9096cb7d9 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -50,14 +50,14 @@ forwards `exec` calls to the **node host** when `host=node` is selected. On the node machine: ```bash -clawdbot node start --host --port 18789 --display-name "Build Node" +clawdbot node run --host --port 18789 --display-name "Build Node" ``` ### Start a node host (service) ```bash -clawdbot node service install --host --port 18789 --display-name "Build Node" -clawdbot node service start +clawdbot node install --host --port 18789 --display-name "Build Node" +clawdbot node start ``` ### Pair + name @@ -71,7 +71,7 @@ clawdbot nodes list ``` Naming options: -- `--display-name` on `clawdbot node start/service install` (persists in `~/.clawdbot/node.json` on the node). +- `--display-name` on `clawdbot node run` / `clawdbot node install` (persists in `~/.clawdbot/node.json` on the node). - `clawdbot nodes rename --node --name "Build Node"` (gateway override). ### Allowlist the commands @@ -281,7 +281,7 @@ or for running a minimal node alongside a server. Start it: ```bash -clawdbot node start --host --port 18790 +clawdbot node run --host --port 18790 ``` Notes: diff --git a/docs/platforms/mac/xpc.md b/docs/platforms/mac/xpc.md index 5aa22b156..4beaf6fa4 100644 --- a/docs/platforms/mac/xpc.md +++ b/docs/platforms/mac/xpc.md @@ -5,7 +5,7 @@ read_when: --- # Clawdbot macOS IPC architecture -**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge. +**Current model:** a local Unix socket connects the **node host service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge. ## Goals - Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript). @@ -18,7 +18,7 @@ read_when: - Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`). ### Node service + app IPC -- A headless node service connects to the Gateway bridge. +- A headless node host service connects to the Gateway bridge. - `system.run` requests are forwarded to the macOS app over a local Unix socket. - The app performs the exec in UI context, prompts if needed, and returns output. diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 561e09189..67b7c84cb 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -57,7 +57,7 @@ The macOS app presents itself as a node. Common commands: The node reports a `permissions` map so agents can decide what’s allowed. Node service + app IPC: -- When the headless node service is running (remote mode), it connects to the Gateway WS as a node. +- When the headless node host service is running (remote mode), it connects to the Gateway WS as a node. - `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app. Diagram (SCI): diff --git a/docs/refactor/exec-host.md b/docs/refactor/exec-host.md index 9cad8c27b..c71a456ef 100644 --- a/docs/refactor/exec-host.md +++ b/docs/refactor/exec-host.md @@ -30,7 +30,7 @@ read_when: - **Node identity:** use existing `nodeId`. - **Socket auth:** Unix socket + token (cross-platform); split later if needed. - **Node host state:** `~/.clawdbot/node.json` (node id + pairing token). -- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC. +- **macOS exec host:** run `system.run` inside the macOS app; node host service forwards requests over local IPC. - **No XPC helper:** stick to Unix socket + token + peer checks. ## Key concepts diff --git a/docs/refactor/strict-config.md b/docs/refactor/strict-config.md index cfdc0ca7b..01578d2c5 100644 --- a/docs/refactor/strict-config.md +++ b/docs/refactor/strict-config.md @@ -54,7 +54,7 @@ Allowed (diagnostic-only): - `clawdbot health` - `clawdbot help` - `clawdbot status` -- `clawdbot service` +- `clawdbot daemon` Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.” diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 369a6a408..c6dccaab5 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -23,7 +23,7 @@ Exec approvals are enforced locally on the execution host: - **node host** → node runner (macOS companion app or headless node host) Planned macOS split: -- **node service** forwards `system.run` to the **macOS app** over local IPC. +- **node host service** forwards `system.run` to the **macOS app** over local IPC. - **macOS app** enforces approvals + executes the command in UI context. ## Settings and storage diff --git a/docs/tools/index.md b/docs/tools/index.md index 541195213..ccb0c87c6 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -181,7 +181,7 @@ Notes: - If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. - `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`. - `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). -- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`). +- `host=node` can target a macOS companion app or a headless node host (`clawdbot node run`). - gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals). ### `process` From cbb987247869aad1d9ac6bab785432c9f6a1b1f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 16:56:20 +0000 Subject: [PATCH 018/171] docs: add FAQ entry for tool_use input error --- docs/start/faq.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/start/faq.md b/docs/start/faq.md index f62e0315f..0158f405e 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -73,6 +73,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Do sessions reset automatically if I never send `/new`?](#do-sessions-reset-automatically-if-i-never-send-new) - [How do I completely reset Clawdbot but keep it installed?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed) - [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact) + - [Why am I seeing “LLM request rejected: messages.N.content.X.tool_use.input: Field required”?](#why-am-i-seeing-llm-request-rejected-messagesncontentxtool_useinput-field-required) - [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes) - [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group) - [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group) @@ -951,6 +952,14 @@ If it keeps happening: Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session). +### Why am I seeing “LLM request rejected: messages.N.content.X.tool_use.input: Field required”? + +This is a provider validation error: the model emitted a `tool_use` block without the required +`input`. It usually means the session history is stale or corrupted (often after long threads +or a tool/schema change). + +Fix: start a fresh session with `/new` (standalone message). + ### Why am I getting heartbeat messages every 30 minutes? Heartbeats run every **30m** by default. Tune or disable them: From cd25d69b4d54c09c36ef9ce1022bb3abccd660cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:05:36 +0000 Subject: [PATCH 019/171] fix: harden bluebubbles short ids and fetch wrapper (#1369) (thanks @tyler6204) --- CHANGELOG.md | 2 + docs/gateway/configuration.md | 3 + extensions/bluebubbles/src/actions.test.ts | 104 +++++++++++++++++++++ extensions/bluebubbles/src/actions.ts | 8 +- extensions/bluebubbles/src/channel.ts | 4 +- extensions/bluebubbles/src/media-send.ts | 2 +- extensions/bluebubbles/src/monitor.test.ts | 6 ++ extensions/bluebubbles/src/monitor.ts | 12 ++- src/cli/node-cli/register.ts | 1 - src/infra/fetch.ts | 5 +- 10 files changed, 136 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f4ac09b..7474dfd2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Docs: https://docs.clawd.bot - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. - Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. - Model picker: list the full catalog when no model allowlist is configured. +- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1369) Thanks @tyler6204. +- Infra: preserve fetch helper methods when wrapping abort signals. (#1369) ## 2026.1.20 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index acf4ee219..1ab4c4bb0 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -3022,6 +3022,9 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m | `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) | | `{{To}}` | Destination identifier | | `{{MessageSid}}` | Channel message id (when available) | +| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened | +| `{{ReplyToId}}` | Reply-to message id (when available) | +| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened | | `{{SessionId}}` | Current session UUID | | `{{IsNewSession}}` | `"true"` when a new session was created | | `{{MediaUrl}}` | Inbound media pseudo-URL (if present) | diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 21f1c9c9d..157776b1c 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -38,6 +38,10 @@ vi.mock("./attachments.js", () => ({ sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }), })); +vi.mock("./monitor.js", () => ({ + resolveBlueBubblesMessageId: vi.fn((id: string) => id), +})); + describe("bluebubblesMessageActions", () => { beforeEach(() => { vi.clearAllMocks(); @@ -358,6 +362,106 @@ describe("bluebubblesMessageActions", () => { ); }); + it("uses toolContext currentChannelId when no explicit target is provided", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + const { resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "👍", + messageId: "msg-456", + }, + cfg, + accountId: null, + toolContext: { + currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111", + }, + }); + + expect(resolveChatGuidForTarget).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" }, + }), + ); + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;-;+15550001111", + }), + ); + }); + + it("resolves short messageId before reacting", async () => { + const { resolveBlueBubblesMessageId } = await import("./monitor.js"); + const { sendBlueBubblesReaction } = await import("./reactions.js"); + vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "1", + chatGuid: "iMessage;-;+15551234567", + }, + cfg, + accountId: null, + }); + + expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true }); + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageGuid: "resolved-uuid", + }), + ); + }); + + it("propagates short-id errors from the resolver", async () => { + const { resolveBlueBubblesMessageId } = await import("./monitor.js"); + vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => { + throw new Error("short id expired"); + }); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "999", + chatGuid: "iMessage;-;+15551234567", + }, + cfg, + accountId: null, + }), + ).rejects.toThrow("short id expired"); + }); + it("accepts message param for edit action", async () => { const { editBlueBubblesMessage } = await import("./chat.js"); diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 3630f91fa..8097add2c 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -143,7 +143,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId); + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const resolvedChatGuid = await resolveChatGuid(); @@ -183,7 +183,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId); + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); @@ -206,7 +206,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId); + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); await unsendBlueBubblesMessage(messageId, { @@ -233,7 +233,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId); + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const result = await sendMessageBlueBubbles(to, text, { diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index f316b499f..5fcb75794 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -240,7 +240,9 @@ export const bluebubblesPlugin: ChannelPlugin = { sendText: async ({ cfg, to, text, accountId, replyToId }) => { const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; // Resolve short ID (e.g., "5") to full UUID - const replyToMessageGuid = rawReplyToId ? resolveBlueBubblesMessageId(rawReplyToId) : ""; + const replyToMessageGuid = rawReplyToId + ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) + : ""; const result = await sendMessageBlueBubbles(to, text, { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined, diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts index 5fc9895e3..5fc2225cc 100644 --- a/extensions/bluebubbles/src/media-send.ts +++ b/extensions/bluebubbles/src/media-send.ts @@ -137,7 +137,7 @@ export async function sendBlueBubblesMedia(params: { // Resolve short ID (e.g., "5") to full UUID const replyToMessageGuid = replyToId?.trim() - ? resolveBlueBubblesMessageId(replyToId.trim()) + ? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true }) : undefined; const attachmentResult = await sendBlueBubblesAttachment({ diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 2806bf82f..ee9b15084 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -1860,6 +1860,12 @@ describe("BlueBubbles webhook monitor", () => { it("returns short ID unchanged when numeric but not in cache", () => { expect(resolveBlueBubblesMessageId("999")).toBe("999"); }); + + it("throws when numeric short ID is missing and requireKnownShortId is set", () => { + expect(() => + resolveBlueBubblesMessageId("999", { requireKnownShortId: true }), + ).toThrow(/short message id/i); + }); }); describe("fromMe messages", () => { diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 3e89f88fa..f55383068 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -119,7 +119,10 @@ function rememberBlueBubblesReplyCache( * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID. * Returns the input unchanged if it's already a UUID or not found in the mapping. */ -export function resolveBlueBubblesMessageId(shortOrUuid: string): string { +export function resolveBlueBubblesMessageId( + shortOrUuid: string, + opts?: { requireKnownShortId?: boolean }, +): string { const trimmed = shortOrUuid.trim(); if (!trimmed) return trimmed; @@ -127,6 +130,11 @@ export function resolveBlueBubblesMessageId(shortOrUuid: string): string { if (/^\d+$/.test(trimmed)) { const uuid = blueBubblesShortIdToUuid.get(trimmed); if (uuid) return uuid; + if (opts?.requireKnownShortId) { + throw new Error( + `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`, + ); + } } // Return as-is (either already a UUID or not found) @@ -1646,7 +1654,7 @@ async function processMessage( const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; // Resolve short ID (e.g., "5") to full UUID const replyToMessageGuid = rawReplyToId - ? resolveBlueBubblesMessageId(rawReplyToId) + ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) : ""; const mediaList = payload.mediaUrls?.length ? payload.mediaUrls diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index 9a7412111..8712c6a44 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -6,7 +6,6 @@ import { runNodeHost } from "../../node-host/runner.js"; import { runNodeDaemonInstall, runNodeDaemonRestart, - runNodeDaemonStart, runNodeDaemonStatus, runNodeDaemonStop, runNodeDaemonUninstall, diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts index 5cd0d94e6..6a472253b 100644 --- a/src/infra/fetch.ts +++ b/src/infra/fetch.ts @@ -1,5 +1,5 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch { - return (input: RequestInfo | URL, init?: RequestInit) => { + const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => { const signal = init?.signal; if (!signal) return fetchImpl(input, init); if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) { @@ -25,5 +25,6 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch }); } return response; - }; + }) as typeof fetch; + return Object.assign(wrapped, fetchImpl); } From d0e8faea97799901cf13df240ac0c64558601703 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:09:51 +0000 Subject: [PATCH 020/171] docs: update changelog for bluebubbles follow-up (#1387) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7474dfd2f..234797386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,8 @@ Docs: https://docs.clawd.bot - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. - Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. - Model picker: list the full catalog when no model allowlist is configured. -- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1369) Thanks @tyler6204. -- Infra: preserve fetch helper methods when wrapping abort signals. (#1369) +- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204. +- Infra: preserve fetch helper methods when wrapping abort signals. (#1387) ## 2026.1.20 From 43afad9f51b3e714914cf953374d7e7ef2568156 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 16:54:54 +0000 Subject: [PATCH 021/171] fix: start instant typing at run start --- CHANGELOG.md | 1 + src/auto-reply/reply/agent-runner.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 234797386..056498694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.clawd.bot - Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. - macOS: exec approvals now respect wildcard agent allowlists (`*`). - UI: remove the chat stop button and keep the composer aligned to the bottom edge. +- Typing: start instant typing indicators at run start so DMs and mentions show immediately. - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. - Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. - Model picker: list the full catalog when no model allowlist is configured. diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 1da6d3957..8b3f39851 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -196,6 +196,8 @@ export async function runReplyAgent(params: { return undefined; } + await typingSignals.signalRunStart(); + activeSessionEntry = await runMemoryFlushIfNeeded({ cfg, followupRun, From d2a0e416ea4eb6f649fa5d7b2d14bdcab89ca519 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:03:00 +0000 Subject: [PATCH 022/171] test: align NO_REPLY typing expectations --- ...eplyagent-typing-heartbeat.signals-typing-normal-runs.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-normal-runs.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-normal-runs.test.ts index 4040c6dc5..31d3249bb 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-normal-runs.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-normal-runs.test.ts @@ -172,6 +172,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const { run, typing } = createMinimalRun({ opts: { isHeartbeat: false, onPartialReply }, + typingMode: "message", }); await run(); From 56799a21be721f7582f5b8c8eca14dfc8bb9de6d Mon Sep 17 00:00:00 2001 From: Ameno Osman Date: Wed, 21 Jan 2026 08:34:04 -0800 Subject: [PATCH 023/171] macOS: allow SSH agents without identity file --- apps/macos/Sources/Clawdbot/CommandResolver.swift | 9 ++++++--- apps/macos/Sources/Clawdbot/RemotePortTunnel.swift | 8 ++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Clawdbot/CommandResolver.swift index 117930710..7661c48f1 100644 --- a/apps/macos/Sources/Clawdbot/CommandResolver.swift +++ b/apps/macos/Sources/Clawdbot/CommandResolver.swift @@ -284,13 +284,16 @@ enum CommandResolver { var args: [String] = [ "-o", "BatchMode=yes", - "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "UpdateHostKeys=yes", ] if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } - if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["-i", settings.identity]) + let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) + if !identity.isEmpty { + // Only use IdentitiesOnly when an explicit identity file is provided. + // This allows 1Password SSH agent and other SSH agents to provide keys. + args.append(contentsOf: ["-o", "IdentitiesOnly=yes"]) + args.append(contentsOf: ["-i", identity]) } let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host args.append(userHost) diff --git a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift index ccbeb6e8d..8eaee1c05 100644 --- a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift +++ b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift @@ -72,7 +72,6 @@ final class RemotePortTunnel { } var args: [String] = [ "-o", "BatchMode=yes", - "-o", "IdentitiesOnly=yes", "-o", "ExitOnForwardFailure=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "UpdateHostKeys=yes", @@ -84,7 +83,12 @@ final class RemotePortTunnel { ] if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) - if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) } + if !identity.isEmpty { + // Only use IdentitiesOnly when an explicit identity file is provided. + // This allows 1Password SSH agent and other SSH agents to provide keys. + args.append(contentsOf: ["-o", "IdentitiesOnly=yes"]) + args.append(contentsOf: ["-i", identity]) + } let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host args.append(userHost) From 8aca606a6f9199b360e6d88d4274343f77a54e26 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:19:38 +0000 Subject: [PATCH 024/171] docs: clarify bluebubbles message ids --- docs/channels/bluebubbles.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index b566fc795..2336f3609 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -149,6 +149,19 @@ Available actions: - **leaveGroup**: Leave a group chat (`chatGuid`) - **sendAttachment**: Send media/files (`to`, `buffer`, `filename`) +### Message IDs (short vs full) +Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens. +- `MessageSid` / `ReplyToId` can be short IDs. +- `MessageSidFull` / `ReplyToIdFull` contain the provider full IDs. +- Short IDs are in-memory; they can expire on restart or cache eviction. +- Actions accept short or full `messageId`, but short IDs will error if no longer available. + +Use full IDs for durable automations and storage: +- Templates: `{{MessageSidFull}}`, `{{ReplyToIdFull}}` +- Context: `MessageSidFull` / `ReplyToIdFull` in inbound payloads + +See [Configuration](/gateway/configuration) for template variables. + ## Block streaming Control whether responses are sent as a single message or streamed in blocks: ```json5 From 4e1806947d2a181d6a43828611ac69e3a6a01d18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:29:39 +0000 Subject: [PATCH 025/171] fix: normalize abort signals for fetch --- docs/channels/telegram.md | 4 ++++ src/discord/api.ts | 8 ++++++- src/discord/probe.ts | 7 +++++- src/infra/fetch.ts | 6 ++++++ src/infra/provider-usage.load.ts | 6 +++++- src/signal/client.ts | 14 ++++++++++-- src/telegram/fetch.test.ts | 37 ++++++++++++++++++++++++++++++++ src/telegram/fetch.ts | 8 +++---- 8 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 src/telegram/fetch.test.ts diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 3c894303e..da29b3c90 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -484,6 +484,10 @@ The agent sees reactions as **system notifications** in the conversation history - Make sure your Telegram user ID is authorized (via pairing or `channels.telegram.allowFrom`) - Commands require authorization even in groups with `groupPolicy: "open"` +**Long-polling aborts immediately on Node 22+ (often with proxies/custom fetch):** +- Node 22+ is stricter about `AbortSignal` instances; foreign signals can abort `fetch` calls right away. +- Upgrade to a Clawdbot build that normalizes abort signals, or run the gateway on Node 20 until you can upgrade. + **Bot starts, then silently stops responding (or logs `HttpError: Network request ... failed`):** - Some hosts resolve `api.telegram.org` to IPv6 first. If your server does not have working IPv6 egress, grammY can get stuck on IPv6-only requests. - Fix by enabling IPv6 egress **or** forcing IPv4 resolution for `api.telegram.org` (for example, add an `/etc/hosts` entry using the IPv4 A record, or prefer IPv4 in your OS DNS stack), then restart the gateway. diff --git a/src/discord/api.ts b/src/discord/api.ts index 6be8b4a0b..de72c8ba1 100644 --- a/src/discord/api.ts +++ b/src/discord/api.ts @@ -1,3 +1,5 @@ +import { resolveFetch } from "../infra/fetch.js"; + const DISCORD_API_BASE = "https://discord.com/api/v10"; type DiscordApiErrorPayload = { @@ -48,7 +50,11 @@ export async function fetchDiscord( token: string, fetcher: typeof fetch = fetch, ): Promise { - const res = await fetcher(`${DISCORD_API_BASE}${path}`, { + const fetchImpl = resolveFetch(fetcher); + if (!fetchImpl) { + throw new Error("fetch is not available"); + } + const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, { headers: { Authorization: `Bot ${token}` }, }); if (!res.ok) { diff --git a/src/discord/probe.ts b/src/discord/probe.ts index 21c6d1922..78175c3b9 100644 --- a/src/discord/probe.ts +++ b/src/discord/probe.ts @@ -1,3 +1,4 @@ +import { resolveFetch } from "../infra/fetch.js"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_API_BASE = "https://discord.com/api/v10"; @@ -90,10 +91,14 @@ async function fetchWithTimeout( fetcher: typeof fetch, headers?: HeadersInit, ): Promise { + const fetchImpl = resolveFetch(fetcher); + if (!fetchImpl) { + throw new Error("fetch is not available"); + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - return await fetcher(url, { signal: controller.signal, headers }); + return await fetchImpl(url, { signal: controller.signal, headers }); } finally { clearTimeout(timer); } diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts index 6a472253b..70ab6f614 100644 --- a/src/infra/fetch.ts +++ b/src/infra/fetch.ts @@ -28,3 +28,9 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch }) as typeof fetch; return Object.assign(wrapped, fetchImpl); } + +export function resolveFetch(fetchImpl?: typeof fetch): typeof fetch | undefined { + const resolved = fetchImpl ?? globalThis.fetch; + if (!resolved) return undefined; + return wrapFetchWithAbortSignal(resolved); +} diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 7fdf8de3e..676ac9920 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -19,6 +19,7 @@ import type { UsageProviderId, UsageSummary, } from "./provider-usage.types.js"; +import { resolveFetch } from "./fetch.js"; type UsageSummaryOptions = { now?: number; @@ -34,7 +35,10 @@ export async function loadProviderUsageSummary( ): Promise { const now = opts.now ?? Date.now(); const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const fetchFn = opts.fetch ?? fetch; + const fetchFn = resolveFetch(opts.fetch); + if (!fetchFn) { + throw new Error("fetch is not available"); + } const auths = await resolveProviderAuths({ providers: opts.providers ?? usageProviders, diff --git a/src/signal/client.ts b/src/signal/client.ts index 925cc0c63..5595edb5b 100644 --- a/src/signal/client.ts +++ b/src/signal/client.ts @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; +import { resolveFetch } from "../infra/fetch.js"; + export type SignalRpcOptions = { baseUrl: string; timeoutMs?: number; @@ -36,10 +38,14 @@ function normalizeBaseUrl(url: string): string { } async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number) { + const fetchImpl = resolveFetch(); + if (!fetchImpl) { + throw new Error("fetch is not available"); + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - return await fetch(url, { ...init, signal: controller.signal }); + return await fetchImpl(url, { ...init, signal: controller.signal }); } finally { clearTimeout(timer); } @@ -113,7 +119,11 @@ export async function streamSignalEvents(params: { const url = new URL(`${baseUrl}/api/v1/events`); if (params.account) url.searchParams.set("account", params.account); - const res = await fetch(url, { + const fetchImpl = resolveFetch(); + if (!fetchImpl) { + throw new Error("fetch is not available"); + } + const res = await fetchImpl(url, { method: "GET", headers: { Accept: "text/event-stream" }, signal: params.abortSignal, diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts new file mode 100644 index 000000000..f1a2353c2 --- /dev/null +++ b/src/telegram/fetch.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveTelegramFetch } from "./fetch.js"; + +describe("resolveTelegramFetch", () => { + it("wraps proxy fetch to normalize foreign abort signals", async () => { + let seenSignal: AbortSignal | undefined; + const proxyFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + seenSignal = init?.signal as AbortSignal | undefined; + return {} as Response; + }); + + const fetcher = resolveTelegramFetch(proxyFetch); + expect(fetcher).toBeTypeOf("function"); + + let abortHandler: (() => void) | null = null; + const fakeSignal = { + aborted: false, + addEventListener: (event: string, handler: () => void) => { + if (event === "abort") abortHandler = handler; + }, + removeEventListener: (event: string, handler: () => void) => { + if (event === "abort" && abortHandler === handler) abortHandler = null; + }, + } as AbortSignal; + + const promise = fetcher!("https://example.com", { signal: fakeSignal }); + expect(proxyFetch).toHaveBeenCalledOnce(); + expect(seenSignal).toBeInstanceOf(AbortSignal); + expect(seenSignal).not.toBe(fakeSignal); + + abortHandler?.(); + expect(seenSignal?.aborted).toBe(true); + + await promise; + }); +}); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 1c4a288d0..ee1c6780c 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,13 +1,13 @@ -import { wrapFetchWithAbortSignal } from "../infra/fetch.js"; +import { resolveFetch } from "../infra/fetch.js"; // Bun-only: force native fetch to avoid grammY's Node shim under Bun. export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined { - if (proxyFetch) return wrapFetchWithAbortSignal(proxyFetch); - const fetchImpl = globalThis.fetch; + if (proxyFetch) return resolveFetch(proxyFetch); const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun); if (!isBun) return undefined; + const fetchImpl = resolveFetch(); if (!fetchImpl) { throw new Error("fetch is not available; set channels.telegram.proxy in config"); } - return wrapFetchWithAbortSignal(fetchImpl); + return fetchImpl; } From fd918bf6bf6ecaf8c09c488969ec39e4402551b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:32:00 +0000 Subject: [PATCH 026/171] fix: allow SSH agent auth without identity file (#1384) (thanks @ameno-) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 056498694..be5093e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot ### Fixes - Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. - macOS: exec approvals now respect wildcard agent allowlists (`*`). +- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-. - UI: remove the chat stop button and keep the composer aligned to the bottom edge. - Typing: start instant typing indicators at run start so DMs and mentions show immediately. - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. From 6f58d508b8025d6eac4c274c5b70db1998f40294 Mon Sep 17 00:00:00 2001 From: Shadow Date: Wed, 21 Jan 2026 11:36:56 -0600 Subject: [PATCH 027/171] chore: update carbon to v0.14.0 --- package.json | 2 +- pnpm-lock.yaml | 75 ++++++++++++++++---------------------------------- 2 files changed, 25 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index 760e951bc..cdea28b6f 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "packageManager": "pnpm@10.23.0", "dependencies": { "@agentclientprotocol/sdk": "0.13.0", - "@buape/carbon": "0.0.0-beta-20260110172854", + "@buape/carbon": "0.14.0", "@clack/prompts": "^0.11.0", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0830488b..c6ab92034 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: 0.13.0 version: 0.13.0(zod@4.3.5) '@buape/carbon': - specifier: 0.0.0-beta-20260110172854 - version: 0.0.0-beta-20260110172854(hono@4.11.4) + specifier: 0.14.0 + version: 0.14.0(hono@4.11.4) '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 @@ -647,8 +647,8 @@ packages: '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} - '@buape/carbon@0.0.0-beta-20260110172854': - resolution: {integrity: sha512-hYUr5Glz+oDEQbl58r6AExqM4yObZCjB8JwXit7chn0LIkb5WDFP4Rj1taP3OP+PgwB7GHOVSbnCnBPBzUxpRw==} + '@buape/carbon@0.14.0': + resolution: {integrity: sha512-mavllPK2iVpRNRtC4C8JOUdJ1hdV0+LDelFW+pjpJaM31MBLMfIJ+f/LlYTIK5QrEcQsXOC+6lU2e0gmgjWhIQ==} '@cacheable/memory@2.0.7': resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==} @@ -666,8 +666,8 @@ packages: '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} - '@cloudflare/workers-types@4.20251205.0': - resolution: {integrity: sha512-7pup7fYkuQW5XD8RUS/vkxF9SXlrGyCXuZ4ro3uVQvca/GTeSa+8bZ8T4wbq1Aea5lmLIGSlKbhl2msME7bRBA==} + '@cloudflare/workers-types@4.20260120.0': + resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==} '@discordjs/voice@0.19.0': resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==} @@ -887,8 +887,8 @@ packages: resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==} hasBin: true - '@hono/node-server@1.19.6': - resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==} + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} peerDependencies: hono: 4.11.4 @@ -2425,8 +2425,8 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/bun@1.3.3': - resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==} + '@types/bun@1.3.6': + resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2491,9 +2491,6 @@ packages: '@types/node@20.19.30': resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} - '@types/node@24.10.9': - resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==} - '@types/node@25.0.9': resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==} @@ -2878,8 +2875,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.3.3: - resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==} + bun-types@1.3.6: + resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==} bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -3130,9 +3127,6 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} - discord-api-types@0.38.36: - resolution: {integrity: sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==} - discord-api-types@0.38.37: resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==} @@ -5229,18 +5223,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -5777,17 +5759,17 @@ snapshots: '@borewit/text-codec@0.2.1': {} - '@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.4)': + '@buape/carbon@0.14.0(hono@4.11.4)': dependencies: - '@types/node': 24.10.9 - discord-api-types: 0.38.36 + '@types/node': 25.0.9 + discord-api-types: 0.38.37 optionalDependencies: - '@cloudflare/workers-types': 4.20251205.0 + '@cloudflare/workers-types': 4.20260120.0 '@discordjs/voice': 0.19.0 - '@hono/node-server': 1.19.6(hono@4.11.4) - '@types/bun': 1.3.3 + '@hono/node-server': 1.19.9(hono@4.11.4) + '@types/bun': 1.3.6 '@types/ws': 8.18.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - '@discordjs/opus' - bufferutil @@ -5826,7 +5808,7 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@cloudflare/workers-types@4.20251205.0': + '@cloudflare/workers-types@4.20260120.0': optional: true '@discordjs/voice@0.19.0': @@ -5992,7 +5974,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@hono/node-server@1.19.6(hono@4.11.4)': + '@hono/node-server@1.19.9(hono@4.11.4)': dependencies: hono: 4.11.4 optional: true @@ -7613,9 +7595,9 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.0.9 - '@types/bun@1.3.3': + '@types/bun@1.3.6': dependencies: - bun-types: 1.3.3 + bun-types: 1.3.6 optional: true '@types/chai@5.2.3': @@ -7692,10 +7674,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.10.9': - dependencies: - undici-types: 7.16.0 - '@types/node@25.0.9': dependencies: undici-types: 7.16.0 @@ -8151,7 +8129,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.3.3: + bun-types@1.3.6: dependencies: '@types/node': 25.0.9 optional: true @@ -8398,8 +8376,6 @@ snapshots: diff@8.0.3: {} - discord-api-types@0.38.36: {} - discord-api-types@0.38.37: {} docx-preview@0.3.7: @@ -10787,9 +10763,6 @@ snapshots: wrappy@1.0.2: {} - ws@8.18.3: - optional: true - ws@8.19.0: {} y18n@5.0.8: {} From 9e22f019db0c4a7952e808a53d95f3d394d40c61 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:45:06 +0000 Subject: [PATCH 028/171] feat: fold gateway service commands into gateway --- src/agents/system-prompt.test.ts | 2 +- src/agents/system-prompt.ts | 10 +-- src/cli/daemon-cli/install.ts | 4 +- src/cli/daemon-cli/lifecycle.ts | 4 +- src/cli/daemon-cli/register.ts | 6 +- src/cli/daemon-cli/shared.ts | 4 +- src/cli/daemon-cli/status.print.ts | 12 +-- src/cli/daemon-cli/status.ts | 2 +- src/cli/gateway-cli.coverage.test.ts | 6 +- src/cli/gateway-cli/register.ts | 78 +++++++++++++++++-- src/cli/gateway-cli/run.ts | 2 +- src/cli/gateway-cli/shared.ts | 8 +- src/cli/program/config-guard.ts | 22 +++++- src/cli/program/register.subclis.ts | 8 -- src/cli/update-cli.ts | 16 ++-- src/commands/configure.daemon.ts | 26 +++---- src/commands/configure.wizard.ts | 4 +- src/commands/daemon-install-helpers.test.ts | 2 +- src/commands/daemon-install-helpers.ts | 4 +- src/commands/doctor-format.ts | 4 +- src/commands/doctor-gateway-daemon-flow.ts | 14 ++-- src/commands/doctor-gateway-services.ts | 4 +- .../local/daemon-install.ts | 4 +- src/commands/status.command.ts | 2 +- src/commands/uninstall.ts | 2 +- src/daemon/systemd-hints.ts | 2 +- src/infra/ports-format.ts | 2 +- 27 files changed, 166 insertions(+), 88 deletions(-) diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index c9b225830..fce27677a 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -66,7 +66,7 @@ describe("buildAgentSystemPrompt", () => { }); expect(prompt).toContain("## Clawdbot CLI Quick Reference"); - expect(prompt).toContain("clawdbot daemon restart"); + expect(prompt).toContain("clawdbot gateway restart"); expect(prompt).toContain("Do not invent commands"); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index cb63eb7ec..889ae84f4 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -365,11 +365,11 @@ export function buildAgentSystemPrompt(params: { "## Clawdbot CLI Quick Reference", "Clawdbot is controlled via subcommands. Do not invent commands.", "To manage the Gateway daemon service (start/stop/restart):", - "- clawdbot daemon status", - "- clawdbot daemon start", - "- clawdbot daemon stop", - "- clawdbot daemon restart", - "If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.", + "- clawdbot gateway status", + "- clawdbot gateway start", + "- clawdbot gateway stop", + "- clawdbot gateway restart", + "If unsure, ask the user to run `clawdbot help` (or `clawdbot gateway --help`) and paste the output.", "", ...skillsSection, ...memorySection, diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index cf852fd38..6f1998d5d 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -43,7 +43,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { }; if (resolveIsNixMode(process.env)) { - fail("Nix mode detected; daemon install is disabled."); + fail("Nix mode detected; service install is disabled."); return; } @@ -84,7 +84,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { if (!json) { defaultRuntime.log(`Gateway service already ${service.loadedText}.`); defaultRuntime.log( - `Reinstall with: ${formatCliCommand("clawdbot daemon install --force")}`, + `Reinstall with: ${formatCliCommand("clawdbot gateway install --force")}`, ); } return; diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 0ad05f9f7..d5008c2d0 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -33,7 +33,7 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) { }; if (resolveIsNixMode(process.env)) { - fail("Nix mode detected; daemon uninstall is disabled."); + fail("Nix mode detected; service uninstall is disabled."); return; } @@ -200,7 +200,7 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) { } /** - * Restart the gateway daemon service. + * Restart the gateway service service. * @returns `true` if restart succeeded, `false` if the service was not loaded. * Throws/exits on check or restart failures. */ diff --git a/src/cli/daemon-cli/register.ts b/src/cli/daemon-cli/register.ts index ed37dc841..92b47690d 100644 --- a/src/cli/daemon-cli/register.ts +++ b/src/cli/daemon-cli/register.ts @@ -14,16 +14,16 @@ import { export function registerDaemonCli(program: Command) { const daemon = program .command("daemon") - .description("Manage the Gateway daemon service (launchd/systemd/schtasks)") + .description("Manage the Gateway service (launchd/systemd/schtasks)") .addHelpText( "after", () => - `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/daemon", "docs.clawd.bot/cli/daemon")}\n`, + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/gateway", "docs.clawd.bot/cli/gateway")}\n`, ); daemon .command("status") - .description("Show daemon install status + probe the Gateway") + .description("Show service install status + probe the Gateway") .option("--url ", "Gateway WebSocket URL (defaults to config/remote/local)") .option("--token ", "Gateway token (if required)") .option("--password ", "Gateway password (password auth)") diff --git a/src/cli/daemon-cli/shared.ts b/src/cli/daemon-cli/shared.ts index 807cf687a..7be597ac5 100644 --- a/src/cli/daemon-cli/shared.ts +++ b/src/cli/daemon-cli/shared.ts @@ -123,7 +123,7 @@ export function renderRuntimeHints( } })(); if (runtime.missingUnit) { - hints.push(`Service not installed. Run: ${formatCliCommand("clawdbot daemon install", env)}`); + hints.push(`Service not installed. Run: ${formatCliCommand("clawdbot gateway install", env)}`); if (fileLog) hints.push(`File logs: ${fileLog}`); return hints; } @@ -146,7 +146,7 @@ export function renderRuntimeHints( export function renderGatewayServiceStartHints(env: NodeJS.ProcessEnv = process.env): string[] { const base = [ - formatCliCommand("clawdbot daemon install", env), + formatCliCommand("clawdbot gateway install", env), formatCliCommand("clawdbot gateway", env), ]; const profile = env.CLAWDBOT_PROFILE; diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index b4e879666..eb7f9f500 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -60,7 +60,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) } const daemonEnvLines = safeDaemonEnv(service.command?.environment); if (daemonEnvLines.length > 0) { - defaultRuntime.log(`${label("Daemon env:")} ${daemonEnvLines.join(" ")}`); + defaultRuntime.log(`${label("Service env:")} ${daemonEnvLines.join(" ")}`); } spacer(); @@ -89,11 +89,11 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) } if (status.config.daemon) { const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`; - defaultRuntime.log(`${label("Config (daemon):")} ${infoText(daemonCfg)}`); + defaultRuntime.log(`${label("Config (service):")} ${infoText(daemonCfg)}`); if (!status.config.daemon.valid && status.config.daemon.issues?.length) { for (const issue of status.config.daemon.issues.slice(0, 5)) { defaultRuntime.error( - `${errorText("Daemon config issue:")} ${issue.path || ""}: ${issue.message}`, + `${errorText("Service config issue:")} ${issue.path || ""}: ${issue.message}`, ); } } @@ -101,12 +101,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) if (status.config.mismatch) { defaultRuntime.error( errorText( - "Root cause: CLI and daemon are using different config paths (likely a profile/state-dir mismatch).", + "Root cause: CLI and service are using different config paths (likely a profile/state-dir mismatch).", ), ); defaultRuntime.error( errorText( - `Fix: rerun \`${formatCliCommand("clawdbot daemon install --force")}\` from the same --profile / CLAWDBOT_STATE_DIR you expect.`, + `Fix: rerun \`${formatCliCommand("clawdbot gateway install --force")}\` from the same --profile / CLAWDBOT_STATE_DIR you expect.`, ), ); } @@ -209,7 +209,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) ), ); defaultRuntime.error( - errorText(`Then reinstall: ${formatCliCommand("clawdbot daemon install")}`), + errorText(`Then reinstall: ${formatCliCommand("clawdbot gateway install")}`), ); spacer(); } diff --git a/src/cli/daemon-cli/status.ts b/src/cli/daemon-cli/status.ts index 630369571..2af5a1977 100644 --- a/src/cli/daemon-cli/status.ts +++ b/src/cli/daemon-cli/status.ts @@ -14,7 +14,7 @@ export async function runDaemonStatus(opts: DaemonStatusOptions) { printDaemonStatus(status, { json: Boolean(opts.json) }); } catch (err) { const rich = isRich(); - defaultRuntime.error(colorize(rich, theme.error, `Daemon status failed: ${String(err)}`)); + defaultRuntime.error(colorize(rich, theme.error, `Gateway status failed: ${String(err)}`)); defaultRuntime.exit(1); } } diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index d6ea4a199..96437d566 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -118,7 +118,7 @@ describe("gateway-cli coverage", () => { expect(runtimeLogs.join("\n")).toContain('"ok": true'); }, 30_000); - it("registers gateway status and routes to gatewayStatusCommand", async () => { + it("registers gateway probe and routes to gatewayStatusCommand", async () => { runtimeLogs.length = 0; runtimeErrors.length = 0; gatewayStatusCommand.mockClear(); @@ -128,7 +128,7 @@ describe("gateway-cli coverage", () => { program.exitOverride(); registerGatewayCli(program); - await program.parseAsync(["gateway", "status", "--json"], { from: "user" }); + await program.parseAsync(["gateway", "probe", "--json"], { from: "user" }); expect(gatewayStatusCommand).toHaveBeenCalledTimes(1); }, 30_000); @@ -311,7 +311,7 @@ describe("gateway-cli coverage", () => { expect(startGatewayServer).toHaveBeenCalled(); expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:"); - expect(runtimeErrors.join("\n")).toContain("clawdbot daemon stop"); + expect(runtimeErrors.join("\n")).toContain("clawdbot gateway stop"); }); it("uses env/config port when --port is omitted", async () => { diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 5cfe71cd8..1f094699e 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -8,6 +8,14 @@ import { formatDocsLink } from "../../terminal/links.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; import { withProgress } from "../progress.js"; import { runCommandWithRuntime } from "../cli-utils.js"; +import { + runDaemonInstall, + runDaemonRestart, + runDaemonStart, + runDaemonStatus, + runDaemonStop, + runDaemonUninstall, +} from "../daemon-cli.js"; import { callGatewayCli, gatewayCallOpts } from "./call.js"; import type { GatewayDiscoverOpts } from "./discover.js"; import { @@ -62,13 +70,73 @@ export function registerGatewayCli(program: Command) { ), ); - // Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias. addGatewayRunCommand( - program - .command("gateway-daemon", { hidden: true }) - .description("Run the WebSocket Gateway as a long-lived daemon"), + gateway.command("run").description("Run the WebSocket Gateway (foreground)"), ); + gateway + .command("status") + .description("Show gateway service status + probe the Gateway") + .option("--url ", "Gateway WebSocket URL (defaults to config/remote/local)") + .option("--token ", "Gateway token (if required)") + .option("--password ", "Gateway password (password auth)") + .option("--timeout ", "Timeout in ms", "10000") + .option("--no-probe", "Skip RPC probe") + .option("--deep", "Scan system-level services", false) + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonStatus({ + rpc: opts, + probe: Boolean(opts.probe), + deep: Boolean(opts.deep), + json: Boolean(opts.json), + }); + }); + + gateway + .command("install") + .description("Install the Gateway service (launchd/systemd/schtasks)") + .option("--port ", "Gateway port") + .option("--runtime ", "Daemon runtime (node|bun). Default: node") + .option("--token ", "Gateway token (token auth)") + .option("--force", "Reinstall/overwrite if already installed", false) + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonInstall(opts); + }); + + gateway + .command("uninstall") + .description("Uninstall the Gateway service (launchd/systemd/schtasks)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonUninstall(opts); + }); + + gateway + .command("start") + .description("Start the Gateway service (launchd/systemd/schtasks)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonStart(opts); + }); + + gateway + .command("stop") + .description("Stop the Gateway service (launchd/systemd/schtasks)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonStop(opts); + }); + + gateway + .command("restart") + .description("Restart the Gateway service (launchd/systemd/schtasks)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonRestart(opts); + }); + gatewayCallOpts( gateway .command("call") @@ -121,7 +189,7 @@ export function registerGatewayCli(program: Command) { ); gateway - .command("status") + .command("probe") .description("Show gateway reachability + discovery + health + status summary (local + remote)") .option("--url ", "Explicit Gateway WebSocket URL (still probes localhost)") .option("--ssh ", "SSH target for remote gateway tunnel (user@host or user@host:port)") diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 211b4c2c4..e75899fb4 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -278,7 +278,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { ) { const errMessage = describeUnknownError(err); defaultRuntime.error( - `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: ${formatCliCommand("clawdbot daemon stop")}`, + `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: ${formatCliCommand("clawdbot gateway stop")}`, ); try { const diagnostics = await inspectPortUsage(port); diff --git a/src/cli/gateway-cli/shared.ts b/src/cli/gateway-cli/shared.ts index 3105a94e6..1170ac35f 100644 --- a/src/cli/gateway-cli/shared.ts +++ b/src/cli/gateway-cli/shared.ts @@ -68,21 +68,21 @@ export function renderGatewayServiceStopHints(env: NodeJS.ProcessEnv = process.e switch (process.platform) { case "darwin": return [ - `Tip: ${formatCliCommand("clawdbot daemon stop")}`, + `Tip: ${formatCliCommand("clawdbot gateway stop")}`, `Or: launchctl bootout gui/$UID/${resolveGatewayLaunchAgentLabel(profile)}`, ]; case "linux": return [ - `Tip: ${formatCliCommand("clawdbot daemon stop")}`, + `Tip: ${formatCliCommand("clawdbot gateway stop")}`, `Or: systemctl --user stop ${resolveGatewaySystemdServiceName(profile)}.service`, ]; case "win32": return [ - `Tip: ${formatCliCommand("clawdbot daemon stop")}`, + `Tip: ${formatCliCommand("clawdbot gateway stop")}`, `Or: schtasks /End /TN "${resolveGatewayWindowsTaskName(profile)}"`, ]; default: - return [`Tip: ${formatCliCommand("clawdbot daemon stop")}`]; + return [`Tip: ${formatCliCommand("clawdbot gateway stop")}`]; } } diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index f8e3576f6..eef500c59 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -4,7 +4,19 @@ import { colorize, isRich, theme } from "../../terminal/theme.js"; import type { RuntimeEnv } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; -const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status", "service"]); +const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]); +const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([ + "status", + "probe", + "health", + "discover", + "call", + "install", + "uninstall", + "start", + "stop", + "restart", +]); let didRunDoctorConfigFlow = false; function formatConfigIssues(issues: Array<{ path: string; message: string }>): string[] { @@ -25,7 +37,13 @@ export async function ensureConfigReady(params: { const snapshot = await readConfigFileSnapshot(); const commandName = params.commandPath?.[0]; - const allowInvalid = commandName ? ALLOWED_INVALID_COMMANDS.has(commandName) : false; + const subcommandName = params.commandPath?.[1]; + const allowInvalid = commandName + ? ALLOWED_INVALID_COMMANDS.has(commandName) || + (commandName === "gateway" && + subcommandName && + ALLOWED_INVALID_GATEWAY_SUBCOMMANDS.has(subcommandName)) + : false; const issues = snapshot.exists && !snapshot.valid ? formatConfigIssues(snapshot.issues) : []; const legacyIssues = snapshot.legacyIssues.length > 0 diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index b13a4c76f..bc2496b70 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -36,14 +36,6 @@ const entries: SubCliEntry[] = [ mod.registerAcpCli(program); }, }, - { - name: "daemon", - description: "Manage the gateway daemon", - register: async (program) => { - const mod = await import("../daemon-cli.js"); - mod.registerDaemonCli(program); - }, - }, { name: "gateway", description: "Gateway control", diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 02f92d01e..240f19ccb 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -785,7 +785,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { if (result.reason === "not-git-install") { defaultRuntime.log( theme.warn( - `Skipped: this Clawdbot install isn't a git checkout, and the package manager couldn't be detected. Update via your package manager, then run \`${formatCliCommand("clawdbot doctor")}\` and \`${formatCliCommand("clawdbot daemon restart")}\`.`, + `Skipped: this Clawdbot install isn't a git checkout, and the package manager couldn't be detected. Update via your package manager, then run \`${formatCliCommand("clawdbot doctor")}\` and \`${formatCliCommand("clawdbot gateway restart")}\`.`, ), ); defaultRuntime.log( @@ -877,11 +877,11 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.log(theme.warn("Skipping plugin updates: config is invalid.")); } - // Restart daemon if requested + // Restart service if requested if (opts.restart) { if (!opts.json) { defaultRuntime.log(""); - defaultRuntime.log(theme.heading("Restarting daemon...")); + defaultRuntime.log(theme.heading("Restarting service...")); } try { const { runDaemonRestart } = await import("./daemon-cli.js"); @@ -905,7 +905,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.log(theme.warn(`Daemon restart failed: ${String(err)}`)); defaultRuntime.log( theme.muted( - `You may need to restart the daemon manually: ${formatCliCommand("clawdbot daemon restart")}`, + `You may need to restart the service manually: ${formatCliCommand("clawdbot gateway restart")}`, ), ); } @@ -915,13 +915,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { if (result.mode === "npm" || result.mode === "pnpm") { defaultRuntime.log( theme.muted( - `Tip: Run \`${formatCliCommand("clawdbot doctor")}\`, then \`${formatCliCommand("clawdbot daemon restart")}\` to apply updates to a running gateway.`, + `Tip: Run \`${formatCliCommand("clawdbot doctor")}\`, then \`${formatCliCommand("clawdbot gateway restart")}\` to apply updates to a running gateway.`, ), ); } else { defaultRuntime.log( theme.muted( - `Tip: Run \`${formatCliCommand("clawdbot daemon restart")}\` to apply updates to a running gateway.`, + `Tip: Run \`${formatCliCommand("clawdbot gateway restart")}\` to apply updates to a running gateway.`, ), ); } @@ -937,7 +937,7 @@ export function registerUpdateCli(program: Command) { .command("update") .description("Update Clawdbot to the latest version") .option("--json", "Output result as JSON", false) - .option("--restart", "Restart the gateway daemon after a successful update", false) + .option("--restart", "Restart the gateway service after a successful update", false) .option("--channel ", "Persist update channel (git + npm)") .option("--tag ", "Override npm dist-tag or version for this update") .option("--timeout ", "Timeout for each update step in seconds (default: 1200)") @@ -948,7 +948,7 @@ export function registerUpdateCli(program: Command) { ["clawdbot update --channel beta", "Switch to beta channel (git + npm)"], ["clawdbot update --channel dev", "Switch to dev channel (git + npm)"], ["clawdbot update --tag beta", "One-off update to a dist-tag or version"], - ["clawdbot update --restart", "Update and restart the daemon"], + ["clawdbot update --restart", "Update and restart the service"], ["clawdbot update --json", "Output result as JSON"], ["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"], ["clawdbot --update", "Shorthand for clawdbot update"], diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index 44f6ed457..7115d49a4 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -37,14 +37,14 @@ export async function maybeInstallDaemon(params: { ); if (action === "restart") { await withProgress( - { label: "Gateway daemon", indeterminate: true, delayMs: 0 }, + { label: "Gateway service", indeterminate: true, delayMs: 0 }, async (progress) => { - progress.setLabel("Restarting Gateway daemon…"); + progress.setLabel("Restarting Gateway service…"); await service.restart({ env: process.env, stdout: process.stdout, }); - progress.setLabel("Gateway daemon restarted."); + progress.setLabel("Gateway service restarted."); }, ); shouldCheckLinger = true; @@ -53,11 +53,11 @@ export async function maybeInstallDaemon(params: { if (action === "skip") return; if (action === "reinstall") { await withProgress( - { label: "Gateway daemon", indeterminate: true, delayMs: 0 }, + { label: "Gateway service", indeterminate: true, delayMs: 0 }, async (progress) => { - progress.setLabel("Uninstalling Gateway daemon…"); + progress.setLabel("Uninstalling Gateway service…"); await service.uninstall({ env: process.env, stdout: process.stdout }); - progress.setLabel("Gateway daemon uninstalled."); + progress.setLabel("Gateway service uninstalled."); }, ); } @@ -66,12 +66,12 @@ export async function maybeInstallDaemon(params: { if (shouldInstall) { let installError: string | null = null; await withProgress( - { label: "Gateway daemon", indeterminate: true, delayMs: 0 }, + { label: "Gateway service", indeterminate: true, delayMs: 0 }, async (progress) => { if (!params.daemonRuntime) { daemonRuntime = guardCancel( await select({ - message: "Gateway daemon runtime", + message: "Gateway service runtime", options: GATEWAY_DAEMON_RUNTIME_OPTIONS, initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, }), @@ -79,7 +79,7 @@ export async function maybeInstallDaemon(params: { ) as GatewayDaemonRuntime; } - progress.setLabel("Preparing Gateway daemon…"); + progress.setLabel("Preparing Gateway service…"); const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, @@ -89,7 +89,7 @@ export async function maybeInstallDaemon(params: { warn: (message, title) => note(message, title), }); - progress.setLabel("Installing Gateway daemon…"); + progress.setLabel("Installing Gateway service…"); try { await service.install({ env: process.env, @@ -98,15 +98,15 @@ export async function maybeInstallDaemon(params: { workingDirectory, environment, }); - progress.setLabel("Gateway daemon installed."); + progress.setLabel("Gateway service installed."); } catch (err) { installError = err instanceof Error ? err.message : String(err); - progress.setLabel("Gateway daemon install failed."); + progress.setLabel("Gateway service install failed."); } }, ); if (installError) { - note("Gateway daemon install failed: " + installError, "Gateway"); + note("Gateway service install failed: " + installError, "Gateway"); note(gatewayInstallErrorHint(), "Gateway"); return; } diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 40b8a5934..e72ddb5a3 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -359,7 +359,7 @@ export async function runConfigureWizard( if (!selected.includes("gateway")) { const portInput = guardCancel( await text({ - message: "Gateway port for daemon install", + message: "Gateway port for service install", initialValue: String(gatewayPort), validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"), }), @@ -481,7 +481,7 @@ export async function runConfigureWizard( if (!didConfigureGateway) { const portInput = guardCancel( await text({ - message: "Gateway port for daemon install", + message: "Gateway port for service install", initialValue: String(gatewayPort), validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"), }), diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 7f6598c66..22ae7f24d 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -100,6 +100,6 @@ describe("buildGatewayInstallPlan", () => { describe("gatewayInstallErrorHint", () => { it("returns platform-specific hints", () => { expect(gatewayInstallErrorHint("win32")).toContain("Run as administrator"); - expect(gatewayInstallErrorHint("linux")).toContain("clawdbot daemon install"); + expect(gatewayInstallErrorHint("linux")).toContain("clawdbot gateway install"); }); }); diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 19a129ccf..26fe7ada5 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -65,6 +65,6 @@ export async function buildGatewayInstallPlan(params: { export function gatewayInstallErrorHint(platform = process.platform): string { return platform === "win32" - ? "Tip: rerun from an elevated PowerShell (Start → type PowerShell → right-click → Run as administrator) or skip daemon install." - : `Tip: rerun \`${formatCliCommand("clawdbot daemon install")}\` after fixing the error.`; + ? "Tip: rerun from an elevated PowerShell (Start → type PowerShell → right-click → Run as administrator) or skip service install." + : `Tip: rerun \`${formatCliCommand("clawdbot gateway install")}\` after fixing the error.`; } diff --git a/src/commands/doctor-format.ts b/src/commands/doctor-format.ts index 535937e10..2e5016b6e 100644 --- a/src/commands/doctor-format.ts +++ b/src/commands/doctor-format.ts @@ -70,10 +70,10 @@ export function buildGatewayRuntimeHints( hints.push( `LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${label}`, ); - hints.push(`Then reinstall: ${formatCliCommand("clawdbot daemon install", env)}`); + hints.push(`Then reinstall: ${formatCliCommand("clawdbot gateway install", env)}`); } if (runtime.missingUnit) { - hints.push(`Service not installed. Run: ${formatCliCommand("clawdbot daemon install", env)}`); + hints.push(`Service not installed. Run: ${formatCliCommand("clawdbot gateway install", env)}`); if (fileLog) hints.push(`File logs: ${fileLog}`); return hints; } diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index fbf9c0ea0..83a4f515e 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -139,16 +139,16 @@ export async function maybeRepairGatewayDaemon(params: { return; } } - note("Gateway daemon not installed.", "Gateway"); + note("Gateway service not installed.", "Gateway"); if (params.cfg.gateway?.mode !== "remote") { const install = await params.prompter.confirmSkipInNonInteractive({ - message: "Install gateway daemon now?", + message: "Install gateway service now?", initialValue: true, }); if (install) { const daemonRuntime = await params.prompter.select( { - message: "Gateway daemon runtime", + message: "Gateway service runtime", options: GATEWAY_DAEMON_RUNTIME_OPTIONS, initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, }, @@ -171,7 +171,7 @@ export async function maybeRepairGatewayDaemon(params: { environment, }); } catch (err) { - note(`Gateway daemon install failed: ${String(err)}`, "Gateway"); + note(`Gateway service install failed: ${String(err)}`, "Gateway"); note(gatewayInstallErrorHint(), "Gateway"); } } @@ -193,7 +193,7 @@ export async function maybeRepairGatewayDaemon(params: { if (serviceRuntime?.status !== "running") { const start = await params.prompter.confirmSkipInNonInteractive({ - message: "Start gateway daemon now?", + message: "Start gateway service now?", initialValue: true, }); if (start) { @@ -208,14 +208,14 @@ export async function maybeRepairGatewayDaemon(params: { if (process.platform === "darwin") { const label = resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE); note( - `LaunchAgent loaded; stopping requires "${formatCliCommand("clawdbot daemon stop")}" or launchctl bootout gui/$UID/${label}.`, + `LaunchAgent loaded; stopping requires "${formatCliCommand("clawdbot gateway stop")}" or launchctl bootout gui/$UID/${label}.`, "Gateway", ); } if (serviceRuntime?.status === "running") { const restart = await params.prompter.confirmSkipInNonInteractive({ - message: "Restart gateway daemon now?", + message: "Restart gateway service now?", initialValue: true, }); if (restart) { diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 24395ff69..e3005428d 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -97,7 +97,7 @@ export async function maybeMigrateLegacyGatewayService( const daemonRuntime = await prompter.select( { - message: "Gateway daemon runtime", + message: "Gateway service runtime", options: GATEWAY_DAEMON_RUNTIME_OPTIONS, initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, }, @@ -120,7 +120,7 @@ export async function maybeMigrateLegacyGatewayService( environment, }); } catch (err) { - runtime.error(`Gateway daemon install failed: ${String(err)}`); + runtime.error(`Gateway service install failed: ${String(err)}`); note(gatewayInstallErrorHint(), "Gateway"); } } diff --git a/src/commands/onboard-non-interactive/local/daemon-install.ts b/src/commands/onboard-non-interactive/local/daemon-install.ts index 895f889ef..5b2e77b63 100644 --- a/src/commands/onboard-non-interactive/local/daemon-install.ts +++ b/src/commands/onboard-non-interactive/local/daemon-install.ts @@ -21,7 +21,7 @@ export async function installGatewayDaemonNonInteractive(params: { const systemdAvailable = process.platform === "linux" ? await isSystemdUserServiceAvailable() : true; if (process.platform === "linux" && !systemdAvailable) { - runtime.log("Systemd user services are unavailable; skipping daemon install."); + runtime.log("Systemd user services are unavailable; skipping service install."); return; } @@ -48,7 +48,7 @@ export async function installGatewayDaemonNonInteractive(params: { environment, }); } catch (err) { - runtime.error(`Gateway daemon install failed: ${String(err)}`); + runtime.error(`Gateway service install failed: ${String(err)}`); runtime.log(gatewayInstallErrorHint()); return; } diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index a857c78bf..1ce771ac8 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -574,6 +574,6 @@ export async function statusCommand( if (gatewayReachable) { runtime.log(` Need to test channels? ${formatCliCommand("clawdbot status --deep")}`); } else { - runtime.log(` Fix reachability first: ${formatCliCommand("clawdbot gateway status")}`); + runtime.log(` Fix reachability first: ${formatCliCommand("clawdbot gateway probe")}`); } } diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 8cc1aff68..23b410606 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -51,7 +51,7 @@ function buildScopeSelection(opts: UninstallOptions): { async function stopAndUninstallService(runtime: RuntimeEnv): Promise { if (isNixMode) { - runtime.error("Nix mode detected; daemon uninstall is disabled."); + runtime.error("Nix mode detected; service uninstall is disabled."); return false; } const service = resolveGatewayService(); diff --git a/src/daemon/systemd-hints.ts b/src/daemon/systemd-hints.ts index 8499a718b..663a9d233 100644 --- a/src/daemon/systemd-hints.ts +++ b/src/daemon/systemd-hints.ts @@ -22,6 +22,6 @@ export function renderSystemdUnavailableHints(options: { wsl?: boolean } = {}): } return [ "systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.", - `If you're in a container, run the gateway in the foreground instead of \`${formatCliCommand("clawdbot daemon")}\`.`, + `If you're in a container, run the gateway in the foreground instead of \`${formatCliCommand("clawdbot gateway")}\`.`, ]; } diff --git a/src/infra/ports-format.ts b/src/infra/ports-format.ts index 6bf7db4bd..e6f29edd7 100644 --- a/src/infra/ports-format.ts +++ b/src/infra/ports-format.ts @@ -21,7 +21,7 @@ export function buildPortHints(listeners: PortListener[], port: number): string[ const hints: string[] = []; if (kinds.has("gateway")) { hints.push( - `Gateway already running locally. Stop it (${formatCliCommand("clawdbot daemon stop")}) or use a different port.`, + `Gateway already running locally. Stop it (${formatCliCommand("clawdbot gateway stop")}) or use a different port.`, ); } if (kinds.has("ssh")) { From c129f0bbaa735480cd60ded7e96f72beacef95c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:45:12 +0000 Subject: [PATCH 029/171] docs: align gateway service naming --- CHANGELOG.md | 1 + docs/cli/daemon.md | 23 ---------- docs/cli/gateway.md | 45 +++++++++++++++++-- docs/cli/index.md | 51 +++++++++++----------- docs/cli/update.md | 4 +- docs/debugging.md | 2 +- docs/docs.json | 2 - docs/gateway/configuration.md | 2 +- docs/gateway/doctor.md | 6 +-- docs/gateway/index.md | 54 +++++++++++------------ docs/gateway/multiple-gateways.md | 12 +++--- docs/gateway/remote.md | 4 +- docs/gateway/troubleshooting.md | 62 +++++++++++++-------------- docs/help/troubleshooting.md | 6 +-- docs/index.md | 4 +- docs/install/uninstall.md | 4 +- docs/install/updating.md | 16 +++---- docs/nodes/index.md | 2 +- docs/platforms/exe-dev.md | 8 ++-- docs/platforms/index.md | 6 +-- docs/platforms/linux.md | 4 +- docs/platforms/mac/bundled-gateway.md | 2 +- docs/platforms/mac/dev-setup.md | 4 +- docs/platforms/macos.md | 4 +- docs/platforms/windows.md | 6 +-- docs/refactor/strict-config.md | 2 +- docs/start/faq.md | 58 ++++++++++++------------- docs/start/getting-started.md | 10 ++--- docs/tools/web.md | 4 +- 29 files changed, 211 insertions(+), 197 deletions(-) delete mode 100644 docs/cli/daemon.md diff --git a/CHANGELOG.md b/CHANGELOG.md index be5093e3e..1b1848f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot - Exec approvals: support wildcard agent allowlists (`*`) across all agents. - Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. - CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. +- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. ### Fixes - Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md deleted file mode 100644 index d1e8753ed..000000000 --- a/docs/cli/daemon.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -summary: "CLI reference for `clawdbot daemon` (install/uninstall/status for the Gateway service)" -read_when: - - You want to run the Gateway as a background service - - You’re debugging daemon install, status, or logs ---- - -# `clawdbot daemon` - -Manage the Gateway daemon (background service). - -Note: `clawdbot daemon …` is the preferred surface for Gateway service management; `daemon` remains -as a legacy alias for compatibility. - -Related: -- Gateway CLI: [Gateway](/cli/gateway) -- macOS platform notes: [macOS](/platforms/macos) - -Tip: run `clawdbot daemon --help` for platform-specific flags. - -Notes: -- `daemon status` supports `--json` for scripting. -- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 21538deef..1334bfe87 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -25,6 +25,12 @@ Run a local Gateway process: clawdbot gateway ``` +Foreground alias: + +```bash +clawdbot gateway run +``` + Notes: - By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs. - Binding beyond loopback without auth is blocked (safety guardrail). @@ -75,15 +81,32 @@ clawdbot gateway health --url ws://127.0.0.1:18789 ### `gateway status` -`gateway status` is the “debug everything” command. It always probes: +`gateway status` shows the Gateway service (launchd/systemd/schtasks) plus an optional RPC probe. + +```bash +clawdbot gateway status +clawdbot gateway status --json +``` + +Options: +- `--url `: override the probe URL. +- `--token `: token auth for the probe. +- `--password `: password auth for the probe. +- `--timeout `: probe timeout (default `10000`). +- `--no-probe`: skip the RPC probe (service-only view). +- `--deep`: scan system-level services too. + +### `gateway probe` + +`gateway probe` is the “debug everything” command. It always probes: - your configured remote gateway (if set), and - localhost (loopback) **even if remote is configured**. If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway. ```bash -clawdbot gateway status -clawdbot gateway status --json +clawdbot gateway probe +clawdbot gateway probe --json ``` #### Remote over SSH (Mac app parity) @@ -93,7 +116,7 @@ The macOS app “Remote over SSH” mode uses a local port-forward so the remote CLI equivalent: ```bash -clawdbot gateway status --ssh user@gateway-host +clawdbot gateway probe --ssh user@gateway-host ``` Options: @@ -114,6 +137,20 @@ clawdbot gateway call status clawdbot gateway call logs.tail --params '{"sinceMs": 60000}' ``` +## Manage the Gateway service + +```bash +clawdbot gateway install +clawdbot gateway start +clawdbot gateway stop +clawdbot gateway restart +clawdbot gateway uninstall +``` + +Notes: +- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`. +- Lifecycle commands accept `--json` for scripting. + ## Discover gateways (Bonjour) `gateway discover` scans for Gateway beacons (`_clawdbot-gw._tcp`). diff --git a/docs/cli/index.md b/docs/cli/index.md index fdf730e2b..710c87eca 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -28,7 +28,6 @@ This page describes the current CLI behavior. If commands change, update this do - [`health`](/cli/health) - [`sessions`](/cli/sessions) - [`gateway`](/cli/gateway) -- [`daemon`](/cli/daemon) - [`logs`](/cli/logs) - [`models`](/cli/models) - [`memory`](/cli/memory) @@ -137,14 +136,14 @@ clawdbot [--dev] [--profile ] call health status + probe discover - daemon - status install uninstall start stop restart + run logs models list @@ -175,14 +174,13 @@ clawdbot [--dev] [--profile ] nodes devices node + run + status + install + uninstall start - daemon - status - install - uninstall - start - stop - restart + stop + restart approvals get set @@ -615,25 +613,25 @@ Options: - `--raw-stream` - `--raw-stream-path ` -### `daemon` +### `gateway service` Manage the Gateway service (launchd/systemd/schtasks). Subcommands: -- `daemon status` (probes the Gateway RPC by default) -- `daemon install` (service install) -- `daemon uninstall` -- `daemon start` -- `daemon stop` -- `daemon restart` +- `gateway status` (probes the Gateway RPC by default) +- `gateway install` (service install) +- `gateway uninstall` +- `gateway start` +- `gateway stop` +- `gateway restart` Notes: -- `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url/--token/--password`). -- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting. -- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra". -- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL. -- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). -- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). -- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`. +- `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url/--token/--password`). +- `gateway status` supports `--no-probe`, `--deep`, and `--json` for scripting. +- `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra". +- `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL. +- `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). +- `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). +- `gateway install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`. ### `logs` Tail Gateway file logs via RPC. @@ -652,13 +650,16 @@ clawdbot logs --no-color ``` ### `gateway ` -Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each). +Gateway CLI helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for RPC subcommands). Subcommands: - `gateway call [--params ]` - `gateway health` - `gateway status` +- `gateway probe` - `gateway discover` +- `gateway install|uninstall|start|stop|restart` +- `gateway run` Common RPCs: - `config.apply` (validate + write config + restart + wake) diff --git a/docs/cli/update.md b/docs/cli/update.md index 12fa90e57..acac61b20 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `clawdbot update` (safe-ish source update + optional daemon restart)" +summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)" read_when: - You want to update a source checkout safely - You need to understand `--update` shorthand behavior @@ -26,7 +26,7 @@ clawdbot --update ## Options -- `--restart`: restart the Gateway daemon after a successful update. +- `--restart`: restart the Gateway service after a successful update. - `--channel `: set the update channel (git + npm; persisted in config). - `--tag `: override the npm dist-tag or version for this update only. - `--json`: print machine-readable `UpdateRunResult` JSON. diff --git a/docs/debugging.md b/docs/debugging.md index a7f8a85ff..b7f94d276 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -100,7 +100,7 @@ CLAWDBOT_PROFILE=dev clawdbot gateway --dev --reset Tip: if a non‑dev gateway is already running (launchd/systemd), stop it first: ```bash -clawdbot daemon stop +clawdbot gateway stop ``` ## Raw stream logging (Clawdbot) diff --git a/docs/docs.json b/docs/docs.json index 8b81228ab..72b5765a3 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -829,8 +829,6 @@ "cli/nodes", "cli/approvals", "cli/gateway", - "cli/daemon", - "cli/service", "cli/tui", "cli/voicecall", "cli/wake", diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 1ab4c4bb0..a17308344 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t When validation fails: - The Gateway does not boot. -- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot daemon`, `clawdbot help`). +- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, `clawdbot gateway probe`, `clawdbot help`). - Run `clawdbot doctor` to see the exact issues. - Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index b3c77e848..50e7ffdcc 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -225,10 +225,10 @@ Notes: - `clawdbot doctor --yes` accepts the default repair prompts. - `clawdbot doctor --repair` applies recommended fixes without prompts. - `clawdbot doctor --repair --force` overwrites custom supervisor configs. -- You can always force a full rewrite via `clawdbot daemon install --force`. +- You can always force a full rewrite via `clawdbot gateway install --force`. ### 16) Gateway runtime + port diagnostics -Doctor inspects the daemon runtime (PID, last exit status) and warns when the +Doctor inspects the service runtime (PID, last exit status) and warns when the service is installed but not actually running. It also checks for port collisions on the gateway port (default `18789`) and reports likely causes (gateway already running, SSH tunnel). @@ -236,7 +236,7 @@ running, SSH tunnel). ### 17) Gateway runtime best practices Doctor warns when the gateway service runs on Bun or a version-managed Node path (`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram channels require Node, -and version-manager paths can break after upgrades because the daemon does not +and version-manager paths can break after upgrades because the service does not load your shell init. Doctor offers to migrate to a system Node install when available (Homebrew/apt/choco). diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 83ed93952..50552bcc5 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -1,9 +1,9 @@ --- -summary: "Runbook for the Gateway daemon, lifecycle, and operations" +summary: "Runbook for the Gateway service, lifecycle, and operations" read_when: - Running or debugging the gateway process --- -# Gateway (daemon) runbook +# Gateway service runbook Last updated: 2025-12-09 @@ -101,10 +101,10 @@ Checklist per instance: - unique `agents.defaults.workspace` - separate WhatsApp numbers (if using WA) -Daemon install per profile: +Service install per profile: ```bash -clawdbot --profile main daemon install -clawdbot --profile rescue daemon install +clawdbot --profile main gateway install +clawdbot --profile rescue gateway install ``` Example: @@ -175,49 +175,49 @@ See also: [Presence](/concepts/presence) for how presence is produced/deduped an - Events are not replayed. Clients detect seq gaps and should refresh (`health` + `system-presence`) before continuing. WebChat and macOS clients now auto-refresh on gap. ## Supervision (macOS example) -- Use launchd to keep the daemon alive: +- Use launchd to keep the service alive: - Program: path to `clawdbot` - Arguments: `gateway` - KeepAlive: true - StandardOut/Err: file paths or `syslog` - On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices. - LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped). - - `clawdbot daemon install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist` + - `clawdbot gateway install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist` (or `com.clawdbot..plist`). - `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults. -## Daemon management (CLI) +## Gateway service management (CLI) -Use the CLI daemon manager for install/start/stop/restart/status: +Use the Gateway CLI for install/start/stop/restart/status: ```bash -clawdbot daemon status -clawdbot daemon install -clawdbot daemon stop -clawdbot daemon restart +clawdbot gateway status +clawdbot gateway install +clawdbot gateway stop +clawdbot gateway restart clawdbot logs --follow ``` Notes: -- `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url`). -- `daemon status --deep` adds system-level scans (LaunchDaemons/system units). -- `daemon status --no-probe` skips the RPC probe (useful when networking is down). -- `daemon status --json` is stable for scripts. -- `daemon status` reports **supervisor runtime** (launchd/systemd running) separately from **RPC reachability** (WS connect + status RPC). -- `daemon status` prints config path + probe target to avoid “localhost vs LAN bind” confusion and profile mismatches. -- `daemon status` includes the last gateway error line when the service looks running but the port is closed. +- `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url`). +- `gateway status --deep` adds system-level scans (LaunchDaemons/system units). +- `gateway status --no-probe` skips the RPC probe (useful when networking is down). +- `gateway status --json` is stable for scripts. +- `gateway status` reports **supervisor runtime** (launchd/systemd running) separately from **RPC reachability** (WS connect + status RPC). +- `gateway status` prints config path + probe target to avoid “localhost vs LAN bind” confusion and profile mismatches. +- `gateway status` includes the last gateway error line when the service looks running but the port is closed. - `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed). - If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services. We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways). - - Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations). -- `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes). + - Cleanup: `clawdbot gateway uninstall` (current service) and `clawdbot doctor` (legacy migrations). +- `gateway install` is a no-op when already installed; use `clawdbot gateway install --force` to reinstall (profile/env/path changes). Bundled mac app: - Clawdbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled `com.clawdbot.gateway` (or `com.clawdbot.`). -- To stop it cleanly, use `clawdbot daemon stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`). -- To restart, use `clawdbot daemon restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`). - - `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot daemon install` first. +- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`). +- To restart, use `clawdbot gateway restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`). + - `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot gateway install` first. - Replace the label with `com.clawdbot.` when running a named profile. ## Supervision (systemd user unit) @@ -226,7 +226,7 @@ recommend user services for single-user machines (simpler env, per-user config). Use a **system service** for multi-user or always-on servers (no lingering required, shared supervision). -`clawdbot daemon install` writes the user unit. `clawdbot doctor` audits the +`clawdbot gateway install` writes the user unit. `clawdbot doctor` audits the unit and can update it to match the current recommended defaults. Create `~/.config/systemd/user/clawdbot-gateway[-].service`: @@ -285,7 +285,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above. - `clawdbot message send --target --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp). - `clawdbot agent --message "hi" --to ` — run an agent turn (waits for final by default). - `clawdbot gateway call --params '{"k":"v"}'` — raw method invoker for debugging. -- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd). +- `clawdbot gateway stop|restart` — stop/restart the supervised gateway service (launchd/systemd). - Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one. ## Migration guidance diff --git a/docs/gateway/multiple-gateways.md b/docs/gateway/multiple-gateways.md index ffb72a639..b1bbd7d04 100644 --- a/docs/gateway/multiple-gateways.md +++ b/docs/gateway/multiple-gateways.md @@ -31,10 +31,10 @@ clawdbot --profile rescue setup clawdbot --profile rescue gateway --port 19001 ``` -Per-profile daemons: +Per-profile services: ```bash -clawdbot --profile main daemon install -clawdbot --profile rescue daemon install +clawdbot --profile main gateway install +clawdbot --profile rescue gateway install ``` ## Rescue-bot guide @@ -55,7 +55,7 @@ Port spacing: leave at least 20 ports between base ports so the derived bridge/b # Main bot (existing or fresh, without --profile param) # Runs on port 18789 + Chrome CDC/Canvas/... Ports clawdbot onboard -clawdbot daemon install +clawdbot gateway install # Rescue bot (isolated profile + ports) clawdbot --profile rescue onboard @@ -65,8 +65,8 @@ clawdbot --profile rescue onboard # better choose completely different base port, like 19789, # - rest of the onboarding is the same as normal -# To install the daemon (if not happened automatically during onboarding) -clawdbot --profile rescue daemon install +# To install the service (if not happened automatically during onboarding) +clawdbot --profile rescue gateway install ``` ## Port mapping (derived) diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index ebe98d413..99ccb6326 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -50,7 +50,7 @@ Guide: [Tailscale](/gateway/tailscale) and [Web overview](/web). ## Command flow (what runs where) -One gateway daemon owns state + channels. Nodes are peripherals. +One gateway service owns state + channels. Nodes are peripherals. Flow example (Telegram → node): - Telegram message arrives at the **Gateway**. @@ -59,7 +59,7 @@ Flow example (Telegram → node): - Node returns the result; Gateway replies back out to Telegram. Notes: -- **Nodes do not run the gateway daemon.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). +- **Nodes do not run the gateway service.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). - macOS app “node mode” is just a node client over the Bridge. ## SSH tunnel (CLI + tools) diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index ccd9c9ea4..93f5d64bc 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -17,12 +17,12 @@ Quick triage commands (in order): | Command | What it tells you | When to use it | |---|---|---| -| `clawdbot status` | Local summary: OS + update, gateway reachability/mode, daemon, agents/sessions, provider config state | First check, quick overview | +| `clawdbot status` | Local summary: OS + update, gateway reachability/mode, service, agents/sessions, provider config state | First check, quick overview | | `clawdbot status --all` | Full local diagnosis (read-only, pasteable, safe-ish) incl. log tail | When you need to share a debug report | | `clawdbot status --deep` | Runs gateway health checks (incl. provider probes; requires reachable gateway) | When “configured” doesn’t mean “working” | -| `clawdbot gateway status` | Gateway discovery + reachability (local + remote targets) | When you suspect you’re probing the wrong gateway | +| `clawdbot gateway probe` | Gateway discovery + reachability (local + remote targets) | When you suspect you’re probing the wrong gateway | | `clawdbot channels status --probe` | Asks the running gateway for channel status (and optionally probes) | When gateway is reachable but channels misbehave | -| `clawdbot daemon status` | Supervisor state (launchd/systemd/schtasks), runtime PID/exit, last gateway error | When the daemon “looks loaded” but nothing runs | +| `clawdbot gateway status` | Supervisor state (launchd/systemd/schtasks), runtime PID/exit, last gateway error | When the service “looks loaded” but nothing runs | | `clawdbot logs --follow` | Live logs (best signal for runtime issues) | When you need the actual failure reason | **Sharing output:** prefer `clawdbot status --all` (it redacts tokens). If you paste `clawdbot status`, consider setting `CLAWDBOT_SHOW_SECRETS=0` first (token previews). @@ -38,16 +38,16 @@ Follow [Secret scanning](/gateway/security#secret-scanning-detect-secrets). ### Service Installed but Nothing is Running -If the gateway service is installed but the process exits immediately, the daemon +If the gateway service is installed but the process exits immediately, the service can appear “loaded” while nothing is running. **Check:** ```bash -clawdbot daemon status +clawdbot gateway status clawdbot doctor ``` -Doctor/daemon will show runtime state (PID/last exit) and log hints. +Doctor/service will show runtime state (PID/last exit) and log hints. **Logs:** - Preferred: `clawdbot logs --follow` @@ -71,12 +71,12 @@ See [/logging](/logging) for a full overview of formats, config, and access. ### Service Environment (PATH + runtime) -The gateway daemon runs with a **minimal PATH** to avoid shell/manager cruft: +The gateway service runs with a **minimal PATH** to avoid shell/manager cruft: - macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin` - Linux: `/usr/local/bin`, `/usr/bin`, `/bin` This intentionally excludes version managers (nvm/fnm/volta/asdf) and package -managers (pnpm/npm) because the daemon does not load your shell init. Runtime +managers (pnpm/npm) because the service does not load your shell init. Runtime variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the gateway). Exec runs on `host=gateway` merge your login-shell `PATH` into the exec environment, @@ -106,31 +106,31 @@ the Gateway likely refused to bind. **What "running" means here** - `Runtime: running` means your supervisor (launchd/systemd/schtasks) thinks the process is alive. - `RPC probe` means the CLI could actually connect to the gateway WebSocket and call `status`. -- Always trust `Probe target:` + `Config (daemon):` as the “what did we actually try?” lines. +- Always trust `Probe target:` + `Config (service):` as the “what did we actually try?” lines. **Check:** -- `gateway.mode` must be `local` for `clawdbot gateway` and the daemon. -- If you set `gateway.mode=remote`, the **CLI defaults** to a remote URL. The daemon can still be running locally, but your CLI may be probing the wrong place. Use `clawdbot daemon status` to see the daemon’s resolved port + probe target (or pass `--url`). -- `clawdbot daemon status` and `clawdbot doctor` surface the **last gateway error** from logs when the service looks running but the port is closed. +- `gateway.mode` must be `local` for `clawdbot gateway` and the service. +- If you set `gateway.mode=remote`, the **CLI defaults** to a remote URL. The service can still be running locally, but your CLI may be probing the wrong place. Use `clawdbot gateway status` to see the service’s resolved port + probe target (or pass `--url`). +- `clawdbot gateway status` and `clawdbot doctor` surface the **last gateway error** from logs when the service looks running but the port is closed. - Non-loopback binds (`lan`/`tailnet`/`auto`) require auth: `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - `gateway.remote.token` is for remote CLI calls only; it does **not** enable local auth. - `gateway.token` is ignored; use `gateway.auth.token`. -**If `clawdbot daemon status` shows a config mismatch** -- `Config (cli): ...` and `Config (daemon): ...` should normally match. -- If they don’t, you’re almost certainly editing one config while the daemon is running another. -- Fix: rerun `clawdbot daemon install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the daemon to use. +**If `clawdbot gateway status` shows a config mismatch** +- `Config (cli): ...` and `Config (service): ...` should normally match. +- If they don’t, you’re almost certainly editing one config while the service is running another. +- Fix: rerun `clawdbot gateway install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the service to use. -**If `clawdbot daemon status` reports service config issues** +**If `clawdbot gateway status` reports service config issues** - The supervisor config (launchd/systemd/schtasks) is missing current defaults. -- Fix: run `clawdbot doctor` to update it (or `clawdbot daemon install --force` for a full rewrite). +- Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite). **If `Last gateway error:` mentions “refusing to bind … without auth”** - You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`auto`) but left auth off. -- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the daemon. +- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service. -**If `clawdbot daemon status` says `bind=tailnet` but no tailnet interface was found** +**If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found** - The gateway tried to bind to a Tailscale IP (100.64.0.0/10) but none were detected on the host. - Fix: bring up Tailscale on that machine (or change `gateway.bind` to `loopback`/`lan`). @@ -144,7 +144,7 @@ This means something is already listening on the gateway port. **Check:** ```bash -clawdbot daemon status +clawdbot gateway status ``` It will show the listener(s) and likely causes (gateway already running, SSH tunnel). @@ -354,7 +354,7 @@ clawdbot doctor --fix Notes: - `clawdbot doctor` reports every invalid entry. - `clawdbot doctor --fix` applies migrations/repairs and rewrites the config. -- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot daemon` still run even if the config is invalid. +- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, and `clawdbot gateway probe` still run even if the config is invalid. ### “All models failed” — what should I check first? @@ -407,7 +407,7 @@ git status # ensure you’re in the repo root pnpm install pnpm build clawdbot doctor -clawdbot daemon restart +clawdbot gateway restart ``` Why: pnpm is the configured package manager for this repo. @@ -432,7 +432,7 @@ Notes: - After switching, run: ```bash clawdbot doctor - clawdbot daemon restart + clawdbot gateway restart ``` ### Telegram block streaming isn’t splitting text between tool calls. Why? @@ -507,8 +507,8 @@ The app connects to a local gateway on port `18789`. If it stays stuck: **Fix 1: Stop the supervisor (preferred)** If the gateway is supervised by launchd, killing the PID will just respawn it. Stop the supervisor first: ```bash -clawdbot daemon status -clawdbot daemon stop +clawdbot gateway status +clawdbot gateway stop # Or: launchctl bootout gui/$UID/com.clawdbot.gateway (replace with com.clawdbot. if needed) ``` @@ -558,9 +558,9 @@ clawdbot channels login --verbose ```bash # Supervisor + probe target + config paths -clawdbot daemon status +clawdbot gateway status # Include system-level scans (legacy/extra services, port listeners) -clawdbot daemon status --deep +clawdbot gateway status --deep # Is the gateway reachable? clawdbot health --json @@ -581,13 +581,13 @@ tail -20 /tmp/clawdbot/clawdbot-*.log Nuclear option: ```bash -clawdbot daemon stop +clawdbot gateway stop # If you installed a service and want a clean install: -# clawdbot daemon uninstall +# clawdbot gateway uninstall trash "${CLAWDBOT_STATE_DIR:-$HOME/.clawdbot}" clawdbot channels login # re-pair WhatsApp -clawdbot daemon restart # or: clawdbot gateway +clawdbot gateway restart # or: clawdbot gateway ``` ⚠️ This loses all sessions and requires re-pairing WhatsApp. diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 6d28b7294..b6219a924 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -14,7 +14,7 @@ Run these in order: ```bash clawdbot status clawdbot status --all -clawdbot daemon status +clawdbot gateway probe clawdbot logs --follow clawdbot doctor ``` @@ -38,10 +38,10 @@ Almost always a Node/npm PATH issue. Start here: - [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway authentication](/gateway/authentication) -### Daemon says running, but RPC probe fails +### Service says running, but RPC probe fails - [Gateway troubleshooting](/gateway/troubleshooting) -- [Background process / daemon](/gateway/background-process) +- [Background process / service](/gateway/background-process) ### Model/auth failures (rate limit, billing, “all models failed”) diff --git a/docs/index.md b/docs/index.md index 293609031..088522bfa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -103,13 +103,13 @@ Runtime requirement: **Node ≥ 22**. npm install -g clawdbot@latest # or: pnpm add -g clawdbot@latest -# Onboard + install the daemon (launchd/systemd user service) +# Onboard + install the service (launchd/systemd user service) clawdbot onboard --install-daemon # Pair WhatsApp Web (shows QR) clawdbot channels login -# Gateway runs via daemon after onboarding; manual run is still possible: +# Gateway runs via the service after onboarding; manual run is still possible: clawdbot gateway --port 18789 ``` diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index c179438a1..5849a6780 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -31,13 +31,13 @@ Manual steps (same result): 1) Stop the gateway service: ```bash -clawdbot daemon stop +clawdbot gateway stop ``` 2) Uninstall the gateway service (launchd/systemd/schtasks): ```bash -clawdbot daemon uninstall +clawdbot gateway uninstall ``` 3) Delete state + config: diff --git a/docs/install/updating.md b/docs/install/updating.md index 327975f50..f0efcae2a 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -68,12 +68,12 @@ Then: ```bash clawdbot doctor -clawdbot daemon restart +clawdbot gateway restart clawdbot health ``` Notes: -- If your Gateway runs as a service, `clawdbot daemon restart` is preferred over killing PIDs. +- If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs. - If you’re pinned to a specific version, see “Rollback / pinning” below. ## Update (`clawdbot update`) @@ -148,9 +148,9 @@ Details: [Doctor](/gateway/doctor) CLI (works regardless of OS): ```bash -clawdbot daemon status -clawdbot daemon stop -clawdbot daemon restart +clawdbot gateway status +clawdbot gateway stop +clawdbot gateway restart clawdbot gateway --port 18789 clawdbot logs --follow ``` @@ -159,7 +159,7 @@ If you’re supervised: - macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway` (use `com.clawdbot.` if set) - Linux systemd user service: `systemctl --user restart clawdbot-gateway[-].service` - Windows (WSL2): `systemctl --user restart clawdbot-gateway[-].service` - - `launchctl`/`systemctl` only work if the service is installed; otherwise run `clawdbot daemon install`. + - `launchctl`/`systemctl` only work if the service is installed; otherwise run `clawdbot gateway install`. Runbook + exact service labels: [Gateway runbook](/gateway) @@ -183,7 +183,7 @@ Then restart + re-run doctor: ```bash clawdbot doctor -clawdbot daemon restart +clawdbot gateway restart ``` ### Pin (source) by date @@ -200,7 +200,7 @@ Then reinstall deps + restart: ```bash pnpm install pnpm build -clawdbot daemon restart +clawdbot gateway restart ``` If you want to go back to latest later: diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 9096cb7d9..6bb48a3e9 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -13,7 +13,7 @@ A **node** is a companion device (iOS/Android today) that connects to the Gatewa 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). Notes: -- Nodes are **peripherals**, not gateways. They don’t run the gateway daemon. +- Nodes are **peripherals**, not gateways. They don’t run the gateway service. - Telegram/WhatsApp/etc. messages land on the **gateway**, not on nodes. ## Pairing + status diff --git a/docs/platforms/exe-dev.md b/docs/platforms/exe-dev.md index 813c45da8..c4fcfdd76 100644 --- a/docs/platforms/exe-dev.md +++ b/docs/platforms/exe-dev.md @@ -97,7 +97,7 @@ It can set up: - `~/.clawdbot/clawdbot.json` config - model auth profiles - model provider config/login -- Linux systemd **user** service (daemon) +- Linux systemd **user** service (service) If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [Help](/help)). @@ -125,7 +125,7 @@ export CLAWDBOT_GATEWAY_TOKEN="$(openssl rand -hex 32)" clawdbot gateway --bind lan --port 8080 --token "$CLAWDBOT_GATEWAY_TOKEN" ``` -For daemon runs, persist it in `~/.clawdbot/clawdbot.json`: +For service runs, persist it in `~/.clawdbot/clawdbot.json`: ```json5 { @@ -159,7 +159,7 @@ Notes: Control UI details: [Control UI](/web/control-ui) -## 6) Keep it running (daemon) +## 6) Keep it running (service) On Linux, Clawdbot uses a systemd **user** service. After `--install-daemon`, verify: @@ -180,7 +180,7 @@ More: [Linux](/platforms/linux) ```bash npm i -g clawdbot@latest clawdbot doctor -clawdbot daemon restart +clawdbot gateway restart clawdbot health ``` diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 6501e1315..d646b2026 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -31,15 +31,15 @@ Native companion apps for Windows are also planned; the Gateway is recommended v - Install guide: [Getting Started](/start/getting-started) - Gateway runbook: [Gateway](/gateway) - Gateway configuration: [Configuration](/gateway/configuration) -- Service status: `clawdbot daemon status` +- Service status: `clawdbot gateway status` ## Gateway service install (CLI) Use one of these (all supported): - Wizard (recommended): `clawdbot onboard --install-daemon` -- Direct: `clawdbot daemon install` -- Configure flow: `clawdbot configure` → select **Gateway daemon** +- Direct: `clawdbot gateway install` +- Configure flow: `clawdbot configure` → select **Gateway service** - Repair/migrate: `clawdbot doctor` (offers to install or fix the service) The service target depends on OS: diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index d6cb44549..1184eca8a 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -41,7 +41,7 @@ clawdbot onboard --install-daemon Or: ``` -clawdbot daemon install +clawdbot gateway install ``` Or: @@ -50,7 +50,7 @@ Or: clawdbot configure ``` -Select **Gateway daemon** when prompted. +Select **Gateway service** when prompted. Repair/migrate: diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index c9d2b9c0d..ae60e5e41 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -34,7 +34,7 @@ Plist location (per‑user): Manager: - The macOS app owns LaunchAgent install/update in Local mode. -- The CLI can also install it: `clawdbot daemon install`. +- The CLI can also install it: `clawdbot gateway install`. Behavior: - “Clawdbot Active” enables/disables the LaunchAgent. diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 01e65da7d..79d23abb5 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -82,8 +82,8 @@ If the app crashes when you try to allow **Speech Recognition** or **Microphone* If the gateway status stays on "Starting...", check if a zombie process is holding the port: ```bash -clawdbot daemon status -clawdbot daemon stop +clawdbot gateway status +clawdbot gateway stop # If you’re not using a LaunchAgent (dev mode / manual runs), find the listener: lsof -nP -iTCP:18789 -sTCP:LISTEN diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 67b7c84cb..b1540def8 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -24,7 +24,7 @@ capabilities to the agent as a node. ## Local vs remote mode - **Local** (default): the app attaches to a running local Gateway if present; - otherwise it enables the launchd service via `clawdbot daemon`. + otherwise it enables the launchd service via `clawdbot gateway install`. - **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts a local process. The app starts the local **node host service** so the remote Gateway can reach this Mac. @@ -43,7 +43,7 @@ launchctl bootout gui/$UID/com.clawdbot.gateway Replace the label with `com.clawdbot.` when running a named profile. If the LaunchAgent isn’t installed, enable it from the app or run -`clawdbot daemon install`. +`clawdbot gateway install`. ## Node capabilities (mac) diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index e737b64c9..30f8714e0 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -32,7 +32,7 @@ clawdbot onboard --install-daemon Or: ``` -clawdbot daemon install +clawdbot gateway install ``` Or: @@ -41,7 +41,7 @@ Or: clawdbot configure ``` -Select **Gateway daemon** when prompted. +Select **Gateway service** when prompted. Repair/migrate: @@ -108,7 +108,7 @@ wsl --install -d Ubuntu-24.04 Reboot if Windows asks. -### 2) Enable systemd (required for daemon install) +### 2) Enable systemd (required for gateway install) In your WSL terminal: diff --git a/docs/refactor/strict-config.md b/docs/refactor/strict-config.md index 01578d2c5..734290daf 100644 --- a/docs/refactor/strict-config.md +++ b/docs/refactor/strict-config.md @@ -54,7 +54,7 @@ Allowed (diagnostic-only): - `clawdbot health` - `clawdbot help` - `clawdbot status` -- `clawdbot daemon` +- `clawdbot gateway status` Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.” diff --git a/docs/start/faq.md b/docs/start/faq.md index 0158f405e..92b6c645e 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -59,14 +59,14 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [How do I use Brave for browser control?](#how-do-i-use-brave-for-browser-control) - [Remote gateways + nodes](#remote-gateways-nodes) - [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes) - - [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon) + - [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) - [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install) - [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac) - [How do I connect a Mac node to a remote Gateway (Tailscale Serve)?](#how-do-i-connect-a-mac-node-to-a-remote-gateway-tailscale-serve) - [Env vars and .env loading](#env-vars-and-env-loading) - [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables) - - [“I started the Gateway via a daemon and my env vars disappeared.” What now?](#i-started-the-gateway-via-a-daemon-and-my-env-vars-disappeared-what-now) + - [“I started the Gateway via the service and my env vars disappeared.” What now?](#i-started-the-gateway-via-the-service-and-my-env-vars-disappeared-what-now) - [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why) - [Sessions & multiple chats](#sessions-multiple-chats) - [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation) @@ -100,8 +100,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [OAuth vs API key: what’s the difference?](#oauth-vs-api-key-whats-the-difference) - [Gateway: ports, “already running”, and remote mode](#gateway-ports-already-running-and-remote-mode) - [What port does the Gateway use?](#what-port-does-the-gateway-use) - - [Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-daemon-status-say-runtime-running-but-rpc-probe-failed) - - [Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different?](#why-does-clawdbot-daemon-status-show-config-cli-and-config-daemon-different) + - [Why does `clawdbot gateway status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-gateway-status-say-runtime-running-but-rpc-probe-failed) + - [Why does `clawdbot gateway status` show `Config (cli)` and `Config (service)` different?](#why-does-clawdbot-gateway-status-show-config-cli-and-config-service-different) - [What does “another gateway instance is already listening” mean?](#what-does-another-gateway-instance-is-already-listening-mean) - [How do I run Clawdbot in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) - [The Control UI says “unauthorized” (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now) @@ -110,8 +110,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [What does “invalid handshake” / code 1008 mean?](#what-does-invalid-handshake--code-1008-mean) - [Logging and debugging](#logging-and-debugging) - [Where are logs?](#where-are-logs) - - [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon) - - [ELI5: `clawdbot daemon restart` vs `clawdbot gateway`](#eli5-clawdbot-daemon-restart-vs-clawdbot-gateway) + - [How do I start/stop/restart the Gateway service?](#how-do-i-startstoprestart-the-gateway-service) + - [ELI5: `clawdbot gateway restart` vs `clawdbot gateway`](#eli5-clawdbot-gateway-restart-vs-clawdbot-gateway) - [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails) - [Media & attachments](#media-attachments) - [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent) @@ -129,7 +129,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, ```bash clawdbot status ``` - Fast local summary: OS + update, gateway/daemon reachability, agents/sessions, provider config + runtime issues (when gateway is reachable). + Fast local summary: OS + update, gateway/service reachability, agents/sessions, provider config + runtime issues (when gateway is reachable). 2) **Pasteable report (safe to share)** ```bash @@ -139,9 +139,9 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, 3) **Daemon + port state** ```bash - clawdbot daemon status + clawdbot gateway status ``` - Shows supervisor runtime vs RPC reachability, the probe target URL, and which config the daemon likely used. + Shows supervisor runtime vs RPC reachability, the probe target URL, and which config the service likely used. 4) **Deep probes** ```bash @@ -335,7 +335,7 @@ cd clawdbot pnpm install pnpm build clawdbot doctor -clawdbot daemon restart +clawdbot gateway restart ``` From git → npm: @@ -343,7 +343,7 @@ From git → npm: ```bash npm install -g clawdbot@latest clawdbot doctor -clawdbot daemon restart +clawdbot gateway restart ``` Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation). @@ -748,7 +748,7 @@ pair devices you trust, and review [Security](/gateway/security). Docs: [Nodes](/nodes), [Bridge protocol](/gateway/bridge-protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security). -### Do nodes run a gateway daemon? +### Do nodes run a gateway service? No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app). @@ -840,11 +840,11 @@ You can also define inline env vars in config (applied only if missing from the See [/environment](/environment) for full precedence and sources. -### “I started the Gateway via a daemon and my env vars disappeared.” What now? +### “I started the Gateway via a service and my env vars disappeared.” What now? Two common fixes: -1) Put the missing keys in `~/.clawdbot/.env` so they’re picked up even when the daemon doesn’t inherit your shell env. +1) Put the missing keys in `~/.clawdbot/.env` so they’re picked up even when the service doesn’t inherit your shell env. 2) Enable shell import (opt‑in convenience): ```json5 @@ -867,7 +867,7 @@ This runs your login shell and imports only missing expected keys (never overrid does **not** mean your env vars are missing — it just means Clawdbot won’t load your login shell automatically. -If the Gateway runs as a daemon (launchd/systemd), it won’t inherit your shell +If the Gateway runs as a service (launchd/systemd), it won’t inherit your shell environment. Fix by doing one of these: 1) Put the token in `~/.clawdbot/.env`: @@ -1345,24 +1345,24 @@ Precedence: --port > CLAWDBOT_GATEWAY_PORT > gateway.port > default 18789 ``` -### Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`? +### Why does `clawdbot gateway status` say `Runtime: running` but `RPC probe: failed`? Because “running” is the **supervisor’s** view (launchd/systemd/schtasks). The RPC probe is the CLI actually connecting to the gateway WebSocket and calling `status`. -Use `clawdbot daemon status` and trust these lines: +Use `clawdbot gateway status` and trust these lines: - `Probe target:` (the URL the probe actually used) - `Listening:` (what’s actually bound on the port) - `Last gateway error:` (common root cause when the process is alive but the port isn’t listening) -### Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different? +### Why does `clawdbot gateway status` show `Config (cli)` and `Config (service)` different? -You’re editing one config file while the daemon is running another (often a `--profile` / `CLAWDBOT_STATE_DIR` mismatch). +You’re editing one config file while the service is running another (often a `--profile` / `CLAWDBOT_STATE_DIR` mismatch). Fix: ```bash -clawdbot daemon install --force +clawdbot gateway install --force ``` -Run that from the same `--profile` / environment you want the daemon to use. +Run that from the same `--profile` / environment you want the service to use. ### What does “another gateway instance is already listening” mean? @@ -1431,7 +1431,7 @@ Yes, but you must isolate: Quick setup (recommended): - Use `clawdbot --profile …` per instance (auto-creates `~/.clawdbot-`). - Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs). -- Install a per-profile daemon: `clawdbot --profile daemon install`. +- Install a per-profile service: `clawdbot --profile gateway install`. Profiles also suffix service names (`com.clawdbot.`, `clawdbot-gateway-.service`, `Clawdbot Gateway ()`). Full guide: [Multiple gateways](/gateway/multiple-gateways). @@ -1484,23 +1484,23 @@ Service/supervisor logs (when the gateway runs via launchd/systemd): See [Troubleshooting](/gateway/troubleshooting#log-locations) for more. -### How do I start/stop/restart the Gateway daemon? +### How do I start/stop/restart the Gateway service? -Use the daemon helpers: +Use the gateway helpers: ```bash -clawdbot daemon status -clawdbot daemon restart +clawdbot gateway status +clawdbot gateway restart ``` If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway). -### ELI5: `clawdbot daemon restart` vs `clawdbot gateway` +### ELI5: `clawdbot gateway restart` vs `clawdbot gateway` -- `clawdbot daemon restart`: restarts the **background service** (launchd/systemd). +- `clawdbot gateway restart`: restarts the **background service** (launchd/systemd). - `clawdbot gateway`: runs the gateway **in the foreground** for this terminal session. -If you installed the daemon, use the daemon commands. Use `clawdbot gateway` when +If you installed the service, use the gateway commands. Use `clawdbot gateway` when you want a one-off, foreground run. ### What’s the fastest way to get more details when something fails? diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index aa890eac5..85371b58b 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -15,7 +15,7 @@ Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It set - channels (WhatsApp/Telegram/Discord/…) - pairing defaults (secure DMs) - workspace bootstrap + skills -- optional background daemon +- optional background service If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security). @@ -71,7 +71,7 @@ npm install -g clawdbot@latest pnpm add -g clawdbot@latest ``` -## 2) Run the onboarding wizard (and install the daemon) +## 2) Run the onboarding wizard (and install the service) ```bash clawdbot onboard --install-daemon @@ -89,7 +89,7 @@ Wizard doc: [Wizard](/start/wizard) ### Auth: where it lives (important) -- **Recommended Anthropic path:** set an API key (wizard can store it for daemon use). `claude setup-token` is also supported if you want to reuse Claude Code credentials. +- **Recommended Anthropic path:** set an API key (wizard can store it for service use). `claude setup-token` is also supported if you want to reuse Claude Code credentials. - OAuth credentials (legacy import): `~/.clawdbot/credentials/oauth.json` - Auth profiles (OAuth + API keys): `~/.clawdbot/agents//agent/auth-profiles.json` @@ -98,10 +98,10 @@ Headless/server tip: do OAuth on a normal machine first, then copy `oauth.json` ## 3) Start the Gateway -If you installed the daemon during onboarding, the Gateway should already be running: +If you installed the service during onboarding, the Gateway should already be running: ```bash -clawdbot daemon status +clawdbot gateway status ``` Manual run (foreground): diff --git a/docs/tools/web.md b/docs/tools/web.md index cf1738570..040c0f389 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -83,7 +83,7 @@ current limits and pricing. `~/.clawdbot/clawdbot.json` under `tools.web.search.apiKey`. **Environment alternative:** set `BRAVE_API_KEY` in the Gateway process -environment. For a daemon install, put it in `~/.clawdbot/.env` (or your +environment. For a gateway install, put it in `~/.clawdbot/.env` (or your service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables). ## Using Perplexity (direct or via OpenRouter) @@ -122,7 +122,7 @@ crypto/prepaid). ``` **Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway -environment. For a daemon install, put it in `~/.clawdbot/.env`. +environment. For a gateway install, put it in `~/.clawdbot/.env`. If no base URL is set, Clawdbot chooses a default based on the API key source: From 9605ad76c55449bceea573f5f6da8c06b46a3ebe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:45:18 +0000 Subject: [PATCH 030/171] fix: preserve fetch preconnect in abort wrapper --- src/infra/fetch.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts index 70ab6f614..9cd10c25f 100644 --- a/src/infra/fetch.ts +++ b/src/infra/fetch.ts @@ -1,3 +1,7 @@ +type FetchWithPreconnect = typeof fetch & { + preconnect: (url: string, init?: { credentials?: RequestCredentials }) => void; +}; + export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch { const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => { const signal = init?.signal; @@ -25,7 +29,11 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch }); } return response; - }) as typeof fetch; + }) as FetchWithPreconnect; + + wrapped.preconnect = + typeof fetchImpl.preconnect === "function" ? fetchImpl.preconnect.bind(fetchImpl) : () => {}; + return Object.assign(wrapped, fetchImpl); } From 9b47f463b73268c2efdd203815b27eaf6b812c1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:46:30 +0000 Subject: [PATCH 031/171] chore: rename gateway daemon prompts --- src/cli/program/register.onboard.ts | 6 ++--- src/wizard/onboarding.finalize.ts | 36 ++++++++++++++--------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 4f1005304..9b1ebed06 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -84,9 +84,9 @@ export function registerOnboardCommand(program: Command) { .option("--remote-token ", "Remote Gateway token (optional)") .option("--tailscale ", "Tailscale: off|serve|funnel") .option("--tailscale-reset-on-exit", "Reset tailscale serve/funnel on exit") - .option("--install-daemon", "Install gateway daemon") - .option("--no-install-daemon", "Skip gateway daemon install") - .option("--skip-daemon", "Skip gateway daemon install") + .option("--install-daemon", "Install gateway service") + .option("--no-install-daemon", "Skip gateway service install") + .option("--skip-daemon", "Skip gateway service install") .option("--daemon-runtime ", "Daemon runtime: node|bun") .option("--skip-channels", "Skip channel setup") .option("--skip-skills", "Skip skills setup") diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index 717446017..09240bb55 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -64,7 +64,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption process.platform === "linux" ? await isSystemdUserServiceAvailable() : true; if (process.platform === "linux" && !systemdAvailable) { await prompter.note( - "Systemd user services are unavailable. Skipping lingering checks and daemon install.", + "Systemd user services are unavailable. Skipping lingering checks and service install.", "Systemd", ); } @@ -94,15 +94,15 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption installDaemon = true; } else { installDaemon = await prompter.confirm({ - message: "Install Gateway daemon (recommended)", + message: "Install Gateway service (recommended)", initialValue: true, }); } if (process.platform === "linux" && !systemdAvailable && installDaemon) { await prompter.note( - "Systemd user services are unavailable; skipping daemon install. Use your container supervisor or `docker compose up -d`.", - "Gateway daemon", + "Systemd user services are unavailable; skipping service install. Use your container supervisor or `docker compose up -d`.", + "Gateway service", ); installDaemon = false; } @@ -112,14 +112,14 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption flow === "quickstart" ? (DEFAULT_GATEWAY_DAEMON_RUNTIME as GatewayDaemonRuntime) : ((await prompter.select({ - message: "Gateway daemon runtime", + message: "Gateway service runtime", options: GATEWAY_DAEMON_RUNTIME_OPTIONS, initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME, })) as GatewayDaemonRuntime); if (flow === "quickstart") { await prompter.note( - "QuickStart uses Node for the Gateway daemon (stable + supported).", - "Gateway daemon runtime", + "QuickStart uses Node for the Gateway service (stable + supported).", + "Gateway service runtime", ); } const service = resolveGatewayService(); @@ -135,10 +135,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption })) as "restart" | "reinstall" | "skip"; if (action === "restart") { await withWizardProgress( - "Gateway daemon", - { doneMessage: "Gateway daemon restarted." }, + "Gateway service", + { doneMessage: "Gateway service restarted." }, async (progress) => { - progress.update("Restarting Gateway daemon…"); + progress.update("Restarting Gateway service…"); await service.restart({ env: process.env, stdout: process.stdout, @@ -147,10 +147,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption ); } else if (action === "reinstall") { await withWizardProgress( - "Gateway daemon", - { doneMessage: "Gateway daemon uninstalled." }, + "Gateway service", + { doneMessage: "Gateway service uninstalled." }, async (progress) => { - progress.update("Uninstalling Gateway daemon…"); + progress.update("Uninstalling Gateway service…"); await service.uninstall({ env: process.env, stdout: process.stdout }); }, ); @@ -158,10 +158,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption } if (!loaded || (loaded && (await service.isLoaded({ env: process.env })) === false)) { - const progress = prompter.progress("Gateway daemon"); + const progress = prompter.progress("Gateway service"); let installError: string | null = null; try { - progress.update("Preparing Gateway daemon…"); + progress.update("Preparing Gateway service…"); const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port: settings.port, @@ -170,7 +170,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption warn: (message, title) => prompter.note(message, title), }); - progress.update("Installing Gateway daemon…"); + progress.update("Installing Gateway service…"); await service.install({ env: process.env, stdout: process.stdout, @@ -182,11 +182,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption installError = err instanceof Error ? err.message : String(err); } finally { progress.stop( - installError ? "Gateway daemon install failed." : "Gateway daemon installed.", + installError ? "Gateway service install failed." : "Gateway service installed.", ); } if (installError) { - await prompter.note(`Gateway daemon install failed: ${installError}`, "Gateway"); + await prompter.note(`Gateway service install failed: ${installError}`, "Gateway"); await prompter.note(gatewayInstallErrorHint(), "Gateway"); } } From 64d29b0c310ca73f1b6b8716c2d317e3d038b528 Mon Sep 17 00:00:00 2001 From: Wimmie Date: Tue, 20 Jan 2026 20:49:40 +0000 Subject: [PATCH 032/171] feat(discord): add wildcard channel config support Add support for '*' wildcard in Discord channel configuration, matching the existing guild-level wildcard behavior. This allows applying default channel settings (like autoThread) to all channels without listing each one explicitly: guilds: '*': channels: '*': { autoThread: true } Specific channel configs still take precedence over the wildcard. --- src/discord/monitor.test.ts | 30 ++++++++++++++++++++++++++++++ src/discord/monitor/allow-list.ts | 14 +++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 6d3565b66..e5c3ad347 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -281,6 +281,36 @@ describe("discord guild/channel resolution", () => { }); expect(thread?.allowed).toBe(false); }); + + it("applies wildcard channel config when no specific match", () => { + const guildInfo: DiscordGuildEntryResolved = { + channels: { + general: { allow: true, requireMention: false }, + "*": { allow: true, autoThread: true, requireMention: true }, + }, + }; + // Specific channel should NOT use wildcard + const general = resolveDiscordChannelConfig({ + guildInfo, + channelId: "123", + channelName: "general", + channelSlug: "general", + }); + expect(general?.allowed).toBe(true); + expect(general?.requireMention).toBe(false); + expect(general?.autoThread).toBeUndefined(); + + // Unknown channel should use wildcard + const random = resolveDiscordChannelConfig({ + guildInfo, + channelId: "999", + channelName: "random", + channelSlug: "random", + }); + expect(random?.allowed).toBe(true); + expect(random?.autoThread).toBe(true); + expect(random?.requireMention).toBe(true); + }); }); describe("discord mention gating", () => { diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 0b4914e1c..b3dc048e7 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -234,7 +234,14 @@ export function resolveDiscordChannelConfig(params: { name: channelName, slug: channelSlug, }); - if (!match.entry || !match.matchKey) return { allowed: false }; + if (!match.entry || !match.matchKey) { + // Wildcard fallback: apply to all channels if "*" is configured + const wildcard = channels["*"]; + if (wildcard) { + return resolveDiscordChannelConfigEntry(wildcard, "*", "direct"); + } + return { allowed: false }; + } return resolveDiscordChannelConfigEntry(match.entry, match.matchKey, "direct"); } @@ -284,6 +291,11 @@ export function resolveDiscordChannelConfigWithFallback(params: { match.matchSource === "parent" ? "parent" : "direct", ); } + // Wildcard fallback: apply to all channels if "*" is configured + const wildcard = channels["*"]; + if (wildcard) { + return resolveDiscordChannelConfigEntry(wildcard, "*", "direct"); + } return { allowed: false }; } From f0a8b34198232ac50b3574e241ffea3fba4829a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:54:31 +0000 Subject: [PATCH 033/171] fix(discord): align wildcard channel matching --- src/discord/monitor.test.ts | 23 +++++++++++++++++++++++ src/discord/monitor/allow-list.ts | 28 +++++++--------------------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index e5c3ad347..be0c8aa65 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -299,6 +299,7 @@ describe("discord guild/channel resolution", () => { expect(general?.allowed).toBe(true); expect(general?.requireMention).toBe(false); expect(general?.autoThread).toBeUndefined(); + expect(general?.matchSource).toBe("direct"); // Unknown channel should use wildcard const random = resolveDiscordChannelConfig({ @@ -310,6 +311,28 @@ describe("discord guild/channel resolution", () => { expect(random?.allowed).toBe(true); expect(random?.autoThread).toBe(true); expect(random?.requireMention).toBe(true); + expect(random?.matchSource).toBe("wildcard"); + }); + + it("falls back to wildcard when thread channel and parent are missing", () => { + const guildInfo: DiscordGuildEntryResolved = { + channels: { + "*": { allow: true, requireMention: false }, + }, + }; + const thread = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId: "thread-123", + channelName: "topic", + channelSlug: "topic", + parentId: "parent-999", + parentName: "general", + parentSlug: "general", + scope: "thread", + }); + expect(thread?.allowed).toBe(true); + expect(thread?.matchKey).toBe("*"); + expect(thread?.matchSource).toBe("wildcard"); }); }); diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index b3dc048e7..236cac15e 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -3,6 +3,7 @@ import type { Guild, User } from "@buape/carbon"; import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, + type ChannelMatchSource, } from "../../channels/channel-config.js"; import type { AllowlistMatch } from "../../channels/allowlist-match.js"; import { formatDiscordUserTag } from "./format.js"; @@ -44,7 +45,7 @@ export type DiscordChannelConfigResolved = { systemPrompt?: string; autoThread?: boolean; matchKey?: string; - matchSource?: "direct" | "parent"; + matchSource?: ChannelMatchSource; }; export function normalizeDiscordAllowList( @@ -198,13 +199,14 @@ function resolveDiscordChannelEntryMatch( entries: channels, keys, parentKeys, + wildcardKey: "*", }); } function resolveDiscordChannelConfigEntry( entry: DiscordChannelEntry, matchKey: string | undefined, - matchSource: "direct" | "parent", + matchSource: ChannelMatchSource, ): DiscordChannelConfigResolved { const resolved: DiscordChannelConfigResolved = { allowed: entry.allow !== false, @@ -234,15 +236,8 @@ export function resolveDiscordChannelConfig(params: { name: channelName, slug: channelSlug, }); - if (!match.entry || !match.matchKey) { - // Wildcard fallback: apply to all channels if "*" is configured - const wildcard = channels["*"]; - if (wildcard) { - return resolveDiscordChannelConfigEntry(wildcard, "*", "direct"); - } - return { allowed: false }; - } - return resolveDiscordChannelConfigEntry(match.entry, match.matchKey, "direct"); + if (!match.entry || !match.matchKey || !match.matchSource) return { allowed: false }; + return resolveDiscordChannelConfigEntry(match.entry, match.matchKey, match.matchSource); } export function resolveDiscordChannelConfigWithFallback(params: { @@ -285,16 +280,7 @@ export function resolveDiscordChannelConfigWithFallback(params: { : undefined, ); if (match.entry && match.matchKey && match.matchSource) { - return resolveDiscordChannelConfigEntry( - match.entry, - match.matchKey, - match.matchSource === "parent" ? "parent" : "direct", - ); - } - // Wildcard fallback: apply to all channels if "*" is configured - const wildcard = channels["*"]; - if (wildcard) { - return resolveDiscordChannelConfigEntry(wildcard, "*", "direct"); + return resolveDiscordChannelConfigEntry(match.entry, match.matchKey, match.matchSource); } return { allowed: false }; } From b52ab96e2c1dcb354041a4028e30ecd0688e4dd5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 17:54:52 +0000 Subject: [PATCH 034/171] docs(changelog): note discord wildcard fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1848f57..05909cdf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.clawd.bot - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. - Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. - Model picker: list the full catalog when no model allowlist is configured. +- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo. - BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204. - Infra: preserve fetch helper methods when wrapping abort signals. (#1387) From 88d76d4be51a0d468eca2077bd16bc14199de558 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 18:21:19 +0000 Subject: [PATCH 035/171] refactor(channels): centralize match metadata --- src/channels/allowlist-match.ts | 4 ++- src/channels/channel-config.test.ts | 29 +++++++++++++++++++ src/channels/channel-config.ts | 18 ++++++++++++ src/channels/plugins/channel-config.ts | 2 ++ src/channels/plugins/index.ts | 2 ++ src/discord/monitor/allow-list.ts | 14 +++------ .../monitor/message-handler.preflight.ts | 9 ++---- src/slack/monitor/channel-config.ts | 18 ++++-------- src/slack/monitor/context.ts | 5 ++-- src/slack/monitor/message-handler/prepare.ts | 5 ++-- src/slack/monitor/slash.ts | 5 ++-- 11 files changed, 73 insertions(+), 38 deletions(-) diff --git a/src/channels/allowlist-match.ts b/src/channels/allowlist-match.ts index 69e797ed9..d77fac1f9 100644 --- a/src/channels/allowlist-match.ts +++ b/src/channels/allowlist-match.ts @@ -16,6 +16,8 @@ export type AllowlistMatch = { matchSource?: TSource; }; -export function formatAllowlistMatchMeta(match?: AllowlistMatch | null): string { +export function formatAllowlistMatchMeta( + match?: { matchKey?: string; matchSource?: string } | null, +): string { return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`; } diff --git a/src/channels/channel-config.test.ts b/src/channels/channel-config.test.ts index 25cee4ac2..984a486c0 100644 --- a/src/channels/channel-config.test.ts +++ b/src/channels/channel-config.test.ts @@ -6,6 +6,8 @@ import { resolveChannelEntryMatch, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision, + applyChannelMatchMeta, + resolveChannelMatchConfig, } from "./channel-config.js"; describe("buildChannelKeyCandidates", () => { @@ -90,6 +92,33 @@ describe("resolveChannelEntryMatchWithFallback", () => { }); }); +describe("applyChannelMatchMeta", () => { + it("copies match metadata onto resolved configs", () => { + const resolved = applyChannelMatchMeta( + { allowed: true }, + { matchKey: "general", matchSource: "direct" }, + ); + expect(resolved.matchKey).toBe("general"); + expect(resolved.matchSource).toBe("direct"); + }); +}); + +describe("resolveChannelMatchConfig", () => { + it("returns null when no entry is matched", () => { + const resolved = resolveChannelMatchConfig({ matchKey: "x" }, () => ({ allowed: true })); + expect(resolved).toBeNull(); + }); + + it("resolves entry and applies match metadata", () => { + const resolved = resolveChannelMatchConfig( + { entry: { allow: true }, matchKey: "*", matchSource: "wildcard" }, + () => ({ allowed: true }), + ); + expect(resolved?.matchKey).toBe("*"); + expect(resolved?.matchSource).toBe("wildcard"); + }); +}); + describe("resolveNestedAllowlistDecision", () => { it("allows when outer allowlist is disabled", () => { expect( diff --git a/src/channels/channel-config.ts b/src/channels/channel-config.ts index 6bf1300ce..af3898667 100644 --- a/src/channels/channel-config.ts +++ b/src/channels/channel-config.ts @@ -11,6 +11,24 @@ export type ChannelEntryMatch = { matchSource?: ChannelMatchSource; }; +export function applyChannelMatchMeta< + TResult extends { matchKey?: string; matchSource?: ChannelMatchSource }, +>(result: TResult, match: ChannelEntryMatch): TResult { + if (match.matchKey && match.matchSource) { + result.matchKey = match.matchKey; + result.matchSource = match.matchSource; + } + return result; +} + +export function resolveChannelMatchConfig< + TEntry, + TResult extends { matchKey?: string; matchSource?: ChannelMatchSource }, +>(match: ChannelEntryMatch, resolveEntry: (entry: TEntry) => TResult): TResult | null { + if (!match.entry) return null; + return applyChannelMatchMeta(resolveEntry(match.entry), match); +} + export function normalizeChannelSlug(value: string): string { return value .trim() diff --git a/src/channels/plugins/channel-config.ts b/src/channels/plugins/channel-config.ts index e2bd35cdc..9f3a5150b 100644 --- a/src/channels/plugins/channel-config.ts +++ b/src/channels/plugins/channel-config.ts @@ -1,8 +1,10 @@ export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js"; export { + applyChannelMatchMeta, buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatch, resolveChannelEntryMatchWithFallback, + resolveChannelMatchConfig, resolveNestedAllowlistDecision, } from "../channel-config.js"; diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index d3861f7fe..6d448df73 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -60,10 +60,12 @@ export { listWhatsAppDirectoryPeersFromConfig, } from "./directory-config.js"; export { + applyChannelMatchMeta, buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatch, resolveChannelEntryMatchWithFallback, + resolveChannelMatchConfig, resolveNestedAllowlistDecision, type ChannelEntryMatch, type ChannelMatchSource, diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 236cac15e..7d495af66 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -3,6 +3,7 @@ import type { Guild, User } from "@buape/carbon"; import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, + resolveChannelMatchConfig, type ChannelMatchSource, } from "../../channels/channel-config.js"; import type { AllowlistMatch } from "../../channels/allowlist-match.js"; @@ -205,8 +206,6 @@ function resolveDiscordChannelEntryMatch( function resolveDiscordChannelConfigEntry( entry: DiscordChannelEntry, - matchKey: string | undefined, - matchSource: ChannelMatchSource, ): DiscordChannelConfigResolved { const resolved: DiscordChannelConfigResolved = { allowed: entry.allow !== false, @@ -217,8 +216,6 @@ function resolveDiscordChannelConfigEntry( systemPrompt: entry.systemPrompt, autoThread: entry.autoThread, }; - if (matchKey) resolved.matchKey = matchKey; - resolved.matchSource = matchSource; return resolved; } @@ -236,8 +233,8 @@ export function resolveDiscordChannelConfig(params: { name: channelName, slug: channelSlug, }); - if (!match.entry || !match.matchKey || !match.matchSource) return { allowed: false }; - return resolveDiscordChannelConfigEntry(match.entry, match.matchKey, match.matchSource); + const resolved = resolveChannelMatchConfig(match, resolveDiscordChannelConfigEntry); + return resolved ?? { allowed: false }; } export function resolveDiscordChannelConfigWithFallback(params: { @@ -279,10 +276,7 @@ export function resolveDiscordChannelConfigWithFallback(params: { } : undefined, ); - if (match.entry && match.matchKey && match.matchSource) { - return resolveDiscordChannelConfigEntry(match.entry, match.matchKey, match.matchSource); - } - return { allowed: false }; + return resolveChannelMatchConfig(match, resolveDiscordChannelConfigEntry) ?? { allowed: false }; } export function resolveDiscordShouldRequireMention(params: { diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 070a8ef5b..6df141e35 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -15,6 +15,7 @@ import { } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js"; +import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { sendMessageDiscord } from "../send.js"; import { resolveControlCommandGate } from "../../channels/command-gating.js"; import { @@ -100,9 +101,7 @@ export async function preflightDiscordMessage( }, }) : { allowed: false }; - const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ - allowMatch.matchSource ?? "none" - }`; + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); const permitted = allowMatch.allowed; if (!permitted) { commandAuthorized = false; @@ -262,9 +261,7 @@ export async function preflightDiscordMessage( scope: threadChannel ? "thread" : "channel", }) : null; - const channelMatchMeta = `matchKey=${channelConfig?.matchKey ?? "none"} matchSource=${ - channelConfig?.matchSource ?? "none" - }`; + const channelMatchMeta = formatAllowlistMatchMeta(channelConfig); if (isGuildMessage && channelConfig?.enabled === false) { logVerbose( `Blocked discord channel ${message.channelId} (channel disabled, ${channelMatchMeta})`, diff --git a/src/slack/monitor/channel-config.ts b/src/slack/monitor/channel-config.ts index 2b9a31291..3e3c541c9 100644 --- a/src/slack/monitor/channel-config.ts +++ b/src/slack/monitor/channel-config.ts @@ -1,8 +1,10 @@ import type { SlackReactionNotificationMode } from "../../config/config.js"; import type { SlackMessageEvent } from "../types.js"; import { + applyChannelMatchMeta, buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, + type ChannelMatchSource, } from "../../channels/channel-config.js"; import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; @@ -14,7 +16,7 @@ export type SlackChannelConfigResolved = { skills?: string[]; systemPrompt?: string; matchKey?: string; - matchSource?: "direct" | "wildcard"; + matchSource?: ChannelMatchSource; }; function firstDefined(...values: Array) { @@ -89,16 +91,12 @@ export function resolveSlackChannelConfig(params: { directName, normalizedName, ); - const { - entry: matched, - wildcardEntry: fallback, - matchKey, - matchSource, - } = resolveChannelEntryMatchWithFallback({ + const match = resolveChannelEntryMatchWithFallback({ entries, keys: candidates, wildcardKey: "*", }); + const { entry: matched, wildcardEntry: fallback } = match; const requireMentionDefault = defaultRequireMention ?? true; if (keys.length === 0) { @@ -127,11 +125,7 @@ export function resolveSlackChannelConfig(params: { skills, systemPrompt, }; - if (matchKey) result.matchKey = matchKey; - if (matchSource === "direct" || matchSource === "wildcard") { - result.matchSource = matchSource; - } - return result; + return applyChannelMatchMeta(result, match); } export type { SlackMessageEvent }; diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index caeaac9b3..bd2425103 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -8,6 +8,7 @@ import { createDedupeCache } from "../../infra/dedupe.js"; import { getChildLogger } from "../../logging.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { SlackMessageEvent } from "../types.js"; +import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { normalizeAllowList, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; import { resolveSlackChannelConfig } from "./channel-config.js"; @@ -310,9 +311,7 @@ export function createSlackMonitorContext(params: { channels: params.channelsConfig, defaultRequireMention, }); - const channelMatchMeta = `matchKey=${channelConfig?.matchKey ?? "none"} matchSource=${ - channelConfig?.matchSource ?? "none" - }`; + const channelMatchMeta = formatAllowlistMatchMeta(channelConfig); const channelAllowed = channelConfig?.allowed !== false; const channelAllowlistConfigured = Boolean(params.channelsConfig) && Object.keys(params.channelsConfig ?? {}).length > 0; diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 849c08d31..b1d9a15c6 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -22,6 +22,7 @@ import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js"; import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js"; +import { formatAllowlistMatchMeta } from "../../../channels/allowlist-match.js"; import { readSessionUpdatedAt, recordSessionMetaFromInbound, @@ -131,9 +132,7 @@ export async function prepareSlackMessage(params: { allowList: allowFromLower, id: directUserId, }); - const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ - allowMatch.matchSource ?? "none" - }`; + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); if (!allowMatch.allowed) { if (ctx.dmPolicy === "pairing") { const sender = await ctx.resolveUserName(directUserId); diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 7221d86eb..d8e97dd43 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -21,6 +21,7 @@ import { import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { resolveConversationLabel } from "../../channels/conversation-label.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import type { ResolvedSlackAccount } from "../accounts.js"; @@ -206,9 +207,7 @@ export function registerSlackMonitorSlashCommands(params: { id: command.user_id, name: senderName, }); - const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ - allowMatch.matchSource ?? "none" - }`; + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); if (!allowMatch.allowed) { if (ctx.dmPolicy === "pairing") { const { code, created } = await upsertChannelPairingRequest({ From c913f05fb50b3d96447df60a4e991c274942da84 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 18:21:24 +0000 Subject: [PATCH 036/171] docs(discord): mention wildcard channel defaults --- docs/channels/discord.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 28ecfdfa0..ca6ff6c9c 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -175,6 +175,7 @@ Notes: - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - If `channels` is present, any channel not listed is denied by default. +- Use a `"*"` channel entry to apply defaults across all channels; explicit channel entries override the wildcard. - Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly. - Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered). - Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels..users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. From cf4f1ed03aa6ded9fafba16ee9205ef715649e94 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 18:17:10 +0000 Subject: [PATCH 037/171] fix: persist history image injections --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05909cdf3..68a3d120d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot - CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. ### Fixes +- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. - Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. - macOS: exec approvals now respect wildcard agent allowlists (`*`). - macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-. diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 6aa62cd39..b54ff4087 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -627,6 +627,7 @@ export async function runEmbeddedAttempt( // Inject history images into their original message positions. // This ensures the model sees images in context (e.g., "compare to the first image"). if (imageResult.historyImagesByIndex.size > 0) { + let didMutate = false; for (const [msgIndex, images] of imageResult.historyImagesByIndex) { // Bounds check: ensure index is valid before accessing if (msgIndex < 0 || msgIndex >= activeSession.messages.length) continue; @@ -635,6 +636,7 @@ export async function runEmbeddedAttempt( // Convert string content to array format if needed if (typeof msg.content === "string") { msg.content = [{ type: "text", text: msg.content }]; + didMutate = true; } if (Array.isArray(msg.content)) { // Check for existing image content to avoid duplicates across turns @@ -653,11 +655,16 @@ export async function runEmbeddedAttempt( // Only add if this image isn't already in the message if (!existingImageData.has(img.data)) { msg.content.push(img); + didMutate = true; } } } } } + if (didMutate) { + // Persist message mutations (e.g., injected history images) so we don't re-scan/reload. + activeSession.agent.replaceMessages(activeSession.messages); + } } cacheTrace?.recordStage("prompt:images", { From 6996c0f330cfa03b5889ee2a02ddfd1597d1bfcb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 18:40:09 +0000 Subject: [PATCH 038/171] test: cover history image injection --- .../pi-embedded-runner/run/attempt.test.ts | 55 ++++++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 90 +++++++++++-------- 2 files changed, 106 insertions(+), 39 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run/attempt.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts new file mode 100644 index 000000000..d87cabd1d --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -0,0 +1,55 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { ImageContent } from "@mariozechner/pi-ai"; +import { describe, expect, it } from "vitest"; + +import { injectHistoryImagesIntoMessages } from "./attempt.js"; + +describe("injectHistoryImagesIntoMessages", () => { + const image: ImageContent = { type: "image", data: "abc", mimeType: "image/png" }; + + it("injects history images and converts string content", () => { + const messages: AgentMessage[] = [ + { + role: "user", + content: "See /tmp/photo.png", + } as AgentMessage, + ]; + + const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]])); + + expect(didMutate).toBe(true); + expect(Array.isArray(messages[0]?.content)).toBe(true); + const content = messages[0]?.content as Array<{ type: string; text?: string; data?: string }>; + expect(content).toHaveLength(2); + expect(content[0]?.type).toBe("text"); + expect(content[1]).toMatchObject({ type: "image", data: "abc" }); + }); + + it("avoids duplicating existing image content", () => { + const messages: AgentMessage[] = [ + { + role: "user", + content: [{ type: "text", text: "See /tmp/photo.png" }, { ...image }], + } as AgentMessage, + ]; + + const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]])); + + expect(didMutate).toBe(false); + expect((messages[0]?.content as unknown[]).length).toBe(2); + }); + + it("ignores non-user messages and out-of-range indices", () => { + const messages: AgentMessage[] = [ + { + role: "assistant", + content: "noop", + } as AgentMessage, + ]; + + const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[1, [image]]])); + + expect(didMutate).toBe(false); + expect(messages[0]?.content).toBe("noop"); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b54ff4087..6a1bd3978 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -77,6 +77,50 @@ import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; import { detectAndLoadPromptImages } from "./images.js"; +export function injectHistoryImagesIntoMessages( + messages: AgentMessage[], + historyImagesByIndex: Map, +): boolean { + if (historyImagesByIndex.size === 0) return false; + let didMutate = false; + + for (const [msgIndex, images] of historyImagesByIndex) { + // Bounds check: ensure index is valid before accessing + if (msgIndex < 0 || msgIndex >= messages.length) continue; + const msg = messages[msgIndex]; + if (msg && msg.role === "user") { + // Convert string content to array format if needed + if (typeof msg.content === "string") { + msg.content = [{ type: "text", text: msg.content }]; + didMutate = true; + } + if (Array.isArray(msg.content)) { + // Check for existing image content to avoid duplicates across turns + const existingImageData = new Set( + msg.content + .filter( + (c): c is ImageContent => + c != null && + typeof c === "object" && + c.type === "image" && + typeof c.data === "string", + ) + .map((c) => c.data), + ); + for (const img of images) { + // Only add if this image isn't already in the message + if (!existingImageData.has(img.data)) { + msg.content.push(img); + didMutate = true; + } + } + } + } + } + + return didMutate; +} + export async function runEmbeddedAttempt( params: EmbeddedRunAttemptParams, ): Promise { @@ -626,45 +670,13 @@ export async function runEmbeddedAttempt( // Inject history images into their original message positions. // This ensures the model sees images in context (e.g., "compare to the first image"). - if (imageResult.historyImagesByIndex.size > 0) { - let didMutate = false; - for (const [msgIndex, images] of imageResult.historyImagesByIndex) { - // Bounds check: ensure index is valid before accessing - if (msgIndex < 0 || msgIndex >= activeSession.messages.length) continue; - const msg = activeSession.messages[msgIndex]; - if (msg && msg.role === "user") { - // Convert string content to array format if needed - if (typeof msg.content === "string") { - msg.content = [{ type: "text", text: msg.content }]; - didMutate = true; - } - if (Array.isArray(msg.content)) { - // Check for existing image content to avoid duplicates across turns - const existingImageData = new Set( - msg.content - .filter( - (c): c is ImageContent => - c != null && - typeof c === "object" && - c.type === "image" && - typeof c.data === "string", - ) - .map((c) => c.data), - ); - for (const img of images) { - // Only add if this image isn't already in the message - if (!existingImageData.has(img.data)) { - msg.content.push(img); - didMutate = true; - } - } - } - } - } - if (didMutate) { - // Persist message mutations (e.g., injected history images) so we don't re-scan/reload. - activeSession.agent.replaceMessages(activeSession.messages); - } + const didMutate = injectHistoryImagesIntoMessages( + activeSession.messages, + imageResult.historyImagesByIndex, + ); + if (didMutate) { + // Persist message mutations (e.g., injected history images) so we don't re-scan/reload. + activeSession.agent.replaceMessages(activeSession.messages); } cacheTrace?.recordStage("prompt:images", { From 32550154f982e9471e7a79f63c3ff4d90713e30f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 18:36:10 +0000 Subject: [PATCH 039/171] feat(queue): add per-channel debounce overrides --- CHANGELOG.md | 1 + src/auto-reply/inbound-debounce.ts | 3 +-- src/auto-reply/reply/queue/settings.ts | 21 +++++++++++++++++++++ src/channels/plugins/types.plugin.ts | 5 +++++ src/config/types.messages.ts | 13 +++---------- src/config/zod-schema.core.ts | 13 ++----------- 6 files changed, 33 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a3d120d..e09aa25b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot - Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. - CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. - CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. +- Queue: allow per-channel debounce overrides and plugin defaults. (#1190) Thanks @cheeeee. ### Fixes - Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. diff --git a/src/auto-reply/inbound-debounce.ts b/src/auto-reply/inbound-debounce.ts index 47a29687d..9d43fa8f7 100644 --- a/src/auto-reply/inbound-debounce.ts +++ b/src/auto-reply/inbound-debounce.ts @@ -11,8 +11,7 @@ const resolveChannelOverride = (params: { channel: string; }): number | undefined => { if (!params.byChannel) return undefined; - const channelKey = params.channel as keyof InboundDebounceByProvider; - return resolveMs(params.byChannel[channelKey]); + return resolveMs(params.byChannel[params.channel]); }; export function resolveInboundDebounceMs(params: { diff --git a/src/auto-reply/reply/queue/settings.ts b/src/auto-reply/reply/queue/settings.ts index 5fd9797dc..9591a8bc4 100644 --- a/src/auto-reply/reply/queue/settings.ts +++ b/src/auto-reply/reply/queue/settings.ts @@ -1,3 +1,5 @@ +import { getChannelPlugin } from "../../../channels/plugins/index.js"; +import type { InboundDebounceByProvider } from "../../../config/types.messages.js"; import { normalizeQueueDropPolicy, normalizeQueueMode } from "./normalize.js"; import { DEFAULT_QUEUE_CAP, DEFAULT_QUEUE_DEBOUNCE_MS, DEFAULT_QUEUE_DROP } from "./state.js"; import type { QueueMode, QueueSettings, ResolveQueueSettingsParams } from "./types.js"; @@ -6,6 +8,23 @@ function defaultQueueModeForChannel(_channel?: string): QueueMode { return "collect"; } +/** Resolve per-channel debounce override from debounceMsByChannel map. */ +function resolveChannelDebounce( + byChannel: InboundDebounceByProvider | undefined, + channelKey: string | undefined, +): number | undefined { + if (!channelKey || !byChannel) return undefined; + const value = byChannel[channelKey]; + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : undefined; +} + +function resolvePluginDebounce(channelKey: string | undefined): number | undefined { + if (!channelKey) return undefined; + const plugin = getChannelPlugin(channelKey); + const value = plugin?.defaults?.queue?.debounceMs; + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : undefined; +} + export function resolveQueueSettings(params: ResolveQueueSettingsParams): QueueSettings { const channelKey = params.channel?.trim().toLowerCase(); const queueCfg = params.cfg.messages?.queue; @@ -22,6 +41,8 @@ export function resolveQueueSettings(params: ResolveQueueSettingsParams): QueueS const debounceRaw = params.inlineOptions?.debounceMs ?? params.sessionEntry?.queueDebounceMs ?? + resolveChannelDebounce(queueCfg?.debounceMsByChannel, channelKey) ?? + resolvePluginDebounce(channelKey) ?? queueCfg?.debounceMs ?? DEFAULT_QUEUE_DEBOUNCE_MS; const capRaw = diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 38ed40666..5aeab17d3 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -48,6 +48,11 @@ export type ChannelPlugin = { id: ChannelId; meta: ChannelMeta; capabilities: ChannelCapabilities; + defaults?: { + queue?: { + debounceMs?: number; + }; + }; reload?: { configPrefixes: string[]; noopPrefixes?: string[] }; // CLI onboarding wizard hooks for this channel. onboarding?: ChannelOnboardingAdapter; diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 691ca617a..7499f79a0 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -13,20 +13,13 @@ export type QueueConfig = { mode?: QueueMode; byChannel?: QueueModeByProvider; debounceMs?: number; + /** Per-channel debounce overrides (ms). */ + debounceMsByChannel?: InboundDebounceByProvider; cap?: number; drop?: QueueDropPolicy; }; -export type InboundDebounceByProvider = { - whatsapp?: number; - telegram?: number; - discord?: number; - slack?: number; - signal?: number; - imessage?: number; - msteams?: number; - webchat?: number; -}; +export type InboundDebounceByProvider = Record; export type InboundDebounceConfig = { debounceMs?: number; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 6e7b34b0d..48066963f 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -217,17 +217,7 @@ export const QueueModeBySurfaceSchema = z .optional(); export const DebounceMsBySurfaceSchema = z - .object({ - whatsapp: z.number().int().nonnegative().optional(), - telegram: z.number().int().nonnegative().optional(), - discord: z.number().int().nonnegative().optional(), - slack: z.number().int().nonnegative().optional(), - signal: z.number().int().nonnegative().optional(), - imessage: z.number().int().nonnegative().optional(), - msteams: z.number().int().nonnegative().optional(), - webchat: z.number().int().nonnegative().optional(), - }) - .strict() + .record(z.string(), z.number().int().nonnegative()) .optional(); export const QueueSchema = z @@ -235,6 +225,7 @@ export const QueueSchema = z mode: QueueModeSchema.optional(), byChannel: QueueModeBySurfaceSchema, debounceMs: z.number().int().nonnegative().optional(), + debounceMsByChannel: DebounceMsBySurfaceSchema, cap: z.number().int().positive().optional(), drop: QueueDropSchema.optional(), }) From 403904ecd107ba6a5e01c196f3c1e052fd3c03a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 18:52:26 +0000 Subject: [PATCH 040/171] fix: harden port listener detection --- src/cli/daemon-cli/shared.ts | 32 ++++++++++++++++----------- src/cli/daemon-cli/status.print.ts | 27 +++++++++++++++++++++-- src/cli/ports.ts | 4 +++- src/infra/ports-inspect.test.ts | 35 ++++++++++++++++++++++++++++++ src/infra/ports-inspect.ts | 27 ++++++++++++++++++----- src/infra/ports-lsof.ts | 35 ++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 src/infra/ports-inspect.test.ts create mode 100644 src/infra/ports-lsof.ts diff --git a/src/cli/daemon-cli/shared.ts b/src/cli/daemon-cli/shared.ts index 7be597ac5..1e0d82dce 100644 --- a/src/cli/daemon-cli/shared.ts +++ b/src/cli/daemon-cli/shared.ts @@ -50,22 +50,28 @@ export function pickProbeHostForBind( return "127.0.0.1"; } -export function safeDaemonEnv(env: Record | undefined): string[] { - if (!env) return []; - const allow = [ - "CLAWDBOT_PROFILE", - "CLAWDBOT_STATE_DIR", - "CLAWDBOT_CONFIG_PATH", - "CLAWDBOT_GATEWAY_PORT", - "CLAWDBOT_NIX_MODE", - ]; - const lines: string[] = []; - for (const key of allow) { +const SAFE_DAEMON_ENV_KEYS = [ + "CLAWDBOT_PROFILE", + "CLAWDBOT_STATE_DIR", + "CLAWDBOT_CONFIG_PATH", + "CLAWDBOT_GATEWAY_PORT", + "CLAWDBOT_NIX_MODE", +]; + +export function filterDaemonEnv(env: Record | undefined): Record { + if (!env) return {}; + const filtered: Record = {}; + for (const key of SAFE_DAEMON_ENV_KEYS) { const value = env[key]; if (!value?.trim()) continue; - lines.push(`${key}=${value.trim()}`); + filtered[key] = value.trim(); } - return lines; + return filtered; +} + +export function safeDaemonEnv(env: Record | undefined): string[] { + const filtered = filterDaemonEnv(env); + return Object.entries(filtered).map(([key, value]) => `${key}=${value}`); } export function normalizeListenerAddress(raw: string): string { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index eb7f9f500..931dc3f10 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -14,16 +14,39 @@ import { getResolvedLoggerSettings } from "../../logging.js"; import { defaultRuntime } from "../../runtime.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; import { formatCliCommand } from "../command-format.js"; -import { formatRuntimeStatus, renderRuntimeHints, safeDaemonEnv } from "./shared.js"; +import { + filterDaemonEnv, + formatRuntimeStatus, + renderRuntimeHints, + safeDaemonEnv, +} from "./shared.js"; import { type DaemonStatus, renderPortDiagnosticsForCli, resolvePortListeningAddresses, } from "./status.gather.js"; +function sanitizeDaemonStatusForJson(status: DaemonStatus): DaemonStatus { + const command = status.service.command; + if (!command?.environment) return status; + const safeEnv = filterDaemonEnv(command.environment); + const nextCommand = { + ...command, + environment: Object.keys(safeEnv).length > 0 ? safeEnv : undefined, + }; + return { + ...status, + service: { + ...status.service, + command: nextCommand, + }, + }; +} + export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { if (opts.json) { - defaultRuntime.log(JSON.stringify(status, null, 2)); + const sanitized = sanitizeDaemonStatusForJson(status); + defaultRuntime.log(JSON.stringify(sanitized, null, 2)); return; } diff --git a/src/cli/ports.ts b/src/cli/ports.ts index afa4210af..5384c056b 100644 --- a/src/cli/ports.ts +++ b/src/cli/ports.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { resolveLsofCommandSync } from "../infra/ports-lsof.js"; export type PortProcess = { pid: number; command?: string }; @@ -30,7 +31,8 @@ export function parseLsofOutput(output: string): PortProcess[] { export function listPortListeners(port: number): PortProcess[] { try { - const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFc"], { + const lsof = resolveLsofCommandSync(); + const out = execFileSync(lsof, ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFc"], { encoding: "utf-8", }); return parseLsofOutput(out); diff --git a/src/infra/ports-inspect.test.ts b/src/infra/ports-inspect.test.ts new file mode 100644 index 000000000..8aaeff424 --- /dev/null +++ b/src/infra/ports-inspect.test.ts @@ -0,0 +1,35 @@ +import net from "node:net"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runCommandWithTimeoutMock = vi.fn(); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + +const describeUnix = process.platform === "win32" ? describe.skip : describe; + +describeUnix("inspectPortUsage", () => { + beforeEach(() => { + runCommandWithTimeoutMock.mockReset(); + }); + + it("reports busy when lsof is missing but loopback listener exists", async () => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as net.AddressInfo).port; + + runCommandWithTimeoutMock.mockRejectedValueOnce( + Object.assign(new Error("spawn lsof ENOENT"), { code: "ENOENT" }), + ); + + try { + const { inspectPortUsage } = await import("./ports-inspect.js"); + const result = await inspectPortUsage(port); + expect(result.status).toBe("busy"); + expect(result.errors?.some((err) => err.includes("ENOENT"))).toBe(true); + } finally { + server.close(); + } + }); +}); diff --git a/src/infra/ports-inspect.ts b/src/infra/ports-inspect.ts index 124c1255b..767480ced 100644 --- a/src/infra/ports-inspect.ts +++ b/src/infra/ports-inspect.ts @@ -1,5 +1,6 @@ import net from "node:net"; import { runCommandWithTimeout } from "../process/exec.js"; +import { resolveLsofCommand } from "./ports-lsof.js"; import { buildPortHints } from "./ports-format.js"; import type { PortListener, PortUsage, PortUsageStatus } from "./ports-types.js"; @@ -71,7 +72,8 @@ async function readUnixListeners( port: number, ): Promise<{ listeners: PortListener[]; detail?: string; errors: string[] }> { const errors: string[] = []; - const res = await runCommandSafe(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFcn"]); + const lsof = await resolveLsofCommand(); + const res = await runCommandSafe([lsof, "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFcn"]); if (res.code === 0) { const listeners = parseLsofFieldOutput(res.stdout); await Promise.all( @@ -87,11 +89,12 @@ async function readUnixListeners( ); return { listeners, detail: res.stdout.trim() || undefined, errors }; } - if (res.code === 1) { + const stderr = res.stderr.trim(); + if (res.code === 1 && !res.error && !stderr) { return { listeners: [], detail: undefined, errors }; } if (res.error) errors.push(res.error); - const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n"); + const detail = [stderr, res.stdout.trim()].filter(Boolean).join("\n"); if (detail) errors.push(detail); return { listeners: [], detail: undefined, errors }; } @@ -175,7 +178,7 @@ async function readWindowsListeners( return { listeners, detail: res.stdout.trim() || undefined, errors }; } -async function checkPortInUse(port: number): Promise { +async function tryListenOnHost(port: number, host: string): Promise { try { await new Promise((resolve, reject) => { const tester = net @@ -184,15 +187,29 @@ async function checkPortInUse(port: number): Promise { .once("listening", () => { tester.close(() => resolve()); }) - .listen(port); + .listen({ port, host, exclusive: true }); }); return "free"; } catch (err) { if (isErrno(err) && err.code === "EADDRINUSE") return "busy"; + if (isErrno(err) && (err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT")) { + return "skip"; + } return "unknown"; } } +async function checkPortInUse(port: number): Promise { + const hosts = ["127.0.0.1", "0.0.0.0", "::1", "::"]; + let sawUnknown = false; + for (const host of hosts) { + const result = await tryListenOnHost(port, host); + if (result === "busy") return "busy"; + if (result === "unknown") sawUnknown = true; + } + return sawUnknown ? "unknown" : "free"; +} + export async function inspectPortUsage(port: number): Promise { const errors: string[] = []; const result = diff --git a/src/infra/ports-lsof.ts b/src/infra/ports-lsof.ts new file mode 100644 index 000000000..4b5f01a6a --- /dev/null +++ b/src/infra/ports-lsof.ts @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; + +const LSOF_CANDIDATES = + process.platform === "darwin" + ? ["/usr/sbin/lsof", "/usr/bin/lsof"] + : ["/usr/bin/lsof", "/usr/sbin/lsof"]; + +async function canExecute(path: string): Promise { + try { + await fsPromises.access(path, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +export async function resolveLsofCommand(): Promise { + for (const candidate of LSOF_CANDIDATES) { + if (await canExecute(candidate)) return candidate; + } + return "lsof"; +} + +export function resolveLsofCommandSync(): string { + for (const candidate of LSOF_CANDIDATES) { + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // keep trying + } + } + return "lsof"; +} From c415ccaed5180f8ff8e871584515e002f3fa42e2 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 21 Jan 2026 13:10:31 -0600 Subject: [PATCH 041/171] feat(sessions): add channelIdleMinutes config for per-channel session idle durations (#1353) * feat(sessions): add channelIdleMinutes config for per-channel session idle durations Add new `channelIdleMinutes` config option to allow different session idle timeouts per channel. For example, Discord sessions can now be configured to last 7 days (10080 minutes) while other channels use shorter defaults. Config example: sessions: channelIdleMinutes: discord: 10080 # 7 days The channel-specific idle is passed as idleMinutesOverride to the existing resolveSessionResetPolicy, integrating cleanly with the new reset policy architecture. * fix * feat: add per-channel session reset overrides (#1353) (thanks @cash-echo-bot) --------- Co-authored-by: Cash Williams Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 +- docs/concepts/session.md | 4 ++ docs/gateway/configuration-examples.md | 4 +- docs/gateway/configuration.md | 5 ++- .../pi-embedded-runner/run/attempt.test.ts | 4 +- src/auto-reply/reply/session.test.ts | 39 +++++++++++++++++++ src/auto-reply/reply/session.ts | 17 +++++++- src/commands/agent/session.ts | 11 +++++- src/config/sessions/reset.ts | 30 +++++++++----- src/config/types.base.ts | 3 +- src/config/zod-schema.session.ts | 2 +- src/infra/fetch.ts | 5 ++- src/web/auto-reply/session-snapshot.test.ts | 14 ++++--- src/web/auto-reply/session-snapshot.ts | 11 ++++-- 14 files changed, 123 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e09aa25b9..a55377fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Docs: https://docs.clawd.bot - Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. - CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. - CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. -- Queue: allow per-channel debounce overrides and plugin defaults. (#1190) Thanks @cheeeee. +- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. ### Fixes - Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index c88e2ddfe..48c85af09 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -60,6 +60,7 @@ the workspace is writable. See [Memory](/concepts/memory) and - Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session. - Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility. - Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector). +- Per-channel overrides (optional): `resetByChannel` overrides the reset policy for a channel (applies to all session types for that channel and takes precedence over `reset`/`resetByType`). - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new ` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset. - Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them. - Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse). @@ -109,6 +110,9 @@ Send these as standalone messages so they register. dm: { mode: "idle", idleMinutes: 240 }, group: { mode: "idle", idleMinutes: 120 } }, + resetByChannel: { + discord: { mode: "idle", idleMinutes: 10080 } + }, resetTriggers: ["/new", "/reset"], store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", mainKey: "main", diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 793ece412..91b40beac 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -151,7 +151,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number. atHour: 4, idleMinutes: 60 }, - heartbeatIdleMinutes: 120, + resetByChannel: { + discord: { mode: "idle", idleMinutes: 10080 } + }, resetTriggers: ["/new", "/reset"], store: "~/.clawdbot/agents/default/sessions/sessions.json", typingIntervalSeconds: 5, diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index a17308344..9eddda3f8 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2453,6 +2453,9 @@ Controls session scoping, reset policy, reset triggers, and where the session st dm: { mode: "idle", idleMinutes: 240 }, group: { mode: "idle", idleMinutes: 120 } }, + resetByChannel: { + discord: { mode: "idle", idleMinutes: 10080 } + }, resetTriggers: ["/new", "/reset"], // Default is already per-agent under ~/.clawdbot/agents//sessions/sessions.json // You can override with {agentId} templating: @@ -2488,7 +2491,7 @@ Fields: - `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins. - `resetByType`: per-session overrides for `dm`, `group`, and `thread`. - If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility. -- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled). +- `resetByChannel`: channel-specific reset policy overrides (keyed by channel id, applies to all session types for that channel; overrides `reset`/`resetByType`). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index d87cabd1d..af1d97828 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -36,7 +36,9 @@ describe("injectHistoryImagesIntoMessages", () => { const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]])); expect(didMutate).toBe(false); - expect((messages[0]?.content as unknown[]).length).toBe(2); + const content = messages[0]?.content as unknown[] | undefined; + expect(content).toBeDefined(); + expect(content).toHaveLength(2); }); it("ignores non-user messages and out-of-range indices", () => { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index dca2ae1c8..af548ecb0 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -436,3 +436,42 @@ describe("initSessionState reset policy", () => { } }); }); + +describe("initSessionState channel reset overrides", () => { + it("uses channel-specific reset policy when configured", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-channel-idle-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:discord:dm:123"; + const sessionId = "session-override"; + const updatedAt = Date.now() - (10080 - 1) * 60_000; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId, + updatedAt, + }, + }); + + const cfg = { + session: { + store: storePath, + idleMinutes: 60, + resetByType: { dm: { mode: "idle", idleMinutes: 10 } }, + resetByChannel: { discord: { mode: "idle", idleMinutes: 10080 } }, + }, + } as ClawdbotConfig; + + const result = await initSessionState({ + ctx: { + Body: "Hello", + SessionKey: sessionKey, + Provider: "discord", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionEntry.sessionId).toBe(sessionId); + }); +}); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 590b9b7d6..da8ca8acf 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -11,6 +11,7 @@ import { evaluateSessionFreshness, type GroupKeyResolution, loadSessionStore, + resolveChannelResetConfig, resolveThreadFlag, resolveSessionResetPolicy, resolveSessionResetType, @@ -106,6 +107,7 @@ export async function initSessionState(params: { sessionKey: sessionCtxForState.SessionKey, config: cfg, }); + const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined; const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers : DEFAULT_RESET_TRIGGERS; @@ -129,7 +131,6 @@ export async function initSessionState(params: { let persistedModelOverride: string | undefined; let persistedProviderOverride: string | undefined; - const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined; const normalizedChatType = normalizeChatType(ctx.ChatType); const isGroup = normalizedChatType != null && normalizedChatType !== "direct" ? true : Boolean(groupResolution); @@ -195,7 +196,19 @@ export async function initSessionState(params: { parentSessionKey: ctx.ParentSessionKey, }); const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread }); - const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType }); + const channelReset = resolveChannelResetConfig({ + sessionCfg, + channel: + groupResolution?.channel ?? + (ctx.OriginatingChannel as string | undefined) ?? + ctx.Surface ?? + ctx.Provider, + }); + const resetPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType, + resetOverride: channelReset, + }); const freshEntry = entry ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh : false; diff --git a/src/commands/agent/session.ts b/src/commands/agent/session.ts index ab9da524d..60860f0ec 100644 --- a/src/commands/agent/session.ts +++ b/src/commands/agent/session.ts @@ -12,6 +12,7 @@ import { evaluateSessionFreshness, loadSessionStore, resolveAgentIdFromSessionKey, + resolveChannelResetConfig, resolveExplicitAgentSessionKey, resolveSessionResetPolicy, resolveSessionResetType, @@ -99,7 +100,15 @@ export function resolveSession(opts: { const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined; const resetType = resolveSessionResetType({ sessionKey }); - const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType }); + const channelReset = resolveChannelResetConfig({ + sessionCfg, + channel: sessionEntry?.lastChannel ?? sessionEntry?.channel, + }); + const resetPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType, + resetOverride: channelReset, + }); const fresh = sessionEntry ? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy }) .fresh diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index 3355e4052..45d54ad4f 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -1,5 +1,6 @@ -import type { SessionConfig } from "../types.base.js"; +import type { SessionConfig, SessionResetConfig } from "../types.base.js"; import { DEFAULT_IDLE_MINUTES } from "./types.js"; +import { normalizeMessageChannel } from "../../utils/message-channel.js"; export type SessionResetMode = "daily" | "idle"; export type SessionResetType = "dm" | "group" | "thread"; @@ -67,13 +68,13 @@ export function resolveDailyResetAtMs(now: number, atHour: number): number { export function resolveSessionResetPolicy(params: { sessionCfg?: SessionConfig; resetType: SessionResetType; - idleMinutesOverride?: number; + resetOverride?: SessionResetConfig; }): SessionResetPolicy { const sessionCfg = params.sessionCfg; - const baseReset = sessionCfg?.reset; - const typeReset = sessionCfg?.resetByType?.[params.resetType]; + const baseReset = params.resetOverride ?? sessionCfg?.reset; + const typeReset = params.resetOverride ? undefined : sessionCfg?.resetByType?.[params.resetType]; const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType); - const legacyIdleMinutes = sessionCfg?.idleMinutes; + const legacyIdleMinutes = params.resetOverride ? undefined : sessionCfg?.idleMinutes; const mode = typeReset?.mode ?? baseReset?.mode ?? @@ -81,11 +82,7 @@ export function resolveSessionResetPolicy(params: { const atHour = normalizeResetAtHour( typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR, ); - const idleMinutesRaw = - params.idleMinutesOverride ?? - typeReset?.idleMinutes ?? - baseReset?.idleMinutes ?? - legacyIdleMinutes; + const idleMinutesRaw = typeReset?.idleMinutes ?? baseReset?.idleMinutes ?? legacyIdleMinutes; let idleMinutes: number | undefined; if (idleMinutesRaw != null) { @@ -100,6 +97,19 @@ export function resolveSessionResetPolicy(params: { return { mode, atHour, idleMinutes }; } +export function resolveChannelResetConfig(params: { + sessionCfg?: SessionConfig; + channel?: string | null; +}): SessionResetConfig | undefined { + const resetByChannel = params.sessionCfg?.resetByChannel; + if (!resetByChannel) return undefined; + const normalized = normalizeMessageChannel(params.channel); + const fallback = params.channel?.trim().toLowerCase(); + const key = normalized ?? fallback; + if (!key) return undefined; + return resetByChannel[key] ?? resetByChannel[key.toLowerCase()]; +} + export function evaluateSessionFreshness(params: { updatedAt: number; now: number; diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 0796fa64c..8d7613936 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -77,9 +77,10 @@ export type SessionConfig = { identityLinks?: Record; resetTriggers?: string[]; idleMinutes?: number; - heartbeatIdleMinutes?: number; reset?: SessionResetConfig; resetByType?: SessionResetByTypeConfig; + /** Channel-specific reset overrides (e.g. { discord: { mode: "idle", idleMinutes: 10080 } }). */ + resetByChannel?: Record; store?: string; typingIntervalSeconds?: number; typingMode?: TypingMode; diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 6a8ad114a..6cc3084d6 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -24,7 +24,6 @@ export const SessionSchema = z identityLinks: z.record(z.string(), z.array(z.string())).optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), - heartbeatIdleMinutes: z.number().int().positive().optional(), reset: SessionResetConfigSchema.optional(), resetByType: z .object({ @@ -34,6 +33,7 @@ export const SessionSchema = z }) .strict() .optional(), + resetByChannel: z.record(z.string(), SessionResetConfigSchema).optional(), store: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), typingMode: z diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts index 9cd10c25f..2f9993c7a 100644 --- a/src/infra/fetch.ts +++ b/src/infra/fetch.ts @@ -31,8 +31,11 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch return response; }) as FetchWithPreconnect; + const fetchWithPreconnect = fetchImpl as FetchWithPreconnect; wrapped.preconnect = - typeof fetchImpl.preconnect === "function" ? fetchImpl.preconnect.bind(fetchImpl) : () => {}; + typeof fetchWithPreconnect.preconnect === "function" + ? fetchWithPreconnect.preconnect.bind(fetchWithPreconnect) + : () => {}; return Object.assign(wrapped, fetchImpl); } diff --git a/src/web/auto-reply/session-snapshot.test.ts b/src/web/auto-reply/session-snapshot.test.ts index 82fa5dbf1..e6cf013c0 100644 --- a/src/web/auto-reply/session-snapshot.test.ts +++ b/src/web/auto-reply/session-snapshot.test.ts @@ -8,7 +8,7 @@ import { saveSessionStore } from "../../config/sessions.js"; import { getSessionSnapshot } from "./session-snapshot.js"; describe("getSessionSnapshot", () => { - it("uses heartbeat idle override while daily reset still applies", async () => { + it("uses channel reset overrides when configured", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); try { @@ -20,6 +20,7 @@ describe("getSessionSnapshot", () => { [sessionKey]: { sessionId: "snapshot-session", updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), + lastChannel: "whatsapp", }, }); @@ -27,7 +28,9 @@ describe("getSessionSnapshot", () => { session: { store: storePath, reset: { mode: "daily", atHour: 4, idleMinutes: 240 }, - heartbeatIdleMinutes: 30, + resetByChannel: { + whatsapp: { mode: "idle", idleMinutes: 360 }, + }, }, } as Parameters[0]; @@ -35,9 +38,10 @@ describe("getSessionSnapshot", () => { sessionKey, }); - expect(snapshot.resetPolicy.idleMinutes).toBe(30); - expect(snapshot.fresh).toBe(false); - expect(snapshot.dailyResetAt).toBe(new Date(2026, 0, 18, 4, 0, 0).getTime()); + expect(snapshot.resetPolicy.mode).toBe("idle"); + expect(snapshot.resetPolicy.idleMinutes).toBe(360); + expect(snapshot.fresh).toBe(true); + expect(snapshot.dailyResetAt).toBeUndefined(); } finally { vi.useRealTimers(); } diff --git a/src/web/auto-reply/session-snapshot.ts b/src/web/auto-reply/session-snapshot.ts index 051c29972..12a5619e6 100644 --- a/src/web/auto-reply/session-snapshot.ts +++ b/src/web/auto-reply/session-snapshot.ts @@ -2,6 +2,7 @@ import type { loadConfig } from "../../config/config.js"; import { evaluateSessionFreshness, loadSessionStore, + resolveChannelResetConfig, resolveThreadFlag, resolveSessionResetPolicy, resolveSessionResetType, @@ -13,7 +14,7 @@ import { normalizeMainKey } from "../../routing/session-key.js"; export function getSessionSnapshot( cfg: ReturnType, from: string, - isHeartbeat = false, + _isHeartbeat = false, ctx?: { sessionKey?: string | null; isGroup?: boolean; @@ -34,6 +35,7 @@ export function getSessionSnapshot( ); const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); const entry = store[key]; + const isThread = resolveThreadFlag({ sessionKey: key, messageThreadId: ctx?.messageThreadId ?? null, @@ -42,11 +44,14 @@ export function getSessionSnapshot( parentSessionKey: ctx?.parentSessionKey ?? null, }); const resetType = resolveSessionResetType({ sessionKey: key, isGroup: ctx?.isGroup, isThread }); - const idleMinutesOverride = isHeartbeat ? sessionCfg?.heartbeatIdleMinutes : undefined; + const channelReset = resolveChannelResetConfig({ + sessionCfg, + channel: entry?.lastChannel ?? entry?.channel, + }); const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType, - idleMinutesOverride, + resetOverride: channelReset, }); const now = Date.now(); const freshness = entry From 9f59ff325b66125e348b4d1a038b42ebd4e48216 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 19:44:20 +0000 Subject: [PATCH 042/171] feat: add cache-ttl pruning mode --- CHANGELOG.md | 1 + docs/concepts/session-pruning.md | 34 +++++------- docs/gateway/configuration.md | 50 ++++++++---------- docs/providers/anthropic.md | 6 +-- src/agents/pi-embedded-runner/cache-ttl.ts | 52 +++++++++++++++++++ src/agents/pi-embedded-runner/extensions.ts | 5 +- src/agents/pi-embedded-runner/extra-params.ts | 4 +- .../pi-embedded-runner/run/attempt.test.ts | 6 ++- src/agents/pi-embedded-runner/run/attempt.ts | 12 +++++ .../pi-extensions/context-pruning.test.ts | 22 +++++--- .../context-pruning/extension.ts | 11 ++++ .../pi-extensions/context-pruning/pruner.ts | 28 ---------- .../pi-extensions/context-pruning/runtime.ts | 1 + .../pi-extensions/context-pruning/settings.ts | 23 ++++++-- src/config/config.pruning-defaults.test.ts | 4 +- src/config/defaults.ts | 19 +------ src/config/types.agent-defaults.ts | 4 +- src/config/zod-schema.agent-defaults.ts | 5 +- 18 files changed, 164 insertions(+), 123 deletions(-) create mode 100644 src/agents/pi-embedded-runner/cache-ttl.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a55377fff..5c5b81a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Docs: https://docs.clawd.bot ## 2026.1.21 ### Changes +- Caching: make tool-result pruning TTL-aware so cache reuse stays stable and token usage drops. - CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. - CLI: exec approvals mutations render tables instead of raw JSON. - Exec approvals: support wildcard agent allowlists (`*`) across all agents. diff --git a/docs/concepts/session-pruning.md b/docs/concepts/session-pruning.md index 4e76f9bb4..5e91b9fb8 100644 --- a/docs/concepts/session-pruning.md +++ b/docs/concepts/session-pruning.md @@ -9,8 +9,10 @@ read_when: Session pruning trims **old tool results** from the in-memory context right before each LLM call. It does **not** rewrite the on-disk session history (`*.jsonl`). ## When it runs -- Before each LLM request (context hook). +- When `mode: "cache-ttl"` is enabled and the last Anthropic call for the session is older than `ttl`. - Only affects the messages sent to the model for that request. + - Only active for Anthropic API calls (and OpenRouter Anthropic models). + - For best results, match `ttl` to your model `cacheControlTtl`. ## What can be pruned - Only `toolResult` messages. @@ -26,14 +28,10 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz 3) `agents.defaults.contextTokens`. 4) Default `200000` tokens. -## Modes -### adaptive -- If estimated context ratio ≥ `softTrimRatio`: soft-trim oversized tool results. -- If still ≥ `hardClearRatio` **and** prunable tool text ≥ `minPrunableToolChars`: hard-clear oldest eligible tool results. - -### aggressive -- Always hard-clears eligible tool results before the cutoff. -- Ignores `hardClear.enabled` (always clears when eligible). +## Mode +### cache-ttl +- Pruning only runs if the last Anthropic call is older than `ttl` (default `5m`). +- When it runs: same soft-trim + hard-clear behavior as before. ## Soft vs hard pruning - **Soft-trim**: only for oversized tool results. @@ -52,6 +50,7 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz - Compaction is separate: compaction summarizes and persists, pruning is transient per request. See [/concepts/compaction](/concepts/compaction). ## Defaults (when enabled) +- `ttl`: `"5m"` - `keepLastAssistants`: `3` - `softTrimRatio`: `0.3` - `hardClearRatio`: `0.5` @@ -60,16 +59,7 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz - `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }` ## Examples -Default (adaptive): -```json5 -{ - agent: { - contextPruning: { mode: "adaptive" } - } -} -``` - -To disable: +Default (off): ```json5 { agent: { @@ -78,11 +68,11 @@ To disable: } ``` -Aggressive: +Enable TTL-aware pruning: ```json5 { agent: { - contextPruning: { mode: "aggressive" } + contextPruning: { mode: "cache-ttl", ttl: "5m" } } } ``` @@ -92,7 +82,7 @@ Restrict pruning to specific tools: { agent: { contextPruning: { - mode: "adaptive", + mode: "cache-ttl", tools: { allow: ["exec", "read"], deny: ["*image*"] } } } diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 9eddda3f8..ddce68e79 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include: - `alias` (optional model shortcut, e.g. `/opus`). - `params` (optional provider-specific API params passed through to the model request). -`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Anthropic API defaults to `"1h"` unless you override (`cacheControlTtl: "5m"`). Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers. +`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers. Example: @@ -1569,7 +1569,7 @@ Example: } ``` -#### `agents.defaults.contextPruning` (tool-result pruning) +#### `agents.defaults.contextPruning` (TTL-aware tool-result pruning) `agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM. It does **not** modify the session history on disk (`*.jsonl` remains complete). @@ -1580,11 +1580,9 @@ High level: - Never touches user/assistant messages. - Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned). - Protects the bootstrap prefix (nothing before the first user message is pruned). -- Modes: - - `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`. - Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and** - there’s enough prunable tool-result bulk (`minPrunableToolChars`). - - `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks). +- Mode: + - `cache-ttl`: pruning only runs when the last Anthropic call for the session is **older** than `ttl`. + When it runs, it uses the same soft-trim + hard-clear behavior as before. Soft vs hard pruning (what changes in the context sent to the LLM): - **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle. @@ -1598,44 +1596,40 @@ Notes / current limitations: - Tool results containing **image blocks are skipped** (never trimmed/cleared) right now. - The estimated “context ratio” is based on **characters** (approximate), not exact tokens. - If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped. -- In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`). +- `cache-ttl` only activates for Anthropic API calls (and OpenRouter Anthropic models). +- For best results, match `contextPruning.ttl` to the model `cacheControlTtl` you set in `agents.defaults.models.*.params`. -Default (adaptive): -```json5 -{ - agents: { defaults: { contextPruning: { mode: "adaptive" } } } -} -``` - -To disable: +Default (off): ```json5 { agents: { defaults: { contextPruning: { mode: "off" } } } } ``` -Defaults (when `mode` is `"adaptive"` or `"aggressive"`): -- `keepLastAssistants`: `3` -- `softTrimRatio`: `0.3` (adaptive only) -- `hardClearRatio`: `0.5` (adaptive only) -- `minPrunableToolChars`: `50000` (adaptive only) -- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` (adaptive only) -- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }` - -Example (aggressive, minimal): +Enable TTL-aware pruning: ```json5 { - agents: { defaults: { contextPruning: { mode: "aggressive" } } } + agents: { defaults: { contextPruning: { mode: "cache-ttl" } } } } ``` -Example (adaptive tuned): +Defaults (when `mode` is `"cache-ttl"`): +- `ttl`: `"5m"` +- `keepLastAssistants`: `3` +- `softTrimRatio`: `0.3` +- `hardClearRatio`: `0.5` +- `minPrunableToolChars`: `50000` +- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` +- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }` + +Example (cache-ttl tuned): ```json5 { agents: { defaults: { contextPruning: { - mode: "adaptive", + mode: "cache-ttl", + ttl: "5m", keepLastAssistants: 3, softTrimRatio: 0.3, hardClearRatio: 0.5, diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 9c72b990a..80cbe5d8b 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -36,10 +36,10 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY" ## Prompt caching (Anthropic API) -Clawdbot enables **1-hour prompt caching by default** for Anthropic API keys. +Clawdbot does **not** override Anthropic’s default cache TTL unless you set it. This is **API-only**; Claude Code CLI OAuth ignores TTL settings. -To override the TTL per model, set `cacheControlTtl` in the model `params`: +To set the TTL per model, use `cacheControlTtl` in the model `params`: ```json5 { @@ -47,7 +47,7 @@ To override the TTL per model, set `cacheControlTtl` in the model `params`: defaults: { models: { "anthropic/claude-opus-4-5": { - params: { cacheControlTtl: "5m" } // or "1h" + params: { cacheControlTtl: "5m" } } } } diff --git a/src/agents/pi-embedded-runner/cache-ttl.ts b/src/agents/pi-embedded-runner/cache-ttl.ts new file mode 100644 index 000000000..a280653f1 --- /dev/null +++ b/src/agents/pi-embedded-runner/cache-ttl.ts @@ -0,0 +1,52 @@ +type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown }; + +export const CACHE_TTL_CUSTOM_TYPE = "clawdbot.cache-ttl"; + +export type CacheTtlEntryData = { + timestamp: number; + provider?: string; + modelId?: string; +}; + +export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean { + const normalizedProvider = provider.toLowerCase(); + const normalizedModelId = modelId.toLowerCase(); + if (normalizedProvider === "anthropic") return true; + if (normalizedProvider === "openrouter" && normalizedModelId.startsWith("anthropic/")) + return true; + return false; +} + +export function readLastCacheTtlTimestamp(sessionManager: unknown): number | null { + const sm = sessionManager as { getEntries?: () => CustomEntryLike[] }; + if (!sm?.getEntries) return null; + try { + const entries = sm.getEntries(); + let last: number | null = null; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry?.type !== "custom" || entry?.customType !== CACHE_TTL_CUSTOM_TYPE) continue; + const data = entry?.data as Partial | undefined; + const ts = typeof data?.timestamp === "number" ? data.timestamp : null; + if (ts && Number.isFinite(ts)) { + last = ts; + break; + } + } + return last; + } catch { + return null; + } +} + +export function appendCacheTtlTimestamp(sessionManager: unknown, data: CacheTtlEntryData): void { + const sm = sessionManager as { + appendCustomEntry?: (customType: string, data: unknown) => void; + }; + if (!sm?.appendCustomEntry) return; + try { + sm.appendCustomEntry(CACHE_TTL_CUSTOM_TYPE, data); + } catch { + // ignore persistence failures + } +} diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index a7fd27ebb..48d9d22e6 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -11,6 +11,7 @@ import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runti import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js"; import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js"; import { ensurePiCompactionReserveTokens } from "../pi-settings.js"; +import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "./cache-ttl.js"; function resolvePiExtensionPath(id: string): string { const self = fileURLToPath(import.meta.url); @@ -43,7 +44,8 @@ function buildContextPruningExtension(params: { model: Model | undefined; }): { additionalExtensionPaths?: string[] } { const raw = params.cfg?.agents?.defaults?.contextPruning; - if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {}; + if (raw?.mode !== "cache-ttl") return {}; + if (!isCacheTtlEligibleProvider(params.provider, params.modelId)) return {}; const settings = computeEffectiveSettings(raw); if (!settings) return {}; @@ -52,6 +54,7 @@ function buildContextPruningExtension(params: { settings, contextWindowTokens: resolveContextWindowTokens(params), isToolPrunable: makeToolPrunablePredicate(settings.tools), + lastCacheTouchAt: readLastCacheTtlTimestamp(params.sessionManager), }); return { diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 11f2ab83a..f6a4490a4 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -21,7 +21,7 @@ export function resolveExtraParams(params: { return modelConfig?.params ? { ...modelConfig.params } : undefined; } -type CacheControlTtl = "5m" | "1h"; +type CacheControlTtl = "5m"; function resolveCacheControlTtl( extraParams: Record | undefined, @@ -29,7 +29,7 @@ function resolveCacheControlTtl( modelId: string, ): CacheControlTtl | undefined { const raw = extraParams?.cacheControlTtl; - if (raw !== "5m" && raw !== "1h") return undefined; + if (raw !== "5m") return undefined; if (provider === "anthropic") return raw; if (provider === "openrouter" && modelId.startsWith("anthropic/")) return raw; return undefined; diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index af1d97828..93d5b5651 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -36,8 +36,10 @@ describe("injectHistoryImagesIntoMessages", () => { const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]])); expect(didMutate).toBe(false); - const content = messages[0]?.content as unknown[] | undefined; - expect(content).toBeDefined(); + const content = messages[0]?.content; + if (!Array.isArray(content)) { + throw new Error("expected array content"); + } expect(content).toHaveLength(2); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 6a1bd3978..e2e3a39dd 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -49,6 +49,7 @@ import { resolveDefaultModelForAgent } from "../../model-selection.js"; import { isAbortError } from "../abort.js"; import { buildEmbeddedExtensionPaths } from "../extensions.js"; import { applyExtraParamsToAgent } from "../extra-params.js"; +import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js"; import { logToolSchemasForGoogle, sanitizeSessionHistory, @@ -685,6 +686,17 @@ export async function runEmbeddedAttempt( note: `images: prompt=${imageResult.images.length} history=${imageResult.historyImagesByIndex.size}`, }); + const shouldTrackCacheTtl = + params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" && + isCacheTtlEligibleProvider(params.provider, params.modelId); + if (shouldTrackCacheTtl) { + appendCacheTtlTimestamp(sessionManager, { + timestamp: Date.now(), + provider: params.provider, + modelId: params.modelId, + }); + } + // Only pass images option if there are actually images to pass // This avoids potential issues with models that don't expect the images parameter if (imageResult.images.length > 0) { diff --git a/src/agents/pi-extensions/context-pruning.test.ts b/src/agents/pi-extensions/context-pruning.test.ts index 7dc8e6c59..b316ef87f 100644 --- a/src/agents/pi-extensions/context-pruning.test.ts +++ b/src/agents/pi-extensions/context-pruning.test.ts @@ -135,12 +135,15 @@ describe("context-pruning", () => { }); it("never prunes tool results before the first user message", () => { - const settings = computeEffectiveSettings({ - mode: "aggressive", + const settings = { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS, keepLastAssistants: 0, - hardClear: { placeholder: "[cleared]" }, - }); - if (!settings) throw new Error("expected settings"); + softTrimRatio: 0.0, + hardClearRatio: 0.0, + minPrunableToolChars: 0, + hardClear: { enabled: true, placeholder: "[cleared]" }, + softTrim: { maxChars: 10, headChars: 3, tailChars: 3 }, + }; const messages: AgentMessage[] = [ makeAssistant("bootstrap tool calls"), @@ -170,7 +173,7 @@ describe("context-pruning", () => { expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]"); }); - it("mode aggressive clears eligible tool results before cutoff", () => { + it("hard-clear removes eligible tool results before cutoff", () => { const messages: AgentMessage[] = [ makeUser("u1"), makeAssistant("a1"), @@ -195,9 +198,11 @@ describe("context-pruning", () => { const settings = { ...DEFAULT_CONTEXT_PRUNING_SETTINGS, - mode: "aggressive", keepLastAssistants: 1, - hardClear: { enabled: false, placeholder: "[cleared]" }, + softTrimRatio: 10.0, + hardClearRatio: 0.0, + minPrunableToolChars: 0, + hardClear: { enabled: true, placeholder: "[cleared]" }, }; const ctx = { @@ -258,6 +263,7 @@ describe("context-pruning", () => { }, contextWindowTokens: 1000, isToolPrunable: () => true, + lastCacheTouchAt: Date.now() - DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs - 1000, }); const messages: AgentMessage[] = [ diff --git a/src/agents/pi-extensions/context-pruning/extension.ts b/src/agents/pi-extensions/context-pruning/extension.ts index 94da729e4..7e48141c4 100644 --- a/src/agents/pi-extensions/context-pruning/extension.ts +++ b/src/agents/pi-extensions/context-pruning/extension.ts @@ -9,6 +9,17 @@ export default function contextPruningExtension(api: ExtensionAPI): void { const runtime = getContextPruningRuntime(ctx.sessionManager); if (!runtime) return undefined; + if (runtime.settings.mode === "cache-ttl") { + const ttlMs = runtime.settings.ttlMs; + const lastTouch = runtime.lastCacheTouchAt ?? null; + if (!lastTouch || ttlMs <= 0) { + return undefined; + } + if (ttlMs > 0 && Date.now() - lastTouch < ttlMs) { + return undefined; + } + } + const next = pruneContextMessages({ messages: event.messages as AgentMessage[], settings: runtime.settings, diff --git a/src/agents/pi-extensions/context-pruning/pruner.ts b/src/agents/pi-extensions/context-pruning/pruner.ts index 61d5154d4..c13e5c37a 100644 --- a/src/agents/pi-extensions/context-pruning/pruner.ts +++ b/src/agents/pi-extensions/context-pruning/pruner.ts @@ -211,34 +211,6 @@ export function pruneContextMessages(params: { const isToolPrunable = params.isToolPrunable ?? makeToolPrunablePredicate(settings.tools); - if (settings.mode === "aggressive") { - let next: AgentMessage[] | null = null; - - for (let i = pruneStartIndex; i < cutoffIndex; i++) { - const msg = messages[i]; - if (!msg || msg.role !== "toolResult") continue; - if (!isToolPrunable(msg.toolName)) continue; - if (hasImageBlocks(msg.content)) { - continue; - } - - const alreadyCleared = - msg.content.length === 1 && - msg.content[0]?.type === "text" && - msg.content[0].text === settings.hardClear.placeholder; - if (alreadyCleared) continue; - - const cleared: ToolResultMessage = { - ...msg, - content: [asText(settings.hardClear.placeholder)], - }; - if (!next) next = messages.slice(); - next[i] = cleared as unknown as AgentMessage; - } - - return next ?? messages; - } - const totalCharsBefore = estimateContextChars(messages); let totalChars = totalCharsBefore; let ratio = totalChars / charWindow; diff --git a/src/agents/pi-extensions/context-pruning/runtime.ts b/src/agents/pi-extensions/context-pruning/runtime.ts index b497e6383..fecb4ce3e 100644 --- a/src/agents/pi-extensions/context-pruning/runtime.ts +++ b/src/agents/pi-extensions/context-pruning/runtime.ts @@ -4,6 +4,7 @@ export type ContextPruningRuntimeValue = { settings: EffectiveContextPruningSettings; contextWindowTokens?: number | null; isToolPrunable: (toolName: string) => boolean; + lastCacheTouchAt?: number | null; }; // Session-scoped runtime registry keyed by object identity. diff --git a/src/agents/pi-extensions/context-pruning/settings.ts b/src/agents/pi-extensions/context-pruning/settings.ts index 69f9474d1..8d1497083 100644 --- a/src/agents/pi-extensions/context-pruning/settings.ts +++ b/src/agents/pi-extensions/context-pruning/settings.ts @@ -1,12 +1,15 @@ +import { parseDurationMs } from "../../../cli/parse-duration.js"; + export type ContextPruningToolMatch = { allow?: string[]; deny?: string[]; }; - -export type ContextPruningMode = "off" | "adaptive" | "aggressive"; +export type ContextPruningMode = "off" | "cache-ttl"; export type ContextPruningConfig = { mode?: ContextPruningMode; + /** TTL to consider cache expired (duration string, default unit: minutes). */ + ttl?: string; keepLastAssistants?: number; softTrimRatio?: number; hardClearRatio?: number; @@ -25,6 +28,7 @@ export type ContextPruningConfig = { export type EffectiveContextPruningSettings = { mode: Exclude; + ttlMs: number; keepLastAssistants: number; softTrimRatio: number; hardClearRatio: number; @@ -42,7 +46,8 @@ export type EffectiveContextPruningSettings = { }; export const DEFAULT_CONTEXT_PRUNING_SETTINGS: EffectiveContextPruningSettings = { - mode: "adaptive", + mode: "cache-ttl", + ttlMs: 5 * 60 * 1000, keepLastAssistants: 3, softTrimRatio: 0.3, hardClearRatio: 0.5, @@ -62,11 +67,19 @@ export const DEFAULT_CONTEXT_PRUNING_SETTINGS: EffectiveContextPruningSettings = export function computeEffectiveSettings(raw: unknown): EffectiveContextPruningSettings | null { if (!raw || typeof raw !== "object") return null; const cfg = raw as ContextPruningConfig; - if (cfg.mode !== "adaptive" && cfg.mode !== "aggressive") return null; + if (cfg.mode !== "cache-ttl") return null; const s: EffectiveContextPruningSettings = structuredClone(DEFAULT_CONTEXT_PRUNING_SETTINGS); s.mode = cfg.mode; + if (typeof cfg.ttl === "string") { + try { + s.ttlMs = parseDurationMs(cfg.ttl, { defaultUnit: "m" }); + } catch { + // keep default ttl + } + } + if (typeof cfg.keepLastAssistants === "number" && Number.isFinite(cfg.keepLastAssistants)) { s.keepLastAssistants = Math.max(0, Math.floor(cfg.keepLastAssistants)); } @@ -94,7 +107,7 @@ export function computeEffectiveSettings(raw: unknown): EffectiveContextPruningS } } if (cfg.hardClear) { - if (s.mode === "adaptive" && typeof cfg.hardClear.enabled === "boolean") { + if (typeof cfg.hardClear.enabled === "boolean") { s.hardClear.enabled = cfg.hardClear.enabled; } if (typeof cfg.hardClear.placeholder === "string" && cfg.hardClear.placeholder.trim()) { diff --git a/src/config/config.pruning-defaults.test.ts b/src/config/config.pruning-defaults.test.ts index 195c96cc5..8bc4d7e20 100644 --- a/src/config/config.pruning-defaults.test.ts +++ b/src/config/config.pruning-defaults.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "./test-helpers.js"; describe("config pruning defaults", () => { - it("defaults contextPruning mode to adaptive", async () => { + it("does not enable contextPruning by default", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); await fs.mkdir(configDir, { recursive: true }); @@ -18,7 +18,7 @@ describe("config pruning defaults", () => { const { loadConfig } = await import("./config.js"); const cfg = loadConfig(); - expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("adaptive"); + expect(cfg.agents?.defaults?.contextPruning?.mode).toBeUndefined(); }); }); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 1d0208fc2..9976a64ef 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -157,24 +157,7 @@ export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig { } export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig { - const defaults = cfg.agents?.defaults; - if (!defaults) return cfg; - const contextPruning = defaults?.contextPruning; - if (contextPruning?.mode) return cfg; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...defaults, - contextPruning: { - ...contextPruning, - mode: "adaptive", - }, - }, - }, - }; + return cfg; } export function applyCompactionDefaults(cfg: ClawdbotConfig): ClawdbotConfig { diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 85eff97f2..0fcdcf49b 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -23,7 +23,9 @@ export type AgentModelListConfig = { }; export type AgentContextPruningConfig = { - mode?: "off" | "adaptive" | "aggressive"; + mode?: "off" | "cache-ttl"; + /** TTL to consider cache expired (duration string, default unit: minutes). */ + ttl?: string; keepLastAssistants?: number; softTrimRatio?: number; hardClearRatio?: number; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index aafa9a6f4..c6c0ab3b2 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -54,9 +54,8 @@ export const AgentDefaultsSchema = z memorySearch: MemorySearchSchema, contextPruning: z .object({ - mode: z - .union([z.literal("off"), z.literal("adaptive"), z.literal("aggressive")]) - .optional(), + mode: z.union([z.literal("off"), z.literal("cache-ttl")]).optional(), + ttl: z.string().optional(), keepLastAssistants: z.number().int().nonnegative().optional(), softTrimRatio: z.number().min(0).max(1).optional(), hardClearRatio: z.number().min(0).max(1).optional(), From 9f999f65542c3b3d71eb0ebce5e9446f4806f245 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 19:53:00 +0000 Subject: [PATCH 043/171] fix: reset cache-ttl pruning window --- .../pi-extensions/context-pruning.test.ts | 68 ++++++++++++++++++- .../context-pruning/extension.ts | 5 ++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-extensions/context-pruning.test.ts b/src/agents/pi-extensions/context-pruning.test.ts index b316ef87f..9e92e320e 100644 --- a/src/agents/pi-extensions/context-pruning.test.ts +++ b/src/agents/pi-extensions/context-pruning.test.ts @@ -2,7 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { describe, expect, it } from "vitest"; -import { setContextPruningRuntime } from "./context-pruning/runtime.js"; +import { getContextPruningRuntime, setContextPruningRuntime } from "./context-pruning/runtime.js"; import { computeEffectiveSettings, @@ -306,6 +306,72 @@ describe("context-pruning", () => { expect(toolText(findToolResult(result.messages, "t1"))).toBe("[cleared]"); }); + it("cache-ttl prunes once and resets the ttl window", () => { + const sessionManager = {}; + const lastTouch = Date.now() - DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs - 1000; + + setContextPruningRuntime(sessionManager, { + settings: { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS, + keepLastAssistants: 0, + softTrimRatio: 0, + hardClearRatio: 0, + minPrunableToolChars: 0, + hardClear: { enabled: true, placeholder: "[cleared]" }, + softTrim: { maxChars: 10, headChars: 3, tailChars: 3 }, + }, + contextWindowTokens: 1000, + isToolPrunable: () => true, + lastCacheTouchAt: lastTouch, + }); + + const messages: AgentMessage[] = [ + makeUser("u1"), + makeAssistant("a1"), + makeToolResult({ + toolCallId: "t1", + toolName: "exec", + text: "x".repeat(20_000), + }), + ]; + + let handler: + | (( + event: { messages: AgentMessage[] }, + ctx: ExtensionContext, + ) => { messages: AgentMessage[] } | undefined) + | undefined; + + const api = { + on: (name: string, fn: unknown) => { + if (name === "context") { + handler = fn as typeof handler; + } + }, + appendEntry: (_type: string, _data?: unknown) => {}, + } as unknown as ExtensionAPI; + + contextPruningExtension(api); + if (!handler) throw new Error("missing context handler"); + + const first = handler({ messages }, { + model: undefined, + sessionManager, + } as unknown as ExtensionContext); + if (!first) throw new Error("expected first prune"); + expect(toolText(findToolResult(first.messages, "t1"))).toBe("[cleared]"); + + const runtime = getContextPruningRuntime(sessionManager); + if (!runtime?.lastCacheTouchAt) throw new Error("expected lastCacheTouchAt"); + expect(runtime.lastCacheTouchAt).toBeGreaterThan(lastTouch); + + const second = handler({ messages }, { + model: undefined, + sessionManager, + } as unknown as ExtensionContext); + expect(second).toBeUndefined(); + }); + it("respects tools allow/deny (deny wins; wildcards supported)", () => { const messages: AgentMessage[] = [ makeUser("u1"), diff --git a/src/agents/pi-extensions/context-pruning/extension.ts b/src/agents/pi-extensions/context-pruning/extension.ts index 7e48141c4..411cc9a44 100644 --- a/src/agents/pi-extensions/context-pruning/extension.ts +++ b/src/agents/pi-extensions/context-pruning/extension.ts @@ -29,6 +29,11 @@ export default function contextPruningExtension(api: ExtensionAPI): void { }); if (next === event.messages) return undefined; + + if (runtime.settings.mode === "cache-ttl") { + runtime.lastCacheTouchAt = Date.now(); + } + return { messages: next }; }); } From 41d56c06b9411e1a540c4aa3649accbe2e6f4497 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Wed, 21 Jan 2026 11:53:02 -0800 Subject: [PATCH 044/171] feat(commands): add /models and fix /model listing UX --- src/auto-reply/commands-registry.data.ts | 9 +- src/auto-reply/commands-registry.test.ts | 1 + src/auto-reply/reply/commands-core.ts | 2 + src/auto-reply/reply/commands-models.test.ts | 89 +++++++++ src/auto-reply/reply/commands-models.ts | 170 ++++++++++++++++++ .../directive-handling.model.chat-ux.test.ts | 60 +++++++ .../reply/directive-handling.model.ts | 102 +++++++---- src/auto-reply/reply/model-selection.ts | 67 +++++-- 8 files changed, 450 insertions(+), 50 deletions(-) create mode 100644 src/auto-reply/reply/commands-models.test.ts create mode 100644 src/auto-reply/reply/commands-models.ts create mode 100644 src/auto-reply/reply/directive-handling.model.chat-ux.test.ts diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 691bdd36e..4ce176b1d 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -429,6 +429,14 @@ function buildChatCommands(): ChatCommandDefinition[] { }, ], }), + defineChatCommand({ + key: "models", + nativeName: "models", + description: "List model providers or provider models.", + textAlias: "/models", + argsParsing: "none", + acceptsArgs: true, + }), defineChatCommand({ key: "queue", nativeName: "queue", @@ -485,7 +493,6 @@ function buildChatCommands(): ChatCommandDefinition[] { registerAlias(commands, "verbose", "/v"); registerAlias(commands, "reasoning", "/reason"); registerAlias(commands, "elevated", "/elev"); - registerAlias(commands, "model", "/models"); assertCommandRegistry(commands); return commands; diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 54fb558bd..4296e06cd 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -25,6 +25,7 @@ describe("commands registry", () => { it("builds command text with args", () => { expect(buildCommandText("status")).toBe("/status"); expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5"); + expect(buildCommandText("models")).toBe("/models"); }); it("exposes native specs", () => { diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 9abe5e677..f974dec74 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -15,6 +15,7 @@ import { } from "./commands-info.js"; import { handleAllowlistCommand } from "./commands-allowlist.js"; import { handleSubagentsCommand } from "./commands-subagents.js"; +import { handleModelsCommand } from "./commands-models.js"; import { handleAbortTrigger, handleActivationCommand, @@ -44,6 +45,7 @@ const HANDLERS: CommandHandler[] = [ handleSubagentsCommand, handleConfigCommand, handleDebugCommand, + handleModelsCommand, handleStopCommand, handleCompactCommand, handleAbortTrigger, diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts new file mode 100644 index 000000000..0dd321399 --- /dev/null +++ b/src/auto-reply/reply/commands-models.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import type { MsgContext } from "../templating.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; +import { parseInlineDirectives } from "./directive-handling.js"; + +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(async () => [ + { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" }, + { provider: "openai", id: "gpt-4.1", name: "GPT-4.1" }, + { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, + { provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" }, + ]), +})); + +function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "text", + CommandAuthorized: true, + Provider: "telegram", + Surface: "telegram", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "anthropic", + model: "claude-opus-4-5", + contextTokens: 16000, + isGroup: false, + }; +} + +describe("/models command", () => { + const cfg = { + commands: { text: true }, + // allowlist is empty => allowAny, but still okay for listing + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + } as unknown as ClawdbotConfig; + + it.each(["telegram", "discord", "whatsapp"])("lists providers on %s", async (surface) => { + const params = buildParams("/models", cfg, { Provider: surface, Surface: surface }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Providers:"); + expect(result.reply?.text).toContain("anthropic"); + expect(result.reply?.text).toContain("Use: /models "); + }); + + it("lists provider models with pagination hints", async () => { + const params = buildParams("/models anthropic", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (anthropic)"); + expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); + expect(result.reply?.text).toContain("Switch: /model "); + expect(result.reply?.text).toContain("All: /models anthropic all"); + }); + + it("handles unknown providers", async () => { + const params = buildParams("/models not-a-provider", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Unknown provider"); + expect(result.reply?.text).toContain("Available providers"); + }); +}); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts new file mode 100644 index 000000000..fbc5560f5 --- /dev/null +++ b/src/auto-reply/reply/commands-models.ts @@ -0,0 +1,170 @@ +import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { + buildAllowedModelSet, + normalizeProviderId, + resolveConfiguredModelRef, +} from "../../agents/model-selection.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import type { ReplyPayload } from "../types.js"; +import type { CommandHandler } from "./commands-types.js"; + +const PAGE_SIZE_DEFAULT = 20; +const PAGE_SIZE_MAX = 100; + +function formatProviderLine(params: { provider: string; count: number }): string { + return `- ${params.provider} (${params.count})`; +} + +function parseModelsArgs(raw: string): { + provider?: string; + page: number; + pageSize: number; + all: boolean; +} { + const trimmed = raw.trim(); + if (!trimmed) { + return { page: 1, pageSize: PAGE_SIZE_DEFAULT, all: false }; + } + + const tokens = trimmed.split(/\s+/g).filter(Boolean); + const provider = tokens[0]?.trim(); + + let page = 1; + let all = false; + for (const token of tokens.slice(1)) { + const lower = token.toLowerCase(); + if (lower === "all" || lower === "--all") { + all = true; + continue; + } + if (lower.startsWith("page=")) { + const value = Number.parseInt(lower.slice("page=".length), 10); + if (Number.isFinite(value) && value > 0) page = value; + continue; + } + if (/^[0-9]+$/.test(lower)) { + const value = Number.parseInt(lower, 10); + if (Number.isFinite(value) && value > 0) page = value; + } + } + + let pageSize = PAGE_SIZE_DEFAULT; + for (const token of tokens) { + const lower = token.toLowerCase(); + if (lower.startsWith("limit=") || lower.startsWith("size=")) { + const rawValue = lower.slice(lower.indexOf("=") + 1); + const value = Number.parseInt(rawValue, 10); + if (Number.isFinite(value) && value > 0) pageSize = Math.min(PAGE_SIZE_MAX, value); + } + } + + return { + provider: provider ? normalizeProviderId(provider) : undefined, + page, + pageSize, + all, + }; +} + +export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) return null; + + const body = params.command.commandBodyNormalized.trim(); + if (!body.startsWith("/models")) return null; + + const argText = body.replace(/^\/models\b/i, "").trim(); + const { provider, page, pageSize, all } = parseModelsArgs(argText); + + const resolvedDefault = resolveConfiguredModelRef({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + + const catalog = await loadModelCatalog({ config: params.cfg }); + const allowed = buildAllowedModelSet({ + cfg: params.cfg, + catalog, + defaultProvider: resolvedDefault.provider, + defaultModel: resolvedDefault.model, + }); + + const byProvider = new Map>(); + const add = (p: string, m: string) => { + const key = normalizeProviderId(p); + const set = byProvider.get(key) ?? new Set(); + set.add(m); + byProvider.set(key, set); + }; + + for (const entry of allowed.allowedCatalog) { + add(entry.provider, entry.id); + } + + // Include config-only allowlist keys that aren't in the curated catalog. + for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) { + const rawKey = String(raw ?? "").trim(); + if (!rawKey) continue; + const slash = rawKey.indexOf("/"); + if (slash === -1) continue; + const p = normalizeProviderId(rawKey.slice(0, slash)); + const m = rawKey.slice(slash + 1).trim(); + if (!p || !m) continue; + add(p, m); + } + + const providers = [...byProvider.keys()].sort(); + + if (!provider) { + const lines: string[] = [ + "Providers:", + ...providers.map((p) => + formatProviderLine({ provider: p, count: byProvider.get(p)?.size ?? 0 }), + ), + "", + "Use: /models ", + "Switch: /model ", + ]; + return { reply: { text: lines.join("\n") }, shouldContinue: false }; + } + + if (!byProvider.has(provider)) { + const lines: string[] = [ + `Unknown provider: ${provider}`, + "", + "Available providers:", + ...providers.map((p) => `- ${p}`), + "", + "Use: /models ", + ]; + return { reply: { text: lines.join("\n") }, shouldContinue: false }; + } + + const models = [...(byProvider.get(provider) ?? new Set())].sort(); + const total = models.length; + + const effectivePageSize = all ? total : pageSize; + const startIndex = (page - 1) * effectivePageSize; + const endIndexExclusive = Math.min(total, startIndex + effectivePageSize); + const pageModels = models.slice(startIndex, endIndexExclusive); + + const header = `Models (${provider}) — showing ${startIndex + 1}-${endIndexExclusive} of ${total}`; + + const lines: string[] = [header]; + for (const id of pageModels) { + lines.push(`- ${provider}/${id}`); + } + + const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1; + + lines.push("", "Switch: /model "); + if (!all && page < pageCount) { + lines.push(`More: /models ${provider} ${page + 1}`); + } + if (!all) { + lines.push(`All: /models ${provider} all`); + } + + const payload: ReplyPayload = { text: lines.join("\n") }; + return { reply: payload, shouldContinue: false }; +}; diff --git a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts new file mode 100644 index 000000000..1e8b2dc7b --- /dev/null +++ b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { parseInlineDirectives } from "./directive-handling.js"; +import { + maybeHandleModelDirectiveInfo, + resolveModelSelectionFromDirective, +} from "./directive-handling.model.js"; + +function baseAliasIndex(): ModelAliasIndex { + return { byAlias: new Map(), byKey: new Map() }; +} + +describe("/model chat UX", () => { + it("shows summary for /model with no args", async () => { + const directives = parseInlineDirectives("/model"); + const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; + + const reply = await maybeHandleModelDirectiveInfo({ + directives, + cfg, + agentDir: "/tmp/agent", + activeAgentId: "main", + provider: "anthropic", + model: "claude-opus-4-5", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelCatalog: [], + resetModelOverride: false, + }); + + expect(reply?.text).toContain("Current:"); + expect(reply?.text).toContain("Browse: /models"); + expect(reply?.text).toContain("Switch: /model "); + }); + + it("suggests closest match for typos without switching", () => { + const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5"); + const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; + + const resolved = resolveModelSelectionFromDirective({ + directives, + cfg, + agentDir: "/tmp/agent", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]), + allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }], + provider: "anthropic", + }); + + expect(resolved.modelSelection).toBeUndefined(); + expect(resolved.errorText).toContain("Did you mean:"); + expect(resolved.errorText).toContain("anthropic/claude-opus-4-5"); + expect(resolved.errorText).toContain("Try: /model anthropic/claude-opus-4-5"); + }); +}); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 3f4200222..6cfef7828 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -169,8 +169,9 @@ export async function maybeHandleModelDirectiveInfo(params: { const rawDirective = params.directives.rawModelDirective?.trim(); const directive = rawDirective?.toLowerCase(); const wantsStatus = directive === "status"; - const wantsList = !rawDirective || directive === "list"; - if (!wantsList && !wantsStatus) return undefined; + const wantsSummary = !rawDirective; + const wantsLegacyList = directive === "list"; + if (!wantsSummary && !wantsStatus && !wantsLegacyList) return undefined; if (params.directives.rawModelProfile) { return { text: "Auth profile override requires a model selection." }; @@ -184,16 +185,28 @@ export async function maybeHandleModelDirectiveInfo(params: { allowedModelCatalog: params.allowedModelCatalog, }); - if (wantsList) { - const items = buildModelPickerItems(pickerCatalog); - if (items.length === 0) return { text: "No models available." }; + if (wantsLegacyList) { + return { + text: [ + "Model listing moved.", + "", + "Use: /models (providers) or /models (models)", + "Switch: /model ", + ].join("\n"), + }; + } + + if (wantsSummary) { const current = `${params.provider}/${params.model}`; - const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model "]; - for (const [idx, item] of items.entries()) { - lines.push(`${idx + 1}) ${item.provider}/${item.model}`); - } - lines.push("", "More: /model status"); - return { text: lines.join("\n") }; + return { + text: [ + `Current: ${current}`, + "", + "Switch: /model ", + "Browse: /models (providers) or /models (models)", + "More: /model status", + ].join("\n"), + }; } const modelsPath = `${params.agentDir}/models.json`; @@ -285,31 +298,36 @@ export function resolveModelSelectionFromDirective(params: { let modelSelection: ModelDirectiveSelection | undefined; if (/^[0-9]+$/.test(raw)) { - const pickerCatalog = buildModelPickerCatalog({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - defaultModel: params.defaultModel, - aliasIndex: params.aliasIndex, - allowedModelCatalog: params.allowedModelCatalog, - }); - const items = buildModelPickerItems(pickerCatalog); - const index = Number.parseInt(raw, 10) - 1; - const item = Number.isFinite(index) ? items[index] : undefined; - if (!item) { - return { - errorText: `Invalid model selection "${raw}". Use /model to list.`, + return { + errorText: [ + "Numeric model selection is not supported in chat.", + "", + "Browse: /models or /models ", + "Switch: /model ", + ].join("\n"), + }; + } + + const explicit = resolveModelRefFromString({ + raw, + defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, + }); + if (explicit) { + const explicitKey = modelKey(explicit.ref.provider, explicit.ref.model); + if (params.allowedModelKeys.size === 0 || params.allowedModelKeys.has(explicitKey)) { + modelSelection = { + provider: explicit.ref.provider, + model: explicit.ref.model, + isDefault: + explicit.ref.provider === params.defaultProvider && + explicit.ref.model === params.defaultModel, + ...(explicit.alias ? { alias: explicit.alias } : {}), }; } - const key = `${item.provider}/${item.model}`; - const aliases = params.aliasIndex.byKey.get(key); - const alias = aliases && aliases.length > 0 ? aliases[0] : undefined; - modelSelection = { - provider: item.provider, - model: item.model, - isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel, - ...(alias ? { alias } : {}), - }; - } else { + } + + if (!modelSelection) { const resolved = resolveModelDirectiveSelection({ raw, defaultProvider: params.defaultProvider, @@ -317,10 +335,24 @@ export function resolveModelSelectionFromDirective(params: { aliasIndex: params.aliasIndex, allowedModelKeys: params.allowedModelKeys, }); + if (resolved.error) { return { errorText: resolved.error }; } - modelSelection = resolved.selection; + + if (resolved.selection) { + const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`; + return { + errorText: [ + `Unrecognized model: ${raw}`, + "", + `Did you mean: ${suggestion}`, + `Try: /model ${suggestion}`, + "", + "Browse: /models or /models ", + ].join("\n"), + }; + } } let profileOverride: string | undefined; diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index f1d9948a8..fe06c1c06 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -46,6 +46,39 @@ const FUZZY_VARIANT_TOKENS = [ "nano", ]; +function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): number | null { + if (a === b) return 0; + if (!a || !b) return null; + const aLen = a.length; + const bLen = b.length; + if (Math.abs(aLen - bLen) > maxDistance) return null; + + // Standard DP with early exit. O(maxDistance * minLen) in common cases. + const prev = new Array(bLen + 1); + const curr = new Array(bLen + 1); + for (let j = 0; j <= bLen; j++) prev[j] = j; + + for (let i = 1; i <= aLen; i++) { + curr[0] = i; + let rowMin = curr[0]; + + const aChar = a.charCodeAt(i - 1); + for (let j = 1; j <= bLen; j++) { + const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + if (curr[j] < rowMin) rowMin = curr[j]; + } + + if (rowMin > maxDistance) return null; + + for (let j = 0; j <= bLen; j++) prev[j] = curr[j] ?? 0; + } + + const dist = prev[bLen] ?? null; + if (dist == null || dist > maxDistance) return null; + return dist; +} + function scoreFuzzyMatch(params: { provider: string; model: string; @@ -94,6 +127,13 @@ function scoreFuzzyMatch(params: { includes: 80, }); + // Best-effort typo tolerance for common near-misses like "claud" vs "claude". + // Bounded to keep this cheap across large model sets. + const distModel = boundedLevenshteinDistance(fragment, modelLower, 3); + if (distModel != null) { + score += (3 - distModel) * 70; + } + const aliases = params.aliasIndex.byKey.get(key) ?? []; for (const alias of aliases) { score += scoreFragment(alias.toLowerCase(), { @@ -293,17 +333,16 @@ export function resolveModelDirectiveSelection(params: { const fragment = params.fragment.trim().toLowerCase(); if (!fragment) return {}; + const providerFilter = params.provider ? normalizeProviderId(params.provider) : undefined; + const candidates: Array<{ provider: string; model: string }> = []; for (const key of allowedModelKeys) { const slash = key.indexOf("/"); if (slash <= 0) continue; const provider = normalizeProviderId(key.slice(0, slash)); const model = key.slice(slash + 1); - if (params.provider && provider !== normalizeProviderId(params.provider)) continue; - const haystack = `${provider}/${model}`.toLowerCase(); - if (haystack.includes(fragment) || model.toLowerCase().includes(fragment)) { - candidates.push({ provider, model }); - } + if (providerFilter && provider !== providerFilter) continue; + candidates.push({ provider, model }); } // Also allow partial alias matches when the user didn't specify a provider. @@ -325,11 +364,6 @@ export function resolveModelDirectiveSelection(params: { } } - if (candidates.length === 1) { - const match = candidates[0]; - if (!match) return {}; - return { selection: buildSelection(match.provider, match.model) }; - } if (candidates.length === 0) return {}; const scored = candidates @@ -354,8 +388,13 @@ export function resolveModelDirectiveSelection(params: { return a.key.localeCompare(b.key); }); - const best = scored[0]?.candidate; - if (!best) return {}; + const bestScored = scored[0]; + const best = bestScored?.candidate; + if (!best || !bestScored) return {}; + + const minScore = providerFilter ? 90 : 120; + if (bestScored.score < minScore) return {}; + return { selection: buildSelection(best.provider, best.model) }; }; @@ -369,7 +408,7 @@ export function resolveModelDirectiveSelection(params: { const fuzzy = resolveFuzzy({ fragment: rawTrimmed }); if (fuzzy.selection || fuzzy.error) return fuzzy; return { - error: `Unrecognized model "${rawTrimmed}". Use /model to list available models.`, + error: `Unrecognized model "${rawTrimmed}". Use /models to list providers, or /models to list models.`, }; } @@ -400,7 +439,7 @@ export function resolveModelDirectiveSelection(params: { if (fuzzy.selection || fuzzy.error) return fuzzy; return { - error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`, + error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /models to list providers, or /models to list models.`, }; } From 41c9c214fc791b733bf3f1d3e4377942b158ad03 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 19:48:16 +0000 Subject: [PATCH 045/171] fix: drop obsolete pi-mono workarounds --- ...ded-helpers.downgradegeminihistory.test.ts | 71 --------- src/agents/pi-embedded-helpers.ts | 7 +- src/agents/pi-embedded-helpers/google.ts | 144 ------------------ ...ed-runner.google-sanitize-thinking.test.ts | 55 ++++--- ...ed-runner.sanitize-session-history.test.ts | 69 +++------ src/agents/pi-embedded-runner/google.ts | 59 +------ .../pi-embedded-runner/run/attempt.test.ts | 6 +- 7 files changed, 57 insertions(+), 354 deletions(-) delete mode 100644 src/agents/pi-embedded-helpers.downgradegeminihistory.test.ts diff --git a/src/agents/pi-embedded-helpers.downgradegeminihistory.test.ts b/src/agents/pi-embedded-helpers.downgradegeminihistory.test.ts deleted file mode 100644 index b48d5d4e1..000000000 --- a/src/agents/pi-embedded-helpers.downgradegeminihistory.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { downgradeGeminiHistory } from "./pi-embedded-helpers.js"; - -describe("downgradeGeminiHistory", () => { - it("drops unsigned tool calls and matching tool results", () => { - const input = [ - { - role: "assistant", - content: [ - { type: "text", text: "hello" }, - { type: "toolCall", id: "call_1", name: "read", arguments: { path: "/tmp" } }, - ], - }, - { - role: "toolResult", - toolCallId: "call_1", - content: [{ type: "text", text: "ok" }], - }, - { role: "user", content: "next" }, - ]; - - expect(downgradeGeminiHistory(input)).toEqual([ - { - role: "assistant", - content: [{ type: "text", text: "hello" }], - }, - { role: "user", content: "next" }, - ]); - }); - - it("keeps signed tool calls and results", () => { - const input = [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_2", - name: "read", - arguments: { path: "/tmp" }, - thought_signature: "sig_123", - }, - ], - }, - { - role: "toolResult", - toolCallId: "call_2", - content: [{ type: "text", text: "ok" }], - }, - ]; - - expect(downgradeGeminiHistory(input)).toEqual(input); - }); - - it("drops assistant messages that only contain unsigned tool calls", () => { - const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_3", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "call_3", - content: [{ type: "text", text: "ok" }], - }, - { role: "user", content: "after" }, - ]; - - expect(downgradeGeminiHistory(input)).toEqual([{ role: "user", content: "after" }]); - }); -}); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 64e14ebcc..85d16af12 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -30,12 +30,7 @@ export { isTimeoutErrorMessage, parseImageDimensionError, } from "./pi-embedded-helpers/errors.js"; -export { - downgradeGeminiHistory, - downgradeGeminiThinkingBlocks, - isGoogleModelApi, - sanitizeGoogleTurnOrdering, -} from "./pi-embedded-helpers/google.js"; +export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js"; export { isEmptyAssistantMessageContent, sanitizeSessionMessagesImages, diff --git a/src/agents/pi-embedded-helpers/google.ts b/src/agents/pi-embedded-helpers/google.ts index dc21da947..59bd06b13 100644 --- a/src/agents/pi-embedded-helpers/google.ts +++ b/src/agents/pi-embedded-helpers/google.ts @@ -1,5 +1,3 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; - import { sanitizeGoogleTurnOrdering } from "./bootstrap.js"; export function isGoogleModelApi(api?: string | null): boolean { @@ -14,145 +12,3 @@ export function isAntigravityClaude(api?: string | null, modelId?: string): bool } export { sanitizeGoogleTurnOrdering }; - -/** - * Drops tool calls that are missing `thought_signature` (required by Gemini) - * to prevent 400 INVALID_ARGUMENT errors. Matching tool results are dropped - * so they don't become orphaned in the transcript. - */ -type GeminiToolCallBlock = { - type?: unknown; - thought_signature?: unknown; - thoughtSignature?: unknown; - id?: unknown; - toolCallId?: unknown; - name?: unknown; - toolName?: unknown; - arguments?: unknown; - input?: unknown; -}; - -type GeminiThinkingBlock = { - type?: unknown; - thinking?: unknown; - thinkingSignature?: unknown; -}; - -export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMessage[] { - const out: AgentMessage[] = []; - for (const msg of messages) { - if (!msg || typeof msg !== "object") { - out.push(msg); - continue; - } - const role = (msg as { role?: unknown }).role; - if (role !== "assistant") { - out.push(msg); - continue; - } - const assistantMsg = msg as Extract; - if (!Array.isArray(assistantMsg.content)) { - out.push(msg); - continue; - } - - // Gemini rejects thinking blocks that lack a signature; downgrade to text for safety. - let hasDowngraded = false; - type AssistantContentBlock = (typeof assistantMsg.content)[number]; - const nextContent = assistantMsg.content.flatMap((block): AssistantContentBlock[] => { - if (!block || typeof block !== "object") return [block as AssistantContentBlock]; - const record = block as GeminiThinkingBlock; - if (record.type !== "thinking") return [block]; - const thinkingSig = - typeof record.thinkingSignature === "string" ? record.thinkingSignature.trim() : ""; - if (thinkingSig.length > 0) return [block]; - const thinking = typeof record.thinking === "string" ? record.thinking : ""; - const trimmed = thinking.trim(); - hasDowngraded = true; - if (!trimmed) return []; - return [{ type: "text" as const, text: thinking }]; - }); - - if (!hasDowngraded) { - out.push(msg); - continue; - } - if (nextContent.length === 0) { - continue; - } - out.push({ ...assistantMsg, content: nextContent } as AgentMessage); - } - return out; -} - -export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[] { - const droppedToolCallIds = new Set(); - const out: AgentMessage[] = []; - - const resolveToolResultId = ( - msg: Extract, - ): string | undefined => { - const toolCallId = (msg as { toolCallId?: unknown }).toolCallId; - if (typeof toolCallId === "string" && toolCallId) return toolCallId; - const toolUseId = (msg as { toolUseId?: unknown }).toolUseId; - if (typeof toolUseId === "string" && toolUseId) return toolUseId; - return undefined; - }; - - for (const msg of messages) { - if (!msg || typeof msg !== "object") { - out.push(msg); - continue; - } - - const role = (msg as { role?: unknown }).role; - if (role === "assistant") { - const assistantMsg = msg as Extract; - if (!Array.isArray(assistantMsg.content)) { - out.push(msg); - continue; - } - - let dropped = false; - const nextContent = assistantMsg.content.filter((block) => { - if (!block || typeof block !== "object") return true; - const blockRecord = block as GeminiToolCallBlock; - const type = blockRecord.type; - if (type === "toolCall" || type === "functionCall" || type === "toolUse") { - const signature = blockRecord.thought_signature ?? blockRecord.thoughtSignature; - const hasSignature = Boolean(signature); - if (!hasSignature) { - const id = - typeof blockRecord.id === "string" - ? blockRecord.id - : typeof blockRecord.toolCallId === "string" - ? blockRecord.toolCallId - : undefined; - if (id) droppedToolCallIds.add(id); - dropped = true; - return false; - } - } - return true; - }); - - if (dropped && nextContent.length === 0) { - continue; - } - - out.push(dropped ? ({ ...assistantMsg, content: nextContent } as AgentMessage) : msg); - continue; - } - - if (role === "toolResult") { - const toolMsg = msg as Extract; - const toolResultId = resolveToolResultId(toolMsg); - if (toolResultId && droppedToolCallIds.has(toolResultId)) { - continue; - } - } - - out.push(msg); - } - return out; -} diff --git a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts index 1b34c732e..b79a88f5c 100644 --- a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts +++ b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js"; describe("sanitizeSessionHistory (google thinking)", () => { - it("downgrades thinking blocks without signatures for Google models", async () => { + it("keeps thinking blocks without signatures for Google models", async () => { const sessionManager = SessionManager.inMemory(); const input = [ { @@ -25,10 +25,10 @@ describe("sanitizeSessionHistory (google thinking)", () => { }); const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; text?: string }>; + content?: Array<{ type?: string; thinking?: string }>; }; - expect(assistant.content?.map((block) => block.type)).toEqual(["text"]); - expect(assistant.content?.[0]?.text).toBe("reasoning"); + expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); + expect(assistant.content?.[0]?.thinking).toBe("reasoning"); }); it("keeps thinking blocks with signatures for Google models", async () => { @@ -59,7 +59,7 @@ describe("sanitizeSessionHistory (google thinking)", () => { expect(assistant.content?.[0]?.thinkingSignature).toBe("sig"); }); - it("downgrades thinking blocks with Anthropic-style signatures for Google models", async () => { + it("keeps thinking blocks with Anthropic-style signatures for Google models", async () => { const sessionManager = SessionManager.inMemory(); const input = [ { @@ -80,10 +80,10 @@ describe("sanitizeSessionHistory (google thinking)", () => { }); const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; text?: string }>; + content?: Array<{ type?: string; thinking?: string }>; }; - expect(assistant.content?.map((block) => block.type)).toEqual(["text"]); - expect(assistant.content?.[0]?.text).toBe("reasoning"); + expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); + expect(assistant.content?.[0]?.thinking).toBe("reasoning"); }); it("keeps unsigned thinking blocks for Antigravity Claude", async () => { @@ -114,7 +114,7 @@ describe("sanitizeSessionHistory (google thinking)", () => { expect(assistant.content?.[0]?.thinking).toBe("reasoning"); }); - it("preserves order when downgrading mixed assistant content", async () => { + it("preserves order for mixed assistant content", async () => { const sessionManager = SessionManager.inMemory(); const input = [ { @@ -139,10 +139,10 @@ describe("sanitizeSessionHistory (google thinking)", () => { }); const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; text?: string }>; + content?: Array<{ type?: string; text?: string; thinking?: string }>; }; - expect(assistant.content?.map((block) => block.type)).toEqual(["text", "text", "text"]); - expect(assistant.content?.[1]?.text).toBe("internal note"); + expect(assistant.content?.map((block) => block.type)).toEqual(["text", "thinking", "text"]); + expect(assistant.content?.[1]?.thinking).toBe("internal note"); }); it("strips non-base64 thought signatures for OpenRouter Gemini", async () => { @@ -185,11 +185,22 @@ describe("sanitizeSessionHistory (google thinking)", () => { }); const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; thought_signature?: string; thoughtSignature?: string }>; + content?: Array<{ + type?: string; + thought_signature?: string; + thoughtSignature?: string; + thinking?: string; + }>; }; expect(assistant.content).toEqual([ { type: "text", text: "hello" }, - { type: "text", text: "ok" }, + { type: "thinking", thinking: "ok", thought_signature: "c2ln" }, + { + type: "toolCall", + id: "call_1", + name: "read", + arguments: { path: "/tmp/foo" }, + }, { type: "toolCall", id: "call_2", @@ -200,7 +211,7 @@ describe("sanitizeSessionHistory (google thinking)", () => { ]); }); - it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => { + it("keeps mixed signed/unsigned thinking blocks for Google models", async () => { const sessionManager = SessionManager.inMemory(); const input = [ { @@ -224,14 +235,14 @@ describe("sanitizeSessionHistory (google thinking)", () => { }); const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; thinking?: string; text?: string }>; + content?: Array<{ type?: string; thinking?: string }>; }; - expect(assistant.content?.map((block) => block.type)).toEqual(["thinking", "text"]); + expect(assistant.content?.map((block) => block.type)).toEqual(["thinking", "thinking"]); expect(assistant.content?.[0]?.thinking).toBe("signed"); - expect(assistant.content?.[1]?.text).toBe("unsigned"); + expect(assistant.content?.[1]?.thinking).toBe("unsigned"); }); - it("drops empty unsigned thinking blocks for Google models", async () => { + it("keeps empty thinking blocks for Google models", async () => { const sessionManager = SessionManager.inMemory(); const input = [ { @@ -251,8 +262,10 @@ describe("sanitizeSessionHistory (google thinking)", () => { sessionId: "session:google-empty", }); - const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant"); - expect(assistant).toBeUndefined(); + const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { + content?: Array<{ type?: string; thinking?: string }>; + }; + expect(assistant?.content?.map((block) => block.type)).toEqual(["thinking"]); }); it("keeps thinking blocks for non-Google models", async () => { diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index fc029b653..87c149d9a 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -13,7 +13,6 @@ vi.mock("./pi-embedded-helpers.js", async () => { return { ...actual, isGoogleModelApi: vi.fn(), - downgradeGeminiHistory: vi.fn(), sanitizeSessionMessagesImages: vi.fn().mockImplementation(async (msgs) => msgs), }; }); @@ -32,19 +31,14 @@ describe("sanitizeSessionHistory", () => { beforeEach(async () => { vi.resetAllMocks(); vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs); - // Default mock implementation - vi.mocked(helpers.downgradeGeminiHistory).mockImplementation((msgs) => { - if (!msgs) return []; - return [...msgs, { role: "system", content: "downgraded" }]; - }); vi.resetModules(); ({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js")); }); - it("should downgrade history for Google models if provider is not google-antigravity", async () => { + it("sanitizes tool call ids for Google model APIs", async () => { vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true); - const result = await sanitizeSessionHistory({ + await sanitizeSessionHistory({ messages: mockMessages, modelApi: "google-gemini", provider: "google-vertex", @@ -53,35 +47,17 @@ describe("sanitizeSessionHistory", () => { }); expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini"); - expect(helpers.downgradeGeminiHistory).toHaveBeenCalled(); - // Check if the result contains the downgraded message - expect(result).toContainEqual({ role: "system", content: "downgraded" }); + expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( + mockMessages, + "session:history", + expect.objectContaining({ sanitizeToolCallIds: true }), + ); }); - it("should NOT downgrade history for google-antigravity provider", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true); - - const result = await sanitizeSessionHistory({ - messages: mockMessages, - modelApi: "google-gemini", - provider: "google-antigravity", - sessionManager: mockSessionManager, - sessionId: "test-session", - }); - - expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini"); - expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled(); - // Result should not contain the downgraded message - expect(result).not.toContainEqual({ - role: "system", - content: "downgraded", - }); - }); - - it("should NOT downgrade history for non-Google models", async () => { + it("does not sanitize tool call ids for non-Google, non-OpenAI APIs", async () => { vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); - const _result = await sanitizeSessionHistory({ + await sanitizeSessionHistory({ messages: mockMessages, modelApi: "anthropic-messages", provider: "anthropic", @@ -90,25 +66,14 @@ describe("sanitizeSessionHistory", () => { }); expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("anthropic-messages"); - expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled(); + expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( + mockMessages, + "session:history", + expect.objectContaining({ sanitizeToolCallIds: false }), + ); }); - it("should downgrade history if provider is undefined but model is Google", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true); - - const _result = await sanitizeSessionHistory({ - messages: mockMessages, - modelApi: "google-gemini", - provider: undefined, - sessionManager: mockSessionManager, - sessionId: "test-session", - }); - - expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini"); - expect(helpers.downgradeGeminiHistory).toHaveBeenCalled(); - }); - - it("drops reasoning-only assistant messages for openai-responses", async () => { + it("keeps reasoning-only assistant messages for openai-responses", async () => { vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); const messages: AgentMessage[] = [ @@ -135,7 +100,7 @@ describe("sanitizeSessionHistory", () => { }); expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("openai-responses"); - expect(result).toHaveLength(1); - expect(result[0]?.role).toBe("user"); + expect(result).toHaveLength(2); + expect(result[1]?.role).toBe("assistant"); }); }); diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index 45ba83ecb..558098c20 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -4,8 +4,6 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js"; import { - downgradeGeminiThinkingBlocks, - downgradeGeminiHistory, isCompactionFailureError, isGoogleModelApi, sanitizeGoogleTurnOrdering, @@ -52,42 +50,6 @@ function shouldSanitizeToolCallIds(modelApi?: string | null): boolean { return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi); } -function filterOpenAIReasoningOnlyMessages( - messages: AgentMessage[], - modelApi?: string | null, -): AgentMessage[] { - if (modelApi !== "openai-responses") return messages; - return messages.filter((msg) => { - if (!msg || typeof msg !== "object") return true; - if ((msg as { role?: unknown }).role !== "assistant") return true; - const assistant = msg as Extract; - const content = assistant.content; - if (!Array.isArray(content) || content.length === 0) return true; - let hasThinking = false; - let hasPairedContent = false; - for (const block of content) { - if (!block || typeof block !== "object") continue; - const type = (block as { type?: unknown }).type; - if (type === "thinking") { - hasThinking = true; - continue; - } - if (type === "toolCall" || type === "toolUse" || type === "functionCall") { - hasPairedContent = true; - break; - } - if (type === "text") { - const text = (block as { text?: unknown }).text; - if (typeof text === "string" && text.trim().length > 0) { - hasPairedContent = true; - break; - } - } - } - return !(hasThinking && !hasPairedContent); - }); -} - function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] { if (!schema || typeof schema !== "object") return []; if (Array.isArray(schema)) { @@ -233,7 +195,6 @@ export async function sanitizeSessionHistory(params: { const modelId = (params.modelId ?? "").toLowerCase(); const isOpenRouterGemini = (provider === "openrouter" || provider === "opencode") && modelId.includes("gemini"); - const isGeminiLike = isGoogleModelApi(params.modelApi) || isOpenRouterGemini; const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", { sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi), enforceToolCallLast: params.modelApi === "anthropic-messages", @@ -242,26 +203,10 @@ export async function sanitizeSessionHistory(params: { ? { allowBase64Only: true, includeCamelCase: true } : undefined, }); - // TODO REMOVE when https://github.com/badlogic/pi-mono/pull/838 is merged. - const openaiReasoningFiltered = filterOpenAIReasoningOnlyMessages( - sanitizedImages, - params.modelApi, - ); - const repairedTools = sanitizeToolUseResultPairing(openaiReasoningFiltered); - const isAntigravityProvider = - provider === "google-antigravity" || params.modelApi === "google-antigravity"; - const shouldDowngradeThinking = isGeminiLike && !isAntigravityClaudeModel; - // Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT. - const downgradedThinking = shouldDowngradeThinking - ? downgradeGeminiThinkingBlocks(repairedTools) - : repairedTools; - const shouldDowngradeHistory = shouldDowngradeThinking && !isAntigravityProvider; - const downgraded = shouldDowngradeHistory - ? downgradeGeminiHistory(downgradedThinking) - : downgradedThinking; + const repairedTools = sanitizeToolUseResultPairing(sanitizedImages); return applyGoogleTurnOrderingFix({ - messages: downgraded, + messages: repairedTools, modelApi: params.modelApi, sessionManager: params.sessionManager, sessionId: params.sessionId, diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 93d5b5651..c94c65736 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -36,11 +36,11 @@ describe("injectHistoryImagesIntoMessages", () => { const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]])); expect(didMutate).toBe(false); - const content = messages[0]?.content; - if (!Array.isArray(content)) { + const first = messages[0]; + if (!first || !Array.isArray(first.content)) { throw new Error("expected array content"); } - expect(content).toHaveLength(2); + expect(first.content).toHaveLength(2); }); it("ignores non-user messages and out-of-range indices", () => { From 6c0a01dc909e511b26c1806716c9a9010bd8b216 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 19:48:21 +0000 Subject: [PATCH 046/171] fix: bundle mac model catalog --- .../Sources/Clawdbot/ModelCatalogLoader.swift | 95 +++++++++++- package.json | 3 - patches/@mariozechner__pi-ai@0.49.2.patch | 135 ------------------ pnpm-lock.yaml | 13 +- scripts/package-mac-app.sh | 9 ++ 5 files changed, 104 insertions(+), 151 deletions(-) delete mode 100644 patches/@mariozechner__pi-ai@0.49.2.patch diff --git a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift b/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift index 48001b4e9..2f1c75fe6 100644 --- a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift +++ b/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift @@ -2,14 +2,28 @@ import Foundation import JavaScriptCore enum ModelCatalogLoader { - static let defaultPath: String = FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path + static var defaultPath: String { self.resolveDefaultPath() } private static let logger = Logger(subsystem: "com.clawdbot", category: "models") + private nonisolated static let appSupportDir: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("Clawdbot", isDirectory: true) + }() + + private static var cachePath: URL { + self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false) + } static func load(from path: String) async throws -> [ModelChoice] { let expanded = (path as NSString).expandingTildeInPath - self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)") - let source = try String(contentsOfFile: expanded, encoding: .utf8) + guard let resolved = self.resolvePath(preferred: expanded) else { + self.logger.error("model catalog load failed: file not found") + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"]) + } + self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)") + let source = try String(contentsOfFile: resolved.path, encoding: .utf8) let sanitized = self.sanitize(source: source) let ctx = JSContext() @@ -45,9 +59,82 @@ enum ModelCatalogLoader { return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending } self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)") + if resolved.shouldCache { + self.cacheCatalog(sourcePath: resolved.path) + } return sorted } + private static func resolveDefaultPath() -> String { + let cache = self.cachePath.path + if FileManager().isReadableFile(atPath: cache) { return cache } + if let bundlePath = self.bundleCatalogPath() { return bundlePath } + if let nodePath = self.nodeModulesCatalogPath() { return nodePath } + return cache + } + + private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? { + if FileManager().isReadableFile(atPath: preferred) { + return (preferred, preferred != self.cachePath.path) + } + + if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred { + self.logger.warning("model catalog path missing; falling back to bundled catalog") + return (bundlePath, true) + } + + let cache = self.cachePath.path + if cache != preferred, FileManager().isReadableFile(atPath: cache) { + self.logger.warning("model catalog path missing; falling back to cached catalog") + return (cache, false) + } + + if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred { + self.logger.warning("model catalog path missing; falling back to node_modules catalog") + return (nodePath, true) + } + + return nil + } + + private static func bundleCatalogPath() -> String? { + guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else { + return nil + } + return url.path + } + + private static func nodeModulesCatalogPath() -> String? { + let roots = [ + URL(fileURLWithPath: CommandResolver.projectRootPath()), + URL(fileURLWithPath: FileManager().currentDirectoryPath), + ] + for root in roots { + let candidate = root + .appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js") + if FileManager().isReadableFile(atPath: candidate.path) { + return candidate.path + } + } + return nil + } + + private static func cacheCatalog(sourcePath: String) { + let destination = self.cachePath + do { + try FileManager().createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true) + if FileManager().fileExists(atPath: destination.path) { + try FileManager().removeItem(at: destination) + } + try FileManager().copyItem(atPath: sourcePath, toPath: destination.path) + self.logger.debug("model catalog cached file=\(destination.lastPathComponent)") + } catch { + self.logger.warning("model catalog cache failed: \(error.localizedDescription)") + } + } + private static func sanitize(source: String) -> String { guard let exportRange = source.range(of: "export const MODELS"), let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), diff --git a/package.json b/package.json index cdea28b6f..9de1783ca 100644 --- a/package.json +++ b/package.json @@ -236,9 +236,6 @@ "@sinclair/typebox": "0.34.47", "hono": "4.11.4", "tar": "7.5.4" - }, - "patchedDependencies": { - "@mariozechner/pi-ai@0.49.2": "patches/@mariozechner__pi-ai@0.49.2.patch" } }, "vitest": { diff --git a/patches/@mariozechner__pi-ai@0.49.2.patch b/patches/@mariozechner__pi-ai@0.49.2.patch deleted file mode 100644 index 57fb965f8..000000000 --- a/patches/@mariozechner__pi-ai@0.49.2.patch +++ /dev/null @@ -1,135 +0,0 @@ -diff --git a/dist/providers/anthropic.js b/dist/providers/anthropic.js -index 1cba2f1365812fd2f88993009c9cc06e9c348279..664dd6d8b400ec523fb735480741b9ad64f9a68c 100644 ---- a/dist/providers/anthropic.js -+++ b/dist/providers/anthropic.js -@@ -298,10 +298,11 @@ function createClient(model, apiKey, interleavedThinking) { - }); - return { client, isOAuthToken: true }; - } -+ const apiBetaFeatures = ["extended-cache-ttl-2025-04-11", ...betaFeatures]; - const defaultHeaders = { - accept: "application/json", - "anthropic-dangerous-direct-browser-access": "true", -- "anthropic-beta": betaFeatures.join(","), -+ "anthropic-beta": apiBetaFeatures.join(","), - ...(model.headers || {}), - }; - const client = new Anthropic({ -@@ -313,9 +314,11 @@ function createClient(model, apiKey, interleavedThinking) { - return { client, isOAuthToken: false }; - } - function buildParams(model, context, isOAuthToken, options) { -+ const cacheControlTtl = !isOAuthToken ? (options?.cacheControlTtl ?? "1h") : undefined; -+ const cacheControl = cacheControlTtl ? { type: "ephemeral", ttl: cacheControlTtl } : { type: "ephemeral" }; - const params = { - model: model.id, -- messages: convertMessages(context.messages, model, isOAuthToken), -+ messages: convertMessages(context.messages, model, isOAuthToken, cacheControl), - max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0, - stream: true, - }; -@@ -325,18 +328,14 @@ function buildParams(model, context, isOAuthToken, options) { - { - type: "text", - text: "You are Claude Code, Anthropic's official CLI for Claude.", -- cache_control: { -- type: "ephemeral", -- }, -+ cache_control: cacheControl, - }, - ]; - if (context.systemPrompt) { - params.system.push({ - type: "text", - text: sanitizeSurrogates(context.systemPrompt), -- cache_control: { -- type: "ephemeral", -- }, -+ cache_control: cacheControl, - }); - } - } -@@ -346,9 +345,7 @@ function buildParams(model, context, isOAuthToken, options) { - { - type: "text", - text: sanitizeSurrogates(context.systemPrompt), -- cache_control: { -- type: "ephemeral", -- }, -+ cache_control: cacheControl, - }, - ]; - } -@@ -378,7 +375,7 @@ function buildParams(model, context, isOAuthToken, options) { - function normalizeToolCallId(id) { - return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); - } --function convertMessages(messages, model, isOAuthToken) { -+function convertMessages(messages, model, isOAuthToken, cacheControl) { - const params = []; - // Transform messages for cross-provider compatibility - const transformedMessages = transformMessages(messages, model, normalizeToolCallId); -@@ -514,7 +511,7 @@ function convertMessages(messages, model, isOAuthToken) { - const lastBlock = lastMessage.content[lastMessage.content.length - 1]; - if (lastBlock && - (lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result")) { -- lastBlock.cache_control = { type: "ephemeral" }; -+ lastBlock.cache_control = cacheControl; - } - } - } -diff --git a/dist/providers/openai-completions.js b/dist/providers/openai-completions.js -index ee5c88d8e280ceeff45ed075f2c7357d40005578..89daad7b0e53753e094028291226d32da9446440 100644 ---- a/dist/providers/openai-completions.js -+++ b/dist/providers/openai-completions.js -@@ -305,7 +305,7 @@ function createClient(model, context, apiKey) { - function buildParams(model, context, options) { - const compat = getCompat(model); - const messages = convertMessages(model, context, compat); -- maybeAddOpenRouterAnthropicCacheControl(model, messages); -+ maybeAddOpenRouterAnthropicCacheControl(model, messages, options?.cacheControlTtl); - const params = { - model: model.id, - messages, -@@ -349,9 +349,10 @@ function buildParams(model, context, options) { - } - return params; - } --function maybeAddOpenRouterAnthropicCacheControl(model, messages) { -+function maybeAddOpenRouterAnthropicCacheControl(model, messages, cacheControlTtl) { - if (model.provider !== "openrouter" || !model.id.startsWith("anthropic/")) - return; -+ const cacheControl = cacheControlTtl ? { type: "ephemeral", ttl: cacheControlTtl } : { type: "ephemeral" }; - // Anthropic-style caching requires cache_control on a text part. Add a breakpoint - // on the last user/assistant message (walking backwards until we find text content). - for (let i = messages.length - 1; i >= 0; i--) { -@@ -361,7 +362,7 @@ function maybeAddOpenRouterAnthropicCacheControl(model, messages) { - const content = msg.content; - if (typeof content === "string") { - msg.content = [ -- Object.assign({ type: "text", text: content }, { cache_control: { type: "ephemeral" } }), -+ Object.assign({ type: "text", text: content }, { cache_control: cacheControl }), - ]; - return; - } -@@ -371,7 +372,7 @@ function maybeAddOpenRouterAnthropicCacheControl(model, messages) { - for (let j = content.length - 1; j >= 0; j--) { - const part = content[j]; - if (part?.type === "text") { -- Object.assign(part, { cache_control: { type: "ephemeral" } }); -+ Object.assign(part, { cache_control: cacheControl }); - return; - } - } -diff --git a/dist/stream.js b/dist/stream.js -index d23fdd9f226a949fac4f2c7160af76f7f5fe71d1..3500f074bd88b85f4c7dd9bf42279f80fdf264d1 100644 ---- a/dist/stream.js -+++ b/dist/stream.js -@@ -146,6 +146,7 @@ function mapOptionsForApi(model, options, apiKey) { - signal: options?.signal, - apiKey: apiKey || options?.apiKey, - sessionId: options?.sessionId, -+ cacheControlTtl: options?.cacheControlTtl, - }; - // Helper to clamp xhigh to high for providers that don't support it - const clampReasoning = (effort) => (effort === "xhigh" ? "high" : effort); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6ab92034..e0a669c0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,6 @@ overrides: hono: 4.11.4 tar: 7.5.4 -patchedDependencies: - '@mariozechner/pi-ai@0.49.2': - hash: 4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e - path: patches/@mariozechner__pi-ai@0.49.2.patch - importers: .: @@ -44,7 +39,7 @@ importers: version: 0.49.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': specifier: 0.49.2 - version: 0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5) + version: 0.49.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-coding-agent': specifier: 0.49.2 version: 0.49.2(ws@8.19.0)(zod@4.3.5) @@ -6265,7 +6260,7 @@ snapshots: '@mariozechner/pi-agent-core@0.49.2(ws@8.19.0)(zod@4.3.5)': dependencies: - '@mariozechner/pi-ai': 0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.49.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.49.2 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -6276,7 +6271,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-ai@0.49.2(ws@8.19.0)(zod@4.3.5)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@aws-sdk/client-bedrock-runtime': 3.971.0 @@ -6303,7 +6298,7 @@ snapshots: '@mariozechner/clipboard': 0.3.0 '@mariozechner/jiti': 2.6.5 '@mariozechner/pi-agent-core': 0.49.2(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-ai': 0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.49.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.49.2 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 41260013e..f0cc385b3 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -207,6 +207,15 @@ echo "📦 Copying device model resources" rm -rf "$APP_ROOT/Contents/Resources/DeviceModels" cp -R "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels" +echo "📦 Copying model catalog" +MODEL_CATALOG_SRC="$ROOT_DIR/node_modules/@mariozechner/pi-ai/dist/models.generated.js" +MODEL_CATALOG_DEST="$APP_ROOT/Contents/Resources/models.generated.js" +if [ -f "$MODEL_CATALOG_SRC" ]; then + cp "$MODEL_CATALOG_SRC" "$MODEL_CATALOG_DEST" +else + echo "WARN: model catalog missing at $MODEL_CATALOG_SRC (continuing)" >&2 +fi + echo "📦 Copying ClawdbotKit resources" CLAWDBOTKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/ClawdbotKit_ClawdbotKit.bundle" if [ -d "$CLAWDBOTKIT_BUNDLE" ]; then From c145a0d11614892dfecf1aa3c4b9c4de3c9c0e11 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 19:48:26 +0000 Subject: [PATCH 047/171] docs: update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5b81a8d..2691cde9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,11 @@ Docs: https://docs.clawd.bot - Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. ### Fixes +- Embedded runner: drop obsolete pi-mono transcript workarounds now handled upstream. - Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. - Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. - macOS: exec approvals now respect wildcard agent allowlists (`*`). +- macOS: bundle and cache the model catalog instead of reading from a local pi-mono checkout. - macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-. - UI: remove the chat stop button and keep the composer aligned to the bottom edge. - Typing: start instant typing indicators at run start so DMs and mentions show immediately. From 6866cca6d7440fb4f64792ebc1869bc154e23d49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 19:58:28 +0000 Subject: [PATCH 048/171] docs: clarify cache-ttl pruning window --- docs/concepts/session-pruning.md | 1 + docs/gateway/configuration.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/concepts/session-pruning.md b/docs/concepts/session-pruning.md index 5e91b9fb8..fdd5080e0 100644 --- a/docs/concepts/session-pruning.md +++ b/docs/concepts/session-pruning.md @@ -13,6 +13,7 @@ Session pruning trims **old tool results** from the in-memory context right befo - Only affects the messages sent to the model for that request. - Only active for Anthropic API calls (and OpenRouter Anthropic models). - For best results, match `ttl` to your model `cacheControlTtl`. + - After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again. ## What can be pruned - Only `toolResult` messages. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index ddce68e79..bdf2ad29b 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1597,6 +1597,7 @@ Notes / current limitations: - The estimated “context ratio” is based on **characters** (approximate), not exact tokens. - If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped. - `cache-ttl` only activates for Anthropic API calls (and OpenRouter Anthropic models). +- After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again. - For best results, match `contextPruning.ttl` to the model `cacheControlTtl` you set in `agents.defaults.models.*.params`. Default (off): From 0daaa5b5925952e75eaf4f98a73f3da4f0e0be9b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 20:00:18 +0000 Subject: [PATCH 049/171] fix: restore 1h cache ttl option --- docs/gateway/configuration.md | 2 +- docs/providers/anthropic.md | 2 +- src/agents/pi-embedded-runner/extra-params.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index bdf2ad29b..b6fd7d141 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include: - `alias` (optional model shortcut, e.g. `/opus`). - `params` (optional provider-specific API params passed through to the model request). -`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers. +`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers. Example: diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 80cbe5d8b..f09d1c870 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -47,7 +47,7 @@ To set the TTL per model, use `cacheControlTtl` in the model `params`: defaults: { models: { "anthropic/claude-opus-4-5": { - params: { cacheControlTtl: "5m" } + params: { cacheControlTtl: "5m" } // or "1h" } } } diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index f6a4490a4..11f2ab83a 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -21,7 +21,7 @@ export function resolveExtraParams(params: { return modelConfig?.params ? { ...modelConfig.params } : undefined; } -type CacheControlTtl = "5m"; +type CacheControlTtl = "5m" | "1h"; function resolveCacheControlTtl( extraParams: Record | undefined, @@ -29,7 +29,7 @@ function resolveCacheControlTtl( modelId: string, ): CacheControlTtl | undefined { const raw = extraParams?.cacheControlTtl; - if (raw !== "5m") return undefined; + if (raw !== "5m" && raw !== "1h") return undefined; if (provider === "anthropic") return raw; if (provider === "openrouter" && modelId.startsWith("anthropic/")) return raw; return undefined; From 44a3539ffa6a2d711df73d700bf47c49b49b2aef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 20:06:24 +0000 Subject: [PATCH 050/171] tmp --- src/canvas-host/a2ui/.bundle.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index b14bb0bad..6e2a91754 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5 +27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce From 8b8e078ef8906edb1bbd40c027200c756fe0ee82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 20:10:24 +0000 Subject: [PATCH 051/171] chore(canvas): update a2ui bundle --- src/canvas-host/a2ui/a2ui.bundle.js | 32685 ++++++++++++-------------- 1 file changed, 15246 insertions(+), 17439 deletions(-) diff --git a/src/canvas-host/a2ui/a2ui.bundle.js b/src/canvas-host/a2ui/a2ui.bundle.js index 43b4c22ec..c29280acd 100644 --- a/src/canvas-host/a2ui/a2ui.bundle.js +++ b/src/canvas-host/a2ui/a2ui.bundle.js @@ -1,1396 +1,978 @@ var __defProp$1 = Object.defineProperty; var __exportAll = (all, symbols) => { - let target = {}; - for (var name in all) { - __defProp$1(target, name, { - get: all[name], - enumerable: true, - }); - } - if (symbols) { - __defProp$1(target, Symbol.toStringTag, { value: "Module" }); - } - return target; + let target = {}; + for (var name in all) { + __defProp$1(target, name, { + get: all[name], + enumerable: true + }); + } + if (symbols) { + __defProp$1(target, Symbol.toStringTag, { value: "Module" }); + } + return target; }; /** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ -const t$6 = globalThis, - e$13 = - t$6.ShadowRoot && - (void 0 === t$6.ShadyCSS || t$6.ShadyCSS.nativeShadow) && - "adoptedStyleSheets" in Document.prototype && - "replace" in CSSStyleSheet.prototype, - s$8 = Symbol(), - o$14 = new WeakMap(); +* @license +* Copyright 2019 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ +const t$6 = globalThis, e$13 = t$6.ShadowRoot && (void 0 === t$6.ShadyCSS || t$6.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, s$8 = Symbol(), o$14 = new WeakMap(); var n$12 = class { - constructor(t$7, e$14, o$15) { - if (((this._$cssResult$ = !0), o$15 !== s$8)) - throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); - ((this.cssText = t$7), (this.t = e$14)); - } - get styleSheet() { - let t$7 = this.o; - const s$9 = this.t; - if (e$13 && void 0 === t$7) { - const e$14 = void 0 !== s$9 && 1 === s$9.length; - (e$14 && (t$7 = o$14.get(s$9)), - void 0 === t$7 && - ((this.o = t$7 = new CSSStyleSheet()).replaceSync(this.cssText), - e$14 && o$14.set(s$9, t$7))); - } - return t$7; - } - toString() { - return this.cssText; - } + constructor(t$7, e$14, o$15) { + if (this._$cssResult$ = !0, o$15 !== s$8) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + this.cssText = t$7, this.t = e$14; + } + get styleSheet() { + let t$7 = this.o; + const s$9 = this.t; + if (e$13 && void 0 === t$7) { + const e$14 = void 0 !== s$9 && 1 === s$9.length; + e$14 && (t$7 = o$14.get(s$9)), void 0 === t$7 && ((this.o = t$7 = new CSSStyleSheet()).replaceSync(this.cssText), e$14 && o$14.set(s$9, t$7)); + } + return t$7; + } + toString() { + return this.cssText; + } }; -const r$11 = (t$7) => new n$12("string" == typeof t$7 ? t$7 : t$7 + "", void 0, s$8), - i$9 = (t$7, ...e$14) => { - const o$15 = - 1 === t$7.length - ? t$7[0] - : e$14.reduce( - (e$15, s$9, o$16) => - e$15 + - ((t$8) => { - if (!0 === t$8._$cssResult$) return t$8.cssText; - if ("number" == typeof t$8) return t$8; - throw Error( - "Value passed to 'css' function must be a 'css' function result: " + - t$8 + - ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.", - ); - })(s$9) + - t$7[o$16 + 1], - t$7[0], - ); - return new n$12(o$15, t$7, s$8); - }, - S$1 = (s$9, o$15) => { - if (e$13) - s$9.adoptedStyleSheets = o$15.map((t$7) => - t$7 instanceof CSSStyleSheet ? t$7 : t$7.styleSheet, - ); - else - for (const e$14 of o$15) { - const o$16 = document.createElement("style"), - n$13 = t$6.litNonce; - (void 0 !== n$13 && o$16.setAttribute("nonce", n$13), - (o$16.textContent = e$14.cssText), - s$9.appendChild(o$16)); - } - }, - c$6 = e$13 - ? (t$7) => t$7 - : (t$7) => - t$7 instanceof CSSStyleSheet - ? ((t$8) => { - let e$14 = ""; - for (const s$9 of t$8.cssRules) e$14 += s$9.cssText; - return r$11(e$14); - })(t$7) - : t$7; +const r$11 = (t$7) => new n$12("string" == typeof t$7 ? t$7 : t$7 + "", void 0, s$8), i$9 = (t$7, ...e$14) => { + const o$15 = 1 === t$7.length ? t$7[0] : e$14.reduce((e$15, s$9, o$16) => e$15 + ((t$8) => { + if (!0 === t$8._$cssResult$) return t$8.cssText; + if ("number" == typeof t$8) return t$8; + throw Error("Value passed to 'css' function must be a 'css' function result: " + t$8 + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); + })(s$9) + t$7[o$16 + 1], t$7[0]); + return new n$12(o$15, t$7, s$8); +}, S$1 = (s$9, o$15) => { + if (e$13) s$9.adoptedStyleSheets = o$15.map((t$7) => t$7 instanceof CSSStyleSheet ? t$7 : t$7.styleSheet); + else for (const e$14 of o$15) { + const o$16 = document.createElement("style"), n$13 = t$6.litNonce; + void 0 !== n$13 && o$16.setAttribute("nonce", n$13), o$16.textContent = e$14.cssText, s$9.appendChild(o$16); + } +}, c$6 = e$13 ? (t$7) => t$7 : (t$7) => t$7 instanceof CSSStyleSheet ? ((t$8) => { + let e$14 = ""; + for (const s$9 of t$8.cssRules) e$14 += s$9.cssText; + return r$11(e$14); +})(t$7) : t$7; /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ const { - is: i$8, - defineProperty: e$12, - getOwnPropertyDescriptor: h$6, - getOwnPropertyNames: r$10, - getOwnPropertySymbols: o$13, - getPrototypeOf: n$11, - } = Object, - a$1 = globalThis, - c$5 = a$1.trustedTypes, - l$4 = c$5 ? c$5.emptyScript : "", - p$2 = a$1.reactiveElementPolyfillSupport, - d$2 = (t$7, s$9) => t$7, - u$3 = { - toAttribute(t$7, s$9) { - switch (s$9) { - case Boolean: - t$7 = t$7 ? l$4 : null; - break; - case Object: - case Array: - t$7 = null == t$7 ? t$7 : JSON.stringify(t$7); - } - return t$7; - }, - fromAttribute(t$7, s$9) { - let i$10 = t$7; - switch (s$9) { - case Boolean: - i$10 = null !== t$7; - break; - case Number: - i$10 = null === t$7 ? null : Number(t$7); - break; - case Object: - case Array: - try { - i$10 = JSON.parse(t$7); - } catch (t$8) { - i$10 = null; - } - } - return i$10; - }, - }, - f$3 = (t$7, s$9) => !i$8(t$7, s$9), - b$1 = { - attribute: !0, - type: String, - converter: u$3, - reflect: !1, - useDefault: !1, - hasChanged: f$3, - }; -((Symbol.metadata ??= Symbol("metadata")), (a$1.litPropertyMetadata ??= new WeakMap())); +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const { is: i$8, defineProperty: e$12, getOwnPropertyDescriptor: h$6, getOwnPropertyNames: r$10, getOwnPropertySymbols: o$13, getPrototypeOf: n$11 } = Object, a$1 = globalThis, c$5 = a$1.trustedTypes, l$4 = c$5 ? c$5.emptyScript : "", p$2 = a$1.reactiveElementPolyfillSupport, d$2 = (t$7, s$9) => t$7, u$3 = { + toAttribute(t$7, s$9) { + switch (s$9) { + case Boolean: + t$7 = t$7 ? l$4 : null; + break; + case Object: + case Array: t$7 = null == t$7 ? t$7 : JSON.stringify(t$7); + } + return t$7; + }, + fromAttribute(t$7, s$9) { + let i$10 = t$7; + switch (s$9) { + case Boolean: + i$10 = null !== t$7; + break; + case Number: + i$10 = null === t$7 ? null : Number(t$7); + break; + case Object: + case Array: try { + i$10 = JSON.parse(t$7); + } catch (t$8) { + i$10 = null; + } + } + return i$10; + } +}, f$3 = (t$7, s$9) => !i$8(t$7, s$9), b$1 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + useDefault: !1, + hasChanged: f$3 +}; +Symbol.metadata ??= Symbol("metadata"), a$1.litPropertyMetadata ??= new WeakMap(); var y$1 = class extends HTMLElement { - static addInitializer(t$7) { - (this._$Ei(), (this.l ??= []).push(t$7)); - } - static get observedAttributes() { - return (this.finalize(), this._$Eh && [...this._$Eh.keys()]); - } - static createProperty(t$7, s$9 = b$1) { - if ( - (s$9.state && (s$9.attribute = !1), - this._$Ei(), - this.prototype.hasOwnProperty(t$7) && ((s$9 = Object.create(s$9)).wrapped = !0), - this.elementProperties.set(t$7, s$9), - !s$9.noAccessor) - ) { - const i$10 = Symbol(), - h$7 = this.getPropertyDescriptor(t$7, i$10, s$9); - void 0 !== h$7 && e$12(this.prototype, t$7, h$7); - } - } - static getPropertyDescriptor(t$7, s$9, i$10) { - const { get: e$14, set: r$12 } = h$6(this.prototype, t$7) ?? { - get() { - return this[s$9]; - }, - set(t$8) { - this[s$9] = t$8; - }, - }; - return { - get: e$14, - set(s$10) { - const h$7 = e$14?.call(this); - (r$12?.call(this, s$10), this.requestUpdate(t$7, h$7, i$10)); - }, - configurable: !0, - enumerable: !0, - }; - } - static getPropertyOptions(t$7) { - return this.elementProperties.get(t$7) ?? b$1; - } - static _$Ei() { - if (this.hasOwnProperty(d$2("elementProperties"))) return; - const t$7 = n$11(this); - (t$7.finalize(), - void 0 !== t$7.l && (this.l = [...t$7.l]), - (this.elementProperties = new Map(t$7.elementProperties))); - } - static finalize() { - if (this.hasOwnProperty(d$2("finalized"))) return; - if (((this.finalized = !0), this._$Ei(), this.hasOwnProperty(d$2("properties")))) { - const t$8 = this.properties, - s$9 = [...r$10(t$8), ...o$13(t$8)]; - for (const i$10 of s$9) this.createProperty(i$10, t$8[i$10]); - } - const t$7 = this[Symbol.metadata]; - if (null !== t$7) { - const s$9 = litPropertyMetadata.get(t$7); - if (void 0 !== s$9) for (const [t$8, i$10] of s$9) this.elementProperties.set(t$8, i$10); - } - this._$Eh = new Map(); - for (const [t$8, s$9] of this.elementProperties) { - const i$10 = this._$Eu(t$8, s$9); - void 0 !== i$10 && this._$Eh.set(i$10, t$8); - } - this.elementStyles = this.finalizeStyles(this.styles); - } - static finalizeStyles(s$9) { - const i$10 = []; - if (Array.isArray(s$9)) { - const e$14 = new Set(s$9.flat(1 / 0).reverse()); - for (const s$10 of e$14) i$10.unshift(c$6(s$10)); - } else void 0 !== s$9 && i$10.push(c$6(s$9)); - return i$10; - } - static _$Eu(t$7, s$9) { - const i$10 = s$9.attribute; - return !1 === i$10 - ? void 0 - : "string" == typeof i$10 - ? i$10 - : "string" == typeof t$7 - ? t$7.toLowerCase() - : void 0; - } - constructor() { - (super(), - (this._$Ep = void 0), - (this.isUpdatePending = !1), - (this.hasUpdated = !1), - (this._$Em = null), - this._$Ev()); - } - _$Ev() { - ((this._$ES = new Promise((t$7) => (this.enableUpdating = t$7))), - (this._$AL = new Map()), - this._$E_(), - this.requestUpdate(), - this.constructor.l?.forEach((t$7) => t$7(this))); - } - addController(t$7) { - ((this._$EO ??= new Set()).add(t$7), - void 0 !== this.renderRoot && this.isConnected && t$7.hostConnected?.()); - } - removeController(t$7) { - this._$EO?.delete(t$7); - } - _$E_() { - const t$7 = new Map(), - s$9 = this.constructor.elementProperties; - for (const i$10 of s$9.keys()) - this.hasOwnProperty(i$10) && (t$7.set(i$10, this[i$10]), delete this[i$10]); - t$7.size > 0 && (this._$Ep = t$7); - } - createRenderRoot() { - const t$7 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return (S$1(t$7, this.constructor.elementStyles), t$7); - } - connectedCallback() { - ((this.renderRoot ??= this.createRenderRoot()), - this.enableUpdating(!0), - this._$EO?.forEach((t$7) => t$7.hostConnected?.())); - } - enableUpdating(t$7) {} - disconnectedCallback() { - this._$EO?.forEach((t$7) => t$7.hostDisconnected?.()); - } - attributeChangedCallback(t$7, s$9, i$10) { - this._$AK(t$7, i$10); - } - _$ET(t$7, s$9) { - const i$10 = this.constructor.elementProperties.get(t$7), - e$14 = this.constructor._$Eu(t$7, i$10); - if (void 0 !== e$14 && !0 === i$10.reflect) { - const h$7 = (void 0 !== i$10.converter?.toAttribute ? i$10.converter : u$3).toAttribute( - s$9, - i$10.type, - ); - ((this._$Em = t$7), - null == h$7 ? this.removeAttribute(e$14) : this.setAttribute(e$14, h$7), - (this._$Em = null)); - } - } - _$AK(t$7, s$9) { - const i$10 = this.constructor, - e$14 = i$10._$Eh.get(t$7); - if (void 0 !== e$14 && this._$Em !== e$14) { - const t$8 = i$10.getPropertyOptions(e$14), - h$7 = - "function" == typeof t$8.converter - ? { fromAttribute: t$8.converter } - : void 0 !== t$8.converter?.fromAttribute - ? t$8.converter - : u$3; - this._$Em = e$14; - const r$12 = h$7.fromAttribute(s$9, t$8.type); - ((this[e$14] = r$12 ?? this._$Ej?.get(e$14) ?? r$12), (this._$Em = null)); - } - } - requestUpdate(t$7, s$9, i$10, e$14 = !1, h$7) { - if (void 0 !== t$7) { - const r$12 = this.constructor; - if ( - (!1 === e$14 && (h$7 = this[t$7]), - (i$10 ??= r$12.getPropertyOptions(t$7)), - !( - (i$10.hasChanged ?? f$3)(h$7, s$9) || - (i$10.useDefault && - i$10.reflect && - h$7 === this._$Ej?.get(t$7) && - !this.hasAttribute(r$12._$Eu(t$7, i$10))) - )) - ) - return; - this.C(t$7, s$9, i$10); - } - !1 === this.isUpdatePending && (this._$ES = this._$EP()); - } - C(t$7, s$9, { useDefault: i$10, reflect: e$14, wrapped: h$7 }, r$12) { - (i$10 && - !(this._$Ej ??= new Map()).has(t$7) && - (this._$Ej.set(t$7, r$12 ?? s$9 ?? this[t$7]), !0 !== h$7 || void 0 !== r$12)) || - (this._$AL.has(t$7) || (this.hasUpdated || i$10 || (s$9 = void 0), this._$AL.set(t$7, s$9)), - !0 === e$14 && this._$Em !== t$7 && (this._$Eq ??= new Set()).add(t$7)); - } - async _$EP() { - this.isUpdatePending = !0; - try { - await this._$ES; - } catch (t$8) { - Promise.reject(t$8); - } - const t$7 = this.scheduleUpdate(); - return (null != t$7 && (await t$7), !this.isUpdatePending); - } - scheduleUpdate() { - return this.performUpdate(); - } - performUpdate() { - if (!this.isUpdatePending) return; - if (!this.hasUpdated) { - if (((this.renderRoot ??= this.createRenderRoot()), this._$Ep)) { - for (const [t$9, s$10] of this._$Ep) this[t$9] = s$10; - this._$Ep = void 0; - } - const t$8 = this.constructor.elementProperties; - if (t$8.size > 0) - for (const [s$10, i$10] of t$8) { - const { wrapped: t$9 } = i$10, - e$14 = this[s$10]; - !0 !== t$9 || this._$AL.has(s$10) || void 0 === e$14 || this.C(s$10, void 0, i$10, e$14); - } - } - let t$7 = !1; - const s$9 = this._$AL; - try { - ((t$7 = this.shouldUpdate(s$9)), - t$7 - ? (this.willUpdate(s$9), - this._$EO?.forEach((t$8) => t$8.hostUpdate?.()), - this.update(s$9)) - : this._$EM()); - } catch (s$10) { - throw ((t$7 = !1), this._$EM(), s$10); - } - t$7 && this._$AE(s$9); - } - willUpdate(t$7) {} - _$AE(t$7) { - (this._$EO?.forEach((t$8) => t$8.hostUpdated?.()), - this.hasUpdated || ((this.hasUpdated = !0), this.firstUpdated(t$7)), - this.updated(t$7)); - } - _$EM() { - ((this._$AL = new Map()), (this.isUpdatePending = !1)); - } - get updateComplete() { - return this.getUpdateComplete(); - } - getUpdateComplete() { - return this._$ES; - } - shouldUpdate(t$7) { - return !0; - } - update(t$7) { - ((this._$Eq &&= this._$Eq.forEach((t$8) => this._$ET(t$8, this[t$8]))), this._$EM()); - } - updated(t$7) {} - firstUpdated(t$7) {} + static addInitializer(t$7) { + this._$Ei(), (this.l ??= []).push(t$7); + } + static get observedAttributes() { + return this.finalize(), this._$Eh && [...this._$Eh.keys()]; + } + static createProperty(t$7, s$9 = b$1) { + if (s$9.state && (s$9.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(t$7) && ((s$9 = Object.create(s$9)).wrapped = !0), this.elementProperties.set(t$7, s$9), !s$9.noAccessor) { + const i$10 = Symbol(), h$7 = this.getPropertyDescriptor(t$7, i$10, s$9); + void 0 !== h$7 && e$12(this.prototype, t$7, h$7); + } + } + static getPropertyDescriptor(t$7, s$9, i$10) { + const { get: e$14, set: r$12 } = h$6(this.prototype, t$7) ?? { + get() { + return this[s$9]; + }, + set(t$8) { + this[s$9] = t$8; + } + }; + return { + get: e$14, + set(s$10) { + const h$7 = e$14?.call(this); + r$12?.call(this, s$10), this.requestUpdate(t$7, h$7, i$10); + }, + configurable: !0, + enumerable: !0 + }; + } + static getPropertyOptions(t$7) { + return this.elementProperties.get(t$7) ?? b$1; + } + static _$Ei() { + if (this.hasOwnProperty(d$2("elementProperties"))) return; + const t$7 = n$11(this); + t$7.finalize(), void 0 !== t$7.l && (this.l = [...t$7.l]), this.elementProperties = new Map(t$7.elementProperties); + } + static finalize() { + if (this.hasOwnProperty(d$2("finalized"))) return; + if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(d$2("properties"))) { + const t$8 = this.properties, s$9 = [...r$10(t$8), ...o$13(t$8)]; + for (const i$10 of s$9) this.createProperty(i$10, t$8[i$10]); + } + const t$7 = this[Symbol.metadata]; + if (null !== t$7) { + const s$9 = litPropertyMetadata.get(t$7); + if (void 0 !== s$9) for (const [t$8, i$10] of s$9) this.elementProperties.set(t$8, i$10); + } + this._$Eh = new Map(); + for (const [t$8, s$9] of this.elementProperties) { + const i$10 = this._$Eu(t$8, s$9); + void 0 !== i$10 && this._$Eh.set(i$10, t$8); + } + this.elementStyles = this.finalizeStyles(this.styles); + } + static finalizeStyles(s$9) { + const i$10 = []; + if (Array.isArray(s$9)) { + const e$14 = new Set(s$9.flat(1 / 0).reverse()); + for (const s$10 of e$14) i$10.unshift(c$6(s$10)); + } else void 0 !== s$9 && i$10.push(c$6(s$9)); + return i$10; + } + static _$Eu(t$7, s$9) { + const i$10 = s$9.attribute; + return !1 === i$10 ? void 0 : "string" == typeof i$10 ? i$10 : "string" == typeof t$7 ? t$7.toLowerCase() : void 0; + } + constructor() { + super(), this._$Ep = void 0, this.isUpdatePending = !1, this.hasUpdated = !1, this._$Em = null, this._$Ev(); + } + _$Ev() { + this._$ES = new Promise((t$7) => this.enableUpdating = t$7), this._$AL = new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t$7) => t$7(this)); + } + addController(t$7) { + (this._$EO ??= new Set()).add(t$7), void 0 !== this.renderRoot && this.isConnected && t$7.hostConnected?.(); + } + removeController(t$7) { + this._$EO?.delete(t$7); + } + _$E_() { + const t$7 = new Map(), s$9 = this.constructor.elementProperties; + for (const i$10 of s$9.keys()) this.hasOwnProperty(i$10) && (t$7.set(i$10, this[i$10]), delete this[i$10]); + t$7.size > 0 && (this._$Ep = t$7); + } + createRenderRoot() { + const t$7 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return S$1(t$7, this.constructor.elementStyles), t$7; + } + connectedCallback() { + this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t$7) => t$7.hostConnected?.()); + } + enableUpdating(t$7) {} + disconnectedCallback() { + this._$EO?.forEach((t$7) => t$7.hostDisconnected?.()); + } + attributeChangedCallback(t$7, s$9, i$10) { + this._$AK(t$7, i$10); + } + _$ET(t$7, s$9) { + const i$10 = this.constructor.elementProperties.get(t$7), e$14 = this.constructor._$Eu(t$7, i$10); + if (void 0 !== e$14 && !0 === i$10.reflect) { + const h$7 = (void 0 !== i$10.converter?.toAttribute ? i$10.converter : u$3).toAttribute(s$9, i$10.type); + this._$Em = t$7, null == h$7 ? this.removeAttribute(e$14) : this.setAttribute(e$14, h$7), this._$Em = null; + } + } + _$AK(t$7, s$9) { + const i$10 = this.constructor, e$14 = i$10._$Eh.get(t$7); + if (void 0 !== e$14 && this._$Em !== e$14) { + const t$8 = i$10.getPropertyOptions(e$14), h$7 = "function" == typeof t$8.converter ? { fromAttribute: t$8.converter } : void 0 !== t$8.converter?.fromAttribute ? t$8.converter : u$3; + this._$Em = e$14; + const r$12 = h$7.fromAttribute(s$9, t$8.type); + this[e$14] = r$12 ?? this._$Ej?.get(e$14) ?? r$12, this._$Em = null; + } + } + requestUpdate(t$7, s$9, i$10, e$14 = !1, h$7) { + if (void 0 !== t$7) { + const r$12 = this.constructor; + if (!1 === e$14 && (h$7 = this[t$7]), i$10 ??= r$12.getPropertyOptions(t$7), !((i$10.hasChanged ?? f$3)(h$7, s$9) || i$10.useDefault && i$10.reflect && h$7 === this._$Ej?.get(t$7) && !this.hasAttribute(r$12._$Eu(t$7, i$10)))) return; + this.C(t$7, s$9, i$10); + } + !1 === this.isUpdatePending && (this._$ES = this._$EP()); + } + C(t$7, s$9, { useDefault: i$10, reflect: e$14, wrapped: h$7 }, r$12) { + i$10 && !(this._$Ej ??= new Map()).has(t$7) && (this._$Ej.set(t$7, r$12 ?? s$9 ?? this[t$7]), !0 !== h$7 || void 0 !== r$12) || (this._$AL.has(t$7) || (this.hasUpdated || i$10 || (s$9 = void 0), this._$AL.set(t$7, s$9)), !0 === e$14 && this._$Em !== t$7 && (this._$Eq ??= new Set()).add(t$7)); + } + async _$EP() { + this.isUpdatePending = !0; + try { + await this._$ES; + } catch (t$8) { + Promise.reject(t$8); + } + const t$7 = this.scheduleUpdate(); + return null != t$7 && await t$7, !this.isUpdatePending; + } + scheduleUpdate() { + return this.performUpdate(); + } + performUpdate() { + if (!this.isUpdatePending) return; + if (!this.hasUpdated) { + if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { + for (const [t$9, s$10] of this._$Ep) this[t$9] = s$10; + this._$Ep = void 0; + } + const t$8 = this.constructor.elementProperties; + if (t$8.size > 0) for (const [s$10, i$10] of t$8) { + const { wrapped: t$9 } = i$10, e$14 = this[s$10]; + !0 !== t$9 || this._$AL.has(s$10) || void 0 === e$14 || this.C(s$10, void 0, i$10, e$14); + } + } + let t$7 = !1; + const s$9 = this._$AL; + try { + t$7 = this.shouldUpdate(s$9), t$7 ? (this.willUpdate(s$9), this._$EO?.forEach((t$8) => t$8.hostUpdate?.()), this.update(s$9)) : this._$EM(); + } catch (s$10) { + throw t$7 = !1, this._$EM(), s$10; + } + t$7 && this._$AE(s$9); + } + willUpdate(t$7) {} + _$AE(t$7) { + this._$EO?.forEach((t$8) => t$8.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(t$7)), this.updated(t$7); + } + _$EM() { + this._$AL = new Map(), this.isUpdatePending = !1; + } + get updateComplete() { + return this.getUpdateComplete(); + } + getUpdateComplete() { + return this._$ES; + } + shouldUpdate(t$7) { + return !0; + } + update(t$7) { + this._$Eq &&= this._$Eq.forEach((t$8) => this._$ET(t$8, this[t$8])), this._$EM(); + } + updated(t$7) {} + firstUpdated(t$7) {} }; -((y$1.elementStyles = []), - (y$1.shadowRootOptions = { mode: "open" }), - (y$1[d$2("elementProperties")] = new Map()), - (y$1[d$2("finalized")] = new Map()), - p$2?.({ ReactiveElement: y$1 }), - (a$1.reactiveElementVersions ??= []).push("2.1.2")); +y$1.elementStyles = [], y$1.shadowRootOptions = { mode: "open" }, y$1[d$2("elementProperties")] = new Map(), y$1[d$2("finalized")] = new Map(), p$2?.({ ReactiveElement: y$1 }), (a$1.reactiveElementVersions ??= []).push("2.1.2"); /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ -const t$5 = globalThis, - i$7 = (t$7) => t$7, - s$7 = t$5.trustedTypes, - e$11 = s$7 ? s$7.createPolicy("lit-html", { createHTML: (t$7) => t$7 }) : void 0, - h$5 = "$lit$", - o$12 = `lit$${Math.random().toFixed(9).slice(2)}$`, - n$10 = "?" + o$12, - r$9 = `<${n$10}>`, - l$3 = document, - c$4 = () => l$3.createComment(""), - a = (t$7) => null === t$7 || ("object" != typeof t$7 && "function" != typeof t$7), - u$2 = Array.isArray, - d$1 = (t$7) => u$2(t$7) || "function" == typeof t$7?.[Symbol.iterator], - f$2 = "[ \n\f\r]", - v$1 = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, - _ = /-->/g, - m$2 = />/g, - p$1 = RegExp(`>|${f$2}(?:([^\\s"'>=/]+)(${f$2}*=${f$2}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`, "g"), - g = /'/g, - $ = /"/g, - y = /^(?:script|style|textarea|title)$/i, - x = - (t$7) => - (i$10, ...s$9) => ({ - _$litType$: t$7, - strings: i$10, - values: s$9, - }), - b = x(1), - w = x(2), - T = x(3), - E = Symbol.for("lit-noChange"), - A = Symbol.for("lit-nothing"), - C = new WeakMap(), - P = l$3.createTreeWalker(l$3, 129); +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ +const t$5 = globalThis, i$7 = (t$7) => t$7, s$7 = t$5.trustedTypes, e$11 = s$7 ? s$7.createPolicy("lit-html", { createHTML: (t$7) => t$7 }) : void 0, h$5 = "$lit$", o$12 = `lit$${Math.random().toFixed(9).slice(2)}$`, n$10 = "?" + o$12, r$9 = `<${n$10}>`, l$3 = document, c$4 = () => l$3.createComment(""), a = (t$7) => null === t$7 || "object" != typeof t$7 && "function" != typeof t$7, u$2 = Array.isArray, d$1 = (t$7) => u$2(t$7) || "function" == typeof t$7?.[Symbol.iterator], f$2 = "[ \n\f\r]", v$1 = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, _ = /-->/g, m$2 = />/g, p$1 = RegExp(`>|${f$2}(?:([^\\s"'>=/]+)(${f$2}*=${f$2}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`, "g"), g = /'/g, $ = /"/g, y = /^(?:script|style|textarea|title)$/i, x = (t$7) => (i$10, ...s$9) => ({ + _$litType$: t$7, + strings: i$10, + values: s$9 +}), b = x(1), w = x(2), T = x(3), E = Symbol.for("lit-noChange"), A = Symbol.for("lit-nothing"), C = new WeakMap(), P = l$3.createTreeWalker(l$3, 129); function V(t$7, i$10) { - if (!u$2(t$7) || !t$7.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return void 0 !== e$11 ? e$11.createHTML(i$10) : i$10; + if (!u$2(t$7) || !t$7.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return void 0 !== e$11 ? e$11.createHTML(i$10) : i$10; } const N = (t$7, i$10) => { - const s$9 = t$7.length - 1, - e$14 = []; - let n$13, - l$5 = 2 === i$10 ? "" : 3 === i$10 ? "" : "", - c$7 = v$1; - for (let i$11 = 0; i$11 < s$9; i$11++) { - const s$10 = t$7[i$11]; - let a, - u$4, - d$3 = -1, - f$4 = 0; - for (; f$4 < s$10.length && ((c$7.lastIndex = f$4), (u$4 = c$7.exec(s$10)), null !== u$4); ) - ((f$4 = c$7.lastIndex), - c$7 === v$1 - ? "!--" === u$4[1] - ? (c$7 = _) - : void 0 !== u$4[1] - ? (c$7 = m$2) - : void 0 !== u$4[2] - ? (y.test(u$4[2]) && (n$13 = RegExp("" === u$4[0] - ? ((c$7 = n$13 ?? v$1), (d$3 = -1)) - : void 0 === u$4[1] - ? (d$3 = -2) - : ((d$3 = c$7.lastIndex - u$4[2].length), - (a = u$4[1]), - (c$7 = void 0 === u$4[3] ? p$1 : '"' === u$4[3] ? $ : g)) - : c$7 === $ || c$7 === g - ? (c$7 = p$1) - : c$7 === _ || c$7 === m$2 - ? (c$7 = v$1) - : ((c$7 = p$1), (n$13 = void 0))); - const x = c$7 === p$1 && t$7[i$11 + 1].startsWith("/>") ? " " : ""; - l$5 += - c$7 === v$1 - ? s$10 + r$9 - : d$3 >= 0 - ? (e$14.push(a), s$10.slice(0, d$3) + h$5 + s$10.slice(d$3) + o$12 + x) - : s$10 + o$12 + (-2 === d$3 ? i$11 : x); - } - return [ - V(t$7, l$5 + (t$7[s$9] || "") + (2 === i$10 ? "" : 3 === i$10 ? "" : "")), - e$14, - ]; + const s$9 = t$7.length - 1, e$14 = []; + let n$13, l$5 = 2 === i$10 ? "" : 3 === i$10 ? "" : "", c$7 = v$1; + for (let i$11 = 0; i$11 < s$9; i$11++) { + const s$10 = t$7[i$11]; + let a, u$4, d$3 = -1, f$4 = 0; + for (; f$4 < s$10.length && (c$7.lastIndex = f$4, u$4 = c$7.exec(s$10), null !== u$4);) f$4 = c$7.lastIndex, c$7 === v$1 ? "!--" === u$4[1] ? c$7 = _ : void 0 !== u$4[1] ? c$7 = m$2 : void 0 !== u$4[2] ? (y.test(u$4[2]) && (n$13 = RegExp("" === u$4[0] ? (c$7 = n$13 ?? v$1, d$3 = -1) : void 0 === u$4[1] ? d$3 = -2 : (d$3 = c$7.lastIndex - u$4[2].length, a = u$4[1], c$7 = void 0 === u$4[3] ? p$1 : "\"" === u$4[3] ? $ : g) : c$7 === $ || c$7 === g ? c$7 = p$1 : c$7 === _ || c$7 === m$2 ? c$7 = v$1 : (c$7 = p$1, n$13 = void 0); + const x = c$7 === p$1 && t$7[i$11 + 1].startsWith("/>") ? " " : ""; + l$5 += c$7 === v$1 ? s$10 + r$9 : d$3 >= 0 ? (e$14.push(a), s$10.slice(0, d$3) + h$5 + s$10.slice(d$3) + o$12 + x) : s$10 + o$12 + (-2 === d$3 ? i$11 : x); + } + return [V(t$7, l$5 + (t$7[s$9] || "") + (2 === i$10 ? "" : 3 === i$10 ? "" : "")), e$14]; }; var S = class S { - constructor({ strings: t$7, _$litType$: i$10 }, e$14) { - let r$12; - this.parts = []; - let l$5 = 0, - a = 0; - const u$4 = t$7.length - 1, - d$3 = this.parts, - [f$4, v$2] = N(t$7, i$10); - if ( - ((this.el = S.createElement(f$4, e$14)), - (P.currentNode = this.el.content), - 2 === i$10 || 3 === i$10) - ) { - const t$8 = this.el.content.firstChild; - t$8.replaceWith(...t$8.childNodes); - } - for (; null !== (r$12 = P.nextNode()) && d$3.length < u$4; ) { - if (1 === r$12.nodeType) { - if (r$12.hasAttributes()) - for (const t$8 of r$12.getAttributeNames()) - if (t$8.endsWith(h$5)) { - const i$11 = v$2[a++], - s$9 = r$12.getAttribute(t$8).split(o$12), - e$15 = /([.?@])?(.*)/.exec(i$11); - (d$3.push({ - type: 1, - index: l$5, - name: e$15[2], - strings: s$9, - ctor: "." === e$15[1] ? I : "?" === e$15[1] ? L : "@" === e$15[1] ? z : H, - }), - r$12.removeAttribute(t$8)); - } else - t$8.startsWith(o$12) && - (d$3.push({ - type: 6, - index: l$5, - }), - r$12.removeAttribute(t$8)); - if (y.test(r$12.tagName)) { - const t$8 = r$12.textContent.split(o$12), - i$11 = t$8.length - 1; - if (i$11 > 0) { - r$12.textContent = s$7 ? s$7.emptyScript : ""; - for (let s$9 = 0; s$9 < i$11; s$9++) - (r$12.append(t$8[s$9], c$4()), - P.nextNode(), - d$3.push({ - type: 2, - index: ++l$5, - })); - r$12.append(t$8[i$11], c$4()); - } - } - } else if (8 === r$12.nodeType) - if (r$12.data === n$10) - d$3.push({ - type: 2, - index: l$5, - }); - else { - let t$8 = -1; - for (; -1 !== (t$8 = r$12.data.indexOf(o$12, t$8 + 1)); ) - (d$3.push({ - type: 7, - index: l$5, - }), - (t$8 += o$12.length - 1)); - } - l$5++; - } - } - static createElement(t$7, i$10) { - const s$9 = l$3.createElement("template"); - return ((s$9.innerHTML = t$7), s$9); - } + constructor({ strings: t$7, _$litType$: i$10 }, e$14) { + let r$12; + this.parts = []; + let l$5 = 0, a = 0; + const u$4 = t$7.length - 1, d$3 = this.parts, [f$4, v$2] = N(t$7, i$10); + if (this.el = S.createElement(f$4, e$14), P.currentNode = this.el.content, 2 === i$10 || 3 === i$10) { + const t$8 = this.el.content.firstChild; + t$8.replaceWith(...t$8.childNodes); + } + for (; null !== (r$12 = P.nextNode()) && d$3.length < u$4;) { + if (1 === r$12.nodeType) { + if (r$12.hasAttributes()) for (const t$8 of r$12.getAttributeNames()) if (t$8.endsWith(h$5)) { + const i$11 = v$2[a++], s$9 = r$12.getAttribute(t$8).split(o$12), e$15 = /([.?@])?(.*)/.exec(i$11); + d$3.push({ + type: 1, + index: l$5, + name: e$15[2], + strings: s$9, + ctor: "." === e$15[1] ? I : "?" === e$15[1] ? L : "@" === e$15[1] ? z : H + }), r$12.removeAttribute(t$8); + } else t$8.startsWith(o$12) && (d$3.push({ + type: 6, + index: l$5 + }), r$12.removeAttribute(t$8)); + if (y.test(r$12.tagName)) { + const t$8 = r$12.textContent.split(o$12), i$11 = t$8.length - 1; + if (i$11 > 0) { + r$12.textContent = s$7 ? s$7.emptyScript : ""; + for (let s$9 = 0; s$9 < i$11; s$9++) r$12.append(t$8[s$9], c$4()), P.nextNode(), d$3.push({ + type: 2, + index: ++l$5 + }); + r$12.append(t$8[i$11], c$4()); + } + } + } else if (8 === r$12.nodeType) if (r$12.data === n$10) d$3.push({ + type: 2, + index: l$5 + }); + else { + let t$8 = -1; + for (; -1 !== (t$8 = r$12.data.indexOf(o$12, t$8 + 1));) d$3.push({ + type: 7, + index: l$5 + }), t$8 += o$12.length - 1; + } + l$5++; + } + } + static createElement(t$7, i$10) { + const s$9 = l$3.createElement("template"); + return s$9.innerHTML = t$7, s$9; + } }; function M$1(t$7, i$10, s$9 = t$7, e$14) { - if (i$10 === E) return i$10; - let h$7 = void 0 !== e$14 ? s$9._$Co?.[e$14] : s$9._$Cl; - const o$15 = a(i$10) ? void 0 : i$10._$litDirective$; - return ( - h$7?.constructor !== o$15 && - (h$7?._$AO?.(!1), - void 0 === o$15 ? (h$7 = void 0) : ((h$7 = new o$15(t$7)), h$7._$AT(t$7, s$9, e$14)), - void 0 !== e$14 ? ((s$9._$Co ??= [])[e$14] = h$7) : (s$9._$Cl = h$7)), - void 0 !== h$7 && (i$10 = M$1(t$7, h$7._$AS(t$7, i$10.values), h$7, e$14)), - i$10 - ); + if (i$10 === E) return i$10; + let h$7 = void 0 !== e$14 ? s$9._$Co?.[e$14] : s$9._$Cl; + const o$15 = a(i$10) ? void 0 : i$10._$litDirective$; + return h$7?.constructor !== o$15 && (h$7?._$AO?.(!1), void 0 === o$15 ? h$7 = void 0 : (h$7 = new o$15(t$7), h$7._$AT(t$7, s$9, e$14)), void 0 !== e$14 ? (s$9._$Co ??= [])[e$14] = h$7 : s$9._$Cl = h$7), void 0 !== h$7 && (i$10 = M$1(t$7, h$7._$AS(t$7, i$10.values), h$7, e$14)), i$10; } var R = class { - constructor(t$7, i$10) { - ((this._$AV = []), (this._$AN = void 0), (this._$AD = t$7), (this._$AM = i$10)); - } - get parentNode() { - return this._$AM.parentNode; - } - get _$AU() { - return this._$AM._$AU; - } - u(t$7) { - const { - el: { content: i$10 }, - parts: s$9, - } = this._$AD, - e$14 = (t$7?.creationScope ?? l$3).importNode(i$10, !0); - P.currentNode = e$14; - let h$7 = P.nextNode(), - o$15 = 0, - n$13 = 0, - r$12 = s$9[0]; - for (; void 0 !== r$12; ) { - if (o$15 === r$12.index) { - let i$11; - (2 === r$12.type - ? (i$11 = new k(h$7, h$7.nextSibling, this, t$7)) - : 1 === r$12.type - ? (i$11 = new r$12.ctor(h$7, r$12.name, r$12.strings, this, t$7)) - : 6 === r$12.type && (i$11 = new Z(h$7, this, t$7)), - this._$AV.push(i$11), - (r$12 = s$9[++n$13])); - } - o$15 !== r$12?.index && ((h$7 = P.nextNode()), o$15++); - } - return ((P.currentNode = l$3), e$14); - } - p(t$7) { - let i$10 = 0; - for (const s$9 of this._$AV) - (void 0 !== s$9 && - (void 0 !== s$9.strings - ? (s$9._$AI(t$7, s$9, i$10), (i$10 += s$9.strings.length - 2)) - : s$9._$AI(t$7[i$10])), - i$10++); - } + constructor(t$7, i$10) { + this._$AV = [], this._$AN = void 0, this._$AD = t$7, this._$AM = i$10; + } + get parentNode() { + return this._$AM.parentNode; + } + get _$AU() { + return this._$AM._$AU; + } + u(t$7) { + const { el: { content: i$10 }, parts: s$9 } = this._$AD, e$14 = (t$7?.creationScope ?? l$3).importNode(i$10, !0); + P.currentNode = e$14; + let h$7 = P.nextNode(), o$15 = 0, n$13 = 0, r$12 = s$9[0]; + for (; void 0 !== r$12;) { + if (o$15 === r$12.index) { + let i$11; + 2 === r$12.type ? i$11 = new k(h$7, h$7.nextSibling, this, t$7) : 1 === r$12.type ? i$11 = new r$12.ctor(h$7, r$12.name, r$12.strings, this, t$7) : 6 === r$12.type && (i$11 = new Z(h$7, this, t$7)), this._$AV.push(i$11), r$12 = s$9[++n$13]; + } + o$15 !== r$12?.index && (h$7 = P.nextNode(), o$15++); + } + return P.currentNode = l$3, e$14; + } + p(t$7) { + let i$10 = 0; + for (const s$9 of this._$AV) void 0 !== s$9 && (void 0 !== s$9.strings ? (s$9._$AI(t$7, s$9, i$10), i$10 += s$9.strings.length - 2) : s$9._$AI(t$7[i$10])), i$10++; + } }; var k = class k { - get _$AU() { - return this._$AM?._$AU ?? this._$Cv; - } - constructor(t$7, i$10, s$9, e$14) { - ((this.type = 2), - (this._$AH = A), - (this._$AN = void 0), - (this._$AA = t$7), - (this._$AB = i$10), - (this._$AM = s$9), - (this.options = e$14), - (this._$Cv = e$14?.isConnected ?? !0)); - } - get parentNode() { - let t$7 = this._$AA.parentNode; - const i$10 = this._$AM; - return (void 0 !== i$10 && 11 === t$7?.nodeType && (t$7 = i$10.parentNode), t$7); - } - get startNode() { - return this._$AA; - } - get endNode() { - return this._$AB; - } - _$AI(t$7, i$10 = this) { - ((t$7 = M$1(this, t$7, i$10)), - a(t$7) - ? t$7 === A || null == t$7 || "" === t$7 - ? (this._$AH !== A && this._$AR(), (this._$AH = A)) - : t$7 !== this._$AH && t$7 !== E && this._(t$7) - : void 0 !== t$7._$litType$ - ? this.$(t$7) - : void 0 !== t$7.nodeType - ? this.T(t$7) - : d$1(t$7) - ? this.k(t$7) - : this._(t$7)); - } - O(t$7) { - return this._$AA.parentNode.insertBefore(t$7, this._$AB); - } - T(t$7) { - this._$AH !== t$7 && (this._$AR(), (this._$AH = this.O(t$7))); - } - _(t$7) { - (this._$AH !== A && a(this._$AH) - ? (this._$AA.nextSibling.data = t$7) - : this.T(l$3.createTextNode(t$7)), - (this._$AH = t$7)); - } - $(t$7) { - const { values: i$10, _$litType$: s$9 } = t$7, - e$14 = - "number" == typeof s$9 - ? this._$AC(t$7) - : (void 0 === s$9.el && (s$9.el = S.createElement(V(s$9.h, s$9.h[0]), this.options)), - s$9); - if (this._$AH?._$AD === e$14) this._$AH.p(i$10); - else { - const t$8 = new R(e$14, this), - s$10 = t$8.u(this.options); - (t$8.p(i$10), this.T(s$10), (this._$AH = t$8)); - } - } - _$AC(t$7) { - let i$10 = C.get(t$7.strings); - return (void 0 === i$10 && C.set(t$7.strings, (i$10 = new S(t$7))), i$10); - } - k(t$7) { - u$2(this._$AH) || ((this._$AH = []), this._$AR()); - const i$10 = this._$AH; - let s$9, - e$14 = 0; - for (const h$7 of t$7) - (e$14 === i$10.length - ? i$10.push((s$9 = new k(this.O(c$4()), this.O(c$4()), this, this.options))) - : (s$9 = i$10[e$14]), - s$9._$AI(h$7), - e$14++); - e$14 < i$10.length && (this._$AR(s$9 && s$9._$AB.nextSibling, e$14), (i$10.length = e$14)); - } - _$AR(t$7 = this._$AA.nextSibling, s$9) { - for (this._$AP?.(!1, !0, s$9); t$7 !== this._$AB; ) { - const s$10 = i$7(t$7).nextSibling; - (i$7(t$7).remove(), (t$7 = s$10)); - } - } - setConnected(t$7) { - void 0 === this._$AM && ((this._$Cv = t$7), this._$AP?.(t$7)); - } + get _$AU() { + return this._$AM?._$AU ?? this._$Cv; + } + constructor(t$7, i$10, s$9, e$14) { + this.type = 2, this._$AH = A, this._$AN = void 0, this._$AA = t$7, this._$AB = i$10, this._$AM = s$9, this.options = e$14, this._$Cv = e$14?.isConnected ?? !0; + } + get parentNode() { + let t$7 = this._$AA.parentNode; + const i$10 = this._$AM; + return void 0 !== i$10 && 11 === t$7?.nodeType && (t$7 = i$10.parentNode), t$7; + } + get startNode() { + return this._$AA; + } + get endNode() { + return this._$AB; + } + _$AI(t$7, i$10 = this) { + t$7 = M$1(this, t$7, i$10), a(t$7) ? t$7 === A || null == t$7 || "" === t$7 ? (this._$AH !== A && this._$AR(), this._$AH = A) : t$7 !== this._$AH && t$7 !== E && this._(t$7) : void 0 !== t$7._$litType$ ? this.$(t$7) : void 0 !== t$7.nodeType ? this.T(t$7) : d$1(t$7) ? this.k(t$7) : this._(t$7); + } + O(t$7) { + return this._$AA.parentNode.insertBefore(t$7, this._$AB); + } + T(t$7) { + this._$AH !== t$7 && (this._$AR(), this._$AH = this.O(t$7)); + } + _(t$7) { + this._$AH !== A && a(this._$AH) ? this._$AA.nextSibling.data = t$7 : this.T(l$3.createTextNode(t$7)), this._$AH = t$7; + } + $(t$7) { + const { values: i$10, _$litType$: s$9 } = t$7, e$14 = "number" == typeof s$9 ? this._$AC(t$7) : (void 0 === s$9.el && (s$9.el = S.createElement(V(s$9.h, s$9.h[0]), this.options)), s$9); + if (this._$AH?._$AD === e$14) this._$AH.p(i$10); + else { + const t$8 = new R(e$14, this), s$10 = t$8.u(this.options); + t$8.p(i$10), this.T(s$10), this._$AH = t$8; + } + } + _$AC(t$7) { + let i$10 = C.get(t$7.strings); + return void 0 === i$10 && C.set(t$7.strings, i$10 = new S(t$7)), i$10; + } + k(t$7) { + u$2(this._$AH) || (this._$AH = [], this._$AR()); + const i$10 = this._$AH; + let s$9, e$14 = 0; + for (const h$7 of t$7) e$14 === i$10.length ? i$10.push(s$9 = new k(this.O(c$4()), this.O(c$4()), this, this.options)) : s$9 = i$10[e$14], s$9._$AI(h$7), e$14++; + e$14 < i$10.length && (this._$AR(s$9 && s$9._$AB.nextSibling, e$14), i$10.length = e$14); + } + _$AR(t$7 = this._$AA.nextSibling, s$9) { + for (this._$AP?.(!1, !0, s$9); t$7 !== this._$AB;) { + const s$10 = i$7(t$7).nextSibling; + i$7(t$7).remove(), t$7 = s$10; + } + } + setConnected(t$7) { + void 0 === this._$AM && (this._$Cv = t$7, this._$AP?.(t$7)); + } }; var H = class { - get tagName() { - return this.element.tagName; - } - get _$AU() { - return this._$AM._$AU; - } - constructor(t$7, i$10, s$9, e$14, h$7) { - ((this.type = 1), - (this._$AH = A), - (this._$AN = void 0), - (this.element = t$7), - (this.name = i$10), - (this._$AM = e$14), - (this.options = h$7), - s$9.length > 2 || "" !== s$9[0] || "" !== s$9[1] - ? ((this._$AH = Array(s$9.length - 1).fill(new String())), (this.strings = s$9)) - : (this._$AH = A)); - } - _$AI(t$7, i$10 = this, s$9, e$14) { - const h$7 = this.strings; - let o$15 = !1; - if (void 0 === h$7) - ((t$7 = M$1(this, t$7, i$10, 0)), - (o$15 = !a(t$7) || (t$7 !== this._$AH && t$7 !== E)), - o$15 && (this._$AH = t$7)); - else { - const e$15 = t$7; - let n$13, r$12; - for (t$7 = h$7[0], n$13 = 0; n$13 < h$7.length - 1; n$13++) - ((r$12 = M$1(this, e$15[s$9 + n$13], i$10, n$13)), - r$12 === E && (r$12 = this._$AH[n$13]), - (o$15 ||= !a(r$12) || r$12 !== this._$AH[n$13]), - r$12 === A ? (t$7 = A) : t$7 !== A && (t$7 += (r$12 ?? "") + h$7[n$13 + 1]), - (this._$AH[n$13] = r$12)); - } - o$15 && !e$14 && this.j(t$7); - } - j(t$7) { - t$7 === A - ? this.element.removeAttribute(this.name) - : this.element.setAttribute(this.name, t$7 ?? ""); - } + get tagName() { + return this.element.tagName; + } + get _$AU() { + return this._$AM._$AU; + } + constructor(t$7, i$10, s$9, e$14, h$7) { + this.type = 1, this._$AH = A, this._$AN = void 0, this.element = t$7, this.name = i$10, this._$AM = e$14, this.options = h$7, s$9.length > 2 || "" !== s$9[0] || "" !== s$9[1] ? (this._$AH = Array(s$9.length - 1).fill(new String()), this.strings = s$9) : this._$AH = A; + } + _$AI(t$7, i$10 = this, s$9, e$14) { + const h$7 = this.strings; + let o$15 = !1; + if (void 0 === h$7) t$7 = M$1(this, t$7, i$10, 0), o$15 = !a(t$7) || t$7 !== this._$AH && t$7 !== E, o$15 && (this._$AH = t$7); + else { + const e$15 = t$7; + let n$13, r$12; + for (t$7 = h$7[0], n$13 = 0; n$13 < h$7.length - 1; n$13++) r$12 = M$1(this, e$15[s$9 + n$13], i$10, n$13), r$12 === E && (r$12 = this._$AH[n$13]), o$15 ||= !a(r$12) || r$12 !== this._$AH[n$13], r$12 === A ? t$7 = A : t$7 !== A && (t$7 += (r$12 ?? "") + h$7[n$13 + 1]), this._$AH[n$13] = r$12; + } + o$15 && !e$14 && this.j(t$7); + } + j(t$7) { + t$7 === A ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t$7 ?? ""); + } }; var I = class extends H { - constructor() { - (super(...arguments), (this.type = 3)); - } - j(t$7) { - this.element[this.name] = t$7 === A ? void 0 : t$7; - } + constructor() { + super(...arguments), this.type = 3; + } + j(t$7) { + this.element[this.name] = t$7 === A ? void 0 : t$7; + } }; var L = class extends H { - constructor() { - (super(...arguments), (this.type = 4)); - } - j(t$7) { - this.element.toggleAttribute(this.name, !!t$7 && t$7 !== A); - } + constructor() { + super(...arguments), this.type = 4; + } + j(t$7) { + this.element.toggleAttribute(this.name, !!t$7 && t$7 !== A); + } }; var z = class extends H { - constructor(t$7, i$10, s$9, e$14, h$7) { - (super(t$7, i$10, s$9, e$14, h$7), (this.type = 5)); - } - _$AI(t$7, i$10 = this) { - if ((t$7 = M$1(this, t$7, i$10, 0) ?? A) === E) return; - const s$9 = this._$AH, - e$14 = - (t$7 === A && s$9 !== A) || - t$7.capture !== s$9.capture || - t$7.once !== s$9.once || - t$7.passive !== s$9.passive, - h$7 = t$7 !== A && (s$9 === A || e$14); - (e$14 && this.element.removeEventListener(this.name, this, s$9), - h$7 && this.element.addEventListener(this.name, this, t$7), - (this._$AH = t$7)); - } - handleEvent(t$7) { - "function" == typeof this._$AH - ? this._$AH.call(this.options?.host ?? this.element, t$7) - : this._$AH.handleEvent(t$7); - } + constructor(t$7, i$10, s$9, e$14, h$7) { + super(t$7, i$10, s$9, e$14, h$7), this.type = 5; + } + _$AI(t$7, i$10 = this) { + if ((t$7 = M$1(this, t$7, i$10, 0) ?? A) === E) return; + const s$9 = this._$AH, e$14 = t$7 === A && s$9 !== A || t$7.capture !== s$9.capture || t$7.once !== s$9.once || t$7.passive !== s$9.passive, h$7 = t$7 !== A && (s$9 === A || e$14); + e$14 && this.element.removeEventListener(this.name, this, s$9), h$7 && this.element.addEventListener(this.name, this, t$7), this._$AH = t$7; + } + handleEvent(t$7) { + "function" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t$7) : this._$AH.handleEvent(t$7); + } }; var Z = class { - constructor(t$7, i$10, s$9) { - ((this.element = t$7), - (this.type = 6), - (this._$AN = void 0), - (this._$AM = i$10), - (this.options = s$9)); - } - get _$AU() { - return this._$AM._$AU; - } - _$AI(t$7) { - M$1(this, t$7); - } + constructor(t$7, i$10, s$9) { + this.element = t$7, this.type = 6, this._$AN = void 0, this._$AM = i$10, this.options = s$9; + } + get _$AU() { + return this._$AM._$AU; + } + _$AI(t$7) { + M$1(this, t$7); + } }; const j$1 = { - M: h$5, - P: o$12, - A: n$10, - C: 1, - L: N, - R, - D: d$1, - V: M$1, - I: k, - H, - N: L, - U: z, - B: I, - F: Z, - }, - B = t$5.litHtmlPolyfillSupport; -(B?.(S, k), (t$5.litHtmlVersions ??= []).push("3.3.2")); + M: h$5, + P: o$12, + A: n$10, + C: 1, + L: N, + R, + D: d$1, + V: M$1, + I: k, + H, + N: L, + U: z, + B: I, + F: Z +}, B = t$5.litHtmlPolyfillSupport; +B?.(S, k), (t$5.litHtmlVersions ??= []).push("3.3.2"); const D = (t$7, i$10, s$9) => { - const e$14 = s$9?.renderBefore ?? i$10; - let h$7 = e$14._$litPart$; - if (void 0 === h$7) { - const t$8 = s$9?.renderBefore ?? null; - e$14._$litPart$ = h$7 = new k(i$10.insertBefore(c$4(), t$8), t$8, void 0, s$9 ?? {}); - } - return (h$7._$AI(t$7), h$7); + const e$14 = s$9?.renderBefore ?? i$10; + let h$7 = e$14._$litPart$; + if (void 0 === h$7) { + const t$8 = s$9?.renderBefore ?? null; + e$14._$litPart$ = h$7 = new k(i$10.insertBefore(c$4(), t$8), t$8, void 0, s$9 ?? {}); + } + return h$7._$AI(t$7), h$7; }; /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ const s$6 = globalThis; +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const s$6 = globalThis; var i$6 = class extends y$1 { - constructor() { - (super(...arguments), (this.renderOptions = { host: this }), (this._$Do = void 0)); - } - createRenderRoot() { - const t$7 = super.createRenderRoot(); - return ((this.renderOptions.renderBefore ??= t$7.firstChild), t$7); - } - update(t$7) { - const r$12 = this.render(); - (this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), - super.update(t$7), - (this._$Do = D(r$12, this.renderRoot, this.renderOptions))); - } - connectedCallback() { - (super.connectedCallback(), this._$Do?.setConnected(!0)); - } - disconnectedCallback() { - (super.disconnectedCallback(), this._$Do?.setConnected(!1)); - } - render() { - return E; - } + constructor() { + super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; + } + createRenderRoot() { + const t$7 = super.createRenderRoot(); + return this.renderOptions.renderBefore ??= t$7.firstChild, t$7; + } + update(t$7) { + const r$12 = this.render(); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t$7), this._$Do = D(r$12, this.renderRoot, this.renderOptions); + } + connectedCallback() { + super.connectedCallback(), this._$Do?.setConnected(!0); + } + disconnectedCallback() { + super.disconnectedCallback(), this._$Do?.setConnected(!1); + } + render() { + return E; + } }; -((i$6._$litElement$ = !0), - (i$6["finalized"] = !0), - s$6.litElementHydrateSupport?.({ LitElement: i$6 })); +i$6._$litElement$ = !0, i$6["finalized"] = !0, s$6.litElementHydrateSupport?.({ LitElement: i$6 }); const o$11 = s$6.litElementPolyfillSupport; o$11?.({ LitElement: i$6 }); const n$9 = { - _$AK: (t$7, e$14, r$12) => { - t$7._$AK(e$14, r$12); - }, - _$AL: (t$7) => t$7._$AL, + _$AK: (t$7, e$14, r$12) => { + t$7._$AK(e$14, r$12); + }, + _$AL: (t$7) => t$7._$AL }; (s$6.litElementVersions ??= []).push("4.2.2"); /** - * @license - * Copyright 2022 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2022 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const o$10 = !1; /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const t$4 = { - ATTRIBUTE: 1, - CHILD: 2, - PROPERTY: 3, - BOOLEAN_ATTRIBUTE: 4, - EVENT: 5, - ELEMENT: 6, - }, - e$10 = - (t$7) => - (...e$14) => ({ - _$litDirective$: t$7, - values: e$14, - }); + ATTRIBUTE: 1, + CHILD: 2, + PROPERTY: 3, + BOOLEAN_ATTRIBUTE: 4, + EVENT: 5, + ELEMENT: 6 +}, e$10 = (t$7) => (...e$14) => ({ + _$litDirective$: t$7, + values: e$14 +}); var i$5 = class { - constructor(t$7) {} - get _$AU() { - return this._$AM._$AU; - } - _$AT(t$7, e$14, i$10) { - ((this._$Ct = t$7), (this._$AM = e$14), (this._$Ci = i$10)); - } - _$AS(t$7, e$14) { - return this.update(t$7, e$14); - } - update(t$7, e$14) { - return this.render(...e$14); - } + constructor(t$7) {} + get _$AU() { + return this._$AM._$AU; + } + _$AT(t$7, e$14, i$10) { + this._$Ct = t$7, this._$AM = e$14, this._$Ci = i$10; + } + _$AS(t$7, e$14) { + return this.update(t$7, e$14); + } + update(t$7, e$14) { + return this.render(...e$14); + } }; /** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ const { I: t$3 } = j$1, - i$4 = (o$15) => o$15, - n$8 = (o$15) => null === o$15 || ("object" != typeof o$15 && "function" != typeof o$15), - e$9 = { - HTML: 1, - SVG: 2, - MATHML: 3, - }, - l$2 = (o$15, t$7) => (void 0 === t$7 ? void 0 !== o$15?._$litType$ : o$15?._$litType$ === t$7), - d = (o$15) => null != o$15?._$litType$?.h, - c$3 = (o$15) => void 0 !== o$15?._$litDirective$, - f$1 = (o$15) => o$15?._$litDirective$, - r$8 = (o$15) => void 0 === o$15.strings, - s$5 = () => document.createComment(""), - v = (o$15, n$13, e$14) => { - const l$5 = o$15._$AA.parentNode, - d = void 0 === n$13 ? o$15._$AB : n$13._$AA; - if (void 0 === e$14) { - const i$10 = l$5.insertBefore(s$5(), d), - n$14 = l$5.insertBefore(s$5(), d); - e$14 = new t$3(i$10, n$14, o$15, o$15.options); - } else { - const t$7 = e$14._$AB.nextSibling, - n$14 = e$14._$AM, - c$7 = n$14 !== o$15; - if (c$7) { - let t$8; - (e$14._$AQ?.(o$15), - (e$14._$AM = o$15), - void 0 !== e$14._$AP && (t$8 = o$15._$AU) !== n$14._$AU && e$14._$AP(t$8)); - } - if (t$7 !== d || c$7) { - let o$16 = e$14._$AA; - for (; o$16 !== t$7; ) { - const t$8 = i$4(o$16).nextSibling; - (i$4(l$5).insertBefore(o$16, d), (o$16 = t$8)); - } - } - } - return e$14; - }, - u$1 = (o$15, t$7, i$10 = o$15) => (o$15._$AI(t$7, i$10), o$15), - m$1 = {}, - p = (o$15, t$7 = m$1) => (o$15._$AH = t$7), - M = (o$15) => o$15._$AH, - h$4 = (o$15) => { - (o$15._$AR(), o$15._$AA.remove()); - }, - j = (o$15) => { - o$15._$AR(); - }; +* @license +* Copyright 2020 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const { I: t$3 } = j$1, i$4 = (o$15) => o$15, n$8 = (o$15) => null === o$15 || "object" != typeof o$15 && "function" != typeof o$15, e$9 = { + HTML: 1, + SVG: 2, + MATHML: 3 +}, l$2 = (o$15, t$7) => void 0 === t$7 ? void 0 !== o$15?._$litType$ : o$15?._$litType$ === t$7, d = (o$15) => null != o$15?._$litType$?.h, c$3 = (o$15) => void 0 !== o$15?._$litDirective$, f$1 = (o$15) => o$15?._$litDirective$, r$8 = (o$15) => void 0 === o$15.strings, s$5 = () => document.createComment(""), v = (o$15, n$13, e$14) => { + const l$5 = o$15._$AA.parentNode, d = void 0 === n$13 ? o$15._$AB : n$13._$AA; + if (void 0 === e$14) { + const i$10 = l$5.insertBefore(s$5(), d), n$14 = l$5.insertBefore(s$5(), d); + e$14 = new t$3(i$10, n$14, o$15, o$15.options); + } else { + const t$7 = e$14._$AB.nextSibling, n$14 = e$14._$AM, c$7 = n$14 !== o$15; + if (c$7) { + let t$8; + e$14._$AQ?.(o$15), e$14._$AM = o$15, void 0 !== e$14._$AP && (t$8 = o$15._$AU) !== n$14._$AU && e$14._$AP(t$8); + } + if (t$7 !== d || c$7) { + let o$16 = e$14._$AA; + for (; o$16 !== t$7;) { + const t$8 = i$4(o$16).nextSibling; + i$4(l$5).insertBefore(o$16, d), o$16 = t$8; + } + } + } + return e$14; +}, u$1 = (o$15, t$7, i$10 = o$15) => (o$15._$AI(t$7, i$10), o$15), m$1 = {}, p = (o$15, t$7 = m$1) => o$15._$AH = t$7, M = (o$15) => o$15._$AH, h$4 = (o$15) => { + o$15._$AR(), o$15._$AA.remove(); +}, j = (o$15) => { + o$15._$AR(); +}; /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const u = (e$14, s$9, t$7) => { - const r$12 = new Map(); - for (let l$5 = s$9; l$5 <= t$7; l$5++) r$12.set(e$14[l$5], l$5); - return r$12; - }, - c$2 = e$10( - class extends i$5 { - constructor(e$14) { - if ((super(e$14), e$14.type !== t$4.CHILD)) - throw Error("repeat() can only be used in text expressions"); - } - dt(e$14, s$9, t$7) { - let r$12; - void 0 === t$7 ? (t$7 = s$9) : void 0 !== s$9 && (r$12 = s$9); - const l$5 = [], - o$15 = []; - let i$10 = 0; - for (const s$10 of e$14) - ((l$5[i$10] = r$12 ? r$12(s$10, i$10) : i$10), (o$15[i$10] = t$7(s$10, i$10)), i$10++); - return { - values: o$15, - keys: l$5, - }; - } - render(e$14, s$9, t$7) { - return this.dt(e$14, s$9, t$7).values; - } - update(s$9, [t$7, r$12, c$7]) { - const d$3 = M(s$9), - { values: p$3, keys: a$2 } = this.dt(t$7, r$12, c$7); - if (!Array.isArray(d$3)) return ((this.ut = a$2), p$3); - const h$7 = (this.ut ??= []), - v$2 = []; - let m$3, - y$2, - x$1 = 0, - j$2 = d$3.length - 1, - k$1 = 0, - w$1 = p$3.length - 1; - for (; x$1 <= j$2 && k$1 <= w$1; ) - if (null === d$3[x$1]) x$1++; - else if (null === d$3[j$2]) j$2--; - else if (h$7[x$1] === a$2[k$1]) ((v$2[k$1] = u$1(d$3[x$1], p$3[k$1])), x$1++, k$1++); - else if (h$7[j$2] === a$2[w$1]) ((v$2[w$1] = u$1(d$3[j$2], p$3[w$1])), j$2--, w$1--); - else if (h$7[x$1] === a$2[w$1]) - ((v$2[w$1] = u$1(d$3[x$1], p$3[w$1])), v(s$9, v$2[w$1 + 1], d$3[x$1]), x$1++, w$1--); - else if (h$7[j$2] === a$2[k$1]) - ((v$2[k$1] = u$1(d$3[j$2], p$3[k$1])), v(s$9, d$3[x$1], d$3[j$2]), j$2--, k$1++); - else if ( - (void 0 === m$3 && ((m$3 = u(a$2, k$1, w$1)), (y$2 = u(h$7, x$1, j$2))), - m$3.has(h$7[x$1])) - ) - if (m$3.has(h$7[j$2])) { - const e$14 = y$2.get(a$2[k$1]), - t$8 = void 0 !== e$14 ? d$3[e$14] : null; - if (null === t$8) { - const e$15 = v(s$9, d$3[x$1]); - (u$1(e$15, p$3[k$1]), (v$2[k$1] = e$15)); - } else ((v$2[k$1] = u$1(t$8, p$3[k$1])), v(s$9, d$3[x$1], t$8), (d$3[e$14] = null)); - k$1++; - } else (h$4(d$3[j$2]), j$2--); - else (h$4(d$3[x$1]), x$1++); - for (; k$1 <= w$1; ) { - const e$14 = v(s$9, v$2[w$1 + 1]); - (u$1(e$14, p$3[k$1]), (v$2[k$1++] = e$14)); - } - for (; x$1 <= j$2; ) { - const e$14 = d$3[x$1++]; - null !== e$14 && h$4(e$14); - } - return ((this.ut = a$2), p(s$9, v$2), E); - } - }, - ); + const r$12 = new Map(); + for (let l$5 = s$9; l$5 <= t$7; l$5++) r$12.set(e$14[l$5], l$5); + return r$12; +}, c$2 = e$10(class extends i$5 { + constructor(e$14) { + if (super(e$14), e$14.type !== t$4.CHILD) throw Error("repeat() can only be used in text expressions"); + } + dt(e$14, s$9, t$7) { + let r$12; + void 0 === t$7 ? t$7 = s$9 : void 0 !== s$9 && (r$12 = s$9); + const l$5 = [], o$15 = []; + let i$10 = 0; + for (const s$10 of e$14) l$5[i$10] = r$12 ? r$12(s$10, i$10) : i$10, o$15[i$10] = t$7(s$10, i$10), i$10++; + return { + values: o$15, + keys: l$5 + }; + } + render(e$14, s$9, t$7) { + return this.dt(e$14, s$9, t$7).values; + } + update(s$9, [t$7, r$12, c$7]) { + const d$3 = M(s$9), { values: p$3, keys: a$2 } = this.dt(t$7, r$12, c$7); + if (!Array.isArray(d$3)) return this.ut = a$2, p$3; + const h$7 = this.ut ??= [], v$2 = []; + let m$3, y$2, x$1 = 0, j$2 = d$3.length - 1, k$1 = 0, w$1 = p$3.length - 1; + for (; x$1 <= j$2 && k$1 <= w$1;) if (null === d$3[x$1]) x$1++; + else if (null === d$3[j$2]) j$2--; + else if (h$7[x$1] === a$2[k$1]) v$2[k$1] = u$1(d$3[x$1], p$3[k$1]), x$1++, k$1++; + else if (h$7[j$2] === a$2[w$1]) v$2[w$1] = u$1(d$3[j$2], p$3[w$1]), j$2--, w$1--; + else if (h$7[x$1] === a$2[w$1]) v$2[w$1] = u$1(d$3[x$1], p$3[w$1]), v(s$9, v$2[w$1 + 1], d$3[x$1]), x$1++, w$1--; + else if (h$7[j$2] === a$2[k$1]) v$2[k$1] = u$1(d$3[j$2], p$3[k$1]), v(s$9, d$3[x$1], d$3[j$2]), j$2--, k$1++; + else if (void 0 === m$3 && (m$3 = u(a$2, k$1, w$1), y$2 = u(h$7, x$1, j$2)), m$3.has(h$7[x$1])) if (m$3.has(h$7[j$2])) { + const e$14 = y$2.get(a$2[k$1]), t$8 = void 0 !== e$14 ? d$3[e$14] : null; + if (null === t$8) { + const e$15 = v(s$9, d$3[x$1]); + u$1(e$15, p$3[k$1]), v$2[k$1] = e$15; + } else v$2[k$1] = u$1(t$8, p$3[k$1]), v(s$9, d$3[x$1], t$8), d$3[e$14] = null; + k$1++; + } else h$4(d$3[j$2]), j$2--; + else h$4(d$3[x$1]), x$1++; + for (; k$1 <= w$1;) { + const e$14 = v(s$9, v$2[w$1 + 1]); + u$1(e$14, p$3[k$1]), v$2[k$1++] = e$14; + } + for (; x$1 <= j$2;) { + const e$14 = d$3[x$1++]; + null !== e$14 && h$4(e$14); + } + return this.ut = a$2, p(s$9, v$2), E; + } +}); /** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2021 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ var s$4 = class extends Event { - constructor(s$9, t$7, e$14, o$15) { - (super("context-request", { - bubbles: !0, - composed: !0, - }), - (this.context = s$9), - (this.contextTarget = t$7), - (this.callback = e$14), - (this.subscribe = o$15 ?? !1)); - } + constructor(s$9, t$7, e$14, o$15) { + super("context-request", { + bubbles: !0, + composed: !0 + }), this.context = s$9, this.contextTarget = t$7, this.callback = e$14, this.subscribe = o$15 ?? !1; + } }; /** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2021 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function n$7(n$13) { - return n$13; + return n$13; } /** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ var s$3 = class { - constructor(t$7, s$9, i$10, h$7) { - if ( - ((this.subscribe = !1), - (this.provided = !1), - (this.value = void 0), - (this.t = (t$8, s$10) => { - (this.unsubscribe && - (this.unsubscribe !== s$10 && ((this.provided = !1), this.unsubscribe()), - this.subscribe || this.unsubscribe()), - (this.value = t$8), - this.host.requestUpdate(), - (this.provided && !this.subscribe) || - ((this.provided = !0), this.callback && this.callback(t$8, s$10)), - (this.unsubscribe = s$10)); - }), - (this.host = t$7), - void 0 !== s$9.context) - ) { - const t$8 = s$9; - ((this.context = t$8.context), - (this.callback = t$8.callback), - (this.subscribe = t$8.subscribe ?? !1)); - } else ((this.context = s$9), (this.callback = i$10), (this.subscribe = h$7 ?? !1)); - this.host.addController(this); - } - hostConnected() { - this.dispatchRequest(); - } - hostDisconnected() { - this.unsubscribe && (this.unsubscribe(), (this.unsubscribe = void 0)); - } - dispatchRequest() { - this.host.dispatchEvent(new s$4(this.context, this.host, this.t, this.subscribe)); - } +* @license +* Copyright 2021 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ var s$3 = class { + constructor(t$7, s$9, i$10, h$7) { + if (this.subscribe = !1, this.provided = !1, this.value = void 0, this.t = (t$8, s$10) => { + this.unsubscribe && (this.unsubscribe !== s$10 && (this.provided = !1, this.unsubscribe()), this.subscribe || this.unsubscribe()), this.value = t$8, this.host.requestUpdate(), this.provided && !this.subscribe || (this.provided = !0, this.callback && this.callback(t$8, s$10)), this.unsubscribe = s$10; + }, this.host = t$7, void 0 !== s$9.context) { + const t$8 = s$9; + this.context = t$8.context, this.callback = t$8.callback, this.subscribe = t$8.subscribe ?? !1; + } else this.context = s$9, this.callback = i$10, this.subscribe = h$7 ?? !1; + this.host.addController(this); + } + hostConnected() { + this.dispatchRequest(); + } + hostDisconnected() { + this.unsubscribe && (this.unsubscribe(), this.unsubscribe = void 0); + } + dispatchRequest() { + this.host.dispatchEvent(new s$4(this.context, this.host, this.t, this.subscribe)); + } }; /** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2021 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ var s$2 = class { - get value() { - return this.o; - } - set value(s$9) { - this.setValue(s$9); - } - setValue(s$9, t$7 = !1) { - const i$10 = t$7 || !Object.is(s$9, this.o); - ((this.o = s$9), i$10 && this.updateObservers()); - } - constructor(s$9) { - ((this.subscriptions = new Map()), - (this.updateObservers = () => { - for (const [s$10, { disposer: t$7 }] of this.subscriptions) s$10(this.o, t$7); - }), - void 0 !== s$9 && (this.value = s$9)); - } - addCallback(s$9, t$7, i$10) { - if (!i$10) return void s$9(this.value); - this.subscriptions.has(s$9) || - this.subscriptions.set(s$9, { - disposer: () => { - this.subscriptions.delete(s$9); - }, - consumerHost: t$7, - }); - const { disposer: h$7 } = this.subscriptions.get(s$9); - s$9(this.value, h$7); - } - clearCallbacks() { - this.subscriptions.clear(); - } + get value() { + return this.o; + } + set value(s$9) { + this.setValue(s$9); + } + setValue(s$9, t$7 = !1) { + const i$10 = t$7 || !Object.is(s$9, this.o); + this.o = s$9, i$10 && this.updateObservers(); + } + constructor(s$9) { + this.subscriptions = new Map(), this.updateObservers = () => { + for (const [s$10, { disposer: t$7 }] of this.subscriptions) s$10(this.o, t$7); + }, void 0 !== s$9 && (this.value = s$9); + } + addCallback(s$9, t$7, i$10) { + if (!i$10) return void s$9(this.value); + this.subscriptions.has(s$9) || this.subscriptions.set(s$9, { + disposer: () => { + this.subscriptions.delete(s$9); + }, + consumerHost: t$7 + }); + const { disposer: h$7 } = this.subscriptions.get(s$9); + s$9(this.value, h$7); + } + clearCallbacks() { + this.subscriptions.clear(); + } }; /** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ var e$8 = class extends Event { - constructor(t$7, s$9) { - (super("context-provider", { - bubbles: !0, - composed: !0, - }), - (this.context = t$7), - (this.contextTarget = s$9)); - } +* @license +* Copyright 2021 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ var e$8 = class extends Event { + constructor(t$7, s$9) { + super("context-provider", { + bubbles: !0, + composed: !0 + }), this.context = t$7, this.contextTarget = s$9; + } }; var i$3 = class extends s$2 { - constructor(s$9, e$14, i$10) { - (super(void 0 !== e$14.context ? e$14.initialValue : i$10), - (this.onContextRequest = (t$7) => { - if (t$7.context !== this.context) return; - const s$10 = t$7.contextTarget ?? t$7.composedPath()[0]; - s$10 !== this.host && - (t$7.stopPropagation(), this.addCallback(t$7.callback, s$10, t$7.subscribe)); - }), - (this.onProviderRequest = (s$10) => { - if (s$10.context !== this.context) return; - if ((s$10.contextTarget ?? s$10.composedPath()[0]) === this.host) return; - const e$15 = new Set(); - for (const [s$11, { consumerHost: i$11 }] of this.subscriptions) - e$15.has(s$11) || - (e$15.add(s$11), i$11.dispatchEvent(new s$4(this.context, i$11, s$11, !0))); - s$10.stopPropagation(); - }), - (this.host = s$9), - void 0 !== e$14.context ? (this.context = e$14.context) : (this.context = e$14), - this.attachListeners(), - this.host.addController?.(this)); - } - attachListeners() { - (this.host.addEventListener("context-request", this.onContextRequest), - this.host.addEventListener("context-provider", this.onProviderRequest)); - } - hostConnected() { - this.host.dispatchEvent(new e$8(this.context, this.host)); - } + constructor(s$9, e$14, i$10) { + super(void 0 !== e$14.context ? e$14.initialValue : i$10), this.onContextRequest = (t$7) => { + if (t$7.context !== this.context) return; + const s$10 = t$7.contextTarget ?? t$7.composedPath()[0]; + s$10 !== this.host && (t$7.stopPropagation(), this.addCallback(t$7.callback, s$10, t$7.subscribe)); + }, this.onProviderRequest = (s$10) => { + if (s$10.context !== this.context) return; + if ((s$10.contextTarget ?? s$10.composedPath()[0]) === this.host) return; + const e$15 = new Set(); + for (const [s$11, { consumerHost: i$11 }] of this.subscriptions) e$15.has(s$11) || (e$15.add(s$11), i$11.dispatchEvent(new s$4(this.context, i$11, s$11, !0))); + s$10.stopPropagation(); + }, this.host = s$9, void 0 !== e$14.context ? this.context = e$14.context : this.context = e$14, this.attachListeners(), this.host.addController?.(this); + } + attachListeners() { + this.host.addEventListener("context-request", this.onContextRequest), this.host.addEventListener("context-provider", this.onProviderRequest); + } + hostConnected() { + this.host.dispatchEvent(new e$8(this.context, this.host)); + } }; /** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ var t$2 = class { - constructor() { - ((this.pendingContextRequests = new Map()), - (this.onContextProvider = (t$7) => { - const s$9 = this.pendingContextRequests.get(t$7.context); - if (void 0 === s$9) return; - this.pendingContextRequests.delete(t$7.context); - const { requests: o$15 } = s$9; - for (const { elementRef: s$10, callbackRef: n$13 } of o$15) { - const o$16 = s$10.deref(), - c$7 = n$13.deref(); - void 0 === o$16 || - void 0 === c$7 || - o$16.dispatchEvent(new s$4(t$7.context, o$16, c$7, !0)); - } - }), - (this.onContextRequest = (e$14) => { - if (!0 !== e$14.subscribe) return; - const t$7 = e$14.contextTarget ?? e$14.composedPath()[0], - s$9 = e$14.callback; - let o$15 = this.pendingContextRequests.get(e$14.context); - void 0 === o$15 && - this.pendingContextRequests.set( - e$14.context, - (o$15 = { - callbacks: new WeakMap(), - requests: [], - }), - ); - let n$13 = o$15.callbacks.get(t$7); - (void 0 === n$13 && o$15.callbacks.set(t$7, (n$13 = new WeakSet())), - n$13.has(s$9) || - (n$13.add(s$9), - o$15.requests.push({ - elementRef: new WeakRef(t$7), - callbackRef: new WeakRef(s$9), - }))); - })); - } - attach(e$14) { - (e$14.addEventListener("context-request", this.onContextRequest), - e$14.addEventListener("context-provider", this.onContextProvider)); - } - detach(e$14) { - (e$14.removeEventListener("context-request", this.onContextRequest), - e$14.removeEventListener("context-provider", this.onContextProvider)); - } +* @license +* Copyright 2021 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ var t$2 = class { + constructor() { + this.pendingContextRequests = new Map(), this.onContextProvider = (t$7) => { + const s$9 = this.pendingContextRequests.get(t$7.context); + if (void 0 === s$9) return; + this.pendingContextRequests.delete(t$7.context); + const { requests: o$15 } = s$9; + for (const { elementRef: s$10, callbackRef: n$13 } of o$15) { + const o$16 = s$10.deref(), c$7 = n$13.deref(); + void 0 === o$16 || void 0 === c$7 || o$16.dispatchEvent(new s$4(t$7.context, o$16, c$7, !0)); + } + }, this.onContextRequest = (e$14) => { + if (!0 !== e$14.subscribe) return; + const t$7 = e$14.contextTarget ?? e$14.composedPath()[0], s$9 = e$14.callback; + let o$15 = this.pendingContextRequests.get(e$14.context); + void 0 === o$15 && this.pendingContextRequests.set(e$14.context, o$15 = { + callbacks: new WeakMap(), + requests: [] + }); + let n$13 = o$15.callbacks.get(t$7); + void 0 === n$13 && o$15.callbacks.set(t$7, n$13 = new WeakSet()), n$13.has(s$9) || (n$13.add(s$9), o$15.requests.push({ + elementRef: new WeakRef(t$7), + callbackRef: new WeakRef(s$9) + })); + }; + } + attach(e$14) { + e$14.addEventListener("context-request", this.onContextRequest), e$14.addEventListener("context-provider", this.onContextProvider); + } + detach(e$14) { + e$14.removeEventListener("context-request", this.onContextRequest), e$14.removeEventListener("context-provider", this.onContextProvider); + } }; /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ function e$7({ context: e$14 }) { - return (n$13, i$10) => { - const r$12 = new WeakMap(); - if ("object" == typeof i$10) - return { - get() { - return n$13.get.call(this); - }, - set(t$7) { - return (r$12.get(this).setValue(t$7), n$13.set.call(this, t$7)); - }, - init(n$14) { - return ( - r$12.set( - this, - new i$3(this, { - context: e$14, - initialValue: n$14, - }), - ), - n$14 - ); - }, - }; - { - n$13.constructor.addInitializer((n$14) => { - r$12.set(n$14, new i$3(n$14, { context: e$14 })); - }); - const o$15 = Object.getOwnPropertyDescriptor(n$13, i$10); - let s$9; - if (void 0 === o$15) { - const t$7 = new WeakMap(); - s$9 = { - get() { - return t$7.get(this); - }, - set(e$15) { - (r$12.get(this).setValue(e$15), t$7.set(this, e$15)); - }, - configurable: !0, - enumerable: !0, - }; - } else { - const t$7 = o$15.set; - s$9 = { - ...o$15, - set(e$15) { - (r$12.get(this).setValue(e$15), t$7?.call(this, e$15)); - }, - }; - } - return void Object.defineProperty(n$13, i$10, s$9); - } - }; +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function e$7({ context: e$14 }) { + return (n$13, i$10) => { + const r$12 = new WeakMap(); + if ("object" == typeof i$10) return { + get() { + return n$13.get.call(this); + }, + set(t$7) { + return r$12.get(this).setValue(t$7), n$13.set.call(this, t$7); + }, + init(n$14) { + return r$12.set(this, new i$3(this, { + context: e$14, + initialValue: n$14 + })), n$14; + } + }; + { + n$13.constructor.addInitializer(((n$14) => { + r$12.set(n$14, new i$3(n$14, { context: e$14 })); + })); + const o$15 = Object.getOwnPropertyDescriptor(n$13, i$10); + let s$9; + if (void 0 === o$15) { + const t$7 = new WeakMap(); + s$9 = { + get() { + return t$7.get(this); + }, + set(e$15) { + r$12.get(this).setValue(e$15), t$7.set(this, e$15); + }, + configurable: !0, + enumerable: !0 + }; + } else { + const t$7 = o$15.set; + s$9 = { + ...o$15, + set(e$15) { + r$12.get(this).setValue(e$15), t$7?.call(this, e$15); + } + }; + } + return void Object.defineProperty(n$13, i$10, s$9); + } + }; } /** - * @license - * Copyright 2022 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ function c$1({ context: c$7, subscribe: e$14 }) { - return (o$15, n$13) => { - "object" == typeof n$13 - ? n$13.addInitializer(function () { - new s$3(this, { - context: c$7, - callback: (t$7) => { - o$15.set.call(this, t$7); - }, - subscribe: e$14, - }); - }) - : o$15.constructor.addInitializer((o$16) => { - new s$3(o$16, { - context: c$7, - callback: (t$7) => { - o$16[n$13] = t$7; - }, - subscribe: e$14, - }); - }); - }; +* @license +* Copyright 2022 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function c$1({ context: c$7, subscribe: e$14 }) { + return (o$15, n$13) => { + "object" == typeof n$13 ? n$13.addInitializer((function() { + new s$3(this, { + context: c$7, + callback: (t$7) => { + o$15.set.call(this, t$7); + }, + subscribe: e$14 + }); + })) : o$15.constructor.addInitializer(((o$16) => { + new s$3(o$16, { + context: c$7, + callback: (t$7) => { + o$16[n$13] = t$7; + }, + subscribe: e$14 + }); + })); + }; } const eventInit = { - bubbles: true, - cancelable: true, - composed: true, + bubbles: true, + cancelable: true, + composed: true }; var StateEvent = class StateEvent extends CustomEvent { - static { - this.eventName = "a2uiaction"; - } - constructor(payload) { - super(StateEvent.eventName, { - detail: payload, - ...eventInit, - }); - this.payload = payload; - } + static { + this.eventName = "a2uiaction"; + } + constructor(payload) { + super(StateEvent.eventName, { + detail: payload, + ...eventInit + }); + this.payload = payload; + } }; const opacityBehavior = ` @@ -1405,15 +987,12 @@ const opacityBehavior = ` } }`; const behavior = ` - ${new Array(21) - .fill(0) - .map((_$1, idx) => { - return `.behavior-ho-${idx * 5} { + ${new Array(21).fill(0).map((_$1, idx) => { + return `.behavior-ho-${idx * 5} { --opacity: ${idx / 20}; ${opacityBehavior} }`; - }) - .join("\n")} +}).join("\n")} .behavior-o-s { overflow: scroll; @@ -1435,10 +1014,8 @@ const behavior = ` const grid = 4; const border = ` - ${new Array(25) - .fill(0) - .map((_$1, idx) => { - return ` + ${new Array(25).fill(0).map((_$1, idx) => { + return ` .border-bw-${idx} { border-width: ${idx}px; } .border-btw-${idx} { border-top-width: ${idx}px; } .border-bbw-${idx} { border-bottom-width: ${idx}px; } @@ -1447,8 +1024,7 @@ const border = ` .border-ow-${idx} { outline-width: ${idx}px; } .border-br-${idx} { border-radius: ${idx * grid}px; overflow: hidden;}`; - }) - .join("\n")} +}).join("\n")} .border-br-50pc { border-radius: 50%; @@ -1459,117 +1035,125 @@ const border = ` } `; -const shades = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]; +const shades = [ + 0, + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 50, + 60, + 70, + 80, + 90, + 95, + 98, + 99, + 100 +]; function merge(...classes) { - const styles = {}; - for (const clazz of classes) { - for (const [key, val] of Object.entries(clazz)) { - const prefix = key.split("-").with(-1, "").join("-"); - const existingKeys = Object.keys(styles).filter((key$1) => key$1.startsWith(prefix)); - for (const existingKey of existingKeys) { - delete styles[existingKey]; - } - styles[key] = val; - } - } - return styles; + const styles = {}; + for (const clazz of classes) { + for (const [key, val] of Object.entries(clazz)) { + const prefix = key.split("-").with(-1, "").join("-"); + const existingKeys = Object.keys(styles).filter((key$1) => key$1.startsWith(prefix)); + for (const existingKey of existingKeys) { + delete styles[existingKey]; + } + styles[key] = val; + } + } + return styles; } function appendToAll(target, exclusions, ...classes) { - const updatedTarget = structuredClone(target); - for (const clazz of classes) { - for (const key of Object.keys(clazz)) { - const prefix = key.split("-").with(-1, "").join("-"); - for (const [tagName, classesToAdd] of Object.entries(updatedTarget)) { - if (exclusions.includes(tagName)) { - continue; - } - let found = false; - for (let t$7 = 0; t$7 < classesToAdd.length; t$7++) { - if (classesToAdd[t$7].startsWith(prefix)) { - found = true; - classesToAdd[t$7] = key; - } - } - if (!found) { - classesToAdd.push(key); - } - } - } - } - return updatedTarget; + const updatedTarget = structuredClone(target); + for (const clazz of classes) { + for (const key of Object.keys(clazz)) { + const prefix = key.split("-").with(-1, "").join("-"); + for (const [tagName, classesToAdd] of Object.entries(updatedTarget)) { + if (exclusions.includes(tagName)) { + continue; + } + let found = false; + for (let t$7 = 0; t$7 < classesToAdd.length; t$7++) { + if (classesToAdd[t$7].startsWith(prefix)) { + found = true; + classesToAdd[t$7] = key; + } + } + if (!found) { + classesToAdd.push(key); + } + } + } + } + return updatedTarget; } function createThemeStyles(palettes) { - const styles = {}; - for (const palette of Object.values(palettes)) { - for (const [key, val] of Object.entries(palette)) { - const prop = toProp(key); - styles[prop] = val; - } - } - return styles; + const styles = {}; + for (const palette of Object.values(palettes)) { + for (const [key, val] of Object.entries(palette)) { + const prop = toProp(key); + styles[prop] = val; + } + } + return styles; } function toProp(key) { - if (key.startsWith("nv")) { - return `--nv-${key.slice(2)}`; - } - return `--${key[0]}-${key.slice(1)}`; + if (key.startsWith("nv")) { + return `--nv-${key.slice(2)}`; + } + return `--${key[0]}-${key.slice(1)}`; } const color = (src) => ` - ${src - .map((key) => { - const inverseKey = getInverseKey(key); - return `.color-bc-${key} { border-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; - }) - .join("\n")} + ${src.map((key) => { + const inverseKey = getInverseKey(key); + return `.color-bc-${key} { border-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; +}).join("\n")} - ${src - .map((key) => { - const inverseKey = getInverseKey(key); - const vals = [ - `.color-bgc-${key} { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`, - `.color-bbgc-${key}::backdrop { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`, - ]; - for (let o$15 = 0.1; o$15 < 1; o$15 += 0.1) { - vals.push(`.color-bbgc-${key}_${(o$15 * 100).toFixed(0)}::backdrop { + ${src.map((key) => { + const inverseKey = getInverseKey(key); + const vals = [`.color-bgc-${key} { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`, `.color-bbgc-${key}::backdrop { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`]; + for (let o$15 = .1; o$15 < 1; o$15 += .1) { + vals.push(`.color-bbgc-${key}_${(o$15 * 100).toFixed(0)}::backdrop { background-color: light-dark(oklch(from var(${toProp(key)}) l c h / calc(alpha * ${o$15.toFixed(1)})), oklch(from var(${toProp(inverseKey)}) l c h / calc(alpha * ${o$15.toFixed(1)})) ); } `); - } - return vals.join("\n"); - }) - .join("\n")} + } + return vals.join("\n"); +}).join("\n")} - ${src - .map((key) => { - const inverseKey = getInverseKey(key); - return `.color-c-${key} { color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; - }) - .join("\n")} + ${src.map((key) => { + const inverseKey = getInverseKey(key); + return `.color-c-${key} { color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; +}).join("\n")} `; const getInverseKey = (key) => { - const match = key.match(/^([a-z]+)(\d+)$/); - if (!match) return key; - const [, prefix, shadeStr] = match; - const shade = parseInt(shadeStr, 10); - const target = 100 - shade; - const inverseShade = shades.reduce((prev, curr) => - Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev, - ); - return `${prefix}${inverseShade}`; + const match = key.match(/^([a-z]+)(\d+)$/); + if (!match) return key; + const [, prefix, shadeStr] = match; + const shade = parseInt(shadeStr, 10); + const target = 100 - shade; + const inverseShade = shades.reduce((prev, curr) => Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev); + return `${prefix}${inverseShade}`; }; const keyFactory = (prefix) => { - return shades.map((v$2) => `${prefix}${v$2}`); + return shades.map((v$2) => `${prefix}${v$2}`); }; const colors = [ - color(keyFactory("p")), - color(keyFactory("s")), - color(keyFactory("t")), - color(keyFactory("n")), - color(keyFactory("nv")), - color(keyFactory("e")), - ` + color(keyFactory("p")), + color(keyFactory("s")), + color(keyFactory("t")), + color(keyFactory("n")), + color(keyFactory("nv")), + color(keyFactory("e")), + ` .color-bgc-transparent { background-color: transparent; } @@ -1577,18 +1161,18 @@ const colors = [ :host { color-scheme: var(--color-scheme); } - `, + ` ]; /** - * CSS classes for Google Symbols. - * - * Usage: - * - * ```html - * pen_spark - * ``` - */ +* CSS classes for Google Symbols. +* +* Usage: +* +* ```html +* pen_spark +* ``` +*/ const icons = ` .g-icon { font-family: "Material Symbols Outlined", "Google Symbols"; @@ -1627,20 +1211,15 @@ const icons = ` const layout = ` :host { - ${new Array(16) - .fill(0) - .map((_$1, idx) => { - return `--g-${idx + 1}: ${(idx + 1) * grid}px;`; - }) - .join("\n")} + ${new Array(16).fill(0).map((_$1, idx) => { + return `--g-${idx + 1}: ${(idx + 1) * grid}px;`; +}).join("\n")} } - ${new Array(49) - .fill(0) - .map((_$1, index) => { - const idx = index - 24; - const lbl = idx < 0 ? `n${Math.abs(idx)}` : idx.toString(); - return ` + ${new Array(49).fill(0).map((_$1, index) => { + const idx = index - 24; + const lbl = idx < 0 ? `n${Math.abs(idx)}` : idx.toString(); + return ` .layout-p-${lbl} { --padding: ${idx * grid}px; padding: var(--padding); } .layout-pt-${lbl} { padding-top: ${idx * grid}px; } .layout-pr-${lbl} { padding-right: ${idx * grid}px; } @@ -1657,24 +1236,17 @@ const layout = ` .layout-r-${lbl} { right: ${idx * grid}px; } .layout-b-${lbl} { bottom: ${idx * grid}px; } .layout-l-${lbl} { left: ${idx * grid}px; }`; - }) - .join("\n")} +}).join("\n")} - ${new Array(25) - .fill(0) - .map((_$1, idx) => { - return ` + ${new Array(25).fill(0).map((_$1, idx) => { + return ` .layout-g-${idx} { gap: ${idx * grid}px; }`; - }) - .join("\n")} +}).join("\n")} - ${new Array(8) - .fill(0) - .map((_$1, idx) => { - return ` + ${new Array(8).fill(0).map((_$1, idx) => { + return ` .layout-grd-col${idx + 1} { grid-template-columns: ${"1fr ".repeat(idx + 1).trim()}; }`; - }) - .join("\n")} +}).join("\n")} .layout-pos-a { position: absolute; @@ -1784,39 +1356,27 @@ const layout = ` /** Widths **/ - ${new Array(10) - .fill(0) - .map((_$1, idx) => { - const weight = (idx + 1) * 10; - return `.layout-w-${weight} { width: ${weight}%; max-width: ${weight}%; }`; - }) - .join("\n")} + ${new Array(10).fill(0).map((_$1, idx) => { + const weight = (idx + 1) * 10; + return `.layout-w-${weight} { width: ${weight}%; max-width: ${weight}%; }`; +}).join("\n")} - ${new Array(16) - .fill(0) - .map((_$1, idx) => { - const weight = idx * grid; - return `.layout-wp-${idx} { width: ${weight}px; }`; - }) - .join("\n")} + ${new Array(16).fill(0).map((_$1, idx) => { + const weight = idx * grid; + return `.layout-wp-${idx} { width: ${weight}px; }`; +}).join("\n")} /** Heights **/ - ${new Array(10) - .fill(0) - .map((_$1, idx) => { - const height = (idx + 1) * 10; - return `.layout-h-${height} { height: ${height}%; }`; - }) - .join("\n")} + ${new Array(10).fill(0).map((_$1, idx) => { + const height = (idx + 1) * 10; + return `.layout-h-${height} { height: ${height}%; }`; +}).join("\n")} - ${new Array(16) - .fill(0) - .map((_$1, idx) => { - const height = idx * grid; - return `.layout-hp-${idx} { height: ${height}px; }`; - }) - .join("\n")} + ${new Array(16).fill(0).map((_$1, idx) => { + const height = idx * grid; + return `.layout-hp-${idx} { height: ${height}px; }`; +}).join("\n")} .layout-el-cv { & img, @@ -1840,12 +1400,9 @@ const layout = ` `; const opacity = ` - ${new Array(21) - .fill(0) - .map((_$1, idx) => { - return `.opacity-el-${idx * 5} { opacity: ${idx / 20}; }`; - }) - .join("\n")} + ${new Array(21).fill(0).map((_$1, idx) => { + return `.opacity-el-${idx * 5} { opacity: ${idx / 20}; }`; +}).join("\n")} `; const type$1 = ` @@ -1980,3056 +1537,2749 @@ const type$1 = ` /** Weights **/ - ${new Array(9) - .fill(0) - .map((_$1, idx) => { - const weight = (idx + 1) * 100; - return `.typography-w-${weight} { font-weight: ${weight}; }`; - }) - .join("\n")} + ${new Array(9).fill(0).map((_$1, idx) => { + const weight = (idx + 1) * 100; + return `.typography-w-${weight} { font-weight: ${weight}; }`; +}).join("\n")} `; -const structuralStyles$1 = [behavior, border, colors, icons, layout, opacity, type$1] - .flat(Infinity) - .join("\n"); +const structuralStyles$1 = [ + behavior, + border, + colors, + icons, + layout, + opacity, + type$1 +].flat(Infinity).join("\n"); var guards_exports = /* @__PURE__ */ __exportAll({ - isComponentArrayReference: () => isComponentArrayReference, - isObject: () => isObject$1, - isPath: () => isPath, - isResolvedAudioPlayer: () => isResolvedAudioPlayer, - isResolvedButton: () => isResolvedButton, - isResolvedCard: () => isResolvedCard, - isResolvedCheckbox: () => isResolvedCheckbox, - isResolvedColumn: () => isResolvedColumn, - isResolvedDateTimeInput: () => isResolvedDateTimeInput, - isResolvedDivider: () => isResolvedDivider, - isResolvedIcon: () => isResolvedIcon, - isResolvedImage: () => isResolvedImage, - isResolvedList: () => isResolvedList, - isResolvedModal: () => isResolvedModal, - isResolvedMultipleChoice: () => isResolvedMultipleChoice, - isResolvedRow: () => isResolvedRow, - isResolvedSlider: () => isResolvedSlider, - isResolvedTabs: () => isResolvedTabs, - isResolvedText: () => isResolvedText, - isResolvedTextField: () => isResolvedTextField, - isResolvedVideo: () => isResolvedVideo, - isValueMap: () => isValueMap, + isComponentArrayReference: () => isComponentArrayReference, + isObject: () => isObject$1, + isPath: () => isPath, + isResolvedAudioPlayer: () => isResolvedAudioPlayer, + isResolvedButton: () => isResolvedButton, + isResolvedCard: () => isResolvedCard, + isResolvedCheckbox: () => isResolvedCheckbox, + isResolvedColumn: () => isResolvedColumn, + isResolvedDateTimeInput: () => isResolvedDateTimeInput, + isResolvedDivider: () => isResolvedDivider, + isResolvedIcon: () => isResolvedIcon, + isResolvedImage: () => isResolvedImage, + isResolvedList: () => isResolvedList, + isResolvedModal: () => isResolvedModal, + isResolvedMultipleChoice: () => isResolvedMultipleChoice, + isResolvedRow: () => isResolvedRow, + isResolvedSlider: () => isResolvedSlider, + isResolvedTabs: () => isResolvedTabs, + isResolvedText: () => isResolvedText, + isResolvedTextField: () => isResolvedTextField, + isResolvedVideo: () => isResolvedVideo, + isValueMap: () => isValueMap }); function isValueMap(value) { - return isObject$1(value) && "key" in value; + return isObject$1(value) && "key" in value; } function isPath(key, value) { - return key === "path" && typeof value === "string"; + return key === "path" && typeof value === "string"; } function isObject$1(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !Array.isArray(value); } function isComponentArrayReference(value) { - if (!isObject$1(value)) return false; - return "explicitList" in value || "template" in value; + if (!isObject$1(value)) return false; + return "explicitList" in value || "template" in value; } function isStringValue(value) { - return ( - isObject$1(value) && - ("path" in value || - ("literal" in value && typeof value.literal === "string") || - "literalString" in value) - ); + return isObject$1(value) && ("path" in value || "literal" in value && typeof value.literal === "string" || "literalString" in value); } function isNumberValue(value) { - return ( - isObject$1(value) && - ("path" in value || - ("literal" in value && typeof value.literal === "number") || - "literalNumber" in value) - ); + return isObject$1(value) && ("path" in value || "literal" in value && typeof value.literal === "number" || "literalNumber" in value); } function isBooleanValue(value) { - return ( - isObject$1(value) && - ("path" in value || - ("literal" in value && typeof value.literal === "boolean") || - "literalBoolean" in value) - ); + return isObject$1(value) && ("path" in value || "literal" in value && typeof value.literal === "boolean" || "literalBoolean" in value); } function isAnyComponentNode(value) { - if (!isObject$1(value)) return false; - const hasBaseKeys = "id" in value && "type" in value && "properties" in value; - if (!hasBaseKeys) return false; - return true; + if (!isObject$1(value)) return false; + const hasBaseKeys = "id" in value && "type" in value && "properties" in value; + if (!hasBaseKeys) return false; + return true; } function isResolvedAudioPlayer(props) { - return isObject$1(props) && "url" in props && isStringValue(props.url); + return isObject$1(props) && "url" in props && isStringValue(props.url); } function isResolvedButton(props) { - return ( - isObject$1(props) && "child" in props && isAnyComponentNode(props.child) && "action" in props - ); + return isObject$1(props) && "child" in props && isAnyComponentNode(props.child) && "action" in props; } function isResolvedCard(props) { - if (!isObject$1(props)) return false; - if (!("child" in props)) { - if (!("children" in props)) { - return false; - } else { - return Array.isArray(props.children) && props.children.every(isAnyComponentNode); - } - } - return isAnyComponentNode(props.child); + if (!isObject$1(props)) return false; + if (!("child" in props)) { + if (!("children" in props)) { + return false; + } else { + return Array.isArray(props.children) && props.children.every(isAnyComponentNode); + } + } + return isAnyComponentNode(props.child); } function isResolvedCheckbox(props) { - return ( - isObject$1(props) && - "label" in props && - isStringValue(props.label) && - "value" in props && - isBooleanValue(props.value) - ); + return isObject$1(props) && "label" in props && isStringValue(props.label) && "value" in props && isBooleanValue(props.value); } function isResolvedColumn(props) { - return ( - isObject$1(props) && - "children" in props && - Array.isArray(props.children) && - props.children.every(isAnyComponentNode) - ); + return isObject$1(props) && "children" in props && Array.isArray(props.children) && props.children.every(isAnyComponentNode); } function isResolvedDateTimeInput(props) { - return isObject$1(props) && "value" in props && isStringValue(props.value); + return isObject$1(props) && "value" in props && isStringValue(props.value); } function isResolvedDivider(props) { - return isObject$1(props); + return isObject$1(props); } function isResolvedImage(props) { - return isObject$1(props) && "url" in props && isStringValue(props.url); + return isObject$1(props) && "url" in props && isStringValue(props.url); } function isResolvedIcon(props) { - return isObject$1(props) && "name" in props && isStringValue(props.name); + return isObject$1(props) && "name" in props && isStringValue(props.name); } function isResolvedList(props) { - return ( - isObject$1(props) && - "children" in props && - Array.isArray(props.children) && - props.children.every(isAnyComponentNode) - ); + return isObject$1(props) && "children" in props && Array.isArray(props.children) && props.children.every(isAnyComponentNode); } function isResolvedModal(props) { - return ( - isObject$1(props) && - "entryPointChild" in props && - isAnyComponentNode(props.entryPointChild) && - "contentChild" in props && - isAnyComponentNode(props.contentChild) - ); + return isObject$1(props) && "entryPointChild" in props && isAnyComponentNode(props.entryPointChild) && "contentChild" in props && isAnyComponentNode(props.contentChild); } function isResolvedMultipleChoice(props) { - return isObject$1(props) && "selections" in props; + return isObject$1(props) && "selections" in props; } function isResolvedRow(props) { - return ( - isObject$1(props) && - "children" in props && - Array.isArray(props.children) && - props.children.every(isAnyComponentNode) - ); + return isObject$1(props) && "children" in props && Array.isArray(props.children) && props.children.every(isAnyComponentNode); } function isResolvedSlider(props) { - return isObject$1(props) && "value" in props && isNumberValue(props.value); + return isObject$1(props) && "value" in props && isNumberValue(props.value); } function isResolvedTabItem(item) { - return ( - isObject$1(item) && - "title" in item && - isStringValue(item.title) && - "child" in item && - isAnyComponentNode(item.child) - ); + return isObject$1(item) && "title" in item && isStringValue(item.title) && "child" in item && isAnyComponentNode(item.child); } function isResolvedTabs(props) { - return ( - isObject$1(props) && - "tabItems" in props && - Array.isArray(props.tabItems) && - props.tabItems.every(isResolvedTabItem) - ); + return isObject$1(props) && "tabItems" in props && Array.isArray(props.tabItems) && props.tabItems.every(isResolvedTabItem); } function isResolvedText(props) { - return isObject$1(props) && "text" in props && isStringValue(props.text); + return isObject$1(props) && "text" in props && isStringValue(props.text); } function isResolvedTextField(props) { - return isObject$1(props) && "label" in props && isStringValue(props.label); + return isObject$1(props) && "label" in props && isStringValue(props.label); } function isResolvedVideo(props) { - return isObject$1(props) && "url" in props && isStringValue(props.url); + return isObject$1(props) && "url" in props && isStringValue(props.url); } /** - * Processes and consolidates A2UIProtocolMessage objects into a structured, - * hierarchical model of UI surfaces. - */ +* Processes and consolidates A2UIProtocolMessage objects into a structured, +* hierarchical model of UI surfaces. +*/ var A2uiMessageProcessor = class A2uiMessageProcessor { - static { - this.DEFAULT_SURFACE_ID = "@default"; - } - #mapCtor = Map; - #arrayCtor = Array; - #setCtor = Set; - #objCtor = Object; - #surfaces; - constructor( - opts = { - mapCtor: Map, - arrayCtor: Array, - setCtor: Set, - objCtor: Object, - }, - ) { - this.opts = opts; - this.#arrayCtor = opts.arrayCtor; - this.#mapCtor = opts.mapCtor; - this.#setCtor = opts.setCtor; - this.#objCtor = opts.objCtor; - this.#surfaces = new opts.mapCtor(); - } - getSurfaces() { - return this.#surfaces; - } - clearSurfaces() { - this.#surfaces.clear(); - } - processMessages(messages) { - for (const message of messages) { - if (message.beginRendering) { - this.#handleBeginRendering(message.beginRendering, message.beginRendering.surfaceId); - } - if (message.surfaceUpdate) { - this.#handleSurfaceUpdate(message.surfaceUpdate, message.surfaceUpdate.surfaceId); - } - if (message.dataModelUpdate) { - this.#handleDataModelUpdate(message.dataModelUpdate, message.dataModelUpdate.surfaceId); - } - if (message.deleteSurface) { - this.#handleDeleteSurface(message.deleteSurface); - } - } - } - /** - * Retrieves the data for a given component node and a relative path string. - * This correctly handles the special `.` path, which refers to the node's - * own data context. - */ - getData(node, relativePath, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { - const surface = this.#getOrCreateSurface(surfaceId); - if (!surface) return null; - let finalPath; - if (relativePath === "." || relativePath === "") { - finalPath = node.dataContextPath ?? "/"; - } else { - finalPath = this.resolvePath(relativePath, node.dataContextPath); - } - return this.#getDataByPath(surface.dataModel, finalPath); - } - setData(node, relativePath, value, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { - if (!node) { - console.warn("No component node set"); - return; - } - const surface = this.#getOrCreateSurface(surfaceId); - if (!surface) return; - let finalPath; - if (relativePath === "." || relativePath === "") { - finalPath = node.dataContextPath ?? "/"; - } else { - finalPath = this.resolvePath(relativePath, node.dataContextPath); - } - this.#setDataByPath(surface.dataModel, finalPath, value); - } - resolvePath(path, dataContextPath) { - if (path.startsWith("/")) { - return path; - } - if (dataContextPath && dataContextPath !== "/") { - return dataContextPath.endsWith("/") - ? `${dataContextPath}${path}` - : `${dataContextPath}/${path}`; - } - return `/${path}`; - } - #parseIfJsonString(value) { - if (typeof value !== "string") { - return value; - } - const trimmedValue = value.trim(); - if ( - (trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) || - (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) - ) { - try { - return JSON.parse(value); - } catch (e$14) { - console.warn(`Failed to parse potential JSON string: "${value.substring(0, 50)}..."`, e$14); - return value; - } - } - return value; - } - /** - * Converts a specific array format [{key: "...", value_string: "..."}, ...] - * into a standard Map. It also attempts to parse any string values that - * appear to be stringified JSON. - */ - #convertKeyValueArrayToMap(arr) { - const map$1 = new this.#mapCtor(); - for (const item of arr) { - if (!isObject$1(item) || !("key" in item)) continue; - const key = item.key; - const valueKey = this.#findValueKey(item); - if (!valueKey) continue; - let value = item[valueKey]; - if (valueKey === "valueMap" && Array.isArray(value)) { - value = this.#convertKeyValueArrayToMap(value); - } else if (typeof value === "string") { - value = this.#parseIfJsonString(value); - } - this.#setDataByPath(map$1, key, value); - } - return map$1; - } - #setDataByPath(root, path, value) { - if ( - Array.isArray(value) && - (value.length === 0 || (isObject$1(value[0]) && "key" in value[0])) - ) { - if (value.length === 1 && isObject$1(value[0]) && value[0].key === ".") { - const item = value[0]; - const valueKey = this.#findValueKey(item); - if (valueKey) { - value = item[valueKey]; - if (valueKey === "valueMap" && Array.isArray(value)) { - value = this.#convertKeyValueArrayToMap(value); - } else if (typeof value === "string") { - value = this.#parseIfJsonString(value); - } - } else { - value = this.#convertKeyValueArrayToMap(value); - } - } else { - value = this.#convertKeyValueArrayToMap(value); - } - } - const segments = this.#normalizePath(path) - .split("/") - .filter((s$9) => s$9); - if (segments.length === 0) { - if (value instanceof Map || isObject$1(value)) { - if (!(value instanceof Map) && isObject$1(value)) { - value = new this.#mapCtor(Object.entries(value)); - } - root.clear(); - for (const [key, v$2] of value.entries()) { - root.set(key, v$2); - } - } else { - console.error("Cannot set root of DataModel to a non-Map value."); - } - return; - } - let current = root; - for (let i$10 = 0; i$10 < segments.length - 1; i$10++) { - const segment = segments[i$10]; - let target; - if (current instanceof Map) { - target = current.get(segment); - } else if (Array.isArray(current) && /^\d+$/.test(segment)) { - target = current[parseInt(segment, 10)]; - } - if (target === undefined || typeof target !== "object" || target === null) { - target = new this.#mapCtor(); - if (current instanceof this.#mapCtor) { - current.set(segment, target); - } else if (Array.isArray(current)) { - current[parseInt(segment, 10)] = target; - } - } - current = target; - } - const finalSegment = segments[segments.length - 1]; - const storedValue = value; - if (current instanceof this.#mapCtor) { - current.set(finalSegment, storedValue); - } else if (Array.isArray(current) && /^\d+$/.test(finalSegment)) { - current[parseInt(finalSegment, 10)] = storedValue; - } - } - /** - * Normalizes a path string into a consistent, slash-delimited format. - * Converts bracket notation and dot notation in a two-pass. - * e.g., "bookRecommendations[0].title" -> "/bookRecommendations/0/title" - * e.g., "book.0.title" -> "/book/0/title" - */ - #normalizePath(path) { - const dotPath = path.replace(/\[(\d+)\]/g, ".$1"); - const segments = dotPath.split("."); - return "/" + segments.filter((s$9) => s$9.length > 0).join("/"); - } - #getDataByPath(root, path) { - const segments = this.#normalizePath(path) - .split("/") - .filter((s$9) => s$9); - let current = root; - for (const segment of segments) { - if (current === undefined || current === null) return null; - if (current instanceof Map) { - current = current.get(segment); - } else if (Array.isArray(current) && /^\d+$/.test(segment)) { - current = current[parseInt(segment, 10)]; - } else if (isObject$1(current)) { - current = current[segment]; - } else { - return null; - } - } - return current; - } - #getOrCreateSurface(surfaceId) { - let surface = this.#surfaces.get(surfaceId); - if (!surface) { - surface = new this.#objCtor({ - rootComponentId: null, - componentTree: null, - dataModel: new this.#mapCtor(), - components: new this.#mapCtor(), - styles: new this.#objCtor(), - }); - this.#surfaces.set(surfaceId, surface); - } - return surface; - } - #handleBeginRendering(message, surfaceId) { - const surface = this.#getOrCreateSurface(surfaceId); - surface.rootComponentId = message.root; - surface.styles = message.styles ?? {}; - this.#rebuildComponentTree(surface); - } - #handleSurfaceUpdate(message, surfaceId) { - const surface = this.#getOrCreateSurface(surfaceId); - for (const component of message.components) { - surface.components.set(component.id, component); - } - this.#rebuildComponentTree(surface); - } - #handleDataModelUpdate(message, surfaceId) { - const surface = this.#getOrCreateSurface(surfaceId); - const path = message.path ?? "/"; - this.#setDataByPath(surface.dataModel, path, message.contents); - this.#rebuildComponentTree(surface); - } - #handleDeleteSurface(message) { - this.#surfaces.delete(message.surfaceId); - } - /** - * Starts at the root component of the surface and builds out the tree - * recursively. This process involves resolving all properties of the child - * components, and expanding on any explicit children lists or templates - * found in the structure. - * - * @param surface The surface to be built. - */ - #rebuildComponentTree(surface) { - if (!surface.rootComponentId) { - surface.componentTree = null; - return; - } - const visited = new this.#setCtor(); - surface.componentTree = this.#buildNodeRecursive( - surface.rootComponentId, - surface, - visited, - "/", - "", - ); - } - /** Finds a value key in a map. */ - #findValueKey(value) { - return Object.keys(value).find((k$1) => k$1.startsWith("value")); - } - /** - * Builds out the nodes recursively. - */ - #buildNodeRecursive(baseComponentId, surface, visited, dataContextPath, idSuffix = "") { - const fullId = `${baseComponentId}${idSuffix}`; - const { components } = surface; - if (!components.has(baseComponentId)) { - return null; - } - if (visited.has(fullId)) { - throw new Error(`Circular dependency for component "${fullId}".`); - } - visited.add(fullId); - const componentData = components.get(baseComponentId); - const componentProps = componentData.component ?? {}; - const componentType = Object.keys(componentProps)[0]; - const unresolvedProperties = componentProps[componentType]; - const resolvedProperties = new this.#objCtor(); - if (isObject$1(unresolvedProperties)) { - for (const [key, value] of Object.entries(unresolvedProperties)) { - resolvedProperties[key] = this.#resolvePropertyValue( - value, - surface, - visited, - dataContextPath, - idSuffix, - key, - ); - } - } - visited.delete(fullId); - const baseNode = { - id: fullId, - dataContextPath, - weight: componentData.weight ?? "initial", - }; - switch (componentType) { - case "Text": - if (!isResolvedText(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Text", - properties: resolvedProperties, - }); - case "Image": - if (!isResolvedImage(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Image", - properties: resolvedProperties, - }); - case "Icon": - if (!isResolvedIcon(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Icon", - properties: resolvedProperties, - }); - case "Video": - if (!isResolvedVideo(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Video", - properties: resolvedProperties, - }); - case "AudioPlayer": - if (!isResolvedAudioPlayer(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "AudioPlayer", - properties: resolvedProperties, - }); - case "Row": - if (!isResolvedRow(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Row", - properties: resolvedProperties, - }); - case "Column": - if (!isResolvedColumn(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Column", - properties: resolvedProperties, - }); - case "List": - if (!isResolvedList(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "List", - properties: resolvedProperties, - }); - case "Card": - if (!isResolvedCard(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Card", - properties: resolvedProperties, - }); - case "Tabs": - if (!isResolvedTabs(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Tabs", - properties: resolvedProperties, - }); - case "Divider": - if (!isResolvedDivider(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Divider", - properties: resolvedProperties, - }); - case "Modal": - if (!isResolvedModal(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Modal", - properties: resolvedProperties, - }); - case "Button": - if (!isResolvedButton(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Button", - properties: resolvedProperties, - }); - case "CheckBox": - if (!isResolvedCheckbox(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "CheckBox", - properties: resolvedProperties, - }); - case "TextField": - if (!isResolvedTextField(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "TextField", - properties: resolvedProperties, - }); - case "DateTimeInput": - if (!isResolvedDateTimeInput(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "DateTimeInput", - properties: resolvedProperties, - }); - case "MultipleChoice": - if (!isResolvedMultipleChoice(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "MultipleChoice", - properties: resolvedProperties, - }); - case "Slider": - if (!isResolvedSlider(resolvedProperties)) { - throw new Error(`Invalid data; expected ${componentType}`); - } - return new this.#objCtor({ - ...baseNode, - type: "Slider", - properties: resolvedProperties, - }); - default: - return new this.#objCtor({ - ...baseNode, - type: componentType, - properties: resolvedProperties, - }); - } - } - /** - * Recursively resolves an individual property value. If a property indicates - * a child node (a string that matches a component ID), an explicitList of - * children, or a template, these will be built out here. - */ - #resolvePropertyValue( - value, - surface, - visited, - dataContextPath, - idSuffix = "", - propertyKey = null, - ) { - const isComponentIdReferenceKey = (key) => key === "child" || key.endsWith("Child"); - if ( - typeof value === "string" && - propertyKey && - isComponentIdReferenceKey(propertyKey) && - surface.components.has(value) - ) { - return this.#buildNodeRecursive(value, surface, visited, dataContextPath, idSuffix); - } - if (isComponentArrayReference(value)) { - if (value.explicitList) { - return value.explicitList.map((id) => - this.#buildNodeRecursive(id, surface, visited, dataContextPath, idSuffix), - ); - } - if (value.template) { - const fullDataPath = this.resolvePath(value.template.dataBinding, dataContextPath); - const data = this.#getDataByPath(surface.dataModel, fullDataPath); - const template = value.template; - if (Array.isArray(data)) { - return data.map((_$1, index) => { - const parentIndices = dataContextPath - .split("/") - .filter((segment) => /^\d+$/.test(segment)); - const newIndices = [...parentIndices, index]; - const newSuffix = `:${newIndices.join(":")}`; - const childDataContextPath = `${fullDataPath}/${index}`; - return this.#buildNodeRecursive( - template.componentId, - surface, - visited, - childDataContextPath, - newSuffix, - ); - }); - } - const mapCtor = this.#mapCtor; - if (data instanceof mapCtor) { - return Array.from(data.keys(), (key) => { - const newSuffix = `:${key}`; - const childDataContextPath = `${fullDataPath}/${key}`; - return this.#buildNodeRecursive( - template.componentId, - surface, - visited, - childDataContextPath, - newSuffix, - ); - }); - } - return new this.#arrayCtor(); - } - } - if (Array.isArray(value)) { - return value.map((item) => - this.#resolvePropertyValue(item, surface, visited, dataContextPath, idSuffix, propertyKey), - ); - } - if (isObject$1(value)) { - const newObj = new this.#objCtor(); - for (const [key, propValue] of Object.entries(value)) { - let propertyValue = propValue; - if (isPath(key, propValue) && dataContextPath !== "/") { - propertyValue = propValue - .replace(/^\.?\/item/, "") - .replace(/^\.?\/text/, "") - .replace(/^\.?\/label/, "") - .replace(/^\.?\//, ""); - newObj[key] = propertyValue; - continue; - } - newObj[key] = this.#resolvePropertyValue( - propertyValue, - surface, - visited, - dataContextPath, - idSuffix, - key, - ); - } - return newObj; - } - return value; - } + static { + this.DEFAULT_SURFACE_ID = "@default"; + } + #mapCtor = Map; + #arrayCtor = Array; + #setCtor = Set; + #objCtor = Object; + #surfaces; + constructor(opts = { + mapCtor: Map, + arrayCtor: Array, + setCtor: Set, + objCtor: Object + }) { + this.opts = opts; + this.#arrayCtor = opts.arrayCtor; + this.#mapCtor = opts.mapCtor; + this.#setCtor = opts.setCtor; + this.#objCtor = opts.objCtor; + this.#surfaces = new opts.mapCtor(); + } + getSurfaces() { + return this.#surfaces; + } + clearSurfaces() { + this.#surfaces.clear(); + } + processMessages(messages) { + for (const message of messages) { + if (message.beginRendering) { + this.#handleBeginRendering(message.beginRendering, message.beginRendering.surfaceId); + } + if (message.surfaceUpdate) { + this.#handleSurfaceUpdate(message.surfaceUpdate, message.surfaceUpdate.surfaceId); + } + if (message.dataModelUpdate) { + this.#handleDataModelUpdate(message.dataModelUpdate, message.dataModelUpdate.surfaceId); + } + if (message.deleteSurface) { + this.#handleDeleteSurface(message.deleteSurface); + } + } + } + /** + * Retrieves the data for a given component node and a relative path string. + * This correctly handles the special `.` path, which refers to the node's + * own data context. + */ + getData(node, relativePath, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { + const surface = this.#getOrCreateSurface(surfaceId); + if (!surface) return null; + let finalPath; + if (relativePath === "." || relativePath === "") { + finalPath = node.dataContextPath ?? "/"; + } else { + finalPath = this.resolvePath(relativePath, node.dataContextPath); + } + return this.#getDataByPath(surface.dataModel, finalPath); + } + setData(node, relativePath, value, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { + if (!node) { + console.warn("No component node set"); + return; + } + const surface = this.#getOrCreateSurface(surfaceId); + if (!surface) return; + let finalPath; + if (relativePath === "." || relativePath === "") { + finalPath = node.dataContextPath ?? "/"; + } else { + finalPath = this.resolvePath(relativePath, node.dataContextPath); + } + this.#setDataByPath(surface.dataModel, finalPath, value); + } + resolvePath(path, dataContextPath) { + if (path.startsWith("/")) { + return path; + } + if (dataContextPath && dataContextPath !== "/") { + return dataContextPath.endsWith("/") ? `${dataContextPath}${path}` : `${dataContextPath}/${path}`; + } + return `/${path}`; + } + #parseIfJsonString(value) { + if (typeof value !== "string") { + return value; + } + const trimmedValue = value.trim(); + if (trimmedValue.startsWith("{") && trimmedValue.endsWith("}") || trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) { + try { + return JSON.parse(value); + } catch (e$14) { + console.warn(`Failed to parse potential JSON string: "${value.substring(0, 50)}..."`, e$14); + return value; + } + } + return value; + } + /** + * Converts a specific array format [{key: "...", value_string: "..."}, ...] + * into a standard Map. It also attempts to parse any string values that + * appear to be stringified JSON. + */ + #convertKeyValueArrayToMap(arr) { + const map$1 = new this.#mapCtor(); + for (const item of arr) { + if (!isObject$1(item) || !("key" in item)) continue; + const key = item.key; + const valueKey = this.#findValueKey(item); + if (!valueKey) continue; + let value = item[valueKey]; + if (valueKey === "valueMap" && Array.isArray(value)) { + value = this.#convertKeyValueArrayToMap(value); + } else if (typeof value === "string") { + value = this.#parseIfJsonString(value); + } + this.#setDataByPath(map$1, key, value); + } + return map$1; + } + #setDataByPath(root, path, value) { + if (Array.isArray(value) && (value.length === 0 || isObject$1(value[0]) && "key" in value[0])) { + if (value.length === 1 && isObject$1(value[0]) && value[0].key === ".") { + const item = value[0]; + const valueKey = this.#findValueKey(item); + if (valueKey) { + value = item[valueKey]; + if (valueKey === "valueMap" && Array.isArray(value)) { + value = this.#convertKeyValueArrayToMap(value); + } else if (typeof value === "string") { + value = this.#parseIfJsonString(value); + } + } else { + value = this.#convertKeyValueArrayToMap(value); + } + } else { + value = this.#convertKeyValueArrayToMap(value); + } + } + const segments = this.#normalizePath(path).split("/").filter((s$9) => s$9); + if (segments.length === 0) { + if (value instanceof Map || isObject$1(value)) { + if (!(value instanceof Map) && isObject$1(value)) { + value = new this.#mapCtor(Object.entries(value)); + } + root.clear(); + for (const [key, v$2] of value.entries()) { + root.set(key, v$2); + } + } else { + console.error("Cannot set root of DataModel to a non-Map value."); + } + return; + } + let current = root; + for (let i$10 = 0; i$10 < segments.length - 1; i$10++) { + const segment = segments[i$10]; + let target; + if (current instanceof Map) { + target = current.get(segment); + } else if (Array.isArray(current) && /^\d+$/.test(segment)) { + target = current[parseInt(segment, 10)]; + } + if (target === undefined || typeof target !== "object" || target === null) { + target = new this.#mapCtor(); + if (current instanceof this.#mapCtor) { + current.set(segment, target); + } else if (Array.isArray(current)) { + current[parseInt(segment, 10)] = target; + } + } + current = target; + } + const finalSegment = segments[segments.length - 1]; + const storedValue = value; + if (current instanceof this.#mapCtor) { + current.set(finalSegment, storedValue); + } else if (Array.isArray(current) && /^\d+$/.test(finalSegment)) { + current[parseInt(finalSegment, 10)] = storedValue; + } + } + /** + * Normalizes a path string into a consistent, slash-delimited format. + * Converts bracket notation and dot notation in a two-pass. + * e.g., "bookRecommendations[0].title" -> "/bookRecommendations/0/title" + * e.g., "book.0.title" -> "/book/0/title" + */ + #normalizePath(path) { + const dotPath = path.replace(/\[(\d+)\]/g, ".$1"); + const segments = dotPath.split("."); + return "/" + segments.filter((s$9) => s$9.length > 0).join("/"); + } + #getDataByPath(root, path) { + const segments = this.#normalizePath(path).split("/").filter((s$9) => s$9); + let current = root; + for (const segment of segments) { + if (current === undefined || current === null) return null; + if (current instanceof Map) { + current = current.get(segment); + } else if (Array.isArray(current) && /^\d+$/.test(segment)) { + current = current[parseInt(segment, 10)]; + } else if (isObject$1(current)) { + current = current[segment]; + } else { + return null; + } + } + return current; + } + #getOrCreateSurface(surfaceId) { + let surface = this.#surfaces.get(surfaceId); + if (!surface) { + surface = new this.#objCtor({ + rootComponentId: null, + componentTree: null, + dataModel: new this.#mapCtor(), + components: new this.#mapCtor(), + styles: new this.#objCtor() + }); + this.#surfaces.set(surfaceId, surface); + } + return surface; + } + #handleBeginRendering(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + surface.rootComponentId = message.root; + surface.styles = message.styles ?? {}; + this.#rebuildComponentTree(surface); + } + #handleSurfaceUpdate(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + for (const component of message.components) { + surface.components.set(component.id, component); + } + this.#rebuildComponentTree(surface); + } + #handleDataModelUpdate(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + const path = message.path ?? "/"; + this.#setDataByPath(surface.dataModel, path, message.contents); + this.#rebuildComponentTree(surface); + } + #handleDeleteSurface(message) { + this.#surfaces.delete(message.surfaceId); + } + /** + * Starts at the root component of the surface and builds out the tree + * recursively. This process involves resolving all properties of the child + * components, and expanding on any explicit children lists or templates + * found in the structure. + * + * @param surface The surface to be built. + */ + #rebuildComponentTree(surface) { + if (!surface.rootComponentId) { + surface.componentTree = null; + return; + } + const visited = new this.#setCtor(); + surface.componentTree = this.#buildNodeRecursive(surface.rootComponentId, surface, visited, "/", ""); + } + /** Finds a value key in a map. */ + #findValueKey(value) { + return Object.keys(value).find((k$1) => k$1.startsWith("value")); + } + /** + * Builds out the nodes recursively. + */ + #buildNodeRecursive(baseComponentId, surface, visited, dataContextPath, idSuffix = "") { + const fullId = `${baseComponentId}${idSuffix}`; + const { components } = surface; + if (!components.has(baseComponentId)) { + return null; + } + if (visited.has(fullId)) { + throw new Error(`Circular dependency for component "${fullId}".`); + } + visited.add(fullId); + const componentData = components.get(baseComponentId); + const componentProps = componentData.component ?? {}; + const componentType = Object.keys(componentProps)[0]; + const unresolvedProperties = componentProps[componentType]; + const resolvedProperties = new this.#objCtor(); + if (isObject$1(unresolvedProperties)) { + for (const [key, value] of Object.entries(unresolvedProperties)) { + resolvedProperties[key] = this.#resolvePropertyValue(value, surface, visited, dataContextPath, idSuffix, key); + } + } + visited.delete(fullId); + const baseNode = { + id: fullId, + dataContextPath, + weight: componentData.weight ?? "initial" + }; + switch (componentType) { + case "Text": + if (!isResolvedText(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Text", + properties: resolvedProperties + }); + case "Image": + if (!isResolvedImage(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Image", + properties: resolvedProperties + }); + case "Icon": + if (!isResolvedIcon(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Icon", + properties: resolvedProperties + }); + case "Video": + if (!isResolvedVideo(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Video", + properties: resolvedProperties + }); + case "AudioPlayer": + if (!isResolvedAudioPlayer(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "AudioPlayer", + properties: resolvedProperties + }); + case "Row": + if (!isResolvedRow(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Row", + properties: resolvedProperties + }); + case "Column": + if (!isResolvedColumn(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Column", + properties: resolvedProperties + }); + case "List": + if (!isResolvedList(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "List", + properties: resolvedProperties + }); + case "Card": + if (!isResolvedCard(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Card", + properties: resolvedProperties + }); + case "Tabs": + if (!isResolvedTabs(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Tabs", + properties: resolvedProperties + }); + case "Divider": + if (!isResolvedDivider(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Divider", + properties: resolvedProperties + }); + case "Modal": + if (!isResolvedModal(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Modal", + properties: resolvedProperties + }); + case "Button": + if (!isResolvedButton(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Button", + properties: resolvedProperties + }); + case "CheckBox": + if (!isResolvedCheckbox(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "CheckBox", + properties: resolvedProperties + }); + case "TextField": + if (!isResolvedTextField(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "TextField", + properties: resolvedProperties + }); + case "DateTimeInput": + if (!isResolvedDateTimeInput(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "DateTimeInput", + properties: resolvedProperties + }); + case "MultipleChoice": + if (!isResolvedMultipleChoice(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "MultipleChoice", + properties: resolvedProperties + }); + case "Slider": + if (!isResolvedSlider(resolvedProperties)) { + throw new Error(`Invalid data; expected ${componentType}`); + } + return new this.#objCtor({ + ...baseNode, + type: "Slider", + properties: resolvedProperties + }); + default: return new this.#objCtor({ + ...baseNode, + type: componentType, + properties: resolvedProperties + }); + } + } + /** + * Recursively resolves an individual property value. If a property indicates + * a child node (a string that matches a component ID), an explicitList of + * children, or a template, these will be built out here. + */ + #resolvePropertyValue(value, surface, visited, dataContextPath, idSuffix = "", propertyKey = null) { + const isComponentIdReferenceKey = (key) => key === "child" || key.endsWith("Child"); + if (typeof value === "string" && propertyKey && isComponentIdReferenceKey(propertyKey) && surface.components.has(value)) { + return this.#buildNodeRecursive(value, surface, visited, dataContextPath, idSuffix); + } + if (isComponentArrayReference(value)) { + if (value.explicitList) { + return value.explicitList.map((id) => this.#buildNodeRecursive(id, surface, visited, dataContextPath, idSuffix)); + } + if (value.template) { + const fullDataPath = this.resolvePath(value.template.dataBinding, dataContextPath); + const data = this.#getDataByPath(surface.dataModel, fullDataPath); + const template = value.template; + if (Array.isArray(data)) { + return data.map((_$1, index) => { + const parentIndices = dataContextPath.split("/").filter((segment) => /^\d+$/.test(segment)); + const newIndices = [...parentIndices, index]; + const newSuffix = `:${newIndices.join(":")}`; + const childDataContextPath = `${fullDataPath}/${index}`; + return this.#buildNodeRecursive(template.componentId, surface, visited, childDataContextPath, newSuffix); + }); + } + const mapCtor = this.#mapCtor; + if (data instanceof mapCtor) { + return Array.from(data.keys(), (key) => { + const newSuffix = `:${key}`; + const childDataContextPath = `${fullDataPath}/${key}`; + return this.#buildNodeRecursive(template.componentId, surface, visited, childDataContextPath, newSuffix); + }); + } + return new this.#arrayCtor(); + } + } + if (Array.isArray(value)) { + return value.map((item) => this.#resolvePropertyValue(item, surface, visited, dataContextPath, idSuffix, propertyKey)); + } + if (isObject$1(value)) { + const newObj = new this.#objCtor(); + for (const [key, propValue] of Object.entries(value)) { + let propertyValue = propValue; + if (isPath(key, propValue) && dataContextPath !== "/") { + propertyValue = propValue.replace(/^\.?\/item/, "").replace(/^\.?\/text/, "").replace(/^\.?\/label/, "").replace(/^\.?\//, ""); + newObj[key] = propertyValue; + continue; + } + newObj[key] = this.#resolvePropertyValue(propertyValue, surface, visited, dataContextPath, idSuffix, key); + } + return newObj; + } + return value; + } }; var __defProp = Object.defineProperty; -var __defNormalProp = (obj, key, value) => - key in obj - ? __defProp(obj, key, { - enumerable: true, - configurable: true, - writable: true, - value, - }) - : (obj[key] = value); +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { + enumerable: true, + configurable: true, + writable: true, + value +}) : obj[key] = value; var __publicField = (obj, key, value) => { - __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); - return value; + __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + return value; }; var __accessCheck = (obj, member, msg) => { - if (!member.has(obj)) throw TypeError("Cannot " + msg); + if (!member.has(obj)) throw TypeError("Cannot " + msg); }; var __privateIn = (member, obj) => { - if (Object(obj) !== obj) throw TypeError('Cannot use the "in" operator on this value'); - return member.has(obj); + if (Object(obj) !== obj) throw TypeError("Cannot use the \"in\" operator on this value"); + return member.has(obj); }; var __privateAdd = (obj, member, value) => { - if (member.has(obj)) throw TypeError("Cannot add the same private member more than once"); - member instanceof WeakSet ? member.add(obj) : member.set(obj, value); + if (member.has(obj)) throw TypeError("Cannot add the same private member more than once"); + member instanceof WeakSet ? member.add(obj) : member.set(obj, value); }; var __privateMethod = (obj, member, method) => { - __accessCheck(obj, member, "access private method"); - return method; + __accessCheck(obj, member, "access private method"); + return method; }; /** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ +* @license +* Copyright Google LLC All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ function defaultEquals(a$2, b$2) { - return Object.is(a$2, b$2); + return Object.is(a$2, b$2); } /** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ +* @license +* Copyright Google LLC All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ let activeConsumer = null; let inNotificationPhase = false; let epoch = 1; const SIGNAL = /* @__PURE__ */ Symbol("SIGNAL"); function setActiveConsumer(consumer) { - const prev = activeConsumer; - activeConsumer = consumer; - return prev; + const prev = activeConsumer; + activeConsumer = consumer; + return prev; } function getActiveConsumer() { - return activeConsumer; + return activeConsumer; } function isInNotificationPhase() { - return inNotificationPhase; + return inNotificationPhase; } const REACTIVE_NODE = { - version: 0, - lastCleanEpoch: 0, - dirty: false, - producerNode: void 0, - producerLastReadVersion: void 0, - producerIndexOfThis: void 0, - nextProducerIndex: 0, - liveConsumerNode: void 0, - liveConsumerIndexOfThis: void 0, - consumerAllowSignalWrites: false, - consumerIsAlwaysLive: false, - producerMustRecompute: () => false, - producerRecomputeValue: () => {}, - consumerMarkedDirty: () => {}, - consumerOnSignalRead: () => {}, + version: 0, + lastCleanEpoch: 0, + dirty: false, + producerNode: void 0, + producerLastReadVersion: void 0, + producerIndexOfThis: void 0, + nextProducerIndex: 0, + liveConsumerNode: void 0, + liveConsumerIndexOfThis: void 0, + consumerAllowSignalWrites: false, + consumerIsAlwaysLive: false, + producerMustRecompute: () => false, + producerRecomputeValue: () => {}, + consumerMarkedDirty: () => {}, + consumerOnSignalRead: () => {} }; function producerAccessed(node) { - if (inNotificationPhase) { - throw new Error( - typeof ngDevMode !== "undefined" && ngDevMode - ? `Assertion error: signal read during notification phase` - : "", - ); - } - if (activeConsumer === null) { - return; - } - activeConsumer.consumerOnSignalRead(node); - const idx = activeConsumer.nextProducerIndex++; - assertConsumerNode(activeConsumer); - if (idx < activeConsumer.producerNode.length && activeConsumer.producerNode[idx] !== node) { - if (consumerIsLive(activeConsumer)) { - const staleProducer = activeConsumer.producerNode[idx]; - producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]); - } - } - if (activeConsumer.producerNode[idx] !== node) { - activeConsumer.producerNode[idx] = node; - activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) - ? producerAddLiveConsumer(node, activeConsumer, idx) - : 0; - } - activeConsumer.producerLastReadVersion[idx] = node.version; + if (inNotificationPhase) { + throw new Error(typeof ngDevMode !== "undefined" && ngDevMode ? `Assertion error: signal read during notification phase` : ""); + } + if (activeConsumer === null) { + return; + } + activeConsumer.consumerOnSignalRead(node); + const idx = activeConsumer.nextProducerIndex++; + assertConsumerNode(activeConsumer); + if (idx < activeConsumer.producerNode.length && activeConsumer.producerNode[idx] !== node) { + if (consumerIsLive(activeConsumer)) { + const staleProducer = activeConsumer.producerNode[idx]; + producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]); + } + } + if (activeConsumer.producerNode[idx] !== node) { + activeConsumer.producerNode[idx] = node; + activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) ? producerAddLiveConsumer(node, activeConsumer, idx) : 0; + } + activeConsumer.producerLastReadVersion[idx] = node.version; } function producerIncrementEpoch() { - epoch++; + epoch++; } function producerUpdateValueVersion(node) { - if (!node.dirty && node.lastCleanEpoch === epoch) { - return; - } - if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) { - node.dirty = false; - node.lastCleanEpoch = epoch; - return; - } - node.producerRecomputeValue(node); - node.dirty = false; - node.lastCleanEpoch = epoch; + if (!node.dirty && node.lastCleanEpoch === epoch) { + return; + } + if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) { + node.dirty = false; + node.lastCleanEpoch = epoch; + return; + } + node.producerRecomputeValue(node); + node.dirty = false; + node.lastCleanEpoch = epoch; } function producerNotifyConsumers(node) { - if (node.liveConsumerNode === void 0) { - return; - } - const prev = inNotificationPhase; - inNotificationPhase = true; - try { - for (const consumer of node.liveConsumerNode) { - if (!consumer.dirty) { - consumerMarkDirty(consumer); - } - } - } finally { - inNotificationPhase = prev; - } + if (node.liveConsumerNode === void 0) { + return; + } + const prev = inNotificationPhase; + inNotificationPhase = true; + try { + for (const consumer of node.liveConsumerNode) { + if (!consumer.dirty) { + consumerMarkDirty(consumer); + } + } + } finally { + inNotificationPhase = prev; + } } function producerUpdatesAllowed() { - return (activeConsumer == null ? void 0 : activeConsumer.consumerAllowSignalWrites) !== false; + return (activeConsumer == null ? void 0 : activeConsumer.consumerAllowSignalWrites) !== false; } function consumerMarkDirty(node) { - var _a$1; - node.dirty = true; - producerNotifyConsumers(node); - (_a$1 = node.consumerMarkedDirty) == null ? void 0 : _a$1.call(node.wrapper ?? node); + var _a$1; + node.dirty = true; + producerNotifyConsumers(node); + (_a$1 = node.consumerMarkedDirty) == null ? void 0 : _a$1.call(node.wrapper ?? node); } function consumerBeforeComputation(node) { - node && (node.nextProducerIndex = 0); - return setActiveConsumer(node); + node && (node.nextProducerIndex = 0); + return setActiveConsumer(node); } function consumerAfterComputation(node, prevConsumer) { - setActiveConsumer(prevConsumer); - if ( - !node || - node.producerNode === void 0 || - node.producerIndexOfThis === void 0 || - node.producerLastReadVersion === void 0 - ) { - return; - } - if (consumerIsLive(node)) { - for (let i$10 = node.nextProducerIndex; i$10 < node.producerNode.length; i$10++) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i$10], node.producerIndexOfThis[i$10]); - } - } - while (node.producerNode.length > node.nextProducerIndex) { - node.producerNode.pop(); - node.producerLastReadVersion.pop(); - node.producerIndexOfThis.pop(); - } + setActiveConsumer(prevConsumer); + if (!node || node.producerNode === void 0 || node.producerIndexOfThis === void 0 || node.producerLastReadVersion === void 0) { + return; + } + if (consumerIsLive(node)) { + for (let i$10 = node.nextProducerIndex; i$10 < node.producerNode.length; i$10++) { + producerRemoveLiveConsumerAtIndex(node.producerNode[i$10], node.producerIndexOfThis[i$10]); + } + } + while (node.producerNode.length > node.nextProducerIndex) { + node.producerNode.pop(); + node.producerLastReadVersion.pop(); + node.producerIndexOfThis.pop(); + } } function consumerPollProducersForChange(node) { - assertConsumerNode(node); - for (let i$10 = 0; i$10 < node.producerNode.length; i$10++) { - const producer = node.producerNode[i$10]; - const seenVersion = node.producerLastReadVersion[i$10]; - if (seenVersion !== producer.version) { - return true; - } - producerUpdateValueVersion(producer); - if (seenVersion !== producer.version) { - return true; - } - } - return false; + assertConsumerNode(node); + for (let i$10 = 0; i$10 < node.producerNode.length; i$10++) { + const producer = node.producerNode[i$10]; + const seenVersion = node.producerLastReadVersion[i$10]; + if (seenVersion !== producer.version) { + return true; + } + producerUpdateValueVersion(producer); + if (seenVersion !== producer.version) { + return true; + } + } + return false; } function producerAddLiveConsumer(node, consumer, indexOfThis) { - var _a$1; - assertProducerNode(node); - assertConsumerNode(node); - if (node.liveConsumerNode.length === 0) { - (_a$1 = node.watched) == null ? void 0 : _a$1.call(node.wrapper); - for (let i$10 = 0; i$10 < node.producerNode.length; i$10++) { - node.producerIndexOfThis[i$10] = producerAddLiveConsumer(node.producerNode[i$10], node, i$10); - } - } - node.liveConsumerIndexOfThis.push(indexOfThis); - return node.liveConsumerNode.push(consumer) - 1; + var _a$1; + assertProducerNode(node); + assertConsumerNode(node); + if (node.liveConsumerNode.length === 0) { + (_a$1 = node.watched) == null ? void 0 : _a$1.call(node.wrapper); + for (let i$10 = 0; i$10 < node.producerNode.length; i$10++) { + node.producerIndexOfThis[i$10] = producerAddLiveConsumer(node.producerNode[i$10], node, i$10); + } + } + node.liveConsumerIndexOfThis.push(indexOfThis); + return node.liveConsumerNode.push(consumer) - 1; } function producerRemoveLiveConsumerAtIndex(node, idx) { - var _a$1; - assertProducerNode(node); - assertConsumerNode(node); - if (typeof ngDevMode !== "undefined" && ngDevMode && idx >= node.liveConsumerNode.length) { - throw new Error( - `Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`, - ); - } - if (node.liveConsumerNode.length === 1) { - (_a$1 = node.unwatched) == null ? void 0 : _a$1.call(node.wrapper); - for (let i$10 = 0; i$10 < node.producerNode.length; i$10++) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i$10], node.producerIndexOfThis[i$10]); - } - } - const lastIdx = node.liveConsumerNode.length - 1; - node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; - node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; - node.liveConsumerNode.length--; - node.liveConsumerIndexOfThis.length--; - if (idx < node.liveConsumerNode.length) { - const idxProducer = node.liveConsumerIndexOfThis[idx]; - const consumer = node.liveConsumerNode[idx]; - assertConsumerNode(consumer); - consumer.producerIndexOfThis[idxProducer] = idx; - } + var _a$1; + assertProducerNode(node); + assertConsumerNode(node); + if (typeof ngDevMode !== "undefined" && ngDevMode && idx >= node.liveConsumerNode.length) { + throw new Error(`Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`); + } + if (node.liveConsumerNode.length === 1) { + (_a$1 = node.unwatched) == null ? void 0 : _a$1.call(node.wrapper); + for (let i$10 = 0; i$10 < node.producerNode.length; i$10++) { + producerRemoveLiveConsumerAtIndex(node.producerNode[i$10], node.producerIndexOfThis[i$10]); + } + } + const lastIdx = node.liveConsumerNode.length - 1; + node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; + node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; + node.liveConsumerNode.length--; + node.liveConsumerIndexOfThis.length--; + if (idx < node.liveConsumerNode.length) { + const idxProducer = node.liveConsumerIndexOfThis[idx]; + const consumer = node.liveConsumerNode[idx]; + assertConsumerNode(consumer); + consumer.producerIndexOfThis[idxProducer] = idx; + } } function consumerIsLive(node) { - var _a$1; - return ( - node.consumerIsAlwaysLive || - (((_a$1 = node == null ? void 0 : node.liveConsumerNode) == null ? void 0 : _a$1.length) ?? 0) > - 0 - ); + var _a$1; + return node.consumerIsAlwaysLive || (((_a$1 = node == null ? void 0 : node.liveConsumerNode) == null ? void 0 : _a$1.length) ?? 0) > 0; } function assertConsumerNode(node) { - node.producerNode ?? (node.producerNode = []); - node.producerIndexOfThis ?? (node.producerIndexOfThis = []); - node.producerLastReadVersion ?? (node.producerLastReadVersion = []); + node.producerNode ?? (node.producerNode = []); + node.producerIndexOfThis ?? (node.producerIndexOfThis = []); + node.producerLastReadVersion ?? (node.producerLastReadVersion = []); } function assertProducerNode(node) { - node.liveConsumerNode ?? (node.liveConsumerNode = []); - node.liveConsumerIndexOfThis ?? (node.liveConsumerIndexOfThis = []); + node.liveConsumerNode ?? (node.liveConsumerNode = []); + node.liveConsumerIndexOfThis ?? (node.liveConsumerIndexOfThis = []); } /** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ +* @license +* Copyright Google LLC All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ function computedGet(node) { - producerUpdateValueVersion(node); - producerAccessed(node); - if (node.value === ERRORED) { - throw node.error; - } - return node.value; + producerUpdateValueVersion(node); + producerAccessed(node); + if (node.value === ERRORED) { + throw node.error; + } + return node.value; } function createComputed(computation) { - const node = Object.create(COMPUTED_NODE); - node.computation = computation; - const computed = () => computedGet(node); - computed[SIGNAL] = node; - return computed; + const node = Object.create(COMPUTED_NODE); + node.computation = computation; + const computed = () => computedGet(node); + computed[SIGNAL] = node; + return computed; } const UNSET = /* @__PURE__ */ Symbol("UNSET"); const COMPUTING = /* @__PURE__ */ Symbol("COMPUTING"); const ERRORED = /* @__PURE__ */ Symbol("ERRORED"); const COMPUTED_NODE = /* @__PURE__ */ (() => { - return { - ...REACTIVE_NODE, - value: UNSET, - dirty: true, - error: null, - equal: defaultEquals, - producerMustRecompute(node) { - return node.value === UNSET || node.value === COMPUTING; - }, - producerRecomputeValue(node) { - if (node.value === COMPUTING) { - throw new Error("Detected cycle in computations."); - } - const oldValue = node.value; - node.value = COMPUTING; - const prevConsumer = consumerBeforeComputation(node); - let newValue; - let wasEqual = false; - try { - newValue = node.computation.call(node.wrapper); - const oldOk = oldValue !== UNSET && oldValue !== ERRORED; - wasEqual = oldOk && node.equal.call(node.wrapper, oldValue, newValue); - } catch (err) { - newValue = ERRORED; - node.error = err; - } finally { - consumerAfterComputation(node, prevConsumer); - } - if (wasEqual) { - node.value = oldValue; - return; - } - node.value = newValue; - node.version++; - }, - }; + return { + ...REACTIVE_NODE, + value: UNSET, + dirty: true, + error: null, + equal: defaultEquals, + producerMustRecompute(node) { + return node.value === UNSET || node.value === COMPUTING; + }, + producerRecomputeValue(node) { + if (node.value === COMPUTING) { + throw new Error("Detected cycle in computations."); + } + const oldValue = node.value; + node.value = COMPUTING; + const prevConsumer = consumerBeforeComputation(node); + let newValue; + let wasEqual = false; + try { + newValue = node.computation.call(node.wrapper); + const oldOk = oldValue !== UNSET && oldValue !== ERRORED; + wasEqual = oldOk && node.equal.call(node.wrapper, oldValue, newValue); + } catch (err) { + newValue = ERRORED; + node.error = err; + } finally { + consumerAfterComputation(node, prevConsumer); + } + if (wasEqual) { + node.value = oldValue; + return; + } + node.value = newValue; + node.version++; + } + }; })(); /** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ +* @license +* Copyright Google LLC All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ function defaultThrowError() { - throw new Error(); + throw new Error(); } let throwInvalidWriteToSignalErrorFn = defaultThrowError; function throwInvalidWriteToSignalError() { - throwInvalidWriteToSignalErrorFn(); + throwInvalidWriteToSignalErrorFn(); } /** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ +* @license +* Copyright Google LLC All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ function createSignal(initialValue) { - const node = Object.create(SIGNAL_NODE); - node.value = initialValue; - const getter = () => { - producerAccessed(node); - return node.value; - }; - getter[SIGNAL] = node; - return getter; + const node = Object.create(SIGNAL_NODE); + node.value = initialValue; + const getter = () => { + producerAccessed(node); + return node.value; + }; + getter[SIGNAL] = node; + return getter; } function signalGetFn() { - producerAccessed(this); - return this.value; + producerAccessed(this); + return this.value; } function signalSetFn(node, newValue) { - if (!producerUpdatesAllowed()) { - throwInvalidWriteToSignalError(); - } - if (!node.equal.call(node.wrapper, node.value, newValue)) { - node.value = newValue; - signalValueChanged(node); - } + if (!producerUpdatesAllowed()) { + throwInvalidWriteToSignalError(); + } + if (!node.equal.call(node.wrapper, node.value, newValue)) { + node.value = newValue; + signalValueChanged(node); + } } const SIGNAL_NODE = /* @__PURE__ */ (() => { - return { - ...REACTIVE_NODE, - equal: defaultEquals, - value: void 0, - }; + return { + ...REACTIVE_NODE, + equal: defaultEquals, + value: void 0 + }; })(); function signalValueChanged(node) { - node.version++; - producerIncrementEpoch(); - producerNotifyConsumers(node); + node.version++; + producerIncrementEpoch(); + producerNotifyConsumers(node); } /** - * @license - * Copyright 2024 Bloomberg Finance L.P. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +* @license +* Copyright 2024 Bloomberg Finance L.P. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ const NODE = Symbol("node"); var Signal; ((Signal2) => { - var _a$1, _brand, brand_fn, _b, _brand2, brand_fn2; - class State { - constructor(initialValue, options = {}) { - __privateAdd(this, _brand); - __publicField(this, _a$1); - const ref = createSignal(initialValue); - const node = ref[SIGNAL]; - this[NODE] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) { - node.equal = equals; - } - node.watched = options[Signal2.subtle.watched]; - node.unwatched = options[Signal2.subtle.unwatched]; - } - } - get() { - if (!(0, Signal2.isState)(this)) - throw new TypeError("Wrong receiver type for Signal.State.prototype.get"); - return signalGetFn.call(this[NODE]); - } - set(newValue) { - if (!(0, Signal2.isState)(this)) - throw new TypeError("Wrong receiver type for Signal.State.prototype.set"); - if (isInNotificationPhase()) { - throw new Error("Writes to signals not permitted during Watcher callback"); - } - const ref = this[NODE]; - signalSetFn(ref, newValue); - } - } - _a$1 = NODE; - _brand = new WeakSet(); - brand_fn = function () {}; - Signal2.isState = (s$9) => typeof s$9 === "object" && __privateIn(_brand, s$9); - Signal2.State = State; - class Computed { - constructor(computation, options) { - __privateAdd(this, _brand2); - __publicField(this, _b); - const ref = createComputed(computation); - const node = ref[SIGNAL]; - node.consumerAllowSignalWrites = true; - this[NODE] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) { - node.equal = equals; - } - node.watched = options[Signal2.subtle.watched]; - node.unwatched = options[Signal2.subtle.unwatched]; - } - } - get() { - if (!(0, Signal2.isComputed)(this)) - throw new TypeError("Wrong receiver type for Signal.Computed.prototype.get"); - return computedGet(this[NODE]); - } - } - _b = NODE; - _brand2 = new WeakSet(); - brand_fn2 = function () {}; - Signal2.isComputed = (c$7) => typeof c$7 === "object" && __privateIn(_brand2, c$7); - Signal2.Computed = Computed; - ((subtle2) => { - var _a2, _brand3, brand_fn3, _assertSignals, assertSignals_fn; - function untrack(cb) { - let output; - let prevActiveConsumer = null; - try { - prevActiveConsumer = setActiveConsumer(null); - output = cb(); - } finally { - setActiveConsumer(prevActiveConsumer); - } - return output; - } - subtle2.untrack = untrack; - function introspectSources(sink) { - var _a3; - if (!(0, Signal2.isComputed)(sink) && !(0, Signal2.isWatcher)(sink)) { - throw new TypeError("Called introspectSources without a Computed or Watcher argument"); - } - return ( - ((_a3 = sink[NODE].producerNode) == null ? void 0 : _a3.map((n$13) => n$13.wrapper)) ?? [] - ); - } - subtle2.introspectSources = introspectSources; - function introspectSinks(signal) { - var _a3; - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) { - throw new TypeError("Called introspectSinks without a Signal argument"); - } - return ( - ((_a3 = signal[NODE].liveConsumerNode) == null - ? void 0 - : _a3.map((n$13) => n$13.wrapper)) ?? [] - ); - } - subtle2.introspectSinks = introspectSinks; - function hasSinks(signal) { - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) { - throw new TypeError("Called hasSinks without a Signal argument"); - } - const liveConsumerNode = signal[NODE].liveConsumerNode; - if (!liveConsumerNode) return false; - return liveConsumerNode.length > 0; - } - subtle2.hasSinks = hasSinks; - function hasSources(signal) { - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isWatcher)(signal)) { - throw new TypeError("Called hasSources without a Computed or Watcher argument"); - } - const producerNode = signal[NODE].producerNode; - if (!producerNode) return false; - return producerNode.length > 0; - } - subtle2.hasSources = hasSources; - class Watcher { - constructor(notify) { - __privateAdd(this, _brand3); - __privateAdd(this, _assertSignals); - __publicField(this, _a2); - let node = Object.create(REACTIVE_NODE); - node.wrapper = this; - node.consumerMarkedDirty = notify; - node.consumerIsAlwaysLive = true; - node.consumerAllowSignalWrites = false; - node.producerNode = []; - this[NODE] = node; - } - watch(...signals) { - if (!(0, Signal2.isWatcher)(this)) { - throw new TypeError("Called unwatch without Watcher receiver"); - } - __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); - const node = this[NODE]; - node.dirty = false; - const prev = setActiveConsumer(node); - for (const signal of signals) { - producerAccessed(signal[NODE]); - } - setActiveConsumer(prev); - } - unwatch(...signals) { - if (!(0, Signal2.isWatcher)(this)) { - throw new TypeError("Called unwatch without Watcher receiver"); - } - __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); - const node = this[NODE]; - assertConsumerNode(node); - for (let i$10 = node.producerNode.length - 1; i$10 >= 0; i$10--) { - if (signals.includes(node.producerNode[i$10].wrapper)) { - producerRemoveLiveConsumerAtIndex( - node.producerNode[i$10], - node.producerIndexOfThis[i$10], - ); - const lastIdx = node.producerNode.length - 1; - node.producerNode[i$10] = node.producerNode[lastIdx]; - node.producerIndexOfThis[i$10] = node.producerIndexOfThis[lastIdx]; - node.producerNode.length--; - node.producerIndexOfThis.length--; - node.nextProducerIndex--; - if (i$10 < node.producerNode.length) { - const idxConsumer = node.producerIndexOfThis[i$10]; - const producer = node.producerNode[i$10]; - assertProducerNode(producer); - producer.liveConsumerIndexOfThis[idxConsumer] = i$10; - } - } - } - } - getPending() { - if (!(0, Signal2.isWatcher)(this)) { - throw new TypeError("Called getPending without Watcher receiver"); - } - const node = this[NODE]; - return node.producerNode.filter((n$13) => n$13.dirty).map((n$13) => n$13.wrapper); - } - } - _a2 = NODE; - _brand3 = new WeakSet(); - brand_fn3 = function () {}; - _assertSignals = new WeakSet(); - assertSignals_fn = function (signals) { - for (const signal of signals) { - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) { - throw new TypeError("Called watch/unwatch without a Computed or State argument"); - } - } - }; - Signal2.isWatcher = (w$1) => __privateIn(_brand3, w$1); - subtle2.Watcher = Watcher; - function currentComputed() { - var _a3; - return (_a3 = getActiveConsumer()) == null ? void 0 : _a3.wrapper; - } - subtle2.currentComputed = currentComputed; - subtle2.watched = Symbol("watched"); - subtle2.unwatched = Symbol("unwatched"); - })(Signal2.subtle || (Signal2.subtle = {})); + var _a$1, _brand, brand_fn, _b, _brand2, brand_fn2; + class State { + constructor(initialValue, options = {}) { + __privateAdd(this, _brand); + __publicField(this, _a$1); + const ref = createSignal(initialValue); + const node = ref[SIGNAL]; + this[NODE] = node; + node.wrapper = this; + if (options) { + const equals = options.equals; + if (equals) { + node.equal = equals; + } + node.watched = options[Signal2.subtle.watched]; + node.unwatched = options[Signal2.subtle.unwatched]; + } + } + get() { + if (!(0, Signal2.isState)(this)) throw new TypeError("Wrong receiver type for Signal.State.prototype.get"); + return signalGetFn.call(this[NODE]); + } + set(newValue) { + if (!(0, Signal2.isState)(this)) throw new TypeError("Wrong receiver type for Signal.State.prototype.set"); + if (isInNotificationPhase()) { + throw new Error("Writes to signals not permitted during Watcher callback"); + } + const ref = this[NODE]; + signalSetFn(ref, newValue); + } + } + _a$1 = NODE; + _brand = new WeakSet(); + brand_fn = function() {}; + Signal2.isState = (s$9) => typeof s$9 === "object" && __privateIn(_brand, s$9); + Signal2.State = State; + class Computed { + constructor(computation, options) { + __privateAdd(this, _brand2); + __publicField(this, _b); + const ref = createComputed(computation); + const node = ref[SIGNAL]; + node.consumerAllowSignalWrites = true; + this[NODE] = node; + node.wrapper = this; + if (options) { + const equals = options.equals; + if (equals) { + node.equal = equals; + } + node.watched = options[Signal2.subtle.watched]; + node.unwatched = options[Signal2.subtle.unwatched]; + } + } + get() { + if (!(0, Signal2.isComputed)(this)) throw new TypeError("Wrong receiver type for Signal.Computed.prototype.get"); + return computedGet(this[NODE]); + } + } + _b = NODE; + _brand2 = new WeakSet(); + brand_fn2 = function() {}; + Signal2.isComputed = (c$7) => typeof c$7 === "object" && __privateIn(_brand2, c$7); + Signal2.Computed = Computed; + ((subtle2) => { + var _a2, _brand3, brand_fn3, _assertSignals, assertSignals_fn; + function untrack(cb) { + let output; + let prevActiveConsumer = null; + try { + prevActiveConsumer = setActiveConsumer(null); + output = cb(); + } finally { + setActiveConsumer(prevActiveConsumer); + } + return output; + } + subtle2.untrack = untrack; + function introspectSources(sink) { + var _a3; + if (!(0, Signal2.isComputed)(sink) && !(0, Signal2.isWatcher)(sink)) { + throw new TypeError("Called introspectSources without a Computed or Watcher argument"); + } + return ((_a3 = sink[NODE].producerNode) == null ? void 0 : _a3.map((n$13) => n$13.wrapper)) ?? []; + } + subtle2.introspectSources = introspectSources; + function introspectSinks(signal) { + var _a3; + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) { + throw new TypeError("Called introspectSinks without a Signal argument"); + } + return ((_a3 = signal[NODE].liveConsumerNode) == null ? void 0 : _a3.map((n$13) => n$13.wrapper)) ?? []; + } + subtle2.introspectSinks = introspectSinks; + function hasSinks(signal) { + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) { + throw new TypeError("Called hasSinks without a Signal argument"); + } + const liveConsumerNode = signal[NODE].liveConsumerNode; + if (!liveConsumerNode) return false; + return liveConsumerNode.length > 0; + } + subtle2.hasSinks = hasSinks; + function hasSources(signal) { + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isWatcher)(signal)) { + throw new TypeError("Called hasSources without a Computed or Watcher argument"); + } + const producerNode = signal[NODE].producerNode; + if (!producerNode) return false; + return producerNode.length > 0; + } + subtle2.hasSources = hasSources; + class Watcher { + constructor(notify) { + __privateAdd(this, _brand3); + __privateAdd(this, _assertSignals); + __publicField(this, _a2); + let node = Object.create(REACTIVE_NODE); + node.wrapper = this; + node.consumerMarkedDirty = notify; + node.consumerIsAlwaysLive = true; + node.consumerAllowSignalWrites = false; + node.producerNode = []; + this[NODE] = node; + } + watch(...signals) { + if (!(0, Signal2.isWatcher)(this)) { + throw new TypeError("Called unwatch without Watcher receiver"); + } + __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); + const node = this[NODE]; + node.dirty = false; + const prev = setActiveConsumer(node); + for (const signal of signals) { + producerAccessed(signal[NODE]); + } + setActiveConsumer(prev); + } + unwatch(...signals) { + if (!(0, Signal2.isWatcher)(this)) { + throw new TypeError("Called unwatch without Watcher receiver"); + } + __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); + const node = this[NODE]; + assertConsumerNode(node); + for (let i$10 = node.producerNode.length - 1; i$10 >= 0; i$10--) { + if (signals.includes(node.producerNode[i$10].wrapper)) { + producerRemoveLiveConsumerAtIndex(node.producerNode[i$10], node.producerIndexOfThis[i$10]); + const lastIdx = node.producerNode.length - 1; + node.producerNode[i$10] = node.producerNode[lastIdx]; + node.producerIndexOfThis[i$10] = node.producerIndexOfThis[lastIdx]; + node.producerNode.length--; + node.producerIndexOfThis.length--; + node.nextProducerIndex--; + if (i$10 < node.producerNode.length) { + const idxConsumer = node.producerIndexOfThis[i$10]; + const producer = node.producerNode[i$10]; + assertProducerNode(producer); + producer.liveConsumerIndexOfThis[idxConsumer] = i$10; + } + } + } + } + getPending() { + if (!(0, Signal2.isWatcher)(this)) { + throw new TypeError("Called getPending without Watcher receiver"); + } + const node = this[NODE]; + return node.producerNode.filter((n$13) => n$13.dirty).map((n$13) => n$13.wrapper); + } + } + _a2 = NODE; + _brand3 = new WeakSet(); + brand_fn3 = function() {}; + _assertSignals = new WeakSet(); + assertSignals_fn = function(signals) { + for (const signal of signals) { + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) { + throw new TypeError("Called watch/unwatch without a Computed or State argument"); + } + } + }; + Signal2.isWatcher = (w$1) => __privateIn(_brand3, w$1); + subtle2.Watcher = Watcher; + function currentComputed() { + var _a3; + return (_a3 = getActiveConsumer()) == null ? void 0 : _a3.wrapper; + } + subtle2.currentComputed = currentComputed; + subtle2.watched = Symbol("watched"); + subtle2.unwatched = Symbol("unwatched"); + })(Signal2.subtle || (Signal2.subtle = {})); })(Signal || (Signal = {})); /** - * equality check here is always false so that we can dirty the storage - * via setting to _anything_ - * - * - * This is for a pattern where we don't *directly* use signals to back the values used in collections - * so that instanceof checks and getters and other native features "just work" without having - * to do nested proxying. - * - * (though, see deep.ts for nested / deep behavior) - */ +* equality check here is always false so that we can dirty the storage +* via setting to _anything_ +* +* +* This is for a pattern where we don't *directly* use signals to back the values used in collections +* so that instanceof checks and getters and other native features "just work" without having +* to do nested proxying. +* +* (though, see deep.ts for nested / deep behavior) +*/ const createStorage = (initial = null) => new Signal.State(initial, { equals: () => false }); /** - * Just an alias for brevity - */ +* Just an alias for brevity +*/ const BOUND_FUNS = new WeakMap(); function fnCacheFor(context) { - let fnCache = BOUND_FUNS.get(context); - if (!fnCache) { - fnCache = new Map(); - BOUND_FUNS.set(context, fnCache); - } - return fnCache; + let fnCache = BOUND_FUNS.get(context); + if (!fnCache) { + fnCache = new Map(); + BOUND_FUNS.set(context, fnCache); + } + return fnCache; } const ARRAY_GETTER_METHODS = new Set([ - Symbol.iterator, - "concat", - "entries", - "every", - "filter", - "find", - "findIndex", - "flat", - "flatMap", - "forEach", - "includes", - "indexOf", - "join", - "keys", - "lastIndexOf", - "map", - "reduce", - "reduceRight", - "slice", - "some", - "values", + Symbol.iterator, + "concat", + "entries", + "every", + "filter", + "find", + "findIndex", + "flat", + "flatMap", + "forEach", + "includes", + "indexOf", + "join", + "keys", + "lastIndexOf", + "map", + "reduce", + "reduceRight", + "slice", + "some", + "values" +]); +const ARRAY_WRITE_THEN_READ_METHODS = new Set([ + "fill", + "push", + "unshift" ]); -const ARRAY_WRITE_THEN_READ_METHODS = new Set(["fill", "push", "unshift"]); function convertToInt(prop) { - if (typeof prop === "symbol") return null; - const num = Number(prop); - if (isNaN(num)) return null; - return num % 1 === 0 ? num : null; + if (typeof prop === "symbol") return null; + const num = Number(prop); + if (isNaN(num)) return null; + return num % 1 === 0 ? num : null; } var SignalArray = class SignalArray { - /** - * Creates an array from an iterable object. - * @param iterable An iterable object to convert to an array. - */ - /** - * Creates an array from an iterable object. - * @param iterable An iterable object to convert to an array. - * @param mapfn A mapping function to call on every element of the array. - * @param thisArg Value of 'this' used to invoke the mapfn. - */ - static from(iterable, mapfn, thisArg) { - return mapfn - ? new SignalArray(Array.from(iterable, mapfn, thisArg)) - : new SignalArray(Array.from(iterable)); - } - static of(...arr) { - return new SignalArray(arr); - } - constructor(arr = []) { - let clone = arr.slice(); - let self = this; - let boundFns = new Map(); - /** + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + */ + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + * @param mapfn A mapping function to call on every element of the array. + * @param thisArg Value of 'this' used to invoke the mapfn. + */ + static from(iterable, mapfn, thisArg) { + return mapfn ? new SignalArray(Array.from(iterable, mapfn, thisArg)) : new SignalArray(Array.from(iterable)); + } + static of(...arr) { + return new SignalArray(arr); + } + constructor(arr = []) { + let clone = arr.slice(); + let self = this; + let boundFns = new Map(); + /** Flag to track whether we have *just* intercepted a call to `.push()` or `.unshift()`, since in those cases (and only those cases!) the `Array` itself checks `.length` to return from the function call. */ - let nativelyAccessingLengthFromPushOrUnshift = false; - return new Proxy(clone, { - get(target, prop) { - let index = convertToInt(prop); - if (index !== null) { - self.#readStorageFor(index); - self.#collection.get(); - return target[index]; - } - if (prop === "length") { - if (nativelyAccessingLengthFromPushOrUnshift) { - nativelyAccessingLengthFromPushOrUnshift = false; - } else { - self.#collection.get(); - } - return target[prop]; - } - if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) { - nativelyAccessingLengthFromPushOrUnshift = true; - } - if (ARRAY_GETTER_METHODS.has(prop)) { - let fn = boundFns.get(prop); - if (fn === undefined) { - fn = (...args) => { - self.#collection.get(); - return target[prop](...args); - }; - boundFns.set(prop, fn); - } - return fn; - } - return target[prop]; - }, - set(target, prop, value) { - target[prop] = value; - let index = convertToInt(prop); - if (index !== null) { - self.#dirtyStorageFor(index); - self.#collection.set(null); - } else if (prop === "length") { - self.#collection.set(null); - } - return true; - }, - getPrototypeOf() { - return SignalArray.prototype; - }, - }); - } - #collection = createStorage(); - #storages = new Map(); - #readStorageFor(index) { - let storage = this.#storages.get(index); - if (storage === undefined) { - storage = createStorage(); - this.#storages.set(index, storage); - } - storage.get(); - } - #dirtyStorageFor(index) { - const storage = this.#storages.get(index); - if (storage) { - storage.set(null); - } - } + let nativelyAccessingLengthFromPushOrUnshift = false; + return new Proxy(clone, { + get(target, prop) { + let index = convertToInt(prop); + if (index !== null) { + self.#readStorageFor(index); + self.#collection.get(); + return target[index]; + } + if (prop === "length") { + if (nativelyAccessingLengthFromPushOrUnshift) { + nativelyAccessingLengthFromPushOrUnshift = false; + } else { + self.#collection.get(); + } + return target[prop]; + } + if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) { + nativelyAccessingLengthFromPushOrUnshift = true; + } + if (ARRAY_GETTER_METHODS.has(prop)) { + let fn = boundFns.get(prop); + if (fn === undefined) { + fn = (...args) => { + self.#collection.get(); + return target[prop](...args); + }; + boundFns.set(prop, fn); + } + return fn; + } + return target[prop]; + }, + set(target, prop, value) { + target[prop] = value; + let index = convertToInt(prop); + if (index !== null) { + self.#dirtyStorageFor(index); + self.#collection.set(null); + } else if (prop === "length") { + self.#collection.set(null); + } + return true; + }, + getPrototypeOf() { + return SignalArray.prototype; + } + }); + } + #collection = createStorage(); + #storages = new Map(); + #readStorageFor(index) { + let storage = this.#storages.get(index); + if (storage === undefined) { + storage = createStorage(); + this.#storages.set(index, storage); + } + storage.get(); + } + #dirtyStorageFor(index) { + const storage = this.#storages.get(index); + if (storage) { + storage.set(null); + } + } }; Object.setPrototypeOf(SignalArray.prototype, Array.prototype); function signalArray(x$1) { - return new SignalArray(x$1); + return new SignalArray(x$1); } var SignalMap = class { - collection = createStorage(); - storages = new Map(); - vals; - readStorageFor(key) { - const { storages } = this; - let storage = storages.get(key); - if (storage === undefined) { - storage = createStorage(); - storages.set(key, storage); - } - storage.get(); - } - dirtyStorageFor(key) { - const storage = this.storages.get(key); - if (storage) { - storage.set(null); - } - } - constructor(existing) { - this.vals = existing ? new Map(existing) : new Map(); - } - get(key) { - this.readStorageFor(key); - return this.vals.get(key); - } - has(key) { - this.readStorageFor(key); - return this.vals.has(key); - } - entries() { - this.collection.get(); - return this.vals.entries(); - } - keys() { - this.collection.get(); - return this.vals.keys(); - } - values() { - this.collection.get(); - return this.vals.values(); - } - forEach(fn) { - this.collection.get(); - this.vals.forEach(fn); - } - get size() { - this.collection.get(); - return this.vals.size; - } - [Symbol.iterator]() { - this.collection.get(); - return this.vals[Symbol.iterator](); - } - get [Symbol.toStringTag]() { - return this.vals[Symbol.toStringTag]; - } - set(key, value) { - this.dirtyStorageFor(key); - this.collection.set(null); - this.vals.set(key, value); - return this; - } - delete(key) { - this.dirtyStorageFor(key); - this.collection.set(null); - return this.vals.delete(key); - } - clear() { - this.storages.forEach((s$9) => s$9.set(null)); - this.collection.set(null); - this.vals.clear(); - } + collection = createStorage(); + storages = new Map(); + vals; + readStorageFor(key) { + const { storages } = this; + let storage = storages.get(key); + if (storage === undefined) { + storage = createStorage(); + storages.set(key, storage); + } + storage.get(); + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) { + storage.set(null); + } + } + constructor(existing) { + this.vals = existing ? new Map(existing) : new Map(); + } + get(key) { + this.readStorageFor(key); + return this.vals.get(key); + } + has(key) { + this.readStorageFor(key); + return this.vals.has(key); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + set(key, value) { + this.dirtyStorageFor(key); + this.collection.set(null); + this.vals.set(key, value); + return this; + } + delete(key) { + this.dirtyStorageFor(key); + this.collection.set(null); + return this.vals.delete(key); + } + clear() { + this.storages.forEach((s$9) => s$9.set(null)); + this.collection.set(null); + this.vals.clear(); + } }; Object.setPrototypeOf(SignalMap.prototype, Map.prototype); /** - * Implementation based of tracked-built-ins' TrackedObject - * https://github.com/tracked-tools/tracked-built-ins/blob/master/addon/src/-private/object.js - */ +* Implementation based of tracked-built-ins' TrackedObject +* https://github.com/tracked-tools/tracked-built-ins/blob/master/addon/src/-private/object.js +*/ var SignalObjectImpl = class SignalObjectImpl { - static fromEntries(entries) { - return new SignalObjectImpl(Object.fromEntries(entries)); - } - #storages = new Map(); - #collection = createStorage(); - constructor(obj = {}) { - let proto = Object.getPrototypeOf(obj); - let descs = Object.getOwnPropertyDescriptors(obj); - let clone = Object.create(proto); - for (let prop in descs) { - Object.defineProperty(clone, prop, descs[prop]); - } - let self = this; - return new Proxy(clone, { - get(target, prop, receiver) { - self.#readStorageFor(prop); - return Reflect.get(target, prop, receiver); - }, - has(target, prop) { - self.#readStorageFor(prop); - return prop in target; - }, - ownKeys(target) { - self.#collection.get(); - return Reflect.ownKeys(target); - }, - set(target, prop, value, receiver) { - let result = Reflect.set(target, prop, value, receiver); - self.#dirtyStorageFor(prop); - self.#dirtyCollection(); - return result; - }, - deleteProperty(target, prop) { - if (prop in target) { - delete target[prop]; - self.#dirtyStorageFor(prop); - self.#dirtyCollection(); - } - return true; - }, - getPrototypeOf() { - return SignalObjectImpl.prototype; - }, - }); - } - #readStorageFor(key) { - let storage = this.#storages.get(key); - if (storage === undefined) { - storage = createStorage(); - this.#storages.set(key, storage); - } - storage.get(); - } - #dirtyStorageFor(key) { - const storage = this.#storages.get(key); - if (storage) { - storage.set(null); - } - } - #dirtyCollection() { - this.#collection.set(null); - } + static fromEntries(entries) { + return new SignalObjectImpl(Object.fromEntries(entries)); + } + #storages = new Map(); + #collection = createStorage(); + constructor(obj = {}) { + let proto = Object.getPrototypeOf(obj); + let descs = Object.getOwnPropertyDescriptors(obj); + let clone = Object.create(proto); + for (let prop in descs) { + Object.defineProperty(clone, prop, descs[prop]); + } + let self = this; + return new Proxy(clone, { + get(target, prop, receiver) { + self.#readStorageFor(prop); + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + self.#readStorageFor(prop); + return prop in target; + }, + ownKeys(target) { + self.#collection.get(); + return Reflect.ownKeys(target); + }, + set(target, prop, value, receiver) { + let result = Reflect.set(target, prop, value, receiver); + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + return result; + }, + deleteProperty(target, prop) { + if (prop in target) { + delete target[prop]; + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + } + return true; + }, + getPrototypeOf() { + return SignalObjectImpl.prototype; + } + }); + } + #readStorageFor(key) { + let storage = this.#storages.get(key); + if (storage === undefined) { + storage = createStorage(); + this.#storages.set(key, storage); + } + storage.get(); + } + #dirtyStorageFor(key) { + const storage = this.#storages.get(key); + if (storage) { + storage.set(null); + } + } + #dirtyCollection() { + this.#collection.set(null); + } }; /** - * Create a reactive Object, backed by Signals, using a Proxy. - * This allows dynamic creation and deletion of signals using the object primitive - * APIs that most folks are familiar with -- the only difference is instantiation. - * ```js - * const obj = new SignalObject({ foo: 123 }); - * - * obj.foo // 123 - * obj.foo = 456 - * obj.foo // 456 - * obj.bar = 2 - * obj.bar // 2 - * ``` - */ +* Create a reactive Object, backed by Signals, using a Proxy. +* This allows dynamic creation and deletion of signals using the object primitive +* APIs that most folks are familiar with -- the only difference is instantiation. +* ```js +* const obj = new SignalObject({ foo: 123 }); +* +* obj.foo // 123 +* obj.foo = 456 +* obj.foo // 456 +* obj.bar = 2 +* obj.bar // 2 +* ``` +*/ const SignalObject = SignalObjectImpl; function signalObject(obj) { - return new SignalObject(obj); + return new SignalObject(obj); } var SignalSet = class { - collection = createStorage(); - storages = new Map(); - vals; - storageFor(key) { - const storages = this.storages; - let storage = storages.get(key); - if (storage === undefined) { - storage = createStorage(); - storages.set(key, storage); - } - return storage; - } - dirtyStorageFor(key) { - const storage = this.storages.get(key); - if (storage) { - storage.set(null); - } - } - constructor(existing) { - this.vals = new Set(existing); - } - has(value) { - this.storageFor(value).get(); - return this.vals.has(value); - } - entries() { - this.collection.get(); - return this.vals.entries(); - } - keys() { - this.collection.get(); - return this.vals.keys(); - } - values() { - this.collection.get(); - return this.vals.values(); - } - forEach(fn) { - this.collection.get(); - this.vals.forEach(fn); - } - get size() { - this.collection.get(); - return this.vals.size; - } - [Symbol.iterator]() { - this.collection.get(); - return this.vals[Symbol.iterator](); - } - get [Symbol.toStringTag]() { - return this.vals[Symbol.toStringTag]; - } - add(value) { - this.dirtyStorageFor(value); - this.collection.set(null); - this.vals.add(value); - return this; - } - delete(value) { - this.dirtyStorageFor(value); - this.collection.set(null); - return this.vals.delete(value); - } - clear() { - this.storages.forEach((s$9) => s$9.set(null)); - this.collection.set(null); - this.vals.clear(); - } + collection = createStorage(); + storages = new Map(); + vals; + storageFor(key) { + const storages = this.storages; + let storage = storages.get(key); + if (storage === undefined) { + storage = createStorage(); + storages.set(key, storage); + } + return storage; + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) { + storage.set(null); + } + } + constructor(existing) { + this.vals = new Set(existing); + } + has(value) { + this.storageFor(value).get(); + return this.vals.has(value); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + add(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + this.vals.add(value); + return this; + } + delete(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + return this.vals.delete(value); + } + clear() { + this.storages.forEach((s$9) => s$9.set(null)); + this.collection.set(null); + this.vals.clear(); + } }; Object.setPrototypeOf(SignalSet.prototype, Set.prototype); function create() { - return new A2uiMessageProcessor({ - arrayCtor: SignalArray, - mapCtor: SignalMap, - objCtor: SignalObject, - setCtor: SignalSet, - }); + return new A2uiMessageProcessor({ + arrayCtor: SignalArray, + mapCtor: SignalMap, + objCtor: SignalObject, + setCtor: SignalSet + }); } var server_to_client_with_standard_catalog_default = { - title: "A2UI Message Schema", - description: - "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", - type: "object", - additionalProperties: false, - properties: { - beginRendering: { - type: "object", - description: - "Signals the client to begin rendering a surface with a root component and specific styles.", - additionalProperties: false, - properties: { - surfaceId: { - type: "string", - description: "The unique identifier for the UI surface to be rendered.", - }, - root: { - type: "string", - description: "The ID of the root component to render.", - }, - styles: { - type: "object", - description: "Styling information for the UI.", - additionalProperties: false, - properties: { - font: { - type: "string", - description: "The primary font for the UI.", - }, - primaryColor: { - type: "string", - description: "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", - pattern: "^#[0-9a-fA-F]{6}$", - }, - }, - }, - }, - required: ["root", "surfaceId"], - }, - surfaceUpdate: { - type: "object", - description: "Updates a surface with a new set of components.", - additionalProperties: false, - properties: { - surfaceId: { - type: "string", - description: - "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown.", - }, - components: { - type: "array", - description: "A list containing all UI components for the surface.", - minItems: 1, - items: { - type: "object", - description: - "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", - additionalProperties: false, - properties: { - id: { - type: "string", - description: "The unique identifier for this component.", - }, - weight: { - type: "number", - description: - "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column.", - }, - component: { - type: "object", - description: - "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", - additionalProperties: false, - properties: { - Text: { - type: "object", - additionalProperties: false, - properties: { - text: { - type: "object", - description: - "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - usageHint: { - type: "string", - description: - "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", - enum: ["h1", "h2", "h3", "h4", "h5", "caption", "body"], - }, - }, - required: ["text"], - }, - Image: { - type: "object", - additionalProperties: false, - properties: { - url: { - type: "object", - description: - "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - fit: { - type: "string", - description: - "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", - enum: ["contain", "cover", "fill", "none", "scale-down"], - }, - usageHint: { - type: "string", - description: - "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", - enum: [ - "icon", - "avatar", - "smallFeature", - "mediumFeature", - "largeFeature", - "header", - ], - }, - }, - required: ["url"], - }, - Icon: { - type: "object", - additionalProperties: false, - properties: { - name: { - type: "object", - description: - "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", - additionalProperties: false, - properties: { - literalString: { - type: "string", - enum: [ - "accountCircle", - "add", - "arrowBack", - "arrowForward", - "attachFile", - "calendarToday", - "call", - "camera", - "check", - "close", - "delete", - "download", - "edit", - "event", - "error", - "favorite", - "favoriteOff", - "folder", - "help", - "home", - "info", - "locationOn", - "lock", - "lockOpen", - "mail", - "menu", - "moreVert", - "moreHoriz", - "notificationsOff", - "notifications", - "payment", - "person", - "phone", - "photo", - "print", - "refresh", - "search", - "send", - "settings", - "share", - "shoppingCart", - "star", - "starHalf", - "starOff", - "upload", - "visibility", - "visibilityOff", - "warning", - ], - }, - path: { type: "string" }, - }, - }, - }, - required: ["name"], - }, - Video: { - type: "object", - additionalProperties: false, - properties: { - url: { - type: "object", - description: - "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - }, - required: ["url"], - }, - AudioPlayer: { - type: "object", - additionalProperties: false, - properties: { - url: { - type: "object", - description: - "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - description: { - type: "object", - description: - "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - }, - required: ["url"], - }, - Row: { - type: "object", - additionalProperties: false, - properties: { - children: { - type: "object", - description: - "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - additionalProperties: false, - properties: { - explicitList: { - type: "array", - items: { type: "string" }, - }, - template: { - type: "object", - description: - "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - additionalProperties: false, - properties: { - componentId: { type: "string" }, - dataBinding: { type: "string" }, - }, - required: ["componentId", "dataBinding"], - }, - }, - }, - distribution: { - type: "string", - description: - "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", - enum: [ - "center", - "end", - "spaceAround", - "spaceBetween", - "spaceEvenly", - "start", - ], - }, - alignment: { - type: "string", - description: - "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - enum: ["start", "center", "end", "stretch"], - }, - }, - required: ["children"], - }, - Column: { - type: "object", - additionalProperties: false, - properties: { - children: { - type: "object", - description: - "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - additionalProperties: false, - properties: { - explicitList: { - type: "array", - items: { type: "string" }, - }, - template: { - type: "object", - description: - "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - additionalProperties: false, - properties: { - componentId: { type: "string" }, - dataBinding: { type: "string" }, - }, - required: ["componentId", "dataBinding"], - }, - }, - }, - distribution: { - type: "string", - description: - "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", - enum: [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly", - ], - }, - alignment: { - type: "string", - description: - "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - enum: ["center", "end", "start", "stretch"], - }, - }, - required: ["children"], - }, - List: { - type: "object", - additionalProperties: false, - properties: { - children: { - type: "object", - description: - "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", - additionalProperties: false, - properties: { - explicitList: { - type: "array", - items: { type: "string" }, - }, - template: { - type: "object", - description: - "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - additionalProperties: false, - properties: { - componentId: { type: "string" }, - dataBinding: { type: "string" }, - }, - required: ["componentId", "dataBinding"], - }, - }, - }, - direction: { - type: "string", - description: "The direction in which the list items are laid out.", - enum: ["vertical", "horizontal"], - }, - alignment: { - type: "string", - description: "Defines the alignment of children along the cross axis.", - enum: ["start", "center", "end", "stretch"], - }, - }, - required: ["children"], - }, - Card: { - type: "object", - additionalProperties: false, - properties: { - child: { - type: "string", - description: "The ID of the component to be rendered inside the card.", - }, - }, - required: ["child"], - }, - Tabs: { - type: "object", - additionalProperties: false, - properties: { - tabItems: { - type: "array", - description: - "An array of objects, where each object defines a tab with a title and a child component.", - items: { - type: "object", - additionalProperties: false, - properties: { - title: { - type: "object", - description: - "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - child: { type: "string" }, - }, - required: ["title", "child"], - }, - }, - }, - required: ["tabItems"], - }, - Divider: { - type: "object", - additionalProperties: false, - properties: { - axis: { - type: "string", - description: "The orientation of the divider.", - enum: ["horizontal", "vertical"], - }, - }, - }, - Modal: { - type: "object", - additionalProperties: false, - properties: { - entryPointChild: { - type: "string", - description: - "The ID of the component that opens the modal when interacted with (e.g., a button).", - }, - contentChild: { - type: "string", - description: "The ID of the component to be displayed inside the modal.", - }, - }, - required: ["entryPointChild", "contentChild"], - }, - Button: { - type: "object", - additionalProperties: false, - properties: { - child: { - type: "string", - description: - "The ID of the component to display in the button, typically a Text component.", - }, - primary: { - type: "boolean", - description: - "Indicates if this button should be styled as the primary action.", - }, - action: { - type: "object", - description: - "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", - additionalProperties: false, - properties: { - name: { type: "string" }, - context: { - type: "array", - items: { - type: "object", - additionalProperties: false, - properties: { - key: { type: "string" }, - value: { - type: "object", - description: - "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", - additionalProperties: false, - properties: { - path: { type: "string" }, - literalString: { type: "string" }, - literalNumber: { type: "number" }, - literalBoolean: { type: "boolean" }, - }, - }, - }, - required: ["key", "value"], - }, - }, - }, - required: ["name"], - }, - }, - required: ["child", "action"], - }, - CheckBox: { - type: "object", - additionalProperties: false, - properties: { - label: { - type: "object", - description: - "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - value: { - type: "object", - description: - "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", - additionalProperties: false, - properties: { - literalBoolean: { type: "boolean" }, - path: { type: "string" }, - }, - }, - }, - required: ["label", "value"], - }, - TextField: { - type: "object", - additionalProperties: false, - properties: { - label: { - type: "object", - description: - "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - text: { - type: "object", - description: - "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - textFieldType: { - type: "string", - description: "The type of input field to display.", - enum: ["date", "longText", "number", "shortText", "obscured"], - }, - validationRegexp: { - type: "string", - description: - "A regular expression used for client-side validation of the input.", - }, - }, - required: ["label"], - }, - DateTimeInput: { - type: "object", - additionalProperties: false, - properties: { - value: { - type: "object", - description: - "The selected date and/or time value. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - enableDate: { - type: "boolean", - description: "If true, allows the user to select a date.", - }, - enableTime: { - type: "boolean", - description: "If true, allows the user to select a time.", - }, - outputFormat: { - type: "string", - description: - "The desired format for the output string after a date or time is selected.", - }, - }, - required: ["value"], - }, - MultipleChoice: { - type: "object", - additionalProperties: false, - properties: { - selections: { - type: "object", - description: - "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", - additionalProperties: false, - properties: { - literalArray: { - type: "array", - items: { type: "string" }, - }, - path: { type: "string" }, - }, - }, - options: { - type: "array", - description: "An array of available options for the user to choose from.", - items: { - type: "object", - additionalProperties: false, - properties: { - label: { - type: "object", - description: - "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", - additionalProperties: false, - properties: { - literalString: { type: "string" }, - path: { type: "string" }, - }, - }, - value: { - type: "string", - description: - "The value to be associated with this option when selected.", - }, - }, - required: ["label", "value"], - }, - }, - maxAllowedSelections: { - type: "integer", - description: - "The maximum number of options that the user is allowed to select.", - }, - }, - required: ["selections", "options"], - }, - Slider: { - type: "object", - additionalProperties: false, - properties: { - value: { - type: "object", - description: - "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", - additionalProperties: false, - properties: { - literalNumber: { type: "number" }, - path: { type: "string" }, - }, - }, - minValue: { - type: "number", - description: "The minimum value of the slider.", - }, - maxValue: { - type: "number", - description: "The maximum value of the slider.", - }, - }, - required: ["value"], - }, - }, - }, - }, - required: ["id", "component"], - }, - }, - }, - required: ["surfaceId", "components"], - }, - dataModelUpdate: { - type: "object", - description: "Updates the data model for a surface.", - additionalProperties: false, - properties: { - surfaceId: { - type: "string", - description: - "The unique identifier for the UI surface this data model update applies to.", - }, - path: { - type: "string", - description: - "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced.", - }, - contents: { - type: "array", - description: - "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", - items: { - type: "object", - description: - "A single data entry. Exactly one 'value*' property should be provided alongside the key.", - additionalProperties: false, - properties: { - key: { - type: "string", - description: "The key for this data entry.", - }, - valueString: { type: "string" }, - valueNumber: { type: "number" }, - valueBoolean: { type: "boolean" }, - valueMap: { - description: "Represents a map as an adjacency list.", - type: "array", - items: { - type: "object", - description: - "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", - additionalProperties: false, - properties: { - key: { type: "string" }, - valueString: { type: "string" }, - valueNumber: { type: "number" }, - valueBoolean: { type: "boolean" }, - }, - required: ["key"], - }, - }, - }, - required: ["key"], - }, - }, - }, - required: ["contents", "surfaceId"], - }, - deleteSurface: { - type: "object", - description: "Signals the client to delete the surface identified by 'surfaceId'.", - additionalProperties: false, - properties: { - surfaceId: { - type: "string", - description: "The unique identifier for the UI surface to be deleted.", - }, - }, - required: ["surfaceId"], - }, - }, + title: "A2UI Message Schema", + description: "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", + type: "object", + additionalProperties: false, + properties: { + "beginRendering": { + "type": "object", + "description": "Signals the client to begin rendering a surface with a root component and specific styles.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "root": { + "type": "string", + "description": "The ID of the root component to render." + }, + "styles": { + "type": "object", + "description": "Styling information for the UI.", + "additionalProperties": false, + "properties": { + "font": { + "type": "string", + "description": "The primary font for the UI." + }, + "primaryColor": { + "type": "string", + "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + } + } + } + }, + "required": ["root", "surfaceId"] + }, + "surfaceUpdate": { + "type": "object", + "description": "Updates a surface with a new set of components.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." + }, + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "type": "object", + "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this component." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + }, + "component": { + "type": "object", + "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", + "additionalProperties": false, + "properties": { + "Text": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "object", + "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "usageHint": { + "type": "string", + "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": ["text"] + }, + "Image": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "object", + "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "fit": { + "type": "string", + "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", + "enum": [ + "contain", + "cover", + "fill", + "none", + "scale-down" + ] + }, + "usageHint": { + "type": "string", + "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", + "enum": [ + "icon", + "avatar", + "smallFeature", + "mediumFeature", + "largeFeature", + "header" + ] + } + }, + "required": ["url"] + }, + "Icon": { + "type": "object", + "additionalProperties": false, + "properties": { "name": { + "type": "object", + "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string", + "enum": [ + "accountCircle", + "add", + "arrowBack", + "arrowForward", + "attachFile", + "calendarToday", + "call", + "camera", + "check", + "close", + "delete", + "download", + "edit", + "event", + "error", + "favorite", + "favoriteOff", + "folder", + "help", + "home", + "info", + "locationOn", + "lock", + "lockOpen", + "mail", + "menu", + "moreVert", + "moreHoriz", + "notificationsOff", + "notifications", + "payment", + "person", + "phone", + "photo", + "print", + "refresh", + "search", + "send", + "settings", + "share", + "shoppingCart", + "star", + "starHalf", + "starOff", + "upload", + "visibility", + "visibilityOff", + "warning" + ] + }, + "path": { "type": "string" } + } + } }, + "required": ["name"] + }, + "Video": { + "type": "object", + "additionalProperties": false, + "properties": { "url": { + "type": "object", + "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + } }, + "required": ["url"] + }, + "AudioPlayer": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "object", + "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "description": { + "type": "object", + "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + } + }, + "required": ["url"] + }, + "Row": { + "type": "object", + "additionalProperties": false, + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "additionalProperties": false, + "properties": { + "explicitList": { + "type": "array", + "items": { "type": "string" } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "additionalProperties": false, + "properties": { + "componentId": { "type": "string" }, + "dataBinding": { "type": "string" } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + } + }, + "required": ["children"] + }, + "Column": { + "type": "object", + "additionalProperties": false, + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "additionalProperties": false, + "properties": { + "explicitList": { + "type": "array", + "items": { "type": "string" } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "additionalProperties": false, + "properties": { + "componentId": { "type": "string" }, + "dataBinding": { "type": "string" } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", + "enum": [ + "center", + "end", + "start", + "stretch" + ] + } + }, + "required": ["children"] + }, + "List": { + "type": "object", + "additionalProperties": false, + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "additionalProperties": false, + "properties": { + "explicitList": { + "type": "array", + "items": { "type": "string" } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "additionalProperties": false, + "properties": { + "componentId": { "type": "string" }, + "dataBinding": { "type": "string" } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "direction": { + "type": "string", + "description": "The direction in which the list items are laid out.", + "enum": ["vertical", "horizontal"] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis.", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + } + }, + "required": ["children"] + }, + "Card": { + "type": "object", + "additionalProperties": false, + "properties": { "child": { + "type": "string", + "description": "The ID of the component to be rendered inside the card." + } }, + "required": ["child"] + }, + "Tabs": { + "type": "object", + "additionalProperties": false, + "properties": { "tabItems": { + "type": "array", + "description": "An array of objects, where each object defines a tab with a title and a child component.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "object", + "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "child": { "type": "string" } + }, + "required": ["title", "child"] + } + } }, + "required": ["tabItems"] + }, + "Divider": { + "type": "object", + "additionalProperties": false, + "properties": { "axis": { + "type": "string", + "description": "The orientation of the divider.", + "enum": ["horizontal", "vertical"] + } } + }, + "Modal": { + "type": "object", + "additionalProperties": false, + "properties": { + "entryPointChild": { + "type": "string", + "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." + }, + "contentChild": { + "type": "string", + "description": "The ID of the component to be displayed inside the modal." + } + }, + "required": ["entryPointChild", "contentChild"] + }, + "Button": { + "type": "object", + "additionalProperties": false, + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to display in the button, typically a Text component." + }, + "primary": { + "type": "boolean", + "description": "Indicates if this button should be styled as the primary action." + }, + "action": { + "type": "object", + "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "context": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { "type": "string" }, + "value": { + "type": "object", + "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "literalString": { "type": "string" }, + "literalNumber": { "type": "number" }, + "literalBoolean": { "type": "boolean" } + } + } + }, + "required": ["key", "value"] + } + } + }, + "required": ["name"] + } + }, + "required": ["child", "action"] + }, + "CheckBox": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "object", + "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "value": { + "type": "object", + "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", + "additionalProperties": false, + "properties": { + "literalBoolean": { "type": "boolean" }, + "path": { "type": "string" } + } + } + }, + "required": ["label", "value"] + }, + "TextField": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "object", + "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "text": { + "type": "object", + "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "textFieldType": { + "type": "string", + "description": "The type of input field to display.", + "enum": [ + "date", + "longText", + "number", + "shortText", + "obscured" + ] + }, + "validationRegexp": { + "type": "string", + "description": "A regular expression used for client-side validation of the input." + } + }, + "required": ["label"] + }, + "DateTimeInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "type": "object", + "description": "The selected date and/or time value. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "enableDate": { + "type": "boolean", + "description": "If true, allows the user to select a date." + }, + "enableTime": { + "type": "boolean", + "description": "If true, allows the user to select a time." + }, + "outputFormat": { + "type": "string", + "description": "The desired format for the output string after a date or time is selected." + } + }, + "required": ["value"] + }, + "MultipleChoice": { + "type": "object", + "additionalProperties": false, + "properties": { + "selections": { + "type": "object", + "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", + "additionalProperties": false, + "properties": { + "literalArray": { + "type": "array", + "items": { "type": "string" } + }, + "path": { "type": "string" } + } + }, + "options": { + "type": "array", + "description": "An array of available options for the user to choose from.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "object", + "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "value": { + "type": "string", + "description": "The value to be associated with this option when selected." + } + }, + "required": ["label", "value"] + } + }, + "maxAllowedSelections": { + "type": "integer", + "description": "The maximum number of options that the user is allowed to select." + } + }, + "required": ["selections", "options"] + }, + "Slider": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "type": "object", + "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", + "additionalProperties": false, + "properties": { + "literalNumber": { "type": "number" }, + "path": { "type": "string" } + } + }, + "minValue": { + "type": "number", + "description": "The minimum value of the slider." + }, + "maxValue": { + "type": "number", + "description": "The maximum value of the slider." + } + }, + "required": ["value"] + } + } + } + }, + "required": ["id", "component"] + } + } + }, + "required": ["surfaceId", "components"] + }, + "dataModelUpdate": { + "type": "object", + "description": "Updates the data model for a surface.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." + }, + "contents": { + "type": "array", + "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", + "items": { + "type": "object", + "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "The key for this data entry." + }, + "valueString": { "type": "string" }, + "valueNumber": { "type": "number" }, + "valueBoolean": { "type": "boolean" }, + "valueMap": { + "description": "Represents a map as an adjacency list.", + "type": "array", + "items": { + "type": "object", + "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", + "additionalProperties": false, + "properties": { + "key": { "type": "string" }, + "valueString": { "type": "string" }, + "valueNumber": { "type": "number" }, + "valueBoolean": { "type": "boolean" } + }, + "required": ["key"] + } + } + }, + "required": ["key"] + } + } + }, + "required": ["contents", "surfaceId"] + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'.", + "additionalProperties": false, + "properties": { "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } }, + "required": ["surfaceId"] + } + } }; const Data = { - createSignalA2uiMessageProcessor: create, - A2uiMessageProcessor, - Guards: guards_exports, + createSignalA2uiMessageProcessor: create, + A2uiMessageProcessor, + Guards: guards_exports }; const Schemas = { A2UIClientEventMessage: server_to_client_with_standard_catalog_default }; /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const t$1 = (t$7) => (e$14, o$15) => { - void 0 !== o$15 - ? o$15.addInitializer(() => { - customElements.define(t$7, e$14); - }) - : customElements.define(t$7, e$14); + void 0 !== o$15 ? o$15.addInitializer(() => { + customElements.define(t$7, e$14); + }) : customElements.define(t$7, e$14); }; /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ const o$9 = { - attribute: !0, - type: String, - converter: u$3, - reflect: !1, - hasChanged: f$3, - }, - r$7 = (t$7 = o$9, e$14, r$12) => { - const { kind: n$13, metadata: i$10 } = r$12; - let s$9 = globalThis.litPropertyMetadata.get(i$10); - if ( - (void 0 === s$9 && globalThis.litPropertyMetadata.set(i$10, (s$9 = new Map())), - "setter" === n$13 && ((t$7 = Object.create(t$7)).wrapped = !0), - s$9.set(r$12.name, t$7), - "accessor" === n$13) - ) { - const { name: o$15 } = r$12; - return { - set(r$13) { - const n$14 = e$14.get.call(this); - (e$14.set.call(this, r$13), this.requestUpdate(o$15, n$14, t$7, !0, r$13)); - }, - init(e$15) { - return (void 0 !== e$15 && this.C(o$15, void 0, t$7, e$15), e$15); - }, - }; - } - if ("setter" === n$13) { - const { name: o$15 } = r$12; - return function (r$13) { - const n$14 = this[o$15]; - (e$14.call(this, r$13), this.requestUpdate(o$15, n$14, t$7, !0, r$13)); - }; - } - throw Error("Unsupported decorator location: " + n$13); - }; +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const o$9 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + hasChanged: f$3 +}, r$7 = (t$7 = o$9, e$14, r$12) => { + const { kind: n$13, metadata: i$10 } = r$12; + let s$9 = globalThis.litPropertyMetadata.get(i$10); + if (void 0 === s$9 && globalThis.litPropertyMetadata.set(i$10, s$9 = new Map()), "setter" === n$13 && ((t$7 = Object.create(t$7)).wrapped = !0), s$9.set(r$12.name, t$7), "accessor" === n$13) { + const { name: o$15 } = r$12; + return { + set(r$13) { + const n$14 = e$14.get.call(this); + e$14.set.call(this, r$13), this.requestUpdate(o$15, n$14, t$7, !0, r$13); + }, + init(e$15) { + return void 0 !== e$15 && this.C(o$15, void 0, t$7, e$15), e$15; + } + }; + } + if ("setter" === n$13) { + const { name: o$15 } = r$12; + return function(r$13) { + const n$14 = this[o$15]; + e$14.call(this, r$13), this.requestUpdate(o$15, n$14, t$7, !0, r$13); + }; + } + throw Error("Unsupported decorator location: " + n$13); +}; function n$6(t$7) { - return (e$14, o$15) => - "object" == typeof o$15 - ? r$7(t$7, e$14, o$15) - : ((t$8, e$15, o$16) => { - const r$12 = e$15.hasOwnProperty(o$16); - return ( - e$15.constructor.createProperty(o$16, t$8), - r$12 ? Object.getOwnPropertyDescriptor(e$15, o$16) : void 0 - ); - })(t$7, e$14, o$15); + return (e$14, o$15) => "object" == typeof o$15 ? r$7(t$7, e$14, o$15) : ((t$8, e$15, o$16) => { + const r$12 = e$15.hasOwnProperty(o$16); + return e$15.constructor.createProperty(o$16, t$8), r$12 ? Object.getOwnPropertyDescriptor(e$15, o$16) : void 0; + })(t$7, e$14, o$15); } /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ function r$6(r$12) { - return n$6({ - ...r$12, - state: !0, - attribute: !1, - }); +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function r$6(r$12) { + return n$6({ + ...r$12, + state: !0, + attribute: !1 + }); } /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function t(t) { - return (n$13, o$15) => { - const c$7 = "function" == typeof n$13 ? n$13 : n$13[o$15]; - Object.assign(c$7, t); - }; + return (n$13, o$15) => { + const c$7 = "function" == typeof n$13 ? n$13 : n$13[o$15]; + Object.assign(c$7, t); + }; } /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ -const e$6 = (e$14, t$7, c$7) => ( - (c$7.configurable = !0), - (c$7.enumerable = !0), - Reflect.decorate && "object" != typeof t$7 && Object.defineProperty(e$14, t$7, c$7), - c$7 -); +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ +const e$6 = (e$14, t$7, c$7) => (c$7.configurable = !0, c$7.enumerable = !0, Reflect.decorate && "object" != typeof t$7 && Object.defineProperty(e$14, t$7, c$7), c$7); /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ function e$5(e$14, r$12) { - return (n$13, s$9, i$10) => { - const o$15 = (t$7) => t$7.renderRoot?.querySelector(e$14) ?? null; - if (r$12) { - const { get: e$15, set: r$13 } = - "object" == typeof s$9 - ? n$13 - : (i$10 ?? - (() => { - const t$7 = Symbol(); - return { - get() { - return this[t$7]; - }, - set(e$16) { - this[t$7] = e$16; - }, - }; - })()); - return e$6(n$13, s$9, { - get() { - let t$7 = e$15.call(this); - return ( - void 0 === t$7 && - ((t$7 = o$15(this)), (null !== t$7 || this.hasUpdated) && r$13.call(this, t$7)), - t$7 - ); - }, - }); - } - return e$6(n$13, s$9, { - get() { - return o$15(this); - }, - }); - }; +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function e$5(e$14, r$12) { + return (n$13, s$9, i$10) => { + const o$15 = (t$7) => t$7.renderRoot?.querySelector(e$14) ?? null; + if (r$12) { + const { get: e$15, set: r$13 } = "object" == typeof s$9 ? n$13 : i$10 ?? (() => { + const t$7 = Symbol(); + return { + get() { + return this[t$7]; + }, + set(e$16) { + this[t$7] = e$16; + } + }; + })(); + return e$6(n$13, s$9, { get() { + let t$7 = e$15.call(this); + return void 0 === t$7 && (t$7 = o$15(this), (null !== t$7 || this.hasUpdated) && r$13.call(this, t$7)), t$7; + } }); + } + return e$6(n$13, s$9, { get() { + return o$15(this); + } }); + }; } /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ let e$4; function r$5(r$12) { - return (n$13, o$15) => - e$6(n$13, o$15, { - get() { - return (this.renderRoot ?? (e$4 ??= document.createDocumentFragment())).querySelectorAll( - r$12, - ); - }, - }); + return (n$13, o$15) => e$6(n$13, o$15, { get() { + return (this.renderRoot ?? (e$4 ??= document.createDocumentFragment())).querySelectorAll(r$12); + } }); } /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function r$4(r$12) { - return (n$13, e$14) => - e$6(n$13, e$14, { - async get() { - return (await this.updateComplete, this.renderRoot?.querySelector(r$12) ?? null); - }, - }); + return (n$13, e$14) => e$6(n$13, e$14, { async get() { + return await this.updateComplete, this.renderRoot?.querySelector(r$12) ?? null; + } }); } /** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ function o$8(o$15) { - return (e$14, n$13) => { - const { slot: r$12, selector: s$9 } = o$15 ?? {}, - c$7 = "slot" + (r$12 ? `[name=${r$12}]` : ":not([name])"); - return e$6(e$14, n$13, { - get() { - const t$7 = this.renderRoot?.querySelector(c$7), - e$15 = t$7?.assignedElements(o$15) ?? []; - return void 0 === s$9 ? e$15 : e$15.filter((t$8) => t$8.matches(s$9)); - }, - }); - }; +* @license +* Copyright 2021 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function o$8(o$15) { + return (e$14, n$13) => { + const { slot: r$12, selector: s$9 } = o$15 ?? {}, c$7 = "slot" + (r$12 ? `[name=${r$12}]` : ":not([name])"); + return e$6(e$14, n$13, { get() { + const t$7 = this.renderRoot?.querySelector(c$7), e$15 = t$7?.assignedElements(o$15) ?? []; + return void 0 === s$9 ? e$15 : e$15.filter((t$8) => t$8.matches(s$9)); + } }); + }; } /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ function n$5(n$13) { - return (o$15, r$12) => { - const { slot: e$14 } = n$13 ?? {}, - s$9 = "slot" + (e$14 ? `[name=${e$14}]` : ":not([name])"); - return e$6(o$15, r$12, { - get() { - const t$7 = this.renderRoot?.querySelector(s$9); - return t$7?.assignedNodes(n$13) ?? []; - }, - }); - }; +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function n$5(n$13) { + return (o$15, r$12) => { + const { slot: e$14 } = n$13 ?? {}, s$9 = "slot" + (e$14 ? `[name=${e$14}]` : ":not([name])"); + return e$6(o$15, r$12, { get() { + const t$7 = this.renderRoot?.querySelector(s$9); + return t$7?.assignedNodes(n$13) ?? []; + } }); + }; } /** - * @license - * Copyright 2023 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ let i$2 = !1; +* @license +* Copyright 2023 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ let i$2 = !1; const s$1 = new Signal.subtle.Watcher(() => { - i$2 || - ((i$2 = !0), - queueMicrotask(() => { - i$2 = !1; - for (const t$7 of s$1.getPending()) t$7.get(); - s$1.watch(); - })); - }), - h$3 = Symbol("SignalWatcherBrand"), - e$3 = new FinalizationRegistry((i$10) => { - i$10.unwatch(...Signal.subtle.introspectSources(i$10)); - }), - n$4 = new WeakMap(); + i$2 || (i$2 = !0, queueMicrotask(() => { + i$2 = !1; + for (const t$7 of s$1.getPending()) t$7.get(); + s$1.watch(); + })); +}), h$3 = Symbol("SignalWatcherBrand"), e$3 = new FinalizationRegistry((i$10) => { + i$10.unwatch(...Signal.subtle.introspectSources(i$10)); +}), n$4 = new WeakMap(); function o$7(i$10) { - return !0 === i$10[h$3] - ? (console.warn("SignalWatcher should not be applied to the same class more than once."), i$10) - : class extends i$10 { - constructor() { - (super(...arguments), - (this._$St = new Map()), - (this._$So = new Signal.State(0)), - (this._$Si = !1)); - } - _$Sl() { - var t$7, i$11; - const s$9 = [], - h$7 = []; - this._$St.forEach((t$8, i$12) => { - ((null == t$8 ? void 0 : t$8.beforeUpdate) ? s$9 : h$7).push(i$12); - }); - const e$14 = - null === (t$7 = this.h) || void 0 === t$7 - ? void 0 - : t$7.getPending().filter((t$8) => t$8 !== this._$Su && !this._$St.has(t$8)); - (s$9.forEach((t$8) => t$8.get()), - null === (i$11 = this._$Su) || void 0 === i$11 || i$11.get(), - e$14.forEach((t$8) => t$8.get()), - h$7.forEach((t$8) => t$8.get())); - } - _$Sv() { - this.isUpdatePending || - queueMicrotask(() => { - this.isUpdatePending || this._$Sl(); - }); - } - _$S_() { - if (void 0 !== this.h) return; - this._$Su = new Signal.Computed(() => { - (this._$So.get(), super.performUpdate()); - }); - const i$11 = (this.h = new Signal.subtle.Watcher(function () { - const t$7 = n$4.get(this); - void 0 !== t$7 && - (!1 === t$7._$Si && - (new Set(this.getPending()).has(t$7._$Su) ? t$7.requestUpdate() : t$7._$Sv()), - this.watch()); - })); - (n$4.set(i$11, this), - e$3.register(this, i$11), - i$11.watch(this._$Su), - i$11.watch(...Array.from(this._$St).map(([t$7]) => t$7))); - } - _$Sp() { - if (void 0 === this.h) return; - let i$11 = !1; - (this.h.unwatch( - ...Signal.subtle.introspectSources(this.h).filter((t$7) => { - var s$9; - const h$7 = - !0 !== - (null === (s$9 = this._$St.get(t$7)) || void 0 === s$9 - ? void 0 - : s$9.manualDispose); - return (h$7 && this._$St.delete(t$7), i$11 || (i$11 = !h$7), h$7); - }), - ), - i$11 || ((this._$Su = void 0), (this.h = void 0), this._$St.clear())); - } - updateEffect(i$11, s$9) { - var h$7; - this._$S_(); - const e$14 = new Signal.Computed(() => { - i$11(); - }); - return ( - this.h.watch(e$14), - this._$St.set(e$14, s$9), - null !== (h$7 = null == s$9 ? void 0 : s$9.beforeUpdate) && void 0 !== h$7 && h$7 - ? Signal.subtle.untrack(() => e$14.get()) - : this.updateComplete.then(() => Signal.subtle.untrack(() => e$14.get())), - () => { - (this._$St.delete(e$14), - this.h.unwatch(e$14), - !1 === this.isConnected && this._$Sp()); - } - ); - } - performUpdate() { - this.isUpdatePending && - (this._$S_(), - (this._$Si = !0), - this._$So.set(this._$So.get() + 1), - (this._$Si = !1), - this._$Sl()); - } - connectedCallback() { - (super.connectedCallback(), this.requestUpdate()); - } - disconnectedCallback() { - (super.disconnectedCallback(), - queueMicrotask(() => { - !1 === this.isConnected && this._$Sp(); - })); - } - }; + return !0 === i$10[h$3] ? (console.warn("SignalWatcher should not be applied to the same class more than once."), i$10) : class extends i$10 { + constructor() { + super(...arguments), this._$St = new Map(), this._$So = new Signal.State(0), this._$Si = !1; + } + _$Sl() { + var t$7, i$11; + const s$9 = [], h$7 = []; + this._$St.forEach((t$8, i$12) => { + ((null == t$8 ? void 0 : t$8.beforeUpdate) ? s$9 : h$7).push(i$12); + }); + const e$14 = null === (t$7 = this.h) || void 0 === t$7 ? void 0 : t$7.getPending().filter((t$8) => t$8 !== this._$Su && !this._$St.has(t$8)); + s$9.forEach((t$8) => t$8.get()), null === (i$11 = this._$Su) || void 0 === i$11 || i$11.get(), e$14.forEach((t$8) => t$8.get()), h$7.forEach((t$8) => t$8.get()); + } + _$Sv() { + this.isUpdatePending || queueMicrotask(() => { + this.isUpdatePending || this._$Sl(); + }); + } + _$S_() { + if (void 0 !== this.h) return; + this._$Su = new Signal.Computed(() => { + this._$So.get(), super.performUpdate(); + }); + const i$11 = this.h = new Signal.subtle.Watcher(function() { + const t$7 = n$4.get(this); + void 0 !== t$7 && (!1 === t$7._$Si && (new Set(this.getPending()).has(t$7._$Su) ? t$7.requestUpdate() : t$7._$Sv()), this.watch()); + }); + n$4.set(i$11, this), e$3.register(this, i$11), i$11.watch(this._$Su), i$11.watch(...Array.from(this._$St).map(([t$7]) => t$7)); + } + _$Sp() { + if (void 0 === this.h) return; + let i$11 = !1; + this.h.unwatch(...Signal.subtle.introspectSources(this.h).filter((t$7) => { + var s$9; + const h$7 = !0 !== (null === (s$9 = this._$St.get(t$7)) || void 0 === s$9 ? void 0 : s$9.manualDispose); + return h$7 && this._$St.delete(t$7), i$11 || (i$11 = !h$7), h$7; + })), i$11 || (this._$Su = void 0, this.h = void 0, this._$St.clear()); + } + updateEffect(i$11, s$9) { + var h$7; + this._$S_(); + const e$14 = new Signal.Computed(() => { + i$11(); + }); + return this.h.watch(e$14), this._$St.set(e$14, s$9), null !== (h$7 = null == s$9 ? void 0 : s$9.beforeUpdate) && void 0 !== h$7 && h$7 ? Signal.subtle.untrack(() => e$14.get()) : this.updateComplete.then(() => Signal.subtle.untrack(() => e$14.get())), () => { + this._$St.delete(e$14), this.h.unwatch(e$14), !1 === this.isConnected && this._$Sp(); + }; + } + performUpdate() { + this.isUpdatePending && (this._$S_(), this._$Si = !0, this._$So.set(this._$So.get() + 1), this._$Si = !1, this._$Sl()); + } + connectedCallback() { + super.connectedCallback(), this.requestUpdate(); + } + disconnectedCallback() { + super.disconnectedCallback(), queueMicrotask(() => { + !1 === this.isConnected && this._$Sp(); + }); + } + }; } /** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ const s = (i$10, t$7) => { - const e$14 = i$10._$AN; - if (void 0 === e$14) return !1; - for (const i$11 of e$14) (i$11._$AO?.(t$7, !1), s(i$11, t$7)); - return !0; - }, - o$6 = (i$10) => { - let t$7, e$14; - do { - if (void 0 === (t$7 = i$10._$AM)) break; - ((e$14 = t$7._$AN), e$14.delete(i$10), (i$10 = t$7)); - } while (0 === e$14?.size); - }, - r$3 = (i$10) => { - for (let t$7; (t$7 = i$10._$AM); i$10 = t$7) { - let e$14 = t$7._$AN; - if (void 0 === e$14) t$7._$AN = e$14 = new Set(); - else if (e$14.has(i$10)) break; - (e$14.add(i$10), c(t$7)); - } - }; +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const s = (i$10, t$7) => { + const e$14 = i$10._$AN; + if (void 0 === e$14) return !1; + for (const i$11 of e$14) i$11._$AO?.(t$7, !1), s(i$11, t$7); + return !0; +}, o$6 = (i$10) => { + let t$7, e$14; + do { + if (void 0 === (t$7 = i$10._$AM)) break; + e$14 = t$7._$AN, e$14.delete(i$10), i$10 = t$7; + } while (0 === e$14?.size); +}, r$3 = (i$10) => { + for (let t$7; t$7 = i$10._$AM; i$10 = t$7) { + let e$14 = t$7._$AN; + if (void 0 === e$14) t$7._$AN = e$14 = new Set(); + else if (e$14.has(i$10)) break; + e$14.add(i$10), c(t$7); + } +}; function h$2(i$10) { - void 0 !== this._$AN ? (o$6(this), (this._$AM = i$10), r$3(this)) : (this._$AM = i$10); + void 0 !== this._$AN ? (o$6(this), this._$AM = i$10, r$3(this)) : this._$AM = i$10; } function n$3(i$10, t$7 = !1, e$14 = 0) { - const r$12 = this._$AH, - h$7 = this._$AN; - if (void 0 !== h$7 && 0 !== h$7.size) - if (t$7) - if (Array.isArray(r$12)) - for (let i$11 = e$14; i$11 < r$12.length; i$11++) (s(r$12[i$11], !1), o$6(r$12[i$11])); - else null != r$12 && (s(r$12, !1), o$6(r$12)); - else s(this, i$10); + const r$12 = this._$AH, h$7 = this._$AN; + if (void 0 !== h$7 && 0 !== h$7.size) if (t$7) if (Array.isArray(r$12)) for (let i$11 = e$14; i$11 < r$12.length; i$11++) s(r$12[i$11], !1), o$6(r$12[i$11]); + else null != r$12 && (s(r$12, !1), o$6(r$12)); + else s(this, i$10); } const c = (i$10) => { - i$10.type == t$4.CHILD && ((i$10._$AP ??= n$3), (i$10._$AQ ??= h$2)); + i$10.type == t$4.CHILD && (i$10._$AP ??= n$3, i$10._$AQ ??= h$2); }; var f = class extends i$5 { - constructor() { - (super(...arguments), (this._$AN = void 0)); - } - _$AT(i$10, t$7, e$14) { - (super._$AT(i$10, t$7, e$14), r$3(this), (this.isConnected = i$10._$AU)); - } - _$AO(i$10, t$7 = !0) { - (i$10 !== this.isConnected && - ((this.isConnected = i$10), i$10 ? this.reconnected?.() : this.disconnected?.()), - t$7 && (s(this, i$10), o$6(this))); - } - setValue(t$7) { - if (r$8(this._$Ct)) this._$Ct._$AI(t$7, this); - else { - const i$10 = [...this._$Ct._$AH]; - ((i$10[this._$Ci] = t$7), this._$Ct._$AI(i$10, this, 0)); - } - } - disconnected() {} - reconnected() {} + constructor() { + super(...arguments), this._$AN = void 0; + } + _$AT(i$10, t$7, e$14) { + super._$AT(i$10, t$7, e$14), r$3(this), this.isConnected = i$10._$AU; + } + _$AO(i$10, t$7 = !0) { + i$10 !== this.isConnected && (this.isConnected = i$10, i$10 ? this.reconnected?.() : this.disconnected?.()), t$7 && (s(this, i$10), o$6(this)); + } + setValue(t$7) { + if (r$8(this._$Ct)) this._$Ct._$AI(t$7, this); + else { + const i$10 = [...this._$Ct._$AH]; + i$10[this._$Ci] = t$7, this._$Ct._$AI(i$10, this, 0); + } + } + disconnected() {} + reconnected() {} }; /** - * @license - * Copyright 2023 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2023 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ let o$5 = !1; const n$2 = new Signal.subtle.Watcher(async () => { - o$5 || - ((o$5 = !0), - queueMicrotask(() => { - o$5 = !1; - for (const i$10 of n$2.getPending()) i$10.get(); - n$2.watch(); - })); + o$5 || (o$5 = !0, queueMicrotask(() => { + o$5 = !1; + for (const i$10 of n$2.getPending()) i$10.get(); + n$2.watch(); + })); }); var r$2 = class extends f { - _$S_() { - var i$10, t$7; - void 0 === this._$Sm && - ((this._$Sj = new Signal.Computed(() => { - var i$11; - const t$8 = null === (i$11 = this._$SW) || void 0 === i$11 ? void 0 : i$11.get(); - return (this.setValue(t$8), t$8); - })), - (this._$Sm = - null !== (t$7 = null === (i$10 = this._$Sk) || void 0 === i$10 ? void 0 : i$10.h) && - void 0 !== t$7 - ? t$7 - : n$2), - this._$Sm.watch(this._$Sj), - Signal.subtle.untrack(() => { - var i$11; - return null === (i$11 = this._$Sj) || void 0 === i$11 ? void 0 : i$11.get(); - })); - } - _$Sp() { - void 0 !== this._$Sm && (this._$Sm.unwatch(this._$SW), (this._$Sm = void 0)); - } - render(i$10) { - return Signal.subtle.untrack(() => i$10.get()); - } - update(i$10, [t$7]) { - var o$15, n$13; - return ( - (null !== (o$15 = this._$Sk) && void 0 !== o$15) || - (this._$Sk = null === (n$13 = i$10.options) || void 0 === n$13 ? void 0 : n$13.host), - t$7 !== this._$SW && void 0 !== this._$SW && this._$Sp(), - (this._$SW = t$7), - this._$S_(), - Signal.subtle.untrack(() => this._$SW.get()) - ); - } - disconnected() { - this._$Sp(); - } - reconnected() { - this._$S_(); - } + _$S_() { + var i$10, t$7; + void 0 === this._$Sm && (this._$Sj = new Signal.Computed(() => { + var i$11; + const t$8 = null === (i$11 = this._$SW) || void 0 === i$11 ? void 0 : i$11.get(); + return this.setValue(t$8), t$8; + }), this._$Sm = null !== (t$7 = null === (i$10 = this._$Sk) || void 0 === i$10 ? void 0 : i$10.h) && void 0 !== t$7 ? t$7 : n$2, this._$Sm.watch(this._$Sj), Signal.subtle.untrack(() => { + var i$11; + return null === (i$11 = this._$Sj) || void 0 === i$11 ? void 0 : i$11.get(); + })); + } + _$Sp() { + void 0 !== this._$Sm && (this._$Sm.unwatch(this._$SW), this._$Sm = void 0); + } + render(i$10) { + return Signal.subtle.untrack(() => i$10.get()); + } + update(i$10, [t$7]) { + var o$15, n$13; + return null !== (o$15 = this._$Sk) && void 0 !== o$15 || (this._$Sk = null === (n$13 = i$10.options) || void 0 === n$13 ? void 0 : n$13.host), t$7 !== this._$SW && void 0 !== this._$SW && this._$Sp(), this._$SW = t$7, this._$S_(), Signal.subtle.untrack(() => this._$SW.get()); + } + disconnected() { + this._$Sp(); + } + reconnected() { + this._$S_(); + } }; const h$1 = e$10(r$2); /** - * @license - * Copyright 2023 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ const m = - (o$15) => - (t$7, ...m) => - o$15( - t$7, - ...m.map((o$16) => - o$16 instanceof Signal.State || o$16 instanceof Signal.Computed ? h$1(o$16) : o$16, - ), - ), - l$1 = m(b), - r$1 = m(w); +* @license +* Copyright 2023 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const m = (o$15) => (t$7, ...m) => o$15(t$7, ...m.map((o$16) => o$16 instanceof Signal.State || o$16 instanceof Signal.Computed ? h$1(o$16) : o$16)), l$1 = m(b), r$1 = m(w); /** - * @license - * Copyright 2023 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ const l = Signal.State, - o$4 = Signal.Computed, - r = (l, o$15) => new Signal.State(l, o$15), - i$1 = (l, o$15) => new Signal.Computed(l, o$15); +* @license +* Copyright 2023 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const l = Signal.State, o$4 = Signal.Computed, r = (l, o$15) => new Signal.State(l, o$15), i$1 = (l, o$15) => new Signal.Computed(l, o$15); /** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ +* @license +* Copyright 2021 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function* o$3(o$15, f$4) { - if (void 0 !== o$15) { - let i$10 = 0; - for (const t$7 of o$15) yield f$4(t$7, i$10++); - } + if (void 0 !== o$15) { + let i$10 = 0; + for (const t$7 of o$15) yield f$4(t$7, i$10++); + } } let pending = false; let watcher = new Signal.subtle.Watcher(() => { - if (!pending) { - pending = true; - queueMicrotask(() => { - pending = false; - flushPending(); - }); - } + if (!pending) { + pending = true; + queueMicrotask(() => { + pending = false; + flushPending(); + }); + } }); function flushPending() { - for (const signal of watcher.getPending()) { - signal.get(); - } - watcher.watch(); + for (const signal of watcher.getPending()) { + signal.get(); + } + watcher.watch(); } /** - * ⚠️ WARNING: Nothing unwatches ⚠️ - * This will produce a memory leak. - */ +* ⚠️ WARNING: Nothing unwatches ⚠️ +* This will produce a memory leak. +*/ function effect(cb) { - let c$7 = new Signal.Computed(() => cb()); - watcher.watch(c$7); - c$7.get(); - return () => { - watcher.unwatch(c$7); - }; + let c$7 = new Signal.Computed(() => cb()); + watcher.watch(c$7); + c$7.get(); + return () => { + watcher.unwatch(c$7); + }; } const themeContext = n$7("A2UITheme"); @@ -5037,469 +4287,371 @@ const themeContext = n$7("A2UITheme"); const structuralStyles = r$11(structuralStyles$1); var ComponentRegistry = class { - constructor() { - this.registry = new Map(); - } - register(typeName, constructor, tagName) { - if (!/^[a-zA-Z0-9]+$/.test(typeName)) { - throw new Error(`[Registry] Invalid typeName '${typeName}'. Must be alphanumeric.`); - } - this.registry.set(typeName, constructor); - const actualTagName = tagName || `a2ui-custom-${typeName.toLowerCase()}`; - const existingName = customElements.getName(constructor); - if (existingName) { - if (existingName !== actualTagName) { - throw new Error( - `Component ${typeName} is already registered as ${existingName}, but requested as ${actualTagName}.`, - ); - } - return; - } - if (!customElements.get(actualTagName)) { - customElements.define(actualTagName, constructor); - } - } - get(typeName) { - return this.registry.get(typeName); - } + constructor() { + this.registry = new Map(); + } + register(typeName, constructor, tagName) { + if (!/^[a-zA-Z0-9]+$/.test(typeName)) { + throw new Error(`[Registry] Invalid typeName '${typeName}'. Must be alphanumeric.`); + } + this.registry.set(typeName, constructor); + const actualTagName = tagName || `a2ui-custom-${typeName.toLowerCase()}`; + const existingName = customElements.getName(constructor); + if (existingName) { + if (existingName !== actualTagName) { + throw new Error(`Component ${typeName} is already registered as ${existingName}, but requested as ${actualTagName}.`); + } + return; + } + if (!customElements.get(actualTagName)) { + customElements.define(actualTagName, constructor); + } + } + get(typeName) { + return this.registry.get(typeName); + } }; const componentRegistry = new ComponentRegistry(); -var __runInitializers$19 = - (void 0 && (void 0).__runInitializers) || - function (thisArg, initializers, value) { - var useValue = arguments.length > 2; - for (var i$10 = 0; i$10 < initializers.length; i$10++) { - value = useValue ? initializers[i$10].call(thisArg, value) : initializers[i$10].call(thisArg); - } - return useValue ? value : void 0; - }; -var __esDecorate$19 = - (void 0 && (void 0).__esDecorate) || - function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { - function accept(f$4) { - if (f$4 !== void 0 && typeof f$4 !== "function") throw new TypeError("Function expected"); - return f$4; - } - var kind = contextIn.kind, - key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; - var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; - var descriptor = - descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); - var _$1, - done = false; - for (var i$10 = decorators.length - 1; i$10 >= 0; i$10--) { - var context = {}; - for (var p$3 in contextIn) context[p$3] = p$3 === "access" ? {} : contextIn[p$3]; - for (var p$3 in contextIn.access) context.access[p$3] = contextIn.access[p$3]; - context.addInitializer = function (f$4) { - if (done) throw new TypeError("Cannot add initializers after decoration has completed"); - extraInitializers.push(accept(f$4 || null)); - }; - var result = (0, decorators[i$10])( - kind === "accessor" - ? { - get: descriptor.get, - set: descriptor.set, - } - : descriptor[key], - context, - ); - if (kind === "accessor") { - if (result === void 0) continue; - if (result === null || typeof result !== "object") throw new TypeError("Object expected"); - if ((_$1 = accept(result.get))) descriptor.get = _$1; - if ((_$1 = accept(result.set))) descriptor.set = _$1; - if ((_$1 = accept(result.init))) initializers.unshift(_$1); - } else if ((_$1 = accept(result))) { - if (kind === "field") initializers.unshift(_$1); - else descriptor[key] = _$1; - } - } - if (target) Object.defineProperty(target, contextIn.name, descriptor); - done = true; - }; +var __runInitializers$19 = void 0 && (void 0).__runInitializers || function(thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i$10 = 0; i$10 < initializers.length; i$10++) { + value = useValue ? initializers[i$10].call(thisArg, value) : initializers[i$10].call(thisArg); + } + return useValue ? value : void 0; +}; +var __esDecorate$19 = void 0 && (void 0).__esDecorate || function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { + function accept(f$4) { + if (f$4 !== void 0 && typeof f$4 !== "function") throw new TypeError("Function expected"); + return f$4; + } + var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; + var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _$1, done = false; + for (var i$10 = decorators.length - 1; i$10 >= 0; i$10--) { + var context = {}; + for (var p$3 in contextIn) context[p$3] = p$3 === "access" ? {} : contextIn[p$3]; + for (var p$3 in contextIn.access) context.access[p$3] = contextIn.access[p$3]; + context.addInitializer = function(f$4) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f$4 || null)); + }; + var result = (0, decorators[i$10])(kind === "accessor" ? { + get: descriptor.get, + set: descriptor.set + } : descriptor[key], context); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if (_$1 = accept(result.get)) descriptor.get = _$1; + if (_$1 = accept(result.set)) descriptor.set = _$1; + if (_$1 = accept(result.init)) initializers.unshift(_$1); + } else if (_$1 = accept(result)) { + if (kind === "field") initializers.unshift(_$1); + else descriptor[key] = _$1; + } + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; let Root = (() => { - let _classDecorators = [t$1("a2ui-root")]; - let _classDescriptor; - let _classExtraInitializers = []; - let _classThis; - let _classSuper = o$7(i$6); - let _instanceExtraInitializers = []; - let _surfaceId_decorators; - let _surfaceId_initializers = []; - let _surfaceId_extraInitializers = []; - let _component_decorators; - let _component_initializers = []; - let _component_extraInitializers = []; - let _theme_decorators; - let _theme_initializers = []; - let _theme_extraInitializers = []; - let _childComponents_decorators; - let _childComponents_initializers = []; - let _childComponents_extraInitializers = []; - let _processor_decorators; - let _processor_initializers = []; - let _processor_extraInitializers = []; - let _dataContextPath_decorators; - let _dataContextPath_initializers = []; - let _dataContextPath_extraInitializers = []; - let _enableCustomElements_decorators; - let _enableCustomElements_initializers = []; - let _enableCustomElements_extraInitializers = []; - let _set_weight_decorators; - var Root = class extends _classSuper { - static { - _classThis = this; - } - static { - const _metadata = - typeof Symbol === "function" && Symbol.metadata - ? Object.create(_classSuper[Symbol.metadata] ?? null) - : void 0; - _surfaceId_decorators = [n$6()]; - _component_decorators = [n$6()]; - _theme_decorators = [c$1({ context: themeContext })]; - _childComponents_decorators = [n$6({ attribute: false })]; - _processor_decorators = [n$6({ attribute: false })]; - _dataContextPath_decorators = [n$6()]; - _enableCustomElements_decorators = [n$6()]; - _set_weight_decorators = [n$6()]; - __esDecorate$19( - this, - null, - _surfaceId_decorators, - { - kind: "accessor", - name: "surfaceId", - static: false, - private: false, - access: { - has: (obj) => "surfaceId" in obj, - get: (obj) => obj.surfaceId, - set: (obj, value) => { - obj.surfaceId = value; - }, - }, - metadata: _metadata, - }, - _surfaceId_initializers, - _surfaceId_extraInitializers, - ); - __esDecorate$19( - this, - null, - _component_decorators, - { - kind: "accessor", - name: "component", - static: false, - private: false, - access: { - has: (obj) => "component" in obj, - get: (obj) => obj.component, - set: (obj, value) => { - obj.component = value; - }, - }, - metadata: _metadata, - }, - _component_initializers, - _component_extraInitializers, - ); - __esDecorate$19( - this, - null, - _theme_decorators, - { - kind: "accessor", - name: "theme", - static: false, - private: false, - access: { - has: (obj) => "theme" in obj, - get: (obj) => obj.theme, - set: (obj, value) => { - obj.theme = value; - }, - }, - metadata: _metadata, - }, - _theme_initializers, - _theme_extraInitializers, - ); - __esDecorate$19( - this, - null, - _childComponents_decorators, - { - kind: "accessor", - name: "childComponents", - static: false, - private: false, - access: { - has: (obj) => "childComponents" in obj, - get: (obj) => obj.childComponents, - set: (obj, value) => { - obj.childComponents = value; - }, - }, - metadata: _metadata, - }, - _childComponents_initializers, - _childComponents_extraInitializers, - ); - __esDecorate$19( - this, - null, - _processor_decorators, - { - kind: "accessor", - name: "processor", - static: false, - private: false, - access: { - has: (obj) => "processor" in obj, - get: (obj) => obj.processor, - set: (obj, value) => { - obj.processor = value; - }, - }, - metadata: _metadata, - }, - _processor_initializers, - _processor_extraInitializers, - ); - __esDecorate$19( - this, - null, - _dataContextPath_decorators, - { - kind: "accessor", - name: "dataContextPath", - static: false, - private: false, - access: { - has: (obj) => "dataContextPath" in obj, - get: (obj) => obj.dataContextPath, - set: (obj, value) => { - obj.dataContextPath = value; - }, - }, - metadata: _metadata, - }, - _dataContextPath_initializers, - _dataContextPath_extraInitializers, - ); - __esDecorate$19( - this, - null, - _enableCustomElements_decorators, - { - kind: "accessor", - name: "enableCustomElements", - static: false, - private: false, - access: { - has: (obj) => "enableCustomElements" in obj, - get: (obj) => obj.enableCustomElements, - set: (obj, value) => { - obj.enableCustomElements = value; - }, - }, - metadata: _metadata, - }, - _enableCustomElements_initializers, - _enableCustomElements_extraInitializers, - ); - __esDecorate$19( - this, - null, - _set_weight_decorators, - { - kind: "setter", - name: "weight", - static: false, - private: false, - access: { - has: (obj) => "weight" in obj, - set: (obj, value) => { - obj.weight = value; - }, - }, - metadata: _metadata, - }, - null, - _instanceExtraInitializers, - ); - __esDecorate$19( - null, - (_classDescriptor = { value: _classThis }), - _classDecorators, - { - kind: "class", - name: _classThis.name, - metadata: _metadata, - }, - null, - _classExtraInitializers, - ); - Root = _classThis = _classDescriptor.value; - if (_metadata) - Object.defineProperty(_classThis, Symbol.metadata, { - enumerable: true, - configurable: true, - writable: true, - value: _metadata, - }); - } - #surfaceId_accessor_storage = - (__runInitializers$19(this, _instanceExtraInitializers), - __runInitializers$19(this, _surfaceId_initializers, null)); - get surfaceId() { - return this.#surfaceId_accessor_storage; - } - set surfaceId(value) { - this.#surfaceId_accessor_storage = value; - } - #component_accessor_storage = - (__runInitializers$19(this, _surfaceId_extraInitializers), - __runInitializers$19(this, _component_initializers, null)); - get component() { - return this.#component_accessor_storage; - } - set component(value) { - this.#component_accessor_storage = value; - } - #theme_accessor_storage = - (__runInitializers$19(this, _component_extraInitializers), - __runInitializers$19(this, _theme_initializers, void 0)); - get theme() { - return this.#theme_accessor_storage; - } - set theme(value) { - this.#theme_accessor_storage = value; - } - #childComponents_accessor_storage = - (__runInitializers$19(this, _theme_extraInitializers), - __runInitializers$19(this, _childComponents_initializers, null)); - get childComponents() { - return this.#childComponents_accessor_storage; - } - set childComponents(value) { - this.#childComponents_accessor_storage = value; - } - #processor_accessor_storage = - (__runInitializers$19(this, _childComponents_extraInitializers), - __runInitializers$19(this, _processor_initializers, null)); - get processor() { - return this.#processor_accessor_storage; - } - set processor(value) { - this.#processor_accessor_storage = value; - } - #dataContextPath_accessor_storage = - (__runInitializers$19(this, _processor_extraInitializers), - __runInitializers$19(this, _dataContextPath_initializers, "")); - get dataContextPath() { - return this.#dataContextPath_accessor_storage; - } - set dataContextPath(value) { - this.#dataContextPath_accessor_storage = value; - } - #enableCustomElements_accessor_storage = - (__runInitializers$19(this, _dataContextPath_extraInitializers), - __runInitializers$19(this, _enableCustomElements_initializers, false)); - get enableCustomElements() { - return this.#enableCustomElements_accessor_storage; - } - set enableCustomElements(value) { - this.#enableCustomElements_accessor_storage = value; - } - set weight(weight) { - this.#weight = weight; - this.style.setProperty("--weight", `${weight}`); - } - get weight() { - return this.#weight; - } - #weight = (__runInitializers$19(this, _enableCustomElements_extraInitializers), 1); - static { - this.styles = [ - structuralStyles, - i$9` + let _classDecorators = [t$1("a2ui-root")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = o$7(i$6); + let _instanceExtraInitializers = []; + let _surfaceId_decorators; + let _surfaceId_initializers = []; + let _surfaceId_extraInitializers = []; + let _component_decorators; + let _component_initializers = []; + let _component_extraInitializers = []; + let _theme_decorators; + let _theme_initializers = []; + let _theme_extraInitializers = []; + let _childComponents_decorators; + let _childComponents_initializers = []; + let _childComponents_extraInitializers = []; + let _processor_decorators; + let _processor_initializers = []; + let _processor_extraInitializers = []; + let _dataContextPath_decorators; + let _dataContextPath_initializers = []; + let _dataContextPath_extraInitializers = []; + let _enableCustomElements_decorators; + let _enableCustomElements_initializers = []; + let _enableCustomElements_extraInitializers = []; + let _set_weight_decorators; + var Root = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0; + _surfaceId_decorators = [n$6()]; + _component_decorators = [n$6()]; + _theme_decorators = [c$1({ context: themeContext })]; + _childComponents_decorators = [n$6({ attribute: false })]; + _processor_decorators = [n$6({ attribute: false })]; + _dataContextPath_decorators = [n$6()]; + _enableCustomElements_decorators = [n$6()]; + _set_weight_decorators = [n$6()]; + __esDecorate$19(this, null, _surfaceId_decorators, { + kind: "accessor", + name: "surfaceId", + static: false, + private: false, + access: { + has: (obj) => "surfaceId" in obj, + get: (obj) => obj.surfaceId, + set: (obj, value) => { + obj.surfaceId = value; + } + }, + metadata: _metadata + }, _surfaceId_initializers, _surfaceId_extraInitializers); + __esDecorate$19(this, null, _component_decorators, { + kind: "accessor", + name: "component", + static: false, + private: false, + access: { + has: (obj) => "component" in obj, + get: (obj) => obj.component, + set: (obj, value) => { + obj.component = value; + } + }, + metadata: _metadata + }, _component_initializers, _component_extraInitializers); + __esDecorate$19(this, null, _theme_decorators, { + kind: "accessor", + name: "theme", + static: false, + private: false, + access: { + has: (obj) => "theme" in obj, + get: (obj) => obj.theme, + set: (obj, value) => { + obj.theme = value; + } + }, + metadata: _metadata + }, _theme_initializers, _theme_extraInitializers); + __esDecorate$19(this, null, _childComponents_decorators, { + kind: "accessor", + name: "childComponents", + static: false, + private: false, + access: { + has: (obj) => "childComponents" in obj, + get: (obj) => obj.childComponents, + set: (obj, value) => { + obj.childComponents = value; + } + }, + metadata: _metadata + }, _childComponents_initializers, _childComponents_extraInitializers); + __esDecorate$19(this, null, _processor_decorators, { + kind: "accessor", + name: "processor", + static: false, + private: false, + access: { + has: (obj) => "processor" in obj, + get: (obj) => obj.processor, + set: (obj, value) => { + obj.processor = value; + } + }, + metadata: _metadata + }, _processor_initializers, _processor_extraInitializers); + __esDecorate$19(this, null, _dataContextPath_decorators, { + kind: "accessor", + name: "dataContextPath", + static: false, + private: false, + access: { + has: (obj) => "dataContextPath" in obj, + get: (obj) => obj.dataContextPath, + set: (obj, value) => { + obj.dataContextPath = value; + } + }, + metadata: _metadata + }, _dataContextPath_initializers, _dataContextPath_extraInitializers); + __esDecorate$19(this, null, _enableCustomElements_decorators, { + kind: "accessor", + name: "enableCustomElements", + static: false, + private: false, + access: { + has: (obj) => "enableCustomElements" in obj, + get: (obj) => obj.enableCustomElements, + set: (obj, value) => { + obj.enableCustomElements = value; + } + }, + metadata: _metadata + }, _enableCustomElements_initializers, _enableCustomElements_extraInitializers); + __esDecorate$19(this, null, _set_weight_decorators, { + kind: "setter", + name: "weight", + static: false, + private: false, + access: { + has: (obj) => "weight" in obj, + set: (obj, value) => { + obj.weight = value; + } + }, + metadata: _metadata + }, null, _instanceExtraInitializers); + __esDecorate$19(null, _classDescriptor = { value: _classThis }, _classDecorators, { + kind: "class", + name: _classThis.name, + metadata: _metadata + }, null, _classExtraInitializers); + Root = _classThis = _classDescriptor.value; + if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata + }); + } + #surfaceId_accessor_storage = (__runInitializers$19(this, _instanceExtraInitializers), __runInitializers$19(this, _surfaceId_initializers, null)); + get surfaceId() { + return this.#surfaceId_accessor_storage; + } + set surfaceId(value) { + this.#surfaceId_accessor_storage = value; + } + #component_accessor_storage = (__runInitializers$19(this, _surfaceId_extraInitializers), __runInitializers$19(this, _component_initializers, null)); + get component() { + return this.#component_accessor_storage; + } + set component(value) { + this.#component_accessor_storage = value; + } + #theme_accessor_storage = (__runInitializers$19(this, _component_extraInitializers), __runInitializers$19(this, _theme_initializers, void 0)); + get theme() { + return this.#theme_accessor_storage; + } + set theme(value) { + this.#theme_accessor_storage = value; + } + #childComponents_accessor_storage = (__runInitializers$19(this, _theme_extraInitializers), __runInitializers$19(this, _childComponents_initializers, null)); + get childComponents() { + return this.#childComponents_accessor_storage; + } + set childComponents(value) { + this.#childComponents_accessor_storage = value; + } + #processor_accessor_storage = (__runInitializers$19(this, _childComponents_extraInitializers), __runInitializers$19(this, _processor_initializers, null)); + get processor() { + return this.#processor_accessor_storage; + } + set processor(value) { + this.#processor_accessor_storage = value; + } + #dataContextPath_accessor_storage = (__runInitializers$19(this, _processor_extraInitializers), __runInitializers$19(this, _dataContextPath_initializers, "")); + get dataContextPath() { + return this.#dataContextPath_accessor_storage; + } + set dataContextPath(value) { + this.#dataContextPath_accessor_storage = value; + } + #enableCustomElements_accessor_storage = (__runInitializers$19(this, _dataContextPath_extraInitializers), __runInitializers$19(this, _enableCustomElements_initializers, false)); + get enableCustomElements() { + return this.#enableCustomElements_accessor_storage; + } + set enableCustomElements(value) { + this.#enableCustomElements_accessor_storage = value; + } + set weight(weight) { + this.#weight = weight; + this.style.setProperty("--weight", `${weight}`); + } + get weight() { + return this.#weight; + } + #weight = (__runInitializers$19(this, _enableCustomElements_extraInitializers), 1); + static { + this.styles = [structuralStyles, i$9` :host { display: flex; flex-direction: column; gap: 8px; max-height: 80%; } - `, - ]; - } - /** - * Holds the cleanup function for our effect. - * We need this to stop the effect when the component is disconnected. - */ - #lightDomEffectDisposer = null; - willUpdate(changedProperties) { - if (changedProperties.has("childComponents")) { - if (this.#lightDomEffectDisposer) { - this.#lightDomEffectDisposer(); - } - this.#lightDomEffectDisposer = effect(() => { - const allChildren = this.childComponents ?? null; - const lightDomTemplate = this.renderComponentTree(allChildren); - D(lightDomTemplate, this, { host: this }); - }); - } - } - /** - * Clean up the effect when the component is removed from the DOM. - */ - disconnectedCallback() { - super.disconnectedCallback(); - if (this.#lightDomEffectDisposer) { - this.#lightDomEffectDisposer(); - } - } - /** - * Turns the SignalMap into a renderable TemplateResult for Lit. - */ - renderComponentTree(components) { - if (!components) { - return A; - } - if (!Array.isArray(components)) { - return A; - } - return b` ${o$3(components, (component) => { - if (this.enableCustomElements) { - const registeredCtor = componentRegistry.get(component.type); - const elCtor = registeredCtor || customElements.get(component.type); - if (elCtor) { - const node = component; - const el = new elCtor(); - el.id = node.id; - if (node.slotName) { - el.slot = node.slotName; - } - el.component = node; - el.weight = node.weight ?? "initial"; - el.processor = this.processor; - el.surfaceId = this.surfaceId; - el.dataContextPath = node.dataContextPath ?? "/"; - for (const [prop, val] of Object.entries(component.properties)) { - el[prop] = val; - } - return b`${el}`; - } - } - switch (component.type) { - case "List": { - const node = component; - const childComponents = node.properties.children; - return b` { + const allChildren = this.childComponents ?? null; + const lightDomTemplate = this.renderComponentTree(allChildren); + D(lightDomTemplate, this, { host: this }); + }); + } + } + /** + * Clean up the effect when the component is removed from the DOM. + */ + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#lightDomEffectDisposer) { + this.#lightDomEffectDisposer(); + } + } + /** + * Turns the SignalMap into a renderable TemplateResult for Lit. + */ + renderComponentTree(components) { + if (!components) { + return A; + } + if (!Array.isArray(components)) { + return A; + } + return b` ${o$3(components, (component) => { + if (this.enableCustomElements) { + const registeredCtor = componentRegistry.get(component.type); + const elCtor = registeredCtor || customElements.get(component.type); + if (elCtor) { + const node = component; + const el = new elCtor(); + el.id = node.id; + if (node.slotName) { + el.slot = node.slotName; + } + el.component = node; + el.weight = node.weight ?? "initial"; + el.processor = this.processor; + el.surfaceId = this.surfaceId; + el.dataContextPath = node.dataContextPath ?? "/"; + for (const [prop, val] of Object.entries(component.properties)) { + el[prop] = val; + } + return b`${el}`; + } + } + switch (component.type) { + case "List": { + const node = component; + const childComponents = node.properties.children; + return b` { .childComponents=${childComponents} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Card": { - const node = component; - let childComponents = node.properties.children; - if (!childComponents && node.properties.child) { - childComponents = [node.properties.child]; - } - return b` { .dataContextPath=${node.dataContextPath ?? ""} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Column": { - const node = component; - return b` { .distribution=${node.properties.distribution ?? "start"} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Row": { - const node = component; - return b` { .distribution=${node.properties.distribution ?? "start"} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Image": { - const node = component; - return b` { .fit=${node.properties.fit} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Icon": { - const node = component; - return b` { .dataContextPath=${node.dataContextPath ?? ""} .enableCustomElements=${this.enableCustomElements} >`; - } - case "AudioPlayer": { - const node = component; - return b` { .dataContextPath=${node.dataContextPath ?? ""} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Button": { - const node = component; - return b` { .childComponents=${[node.properties.child]} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Text": { - const node = component; - return b` { .usageHint=${node.properties.usageHint} .enableCustomElements=${this.enableCustomElements} >`; - } - case "CheckBox": { - const node = component; - return b` { .value=${node.properties.value} .enableCustomElements=${this.enableCustomElements} >`; - } - case "DateTimeInput": { - const node = component; - return b` { .value=${node.properties.value} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Divider": { - const node = component; - return b` { .color=${node.properties.color} .enableCustomElements=${this.enableCustomElements} >`; - } - case "MultipleChoice": { - const node = component; - return b` { .selections=${node.properties.selections} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Slider": { - const node = component; - return b` { .maxValue=${node.properties.maxValue} .enableCustomElements=${this.enableCustomElements} >`; - } - case "TextField": { - const node = component; - return b` { .validationRegexp=${node.properties.validationRegexp} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Video": { - const node = component; - return b` { .url=${node.properties.url} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Tabs": { - const node = component; - const titles = []; - const childComponents = []; - if (node.properties.tabItems) { - for (const item of node.properties.tabItems) { - titles.push(item.title); - childComponents.push(item.child); - } - } - return b` { .childComponents=${childComponents} .enableCustomElements=${this.enableCustomElements} >`; - } - case "Modal": { - const node = component; - const childComponents = [node.properties.entryPointChild, node.properties.contentChild]; - node.properties.entryPointChild.slotName = "entry"; - return b` { .childComponents=${childComponents} .enableCustomElements=${this.enableCustomElements} >`; - } - default: { - return this.renderCustomComponent(component); - } - } - })}`; - } - renderCustomComponent(component) { - if (!this.enableCustomElements) { - return; - } - const node = component; - const registeredCtor = componentRegistry.get(component.type); - const elCtor = registeredCtor || customElements.get(component.type); - if (!elCtor) { - return b`Unknown element ${component.type}`; - } - const el = new elCtor(); - el.id = node.id; - if (node.slotName) { - el.slot = node.slotName; - } - el.component = node; - el.weight = node.weight ?? "initial"; - el.processor = this.processor; - el.surfaceId = this.surfaceId; - el.dataContextPath = node.dataContextPath ?? "/"; - for (const [prop, val] of Object.entries(component.properties)) { - el[prop] = val; - } - return b`${el}`; - } - render() { - return b``; - } - static { - __runInitializers$19(_classThis, _classExtraInitializers); - } - }; - return (Root = _classThis); + } + default: { + return this.renderCustomComponent(component); + } + } + })}`; + } + renderCustomComponent(component) { + if (!this.enableCustomElements) { + return; + } + const node = component; + const registeredCtor = componentRegistry.get(component.type); + const elCtor = registeredCtor || customElements.get(component.type); + if (!elCtor) { + return b`Unknown element ${component.type}`; + } + const el = new elCtor(); + el.id = node.id; + if (node.slotName) { + el.slot = node.slotName; + } + el.component = node; + el.weight = node.weight ?? "initial"; + el.processor = this.processor; + el.surfaceId = this.surfaceId; + el.dataContextPath = node.dataContextPath ?? "/"; + for (const [prop, val] of Object.entries(component.properties)) { + el[prop] = val; + } + return b`${el}`; + } + render() { + return b``; + } + static { + __runInitializers$19(_classThis, _classExtraInitializers); + } + }; + return Root = _classThis; })(); /** - * @license - * Copyright 2018 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ const e$2 = e$10( - class extends i$5 { - constructor(t$7) { - if ( - (super(t$7), t$7.type !== t$4.ATTRIBUTE || "class" !== t$7.name || t$7.strings?.length > 2) - ) - throw Error( - "`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.", - ); - } - render(t$7) { - return ( - " " + - Object.keys(t$7) - .filter((s$9) => t$7[s$9]) - .join(" ") + - " " - ); - } - update(s$9, [i$10]) { - if (void 0 === this.st) { - ((this.st = new Set()), - void 0 !== s$9.strings && - (this.nt = new Set( - s$9.strings - .join(" ") - .split(/\s/) - .filter((t$7) => "" !== t$7), - ))); - for (const t$7 in i$10) i$10[t$7] && !this.nt?.has(t$7) && this.st.add(t$7); - return this.render(i$10); - } - const r$12 = s$9.element.classList; - for (const t$7 of this.st) t$7 in i$10 || (r$12.remove(t$7), this.st.delete(t$7)); - for (const t$7 in i$10) { - const s$10 = !!i$10[t$7]; - s$10 === this.st.has(t$7) || - this.nt?.has(t$7) || - (s$10 ? (r$12.add(t$7), this.st.add(t$7)) : (r$12.remove(t$7), this.st.delete(t$7))); - } - return E; - } - }, -); +* @license +* Copyright 2018 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const e$2 = e$10(class extends i$5 { + constructor(t$7) { + if (super(t$7), t$7.type !== t$4.ATTRIBUTE || "class" !== t$7.name || t$7.strings?.length > 2) throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute."); + } + render(t$7) { + return " " + Object.keys(t$7).filter((s$9) => t$7[s$9]).join(" ") + " "; + } + update(s$9, [i$10]) { + if (void 0 === this.st) { + this.st = new Set(), void 0 !== s$9.strings && (this.nt = new Set(s$9.strings.join(" ").split(/\s/).filter((t$7) => "" !== t$7))); + for (const t$7 in i$10) i$10[t$7] && !this.nt?.has(t$7) && this.st.add(t$7); + return this.render(i$10); + } + const r$12 = s$9.element.classList; + for (const t$7 of this.st) t$7 in i$10 || (r$12.remove(t$7), this.st.delete(t$7)); + for (const t$7 in i$10) { + const s$10 = !!i$10[t$7]; + s$10 === this.st.has(t$7) || this.nt?.has(t$7) || (s$10 ? (r$12.add(t$7), this.st.add(t$7)) : (r$12.remove(t$7), this.st.delete(t$7))); + } + return E; + } +}); /** - * @license - * Copyright 2018 Google LLC - * SPDX-License-Identifier: BSD-3-Clause - */ const n$1 = "important", - i = " !" + n$1, - o$2 = e$10( - class extends i$5 { - constructor(t$7) { - if ( - (super(t$7), - t$7.type !== t$4.ATTRIBUTE || "style" !== t$7.name || t$7.strings?.length > 2) - ) - throw Error( - "The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.", - ); - } - render(t$7) { - return Object.keys(t$7).reduce((e$14, r$12) => { - const s$9 = t$7[r$12]; - return null == s$9 - ? e$14 - : e$14 + - `${(r$12 = r$12.includes("-") ? r$12 : r$12.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g, "-$&").toLowerCase())}:${s$9};`; - }, ""); - } - update(e$14, [r$12]) { - const { style: s$9 } = e$14.element; - if (void 0 === this.ft) return ((this.ft = new Set(Object.keys(r$12))), this.render(r$12)); - for (const t$7 of this.ft) - null == r$12[t$7] && - (this.ft.delete(t$7), t$7.includes("-") ? s$9.removeProperty(t$7) : (s$9[t$7] = null)); - for (const t$7 in r$12) { - const e$15 = r$12[t$7]; - if (null != e$15) { - this.ft.add(t$7); - const r$13 = "string" == typeof e$15 && e$15.endsWith(i); - t$7.includes("-") || r$13 - ? s$9.setProperty(t$7, r$13 ? e$15.slice(0, -11) : e$15, r$13 ? n$1 : "") - : (s$9[t$7] = e$15); - } - } - return E; - } - }, - ); +* @license +* Copyright 2018 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const n$1 = "important", i = " !" + n$1, o$2 = e$10(class extends i$5 { + constructor(t$7) { + if (super(t$7), t$7.type !== t$4.ATTRIBUTE || "style" !== t$7.name || t$7.strings?.length > 2) throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute."); + } + render(t$7) { + return Object.keys(t$7).reduce((e$14, r$12) => { + const s$9 = t$7[r$12]; + return null == s$9 ? e$14 : e$14 + `${r$12 = r$12.includes("-") ? r$12 : r$12.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g, "-$&").toLowerCase()}:${s$9};`; + }, ""); + } + update(e$14, [r$12]) { + const { style: s$9 } = e$14.element; + if (void 0 === this.ft) return this.ft = new Set(Object.keys(r$12)), this.render(r$12); + for (const t$7 of this.ft) null == r$12[t$7] && (this.ft.delete(t$7), t$7.includes("-") ? s$9.removeProperty(t$7) : s$9[t$7] = null); + for (const t$7 in r$12) { + const e$15 = r$12[t$7]; + if (null != e$15) { + this.ft.add(t$7); + const r$13 = "string" == typeof e$15 && e$15.endsWith(i); + t$7.includes("-") || r$13 ? s$9.setProperty(t$7, r$13 ? e$15.slice(0, -11) : e$15, r$13 ? n$1 : "") : s$9[t$7] = e$15; + } + } + return E; + } +}); -var __esDecorate$18 = - (void 0 && (void 0).__esDecorate) || - function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { - function accept(f$4) { - if (f$4 !== void 0 && typeof f$4 !== "function") throw new TypeError("Function expected"); - return f$4; - } - var kind = contextIn.kind, - key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; - var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; - var descriptor = - descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); - var _$1, - done = false; - for (var i$10 = decorators.length - 1; i$10 >= 0; i$10--) { - var context = {}; - for (var p$3 in contextIn) context[p$3] = p$3 === "access" ? {} : contextIn[p$3]; - for (var p$3 in contextIn.access) context.access[p$3] = contextIn.access[p$3]; - context.addInitializer = function (f$4) { - if (done) throw new TypeError("Cannot add initializers after decoration has completed"); - extraInitializers.push(accept(f$4 || null)); - }; - var result = (0, decorators[i$10])( - kind === "accessor" - ? { - get: descriptor.get, - set: descriptor.set, - } - : descriptor[key], - context, - ); - if (kind === "accessor") { - if (result === void 0) continue; - if (result === null || typeof result !== "object") throw new TypeError("Object expected"); - if ((_$1 = accept(result.get))) descriptor.get = _$1; - if ((_$1 = accept(result.set))) descriptor.set = _$1; - if ((_$1 = accept(result.init))) initializers.unshift(_$1); - } else if ((_$1 = accept(result))) { - if (kind === "field") initializers.unshift(_$1); - else descriptor[key] = _$1; - } - } - if (target) Object.defineProperty(target, contextIn.name, descriptor); - done = true; - }; -var __runInitializers$18 = - (void 0 && (void 0).__runInitializers) || - function (thisArg, initializers, value) { - var useValue = arguments.length > 2; - for (var i$10 = 0; i$10 < initializers.length; i$10++) { - value = useValue ? initializers[i$10].call(thisArg, value) : initializers[i$10].call(thisArg); - } - return useValue ? value : void 0; - }; +var __esDecorate$18 = void 0 && (void 0).__esDecorate || function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { + function accept(f$4) { + if (f$4 !== void 0 && typeof f$4 !== "function") throw new TypeError("Function expected"); + return f$4; + } + var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; + var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _$1, done = false; + for (var i$10 = decorators.length - 1; i$10 >= 0; i$10--) { + var context = {}; + for (var p$3 in contextIn) context[p$3] = p$3 === "access" ? {} : contextIn[p$3]; + for (var p$3 in contextIn.access) context.access[p$3] = contextIn.access[p$3]; + context.addInitializer = function(f$4) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f$4 || null)); + }; + var result = (0, decorators[i$10])(kind === "accessor" ? { + get: descriptor.get, + set: descriptor.set + } : descriptor[key], context); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if (_$1 = accept(result.get)) descriptor.get = _$1; + if (_$1 = accept(result.set)) descriptor.set = _$1; + if (_$1 = accept(result.init)) initializers.unshift(_$1); + } else if (_$1 = accept(result)) { + if (kind === "field") initializers.unshift(_$1); + else descriptor[key] = _$1; + } + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +var __runInitializers$18 = void 0 && (void 0).__runInitializers || function(thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i$10 = 0; i$10 < initializers.length; i$10++) { + value = useValue ? initializers[i$10].call(thisArg, value) : initializers[i$10].call(thisArg); + } + return useValue ? value : void 0; +}; let Audio = (() => { - let _classDecorators = [t$1("a2ui-audioplayer")]; - let _classDescriptor; - let _classExtraInitializers = []; - let _classThis; - let _classSuper = Root; - let _url_decorators; - let _url_initializers = []; - let _url_extraInitializers = []; - var Audio = class extends _classSuper { - static { - _classThis = this; - } - static { - const _metadata = - typeof Symbol === "function" && Symbol.metadata - ? Object.create(_classSuper[Symbol.metadata] ?? null) - : void 0; - _url_decorators = [n$6()]; - __esDecorate$18( - this, - null, - _url_decorators, - { - kind: "accessor", - name: "url", - static: false, - private: false, - access: { - has: (obj) => "url" in obj, - get: (obj) => obj.url, - set: (obj, value) => { - obj.url = value; - }, - }, - metadata: _metadata, - }, - _url_initializers, - _url_extraInitializers, - ); - __esDecorate$18( - null, - (_classDescriptor = { value: _classThis }), - _classDecorators, - { - kind: "class", - name: _classThis.name, - metadata: _metadata, - }, - null, - _classExtraInitializers, - ); - Audio = _classThis = _classDescriptor.value; - if (_metadata) - Object.defineProperty(_classThis, Symbol.metadata, { - enumerable: true, - configurable: true, - writable: true, - value: _metadata, - }); - } - #url_accessor_storage = __runInitializers$18(this, _url_initializers, null); - get url() { - return this.#url_accessor_storage; - } - set url(value) { - this.#url_accessor_storage = value; - } - static { - this.styles = [ - structuralStyles, - i$9` + let _classDecorators = [t$1("a2ui-audioplayer")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = Root; + let _url_decorators; + let _url_initializers = []; + let _url_extraInitializers = []; + var Audio = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0; + _url_decorators = [n$6()]; + __esDecorate$18(this, null, _url_decorators, { + kind: "accessor", + name: "url", + static: false, + private: false, + access: { + has: (obj) => "url" in obj, + get: (obj) => obj.url, + set: (obj, value) => { + obj.url = value; + } + }, + metadata: _metadata + }, _url_initializers, _url_extraInitializers); + __esDecorate$18(null, _classDescriptor = { value: _classThis }, _classDecorators, { + kind: "class", + name: _classThis.name, + metadata: _metadata + }, null, _classExtraInitializers); + Audio = _classThis = _classDescriptor.value; + if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata + }); + } + #url_accessor_storage = __runInitializers$18(this, _url_initializers, null); + get url() { + return this.#url_accessor_storage; + } + set url(value) { + this.#url_accessor_storage = value; + } + static { + this.styles = [structuralStyles, i$9` * { box-sizing: border-box; } @@ -6064,317 +5145,254 @@ let Audio = (() => { display: block; width: 100%; } - `, - ]; - } - #renderAudio() { - if (!this.url) { - return A; - } - if (this.url && typeof this.url === "object") { - if ("literalString" in this.url) { - return b`