---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index a6623a632..24a6d1369 100644
--- a/README.md
+++ b/README.md
@@ -446,6 +446,8 @@ AI/vibe-coded PRs welcome! ๐ค
Thanks to all clawtributors:
+Special thanks to [@carlulsoe](https://github.com/carlulsoe) for the Vertex AI JSON Schema fixes.
+
From 187f3ed480c4a23e7dafd6304fae045d0972ea8a Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 18:24:09 +0000
Subject: [PATCH 046/115] docs: tidy contributors section
---
README.md | 2 --
1 file changed, 2 deletions(-)
diff --git a/README.md b/README.md
index 24a6d1369..a6623a632 100644
--- a/README.md
+++ b/README.md
@@ -446,8 +446,6 @@ AI/vibe-coded PRs welcome! ๐ค
Thanks to all clawtributors:
-Special thanks to [@carlulsoe](https://github.com/carlulsoe) for the Vertex AI JSON Schema fixes.
-
From 7f4248e5e0bb805f3b885b53f0d22b1a2eb0eac8 Mon Sep 17 00:00:00 2001
From: Emanuel Stadler <9994339+emanuelst@users.noreply.github.com>
Date: Wed, 7 Jan 2026 18:39:35 +0100
Subject: [PATCH 047/115] Cron: clamp timer to avoid TimeoutOverflowWarning
---
src/cron/service.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/cron/service.ts b/src/cron/service.ts
index cdda4bc51..a75cc9ae6 100644
--- a/src/cron/service.ts
+++ b/src/cron/service.ts
@@ -44,6 +44,7 @@ export type CronServiceDeps = {
};
const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
+const MAX_TIMEOUT_MS = 2 ** 31 - 1;
function normalizeRequiredName(raw: unknown) {
if (typeof raw !== "string") throw new Error("cron job name is required");
@@ -393,11 +394,13 @@ export class CronService {
const nextAt = this.nextWakeAtMs();
if (!nextAt) return;
const delay = Math.max(nextAt - this.deps.nowMs(), 0);
+ // Avoid TimeoutOverflowWarning when a job is far in the future.
+ const clampedDelay = Math.min(delay, MAX_TIMEOUT_MS);
this.timer = setTimeout(() => {
void this.onTimer().catch((err) => {
this.deps.log.error({ err: String(err) }, "cron: timer tick failed");
});
- }, delay);
+ }, clampedDelay);
this.timer.unref?.();
}
From 422477499c662780c1e7ba559098dbedc554ae83 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 19:24:19 +0100
Subject: [PATCH 048/115] fix: clamp cron timer delay
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1efcf6e42..e59158d7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380.
+- Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412.
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
From 7e5cef29a0349af740ce0d2a392e0dbccb45a87d Mon Sep 17 00:00:00 2001
From: Shadow
Date: Wed, 7 Jan 2026 09:02:20 -0600
Subject: [PATCH 049/115] Threads: add Slack/Discord thread sessions
---
src/auto-reply/reply.ts | 17 ++--
src/auto-reply/reply/commands.ts | 4 +-
src/auto-reply/reply/session.test.ts | 82 ++++++++++++++++
src/auto-reply/reply/session.ts | 66 +++++++++++++
src/auto-reply/status.ts | 9 +-
src/auto-reply/templating.ts | 3 +
src/commands/agent.ts | 4 +-
src/config/sessions.ts | 13 +++
src/discord/monitor.tool-result.test.ts | 103 ++++++++++++++++++++
src/discord/monitor.ts | 122 ++++++++++++++++++++++--
src/gateway/server-bridge.ts | 4 +-
src/gateway/server-methods/chat.ts | 4 +-
src/gateway/server-methods/sessions.ts | 2 +
src/gateway/server.chat.test.ts | 61 ++++++++++++
src/gateway/session-utils.ts | 9 +-
src/slack/monitor.tool-result.test.ts | 109 +++++++++++++++++++++
src/slack/monitor.ts | 85 ++++++++++++++++-
17 files changed, 670 insertions(+), 27 deletions(-)
create mode 100644 src/auto-reply/reply/session.test.ts
diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts
index f4cc9b445..aaa9efd76 100644
--- a/src/auto-reply/reply.ts
+++ b/src/auto-reply/reply.ts
@@ -25,7 +25,7 @@ import {
type ClawdbotConfig,
loadConfig,
} from "../config/config.js";
-import { resolveSessionTranscriptPath } from "../config/sessions.js";
+import { resolveSessionFilePath } from "../config/sessions.js";
import { logVerbose } from "../globals.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
@@ -646,6 +646,11 @@ export async function getReplyFromConfig(
isNewSession,
prefixedBodyBase,
});
+ const threadStarterBody = ctx.ThreadStarterBody?.trim();
+ const threadStarterNote =
+ isNewSession && threadStarterBody
+ ? `[Thread starter - for context]\n${threadStarterBody}`
+ : undefined;
const skillResult = await ensureSkillSnapshot({
sessionEntry,
sessionStore,
@@ -661,10 +666,10 @@ export async function getReplyFromConfig(
systemSent = skillResult.systemSent;
const skillsSnapshot = skillResult.skillsSnapshot;
const prefixedBody = transcribedText
- ? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
+ ? [threadStarterNote, prefixedBodyBase, `Transcript:\n${transcribedText}`]
.filter(Boolean)
.join("\n\n")
- : prefixedBodyBase;
+ : [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
const mediaNote = ctx.MediaPath?.length
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
: undefined;
@@ -689,12 +694,12 @@ export async function getReplyFromConfig(
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
}
const sessionIdFinal = sessionId ?? crypto.randomUUID();
- const sessionFile = resolveSessionTranscriptPath(sessionIdFinal);
+ const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
const queueBodyBase = transcribedText
- ? [baseBodyFinal, `Transcript:\n${transcribedText}`]
+ ? [threadStarterNote, baseBodyFinal, `Transcript:\n${transcribedText}`]
.filter(Boolean)
.join("\n\n")
- : baseBodyFinal;
+ : [threadStarterNote, baseBodyFinal].filter(Boolean).join("\n\n");
const queuedBody = mediaNote
? [mediaNote, mediaReplyHint, queueBodyBase]
.filter(Boolean)
diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts
index fce665e9f..30152dda9 100644
--- a/src/auto-reply/reply/commands.ts
+++ b/src/auto-reply/reply/commands.ts
@@ -14,7 +14,7 @@ import {
} from "../../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
- resolveSessionTranscriptPath,
+ resolveSessionFilePath,
type SessionEntry,
type SessionScope,
saveSessionStore,
@@ -509,7 +509,7 @@ export async function handleCommands(params: {
sessionId,
sessionKey,
messageProvider: command.provider,
- sessionFile: resolveSessionTranscriptPath(sessionId),
+ sessionFile: resolveSessionFilePath(sessionId, sessionEntry),
workspaceDir,
config: cfg,
skillsSnapshot: sessionEntry.skillsSnapshot,
diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts
new file mode 100644
index 000000000..4e1e28ffb
--- /dev/null
+++ b/src/auto-reply/reply/session.test.ts
@@ -0,0 +1,82 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import type { ClawdbotConfig } from "../../config/config.js";
+import { saveSessionStore } from "../../config/sessions.js";
+import { initSessionState } from "./session.js";
+
+describe("initSessionState thread forking", () => {
+ it("forks a new session from the parent session file", async () => {
+ const root = await fs.mkdtemp(
+ path.join(os.tmpdir(), "clawdbot-thread-session-"),
+ );
+ const sessionsDir = path.join(root, "sessions");
+ await fs.mkdir(sessionsDir, { recursive: true });
+
+ const parentSessionId = "parent-session";
+ const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
+ const header = {
+ type: "session",
+ version: 3,
+ id: parentSessionId,
+ timestamp: new Date().toISOString(),
+ cwd: process.cwd(),
+ };
+ const message = {
+ type: "message",
+ id: "m1",
+ parentId: null,
+ timestamp: new Date().toISOString(),
+ message: { role: "user", content: "Parent prompt" },
+ };
+ await fs.writeFile(
+ parentSessionFile,
+ `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`,
+ "utf-8",
+ );
+
+ const storePath = path.join(root, "sessions.json");
+ const parentSessionKey = "slack:channel:C1";
+ await saveSessionStore(storePath, {
+ [parentSessionKey]: {
+ sessionId: parentSessionId,
+ sessionFile: parentSessionFile,
+ updatedAt: Date.now(),
+ },
+ });
+
+ const cfg = {
+ session: { store: storePath },
+ } as ClawdbotConfig;
+
+ const threadSessionKey = "slack:thread:C1:123";
+ const threadLabel = "Slack thread #general: starter";
+ const result = await initSessionState({
+ ctx: {
+ Body: "Thread reply",
+ SessionKey: threadSessionKey,
+ ParentSessionKey: parentSessionKey,
+ ThreadLabel: threadLabel,
+ },
+ cfg,
+ commandAuthorized: true,
+ });
+
+ expect(result.sessionKey).toBe(threadSessionKey);
+ expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
+ expect(result.sessionEntry.sessionFile).toBeTruthy();
+ expect(result.sessionEntry.displayName).toBe(threadLabel);
+
+ const newSessionFile = result.sessionEntry.sessionFile!;
+ const [headerLine] = (await fs.readFile(newSessionFile, "utf-8"))
+ .split(/\r?\n/)
+ .filter((line) => line.trim().length > 0);
+ const parsedHeader = JSON.parse(headerLine) as {
+ parentSession?: string;
+ };
+ expect(parsedHeader.parentSession).toBe(parentSessionFile);
+ });
+});
diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts
index 992fb2f61..872e798f0 100644
--- a/src/auto-reply/reply/session.ts
+++ b/src/auto-reply/reply/session.ts
@@ -1,5 +1,11 @@
import crypto from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+import {
+ CURRENT_SESSION_VERSION,
+ SessionManager,
+} from "@mariozechner/pi-coding-agent";
import type { ClawdbotConfig } from "../../config/config.js";
import {
buildGroupDisplayName,
@@ -9,6 +15,7 @@ import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveGroupSessionKey,
+ resolveSessionFilePath,
resolveSessionKey,
resolveStorePath,
type SessionEntry,
@@ -36,6 +43,45 @@ export type SessionInitResult = {
triggerBodyNormalized: string;
};
+function forkSessionFromParent(params: {
+ parentEntry: SessionEntry;
+}): { sessionId: string; sessionFile: string } | null {
+ const parentSessionFile = resolveSessionFilePath(
+ params.parentEntry.sessionId,
+ params.parentEntry,
+ );
+ if (!parentSessionFile || !fs.existsSync(parentSessionFile)) return null;
+ try {
+ const manager = SessionManager.open(parentSessionFile);
+ const leafId = manager.getLeafId();
+ if (leafId) {
+ const sessionFile =
+ manager.createBranchedSession(leafId) ?? manager.getSessionFile();
+ const sessionId = manager.getSessionId();
+ if (sessionFile && sessionId) return { sessionId, sessionFile };
+ }
+ const sessionId = crypto.randomUUID();
+ const timestamp = new Date().toISOString();
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-");
+ const sessionFile = path.join(
+ manager.getSessionDir(),
+ `${fileTimestamp}_${sessionId}.jsonl`,
+ );
+ const header = {
+ type: "session",
+ version: CURRENT_SESSION_VERSION,
+ id: sessionId,
+ timestamp,
+ cwd: manager.getCwd(),
+ parentSession: parentSessionFile,
+ };
+ fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`, "utf-8");
+ return { sessionId, sessionFile };
+ } catch {
+ return null;
+ }
+}
+
export async function initSessionState(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
@@ -189,6 +235,26 @@ export async function initSessionState(params: {
} else if (!sessionEntry.chatType) {
sessionEntry.chatType = "direct";
}
+ const threadLabel = ctx.ThreadLabel?.trim();
+ if (threadLabel) {
+ sessionEntry.displayName = threadLabel;
+ }
+ const parentSessionKey = ctx.ParentSessionKey?.trim();
+ if (
+ isNewSession &&
+ parentSessionKey &&
+ parentSessionKey !== sessionKey &&
+ sessionStore[parentSessionKey]
+ ) {
+ const forked = forkSessionFromParent({
+ parentEntry: sessionStore[parentSessionKey],
+ });
+ if (forked) {
+ sessionId = forked.sessionId;
+ sessionEntry.sessionId = forked.sessionId;
+ sessionEntry.sessionFile = forked.sessionFile;
+ }
+ }
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts
index da125ece8..1c177c89e 100644
--- a/src/auto-reply/status.ts
+++ b/src/auto-reply/status.ts
@@ -16,7 +16,7 @@ import {
import type { ClawdbotConfig } from "../config/config.js";
import {
resolveMainSessionKey,
- resolveSessionTranscriptPath,
+ resolveSessionFilePath,
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
@@ -185,6 +185,7 @@ const formatQueueDetails = (queue?: QueueStatus) => {
const readUsageFromSessionLog = (
sessionId?: string,
+ sessionEntry?: SessionEntry,
):
| {
input: number;
@@ -194,9 +195,9 @@ const readUsageFromSessionLog = (
model?: string;
}
| undefined => {
- // Transcripts always live at: ~/.clawdbot/sessions/.jsonl
+ // Transcripts are stored at the session file path (fallback: ~/.clawdbot/sessions/.jsonl)
if (!sessionId) return undefined;
- const logPath = resolveSessionTranscriptPath(sessionId);
+ const logPath = resolveSessionFilePath(sessionId, sessionEntry);
if (!fs.existsSync(logPath)) return undefined;
try {
@@ -264,7 +265,7 @@ export function buildStatusMessage(args: StatusArgs): string {
// Prefer prompt-size tokens from the session transcript when it looks larger
// (cached prompt tokens are often missing from agent meta/store).
if (args.includeTranscriptUsage) {
- const logUsage = readUsageFromSessionLog(entry?.sessionId);
+ const logUsage = readUsageFromSessionLog(entry?.sessionId, entry);
if (logUsage) {
const candidate = logUsage.promptTokens || logUsage.total;
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts
index a63243237..398290c2f 100644
--- a/src/auto-reply/templating.ts
+++ b/src/auto-reply/templating.ts
@@ -15,10 +15,13 @@ export type MsgContext = {
SessionKey?: string;
/** Provider account id (multi-account). */
AccountId?: string;
+ ParentSessionKey?: string;
MessageSid?: string;
ReplyToId?: string;
ReplyToBody?: string;
ReplyToSender?: string;
+ ThreadStarterBody?: string;
+ ThreadLabel?: string;
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;
diff --git a/src/commands/agent.ts b/src/commands/agent.ts
index c425e09e5..2172c8475 100644
--- a/src/commands/agent.ts
+++ b/src/commands/agent.ts
@@ -36,7 +36,7 @@ import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveSessionKey,
- resolveSessionTranscriptPath,
+ resolveSessionFilePath,
resolveStorePath,
type SessionEntry,
saveSessionStore,
@@ -386,7 +386,7 @@ export async function agentCommand(
catalog: catalogForThinking,
});
}
- const sessionFile = resolveSessionTranscriptPath(sessionId);
+ const sessionFile = resolveSessionFilePath(sessionId, sessionEntry);
const startedAt = Date.now();
let lifecycleEnded = false;
diff --git a/src/config/sessions.ts b/src/config/sessions.ts
index e1f986a1d..024761b73 100644
--- a/src/config/sessions.ts
+++ b/src/config/sessions.ts
@@ -33,6 +33,7 @@ export type SessionChatType = "direct" | "group" | "room";
export type SessionEntry = {
sessionId: string;
updatedAt: number;
+ sessionFile?: string;
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */
spawnedBy?: string;
systemSent?: boolean;
@@ -137,6 +138,17 @@ export function resolveSessionTranscriptPath(
return path.join(resolveAgentSessionsDir(agentId), `${sessionId}.jsonl`);
}
+export function resolveSessionFilePath(
+ sessionId: string,
+ entry?: SessionEntry,
+ opts?: { agentId?: string },
+): string {
+ const candidate = entry?.sessionFile?.trim();
+ return candidate
+ ? candidate
+ : resolveSessionTranscriptPath(sessionId, opts?.agentId);
+}
+
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
if (!store) return resolveDefaultSessionStorePath(agentId);
@@ -393,6 +405,7 @@ export async function updateLastRoute(params: {
const next: SessionEntry = {
sessionId: existing?.sessionId ?? crypto.randomUUID(),
updatedAt: Math.max(existing?.updatedAt ?? 0, now),
+ sessionFile: existing?.sessionFile,
systemSent: existing?.systemSent,
abortedLastRun: existing?.abortedLastRun,
thinkingLevel: existing?.thinkingLevel,
diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts
index 310c07e82..2abf02624 100644
--- a/src/discord/monitor.tool-result.test.ts
+++ b/src/discord/monitor.tool-result.test.ts
@@ -167,4 +167,107 @@ describe("discord tool result dispatch", () => {
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledTimes(1);
}, 10000);
+
+ });
+
+ it("forks thread sessions and injects starter context", async () => {
+ const { createDiscordMessageHandler } = await import("./monitor.js");
+ const { resolveSessionKey } = await import("../config/sessions.js");
+ vi.mocked(resolveSessionKey).mockReturnValue("discord:parent:p1");
+
+ let capturedCtx:
+ | {
+ SessionKey?: string;
+ ParentSessionKey?: string;
+ ThreadStarterBody?: string;
+ ThreadLabel?: string;
+ }
+ | undefined;
+ dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
+ capturedCtx = ctx;
+ dispatcher.sendFinalReply({ text: "hi" });
+ return { queuedFinal: true, counts: { final: 1 } };
+ });
+
+ const cfg = {
+ agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
+ session: { store: "/tmp/clawdbot-sessions.json" },
+ messages: { responsePrefix: "PFX" },
+ discord: {
+ dm: { enabled: true, policy: "open" },
+ guilds: { "*": { requireMention: false } },
+ },
+ routing: { allowFrom: [] },
+ } as ReturnType;
+
+ const handler = createDiscordMessageHandler({
+ cfg,
+ token: "token",
+ runtime: {
+ log: vi.fn(),
+ error: vi.fn(),
+ exit: (code: number): never => {
+ throw new Error(`exit ${code}`);
+ },
+ },
+ botUserId: "bot-id",
+ guildHistories: new Map(),
+ historyLimit: 0,
+ mediaMaxBytes: 10_000,
+ textLimit: 2000,
+ replyToMode: "off",
+ dmEnabled: true,
+ groupDmEnabled: false,
+ guildEntries: { "*": { requireMention: false } },
+ });
+
+ const threadChannel = {
+ type: ChannelType.GuildText,
+ name: "thread-name",
+ parentId: "p1",
+ parent: { id: "p1", name: "general" },
+ isThread: () => true,
+ fetchStarterMessage: async () => ({
+ content: "starter message",
+ author: { tag: "Alice#1", username: "Alice" },
+ createdTimestamp: Date.now(),
+ }),
+ };
+
+ const client = {
+ fetchChannel: vi.fn().mockResolvedValue({
+ type: ChannelType.GuildText,
+ name: "thread-name",
+ }),
+ } as unknown as Client;
+
+ await handler(
+ {
+ message: {
+ id: "m4",
+ content: "thread reply",
+ channelId: "t1",
+ channel: threadChannel,
+ timestamp: new Date().toISOString(),
+ type: MessageType.Default,
+ attachments: [],
+ embeds: [],
+ mentionedEveryone: false,
+ mentionedUsers: [],
+ mentionedRoles: [],
+ author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
+ },
+ author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
+ member: { displayName: "Bob" },
+ guild: { id: "g1", name: "Guild" },
+ guild_id: "g1",
+ },
+ client,
+ );
+
+ expect(capturedCtx?.SessionKey).toBe("discord:thread:t1");
+ expect(capturedCtx?.ParentSessionKey).toBe("discord:parent:p1");
+ expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
+ expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
+ });
});
diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts
index 473d18eab..95d4a9df5 100644
--- a/src/discord/monitor.ts
+++ b/src/discord/monitor.ts
@@ -11,6 +11,11 @@ import {
MessageReactionRemoveListener,
MessageType,
type RequestClient,
+ type PartialMessage,
+ type PartialMessageReaction,
+ Partials,
+ type ThreadChannel,
+ type PartialUser,
type User,
} from "@buape/carbon";
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
@@ -81,6 +86,44 @@ type DiscordHistoryEntry = {
};
type DiscordReactionEvent = Parameters[0];
+type DiscordThreadStarter = {
+ text: string;
+ author: string;
+ timestamp?: number;
+};
+
+const DISCORD_THREAD_STARTER_CACHE = new Map();
+
+async function resolveDiscordThreadStarter(
+ channel: ThreadChannel,
+): Promise {
+ const cacheKey = channel.id;
+ const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
+ if (cached) return cached;
+ try {
+ const starter = await channel.fetchStarterMessage();
+ if (!starter) return null;
+ const text =
+ starter.content?.trim() ??
+ starter.embeds?.[0]?.description?.trim() ??
+ "";
+ if (!text) return null;
+ const author =
+ starter.member?.displayName ??
+ starter.author?.tag ??
+ starter.author?.username ??
+ "Unknown";
+ const payload: DiscordThreadStarter = {
+ text,
+ author,
+ timestamp: starter.createdTimestamp ?? undefined,
+ };
+ DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload);
+ return payload;
+ } catch {
+ return null;
+ }
+}
export type DiscordAllowList = {
allowAll: boolean;
@@ -509,7 +552,30 @@ export function createDiscordMessageHandler(params: {
return;
}
- const channelName = channelInfo?.name;
+ const channelName =
+ channelInfo?.name ??
+ ((isGuildMessage || isGroupDm) && "name" in message.channel
+ ? message.channel.name
+ : undefined);
+ const isThreadChannel =
+ isGuildMessage &&
+ "isThread" in message.channel &&
+ message.channel.isThread();
+ const threadChannel = isThreadChannel
+ ? (message.channel as ThreadChannel)
+ : null;
+ const threadParentId =
+ threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
+ const threadParentName = threadChannel?.parent?.name;
+ const threadName = threadChannel?.name;
+ const configChannelName = threadParentName ?? channelName;
+ const configChannelSlug = configChannelName
+ ? normalizeDiscordSlug(configChannelName)
+ : "";
+ const displayChannelName = threadName ?? channelName;
+ const displayChannelSlug = displayChannelName
+ ? normalizeDiscordSlug(displayChannelName)
+ : "";
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const guildSlug =
guildInfo?.slug ||
@@ -527,9 +593,9 @@ export function createDiscordMessageHandler(params: {
const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({
guildInfo,
- channelId: message.channelId,
- channelName,
- channelSlug,
+ channelId: threadParentId ?? message.channelId,
+ channelName: configChannelName,
+ channelSlug: configChannelSlug,
})
: null;
if (isGuildMessage && channelConfig?.enabled === false) {
@@ -544,8 +610,8 @@ export function createDiscordMessageHandler(params: {
resolveGroupDmAllow({
channels: groupDmChannels,
channelId: message.channelId,
- channelName,
- channelSlug,
+ channelName: displayChannelName,
+ channelSlug: displayChannelSlug,
});
if (isGroupDm && !groupDmAllowed) return;
@@ -715,7 +781,9 @@ export function createDiscordMessageHandler(params: {
channelId: message.channelId,
});
const groupRoom =
- isGuildMessage && channelSlug ? `#${channelSlug}` : undefined;
+ isGuildMessage && displayChannelSlug
+ ? `#${displayChannelSlug}`
+ : undefined;
const groupSubject = isDirectMessage ? undefined : groupRoom;
const channelDescription = channelInfo?.topic?.trim();
const systemPromptParts = [
@@ -761,6 +829,41 @@ export function createDiscordMessageHandler(params: {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
}
+ let threadStarterBody: string | undefined;
+ let threadLabel: string | undefined;
+ let threadSessionKey: string | undefined;
+ let parentSessionKey: string | undefined;
+ if (threadChannel) {
+ const starter = await resolveDiscordThreadStarter(threadChannel);
+ if (starter?.text) {
+ const starterEnvelope = formatAgentEnvelope({
+ surface: "Discord",
+ from: starter.author,
+ timestamp: starter.timestamp,
+ body: starter.text,
+ });
+ threadStarterBody = starterEnvelope;
+ }
+ const parentName = threadParentName ?? "parent";
+ threadLabel = threadName
+ ? `Discord thread #${normalizeDiscordSlug(parentName)} โบ ${threadName}`
+ : `Discord thread #${normalizeDiscordSlug(parentName)}`;
+ threadSessionKey = `discord:thread:${message.channelId}`;
+ const sessionCfg = cfg.session;
+ const sessionScope = sessionCfg?.scope ?? "per-sender";
+ const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
+ if (threadParentId) {
+ parentSessionKey = resolveSessionKey(
+ sessionScope,
+ {
+ From: `group:${threadParentId}`,
+ ChatType: "group",
+ Surface: "discord",
+ },
+ mainKey,
+ );
+ }
+ }
const mediaPayload = buildDiscordMediaPayload(mediaList);
const discordTo = `channel:${message.channelId}`;
const ctxPayload = {
@@ -769,7 +872,7 @@ export function createDiscordMessageHandler(params: {
? `discord:${author.id}`
: `group:${message.channelId}`,
To: discordTo,
- SessionKey: route.sessionKey,
+ SessionKey: threadSessionKey ?? route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
SenderName:
@@ -787,6 +890,9 @@ export function createDiscordMessageHandler(params: {
Surface: "discord" as const,
WasMentioned: wasMentioned,
MessageSid: message.id,
+ ParentSessionKey: parentSessionKey,
+ ThreadStarterBody: threadStarterBody,
+ ThreadLabel: threadLabel,
Timestamp: resolveTimestampMs(message.timestamp),
...mediaPayload,
CommandAuthorized: commandAuthorized,
diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts
index 2fc6f49af..5d4b2cf5e 100644
--- a/src/gateway/server-bridge.ts
+++ b/src/gateway/server-bridge.ts
@@ -707,6 +707,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
for (const candidate of resolveSessionTranscriptCandidates(
sessionId,
storePath,
+ entry?.sessionFile,
)) {
if (!fs.existsSync(candidate)) continue;
try {
@@ -773,6 +774,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
const filePath = resolveSessionTranscriptCandidates(
sessionId,
storePath,
+ entry?.sessionFile,
).find((candidate) => fs.existsSync(candidate));
if (!filePath) {
return {
@@ -843,7 +845,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
const sessionId = entry?.sessionId;
const rawMessages =
sessionId && storePath
- ? readSessionMessages(sessionId, storePath)
+ ? readSessionMessages(sessionId, storePath, entry?.sessionFile)
: [];
const max = typeof limit === "number" ? limit : 200;
const sliced =
diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts
index 36b412442..6b2799200 100644
--- a/src/gateway/server-methods/chat.ts
+++ b/src/gateway/server-methods/chat.ts
@@ -46,7 +46,9 @@ export const chatHandlers: GatewayRequestHandlers = {
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
const sessionId = entry?.sessionId;
const rawMessages =
- sessionId && storePath ? readSessionMessages(sessionId, storePath) : [];
+ sessionId && storePath
+ ? readSessionMessages(sessionId, storePath, entry?.sessionFile)
+ : [];
const hardMax = 1000;
const defaultLimit = 200;
const requested = typeof limit === "number" ? limit : defaultLimit;
diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts
index 7da991553..3e86dfdb1 100644
--- a/src/gateway/server-methods/sessions.ts
+++ b/src/gateway/server-methods/sessions.ts
@@ -485,6 +485,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
for (const candidate of resolveSessionTranscriptCandidates(
sessionId,
storePath,
+ entry?.sessionFile,
target.agentId,
)) {
if (!fs.existsSync(candidate)) continue;
@@ -559,6 +560,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const filePath = resolveSessionTranscriptCandidates(
sessionId,
storePath,
+ entry?.sessionFile,
target.agentId,
).find((candidate) => fs.existsSync(candidate));
if (!filePath) {
diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts
index 2c4a72af2..b4650cc51 100644
--- a/src/gateway/server.chat.test.ts
+++ b/src/gateway/server.chat.test.ts
@@ -327,6 +327,67 @@ describe("gateway server chat", () => {
await server.close();
});
+ test("chat.history prefers sessionFile when set", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
+ testState.sessionStorePath = path.join(dir, "sessions.json");
+
+ const forkedPath = path.join(dir, "sess-forked.jsonl");
+ await fs.writeFile(
+ forkedPath,
+ JSON.stringify({
+ message: {
+ role: "user",
+ content: [{ type: "text", text: "from-fork" }],
+ timestamp: Date.now(),
+ },
+ }),
+ "utf-8",
+ );
+
+ await fs.writeFile(
+ path.join(dir, "sess-main.jsonl"),
+ JSON.stringify({
+ message: {
+ role: "user",
+ content: [{ type: "text", text: "from-default" }],
+ timestamp: Date.now(),
+ },
+ }),
+ "utf-8",
+ );
+
+ await fs.writeFile(
+ testState.sessionStorePath,
+ JSON.stringify(
+ {
+ main: {
+ sessionId: "sess-main",
+ sessionFile: forkedPath,
+ updatedAt: Date.now(),
+ },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ const { server, ws } = await startServerWithClient();
+ await connectOk(ws);
+
+ const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
+ sessionKey: "main",
+ });
+ expect(res.ok).toBe(true);
+ const messages = res.payload?.messages ?? [];
+ expect(messages.length).toBe(1);
+ const first = messages[0] as { content?: { text?: string }[] };
+ expect(first.content?.[0]?.text).toBe("from-fork");
+
+ ws.close();
+ await server.close();
+ });
+
test("chat.history defaults thinking to low for reasoning-capable models", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [
diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts
index 3aa1aca4d..91dc540e8 100644
--- a/src/gateway/session-utils.ts
+++ b/src/gateway/session-utils.ts
@@ -74,8 +74,13 @@ export type SessionsPatchResult = {
export function readSessionMessages(
sessionId: string,
storePath: string | undefined,
+ sessionFile?: string,
): unknown[] {
- const candidates = resolveSessionTranscriptCandidates(sessionId, storePath);
+ const candidates = resolveSessionTranscriptCandidates(
+ sessionId,
+ storePath,
+ sessionFile,
+ );
const filePath = candidates.find((p) => fs.existsSync(p));
if (!filePath) return [];
@@ -99,9 +104,11 @@ export function readSessionMessages(
export function resolveSessionTranscriptCandidates(
sessionId: string,
storePath: string | undefined,
+ sessionFile?: string,
agentId?: string,
): string[] {
const candidates: string[] = [];
+ if (sessionFile) candidates.push(sessionFile);
if (storePath) {
const dir = path.dirname(storePath);
candidates.push(path.join(dir, `${sessionId}.jsonl`));
diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts
index 55ab49cf1..64caad341 100644
--- a/src/slack/monitor.tool-result.test.ts
+++ b/src/slack/monitor.tool-result.test.ts
@@ -57,6 +57,7 @@ vi.mock("@slack/bolt", () => {
info: vi.fn().mockResolvedValue({
channel: { name: "dm", is_im: true },
}),
+ replies: vi.fn().mockResolvedValue({ messages: [] }),
},
users: {
info: vi.fn().mockResolvedValue({
@@ -283,6 +284,114 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" });
});
+ it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => {
+ const { resolveSessionKey } = await import("../config/sessions.js");
+ vi.mocked(resolveSessionKey).mockReturnValue("main");
+ replyMock.mockResolvedValue({ text: "thread reply" });
+
+ const controller = new AbortController();
+ const run = monitorSlackProvider({
+ botToken: "bot-token",
+ appToken: "app-token",
+ abortSignal: controller.signal,
+ });
+
+ await waitForEvent("message");
+ const handler = getSlackHandlers()?.get("message");
+ if (!handler) throw new Error("Slack message handler not registered");
+
+ await handler({
+ event: {
+ type: "message",
+ user: "U1",
+ text: "hello",
+ ts: "123",
+ thread_ts: "123",
+ parent_user_id: "U2",
+ channel: "C1",
+ channel_type: "im",
+ },
+ });
+
+ await flush();
+ controller.abort();
+ await run;
+
+ expect(replyMock).toHaveBeenCalledTimes(1);
+ const ctx = replyMock.mock.calls[0]?.[0] as {
+ SessionKey?: string;
+ ParentSessionKey?: string;
+ };
+ expect(ctx.SessionKey).toBe("slack:thread:C1:123");
+ expect(ctx.ParentSessionKey).toBe("main");
+ });
+
+ it("forks thread sessions and injects starter context", async () => {
+ const { resolveSessionKey } = await import("../config/sessions.js");
+ vi.mocked(resolveSessionKey).mockReturnValue("slack:channel:C1");
+ replyMock.mockResolvedValue({ text: "ok" });
+
+ const client = getSlackClient();
+ if (client?.conversations?.info) {
+ client.conversations.info.mockResolvedValue({
+ channel: { name: "general", is_channel: true },
+ });
+ }
+ if (client?.conversations?.replies) {
+ client.conversations.replies.mockResolvedValue({
+ messages: [{ text: "starter message", user: "U2", ts: "111.222" }],
+ });
+ }
+
+ config = {
+ messages: { responsePrefix: "PFX" },
+ slack: {
+ dm: { enabled: true, policy: "open", allowFrom: ["*"] },
+ channels: { C1: { allow: true, requireMention: false } },
+ },
+ routing: { allowFrom: [] },
+ };
+
+ const controller = new AbortController();
+ const run = monitorSlackProvider({
+ botToken: "bot-token",
+ appToken: "app-token",
+ abortSignal: controller.signal,
+ });
+
+ await waitForEvent("message");
+ const handler = getSlackHandlers()?.get("message");
+ if (!handler) throw new Error("Slack message handler not registered");
+
+ await handler({
+ event: {
+ type: "message",
+ user: "U1",
+ text: "thread reply",
+ ts: "123.456",
+ thread_ts: "111.222",
+ channel: "C1",
+ channel_type: "channel",
+ },
+ });
+
+ await flush();
+ controller.abort();
+ await run;
+
+ expect(replyMock).toHaveBeenCalledTimes(1);
+ const ctx = replyMock.mock.calls[0]?.[0] as {
+ SessionKey?: string;
+ ParentSessionKey?: string;
+ ThreadStarterBody?: string;
+ ThreadLabel?: string;
+ };
+ expect(ctx.SessionKey).toBe("slack:thread:C1:111.222");
+ expect(ctx.ParentSessionKey).toBe("slack:channel:C1");
+ expect(ctx.ThreadStarterBody).toContain("starter message");
+ expect(ctx.ThreadLabel).toContain("Slack thread #general");
+ });
+
it("keeps replies in channel root when message is not threaded", async () => {
replyMock.mockResolvedValue({ text: "root reply" });
diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts
index 91586f2ba..dcde4bdae 100644
--- a/src/slack/monitor.ts
+++ b/src/slack/monitor.ts
@@ -3,6 +3,7 @@ import {
type SlackCommandMiddlewareArgs,
type SlackEventMiddlewareArgs,
} from "@slack/bolt";
+import type { WebClient as SlackWebClient } from "@slack/web-api";
import {
chunkMarkdownText,
resolveTextChunkLimit,
@@ -74,6 +75,7 @@ type SlackMessageEvent = {
text?: string;
ts?: string;
thread_ts?: string;
+ parent_user_id?: string;
channel: string;
channel_type?: "im" | "mpim" | "channel" | "group";
files?: SlackFile[];
@@ -86,6 +88,7 @@ type SlackAppMentionEvent = {
text?: string;
ts?: string;
thread_ts?: string;
+ parent_user_id?: string;
channel: string;
channel_type?: "im" | "mpim" | "channel" | "group";
};
@@ -390,6 +393,44 @@ async function resolveSlackMedia(params: {
return null;
}
+type SlackThreadStarter = {
+ text: string;
+ userId?: string;
+ ts?: string;
+};
+
+const THREAD_STARTER_CACHE = new Map();
+
+async function resolveSlackThreadStarter(params: {
+ channelId: string;
+ threadTs: string;
+ client: SlackWebClient;
+}): Promise {
+ const cacheKey = `${params.channelId}:${params.threadTs}`;
+ const cached = THREAD_STARTER_CACHE.get(cacheKey);
+ if (cached) return cached;
+ try {
+ const response = (await params.client.conversations.replies({
+ channel: params.channelId,
+ ts: params.threadTs,
+ limit: 1,
+ inclusive: true,
+ })) as { messages?: Array<{ text?: string; user?: string; ts?: string }> };
+ const message = response?.messages?.[0];
+ const text = (message?.text ?? "").trim();
+ if (!message || !text) return null;
+ const starter: SlackThreadStarter = {
+ text,
+ userId: message.user,
+ ts: message.ts,
+ };
+ THREAD_STARTER_CACHE.set(cacheKey, starter);
+ return starter;
+ } catch {
+ return null;
+ }
+}
+
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const cfg = loadConfig();
const sessionCfg = cfg.session;
@@ -883,7 +924,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
},
});
- const sessionKey = route.sessionKey;
+ const baseSessionKey = route.sessionKey;
+ const threadTs = message.thread_ts;
+ const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
+ const isThreadReply =
+ hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id));
+ const threadSessionKey = isThreadReply && threadTs
+ ? `slack:thread:${message.channel}:${threadTs}`
+ : undefined;
+ const parentSessionKey = isThreadReply ? baseSessionKey : undefined;
+ const sessionKey = threadSessionKey ?? baseSessionKey;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
@@ -912,11 +962,39 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
+ let threadStarterBody: string | undefined;
+ let threadLabel: string | undefined;
+ if (isThreadReply && threadTs) {
+ const starter = await resolveSlackThreadStarter({
+ channelId: message.channel,
+ threadTs,
+ client: app.client,
+ });
+ if (starter?.text) {
+ const starterUser = starter.userId
+ ? await resolveUserName(starter.userId)
+ : null;
+ const starterName = starterUser?.name ?? starter.userId ?? "Unknown";
+ const starterWithId = `${starter.text}\n[slack message id: ${starter.ts ?? threadTs} channel: ${message.channel}]`;
+ threadStarterBody = formatAgentEnvelope({
+ provider: "Slack",
+ from: starterName,
+ timestamp: starter.ts
+ ? Math.round(Number(starter.ts) * 1000)
+ : undefined,
+ body: starterWithId,
+ });
+ const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
+ threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
+ } else {
+ threadLabel = `Slack thread ${roomLabel}`;
+ }
+ }
const ctxPayload = {
Body: body,
From: slackFrom,
To: slackTo,
- SessionKey: route.sessionKey,
+ SessionKey: sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
GroupSubject: isRoomish ? roomLabel : undefined,
@@ -927,6 +1005,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
Surface: "slack" as const,
MessageSid: message.ts,
ReplyToId: message.thread_ts ?? message.ts,
+ ParentSessionKey: parentSessionKey,
+ ThreadStarterBody: threadStarterBody,
+ ThreadLabel: threadLabel,
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
WasMentioned: isRoomish ? wasMentioned : undefined,
MediaPath: media?.path,
From 9be7e1b332a8554aa1a1aa0caba40abbe4ba3403 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 18:30:45 +0000
Subject: [PATCH 050/115] fix(ClawdbotKit): bundle tool-display.json
---
CHANGELOG.md | 1 +
.../{ => Sources/ClawdbotKit}/Resources/tool-display.json | 0
2 files changed, 1 insertion(+)
rename apps/shared/ClawdbotKit/{ => Sources/ClawdbotKit}/Resources/tool-display.json (100%)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e59158d7d..18ac36b77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380.
- Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412.
+- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
diff --git a/apps/shared/ClawdbotKit/Resources/tool-display.json b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/tool-display.json
similarity index 100%
rename from apps/shared/ClawdbotKit/Resources/tool-display.json
rename to apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/tool-display.json
From 2b09cb3d9fe23d327bf0b083b3fe2183d510247e Mon Sep 17 00:00:00 2001
From: Azade
Date: Wed, 7 Jan 2026 13:55:21 +0000
Subject: [PATCH 051/115] fix(status): show configured model instead of
last-run model
---
src/auto-reply/status.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts
index da125ece8..45c51065d 100644
--- a/src/auto-reply/status.ts
+++ b/src/auto-reply/status.ts
@@ -249,8 +249,8 @@ export function buildStatusMessage(args: StatusArgs): string {
defaultModel: DEFAULT_MODEL,
});
const provider =
- entry?.modelProvider ?? resolved.provider ?? DEFAULT_PROVIDER;
- let model = entry?.model ?? resolved.model ?? DEFAULT_MODEL;
+ entry?.providerOverride ?? resolved.provider ?? DEFAULT_PROVIDER;
+ let model = entry?.modelOverride ?? resolved.model ?? DEFAULT_MODEL;
let contextTokens =
entry?.contextTokens ??
args.agent?.contextTokens ??
From aba4695cd12e86670e847e525eb18faabc10949a Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 18:38:55 +0000
Subject: [PATCH 052/115] test(status): cover model override display
---
src/auto-reply/status.test.ts | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts
index 97bafe16e..549334c7f 100644
--- a/src/auto-reply/status.test.ts
+++ b/src/auto-reply/status.test.ts
@@ -45,6 +45,29 @@ describe("buildStatusMessage", () => {
expect(text).toContain("Queue: collect");
});
+ it("prefers model overrides over last-run model", () => {
+ const text = buildStatusMessage({
+ agent: {
+ model: "anthropic/claude-opus-4-5",
+ contextTokens: 32_000,
+ },
+ sessionEntry: {
+ sessionId: "override-1",
+ updatedAt: 0,
+ providerOverride: "openai",
+ modelOverride: "gpt-4.1-mini",
+ modelProvider: "anthropic",
+ model: "claude-haiku-4-5",
+ contextTokens: 32_000,
+ },
+ sessionKey: "agent:main:main",
+ sessionScope: "per-sender",
+ queue: { mode: "collect", depth: 0 },
+ });
+
+ expect(text).toContain("๐ง Model: openai/gpt-4.1-mini");
+ });
+
it("handles missing agent config gracefully", () => {
const text = buildStatusMessage({
agent: {},
From 0603aaaf7a4a3f4ff27dd44126c1afb2790437fe Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 18:38:58 +0000
Subject: [PATCH 053/115] docs(changelog): note status model override
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18ac36b77..705b1f892 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -88,6 +88,7 @@
- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270.
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
+- Status: show configured model in `/status` (override-aware). Thanks @azade-c for PR #396.
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
- Auto-reply: add configurable ack reactions for inbound messages (default ๐ or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) โ thanks @dbhurley
From 0d021391a990d17266fc3c3327fd5b4c5ed3d1a3 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 19:42:50 +0100
Subject: [PATCH 054/115] fix: scope thread sessions and discord starter fetch
---
CHANGELOG.md | 1 +
src/auto-reply/reply/session.test.ts | 9 ++-
src/commands/agent.ts | 2 +-
src/discord/monitor.tool-result.test.ts | 16 +++--
src/discord/monitor.ts | 95 +++++++++++++++----------
src/slack/monitor.tool-result.test.ts | 12 ++--
src/slack/monitor.ts | 10 +--
7 files changed, 84 insertions(+), 61 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e59158d7d..64bb38298 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
- CLI: add `clawdbot docs` live docs search with pretty output.
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
+- Discord/Slack: fork thread sessions and inject thread starters for context. Thanks @thewilloftheshadow for PR #400.
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
- Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381.
- Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381.
diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts
index 4e1e28ffb..f511aceab 100644
--- a/src/auto-reply/reply/session.test.ts
+++ b/src/auto-reply/reply/session.test.ts
@@ -39,7 +39,7 @@ describe("initSessionState thread forking", () => {
);
const storePath = path.join(root, "sessions.json");
- const parentSessionKey = "slack:channel:C1";
+ const parentSessionKey = "agent:main:slack:channel:C1";
await saveSessionStore(storePath, {
[parentSessionKey]: {
sessionId: parentSessionId,
@@ -52,7 +52,7 @@ describe("initSessionState thread forking", () => {
session: { store: storePath },
} as ClawdbotConfig;
- const threadSessionKey = "slack:thread:C1:123";
+ const threadSessionKey = "agent:main:slack:channel:C1:thread:123";
const threadLabel = "Slack thread #general: starter";
const result = await initSessionState({
ctx: {
@@ -70,7 +70,10 @@ describe("initSessionState thread forking", () => {
expect(result.sessionEntry.sessionFile).toBeTruthy();
expect(result.sessionEntry.displayName).toBe(threadLabel);
- const newSessionFile = result.sessionEntry.sessionFile!;
+ const newSessionFile = result.sessionEntry.sessionFile;
+ if (!newSessionFile) {
+ throw new Error("Missing session file for forked thread");
+ }
const [headerLine] = (await fs.readFile(newSessionFile, "utf-8"))
.split(/\r?\n/)
.filter((line) => line.trim().length > 0);
diff --git a/src/commands/agent.ts b/src/commands/agent.ts
index 2172c8475..6386befe0 100644
--- a/src/commands/agent.ts
+++ b/src/commands/agent.ts
@@ -35,8 +35,8 @@ import {
DEFAULT_IDLE_MINUTES,
loadSessionStore,
resolveAgentIdFromSessionKey,
- resolveSessionKey,
resolveSessionFilePath,
+ resolveSessionKey,
resolveStorePath,
type SessionEntry,
saveSessionStore,
diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts
index 2abf02624..84d1e5d2f 100644
--- a/src/discord/monitor.tool-result.test.ts
+++ b/src/discord/monitor.tool-result.test.ts
@@ -168,13 +168,8 @@ describe("discord tool result dispatch", () => {
expect(sendMock).toHaveBeenCalledTimes(1);
}, 10000);
- });
-
it("forks thread sessions and injects starter context", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
- const { resolveSessionKey } = await import("../config/sessions.js");
- vi.mocked(resolveSessionKey).mockReturnValue("discord:parent:p1");
-
let capturedCtx:
| {
SessionKey?: string;
@@ -239,6 +234,13 @@ describe("discord tool result dispatch", () => {
type: ChannelType.GuildText,
name: "thread-name",
}),
+ rest: {
+ get: vi.fn().mockResolvedValue({
+ content: "starter message",
+ author: { id: "u1", username: "Alice", discriminator: "0001" },
+ timestamp: new Date().toISOString(),
+ }),
+ },
} as unknown as Client;
await handler(
@@ -265,8 +267,8 @@ describe("discord tool result dispatch", () => {
client,
);
- expect(capturedCtx?.SessionKey).toBe("discord:thread:t1");
- expect(capturedCtx?.ParentSessionKey).toBe("discord:parent:p1");
+ expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
+ expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:p1");
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
});
diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts
index 95d4a9df5..01ac99d50 100644
--- a/src/discord/monitor.ts
+++ b/src/discord/monitor.ts
@@ -11,11 +11,6 @@ import {
MessageReactionRemoveListener,
MessageType,
type RequestClient,
- type PartialMessage,
- type PartialMessageReaction,
- Partials,
- type ThreadChannel,
- type PartialUser,
type User,
} from "@buape/carbon";
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
@@ -56,7 +51,10 @@ import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
-import { resolveAgentRoute } from "../routing/resolve-route.js";
+import {
+ buildAgentSessionKey,
+ resolveAgentRoute,
+} from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js";
import { loadWebMedia } from "../web/media.js";
import { fetchDiscordApplicationId } from "./probe.js";
@@ -86,6 +84,12 @@ type DiscordHistoryEntry = {
};
type DiscordReactionEvent = Parameters[0];
+type DiscordThreadChannel = {
+ id: string;
+ name?: string | null;
+ parentId?: string | null;
+ parent?: { id?: string; name?: string };
+};
type DiscordThreadStarter = {
text: string;
author: string;
@@ -94,29 +98,46 @@ type DiscordThreadStarter = {
const DISCORD_THREAD_STARTER_CACHE = new Map();
-async function resolveDiscordThreadStarter(
- channel: ThreadChannel,
-): Promise {
- const cacheKey = channel.id;
+async function resolveDiscordThreadStarter(params: {
+ channel: DiscordThreadChannel;
+ client: Client;
+ parentId?: string;
+}): Promise {
+ const cacheKey = params.channel.id;
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
if (cached) return cached;
try {
- const starter = await channel.fetchStarterMessage();
+ if (!params.parentId) return null;
+ const starter = (await params.client.rest.get(
+ Routes.channelMessage(params.parentId, params.channel.id),
+ )) as {
+ content?: string | null;
+ embeds?: Array<{ description?: string | null }>;
+ member?: { nick?: string | null; displayName?: string | null };
+ author?: {
+ id?: string | null;
+ username?: string | null;
+ discriminator?: string | null;
+ };
+ timestamp?: string | null;
+ };
if (!starter) return null;
const text =
- starter.content?.trim() ??
- starter.embeds?.[0]?.description?.trim() ??
- "";
+ starter.content?.trim() ?? starter.embeds?.[0]?.description?.trim() ?? "";
if (!text) return null;
const author =
+ starter.member?.nick ??
starter.member?.displayName ??
- starter.author?.tag ??
- starter.author?.username ??
- "Unknown";
+ (starter.author
+ ? starter.author.discriminator && starter.author.discriminator !== "0"
+ ? `${starter.author.username ?? "Unknown"}#${starter.author.discriminator}`
+ : (starter.author.username ?? starter.author.id ?? "Unknown")
+ : "Unknown");
+ const timestamp = resolveTimestampMs(starter.timestamp);
const payload: DiscordThreadStarter = {
text,
author,
- timestamp: starter.createdTimestamp ?? undefined,
+ timestamp: timestamp ?? undefined,
};
DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload);
return payload;
@@ -554,15 +575,18 @@ export function createDiscordMessageHandler(params: {
const channelName =
channelInfo?.name ??
- ((isGuildMessage || isGroupDm) && "name" in message.channel
+ ((isGuildMessage || isGroupDm) &&
+ message.channel &&
+ "name" in message.channel
? message.channel.name
: undefined);
const isThreadChannel =
isGuildMessage &&
+ message.channel &&
"isThread" in message.channel &&
message.channel.isThread();
const threadChannel = isThreadChannel
- ? (message.channel as ThreadChannel)
+ ? (message.channel as DiscordThreadChannel)
: null;
const threadParentId =
threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
@@ -576,7 +600,6 @@ export function createDiscordMessageHandler(params: {
const displayChannelSlug = displayChannelName
? normalizeDiscordSlug(displayChannelName)
: "";
- const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const guildSlug =
guildInfo?.slug ||
(data.guild?.name ? normalizeDiscordSlug(data.guild.name) : "");
@@ -590,6 +613,7 @@ export function createDiscordMessageHandler(params: {
id: isDirectMessage ? author.id : message.channelId,
},
});
+ const baseSessionKey = route.sessionKey;
const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({
guildInfo,
@@ -831,13 +855,16 @@ export function createDiscordMessageHandler(params: {
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
- let threadSessionKey: string | undefined;
let parentSessionKey: string | undefined;
if (threadChannel) {
- const starter = await resolveDiscordThreadStarter(threadChannel);
+ const starter = await resolveDiscordThreadStarter({
+ channel: threadChannel,
+ client,
+ parentId: threadParentId,
+ });
if (starter?.text) {
const starterEnvelope = formatAgentEnvelope({
- surface: "Discord",
+ provider: "Discord",
from: starter.author,
timestamp: starter.timestamp,
body: starter.text,
@@ -848,20 +875,12 @@ export function createDiscordMessageHandler(params: {
threadLabel = threadName
? `Discord thread #${normalizeDiscordSlug(parentName)} โบ ${threadName}`
: `Discord thread #${normalizeDiscordSlug(parentName)}`;
- threadSessionKey = `discord:thread:${message.channelId}`;
- const sessionCfg = cfg.session;
- const sessionScope = sessionCfg?.scope ?? "per-sender";
- const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
if (threadParentId) {
- parentSessionKey = resolveSessionKey(
- sessionScope,
- {
- From: `group:${threadParentId}`,
- ChatType: "group",
- Surface: "discord",
- },
- mainKey,
- );
+ parentSessionKey = buildAgentSessionKey({
+ agentId: route.agentId,
+ provider: route.provider,
+ peer: { kind: "channel", id: threadParentId },
+ });
}
}
const mediaPayload = buildDiscordMediaPayload(mediaList);
@@ -872,7 +891,7 @@ export function createDiscordMessageHandler(params: {
? `discord:${author.id}`
: `group:${message.channelId}`,
To: discordTo,
- SessionKey: threadSessionKey ?? route.sessionKey,
+ SessionKey: baseSessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
SenderName:
diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts
index 64caad341..e7d7a3338 100644
--- a/src/slack/monitor.tool-result.test.ts
+++ b/src/slack/monitor.tool-result.test.ts
@@ -285,8 +285,6 @@ describe("monitorSlackProvider tool results", () => {
});
it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => {
- const { resolveSessionKey } = await import("../config/sessions.js");
- vi.mocked(resolveSessionKey).mockReturnValue("main");
replyMock.mockResolvedValue({ text: "thread reply" });
const controller = new AbortController();
@@ -322,13 +320,11 @@ describe("monitorSlackProvider tool results", () => {
SessionKey?: string;
ParentSessionKey?: string;
};
- expect(ctx.SessionKey).toBe("slack:thread:C1:123");
- expect(ctx.ParentSessionKey).toBe("main");
+ expect(ctx.SessionKey).toBe("agent:main:main:thread:123");
+ expect(ctx.ParentSessionKey).toBe("agent:main:main");
});
it("forks thread sessions and injects starter context", async () => {
- const { resolveSessionKey } = await import("../config/sessions.js");
- vi.mocked(resolveSessionKey).mockReturnValue("slack:channel:C1");
replyMock.mockResolvedValue({ text: "ok" });
const client = getSlackClient();
@@ -386,8 +382,8 @@ describe("monitorSlackProvider tool results", () => {
ThreadStarterBody?: string;
ThreadLabel?: string;
};
- expect(ctx.SessionKey).toBe("slack:thread:C1:111.222");
- expect(ctx.ParentSessionKey).toBe("slack:channel:C1");
+ expect(ctx.SessionKey).toBe("agent:main:slack:channel:C1:thread:111.222");
+ expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:C1");
expect(ctx.ThreadStarterBody).toContain("starter message");
expect(ctx.ThreadLabel).toContain("Slack thread #general");
});
diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts
index dcde4bdae..3af63cff7 100644
--- a/src/slack/monitor.ts
+++ b/src/slack/monitor.ts
@@ -928,10 +928,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const threadTs = message.thread_ts;
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
const isThreadReply =
- hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id));
- const threadSessionKey = isThreadReply && threadTs
- ? `slack:thread:${message.channel}:${threadTs}`
- : undefined;
+ hasThreadTs &&
+ (threadTs !== message.ts || Boolean(message.parent_user_id));
+ const threadSessionKey =
+ isThreadReply && threadTs
+ ? `${baseSessionKey}:thread:${threadTs}`
+ : undefined;
const parentSessionKey = isThreadReply ? baseSessionKey : undefined;
const sessionKey = threadSessionKey ?? baseSessionKey;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
From 42b637bbc85665cf1a9930db372c14ce9295fcaa Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 19:50:17 +0100
Subject: [PATCH 055/115] test: cover thread session routing
---
src/discord/monitor.tool-result.test.ts | 104 ++++++++++++++++++++++++
src/slack/monitor.tool-result.test.ts | 67 +++++++++++++++
2 files changed, 171 insertions(+)
diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts
index 84d1e5d2f..9606b0388 100644
--- a/src/discord/monitor.tool-result.test.ts
+++ b/src/discord/monitor.tool-result.test.ts
@@ -272,4 +272,108 @@ describe("discord tool result dispatch", () => {
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
});
+
+ it("scopes thread sessions to the routed agent", async () => {
+ const { createDiscordMessageHandler } = await import("./monitor.js");
+
+ let capturedCtx:
+ | {
+ SessionKey?: string;
+ ParentSessionKey?: string;
+ }
+ | undefined;
+ dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
+ capturedCtx = ctx;
+ dispatcher.sendFinalReply({ text: "hi" });
+ return { queuedFinal: true, counts: { final: 1 } };
+ });
+
+ const cfg = {
+ agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
+ session: { store: "/tmp/clawdbot-sessions.json" },
+ messages: { responsePrefix: "PFX" },
+ discord: {
+ dm: { enabled: true, policy: "open" },
+ guilds: { "*": { requireMention: false } },
+ },
+ routing: {
+ allowFrom: [],
+ bindings: [
+ { agentId: "support", match: { provider: "discord", guildId: "g1" } },
+ ],
+ },
+ } as ReturnType;
+
+ const handler = createDiscordMessageHandler({
+ cfg,
+ token: "token",
+ runtime: {
+ log: vi.fn(),
+ error: vi.fn(),
+ exit: (code: number): never => {
+ throw new Error(`exit ${code}`);
+ },
+ },
+ botUserId: "bot-id",
+ guildHistories: new Map(),
+ historyLimit: 0,
+ mediaMaxBytes: 10_000,
+ textLimit: 2000,
+ replyToMode: "off",
+ dmEnabled: true,
+ groupDmEnabled: false,
+ guildEntries: { "*": { requireMention: false } },
+ });
+
+ const threadChannel = {
+ type: ChannelType.GuildText,
+ name: "thread-name",
+ parentId: "p1",
+ parent: { id: "p1", name: "general" },
+ isThread: () => true,
+ };
+
+ const client = {
+ fetchChannel: vi.fn().mockResolvedValue({
+ type: ChannelType.GuildText,
+ name: "thread-name",
+ }),
+ rest: {
+ get: vi.fn().mockResolvedValue({
+ content: "starter message",
+ author: { id: "u1", username: "Alice", discriminator: "0001" },
+ timestamp: new Date().toISOString(),
+ }),
+ },
+ } as unknown as Client;
+
+ await handler(
+ {
+ message: {
+ id: "m5",
+ content: "thread reply",
+ channelId: "t1",
+ channel: threadChannel,
+ timestamp: new Date().toISOString(),
+ type: MessageType.Default,
+ attachments: [],
+ embeds: [],
+ mentionedEveryone: false,
+ mentionedUsers: [],
+ mentionedRoles: [],
+ author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
+ },
+ author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
+ member: { displayName: "Bob" },
+ guild: { id: "g1", name: "Guild" },
+ guild_id: "g1",
+ },
+ client,
+ );
+
+ expect(capturedCtx?.SessionKey).toBe("agent:support:discord:channel:t1");
+ expect(capturedCtx?.ParentSessionKey).toBe(
+ "agent:support:discord:channel:p1",
+ );
+ });
});
diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts
index e7d7a3338..540a065b8 100644
--- a/src/slack/monitor.tool-result.test.ts
+++ b/src/slack/monitor.tool-result.test.ts
@@ -388,6 +388,73 @@ describe("monitorSlackProvider tool results", () => {
expect(ctx.ThreadLabel).toContain("Slack thread #general");
});
+ it("scopes thread session keys to the routed agent", async () => {
+ replyMock.mockResolvedValue({ text: "ok" });
+ config = {
+ messages: { responsePrefix: "PFX" },
+ slack: {
+ dm: { enabled: true, policy: "open", allowFrom: ["*"] },
+ channels: { C1: { allow: true, requireMention: false } },
+ },
+ routing: {
+ allowFrom: [],
+ bindings: [
+ { agentId: "support", match: { provider: "slack", teamId: "T1" } },
+ ],
+ },
+ };
+
+ const client = getSlackClient();
+ if (client?.auth?.test) {
+ client.auth.test.mockResolvedValue({
+ user_id: "bot-user",
+ team_id: "T1",
+ });
+ }
+ if (client?.conversations?.info) {
+ client.conversations.info.mockResolvedValue({
+ channel: { name: "general", is_channel: true },
+ });
+ }
+
+ const controller = new AbortController();
+ const run = monitorSlackProvider({
+ botToken: "bot-token",
+ appToken: "app-token",
+ abortSignal: controller.signal,
+ });
+
+ await waitForEvent("message");
+ const handler = getSlackHandlers()?.get("message");
+ if (!handler) throw new Error("Slack message handler not registered");
+
+ await handler({
+ event: {
+ type: "message",
+ user: "U1",
+ text: "thread reply",
+ ts: "123.456",
+ thread_ts: "111.222",
+ channel: "C1",
+ channel_type: "channel",
+ },
+ });
+
+ await flush();
+ controller.abort();
+ await run;
+
+ expect(replyMock).toHaveBeenCalledTimes(1);
+ const ctx = replyMock.mock.calls[0]?.[0] as {
+ SessionKey?: string;
+ ParentSessionKey?: string;
+ };
+ expect(ctx.SessionKey).toBe(
+ "agent:support:slack:channel:C1:thread:111.222",
+ );
+ expect(ctx.ParentSessionKey).toBe("agent:support:slack:channel:C1");
+ });
+
it("keeps replies in channel root when message is not threaded", async () => {
replyMock.mockResolvedValue({ text: "root reply" });
From cb9f8146c41630ef8b751b8053521c0c7c876b3a Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:01:19 +0100
Subject: [PATCH 056/115] refactor: centralize thread helpers
---
src/auto-reply/envelope.ts | 14 ++++++++++++++
src/discord/monitor.ts | 20 +++++++++++++++-----
src/routing/session-key.ts | 17 +++++++++++++++++
src/slack/monitor.ts | 24 ++++++++++++++----------
4 files changed, 60 insertions(+), 15 deletions(-)
diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts
index 628e13e54..0130ed59d 100644
--- a/src/auto-reply/envelope.ts
+++ b/src/auto-reply/envelope.ts
@@ -34,3 +34,17 @@ export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
const header = `[${parts.join(" ")}]`;
return `${header} ${params.body}`;
}
+
+export function formatThreadStarterEnvelope(params: {
+ provider: string;
+ author?: string;
+ timestamp?: number | Date;
+ body: string;
+}): string {
+ return formatAgentEnvelope({
+ provider: params.provider,
+ from: params.author,
+ timestamp: params.timestamp,
+ body: params.body,
+ });
+}
diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts
index 01ac99d50..11482199d 100644
--- a/src/discord/monitor.ts
+++ b/src/discord/monitor.ts
@@ -27,7 +27,10 @@ import {
listNativeCommandSpecs,
shouldHandleTextCommands,
} from "../auto-reply/commands-registry.js";
-import { formatAgentEnvelope } from "../auto-reply/envelope.js";
+import {
+ formatAgentEnvelope,
+ formatThreadStarterEnvelope,
+} from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import {
buildMentionRegexes,
@@ -55,6 +58,7 @@ import {
buildAgentSessionKey,
resolveAgentRoute,
} from "../routing/resolve-route.js";
+import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { loadWebMedia } from "../web/media.js";
import { fetchDiscordApplicationId } from "./probe.js";
@@ -863,9 +867,9 @@ export function createDiscordMessageHandler(params: {
parentId: threadParentId,
});
if (starter?.text) {
- const starterEnvelope = formatAgentEnvelope({
+ const starterEnvelope = formatThreadStarterEnvelope({
provider: "Discord",
- from: starter.author,
+ author: starter.author,
timestamp: starter.timestamp,
body: starter.text,
});
@@ -885,13 +889,19 @@ export function createDiscordMessageHandler(params: {
}
const mediaPayload = buildDiscordMediaPayload(mediaList);
const discordTo = `channel:${message.channelId}`;
+ const threadKeys = resolveThreadSessionKeys({
+ baseSessionKey,
+ threadId: threadChannel ? message.channelId : undefined,
+ parentSessionKey,
+ useSuffix: false,
+ });
const ctxPayload = {
Body: combinedBody,
From: isDirectMessage
? `discord:${author.id}`
: `group:${message.channelId}`,
To: discordTo,
- SessionKey: baseSessionKey,
+ SessionKey: threadKeys.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
SenderName:
@@ -909,7 +919,7 @@ export function createDiscordMessageHandler(params: {
Surface: "discord" as const,
WasMentioned: wasMentioned,
MessageSid: message.id,
- ParentSessionKey: parentSessionKey,
+ ParentSessionKey: threadKeys.parentSessionKey,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: resolveTimestampMs(message.timestamp),
diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts
index 52563936f..f0efb1004 100644
--- a/src/routing/session-key.ts
+++ b/src/routing/session-key.ts
@@ -89,3 +89,20 @@ export function buildAgentPeerSessionKey(params: {
const peerId = (params.peerId ?? "").trim() || "unknown";
return `agent:${normalizeAgentId(params.agentId)}:${provider}:${peerKind}:${peerId}`;
}
+
+export function resolveThreadSessionKeys(params: {
+ baseSessionKey: string;
+ threadId?: string | null;
+ parentSessionKey?: string;
+ useSuffix?: boolean;
+}): { sessionKey: string; parentSessionKey?: string } {
+ const threadId = (params.threadId ?? "").trim();
+ if (!threadId) {
+ return { sessionKey: params.baseSessionKey, parentSessionKey: undefined };
+ }
+ const useSuffix = params.useSuffix ?? true;
+ const sessionKey = useSuffix
+ ? `${params.baseSessionKey}:thread:${threadId}`
+ : params.baseSessionKey;
+ return { sessionKey, parentSessionKey: params.parentSessionKey };
+}
diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts
index 3af63cff7..d5ede54d1 100644
--- a/src/slack/monitor.ts
+++ b/src/slack/monitor.ts
@@ -14,7 +14,10 @@ import {
listNativeCommandSpecs,
shouldHandleTextCommands,
} from "../auto-reply/commands-registry.js";
-import { formatAgentEnvelope } from "../auto-reply/envelope.js";
+import {
+ formatAgentEnvelope,
+ formatThreadStarterEnvelope,
+} from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import {
buildMentionRegexes,
@@ -34,6 +37,7 @@ import {
resolveStorePath,
updateLastRoute,
} from "../config/sessions.js";
+import { resolveThreadSessionKeys } from "../routing/session-key.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
@@ -930,12 +934,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const isThreadReply =
hasThreadTs &&
(threadTs !== message.ts || Boolean(message.parent_user_id));
- const threadSessionKey =
- isThreadReply && threadTs
- ? `${baseSessionKey}:thread:${threadTs}`
- : undefined;
- const parentSessionKey = isThreadReply ? baseSessionKey : undefined;
- const sessionKey = threadSessionKey ?? baseSessionKey;
+ const threadKeys = resolveThreadSessionKeys({
+ baseSessionKey,
+ threadId: isThreadReply ? threadTs : undefined,
+ parentSessionKey: isThreadReply ? baseSessionKey : undefined,
+ });
+ const sessionKey = threadKeys.sessionKey;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
@@ -978,9 +982,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
: null;
const starterName = starterUser?.name ?? starter.userId ?? "Unknown";
const starterWithId = `${starter.text}\n[slack message id: ${starter.ts ?? threadTs} channel: ${message.channel}]`;
- threadStarterBody = formatAgentEnvelope({
+ threadStarterBody = formatThreadStarterEnvelope({
provider: "Slack",
- from: starterName,
+ author: starterName,
timestamp: starter.ts
? Math.round(Number(starter.ts) * 1000)
: undefined,
@@ -1007,7 +1011,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
Surface: "slack" as const,
MessageSid: message.ts,
ReplyToId: message.thread_ts ?? message.ts,
- ParentSessionKey: parentSessionKey,
+ ParentSessionKey: threadKeys.parentSessionKey,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
From d4bba937a08acfadd993999b460beb09b9490695 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Wed, 7 Jan 2026 09:02:20 -0600
Subject: [PATCH 057/115] Threads: add Slack/Discord thread sessions
---
src/discord/monitor.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts
index 11482199d..f3c2544f8 100644
--- a/src/discord/monitor.ts
+++ b/src/discord/monitor.ts
@@ -11,6 +11,11 @@ import {
MessageReactionRemoveListener,
MessageType,
type RequestClient,
+ type PartialMessage,
+ type PartialMessageReaction,
+ Partials,
+ type ThreadChannel,
+ type PartialUser,
type User,
} from "@buape/carbon";
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
@@ -902,6 +907,7 @@ export function createDiscordMessageHandler(params: {
: `group:${message.channelId}`,
To: discordTo,
SessionKey: threadKeys.sessionKey,
+ SessionKey: threadKeys.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
SenderName:
From 579828b2d540441928f2c4c96f8e0bbb2ef40115 Mon Sep 17 00:00:00 2001
From: alejandro maza
Date: Wed, 7 Jan 2026 07:51:04 -0600
Subject: [PATCH 058/115] Handle 413 context overflow errors gracefully
When the conversation context exceeds the model's limit, instead of
throwing an opaque error or returning raw JSON, we now:
1. Detect context overflow errors (413, request_too_large, etc.)
2. Return a user-friendly message explaining the issue
3. Suggest using /new or /reset to start fresh
This prevents the assistant from becoming completely unresponsive
when context grows too large (e.g., from many screenshots or long
tool outputs).
Addresses issue #394
---
src/agents/pi-embedded-helpers.test.ts | 40 +++++++++++++++++++++++++-
src/agents/pi-embedded-helpers.ts | 20 +++++++++++++
src/agents/pi-embedded-runner.ts | 21 ++++++++++++++
3 files changed, 80 insertions(+), 1 deletion(-)
diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts
index c36664dba..726c34565 100644
--- a/src/agents/pi-embedded-helpers.test.ts
+++ b/src/agents/pi-embedded-helpers.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
-import { buildBootstrapContextFiles } from "./pi-embedded-helpers.js";
+import type { AssistantMessage } from "@mariozechner/pi-ai";
+
+import {
+ buildBootstrapContextFiles,
+ formatAssistantErrorText,
+ isContextOverflowError,
+} from "./pi-embedded-helpers.js";
import {
DEFAULT_AGENTS_FILENAME,
type WorkspaceBootstrapFile,
@@ -46,3 +52,35 @@ describe("buildBootstrapContextFiles", () => {
expect(result?.content.endsWith(long.slice(-120))).toBe(true);
});
});
+
+describe("isContextOverflowError", () => {
+ it("matches known overflow hints", () => {
+ const samples = [
+ "request_too_large",
+ "Request exceeds the maximum size",
+ "context length exceeded",
+ "Maximum context length",
+ "413 Request Entity Too Large",
+ ];
+ for (const sample of samples) {
+ expect(isContextOverflowError(sample)).toBe(true);
+ }
+ });
+
+ it("ignores unrelated errors", () => {
+ expect(isContextOverflowError("rate limit exceeded")).toBe(false);
+ });
+});
+
+describe("formatAssistantErrorText", () => {
+ const makeAssistantError = (errorMessage: string): AssistantMessage =>
+ ({
+ stopReason: "error",
+ errorMessage,
+ }) as AssistantMessage;
+
+ it("returns a friendly message for context overflow", () => {
+ const msg = makeAssistantError("request_too_large");
+ expect(formatAssistantErrorText(msg)).toContain("Context overflow");
+ });
+});
diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts
index 750c9504b..0b0eaec19 100644
--- a/src/agents/pi-embedded-helpers.ts
+++ b/src/agents/pi-embedded-helpers.ts
@@ -126,6 +126,18 @@ export function buildBootstrapContextFiles(
return result;
}
+export function isContextOverflowError(errorMessage?: string): boolean {
+ if (!errorMessage) return false;
+ const lower = errorMessage.toLowerCase();
+ return (
+ lower.includes("request_too_large") ||
+ lower.includes("request exceeds the maximum size") ||
+ lower.includes("context length exceeded") ||
+ lower.includes("maximum context length") ||
+ (lower.includes("413") && lower.includes("too large"))
+ );
+}
+
export function formatAssistantErrorText(
msg: AssistantMessage,
): string | undefined {
@@ -133,6 +145,14 @@ export function formatAssistantErrorText(
const raw = (msg.errorMessage ?? "").trim();
if (!raw) return "LLM request failed with an unknown error.";
+ // Check for context overflow (413) errors
+ if (isContextOverflowError(raw)) {
+ return (
+ "Context overflow: the conversation history is too large. " +
+ "Use /new or /reset to start a fresh session."
+ );
+ }
+
const invalidRequest = raw.match(
/"type":"invalid_request_error".*?"message":"([^"]+)"/,
);
diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts
index 86b790a44..e5a75a473 100644
--- a/src/agents/pi-embedded-runner.ts
+++ b/src/agents/pi-embedded-runner.ts
@@ -59,6 +59,7 @@ import {
formatAssistantErrorText,
isAuthAssistantError,
isAuthErrorMessage,
+ isContextOverflowError,
isRateLimitAssistantError,
isRateLimitErrorMessage,
pickFallbackThinkingLevel,
@@ -1153,6 +1154,26 @@ export async function runEmbeddedPiAgent(params: {
}
if (promptError && !aborted) {
const errorText = describeUnknownError(promptError);
+ if (isContextOverflowError(errorText)) {
+ return {
+ payloads: [
+ {
+ text:
+ "Context overflow: the conversation history is too large for the model. " +
+ "Use /new or /reset to start a fresh session, or try a model with a larger context window.",
+ isError: true,
+ },
+ ],
+ meta: {
+ durationMs: Date.now() - started,
+ agentMeta: {
+ sessionId: sessionIdUsed,
+ provider,
+ model: model.id,
+ },
+ },
+ };
+ }
if (
(isAuthErrorMessage(errorText) ||
isRateLimitErrorMessage(errorText)) &&
From 43c7f5036a94572567ab6159ded32dd87ea88764 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 19:04:04 +0000
Subject: [PATCH 059/115] fix(tools): keep tool errors concise
---
CHANGELOG.md | 1 +
src/agents/pi-embedded-helpers.test.ts | 3 +--
src/agents/pi-tool-definition-adapter.test.ts | 3 ++-
src/agents/pi-tool-definition-adapter.ts | 23 +++++++++++++++----
4 files changed, 22 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d76591cb..3bb6b2e1a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,7 @@
- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
+- Tools: keep tool failure logs concise (no stack traces); full stack only in debug logs.
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
- Android: fix APK output filename renaming after AGP updates. Thanks @Syhids for PR #410.
- Android: rotate camera photos by EXIF orientation. Thanks @fcatuhe for PR #403.
diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts
index 726c34565..69a93430a 100644
--- a/src/agents/pi-embedded-helpers.test.ts
+++ b/src/agents/pi-embedded-helpers.test.ts
@@ -1,6 +1,5 @@
-import { describe, expect, it } from "vitest";
-
import type { AssistantMessage } from "@mariozechner/pi-ai";
+import { describe, expect, it } from "vitest";
import {
buildBootstrapContextFiles,
diff --git a/src/agents/pi-tool-definition-adapter.test.ts b/src/agents/pi-tool-definition-adapter.test.ts
index 27a101002..48700ad28 100644
--- a/src/agents/pi-tool-definition-adapter.test.ts
+++ b/src/agents/pi-tool-definition-adapter.test.ts
@@ -22,6 +22,7 @@ describe("pi tool definition adapter", () => {
status: "error",
tool: "boom",
});
- expect(JSON.stringify(result.details)).toContain("nope");
+ expect(result.details).toMatchObject({ error: "nope" });
+ expect(JSON.stringify(result.details)).not.toContain("\n at ");
});
});
diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts
index df8b64d8d..9f4451625 100644
--- a/src/agents/pi-tool-definition-adapter.ts
+++ b/src/agents/pi-tool-definition-adapter.ts
@@ -4,12 +4,23 @@ import type {
AgentToolUpdateCallback,
} from "@mariozechner/pi-agent-core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
-import { logError } from "../logger.js";
+import { logDebug, logError } from "../logger.js";
import { jsonResult } from "./tools/common.js";
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
type AnyAgentTool = AgentTool;
+function describeToolExecutionError(err: unknown): {
+ message: string;
+ stack?: string;
+} {
+ if (err instanceof Error) {
+ const message = err.message?.trim() ? err.message : String(err);
+ return { message, stack: err.stack };
+ }
+ return { message: String(err) };
+}
+
export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
return tools.map((tool) => {
const name = tool.name || "tool";
@@ -37,13 +48,15 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
? String((err as { name?: unknown }).name)
: "";
if (name === "AbortError") throw err;
- const message =
- err instanceof Error ? (err.stack ?? err.message) : String(err);
- logError(`[tools] ${tool.name} failed: ${message}`);
+ const described = describeToolExecutionError(err);
+ if (described.stack && described.stack !== described.message) {
+ logDebug(`tools: ${tool.name} failed stack:\n${described.stack}`);
+ }
+ logError(`[tools] ${tool.name} failed: ${described.message}`);
return jsonResult({
status: "error",
tool: tool.name,
- error: message,
+ error: described.message,
});
}
},
From 3842a6ae6e56a08f3bcf7d0ea5d8948aa7dfc523 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 19:06:46 +0000
Subject: [PATCH 060/115] docs: credit PR #395 contributor
---
CHANGELOG.md | 1 +
README.md | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bb6b2e1a..e2730752a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,7 @@
- Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381.
- Agent: deliver final replies for non-streaming models when block chunking is enabled. Thank you @mneves75 for PR #369!
- Agent: trim bootstrap context injections and keep group guidance concise (emoji reactions allowed). Thanks @tobiasbischoff for PR #370.
+- Agent: return a friendly context overflow response (413/request_too_large). Thanks @alejandroOPI for PR #395.
- Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298.
- Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent.
- Sessions: forward explicit sessionKey through gateway/chat/node bridge to avoid sub-agent sessionId mixups.
diff --git a/README.md b/README.md
index a6623a632..eb491440b 100644
--- a/README.md
+++ b/README.md
@@ -454,5 +454,5 @@ Thanks to all clawtributors:
-
+
From d81cb886cedb2be74f933d2f869caa5dd833f566 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:09:57 +0100
Subject: [PATCH 061/115] fix: polish thread session routing
---
CHANGELOG.md | 2 +-
src/discord/monitor.ts | 6 ------
src/slack/monitor.ts | 2 +-
3 files changed, 2 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d76591cb..adb990f01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,7 +34,7 @@
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
- CLI: add `clawdbot docs` live docs search with pretty output.
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
-- Discord/Slack: fork thread sessions and inject thread starters for context. Thanks @thewilloftheshadow for PR #400.
+- Discord/Slack: fork thread sessions (agent-scoped) and inject thread starters for context. Thanks @thewilloftheshadow for PR #400.
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
- Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381.
- Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381.
diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts
index f3c2544f8..11482199d 100644
--- a/src/discord/monitor.ts
+++ b/src/discord/monitor.ts
@@ -11,11 +11,6 @@ import {
MessageReactionRemoveListener,
MessageType,
type RequestClient,
- type PartialMessage,
- type PartialMessageReaction,
- Partials,
- type ThreadChannel,
- type PartialUser,
type User,
} from "@buape/carbon";
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
@@ -907,7 +902,6 @@ export function createDiscordMessageHandler(params: {
: `group:${message.channelId}`,
To: discordTo,
SessionKey: threadKeys.sessionKey,
- SessionKey: threadKeys.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
SenderName:
diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts
index d5ede54d1..042849126 100644
--- a/src/slack/monitor.ts
+++ b/src/slack/monitor.ts
@@ -37,7 +37,6 @@ import {
resolveStorePath,
updateLastRoute,
} from "../config/sessions.js";
-import { resolveThreadSessionKeys } from "../routing/session-key.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
@@ -48,6 +47,7 @@ import {
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
+import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { reactSlackMessage } from "./actions.js";
import { sendMessageSlack } from "./send.js";
From b253b9c3a092b2ac8a33dd6dccc708956fd37951 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:16:57 +0100
Subject: [PATCH 062/115] docs: clarify landing note
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 176232a06..ca40cb52a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,7 +50,7 @@
### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
-- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing).
+- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
## Security & Configuration Tips
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
From 765d7771c8da5affc25e7e6b1f644f91aa6e7509 Mon Sep 17 00:00:00 2001
From: Palash Oswal
Date: Wed, 7 Jan 2026 13:14:40 -0500
Subject: [PATCH 063/115] UI: add reconnect + URL password for gateway auth
---
ui/src/ui/app-render.ts | 1 +
ui/src/ui/app.ts | 14 +++++++++++---
ui/src/ui/views/overview.ts | 4 +++-
3 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts
index eb2d48cc8..85f15b740 100644
--- a/ui/src/ui/app-render.ts
+++ b/ui/src/ui/app-render.ts
@@ -247,6 +247,7 @@ export function renderApp(state: AppViewState) {
state.applySettings({ ...state.settings, sessionKey: next });
},
onRefresh: () => state.loadOverview(),
+ onReconnect: () => state.connect(),
})
: nothing}
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index 660fe5ec4..18e109fc7 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -664,11 +664,19 @@ export class ClawdbotApp extends LitElement {
if (!window.location.search) return;
const params = new URLSearchParams(window.location.search);
const token = params.get("token")?.trim();
- if (!token) return;
- if (!this.settings.token) {
+ const password = params.get("password")?.trim();
+ let changed = false;
+ if (token && !this.settings.token) {
this.applySettings({ ...this.settings, token });
+ params.delete("token");
+ changed = true;
}
- params.delete("token");
+ if (password) {
+ this.password = password;
+ params.delete("password");
+ changed = true;
+ }
+ if (!changed) return;
const url = new URL(window.location.href);
url.search = params.toString();
window.history.replaceState({}, "", url.toString());
diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts
index 6a728808a..39f348ec2 100644
--- a/ui/src/ui/views/overview.ts
+++ b/ui/src/ui/views/overview.ts
@@ -20,6 +20,7 @@ export type OverviewProps = {
onPasswordChange: (next: string) => void;
onSessionKeyChange: (next: string) => void;
onRefresh: () => void;
+ onReconnect: () => void;
};
export function renderOverview(props: OverviewProps) {
@@ -84,7 +85,8 @@ export function renderOverview(props: OverviewProps) {
- Reconnect to apply changes.
+
+ Reconnect to apply URL/password changes.
From 9980f20218f5ee5c661b1029b1cd3c6939941053 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:20:32 +0100
Subject: [PATCH 064/115] fix(ui): scrub auth params
---
CHANGELOG.md | 1 +
ui/src/ui/app.ts | 27 ++++++++++++++++++---------
ui/src/ui/navigation.browser.test.ts | 22 ++++++++++++++++++++++
3 files changed, 41 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 765889304..a1ce2c7bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380.
- Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412.
+- Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414.
- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index 18e109fc7..5a6aa3435 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -663,20 +663,29 @@ export class ClawdbotApp extends LitElement {
private applySettingsFromUrl() {
if (!window.location.search) return;
const params = new URLSearchParams(window.location.search);
- const token = params.get("token")?.trim();
- const password = params.get("password")?.trim();
+ const tokenRaw = params.get("token");
+ const passwordRaw = params.get("password");
let changed = false;
- if (token && !this.settings.token) {
- this.applySettings({ ...this.settings, token });
+
+ if (tokenRaw != null) {
+ const token = tokenRaw.trim();
+ if (token && !this.settings.token) {
+ this.applySettings({ ...this.settings, token });
+ changed = true;
+ }
params.delete("token");
- changed = true;
}
- if (password) {
- this.password = password;
+
+ if (passwordRaw != null) {
+ const password = passwordRaw.trim();
+ if (password) {
+ this.password = password;
+ changed = true;
+ }
params.delete("password");
- changed = true;
}
- if (!changed) return;
+
+ if (!changed && tokenRaw == null && passwordRaw == null) return;
const url = new URL(window.location.href);
url.search = params.toString();
window.history.replaceState({}, "", url.toString());
diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts
index 6c3b68b0c..69d9af71e 100644
--- a/ui/src/ui/navigation.browser.test.ts
+++ b/ui/src/ui/navigation.browser.test.ts
@@ -128,4 +128,26 @@ describe("control UI routing", () => {
expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe("");
});
+
+ it("hydrates password from URL params and strips it", async () => {
+ const app = mountApp("/ui/overview?password=sekret");
+ await app.updateComplete;
+
+ expect(app.password).toBe("sekret");
+ expect(window.location.pathname).toBe("/ui/overview");
+ expect(window.location.search).toBe("");
+ });
+
+ it("strips auth params even when settings already set", async () => {
+ localStorage.setItem(
+ "clawdbot.control.settings.v1",
+ JSON.stringify({ token: "existing-token" }),
+ );
+ const app = mountApp("/ui/overview?token=abc123");
+ await app.updateComplete;
+
+ expect(app.settings.token).toBe("existing-token");
+ expect(window.location.pathname).toBe("/ui/overview");
+ expect(window.location.search).toBe("");
+ });
});
From 0e9837183d43632aa9e8125242a9ee16728ad999 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:31:23 +0100
Subject: [PATCH 065/115] docs: expand per-agent sandbox profiles
---
docs/gateway/configuration.md | 69 ++++++++++++++++++++++++++++
docs/gateway/security.md | 84 ++++++++++++++++++++++++++++++++---
docs/install/docker.md | 12 +++++
3 files changed, 160 insertions(+), 5 deletions(-)
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 8c4c22408..57c3cc955 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -359,6 +359,75 @@ Deterministic match order:
Within each match tier, the first matching entry in `routing.bindings` wins.
+#### Per-agent access profiles (multi-agent)
+
+Each agent can carry its own sandbox + tool policy. Use this to mix access
+levels in one gateway:
+- **Full access** (personal agent)
+- **Read-only** tools + workspace
+- **No filesystem access** (messaging/session tools only)
+
+See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for precedence and
+additional examples.
+
+Full access (no sandbox):
+```json5
+{
+ routing: {
+ agents: {
+ personal: {
+ workspace: "~/clawd-personal",
+ sandbox: { mode: "off" }
+ }
+ }
+ }
+}
+```
+
+Read-only tools + read-only workspace:
+```json5
+{
+ routing: {
+ agents: {
+ family: {
+ workspace: "~/clawd-family",
+ sandbox: {
+ mode: "all",
+ scope: "agent",
+ workspaceAccess: "ro"
+ },
+ tools: {
+ allow: ["read", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
+ deny: ["write", "edit", "bash", "process", "browser"]
+ }
+ }
+ }
+ }
+}
+```
+
+No filesystem access (messaging/session tools enabled):
+```json5
+{
+ routing: {
+ agents: {
+ public: {
+ workspace: "~/clawd-public",
+ sandbox: {
+ mode: "all",
+ scope: "agent",
+ workspaceAccess: "none"
+ },
+ tools: {
+ allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "whatsapp", "telegram", "slack", "discord", "gateway"],
+ deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
+ }
+ }
+ }
+ }
+}
+```
+
Example: two WhatsApp accounts โ two agents:
```json5
diff --git a/docs/gateway/security.md b/docs/gateway/security.md
index d12dde53b..e09347746 100644
--- a/docs/gateway/security.md
+++ b/docs/gateway/security.md
@@ -128,12 +128,13 @@ Consider running your AI on a separate phone number from your personal one:
- Personal number: Your conversations stay private
- Bot number: AI handles these, with appropriate boundaries
-### 4. Read-Only Mode (Future)
+### 4. Read-Only Mode (Today, via sandbox + tools)
-We're considering a `readOnlyMode` flag that prevents the AI from:
-- Writing files outside a sandbox
-- Executing shell commands
-- Sending messages
+You can already build a read-only profile by combining:
+- `sandbox.workspaceAccess: "ro"` (or `"none"` for no workspace access)
+- tool allow/deny lists that block `write`, `edit`, `bash`, `process`, etc.
+
+We may add a single `readOnlyMode` flag later to simplify this configuration.
## Sandboxing (recommended)
@@ -153,6 +154,79 @@ Also consider agent workspace access inside the sandbox:
Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and donโt enable it for strangers.
+## Per-agent access profiles (multi-agent)
+
+With multi-agent routing, each agent can have its own sandbox + tool policy:
+use this to give **full access**, **read-only**, or **no access** per agent.
+See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for full details
+and precedence rules.
+
+Common use cases:
+- Personal agent: full access, no sandbox
+- Family/work agent: sandboxed + read-only tools
+- Public agent: sandboxed + no filesystem/shell tools
+
+### Example: full access (no sandbox)
+
+```json5
+{
+ routing: {
+ agents: {
+ personal: {
+ workspace: "~/clawd-personal",
+ sandbox: { mode: "off" }
+ }
+ }
+ }
+}
+```
+
+### Example: read-only tools + read-only workspace
+
+```json5
+{
+ routing: {
+ agents: {
+ family: {
+ workspace: "~/clawd-family",
+ sandbox: {
+ mode: "all",
+ scope: "agent",
+ workspaceAccess: "ro"
+ },
+ tools: {
+ allow: ["read"],
+ deny: ["write", "edit", "bash", "process", "browser"]
+ }
+ }
+ }
+ }
+}
+```
+
+### Example: no filesystem/shell access (provider messaging allowed)
+
+```json5
+{
+ routing: {
+ agents: {
+ public: {
+ workspace: "~/clawd-public",
+ sandbox: {
+ mode: "all",
+ scope: "agent",
+ workspaceAccess: "none"
+ },
+ tools: {
+ allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "whatsapp", "telegram", "slack", "discord", "gateway"],
+ deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
+ }
+ }
+ }
+ }
+}
+```
+
## What to Tell Your AI
Include security guidelines in your agent's system prompt:
diff --git a/docs/install/docker.md b/docs/install/docker.md
index ed06679e9..0f3879de4 100644
--- a/docs/install/docker.md
+++ b/docs/install/docker.md
@@ -86,6 +86,18 @@ container. The gateway stays on your host, but the tool execution is isolated:
Warning: `scope: "shared"` disables cross-session isolation. All sessions share
one container and one workspace.
+### Per-agent sandbox profiles (multi-agent)
+
+If you use multi-agent routing, each agent can override sandbox + tool settings:
+`routing.agents[id].sandbox` and `routing.agents[id].tools`. This lets you run
+mixed access levels in one gateway:
+- Full access (personal agent)
+- Read-only tools + read-only workspace (family/work agent)
+- No filesystem/shell tools (public agent)
+
+See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for examples,
+precedence, and troubleshooting.
+
### Default behavior
- Image: `clawdbot-sandbox:bookworm-slim`
From c572859c8674775c4e55f87434fce6dcca804ca8 Mon Sep 17 00:00:00 2001
From: gupsammy
Date: Wed, 7 Jan 2026 18:11:24 +0530
Subject: [PATCH 066/115] fix(macos): prevent gateway launchd race condition on
startup (#306)
---
CHANGELOG.md | 1 +
.../Clawdbot/GatewayLaunchAgentManager.swift | 14 ++++++++++++++
2 files changed, 15 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1ce2c7bf..bbb63fe87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@
### Fixes
- Discord/Telegram: add per-request retry policy with configurable delays and docs.
+- macOS: prevent gateway launchd startup race where the app could kill a just-started gateway; avoid unnecessary `bootout` and ensure the job is enabled at login. Fixes #306. Thanks @gupsammy for PR #387.
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
index ee6b2e8e1..9038c1489 100644
--- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
@@ -58,6 +58,18 @@ enum GatewayLaunchAgentManager {
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
}
+
+ // Check if service is already running - if so, skip bootout to avoid killing it
+ let alreadyRunning = await self.status()
+ if alreadyRunning {
+ self.logger.info("launchd service already running, skipping bootout")
+ // Still update plist in case config changed, but don't restart
+ self.writePlist(bundlePath: bundlePath, port: port)
+ // Ensure service is marked as enabled for auto-start on login
+ _ = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ return nil
+ }
+
self.logger.info("launchd enable requested port=\(port)")
self.writePlist(bundlePath: bundlePath, port: port)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
@@ -69,6 +81,8 @@ enum GatewayLaunchAgentManager {
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
+ // Ensure service is marked as enabled for auto-start on login
+ _ = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
// Note: removed redundant `kickstart -k` that caused race condition.
// bootstrap already starts the job; kickstart -k would kill it immediately
// and with KeepAlive=true, cause a restart loop with port conflicts.
From e4f62c5b0c83480c1b1f21c8c233b10214f83c3b Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 19:29:44 +0000
Subject: [PATCH 067/115] fix(macos): make launchd enable idempotent
---
.../Clawdbot/GatewayLaunchAgentManager.swift | 107 +++++++++++++++---
.../GatewayLaunchAgentManagerTests.swift | 49 ++++++++
2 files changed, 141 insertions(+), 15 deletions(-)
create mode 100644 apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift
diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
index 9038c1489..162820b6e 100644
--- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
@@ -59,19 +59,22 @@ enum GatewayLaunchAgentManager {
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
}
- // Check if service is already running - if so, skip bootout to avoid killing it
- let alreadyRunning = await self.status()
- if alreadyRunning {
- self.logger.info("launchd service already running, skipping bootout")
- // Still update plist in case config changed, but don't restart
- self.writePlist(bundlePath: bundlePath, port: port)
- // Ensure service is marked as enabled for auto-start on login
- _ = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ let desiredBind = self.preferredGatewayBind() ?? "loopback"
+ self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
+ self.writePlist(bundlePath: bundlePath, port: port)
+
+ // If launchd already loaded the job (common on login), avoid `bootout` unless we must
+ // change the config. `bootout` can kill a just-started gateway and cause attach loops.
+ if let snapshot = await self.gatewayJobSnapshot(),
+ snapshot.matches(port: port, bind: desiredBind)
+ {
+ self.logger.info("launchd job already loaded with desired config; skipping bootout")
+ await self.ensureEnabled()
+ _ = await self.runLaunchctl(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
- self.logger.info("launchd enable requested port=\(port)")
- self.writePlist(bundlePath: bundlePath, port: port)
+ await self.ensureEnabled()
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
@@ -81,11 +84,7 @@ enum GatewayLaunchAgentManager {
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
- // Ensure service is marked as enabled for auto-start on login
- _ = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
- // Note: removed redundant `kickstart -k` that caused race condition.
- // bootstrap already starts the job; kickstart -k would kill it immediately
- // and with KeepAlive=true, cause a restart loop with port conflicts.
+ await self.ensureEnabled()
return nil
}
@@ -227,6 +226,84 @@ enum GatewayLaunchAgentManager {
let output: String
}
+ struct LaunchdJobSnapshot: Equatable {
+ let pid: Int?
+ let port: Int?
+ let bind: String?
+
+ func matches(port: Int, bind: String) -> Bool {
+ guard self.port == port else { return false }
+ if let bindValue = self.bind {
+ return bindValue == bind
+ }
+ return true
+ }
+ }
+
+ static func parseLaunchctlPrintSnapshot(_ output: String) -> LaunchdJobSnapshot {
+ let pid = self.extractIntValue(output: output, key: "pid")
+ let port = self.extractFlagIntValue(output: output, flag: "--port")
+ let bind = self.extractFlagStringValue(output: output, flag: "--bind")?.lowercased()
+ return LaunchdJobSnapshot(pid: pid, port: port, bind: bind)
+ }
+
+ private static func gatewayJobSnapshot() async -> LaunchdJobSnapshot? {
+ let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ guard result.status == 0 else { return nil }
+ return self.parseLaunchctlPrintSnapshot(result.output)
+ }
+
+ private static func ensureEnabled() async {
+ let result = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ guard result.status != 0 else { return }
+ let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ if msg.isEmpty {
+ self.logger.warning("launchd enable failed")
+ } else {
+ self.logger.warning("launchd enable failed: \(msg)")
+ }
+ }
+
+ private static func extractIntValue(output: String, key: String) -> Int? {
+ // launchctl print commonly emits `pid = 123`
+ guard let range = output.range(of: "\(key) =") else { return nil }
+ var idx = range.upperBound
+ while idx < output.endIndex, output[idx].isWhitespace { idx = output.index(after: idx) }
+ var end = idx
+ while end < output.endIndex, output[end].isNumber { end = output.index(after: end) }
+ guard end > idx else { return nil }
+ return Int(output[idx.. Int? {
+ guard let raw = self.extractFlagStringValue(output: output, flag: flag) else { return nil }
+ return Int(raw)
+ }
+
+ private static func extractFlagStringValue(output: String, flag: String) -> String? {
+ guard let range = output.range(of: flag) else { return nil }
+ var idx = range.upperBound
+ while idx < output.endIndex {
+ let ch = output[idx]
+ if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "=" || ch == "\"" || ch == "'" {
+ idx = output.index(after: idx)
+ continue
+ }
+ break
+ }
+ guard idx < output.endIndex else { return nil }
+ var end = idx
+ while end < output.endIndex {
+ let ch = output[end]
+ if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "\"" || ch == "'" || ch == "\n" || ch == "\r" {
+ break
+ }
+ end = output.index(after: end)
+ }
+ let token = output[idx.. LaunchctlResult {
await Task.detached(priority: .utility) { () -> LaunchctlResult in
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift
new file mode 100644
index 000000000..2ce38dda4
--- /dev/null
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift
@@ -0,0 +1,49 @@
+import Testing
+@testable import Clawdbot
+
+@Suite struct GatewayLaunchAgentManagerTests {
+ @Test func parseLaunchctlPrintSnapshotParsesQuotedArgs() {
+ let output = """
+ service = com.clawdbot.gateway
+ program arguments = (
+ "/Applications/Clawdbot.app/Contents/Resources/Relay/clawdbot",
+ "gateway-daemon",
+ "--port",
+ "18789",
+ "--bind",
+ "loopback"
+ )
+ pid = 123
+ """
+ let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
+ #expect(snapshot.pid == 123)
+ #expect(snapshot.port == 18789)
+ #expect(snapshot.bind == "loopback")
+ #expect(snapshot.matches(port: 18789, bind: "loopback"))
+ #expect(snapshot.matches(port: 18789, bind: "tailnet") == false)
+ #expect(snapshot.matches(port: 19999, bind: "loopback") == false)
+ }
+
+ @Test func parseLaunchctlPrintSnapshotParsesUnquotedArgs() {
+ let output = """
+ argv[] = { /usr/local/bin/clawdbot, gateway-daemon, --port, 19999, --bind, tailnet }
+ pid = 0
+ """
+ let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
+ #expect(snapshot.pid == 0)
+ #expect(snapshot.port == 19999)
+ #expect(snapshot.bind == "tailnet")
+ }
+
+ @Test func parseLaunchctlPrintSnapshotAllowsMissingBind() {
+ let output = """
+ program arguments = ( "clawdbot", "gateway-daemon", "--port", "18789" )
+ pid = 456
+ """
+ let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
+ #expect(snapshot.port == 18789)
+ #expect(snapshot.bind == nil)
+ #expect(snapshot.matches(port: 18789, bind: "loopback"))
+ }
+}
+
From 8913bfbcd517157545170f94258de854f99d8007 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 19:30:01 +0000
Subject: [PATCH 068/115] refactor(macos): drop duplicate AnyCodable
---
.../Sources/Clawdbot/AgentEventsWindow.swift | 1 +
apps/macos/Sources/Clawdbot/AnyCodable.swift | 54 -------------------
.../Clawdbot/Bridge/BridgeServer.swift | 22 ++++----
.../Sources/Clawdbot/ClawdbotConfigFile.swift | 1 +
.../Sources/Clawdbot/ClawdbotPaths.swift | 6 +--
apps/macos/Sources/Clawdbot/ConfigStore.swift | 1 +
.../Clawdbot/CronJobEditor+Helpers.swift | 1 +
.../Sources/Clawdbot/CronJobEditor.swift | 1 +
.../Clawdbot/CronSettings+Actions.swift | 1 +
.../Sources/Clawdbot/WorkActivityStore.swift | 11 ++--
.../AgentEventStoreTests.swift | 5 +-
.../AnyCodableEncodingTests.swift | 2 +-
.../CronJobEditorSmokeTests.swift | 2 +-
.../ClawdbotIPCTests/CronModelsTests.swift | 2 +-
.../GatewayChannelConfigureTests.swift | 10 ++--
.../GatewayEnvironmentTests.swift | 18 ++++++-
.../LowCoverageHelperTests.swift | 3 +-
.../LowCoverageViewSmokeTests.swift | 1 +
.../MenuSessionsInjectorTests.swift | 4 +-
.../ClawdbotIPCTests/SessionDataTests.swift | 2 +-
.../SettingsViewSmokeTests.swift | 2 +-
.../SkillsSettingsSmokeTests.swift | 1 +
.../VoiceWakeForwarderTests.swift | 2 +-
.../WorkActivityStoreTests.swift | 1 +
24 files changed, 64 insertions(+), 90 deletions(-)
delete mode 100644 apps/macos/Sources/Clawdbot/AnyCodable.swift
diff --git a/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift b/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift
index f37961ae3..e3ccc87bc 100644
--- a/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift
+++ b/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import SwiftUI
@MainActor
diff --git a/apps/macos/Sources/Clawdbot/AnyCodable.swift b/apps/macos/Sources/Clawdbot/AnyCodable.swift
deleted file mode 100644
index 7c9a4668d..000000000
--- a/apps/macos/Sources/Clawdbot/AnyCodable.swift
+++ /dev/null
@@ -1,54 +0,0 @@
-import Foundation
-
-/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
-/// Marked `@unchecked Sendable` because it can hold reference types.
-struct AnyCodable: Codable, @unchecked Sendable {
- let value: Any
-
- init(_ value: Any) { self.value = value }
-
- init(from decoder: Decoder) throws {
- let container = try decoder.singleValueContainer()
- if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
- if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
- if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
- if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
- if container.decodeNil() { self.value = NSNull(); return }
- if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
- if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
- throw DecodingError.dataCorruptedError(
- in: container,
- debugDescription: "Unsupported type")
- }
-
- func encode(to encoder: Encoder) throws {
- var container = encoder.singleValueContainer()
- switch self.value {
- case let intVal as Int: try container.encode(intVal)
- case let doubleVal as Double: try container.encode(doubleVal)
- case let boolVal as Bool: try container.encode(boolVal)
- case let stringVal as String: try container.encode(stringVal)
- case is NSNull: try container.encodeNil()
- case let dict as [String: AnyCodable]: try container.encode(dict)
- case let array as [AnyCodable]: try container.encode(array)
- case let dict as [String: Any]:
- try container.encode(dict.mapValues { AnyCodable($0) })
- case let array as [Any]:
- try container.encode(array.map { AnyCodable($0) })
- case let dict as NSDictionary:
- var converted: [String: AnyCodable] = [:]
- for (k, v) in dict {
- guard let key = k as? String else { continue }
- converted[key] = AnyCodable(v)
- }
- try container.encode(converted)
- case let array as NSArray:
- try container.encode(array.map { AnyCodable($0) })
- default:
- let context = EncodingError.Context(
- codingPath: encoder.codingPath,
- debugDescription: "Unsupported type")
- throw EncodingError.invalidValue(self.value, context)
- }
- }
-}
diff --git a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
index 60d01459a..4b71e6dec 100644
--- a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
+++ b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
@@ -229,7 +229,7 @@ actor BridgeServer {
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
}
- let params: [String: AnyCodable]?
+ let params: [String: ClawdbotProtocol.AnyCodable]?
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
guard let data = json.data(using: .utf8) else {
return BridgeRPCResponse(
@@ -238,7 +238,7 @@ actor BridgeServer {
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
}
do {
- params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
+ params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
} catch {
return BridgeRPCResponse(
id: req.id,
@@ -360,16 +360,16 @@ actor BridgeServer {
"reason \(reason)",
].compactMap(\.self).joined(separator: " ยท ")
- var params: [String: AnyCodable] = [
- "text": AnyCodable(summary),
- "instanceId": AnyCodable(nodeId),
- "host": AnyCodable(host),
- "mode": AnyCodable("node"),
- "reason": AnyCodable(reason),
- "tags": AnyCodable(tags),
+ var params: [String: ClawdbotProtocol.AnyCodable] = [
+ "text": ClawdbotProtocol.AnyCodable(summary),
+ "instanceId": ClawdbotProtocol.AnyCodable(nodeId),
+ "host": ClawdbotProtocol.AnyCodable(host),
+ "mode": ClawdbotProtocol.AnyCodable("node"),
+ "reason": ClawdbotProtocol.AnyCodable(reason),
+ "tags": ClawdbotProtocol.AnyCodable(tags),
]
- if let ip { params["ip"] = AnyCodable(ip) }
- if let version { params["version"] = AnyCodable(version) }
+ if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
+ if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
await GatewayConnection.shared.sendSystemEvent(params)
}
diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift
index 433b3e1c8..a5fad5f0a 100644
--- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift
+++ b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import Foundation
enum ClawdbotConfigFile {
diff --git a/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift b/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift
index 9cd7985ea..3e32782c0 100644
--- a/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift
+++ b/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift
@@ -3,9 +3,9 @@ import Foundation
enum ClawdbotEnv {
static func path(_ key: String) -> String? {
// Normalize env overrides once so UI + file IO stay consistent.
- guard let value = ProcessInfo.processInfo.environment[key]?
- .trimmingCharacters(in: .whitespacesAndNewlines),
- !value.isEmpty
+ guard let raw = getenv(key) else { return nil }
+ let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty
else {
return nil
}
diff --git a/apps/macos/Sources/Clawdbot/ConfigStore.swift b/apps/macos/Sources/Clawdbot/ConfigStore.swift
index 9090dd1d8..93b10cff4 100644
--- a/apps/macos/Sources/Clawdbot/ConfigStore.swift
+++ b/apps/macos/Sources/Clawdbot/ConfigStore.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import Foundation
enum ConfigStore {
diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift
index ec07cc5e4..877c0c6c7 100644
--- a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift
+++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import Foundation
import SwiftUI
diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift
index 93d2615bf..144368bf1 100644
--- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift
+++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import SwiftUI
struct CronJobEditor: View {
diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift b/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift
index 8ae63704b..0de686bad 100644
--- a/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift
+++ b/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import Foundation
extension CronSettings {
diff --git a/apps/macos/Sources/Clawdbot/WorkActivityStore.swift b/apps/macos/Sources/Clawdbot/WorkActivityStore.swift
index 47d241ace..9ab5b93d4 100644
--- a/apps/macos/Sources/Clawdbot/WorkActivityStore.swift
+++ b/apps/macos/Sources/Clawdbot/WorkActivityStore.swift
@@ -1,4 +1,5 @@
import ClawdbotKit
+import ClawdbotProtocol
import Foundation
import Observation
import SwiftUI
@@ -53,7 +54,7 @@ final class WorkActivityStore {
phase: String,
name: String?,
meta: String?,
- args: [String: AnyCodable]?)
+ args: [String: ClawdbotProtocol.AnyCodable]?)
{
let toolKind = Self.mapToolKind(name)
let label = Self.buildLabel(name: name, meta: meta, args: args)
@@ -211,7 +212,7 @@ final class WorkActivityStore {
private static func buildLabel(
name: String?,
meta: String?,
- args: [String: AnyCodable]?) -> String
+ args: [String: ClawdbotProtocol.AnyCodable]?) -> String
{
let wrappedArgs = self.wrapToolArgs(args)
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
@@ -221,17 +222,17 @@ final class WorkActivityStore {
return display.label
}
- private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdbotKit.AnyCodable? {
+ private static func wrapToolArgs(_ args: [String: ClawdbotProtocol.AnyCodable]?) -> ClawdbotKit.AnyCodable? {
guard let args else { return nil }
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
return ClawdbotKit.AnyCodable(converted)
}
private static func unwrapJSONValue(_ value: Any) -> Any {
- if let dict = value as? [String: AnyCodable] {
+ if let dict = value as? [String: ClawdbotProtocol.AnyCodable] {
return dict.mapValues { self.unwrapJSONValue($0.value) }
}
- if let array = value as? [AnyCodable] {
+ if let array = value as? [ClawdbotProtocol.AnyCodable] {
return array.map { self.unwrapJSONValue($0.value) }
}
if let dict = value as? [String: Any] {
diff --git a/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift b/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift
index 1353c8b4f..1b0e75207 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift
@@ -1,5 +1,6 @@
import Foundation
import Testing
+import ClawdbotProtocol
@testable import Clawdbot
@Suite
@@ -15,7 +16,7 @@ struct AgentEventStoreTests {
seq: 1,
stream: "test",
ts: 0,
- data: [:] as [String: AnyCodable],
+ data: [:] as [String: ClawdbotProtocol.AnyCodable],
summary: nil))
#expect(store.events.count == 1)
@@ -32,7 +33,7 @@ struct AgentEventStoreTests {
seq: i,
stream: "test",
ts: Double(i),
- data: [:] as [String: AnyCodable],
+ data: [:] as [String: ClawdbotProtocol.AnyCodable],
summary: nil))
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift b/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift
index 897ab6433..cb1cec109 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift
@@ -12,7 +12,7 @@ import Testing
"null": NSNull(),
]
- let data = try JSONEncoder().encode(Clawdbot.AnyCodable(payload))
+ let data = try JSONEncoder().encode(ClawdbotProtocol.AnyCodable(payload))
let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(obj["tags"] as? [String] == ["node", "ios"])
diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift
index efa369e5c..ea5f86579 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift
@@ -35,7 +35,7 @@ struct CronJobEditorSmokeTests {
thinking: "low",
timeoutSeconds: 120,
deliver: true,
- channel: "whatsapp",
+ provider: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift
index fe478ac14..81ef2e96e 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift
@@ -31,7 +31,7 @@ struct CronModelsTests {
thinking: "low",
timeoutSeconds: 15,
deliver: true,
- channel: "whatsapp",
+ provider: "whatsapp",
to: "+15551234567",
bestEffortDeliver: false)
let data = try JSONEncoder().encode(payload)
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift
index 7893dafe7..22c83d4fd 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift
@@ -170,7 +170,7 @@ import Testing
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
- configProvider: { (url, cfg.snapshotToken()) },
+ configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session))
_ = try await conn.request(method: "status", params: nil)
@@ -186,7 +186,7 @@ import Testing
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: "a")
let conn = GatewayConnection(
- configProvider: { (url, cfg.snapshotToken()) },
+ configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session))
_ = try await conn.request(method: "status", params: nil)
@@ -203,7 +203,7 @@ import Testing
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
- configProvider: { (url, cfg.snapshotToken()) },
+ configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session))
async let r1: Data = conn.request(method: "status", params: nil)
@@ -218,7 +218,7 @@ import Testing
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
- configProvider: { (url, cfg.snapshotToken()) },
+ configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session))
_ = try await conn.request(method: "status", params: nil)
@@ -239,7 +239,7 @@ import Testing
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
- configProvider: { (url, cfg.snapshotToken()) },
+ configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let stream = await conn.subscribe(bufferingNewest: 10)
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
index 0e4e35e6f..e27df5b21 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
@@ -20,11 +20,27 @@ import Testing
}
@Test func gatewayPortDefaultsAndRespectsOverride() {
+ let envKey = "CLAWDBOT_CONFIG_PATH"
+ let previousEnv = getenv(envKey).map { String(cString: $0) }
+ let configPath = FileManager.default.temporaryDirectory
+ .appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
+ .path
+ setenv(envKey, configPath, 1)
+ defer {
+ if let previousEnv {
+ setenv(envKey, previousEnv, 1)
+ } else {
+ unsetenv(envKey)
+ }
+ }
+
+ UserDefaults.standard.removeObject(forKey: "gatewayPort")
+ defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
+
let defaultPort = GatewayEnvironment.gatewayPort()
#expect(defaultPort == 18789)
UserDefaults.standard.set(19999, forKey: "gatewayPort")
- defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
#expect(GatewayEnvironment.gatewayPort() == 19999)
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
index e7b745b08..080a29589 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
@@ -1,6 +1,7 @@
import AppKit
import Foundation
import Testing
+import ClawdbotProtocol
@testable import Clawdbot
@@ -23,7 +24,7 @@ struct LowCoverageHelperTests {
#expect(dict["list"]?.arrayValue?.count == 2)
let foundation = any.foundationValue as? [String: Any]
- #expect(foundation?["title"] as? String == "Hello")
+ #expect((foundation?["title"] as? String) == "Hello")
}
@Test func attributedStringStripsForegroundColor() {
diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift
index 98018035b..27aff597e 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift
@@ -1,6 +1,7 @@
import AppKit
import SwiftUI
import Testing
+import ClawdbotProtocol
@testable import Clawdbot
diff --git a/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift
index 2e5bafcfd..cae8b7be4 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift
@@ -30,7 +30,7 @@ struct MenuSessionsInjectorTests {
key: "main",
kind: .direct,
displayName: nil,
- surface: nil,
+ provider: nil,
subject: nil,
room: nil,
space: nil,
@@ -47,7 +47,7 @@ struct MenuSessionsInjectorTests {
key: "discord:group:alpha",
kind: .group,
displayName: nil,
- surface: nil,
+ provider: nil,
subject: nil,
room: nil,
space: nil,
diff --git a/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift
index 96f21ab0c..d52c9aecb 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift
@@ -28,7 +28,7 @@ struct SessionDataTests {
key: "user@example.com",
kind: .direct,
displayName: nil,
- surface: nil,
+ provider: nil,
subject: nil,
room: nil,
space: nil,
diff --git a/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift
index d3fe9e07d..c59aba43a 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift
@@ -45,7 +45,7 @@ struct SettingsViewSmokeTests {
thinking: "low",
timeoutSeconds: 30,
deliver: true,
- channel: "sms",
+ provider: "sms",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
diff --git a/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift
index afa028dcf..f2d8a61bf 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift
@@ -1,4 +1,5 @@
import Testing
+import ClawdbotProtocol
@testable import Clawdbot
@Suite(.serialized)
diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift
index b8318c3fe..35a96626b 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift
@@ -17,6 +17,6 @@ import Testing
#expect(opts.thinking == "low")
#expect(opts.deliver == true)
#expect(opts.to == nil)
- #expect(opts.channel == .last)
+ #expect(opts.provider == .last)
}
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift b/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift
index 50a4e69d6..983c394b3 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift
@@ -1,5 +1,6 @@
import Foundation
import Testing
+import ClawdbotProtocol
@testable import Clawdbot
@Suite
From f10d1fd9ac8dbf5f66088a999020a6da10727a26 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 19:42:22 +0000
Subject: [PATCH 069/115] fix(macos): stabilize node runtime + menu sessions
---
.../Sources/Clawdbot/ClawdbotConfigFile.swift | 3 +-
.../Clawdbot/MenuSessionsInjector.swift | 22 +++++--
.../Clawdbot/NodeMode/MacNodeRuntime.swift | 62 ++++++++++++++-----
.../ClawdbotConfigFileTests.swift | 2 +-
.../GatewayEnvironmentTests.swift | 2 +-
5 files changed, 68 insertions(+), 23 deletions(-)
diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift
index a5fad5f0a..83d38b79a 100644
--- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift
+++ b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift
@@ -33,7 +33,8 @@ enum ClawdbotConfigFile {
}
static func saveDict(_ dict: [String: Any]) {
- if ProcessInfo.processInfo.isNixMode { return }
+ // Nix mode disables config writes in production, but tests rely on saving temp configs.
+ if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift
index c7fdcb545..286f460f7 100644
--- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift
+++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift
@@ -110,8 +110,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
-
- guard self.isControlChannelConnected else { return }
+ let isConnected = self.isControlChannelConnected
var cursor = insertIndex
var headerView: NSView?
@@ -132,7 +131,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
headerItem.tag = self.tag
headerItem.isEnabled = false
let hosted = self.makeHostedView(
- rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
+ rootView: AnyView(MenuSessionsHeaderView(
+ count: rows.count,
+ statusText: isConnected ? nil : "Gateway disconnected")),
width: width,
highlighted: false)
headerItem.view = hosted
@@ -163,16 +164,29 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
+ let statusText = isConnected
+ ? (self.cachedErrorText ?? "Loading sessionsโฆ")
+ : "Gateway disconnected"
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
- statusText: self.cachedErrorText ?? "Loading sessionsโฆ")),
+ statusText: statusText)),
width: width,
highlighted: false)
headerItem.view = hosted
headerView = hosted
menu.insertItem(headerItem, at: cursor)
cursor += 1
+
+ if !isConnected {
+ menu.insertItem(
+ self.makeMessageItem(
+ text: "Connect the gateway to see sessions",
+ symbolName: "bolt.slash",
+ width: width),
+ at: cursor)
+ cursor += 1
+ }
}
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift
index cf0e28372..dc4ae53e1 100644
--- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift
+++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift
@@ -1,12 +1,46 @@
import AppKit
import ClawdbotIPC
import ClawdbotKit
+import CoreLocation
import Foundation
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
- @MainActor private let screenRecorder = ScreenRecordService()
- @MainActor private let locationService = MacNodeLocationService()
+ private struct LocationPermissionRequired: Error {}
+
+ @MainActor
+ private static func currentLocation(
+ desiredAccuracy: ClawdbotLocationAccuracy,
+ maxAgeMs: Int?,
+ timeoutMs: Int?) async throws -> (location: CLLocation, isPrecise: Bool)
+ {
+ let locationService = MacNodeLocationService()
+ if locationService.authorizationStatus() != .authorizedAlways {
+ throw LocationPermissionRequired()
+ }
+ let location = try await locationService.currentLocation(
+ desiredAccuracy: desiredAccuracy,
+ maxAgeMs: maxAgeMs,
+ timeoutMs: timeoutMs)
+ let isPrecise = locationService.accuracyAuthorization() == .fullAccuracy
+ return (location: location, isPrecise: isPrecise)
+ }
+
+ @MainActor
+ private static func recordScreen(
+ screenIndex: Int?,
+ durationMs: Int?,
+ fps: Double?,
+ includeAudio: Bool?) async throws -> (path: String, hasAudio: Bool)
+ {
+ let screenRecorder = ScreenRecordService()
+ return try await screenRecorder.record(
+ screenIndex: screenIndex,
+ durationMs: durationMs,
+ fps: fps,
+ includeAudio: includeAudio,
+ outPath: nil)
+ }
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
@@ -212,21 +246,11 @@ actor MacNodeRuntime {
ClawdbotLocationGetParams()
let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced)
- let status = await self.locationService.authorizationStatus()
- if status != .authorizedAlways {
- return BridgeInvokeResponse(
- id: req.id,
- ok: false,
- error: ClawdbotNodeError(
- code: .unavailable,
- message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
- }
do {
- let location = try await self.locationService.currentLocation(
+ let (location, isPrecise) = try await Self.currentLocation(
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
- let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdbotLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
@@ -239,6 +263,13 @@ actor MacNodeRuntime {
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
+ } catch is LocationPermissionRequired {
+ return BridgeInvokeResponse(
+ id: req.id,
+ ok: false,
+ error: ClawdbotNodeError(
+ code: .unavailable,
+ message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
} catch MacNodeLocationService.Error.timeout {
return BridgeInvokeResponse(
id: req.id,
@@ -265,12 +296,11 @@ actor MacNodeRuntime {
code: .invalidRequest,
message: "INVALID_REQUEST: screen format must be mp4")
}
- let res = try await self.screenRecorder.record(
+ let res = try await Self.recordScreen(
screenIndex: params.screenIndex,
durationMs: params.durationMs,
fps: params.fps,
- includeAudio: params.includeAudio,
- outPath: nil)
+ includeAudio: params.includeAudio)
defer { try? FileManager.default.removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ScreenPayload: Encodable {
diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift
index b976541f6..2a5c70d60 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift
@@ -2,7 +2,7 @@ import Foundation
import Testing
@testable import Clawdbot
-@Suite
+@Suite(.serialized)
struct ClawdbotConfigFileTests {
@Test
func configPathRespectsEnvOverride() {
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
index e27df5b21..00ce5ab1d 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
@@ -2,7 +2,7 @@ import Foundation
import Testing
@testable import Clawdbot
-@Suite struct GatewayEnvironmentTests {
+@Suite(.serialized) struct GatewayEnvironmentTests {
@Test func semverParsesCommonForms() {
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
From 8c48220a60cab0c1a5825cbdecf7bf10539b8ae0 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:37:48 +0100
Subject: [PATCH 070/115] docs: require tmux for 1password skill
---
skills/1password/SKILL.md | 22 +++++++++++++---------
1 file changed, 13 insertions(+), 9 deletions(-)
diff --git a/skills/1password/SKILL.md b/skills/1password/SKILL.md
index 7aea6b8c1..7bac1be06 100644
--- a/skills/1password/SKILL.md
+++ b/skills/1password/SKILL.md
@@ -19,26 +19,29 @@ Follow the official CLI get-started steps. Don't guess install commands.
1. Check OS + shell.
2. Verify CLI present: `op --version`.
3. Confirm desktop app integration is enabled (per get-started) and the app is unlocked.
-4. Sign in / authorize this terminal: `op signin` (expect an app prompt).
-5. If multiple accounts: use `--account` or `OP_ACCOUNT`.
-6. Verify access: `op whoami` or `op account list`.
+4. REQUIRED: create a fresh tmux session for all `op` commands (no direct `op` calls outside tmux).
+5. Sign in / authorize inside tmux: `op signin` (expect app prompt).
+6. Verify access inside tmux: `op whoami` (must succeed before any secret read).
+7. If multiple accounts: use `--account` or `OP_ACCOUNT`.
-## Avoid repeated auth prompts (tmux)
+## REQUIRED tmux session (T-Max)
-The bash tool uses a fresh TTY per command, so app integration may prompt every time. To reuse authorization, run multiple `op` commands inside a single tmux session.
+The shell tool uses a fresh TTY per command. To avoid re-prompts and failures, always run `op` inside a dedicated tmux session with a fresh socket/session name.
-Example (see `tmux` skill for socket conventions):
+Example (see `tmux` skill for socket conventions, do not reuse old session names):
```bash
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
mkdir -p "$SOCKET_DIR"
-SOCKET="$SOCKET_DIR/clawdbot.sock"
-SESSION=op-auth
+SOCKET="$SOCKET_DIR/clawdbot-op.sock"
+SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op signin --account my.1password.com" Enter
+tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op vault list" Enter
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
+tmux -S "$SOCKET" kill-session -t "$SESSION"
```
## Guardrails
@@ -46,4 +49,5 @@ tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
- Never paste secrets into logs, chat, or code.
- Prefer `op run` / `op inject` over writing secrets to disk.
- If sign-in without app integration is needed, use `op account add`.
-- If a command returns "account is not signed in", re-run `op signin` and authorize in the app.
+- If a command returns "account is not signed in", re-run `op signin` inside tmux and authorize in the app.
+- Do not run `op` outside tmux; stop and ask if tmux is unavailable.
From ef644b836982fbbb24500fe15a82a99bffecefcb Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:40:24 +0100
Subject: [PATCH 071/115] fix: suppress whatsapp pairing in self-phone mode
---
CHANGELOG.md | 1 +
docs/providers/whatsapp.md | 2 +
src/commands/onboard-providers.ts | 89 ++++++++++++++++---------
src/config/schema.ts | 3 +
src/config/types.ts | 7 ++
src/config/zod-schema.ts | 2 +
src/web/accounts.ts | 2 +
src/web/inbound.ts | 9 +++
src/web/monitor-inbox.test.ts | 104 ++++++++++++++++++++++++++++++
9 files changed, 187 insertions(+), 32 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbb63fe87..9e3483525 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@
- macOS: prevent gateway launchd startup race where the app could kill a just-started gateway; avoid unnecessary `bootout` and ensure the job is enabled at login. Fixes #306. Thanks @gupsammy for PR #387.
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
+- WhatsApp: add self-phone mode to suppress pairing replies for outbound DMs and prompt during onboarding.
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380.
diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md
index 42dfb0572..dfbe1d0dd 100644
--- a/docs/providers/whatsapp.md
+++ b/docs/providers/whatsapp.md
@@ -61,6 +61,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp `; codes expire after 1 hour).
- Open: requires `whatsapp.allowFrom` to include `"*"`.
- Self messages are always allowed; โself-chat modeโ still requires `whatsapp.allowFrom` to include your own number.
+- **Same-phone mode**: set `whatsapp.selfChatMode=true` when Clawdbot runs on your personal WhatsApp number. This suppresses pairing replies for outbound DMs.
- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).
- `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
@@ -139,6 +140,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
## Config quick map
- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
+- `whatsapp.selfChatMode` (same-phone setup; suppress pairing replies for outbound DMs).
- `whatsapp.allowFrom` (DM allowlist).
- `whatsapp.accounts..*` (per-account settings + optional `authDir`).
- `whatsapp.groupAllowFrom` (group sender allowlist).
diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts
index 77d1bf174..ae81e683a 100644
--- a/src/commands/onboard-providers.ts
+++ b/src/commands/onboard-providers.ts
@@ -202,6 +202,19 @@ function setWhatsAppAllowFrom(cfg: ClawdbotConfig, allowFrom?: string[]) {
};
}
+function setWhatsAppSelfChatMode(
+ cfg: ClawdbotConfig,
+ selfChatMode?: boolean,
+) {
+ return {
+ ...cfg,
+ whatsapp: {
+ ...cfg.whatsapp,
+ selfChatMode,
+ },
+ };
+}
+
function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
@@ -415,8 +428,10 @@ async function promptWhatsAppAllowFrom(
],
})) as DmPolicy;
- const next = setWhatsAppDmPolicy(cfg, policy);
- if (policy === "open") return setWhatsAppAllowFrom(next, ["*"]);
+ let next = setWhatsAppDmPolicy(cfg, policy);
+ if (policy === "open") {
+ next = setWhatsAppAllowFrom(next, ["*"]);
+ }
if (policy === "disabled") return next;
const options =
@@ -439,38 +454,48 @@ async function promptWhatsAppAllowFrom(
options: options.map((opt) => ({ value: opt.value, label: opt.label })),
})) as (typeof options)[number]["value"];
- if (mode === "keep") return next;
- if (mode === "unset") return setWhatsAppAllowFrom(next, undefined);
+ if (mode === "keep") {
+ // Keep allowFrom as-is.
+ } else if (mode === "unset") {
+ next = setWhatsAppAllowFrom(next, undefined);
+ } else {
+ const allowRaw = await prompter.text({
+ message: "Allowed sender numbers (comma-separated, E.164)",
+ placeholder: "+15555550123, +447700900123",
+ validate: (value) => {
+ const raw = String(value ?? "").trim();
+ if (!raw) return "Required";
+ const parts = raw
+ .split(/[\n,;]+/g)
+ .map((p) => p.trim())
+ .filter(Boolean);
+ if (parts.length === 0) return "Required";
+ for (const part of parts) {
+ if (part === "*") continue;
+ const normalized = normalizeE164(part);
+ if (!normalized) return `Invalid number: ${part}`;
+ }
+ return undefined;
+ },
+ });
- const allowRaw = await prompter.text({
- message: "Allowed sender numbers (comma-separated, E.164)",
- placeholder: "+15555550123, +447700900123",
- validate: (value) => {
- const raw = String(value ?? "").trim();
- if (!raw) return "Required";
- const parts = raw
- .split(/[\n,;]+/g)
- .map((p) => p.trim())
- .filter(Boolean);
- if (parts.length === 0) return "Required";
- for (const part of parts) {
- if (part === "*") continue;
- const normalized = normalizeE164(part);
- if (!normalized) return `Invalid number: ${part}`;
- }
- return undefined;
- },
+ const parts = String(allowRaw)
+ .split(/[\n,;]+/g)
+ .map((p) => p.trim())
+ .filter(Boolean);
+ const normalized = parts.map((part) =>
+ part === "*" ? "*" : normalizeE164(part),
+ );
+ const unique = [...new Set(normalized.filter(Boolean))];
+ next = setWhatsAppAllowFrom(next, unique);
+ }
+
+ const selfChatMode = await prompter.confirm({
+ message:
+ "Same-phone setup? (using your personal WhatsApp number for Clawdbot)",
+ initialValue: next.whatsapp?.selfChatMode ?? false,
});
-
- const parts = String(allowRaw)
- .split(/[\n,;]+/g)
- .map((p) => p.trim())
- .filter(Boolean);
- const normalized = parts.map((part) =>
- part === "*" ? "*" : normalizeE164(part),
- );
- const unique = [...new Set(normalized.filter(Boolean))];
- return setWhatsAppAllowFrom(next, unique);
+ return setWhatsAppSelfChatMode(next, selfChatMode);
}
type SetupProvidersOptions = {
diff --git a/src/config/schema.ts b/src/config/schema.ts
index da58d2a56..639c80f9f 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -113,6 +113,7 @@ const FIELD_LABELS: Record = {
"telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
"telegram.retry.jitter": "Telegram Retry Jitter",
"whatsapp.dmPolicy": "WhatsApp DM Policy",
+ "whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"signal.dmPolicy": "Signal DM Policy",
"imessage.dmPolicy": "iMessage DM Policy",
"discord.dm.policy": "Discord DM Policy",
@@ -176,6 +177,8 @@ const FIELD_HELP: Record = {
"Jitter factor (0-1) applied to Telegram retry delays.",
"whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].',
+ "whatsapp.selfChatMode":
+ "Same-phone setup (bot uses your personal WhatsApp number). Suppresses pairing replies for outbound DMs.",
"signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires signal.allowFrom=["*"].',
"imessage.dmPolicy":
diff --git a/src/config/types.ts b/src/config/types.ts
index a9846c4e6..fcb499022 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -97,6 +97,11 @@ export type WhatsAppConfig = {
accounts?: Record;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
+ /**
+ * Same-phone setup (bot uses your personal WhatsApp number).
+ * When true, suppress pairing replies for outbound DMs.
+ */
+ selfChatMode?: boolean;
/** Optional allowlist for WhatsApp direct chats (E.164). */
allowFrom?: string[];
/** Optional allowlist for WhatsApp group senders (E.164). */
@@ -127,6 +132,8 @@ export type WhatsAppAccountConfig = {
authDir?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
+ /** Same-phone setup for this account (suppresses pairing replies for outbound DMs). */
+ selfChatMode?: boolean;
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index a1d42b96e..e6ca4099a 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -764,6 +764,7 @@ export const ClawdbotSchema = z.object({
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
+ selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
@@ -796,6 +797,7 @@ export const ClawdbotSchema = z.object({
)
.optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
+ selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
diff --git a/src/web/accounts.ts b/src/web/accounts.ts
index a9fcffaad..1ed06a4a3 100644
--- a/src/web/accounts.ts
+++ b/src/web/accounts.ts
@@ -12,6 +12,7 @@ export type ResolvedWhatsAppAccount = {
enabled: boolean;
authDir: string;
isLegacyAuthDir: boolean;
+ selfChatMode?: boolean;
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
@@ -103,6 +104,7 @@ export function resolveWhatsAppAccount(params: {
enabled,
authDir,
isLegacyAuthDir: isLegacy,
+ selfChatMode: accountCfg?.selfChatMode ?? params.cfg.whatsapp?.selfChatMode,
allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom,
groupAllowFrom:
accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom,
diff --git a/src/web/inbound.ts b/src/web/inbound.ts
index 7c73fb7b1..c70b7ab47 100644
--- a/src/web/inbound.ts
+++ b/src/web/inbound.ts
@@ -202,6 +202,9 @@ export async function monitorWebInbox(options: {
: undefined);
const isSamePhone = from === selfE164;
const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom);
+ const isFromMe = Boolean(msg.key?.fromMe);
+ const selfChatMode = account.selfChatMode ?? false;
+ const selfPhoneMode = selfChatMode || isSelfChat;
// Pre-compute normalized allowlists for filtering
const dmHasWildcard = allowFrom?.includes("*") ?? false;
@@ -246,6 +249,12 @@ export async function monitorWebInbox(options: {
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled"
if (!group) {
+ if (isFromMe && !isSamePhone && selfPhoneMode) {
+ logVerbose(
+ "Skipping outbound self-phone DM (fromMe); no pairing reply needed.",
+ );
+ continue;
+ }
if (dmPolicy === "disabled") {
logVerbose("Blocked dm (dmPolicy: disabled)");
continue;
diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts
index 6abc8e6fa..3ae395d66 100644
--- a/src/web/monitor-inbox.test.ts
+++ b/src/web/monitor-inbox.test.ts
@@ -1099,6 +1099,110 @@ describe("web monitor inbox", () => {
await listener.close();
});
+ it("skips pairing replies for outbound DMs in same-phone mode", async () => {
+ mockLoadConfig.mockReturnValue({
+ whatsapp: {
+ dmPolicy: "pairing",
+ selfChatMode: true,
+ },
+ messages: {
+ messagePrefix: undefined,
+ responsePrefix: undefined,
+ },
+ });
+
+ const onMessage = vi.fn();
+ const listener = await monitorWebInbox({ verbose: false, onMessage });
+ const sock = await createWaSocket();
+
+ const upsert = {
+ type: "notify",
+ messages: [
+ {
+ key: {
+ id: "fromme-1",
+ fromMe: true,
+ remoteJid: "999@s.whatsapp.net",
+ },
+ message: { conversation: "hello" },
+ messageTimestamp: 1_700_000_000,
+ },
+ ],
+ };
+
+ sock.ev.emit("messages.upsert", upsert);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(onMessage).not.toHaveBeenCalled();
+ expect(upsertPairingRequestMock).not.toHaveBeenCalled();
+ expect(sock.sendMessage).not.toHaveBeenCalled();
+
+ mockLoadConfig.mockReturnValue({
+ whatsapp: {
+ allowFrom: ["*"],
+ },
+ messages: {
+ messagePrefix: undefined,
+ responsePrefix: undefined,
+ },
+ });
+
+ await listener.close();
+ });
+
+ it("still pairs outbound DMs when same-phone mode is disabled", async () => {
+ mockLoadConfig.mockReturnValue({
+ whatsapp: {
+ dmPolicy: "pairing",
+ selfChatMode: false,
+ },
+ messages: {
+ messagePrefix: undefined,
+ responsePrefix: undefined,
+ },
+ });
+
+ const onMessage = vi.fn();
+ const listener = await monitorWebInbox({ verbose: false, onMessage });
+ const sock = await createWaSocket();
+
+ const upsert = {
+ type: "notify",
+ messages: [
+ {
+ key: {
+ id: "fromme-2",
+ fromMe: true,
+ remoteJid: "999@s.whatsapp.net",
+ },
+ message: { conversation: "hello again" },
+ messageTimestamp: 1_700_000_000,
+ },
+ ],
+ };
+
+ sock.ev.emit("messages.upsert", upsert);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(onMessage).not.toHaveBeenCalled();
+ expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
+ expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
+ text: expect.stringContaining("Pairing code: PAIRCODE"),
+ });
+
+ mockLoadConfig.mockReturnValue({
+ whatsapp: {
+ allowFrom: ["*"],
+ },
+ messages: {
+ messagePrefix: undefined,
+ responsePrefix: undefined,
+ },
+ });
+
+ await listener.close();
+ });
+
it("handles append messages by marking them read but skipping auto-reply", async () => {
const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage });
From 54960d1380e35eb0c36a7ebacc5e754c8a7de52e Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:49:44 +0100
Subject: [PATCH 072/115] fix: refine whatsapp personal phone onboarding
---
CHANGELOG.md | 2 +-
docs/providers/whatsapp.md | 20 ++++++++-
src/commands/onboard-providers.ts | 74 ++++++++++++++++++++++++++++---
3 files changed, 87 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e3483525..82a4557e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,7 +22,7 @@
- macOS: prevent gateway launchd startup race where the app could kill a just-started gateway; avoid unnecessary `bootout` and ensure the job is enabled at login. Fixes #306. Thanks @gupsammy for PR #387.
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
-- WhatsApp: add self-phone mode to suppress pairing replies for outbound DMs and prompt during onboarding.
+- WhatsApp: add self-phone mode (no pairing replies for outbound DMs) and onboarding prompt for personal vs separate numbers (auto allowlist + response prefix for personal).
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380.
diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md
index dfbe1d0dd..a777af70f 100644
--- a/docs/providers/whatsapp.md
+++ b/docs/providers/whatsapp.md
@@ -61,7 +61,25 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp `; codes expire after 1 hour).
- Open: requires `whatsapp.allowFrom` to include `"*"`.
- Self messages are always allowed; โself-chat modeโ still requires `whatsapp.allowFrom` to include your own number.
-- **Same-phone mode**: set `whatsapp.selfChatMode=true` when Clawdbot runs on your personal WhatsApp number. This suppresses pairing replies for outbound DMs.
+
+### Same-phone mode (personal number)
+If you run Clawdbot on your **personal WhatsApp number**, set:
+
+```json
+{
+ "whatsapp": {
+ "selfChatMode": true
+ }
+}
+```
+
+Behavior:
+- Suppresses pairing replies for **outbound DMs** (prevents spamming contacts).
+- Inbound unknown senders still follow `whatsapp.dmPolicy`.
+
+Recommended for personal numbers:
+- Set `whatsapp.dmPolicy="allowlist"` and add your number to `whatsapp.allowFrom`.
+- Set `messages.responsePrefix` (for example, `[clawdbot]`) so replies are clearly labeled.
- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).
- `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts
index ae81e683a..d489ff872 100644
--- a/src/commands/onboard-providers.ts
+++ b/src/commands/onboard-providers.ts
@@ -202,6 +202,19 @@ function setWhatsAppAllowFrom(cfg: ClawdbotConfig, allowFrom?: string[]) {
};
}
+function setMessagesResponsePrefix(
+ cfg: ClawdbotConfig,
+ responsePrefix?: string,
+) {
+ return {
+ ...cfg,
+ messages: {
+ ...cfg.messages,
+ responsePrefix,
+ },
+ };
+}
+
function setWhatsAppSelfChatMode(
cfg: ClawdbotConfig,
selfChatMode?: boolean,
@@ -403,6 +416,7 @@ async function promptWhatsAppAllowFrom(
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
const existingLabel =
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
+ const existingResponsePrefix = cfg.messages?.responsePrefix;
await prompter.note(
[
@@ -418,6 +432,56 @@ async function promptWhatsAppAllowFrom(
"WhatsApp DM access",
);
+ const phoneMode = (await prompter.select({
+ message: "WhatsApp phone setup",
+ options: [
+ { value: "personal", label: "This is my personal phone number" },
+ { value: "separate", label: "Separate phone just for Clawdbot" },
+ ],
+ })) as "personal" | "separate";
+
+ if (phoneMode === "personal") {
+ const entry = await prompter.text({
+ message: "Your WhatsApp number (E.164)",
+ placeholder: "+15555550123",
+ initialValue: existingAllowFrom[0],
+ validate: (value) => {
+ const raw = String(value ?? "").trim();
+ if (!raw) return "Required";
+ const normalized = normalizeE164(raw);
+ if (!normalized) return `Invalid number: ${raw}`;
+ return undefined;
+ },
+ });
+ const normalized = normalizeE164(String(entry).trim());
+ const merged = [
+ ...existingAllowFrom
+ .filter((item) => item !== "*")
+ .map((item) => normalizeE164(item))
+ .filter(Boolean),
+ normalized,
+ ];
+ const unique = [...new Set(merged.filter(Boolean))];
+ let next = setWhatsAppSelfChatMode(cfg, true);
+ next = setWhatsAppDmPolicy(next, "allowlist");
+ next = setWhatsAppAllowFrom(next, unique);
+ if (existingResponsePrefix === undefined) {
+ next = setMessagesResponsePrefix(next, "[clawdbot]");
+ }
+ await prompter.note(
+ [
+ "Personal phone mode enabled.",
+ "- dmPolicy set to allowlist (pairing skipped)",
+ `- allowFrom includes ${normalized}`,
+ existingResponsePrefix === undefined
+ ? "- responsePrefix set to [clawdbot]"
+ : "- responsePrefix left unchanged",
+ ].join("\n"),
+ "WhatsApp personal phone",
+ );
+ return next;
+ }
+
const policy = (await prompter.select({
message: "WhatsApp DM policy",
options: [
@@ -428,7 +492,8 @@ async function promptWhatsAppAllowFrom(
],
})) as DmPolicy;
- let next = setWhatsAppDmPolicy(cfg, policy);
+ let next = setWhatsAppSelfChatMode(cfg, false);
+ next = setWhatsAppDmPolicy(next, policy);
if (policy === "open") {
next = setWhatsAppAllowFrom(next, ["*"]);
}
@@ -490,12 +555,7 @@ async function promptWhatsAppAllowFrom(
next = setWhatsAppAllowFrom(next, unique);
}
- const selfChatMode = await prompter.confirm({
- message:
- "Same-phone setup? (using your personal WhatsApp number for Clawdbot)",
- initialValue: next.whatsapp?.selfChatMode ?? false,
- });
- return setWhatsAppSelfChatMode(next, selfChatMode);
+ return next;
}
type SetupProvidersOptions = {
From d45fcc44da9827cbddf68e83973fae003a120503 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:13:21 +0000
Subject: [PATCH 073/115] refactor(macos): move launchctl + plist snapshot
---
.../Clawdbot/GatewayLaunchAgentManager.swift | 142 ++++++------------
.../Clawdbot/GatewayProcessManager.swift | 2 +-
apps/macos/Sources/Clawdbot/Launchctl.swift | 82 ++++++++++
.../GatewayLaunchAgentManagerTests.swift | 64 ++++----
4 files changed, 161 insertions(+), 129 deletions(-)
create mode 100644 apps/macos/Sources/Clawdbot/Launchctl.swift
diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
index 162820b6e..f97ae9fd9 100644
--- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
@@ -43,15 +43,15 @@ enum GatewayLaunchAgentManager {
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
}
- static func status() async -> Bool {
+ static func isLoaded() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
- let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return result.status == 0
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled {
- _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
+ _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
@@ -60,23 +60,31 @@ enum GatewayLaunchAgentManager {
}
let desiredBind = self.preferredGatewayBind() ?? "loopback"
- self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
- self.writePlist(bundlePath: bundlePath, port: port)
+ let desiredToken = self.preferredGatewayToken()
+ let desiredPassword = self.preferredGatewayPassword()
+ let desiredConfig = DesiredConfig(port: port, bind: desiredBind, token: desiredToken, password: desiredPassword)
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
- if let snapshot = await self.gatewayJobSnapshot(),
- snapshot.matches(port: port, bind: desiredBind)
+ let loaded = await self.isLoaded()
+ if loaded,
+ let existing = self.readPlistConfig(),
+ existing.matches(desiredConfig)
{
self.logger.info("launchd job already loaded with desired config; skipping bootout")
await self.ensureEnabled()
- _ = await self.runLaunchctl(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ _ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
+ self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
+ self.writePlist(bundlePath: bundlePath, port: port)
+
await self.ensureEnabled()
- _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
- let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
+ if loaded {
+ _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ }
+ let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)")
@@ -89,13 +97,14 @@ enum GatewayLaunchAgentManager {
}
self.logger.info("launchd disable requested")
- _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ await self.ensureDisabled()
try? FileManager.default.removeItem(at: self.plistURL)
return nil
}
static func kickstart() async {
- _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ _ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
private static func writePlist(bundlePath: String, port: Int) {
@@ -221,40 +230,39 @@ enum GatewayLaunchAgentManager {
.replacingOccurrences(of: "'", with: "'")
}
- private struct LaunchctlResult {
- let status: Int32
- let output: String
+ private struct DesiredConfig: Equatable {
+ let port: Int
+ let bind: String
+ let token: String?
+ let password: String?
}
- struct LaunchdJobSnapshot: Equatable {
- let pid: Int?
+ private struct InstalledConfig: Equatable {
let port: Int?
let bind: String?
+ let token: String?
+ let password: String?
- func matches(port: Int, bind: String) -> Bool {
- guard self.port == port else { return false }
- if let bindValue = self.bind {
- return bindValue == bind
- }
+ func matches(_ desired: DesiredConfig) -> Bool {
+ guard self.port == desired.port else { return false }
+ guard (self.bind ?? "loopback") == desired.bind else { return false }
+ guard self.token == desired.token else { return false }
+ guard self.password == desired.password else { return false }
return true
}
}
- static func parseLaunchctlPrintSnapshot(_ output: String) -> LaunchdJobSnapshot {
- let pid = self.extractIntValue(output: output, key: "pid")
- let port = self.extractFlagIntValue(output: output, flag: "--port")
- let bind = self.extractFlagStringValue(output: output, flag: "--bind")?.lowercased()
- return LaunchdJobSnapshot(pid: pid, port: port, bind: bind)
- }
-
- private static func gatewayJobSnapshot() async -> LaunchdJobSnapshot? {
- let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
- guard result.status == 0 else { return nil }
- return self.parseLaunchctlPrintSnapshot(result.output)
+ private static func readPlistConfig() -> InstalledConfig? {
+ guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
+ return InstalledConfig(
+ port: snapshot.port,
+ bind: snapshot.bind,
+ token: snapshot.token,
+ password: snapshot.password)
}
private static func ensureEnabled() async {
- let result = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
@@ -264,65 +272,15 @@ enum GatewayLaunchAgentManager {
}
}
- private static func extractIntValue(output: String, key: String) -> Int? {
- // launchctl print commonly emits `pid = 123`
- guard let range = output.range(of: "\(key) =") else { return nil }
- var idx = range.upperBound
- while idx < output.endIndex, output[idx].isWhitespace { idx = output.index(after: idx) }
- var end = idx
- while end < output.endIndex, output[end].isNumber { end = output.index(after: end) }
- guard end > idx else { return nil }
- return Int(output[idx.. Int? {
- guard let raw = self.extractFlagStringValue(output: output, flag: flag) else { return nil }
- return Int(raw)
- }
-
- private static func extractFlagStringValue(output: String, flag: String) -> String? {
- guard let range = output.range(of: flag) else { return nil }
- var idx = range.upperBound
- while idx < output.endIndex {
- let ch = output[idx]
- if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "=" || ch == "\"" || ch == "'" {
- idx = output.index(after: idx)
- continue
- }
- break
+ private static func ensureDisabled() async {
+ let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ guard result.status != 0 else { return }
+ let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ if msg.isEmpty {
+ self.logger.warning("launchd disable failed")
+ } else {
+ self.logger.warning("launchd disable failed: \(msg)")
}
- guard idx < output.endIndex else { return nil }
- var end = idx
- while end < output.endIndex {
- let ch = output[end]
- if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "\"" || ch == "'" || ch == "\n" || ch == "\r" {
- break
- }
- end = output.index(after: end)
- }
- let token = output[idx.. LaunchctlResult {
- await Task.detached(priority: .utility) { () -> LaunchctlResult in
- let process = Process()
- process.launchPath = "/bin/launchctl"
- process.arguments = args
- let pipe = Pipe()
- process.standardOutput = pipe
- process.standardError = pipe
- do {
- try process.run()
- process.waitUntilExit()
- let data = pipe.fileHandleForReading.readToEndSafely()
- let output = String(data: data, encoding: .utf8) ?? ""
- return LaunchctlResult(status: process.terminationStatus, output: output)
- } catch {
- return LaunchctlResult(status: -1, output: error.localizedDescription)
- }
- }.value
}
}
diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
index 62745cc66..3d046d855 100644
--- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
@@ -70,7 +70,7 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return }
guard !AppStateStore.attachExistingGatewayOnly else { return }
- let enabled = await GatewayLaunchAgentManager.status()
+ let enabled = await GatewayLaunchAgentManager.isLoaded()
guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()
diff --git a/apps/macos/Sources/Clawdbot/Launchctl.swift b/apps/macos/Sources/Clawdbot/Launchctl.swift
new file mode 100644
index 000000000..9a0cee654
--- /dev/null
+++ b/apps/macos/Sources/Clawdbot/Launchctl.swift
@@ -0,0 +1,82 @@
+import Foundation
+
+enum Launchctl {
+ struct Result: Sendable {
+ let status: Int32
+ let output: String
+ }
+
+ @discardableResult
+ static func run(_ args: [String]) async -> Result {
+ await Task.detached(priority: .utility) { () -> Result in
+ let process = Process()
+ process.launchPath = "/bin/launchctl"
+ process.arguments = args
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = pipe
+ do {
+ try process.run()
+ process.waitUntilExit()
+ let data = pipe.fileHandleForReading.readToEndSafely()
+ let output = String(data: data, encoding: .utf8) ?? ""
+ return Result(status: process.terminationStatus, output: output)
+ } catch {
+ return Result(status: -1, output: error.localizedDescription)
+ }
+ }.value
+ }
+}
+
+struct LaunchAgentPlistSnapshot: Equatable, Sendable {
+ let programArguments: [String]
+ let environment: [String: String]
+
+ let port: Int?
+ let bind: String?
+ let token: String?
+ let password: String?
+}
+
+enum LaunchAgentPlist {
+ static func snapshot(url: URL) -> LaunchAgentPlistSnapshot? {
+ guard let data = try? Data(contentsOf: url) else { return nil }
+ let rootAny: Any
+ do {
+ rootAny = try PropertyListSerialization.propertyList(
+ from: data,
+ options: [],
+ format: nil)
+ } catch {
+ return nil
+ }
+ guard let root = rootAny as? [String: Any] else { return nil }
+ let programArguments = root["ProgramArguments"] as? [String] ?? []
+ let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
+ let port = Self.extractFlagInt(programArguments, flag: "--port")
+ let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
+ let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
+ let password = env["CLAWDBOT_GATEWAY_PASSWORD"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
+ return LaunchAgentPlistSnapshot(
+ programArguments: programArguments,
+ environment: env,
+ port: port,
+ bind: bind,
+ token: token,
+ password: password)
+ }
+
+ private static func extractFlagInt(_ args: [String], flag: String) -> Int? {
+ guard let raw = self.extractFlagString(args, flag: flag) else { return nil }
+ return Int(raw)
+ }
+
+ private static func extractFlagString(_ args: [String], flag: String) -> String? {
+ guard let idx = args.firstIndex(of: flag) else { return nil }
+ let valueIdx = args.index(after: idx)
+ guard valueIdx < args.endIndex else { return nil }
+ let token = args[valueIdx].trimmingCharacters(in: .whitespacesAndNewlines)
+ return token.isEmpty ? nil : token
+ }
+}
+
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift
index 2ce38dda4..ae8357b0c 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift
@@ -1,49 +1,41 @@
+import Foundation
import Testing
@testable import Clawdbot
@Suite struct GatewayLaunchAgentManagerTests {
- @Test func parseLaunchctlPrintSnapshotParsesQuotedArgs() {
- let output = """
- service = com.clawdbot.gateway
- program arguments = (
- "/Applications/Clawdbot.app/Contents/Resources/Relay/clawdbot",
- "gateway-daemon",
- "--port",
- "18789",
- "--bind",
- "loopback"
- )
- pid = 123
- """
- let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
- #expect(snapshot.pid == 123)
+ @Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
+ let plist: [String: Any] = [
+ "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
+ "EnvironmentVariables": [
+ "CLAWDBOT_GATEWAY_TOKEN": " secret ",
+ "CLAWDBOT_GATEWAY_PASSWORD": "pw",
+ ],
+ ]
+ let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ try data.write(to: url, options: [.atomic])
+ defer { try? FileManager.default.removeItem(at: url) }
+
+ let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789)
#expect(snapshot.bind == "loopback")
- #expect(snapshot.matches(port: 18789, bind: "loopback"))
- #expect(snapshot.matches(port: 18789, bind: "tailnet") == false)
- #expect(snapshot.matches(port: 19999, bind: "loopback") == false)
+ #expect(snapshot.token == "secret")
+ #expect(snapshot.password == "pw")
}
- @Test func parseLaunchctlPrintSnapshotParsesUnquotedArgs() {
- let output = """
- argv[] = { /usr/local/bin/clawdbot, gateway-daemon, --port, 19999, --bind, tailnet }
- pid = 0
- """
- let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
- #expect(snapshot.pid == 0)
- #expect(snapshot.port == 19999)
- #expect(snapshot.bind == "tailnet")
- }
+ @Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
+ let plist: [String: Any] = [
+ "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
+ ]
+ let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ try data.write(to: url, options: [.atomic])
+ defer { try? FileManager.default.removeItem(at: url) }
- @Test func parseLaunchctlPrintSnapshotAllowsMissingBind() {
- let output = """
- program arguments = ( "clawdbot", "gateway-daemon", "--port", "18789" )
- pid = 456
- """
- let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
+ let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789)
#expect(snapshot.bind == nil)
- #expect(snapshot.matches(port: 18789, bind: "loopback"))
}
}
-
From 5a09926126e4fa23f99445c40e671ad0ca8d113f Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:13:24 +0000
Subject: [PATCH 074/115] test(macos): isolate env + defaults
---
.../Clawdbot/ProcessInfo+Clawdbot.swift | 5 +-
.../ClawdbotConfigFileTests.swift | 42 +++----
.../GatewayEnvironmentTests.swift | 36 +++---
.../ClawdbotIPCTests/TestIsolation.swift | 104 ++++++++++++++++++
4 files changed, 133 insertions(+), 54 deletions(-)
create mode 100644 apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
diff --git a/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift b/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift
index b4d037018..29f9e7251 100644
--- a/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift
+++ b/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift
@@ -2,11 +2,12 @@ import Foundation
extension ProcessInfo {
var isPreview: Bool {
- self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
+ guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false }
+ return String(cString: raw) == "1"
}
var isNixMode: Bool {
- if self.environment["CLAWDBOT_NIX_MODE"] == "1" { return true }
+ if let raw = getenv("CLAWDBOT_NIX_MODE"), String(cString: raw) == "1" { return true }
return UserDefaults.standard.bool(forKey: "clawdbot.nixMode")
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift
index 2a5c70d60..9ee97e22c 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift
@@ -5,26 +5,26 @@ import Testing
@Suite(.serialized)
struct ClawdbotConfigFileTests {
@Test
- func configPathRespectsEnvOverride() {
+ func configPathRespectsEnvOverride() async {
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
- self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
+ await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
#expect(ClawdbotConfigFile.url().path == override)
}
}
@MainActor
@Test
- func remoteGatewayPortParsesAndMatchesHost() {
+ func remoteGatewayPortParsesAndMatchesHost() async {
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
- self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
+ await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
@@ -41,13 +41,13 @@ struct ClawdbotConfigFileTests {
@MainActor
@Test
- func setRemoteGatewayUrlPreservesScheme() {
+ func setRemoteGatewayUrlPreservesScheme() async {
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
- self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
+ await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
@@ -63,33 +63,17 @@ struct ClawdbotConfigFileTests {
}
@Test
- func stateDirOverrideSetsConfigPath() {
+ func stateDirOverrideSetsConfigPath() async {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
.path
- self.withEnv("CLAWDBOT_CONFIG_PATH", value: nil) {
- self.withEnv("CLAWDBOT_STATE_DIR", value: dir) {
- #expect(ClawdbotConfigFile.stateDirURL().path == dir)
- #expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json")
- }
+ await TestIsolation.withEnvValues([
+ "CLAWDBOT_CONFIG_PATH": nil,
+ "CLAWDBOT_STATE_DIR": dir,
+ ]) {
+ #expect(ClawdbotConfigFile.stateDirURL().path == dir)
+ #expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json")
}
}
-
- private func withEnv(_ key: String, value: String?, _ body: () -> Void) {
- let previous = ProcessInfo.processInfo.environment[key]
- if let value {
- setenv(key, value, 1)
- } else {
- unsetenv(key)
- }
- defer {
- if let previous {
- setenv(key, previous, 1)
- } else {
- unsetenv(key)
- }
- }
- body()
- }
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
index 00ce5ab1d..20d5b5973 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
@@ -2,7 +2,7 @@ import Foundation
import Testing
@testable import Clawdbot
-@Suite(.serialized) struct GatewayEnvironmentTests {
+@Suite struct GatewayEnvironmentTests {
@Test func semverParsesCommonForms() {
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
@@ -19,29 +19,19 @@ import Testing
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
}
- @Test func gatewayPortDefaultsAndRespectsOverride() {
- let envKey = "CLAWDBOT_CONFIG_PATH"
- let previousEnv = getenv(envKey).map { String(cString: $0) }
- let configPath = FileManager.default.temporaryDirectory
- .appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
- .path
- setenv(envKey, configPath, 1)
- defer {
- if let previousEnv {
- setenv(envKey, previousEnv, 1)
- } else {
- unsetenv(envKey)
- }
+ @Test func gatewayPortDefaultsAndRespectsOverride() async {
+ let configPath = TestIsolation.tempConfigPath()
+ await TestIsolation.withIsolatedState(
+ env: ["CLAWDBOT_CONFIG_PATH": configPath],
+ defaults: ["gatewayPort": nil])
+ {
+ let defaultPort = GatewayEnvironment.gatewayPort()
+ #expect(defaultPort == 18789)
+
+ UserDefaults.standard.set(19999, forKey: "gatewayPort")
+ defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
+ #expect(GatewayEnvironment.gatewayPort() == 19999)
}
-
- UserDefaults.standard.removeObject(forKey: "gatewayPort")
- defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
-
- let defaultPort = GatewayEnvironment.gatewayPort()
- #expect(defaultPort == 18789)
-
- UserDefaults.standard.set(19999, forKey: "gatewayPort")
- #expect(GatewayEnvironment.gatewayPort() == 19999)
}
@Test func expectedGatewayVersionFromStringUsesParser() {
diff --git a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
new file mode 100644
index 000000000..fa0180131
--- /dev/null
+++ b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
@@ -0,0 +1,104 @@
+import Foundation
+
+actor TestIsolationLock {
+ static let shared = TestIsolationLock()
+
+ private var locked = false
+ private var waiters: [CheckedContinuation] = []
+
+ private func lock() async {
+ if !self.locked {
+ self.locked = true
+ return
+ }
+ await withCheckedContinuation { cont in
+ self.waiters.append(cont)
+ }
+ // `unlock()` resumed us; lock is now held for this caller.
+ }
+
+ private func unlock() {
+ if self.waiters.isEmpty {
+ self.locked = false
+ return
+ }
+ let next = self.waiters.removeFirst()
+ next.resume()
+ }
+
+ func withLock(_ body: () async throws -> T) async rethrows -> T {
+ await self.lock()
+ defer { self.unlock() }
+ return try await body()
+ }
+}
+
+enum TestIsolation {
+ static func withIsolatedState(
+ env: [String: String?] = [:],
+ defaults: [String: Any?] = [:],
+ _ body: () async throws -> T) async rethrows -> T
+ {
+ try await TestIsolationLock.shared.withLock {
+ var previousEnv: [String: String?] = [:]
+ for (key, value) in env {
+ previousEnv[key] = getenv(key).map { String(cString: $0) }
+ if let value {
+ setenv(key, value, 1)
+ } else {
+ unsetenv(key)
+ }
+ }
+
+ let userDefaults = UserDefaults.standard
+ var previousDefaults: [String: Any?] = [:]
+ for (key, value) in defaults {
+ previousDefaults[key] = userDefaults.object(forKey: key)
+ if let value {
+ userDefaults.set(value, forKey: key)
+ } else {
+ userDefaults.removeObject(forKey: key)
+ }
+ }
+
+ defer {
+ for (key, value) in previousDefaults {
+ if let value {
+ userDefaults.set(value, forKey: key)
+ } else {
+ userDefaults.removeObject(forKey: key)
+ }
+ }
+ for (key, value) in previousEnv {
+ if let value {
+ setenv(key, value, 1)
+ } else {
+ unsetenv(key)
+ }
+ }
+ }
+
+ return try await body()
+ }
+ }
+
+ static func withEnvValues(
+ _ values: [String: String?],
+ _ body: () async throws -> T) async rethrows -> T
+ {
+ try await Self.withIsolatedState(env: values, defaults: [:], body)
+ }
+
+ static func withUserDefaultsValues(
+ _ values: [String: Any?],
+ _ body: () async throws -> T) async rethrows -> T
+ {
+ try await Self.withIsolatedState(env: [:], defaults: values, body)
+ }
+
+ static func tempConfigPath() -> String {
+ FileManager.default.temporaryDirectory
+ .appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
+ .path
+ }
+}
From eb5f0b73a90553ccb6b155699137bb425897cd4a Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:20:52 +0000
Subject: [PATCH 075/115] refactor(macos): inject main-actor services into node
runtime
---
.../Clawdbot/NodeMode/MacNodeRuntime.swift | 73 ++++++++-----------
.../MacNodeRuntimeMainActorServices.swift | 60 +++++++++++++++
.../LowCoverageHelperTests.swift | 42 ++++-------
.../MacNodeRuntimeTests.swift | 63 ++++++++++++----
.../ClawdbotIPCTests/TestIsolation.swift | 14 ++--
5 files changed, 162 insertions(+), 90 deletions(-)
create mode 100644 apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift
diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift
index dc4ae53e1..b439c66ea 100644
--- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift
+++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift
@@ -1,45 +1,19 @@
import AppKit
import ClawdbotIPC
import ClawdbotKit
-import CoreLocation
import Foundation
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
- private struct LocationPermissionRequired: Error {}
+ private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
+ private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
- @MainActor
- private static func currentLocation(
- desiredAccuracy: ClawdbotLocationAccuracy,
- maxAgeMs: Int?,
- timeoutMs: Int?) async throws -> (location: CLLocation, isPrecise: Bool)
+ init(
+ makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
+ await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
+ })
{
- let locationService = MacNodeLocationService()
- if locationService.authorizationStatus() != .authorizedAlways {
- throw LocationPermissionRequired()
- }
- let location = try await locationService.currentLocation(
- desiredAccuracy: desiredAccuracy,
- maxAgeMs: maxAgeMs,
- timeoutMs: timeoutMs)
- let isPrecise = locationService.accuracyAuthorization() == .fullAccuracy
- return (location: location, isPrecise: isPrecise)
- }
-
- @MainActor
- private static func recordScreen(
- screenIndex: Int?,
- durationMs: Int?,
- fps: Double?,
- includeAudio: Bool?) async throws -> (path: String, hasAudio: Bool)
- {
- let screenRecorder = ScreenRecordService()
- return try await screenRecorder.record(
- screenIndex: screenIndex,
- durationMs: durationMs,
- fps: fps,
- includeAudio: includeAudio,
- outPath: nil)
+ self.makeMainActorServices = makeMainActorServices
}
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -246,11 +220,22 @@ actor MacNodeRuntime {
ClawdbotLocationGetParams()
let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced)
+ let services = await self.mainActorServices()
+ let status = await services.locationAuthorizationStatus()
+ if status != .authorizedAlways {
+ return BridgeInvokeResponse(
+ id: req.id,
+ ok: false,
+ error: ClawdbotNodeError(
+ code: .unavailable,
+ message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
+ }
do {
- let (location, isPrecise) = try await Self.currentLocation(
+ let location = try await services.currentLocation(
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
+ let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy
let payload = ClawdbotLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
@@ -263,13 +248,6 @@ actor MacNodeRuntime {
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
- } catch is LocationPermissionRequired {
- return BridgeInvokeResponse(
- id: req.id,
- ok: false,
- error: ClawdbotNodeError(
- code: .unavailable,
- message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
} catch MacNodeLocationService.Error.timeout {
return BridgeInvokeResponse(
id: req.id,
@@ -296,11 +274,13 @@ actor MacNodeRuntime {
code: .invalidRequest,
message: "INVALID_REQUEST: screen format must be mp4")
}
- let res = try await Self.recordScreen(
+ let services = await self.mainActorServices()
+ let res = try await services.recordScreen(
screenIndex: params.screenIndex,
durationMs: params.durationMs,
fps: params.fps,
- includeAudio: params.includeAudio)
+ includeAudio: params.includeAudio,
+ outPath: nil)
defer { try? FileManager.default.removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ScreenPayload: Encodable {
@@ -321,6 +301,13 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
+ private func mainActorServices() async -> any MacNodeRuntimeMainActorServices {
+ if let cachedMainActorServices { return cachedMainActorServices }
+ let services = await self.makeMainActorServices()
+ self.cachedMainActorServices = services
+ return services
+ }
+
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
try await self.ensureA2UIHost()
diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift
new file mode 100644
index 000000000..a6e03e3e3
--- /dev/null
+++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift
@@ -0,0 +1,60 @@
+import ClawdbotKit
+import CoreLocation
+import Foundation
+
+@MainActor
+protocol MacNodeRuntimeMainActorServices: Sendable {
+ func recordScreen(
+ screenIndex: Int?,
+ durationMs: Int?,
+ fps: Double?,
+ includeAudio: Bool?,
+ outPath: String?) async throws -> (path: String, hasAudio: Bool)
+
+ func locationAuthorizationStatus() -> CLAuthorizationStatus
+ func locationAccuracyAuthorization() -> CLAccuracyAuthorization
+ func currentLocation(
+ desiredAccuracy: ClawdbotLocationAccuracy,
+ maxAgeMs: Int?,
+ timeoutMs: Int?) async throws -> CLLocation
+}
+
+@MainActor
+final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
+ private let screenRecorder = ScreenRecordService()
+ private let locationService = MacNodeLocationService()
+
+ func recordScreen(
+ screenIndex: Int?,
+ durationMs: Int?,
+ fps: Double?,
+ includeAudio: Bool?,
+ outPath: String?) async throws -> (path: String, hasAudio: Bool)
+ {
+ try await self.screenRecorder.record(
+ screenIndex: screenIndex,
+ durationMs: durationMs,
+ fps: fps,
+ includeAudio: includeAudio,
+ outPath: outPath)
+ }
+
+ func locationAuthorizationStatus() -> CLAuthorizationStatus {
+ self.locationService.authorizationStatus()
+ }
+
+ func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
+ self.locationService.accuracyAuthorization()
+ }
+
+ func currentLocation(
+ desiredAccuracy: ClawdbotLocationAccuracy,
+ maxAgeMs: Int?,
+ timeoutMs: Int?) async throws -> CLLocation
+ {
+ try await self.locationService.currentLocation(
+ desiredAccuracy: desiredAccuracy,
+ maxAgeMs: maxAgeMs,
+ timeoutMs: timeoutMs)
+ }
+}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
index 080a29589..a22bdab44 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
@@ -93,34 +93,22 @@ struct LowCoverageHelperTests {
_ = PresenceReporter._testPrimaryIPv4Address()
}
- @Test func gatewayLaunchAgentHelpers() {
- let keyBind = "CLAWDBOT_GATEWAY_BIND"
- let keyToken = "CLAWDBOT_GATEWAY_TOKEN"
- let previousBind = ProcessInfo.processInfo.environment[keyBind]
- let previousToken = ProcessInfo.processInfo.environment[keyToken]
- defer {
- if let previousBind {
- setenv(keyBind, previousBind, 1)
- } else {
- unsetenv(keyBind)
- }
- if let previousToken {
- setenv(keyToken, previousToken, 1)
- } else {
- unsetenv(keyToken)
- }
+ @Test func gatewayLaunchAgentHelpers() async throws {
+ try await TestIsolation.withEnvValues(
+ [
+ "CLAWDBOT_GATEWAY_BIND": "Lan",
+ "CLAWDBOT_GATEWAY_TOKEN": " secret ",
+ ])
+ {
+ #expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
+ #expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
+ #expect(
+ GatewayLaunchAgentManager._testEscapePlistValue("a&b\"'") ==
+ "a&b<c>"'")
+
+ #expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
+ #expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
}
-
- setenv(keyBind, "Lan", 1)
- setenv(keyToken, " secret ", 1)
- #expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
- #expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
- #expect(
- GatewayLaunchAgentManager._testEscapePlistValue("a&b\"'") ==
- "a&b<c>"'")
-
- #expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
- #expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
}
@Test func portGuardianParsesListenersAndBuildsReports() {
diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift
index 7b64265f5..2dd408f1f 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift
@@ -1,9 +1,9 @@
import ClawdbotKit
+import CoreLocation
import Foundation
import Testing
@testable import Clawdbot
-@Suite(.serialized)
struct MacNodeRuntimeTests {
@Test func handleInvokeRejectsUnknownCommand() async {
let runtime = MacNodeRuntime()
@@ -31,21 +31,58 @@ struct MacNodeRuntimeTests {
}
@Test func handleInvokeCameraListRequiresEnabledCamera() async {
- let defaults = UserDefaults.standard
- let previous = defaults.object(forKey: cameraEnabledKey)
- defaults.set(false, forKey: cameraEnabledKey)
- defer {
- if let previous {
- defaults.set(previous, forKey: cameraEnabledKey)
- } else {
- defaults.removeObject(forKey: cameraEnabledKey)
+ await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) {
+ let runtime = MacNodeRuntime()
+ let response = await runtime.handleInvoke(
+ BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
+ #expect(response.ok == false)
+ #expect(response.error?.message.contains("CAMERA_DISABLED") == true)
+ }
+ }
+
+ @Test func handleInvokeScreenRecordUsesInjectedServices() async throws {
+ @MainActor
+ final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
+ func recordScreen(
+ screenIndex: Int?,
+ durationMs: Int?,
+ fps: Double?,
+ includeAudio: Bool?,
+ outPath: String?) async throws -> (path: String, hasAudio: Bool)
+ {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4")
+ try Data("ok".utf8).write(to: url)
+ return (path: url.path, hasAudio: false)
+ }
+
+ func locationAuthorizationStatus() -> CLAuthorizationStatus { .authorizedAlways }
+ func locationAccuracyAuthorization() -> CLAccuracyAuthorization { .fullAccuracy }
+ func currentLocation(
+ desiredAccuracy: ClawdbotLocationAccuracy,
+ maxAgeMs: Int?,
+ timeoutMs: Int?) async throws -> CLLocation
+ {
+ CLLocation(latitude: 0, longitude: 0)
}
}
- let runtime = MacNodeRuntime()
+ let services = await MainActor.run { FakeMainActorServices() }
+ let runtime = MacNodeRuntime(makeMainActorServices: { services })
+
+ let params = MacNodeScreenRecordParams(durationMs: 250)
+ let json = String(data: try JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
- BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
- #expect(response.ok == false)
- #expect(response.error?.message.contains("CAMERA_DISABLED") == true)
+ BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json))
+ #expect(response.ok == true)
+ let payloadJSON = try #require(response.payloadJSON)
+
+ struct Payload: Decodable {
+ var format: String
+ var base64: String
+ }
+ let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
+ #expect(payload.format == "mp4")
+ #expect(!payload.base64.isEmpty)
}
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
index fa0180131..0613c830c 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
@@ -26,7 +26,7 @@ actor TestIsolationLock {
next.resume()
}
- func withLock(_ body: () async throws -> T) async rethrows -> T {
+ func withLock(_ body: @Sendable () async throws -> T) async rethrows -> T {
await self.lock()
defer { self.unlock() }
return try await body()
@@ -34,10 +34,10 @@ actor TestIsolationLock {
}
enum TestIsolation {
- static func withIsolatedState(
+ static func withIsolatedState(
env: [String: String?] = [:],
defaults: [String: Any?] = [:],
- _ body: () async throws -> T) async rethrows -> T
+ _ body: @Sendable () async throws -> T) async rethrows -> T
{
try await TestIsolationLock.shared.withLock {
var previousEnv: [String: String?] = [:]
@@ -82,16 +82,16 @@ enum TestIsolation {
}
}
- static func withEnvValues(
+ static func withEnvValues(
_ values: [String: String?],
- _ body: () async throws -> T) async rethrows -> T
+ _ body: @Sendable () async throws -> T) async rethrows -> T
{
try await Self.withIsolatedState(env: values, defaults: [:], body)
}
- static func withUserDefaultsValues(
+ static func withUserDefaultsValues(
_ values: [String: Any?],
- _ body: () async throws -> T) async rethrows -> T
+ _ body: @Sendable () async throws -> T) async rethrows -> T
{
try await Self.withIsolatedState(env: [:], defaults: values, body)
}
From 2b6adc9e60a159c8a061499ec8b47dfef84e6736 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:30:57 +0000
Subject: [PATCH 076/115] test(macos): make env/defaults helper Swift 6-safe
---
.../LowCoverageHelperTests.swift | 2 +-
.../ClawdbotIPCTests/TestIsolation.swift | 102 ++++++++++--------
2 files changed, 58 insertions(+), 46 deletions(-)
diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
index a22bdab44..6ee7cc012 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
@@ -94,7 +94,7 @@ struct LowCoverageHelperTests {
}
@Test func gatewayLaunchAgentHelpers() async throws {
- try await TestIsolation.withEnvValues(
+ await TestIsolation.withEnvValues(
[
"CLAWDBOT_GATEWAY_BIND": "Lan",
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
diff --git a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
index 0613c830c..03c32607f 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
@@ -6,7 +6,7 @@ actor TestIsolationLock {
private var locked = false
private var waiters: [CheckedContinuation] = []
- private func lock() async {
+ func acquire() async {
if !self.locked {
self.locked = true
return
@@ -17,7 +17,7 @@ actor TestIsolationLock {
// `unlock()` resumed us; lock is now held for this caller.
}
- private func unlock() {
+ func release() {
if self.waiters.isEmpty {
self.locked = false
return
@@ -25,78 +25,90 @@ actor TestIsolationLock {
let next = self.waiters.removeFirst()
next.resume()
}
-
- func withLock(_ body: @Sendable () async throws -> T) async rethrows -> T {
- await self.lock()
- defer { self.unlock() }
- return try await body()
- }
}
+@MainActor
enum TestIsolation {
- static func withIsolatedState(
+ static func withIsolatedState(
env: [String: String?] = [:],
defaults: [String: Any?] = [:],
- _ body: @Sendable () async throws -> T) async rethrows -> T
+ _ body: () async throws -> T) async rethrows -> T
{
- try await TestIsolationLock.shared.withLock {
- var previousEnv: [String: String?] = [:]
- for (key, value) in env {
- previousEnv[key] = getenv(key).map { String(cString: $0) }
- if let value {
- setenv(key, value, 1)
- } else {
- unsetenv(key)
- }
+ await TestIsolationLock.shared.acquire()
+ var previousEnv: [String: String?] = [:]
+ for (key, value) in env {
+ previousEnv[key] = getenv(key).map { String(cString: $0) }
+ if let value {
+ setenv(key, value, 1)
+ } else {
+ unsetenv(key)
}
+ }
- let userDefaults = UserDefaults.standard
- var previousDefaults: [String: Any?] = [:]
- for (key, value) in defaults {
- previousDefaults[key] = userDefaults.object(forKey: key)
+ let userDefaults = UserDefaults.standard
+ var previousDefaults: [String: Any?] = [:]
+ for (key, value) in defaults {
+ previousDefaults[key] = userDefaults.object(forKey: key)
+ if let value {
+ userDefaults.set(value, forKey: key)
+ } else {
+ userDefaults.removeObject(forKey: key)
+ }
+ }
+
+ do {
+ let result = try await body()
+ for (key, value) in previousDefaults {
if let value {
userDefaults.set(value, forKey: key)
} else {
userDefaults.removeObject(forKey: key)
}
}
-
- defer {
- for (key, value) in previousDefaults {
- if let value {
- userDefaults.set(value, forKey: key)
- } else {
- userDefaults.removeObject(forKey: key)
- }
- }
- for (key, value) in previousEnv {
- if let value {
- setenv(key, value, 1)
- } else {
- unsetenv(key)
- }
+ for (key, value) in previousEnv {
+ if let value {
+ setenv(key, value, 1)
+ } else {
+ unsetenv(key)
}
}
-
- return try await body()
+ await TestIsolationLock.shared.release()
+ return result
+ } catch {
+ for (key, value) in previousDefaults {
+ if let value {
+ userDefaults.set(value, forKey: key)
+ } else {
+ userDefaults.removeObject(forKey: key)
+ }
+ }
+ for (key, value) in previousEnv {
+ if let value {
+ setenv(key, value, 1)
+ } else {
+ unsetenv(key)
+ }
+ }
+ await TestIsolationLock.shared.release()
+ throw error
}
}
- static func withEnvValues(
+ static func withEnvValues(
_ values: [String: String?],
- _ body: @Sendable () async throws -> T) async rethrows -> T
+ _ body: () async throws -> T) async rethrows -> T
{
try await Self.withIsolatedState(env: values, defaults: [:], body)
}
- static func withUserDefaultsValues(
+ static func withUserDefaultsValues(
_ values: [String: Any?],
- _ body: @Sendable () async throws -> T) async rethrows -> T
+ _ body: () async throws -> T) async rethrows -> T
{
try await Self.withIsolatedState(env: [:], defaults: values, body)
}
- static func tempConfigPath() -> String {
+ nonisolated static func tempConfigPath() -> String {
FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
.path
From 7aeb6d592163f30496ee61a8632ba7f1b0e01b17 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:31:00 +0000
Subject: [PATCH 077/115] fix(wizard): keep WhatsApp config setters typed
---
src/commands/onboard-providers.ts | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts
index d489ff872..0c032942f 100644
--- a/src/commands/onboard-providers.ts
+++ b/src/commands/onboard-providers.ts
@@ -182,7 +182,10 @@ async function noteSlackTokenHelp(
);
}
-function setWhatsAppDmPolicy(cfg: ClawdbotConfig, dmPolicy?: DmPolicy) {
+function setWhatsAppDmPolicy(
+ cfg: ClawdbotConfig,
+ dmPolicy?: DmPolicy,
+): ClawdbotConfig {
return {
...cfg,
whatsapp: {
@@ -192,7 +195,10 @@ function setWhatsAppDmPolicy(cfg: ClawdbotConfig, dmPolicy?: DmPolicy) {
};
}
-function setWhatsAppAllowFrom(cfg: ClawdbotConfig, allowFrom?: string[]) {
+function setWhatsAppAllowFrom(
+ cfg: ClawdbotConfig,
+ allowFrom?: string[],
+): ClawdbotConfig {
return {
...cfg,
whatsapp: {
@@ -218,7 +224,7 @@ function setMessagesResponsePrefix(
function setWhatsAppSelfChatMode(
cfg: ClawdbotConfig,
selfChatMode?: boolean,
-) {
+): ClawdbotConfig {
return {
...cfg,
whatsapp: {
From 391a3d6eaf2d8a3ae21ed036ad37d48b2e468b2f Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 21:37:05 +0100
Subject: [PATCH 078/115] feat: add daemon service management
---
docs/cli/index.md | 23 ++
docs/docs.json | 16 +-
docs/gateway/index.md | 19 ++
docs/platforms/android.md | 9 +
docs/platforms/index.md | 40 +++
docs/platforms/ios.md | 9 +
docs/platforms/linux.md | 73 ++++-
docs/platforms/macos.md | 17 +
docs/platforms/windows.md | 53 ++-
docs/start/hubs.md | 16 +-
src/cli/daemon-cli.coverage.test.ts | 134 ++++++++
src/cli/daemon-cli.ts | 466 +++++++++++++++++++++++++++
src/cli/gateway-cli.coverage.test.ts | 36 ++-
src/cli/gateway-cli.ts | 122 +++----
src/cli/program.ts | 2 +
src/commands/onboard-providers.ts | 2 +-
src/daemon/inspect.ts | 305 ++++++++++++++++++
17 files changed, 1264 insertions(+), 78 deletions(-)
create mode 100644 docs/platforms/index.md
create mode 100644 src/cli/daemon-cli.coverage.test.ts
create mode 100644 src/cli/daemon-cli.ts
create mode 100644 src/daemon/inspect.ts
diff --git a/docs/cli/index.md b/docs/cli/index.md
index a12dbcf2c..0de6e00ff 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -362,6 +362,25 @@ Options:
### `gateway-daemon`
Run the Gateway as a long-lived daemon (same options as `gateway`, minus `--allow-unconfigured` and `--force`).
+### `daemon`
+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`
+
+Notes:
+- `daemon status` uses the same URL/token defaults as `gateway status` unless you pass `--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).
+- `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
+- `daemon install` options: `--port`, `--runtime`, `--token`.
+- `gateway install|uninstall|start|stop|restart` remain as service aliases; `daemon` is the dedicated manager.
+
### `gateway `
Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
@@ -372,8 +391,12 @@ Subcommands:
- `gateway wake --text [--mode now|next-heartbeat]`
- `gateway send --to --message [--media-url ] [--gif-playback] [--idempotency-key ]`
- `gateway agent --message [--to ] [--session-id ] [--thinking ] [--deliver] [--timeout-seconds ] [--idempotency-key ]`
+- `gateway install`
+- `gateway uninstall`
+- `gateway start`
- `gateway stop`
- `gateway restart`
+- `gateway daemon status` (alias for `clawdbot daemon status`)
## Models
diff --git a/docs/docs.json b/docs/docs.json
index de617e251..b6ce5c4cd 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -646,7 +646,17 @@
{
"group": "Platforms",
"pages": [
+ "platforms",
"platforms/macos",
+ "platforms/ios",
+ "platforms/android",
+ "platforms/windows",
+ "platforms/linux"
+ ]
+ },
+ {
+ "group": "macOS Companion App",
+ "pages": [
"platforms/mac/dev-setup",
"platforms/mac/menu-bar",
"platforms/mac/voicewake",
@@ -664,11 +674,7 @@
"platforms/mac/bun",
"platforms/mac/xpc",
"platforms/mac/skills",
- "platforms/mac/peekaboo",
- "platforms/ios",
- "platforms/android",
- "platforms/windows",
- "platforms/linux"
+ "platforms/mac/peekaboo"
]
},
{
diff --git a/docs/gateway/index.md b/docs/gateway/index.md
index f025dff31..416ee4682 100644
--- a/docs/gateway/index.md
+++ b/docs/gateway/index.md
@@ -157,6 +157,25 @@ See also: [`docs/presence.md`](/concepts/presence) for how presence is produced/
- 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).
+## Daemon management (CLI)
+
+Use the CLI daemon manager for install/start/stop/restart/status:
+
+```bash
+clawdbot daemon status
+clawdbot daemon install
+clawdbot daemon stop
+clawdbot daemon restart
+```
+
+Notes:
+- `daemon status` probes the Gateway RPC by default (same URL/token defaults as `gateway status`).
+- `daemon status --deep` adds system-level scans (LaunchDaemons/system units).
+- `gateway install|uninstall|start|stop|restart` remain supported as aliases; `daemon` is the dedicated manager.
+- `gateway daemon status` is an alias for `clawdbot daemon status`.
+- If other gateway-like services are detected, the CLI warns. We recommend **one gateway per machine**; one gateway can host multiple agents.
+ - Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
+
Bundled mac app:
- Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`.
- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
diff --git a/docs/platforms/android.md b/docs/platforms/android.md
index 56beab345..9e274da0a 100644
--- a/docs/platforms/android.md
+++ b/docs/platforms/android.md
@@ -8,6 +8,15 @@ read_when:
# Android App (Node)
+## Support snapshot
+- Role: companion node app (Android does not host the Gateway).
+- Gateway required: yes (run it on macOS, Linux, or Windows via WSL2).
+- Install: [Getting Started](/start/getting-started) + [Pairing](/gateway/pairing).
+- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration).
+
+## System control
+System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gateway).
+
## Connection Runbook
Android node app โ (mDNS/NSD + TCP bridge) โ **Gateway bridge** โ (loopback WS) โ **Gateway**
diff --git a/docs/platforms/index.md b/docs/platforms/index.md
new file mode 100644
index 000000000..9d388140f
--- /dev/null
+++ b/docs/platforms/index.md
@@ -0,0 +1,40 @@
+---
+summary: "Platform support overview (Gateway + companion apps)"
+read_when:
+ - Looking for OS support or install paths
+ - Deciding where to run the Gateway
+---
+# Platforms
+
+Clawdbot core is written in TypeScript, so the CLI + Gateway run anywhere Node or Bun runs.
+
+Companion apps exist for macOS (menu bar app) and mobile nodes (iOS/Android). Windows and
+Linux companion apps are planned, but the core Gateway is fully supported today.
+
+## Choose your OS
+
+- macOS: [macOS](/platforms/macos)
+- iOS: [iOS](/platforms/ios)
+- Android: [Android](/platforms/android)
+- Windows: [Windows](/platforms/windows)
+- Linux: [Linux](/platforms/linux)
+
+## Common links
+
+- Install guide: [Getting Started](/start/getting-started)
+- Gateway runbook: [Gateway](/gateway)
+- Gateway configuration: [Configuration](/gateway/configuration)
+- Service status: `clawdbot daemon status`
+
+## Gateway service install (CLI)
+
+Use one of these (all supported):
+
+- Wizard (recommended): `clawdbot onboard --install-daemon`
+- Direct: `clawdbot daemon install` (alias: `clawdbot gateway install`)
+- Configure flow: `clawdbot configure` โ select **Gateway daemon**
+- Repair/migrate: `clawdbot doctor` (offers to install or fix the service)
+
+The service target depends on OS:
+- macOS: LaunchAgent (`com.clawdbot.gateway`)
+- Linux/WSL2: systemd user service
diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md
index 09cb80ce4..939d5c044 100644
--- a/docs/platforms/ios.md
+++ b/docs/platforms/ios.md
@@ -12,6 +12,15 @@ read_when:
Status: prototype implemented (internal) ยท Date: 2025-12-13
+## Support snapshot
+- Role: companion node app (iOS does not host the Gateway).
+- Gateway required: yes (run it on macOS, Linux, or Windows via WSL2).
+- Install: [Getting Started](/start/getting-started) + [Pairing](/gateway/pairing).
+- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration).
+
+## System control
+System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gateway).
+
## Connection Runbook
This is the practical โhow do I connect the iOS nodeโ guide:
diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md
index b5e27e4cb..78348d698 100644
--- a/docs/platforms/linux.md
+++ b/docs/platforms/linux.md
@@ -1,11 +1,80 @@
---
-summary: "Linux app status + contribution call"
+summary: "Linux support + companion app status"
read_when:
- Looking for Linux companion app status
- Planning platform coverage or contributions
---
# Linux App
-Clawdbot core is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node runs.
+Clawdbot core is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node or Bun runs.
We do not have a Linux companion app yet. It is planned, and we would love contributions to make it happen.
+
+## Install
+- [Getting Started](/start/getting-started)
+- [Install & updates](/install/updating)
+- Optional flows: [Bun](/install/bun), [Nix](/install/nix), [Docker](/install/docker)
+
+## Gateway
+- [Gateway runbook](/gateway)
+- [Configuration](/gateway/configuration)
+
+## Gateway service install (CLI)
+
+Use one of these:
+
+```
+clawdbot onboard --install-daemon
+```
+
+Or:
+
+```
+clawdbot daemon install
+```
+
+Or:
+
+```
+clawdbot gateway install
+```
+
+Or:
+
+```
+clawdbot configure
+```
+
+Select **Gateway daemon** when prompted.
+
+Repair/migrate:
+
+```
+clawdbot doctor
+```
+
+## System control (systemd user unit)
+Full unit example lives in the [Gateway runbook](/gateway). Minimal setup:
+
+Create `~/.config/systemd/user/clawdbot-gateway.service`:
+
+```
+[Unit]
+Description=Clawdbot Gateway
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+ExecStart=/usr/local/bin/clawdbot gateway --port 18789
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=default.target
+```
+
+Enable it:
+
+```
+systemctl --user enable --now clawdbot-gateway.service
+```
diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md
index bac5c1539..a1daa37cc 100644
--- a/docs/platforms/macos.md
+++ b/docs/platforms/macos.md
@@ -8,6 +8,23 @@ read_when:
Author: steipete ยท Status: draft spec ยท Date: 2025-12-20
+## Support snapshot
+- Core Gateway: supported (TypeScript on Node/Bun).
+- Companion app: macOS menu bar app with permissions + node bridge.
+- Install: [Getting Started](/start/getting-started) or [Install & updates](/install/updating).
+- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration).
+
+## System control (launchd)
+If you run the bundled macOS app, it installs a per-user LaunchAgent labeled `com.clawdbot.gateway`.
+CLI-only installs can use `clawdbot onboard --install-daemon`, `clawdbot daemon install`, or `clawdbot configure` โ **Gateway daemon**.
+
+```bash
+launchctl kickstart -k gui/$UID/com.clawdbot.gateway
+launchctl bootout gui/$UID/com.clawdbot.gateway
+```
+
+Details: [Gateway runbook](/gateway) and [Bundled bun Gateway](/platforms/mac/bun).
+
## Purpose
- Single macOS menu-bar app named **Clawdbot** that:
- Shows native notifications for Clawdbot/clawdbot events.
diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md
index 67ad766c0..b97906295 100644
--- a/docs/platforms/windows.md
+++ b/docs/platforms/windows.md
@@ -1,5 +1,5 @@
---
-summary: "Windows (WSL2) setup + companion app status"
+summary: "Windows (WSL2) support + companion app status"
read_when:
- Installing Clawdbot on Windows
- Looking for Windows companion app status
@@ -7,14 +7,55 @@ read_when:
---
# Windows (WSL2)
-Clawdbot runs on Windows **via WSL2** (Ubuntu recommended). WSL2 is **strongly
-recommended**; native Windows installs are untested and more problematic. Use
-WSL2 and follow the Linux flow inside it.
+Clawdbot core is supported on Windows **via WSL2** (Ubuntu recommended). The
+CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
+Windows installs are untested and more problematic.
+
+## Install
+- [Getting Started](/start/getting-started) (use inside WSL)
+- [Install & updates](/install/updating)
+- Official WSL2 guide (Microsoft): https://learn.microsoft.com/windows/wsl/install
+
+## Gateway
+- [Gateway runbook](/gateway)
+- [Configuration](/gateway/configuration)
+
+## Gateway service install (CLI)
+
+Inside WSL2:
+
+```
+clawdbot onboard --install-daemon
+```
+
+Or:
+
+```
+clawdbot daemon install
+```
+
+Or:
+
+```
+clawdbot gateway install
+```
+
+Or:
+
+```
+clawdbot configure
+```
+
+Select **Gateway daemon** when prompted.
+
+Repair/migrate:
+
+```
+clawdbot doctor
+```
## How to install this correctly
-Start here (official WSL2 guide): https://learn.microsoft.com/windows/wsl/install
-
### 1) Install WSL2 + Ubuntu
Open PowerShell (Admin):
diff --git a/docs/start/hubs.md b/docs/start/hubs.md
index 9706700ec..58b9209b7 100644
--- a/docs/start/hubs.md
+++ b/docs/start/hubs.md
@@ -114,7 +114,16 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Platforms
-- [macOS app overview](https://docs.clawd.bot/platforms/macos)
+- [Platforms overview](https://docs.clawd.bot/platforms)
+- [macOS](https://docs.clawd.bot/platforms/macos)
+- [iOS](https://docs.clawd.bot/platforms/ios)
+- [Android](https://docs.clawd.bot/platforms/android)
+- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
+- [Linux](https://docs.clawd.bot/platforms/linux)
+- [Web surfaces](https://docs.clawd.bot/web)
+
+## macOS companion app (internals)
+
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
@@ -133,11 +142,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [macOS XPC](https://docs.clawd.bot/platforms/mac/xpc)
- [macOS skills](https://docs.clawd.bot/platforms/mac/skills)
- [macOS Peekaboo plan](https://docs.clawd.bot/platforms/mac/peekaboo)
-- [iOS node](https://docs.clawd.bot/platforms/ios)
-- [Android node](https://docs.clawd.bot/platforms/android)
-- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
-- [Linux app](https://docs.clawd.bot/platforms/linux)
-- [Web surfaces](https://docs.clawd.bot/web)
## Workspace + templates
diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts
new file mode 100644
index 000000000..1c8fedb5d
--- /dev/null
+++ b/src/cli/daemon-cli.coverage.test.ts
@@ -0,0 +1,134 @@
+import { Command } from "commander";
+import { describe, expect, it, vi } from "vitest";
+
+const callGateway = vi.fn(async () => ({ ok: true }));
+const resolveGatewayProgramArguments = vi.fn(async () => ({
+ programArguments: ["/bin/node", "cli", "gateway-daemon", "--port", "18789"],
+}));
+const serviceInstall = vi.fn().mockResolvedValue(undefined);
+const serviceUninstall = vi.fn().mockResolvedValue(undefined);
+const serviceStop = vi.fn().mockResolvedValue(undefined);
+const serviceRestart = vi.fn().mockResolvedValue(undefined);
+const serviceIsLoaded = vi.fn().mockResolvedValue(false);
+const serviceReadCommand = vi.fn().mockResolvedValue(null);
+const findExtraGatewayServices = vi.fn(async () => []);
+
+const runtimeLogs: string[] = [];
+const runtimeErrors: string[] = [];
+const defaultRuntime = {
+ log: (msg: string) => runtimeLogs.push(msg),
+ error: (msg: string) => runtimeErrors.push(msg),
+ exit: (code: number) => {
+ throw new Error(`__exit__:${code}`);
+ },
+};
+
+vi.mock("../gateway/call.js", () => ({
+ callGateway: (opts: unknown) => callGateway(opts),
+}));
+
+vi.mock("../daemon/program-args.js", () => ({
+ resolveGatewayProgramArguments: (opts: unknown) =>
+ resolveGatewayProgramArguments(opts),
+}));
+
+vi.mock("../daemon/service.js", () => ({
+ resolveGatewayService: () => ({
+ label: "LaunchAgent",
+ loadedText: "loaded",
+ notLoadedText: "not loaded",
+ install: serviceInstall,
+ uninstall: serviceUninstall,
+ stop: serviceStop,
+ restart: serviceRestart,
+ isLoaded: serviceIsLoaded,
+ readCommand: serviceReadCommand,
+ }),
+}));
+
+vi.mock("../daemon/legacy.js", () => ({
+ findLegacyGatewayServices: () => [],
+}));
+
+vi.mock("../daemon/inspect.js", () => ({
+ findExtraGatewayServices: (env: unknown, opts?: unknown) =>
+ findExtraGatewayServices(env, opts),
+}));
+
+vi.mock("../runtime.js", () => ({
+ defaultRuntime,
+}));
+
+vi.mock("./deps.js", () => ({
+ createDefaultDeps: () => {},
+}));
+
+describe("daemon-cli coverage", () => {
+ it("probes gateway status by default", async () => {
+ runtimeLogs.length = 0;
+ runtimeErrors.length = 0;
+ callGateway.mockClear();
+
+ const { registerDaemonCli } = await import("./daemon-cli.js");
+ const program = new Command();
+ program.exitOverride();
+ registerDaemonCli(program);
+
+ await program.parseAsync(["daemon", "status"], { from: "user" });
+
+ expect(callGateway).toHaveBeenCalledTimes(1);
+ expect(callGateway).toHaveBeenCalledWith(
+ expect.objectContaining({ method: "status" }),
+ );
+ expect(findExtraGatewayServices).toHaveBeenCalled();
+ });
+
+ it("passes deep scan flag for daemon status", async () => {
+ findExtraGatewayServices.mockClear();
+
+ const { registerDaemonCli } = await import("./daemon-cli.js");
+ const program = new Command();
+ program.exitOverride();
+ registerDaemonCli(program);
+
+ await program.parseAsync(["daemon", "status", "--deep"], { from: "user" });
+
+ expect(findExtraGatewayServices).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ deep: true }),
+ );
+ });
+
+ it("installs the daemon when requested", async () => {
+ serviceIsLoaded.mockResolvedValueOnce(false);
+ serviceInstall.mockClear();
+
+ const { registerDaemonCli } = await import("./daemon-cli.js");
+ const program = new Command();
+ program.exitOverride();
+ registerDaemonCli(program);
+
+ await program.parseAsync(["daemon", "install", "--port", "18789"], {
+ from: "user",
+ });
+
+ expect(serviceInstall).toHaveBeenCalledTimes(1);
+ });
+
+ it("starts and stops the daemon via service helpers", async () => {
+ serviceRestart.mockClear();
+ serviceStop.mockClear();
+ serviceIsLoaded.mockResolvedValue(true);
+
+ const { registerDaemonCli } = await import("./daemon-cli.js");
+ const program = new Command();
+ program.exitOverride();
+ registerDaemonCli(program);
+
+ await program.parseAsync(["daemon", "start"], { from: "user" });
+ await program.parseAsync(["daemon", "stop"], { from: "user" });
+
+ expect(serviceRestart).toHaveBeenCalledTimes(1);
+ expect(serviceStop).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts
new file mode 100644
index 000000000..92532726b
--- /dev/null
+++ b/src/cli/daemon-cli.ts
@@ -0,0 +1,466 @@
+import path from "node:path";
+import type { Command } from "commander";
+
+import {
+ DEFAULT_GATEWAY_DAEMON_RUNTIME,
+ isGatewayDaemonRuntime,
+} from "../commands/daemon-runtime.js";
+import { loadConfig, resolveGatewayPort } from "../config/config.js";
+import { resolveIsNixMode } from "../config/paths.js";
+import {
+ GATEWAY_LAUNCH_AGENT_LABEL,
+ GATEWAY_SYSTEMD_SERVICE_NAME,
+ GATEWAY_WINDOWS_TASK_NAME,
+} from "../daemon/constants.js";
+import {
+ type FindExtraGatewayServicesOptions,
+ findExtraGatewayServices,
+} from "../daemon/inspect.js";
+import { findLegacyGatewayServices } from "../daemon/legacy.js";
+import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
+import { resolveGatewayService } from "../daemon/service.js";
+import { callGateway } from "../gateway/call.js";
+import { defaultRuntime } from "../runtime.js";
+import { createDefaultDeps } from "./deps.js";
+
+type DaemonStatus = {
+ service: {
+ label: string;
+ loaded: boolean;
+ loadedText: string;
+ notLoadedText: string;
+ command?: {
+ programArguments: string[];
+ workingDirectory?: string;
+ } | null;
+ };
+ rpc?: {
+ ok: boolean;
+ error?: string;
+ };
+ legacyServices: Array<{ label: string; detail: string }>;
+ extraServices: Array<{ label: string; detail: string; scope: string }>;
+};
+
+export type GatewayRpcOpts = {
+ url?: string;
+ token?: string;
+ password?: string;
+ timeout?: string;
+};
+
+export type DaemonStatusOptions = {
+ rpc: GatewayRpcOpts;
+ probe: boolean;
+ json: boolean;
+} & FindExtraGatewayServicesOptions;
+
+export type DaemonInstallOptions = {
+ port?: string | number;
+ runtime?: string;
+ token?: string;
+};
+
+function parsePort(raw: unknown): number | null {
+ if (raw === undefined || raw === null) return null;
+ const value =
+ typeof raw === "string"
+ ? raw
+ : typeof raw === "number" || typeof raw === "bigint"
+ ? raw.toString()
+ : null;
+ if (value === null) return null;
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
+ return parsed;
+}
+
+async function probeGatewayStatus(opts: GatewayRpcOpts) {
+ try {
+ await callGateway({
+ url: opts.url,
+ token: opts.token,
+ password: opts.password,
+ method: "status",
+ timeoutMs: Number(opts.timeout ?? 10_000),
+ clientName: "cli",
+ mode: "cli",
+ });
+ return { ok: true } as const;
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : String(err),
+ } as const;
+ }
+}
+
+function renderGatewayServiceStartHints(): string[] {
+ switch (process.platform) {
+ case "darwin":
+ return [
+ `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
+ ];
+ case "linux":
+ return [`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`];
+ case "win32":
+ return [`schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`];
+ default:
+ return [];
+ }
+}
+
+function renderGatewayServiceCleanupHints(): string[] {
+ switch (process.platform) {
+ case "darwin":
+ return [
+ `launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
+ `rm ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
+ ];
+ case "linux":
+ return [
+ `systemctl --user disable --now ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
+ `rm ~/.config/systemd/user/${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
+ ];
+ case "win32":
+ return [`schtasks /Delete /TN "${GATEWAY_WINDOWS_TASK_NAME}" /F`];
+ default:
+ return [];
+ }
+}
+
+async function gatherDaemonStatus(opts: {
+ rpc: GatewayRpcOpts;
+ probe: boolean;
+ deep?: boolean;
+}): Promise {
+ const service = resolveGatewayService();
+ const [loaded, command] = await Promise.all([
+ service.isLoaded({ env: process.env }).catch(() => false),
+ service.readCommand(process.env).catch(() => null),
+ ]);
+ const legacyServices = await findLegacyGatewayServices(process.env);
+ const extraServices = await findExtraGatewayServices(process.env, {
+ deep: opts.deep,
+ });
+ const rpc = opts.probe ? await probeGatewayStatus(opts.rpc) : undefined;
+
+ return {
+ service: {
+ label: service.label,
+ loaded,
+ loadedText: service.loadedText,
+ notLoadedText: service.notLoadedText,
+ command,
+ },
+ rpc,
+ legacyServices,
+ extraServices,
+ };
+}
+
+function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
+ if (opts.json) {
+ defaultRuntime.log(JSON.stringify(status, null, 2));
+ return;
+ }
+
+ const { service, rpc, legacyServices, extraServices } = status;
+ defaultRuntime.log(
+ `Service: ${service.label} (${service.loaded ? service.loadedText : service.notLoadedText})`,
+ );
+ if (service.command?.programArguments?.length) {
+ defaultRuntime.log(
+ `Command: ${service.command.programArguments.join(" ")}`,
+ );
+ }
+ if (service.command?.workingDirectory) {
+ defaultRuntime.log(`Working dir: ${service.command.workingDirectory}`);
+ }
+ if (rpc) {
+ if (rpc.ok) {
+ defaultRuntime.log("RPC probe: ok");
+ } else {
+ defaultRuntime.error(`RPC probe: failed (${rpc.error})`);
+ }
+ }
+
+ if (legacyServices.length > 0) {
+ defaultRuntime.error("Legacy Clawdis services detected:");
+ for (const svc of legacyServices) {
+ defaultRuntime.error(`- ${svc.label} (${svc.detail})`);
+ }
+ defaultRuntime.error("Cleanup: clawdbot doctor");
+ }
+
+ if (extraServices.length > 0) {
+ defaultRuntime.error("Other gateway-like services detected (best effort):");
+ for (const svc of extraServices) {
+ defaultRuntime.error(`- ${svc.label} (${svc.scope}, ${svc.detail})`);
+ }
+ for (const hint of renderGatewayServiceCleanupHints()) {
+ defaultRuntime.error(`Cleanup hint: ${hint}`);
+ }
+ }
+
+ if (legacyServices.length > 0 || extraServices.length > 0) {
+ defaultRuntime.error(
+ "Recommendation: run a single gateway per machine. One gateway supports multiple agents.",
+ );
+ defaultRuntime.error(
+ "If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
+ );
+ }
+}
+
+export async function runDaemonStatus(opts: DaemonStatusOptions) {
+ try {
+ const status = await gatherDaemonStatus({
+ rpc: opts.rpc,
+ probe: Boolean(opts.probe),
+ deep: Boolean(opts.deep),
+ });
+ printDaemonStatus(status, { json: Boolean(opts.json) });
+ } catch (err) {
+ defaultRuntime.error(`Daemon status failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ }
+}
+
+export async function runDaemonInstall(opts: DaemonInstallOptions) {
+ if (resolveIsNixMode(process.env)) {
+ defaultRuntime.error("Nix mode detected; daemon install is disabled.");
+ defaultRuntime.exit(1);
+ return;
+ }
+
+ const cfg = loadConfig();
+ const portOverride = parsePort(opts.port);
+ if (opts.port !== undefined && portOverride === null) {
+ defaultRuntime.error("Invalid port");
+ defaultRuntime.exit(1);
+ return;
+ }
+ const port = portOverride ?? resolveGatewayPort(cfg);
+ if (!Number.isFinite(port) || port <= 0) {
+ defaultRuntime.error("Invalid port");
+ defaultRuntime.exit(1);
+ return;
+ }
+ const runtimeRaw = opts.runtime
+ ? String(opts.runtime)
+ : DEFAULT_GATEWAY_DAEMON_RUNTIME;
+ if (!isGatewayDaemonRuntime(runtimeRaw)) {
+ defaultRuntime.error('Invalid --runtime (use "node" or "bun")');
+ defaultRuntime.exit(1);
+ return;
+ }
+
+ const service = resolveGatewayService();
+ let loaded = false;
+ try {
+ loaded = await service.isLoaded({ env: process.env });
+ } catch (err) {
+ defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ return;
+ }
+ if (loaded) {
+ defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
+ return;
+ }
+
+ const devMode =
+ process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
+ process.argv[1]?.endsWith(".ts");
+ const { programArguments, workingDirectory } =
+ await resolveGatewayProgramArguments({
+ port,
+ dev: devMode,
+ runtime: runtimeRaw,
+ });
+ const environment: Record = {
+ PATH: process.env.PATH,
+ CLAWDBOT_GATEWAY_TOKEN:
+ opts.token ||
+ cfg.gateway?.auth?.token ||
+ process.env.CLAWDBOT_GATEWAY_TOKEN,
+ CLAWDBOT_LAUNCHD_LABEL:
+ process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
+ };
+
+ try {
+ await service.install({
+ env: process.env,
+ stdout: process.stdout,
+ programArguments,
+ workingDirectory,
+ environment,
+ });
+ } catch (err) {
+ defaultRuntime.error(`Gateway install failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ }
+}
+
+export async function runDaemonUninstall() {
+ if (resolveIsNixMode(process.env)) {
+ defaultRuntime.error("Nix mode detected; daemon uninstall is disabled.");
+ defaultRuntime.exit(1);
+ return;
+ }
+
+ const service = resolveGatewayService();
+ try {
+ await service.uninstall({ env: process.env, stdout: process.stdout });
+ } catch (err) {
+ defaultRuntime.error(`Gateway uninstall failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ }
+}
+
+export async function runDaemonStart() {
+ const service = resolveGatewayService();
+ let loaded = false;
+ try {
+ loaded = await service.isLoaded({ env: process.env });
+ } catch (err) {
+ defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ return;
+ }
+ if (!loaded) {
+ defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
+ for (const hint of renderGatewayServiceStartHints()) {
+ defaultRuntime.log(`Start with: ${hint}`);
+ }
+ return;
+ }
+ try {
+ await service.restart({ stdout: process.stdout });
+ } catch (err) {
+ defaultRuntime.error(`Gateway start failed: ${String(err)}`);
+ for (const hint of renderGatewayServiceStartHints()) {
+ defaultRuntime.error(`Start with: ${hint}`);
+ }
+ defaultRuntime.exit(1);
+ }
+}
+
+export async function runDaemonStop() {
+ const service = resolveGatewayService();
+ let loaded = false;
+ try {
+ loaded = await service.isLoaded({ env: process.env });
+ } catch (err) {
+ defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ return;
+ }
+ if (!loaded) {
+ defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
+ return;
+ }
+ try {
+ await service.stop({ stdout: process.stdout });
+ } catch (err) {
+ defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ }
+}
+
+export async function runDaemonRestart() {
+ const service = resolveGatewayService();
+ let loaded = false;
+ try {
+ loaded = await service.isLoaded({ env: process.env });
+ } catch (err) {
+ defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ return;
+ }
+ if (!loaded) {
+ defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
+ for (const hint of renderGatewayServiceStartHints()) {
+ defaultRuntime.log(`Start with: ${hint}`);
+ }
+ return;
+ }
+ try {
+ await service.restart({ stdout: process.stdout });
+ } catch (err) {
+ defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ }
+}
+
+export function registerDaemonCli(program: Command) {
+ const daemon = program
+ .command("daemon")
+ .description(
+ "Manage the Gateway daemon service (launchd/systemd/schtasks)",
+ );
+
+ daemon
+ .command("status")
+ .description("Show daemon 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)")
+ .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),
+ });
+ });
+
+ daemon
+ .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)")
+ .action(async (opts) => {
+ await runDaemonInstall(opts);
+ });
+
+ daemon
+ .command("uninstall")
+ .description("Uninstall the Gateway service (launchd/systemd/schtasks)")
+ .action(async () => {
+ await runDaemonUninstall();
+ });
+
+ daemon
+ .command("start")
+ .description("Start the Gateway service (launchd/systemd/schtasks)")
+ .action(async () => {
+ await runDaemonStart();
+ });
+
+ daemon
+ .command("stop")
+ .description("Stop the Gateway service (launchd/systemd/schtasks)")
+ .action(async () => {
+ await runDaemonStop();
+ });
+
+ daemon
+ .command("restart")
+ .description("Restart the Gateway service (launchd/systemd/schtasks)")
+ .action(async () => {
+ await runDaemonRestart();
+ });
+
+ // Build default deps (parity with other commands).
+ void createDefaultDeps();
+}
diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts
index c4a134a6d..9bdef0027 100644
--- a/src/cli/gateway-cli.coverage.test.ts
+++ b/src/cli/gateway-cli.coverage.test.ts
@@ -13,7 +13,9 @@ const forceFreePortAndWait = vi.fn(async () => ({
waitedMs: 0,
escalatedToSigkill: false,
}));
+const serviceInstall = vi.fn().mockResolvedValue(undefined);
const serviceStop = vi.fn().mockResolvedValue(undefined);
+const serviceUninstall = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
@@ -82,8 +84,8 @@ vi.mock("../daemon/service.js", () => ({
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
- install: vi.fn(),
- uninstall: vi.fn(),
+ install: serviceInstall,
+ uninstall: serviceUninstall,
stop: serviceStop,
restart: serviceRestart,
isLoaded: serviceIsLoaded,
@@ -91,6 +93,12 @@ vi.mock("../daemon/service.js", () => ({
}),
}));
+vi.mock("../daemon/program-args.js", () => ({
+ resolveGatewayProgramArguments: async () => ({
+ programArguments: ["/bin/node", "cli", "gateway-daemon", "--port", "18789"],
+ }),
+}));
+
describe("gateway-cli coverage", () => {
it("registers call/health/status/send/agent commands and routes to callGateway", async () => {
runtimeLogs.length = 0;
@@ -264,6 +272,30 @@ describe("gateway-cli coverage", () => {
expect(serviceRestart).toHaveBeenCalledTimes(1);
});
+ it("supports gateway install/uninstall/start via daemon helpers", async () => {
+ runtimeLogs.length = 0;
+ runtimeErrors.length = 0;
+ serviceInstall.mockClear();
+ serviceUninstall.mockClear();
+ serviceRestart.mockClear();
+ serviceIsLoaded.mockResolvedValueOnce(false);
+
+ const { registerGatewayCli } = await import("./gateway-cli.js");
+ const program = new Command();
+ program.exitOverride();
+ registerGatewayCli(program);
+
+ await program.parseAsync(["gateway", "install", "--port", "18789"], {
+ from: "user",
+ });
+ await program.parseAsync(["gateway", "uninstall"], { from: "user" });
+ await program.parseAsync(["gateway", "start"], { from: "user" });
+
+ expect(serviceInstall).toHaveBeenCalledTimes(1);
+ expect(serviceUninstall).toHaveBeenCalledTimes(1);
+ expect(serviceRestart).toHaveBeenCalledTimes(1);
+ });
+
it("prints stop hints on GatewayLockError when service is loaded", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts
index 6ac33db34..72e5badd2 100644
--- a/src/cli/gateway-cli.ts
+++ b/src/cli/gateway-cli.ts
@@ -22,6 +22,14 @@ import { setVerbose } from "../globals.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { createSubsystemLogger } from "../logging.js";
import { defaultRuntime } from "../runtime.js";
+import {
+ runDaemonInstall,
+ runDaemonRestart,
+ runDaemonStart,
+ runDaemonStatus,
+ runDaemonStop,
+ runDaemonUninstall,
+} from "./daemon-cli.js";
import { createDefaultDeps } from "./deps.js";
import { forceFreePortAndWait } from "./ports.js";
@@ -91,21 +99,6 @@ function renderGatewayServiceStopHints(): string[] {
}
}
-function renderGatewayServiceStartHints(): string[] {
- switch (process.platform) {
- case "darwin":
- return [
- `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
- ];
- case "linux":
- return [`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`];
- case "win32":
- return [`schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`];
- default:
- return [];
- }
-}
-
async function maybeExplainGatewayServiceStop() {
const service = resolveGatewayService();
let loaded: boolean | null = null;
@@ -594,6 +587,62 @@ export function registerGatewayCli(program: Command) {
}
});
+ gateway
+ .command("install")
+ .description(
+ "Install the Gateway service (alias for `clawdbot daemon install`)",
+ )
+ .option("--port ", "Gateway port")
+ .option("--runtime ", "Daemon runtime (node|bun). Default: node")
+ .option("--token ", "Gateway token (token auth)")
+ .action(async (opts) => {
+ await runDaemonInstall(opts);
+ });
+
+ gateway
+ .command("uninstall")
+ .description(
+ "Uninstall the Gateway service (alias for `clawdbot daemon uninstall`)",
+ )
+ .action(async () => {
+ await runDaemonUninstall();
+ });
+
+ gateway
+ .command("start")
+ .description(
+ "Start the Gateway service (alias for `clawdbot daemon start`)",
+ )
+ .action(async () => {
+ await runDaemonStart();
+ });
+
+ const gatewayDaemon = gateway
+ .command("daemon")
+ .description("Daemon helpers (alias for `clawdbot daemon`)");
+
+ gatewayDaemon
+ .command("status")
+ .description("Show daemon 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)")
+ .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),
+ });
+ });
+
gatewayCallOpts(
gateway
.command("call")
@@ -737,53 +786,14 @@ export function registerGatewayCli(program: Command) {
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
- const service = resolveGatewayService();
- let loaded = false;
- try {
- loaded = await service.isLoaded({ env: process.env });
- } catch (err) {
- defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
- defaultRuntime.exit(1);
- return;
- }
- if (!loaded) {
- defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
- return;
- }
- try {
- await service.stop({ stdout: process.stdout });
- } catch (err) {
- defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
- defaultRuntime.exit(1);
- }
+ await runDaemonStop();
});
gateway
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
- const service = resolveGatewayService();
- let loaded = false;
- try {
- loaded = await service.isLoaded({ env: process.env });
- } catch (err) {
- defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
- defaultRuntime.exit(1);
- return;
- }
- if (!loaded) {
- defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
- for (const hint of renderGatewayServiceStartHints()) {
- defaultRuntime.log(`Start with: ${hint}`);
- }
- return;
- }
- try {
- await service.restart({ stdout: process.stdout });
- } catch (err) {
- defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
- defaultRuntime.exit(1);
- }
+ await runDaemonRestart();
});
// Build default deps (keeps parity with other commands; future-proofing).
diff --git a/src/cli/program.ts b/src/cli/program.ts
index 3ff9dbc73..196c506e2 100644
--- a/src/cli/program.ts
+++ b/src/cli/program.ts
@@ -32,6 +32,7 @@ import { resolveWhatsAppAccount } from "../web/accounts.js";
import { registerBrowserCli } from "./browser-cli.js";
import { registerCanvasCli } from "./canvas-cli.js";
import { registerCronCli } from "./cron-cli.js";
+import { registerDaemonCli } from "./daemon-cli.js";
import { createDefaultDeps } from "./deps.js";
import { registerDnsCli } from "./dns-cli.js";
import { registerDocsCli } from "./docs-cli.js";
@@ -624,6 +625,7 @@ Examples:
});
registerCanvasCli(program);
+ registerDaemonCli(program);
registerGatewayCli(program);
registerModelsCli(program);
registerNodesCli(program);
diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts
index 0c032942f..9113cd4ea 100644
--- a/src/commands/onboard-providers.ts
+++ b/src/commands/onboard-providers.ts
@@ -211,7 +211,7 @@ function setWhatsAppAllowFrom(
function setMessagesResponsePrefix(
cfg: ClawdbotConfig,
responsePrefix?: string,
-) {
+): ClawdbotConfig {
return {
...cfg,
messages: {
diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts
new file mode 100644
index 000000000..3f8ce4c97
--- /dev/null
+++ b/src/daemon/inspect.ts
@@ -0,0 +1,305 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+
+import {
+ GATEWAY_LAUNCH_AGENT_LABEL,
+ GATEWAY_SYSTEMD_SERVICE_NAME,
+ GATEWAY_WINDOWS_TASK_NAME,
+ LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
+ LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
+ LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
+} from "./constants.js";
+
+export type ExtraGatewayService = {
+ platform: "darwin" | "linux" | "win32";
+ label: string;
+ detail: string;
+ scope: "user" | "system";
+};
+
+export type FindExtraGatewayServicesOptions = {
+ deep?: boolean;
+};
+
+const EXTRA_MARKERS = ["clawdbot", "clawdis", "gateway-daemon"];
+const execFileAsync = promisify(execFile);
+
+function resolveHomeDir(env: Record): string {
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim();
+ if (!home) throw new Error("Missing HOME");
+ return home;
+}
+
+function containsMarker(content: string): boolean {
+ const lower = content.toLowerCase();
+ return EXTRA_MARKERS.some((marker) => lower.includes(marker));
+}
+
+function tryExtractPlistLabel(contents: string): string | null {
+ const match = contents.match(
+ /Label<\/key>\s*([\s\S]*?)<\/string>/i,
+ );
+ if (!match) return null;
+ return match[1]?.trim() || null;
+}
+
+function isIgnoredLaunchdLabel(label: string): boolean {
+ return (
+ label === GATEWAY_LAUNCH_AGENT_LABEL ||
+ LEGACY_GATEWAY_LAUNCH_AGENT_LABELS.includes(label)
+ );
+}
+
+function isIgnoredSystemdName(name: string): boolean {
+ return (
+ name === GATEWAY_SYSTEMD_SERVICE_NAME ||
+ LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES.includes(name)
+ );
+}
+
+async function scanLaunchdDir(params: {
+ dir: string;
+ scope: "user" | "system";
+}): Promise {
+ const results: ExtraGatewayService[] = [];
+ let entries: string[] = [];
+ try {
+ entries = await fs.readdir(params.dir);
+ } catch {
+ return results;
+ }
+
+ for (const entry of entries) {
+ if (!entry.endsWith(".plist")) continue;
+ const labelFromName = entry.replace(/\.plist$/, "");
+ if (isIgnoredLaunchdLabel(labelFromName)) continue;
+ const fullPath = path.join(params.dir, entry);
+ let contents = "";
+ try {
+ contents = await fs.readFile(fullPath, "utf8");
+ } catch {
+ continue;
+ }
+ if (!containsMarker(contents)) continue;
+ const label = tryExtractPlistLabel(contents) ?? labelFromName;
+ if (isIgnoredLaunchdLabel(label)) continue;
+ results.push({
+ platform: "darwin",
+ label,
+ detail: `plist: ${fullPath}`,
+ scope: params.scope,
+ });
+ }
+
+ return results;
+}
+
+async function scanSystemdDir(params: {
+ dir: string;
+ scope: "user" | "system";
+}): Promise {
+ const results: ExtraGatewayService[] = [];
+ let entries: string[] = [];
+ try {
+ entries = await fs.readdir(params.dir);
+ } catch {
+ return results;
+ }
+
+ for (const entry of entries) {
+ if (!entry.endsWith(".service")) continue;
+ const name = entry.replace(/\.service$/, "");
+ if (isIgnoredSystemdName(name)) continue;
+ const fullPath = path.join(params.dir, entry);
+ let contents = "";
+ try {
+ contents = await fs.readFile(fullPath, "utf8");
+ } catch {
+ continue;
+ }
+ if (!containsMarker(contents)) continue;
+ results.push({
+ platform: "linux",
+ label: entry,
+ detail: `unit: ${fullPath}`,
+ scope: params.scope,
+ });
+ }
+
+ return results;
+}
+
+type ScheduledTaskInfo = {
+ name: string;
+ taskToRun?: string;
+};
+
+function parseSchtasksList(output: string): ScheduledTaskInfo[] {
+ const tasks: ScheduledTaskInfo[] = [];
+ let current: ScheduledTaskInfo | null = null;
+
+ for (const rawLine of output.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line) {
+ if (current) {
+ tasks.push(current);
+ current = null;
+ }
+ continue;
+ }
+ const idx = line.indexOf(":");
+ if (idx <= 0) continue;
+ const key = line.slice(0, idx).trim().toLowerCase();
+ const value = line.slice(idx + 1).trim();
+ if (!value) continue;
+ if (key === "taskname") {
+ if (current) tasks.push(current);
+ current = { name: value };
+ continue;
+ }
+ if (!current) continue;
+ if (key === "task to run") {
+ current.taskToRun = value;
+ }
+ }
+
+ if (current) tasks.push(current);
+ return tasks;
+}
+
+async function execSchtasks(
+ args: string[],
+): Promise<{ stdout: string; stderr: string; code: number }> {
+ try {
+ const { stdout, stderr } = await execFileAsync("schtasks", args, {
+ encoding: "utf8",
+ windowsHide: true,
+ });
+ return {
+ stdout: String(stdout ?? ""),
+ stderr: String(stderr ?? ""),
+ code: 0,
+ };
+ } catch (error) {
+ const e = error as {
+ stdout?: unknown;
+ stderr?: unknown;
+ code?: unknown;
+ message?: unknown;
+ };
+ return {
+ stdout: typeof e.stdout === "string" ? e.stdout : "",
+ stderr:
+ typeof e.stderr === "string"
+ ? e.stderr
+ : typeof e.message === "string"
+ ? e.message
+ : "",
+ code: typeof e.code === "number" ? e.code : 1,
+ };
+ }
+}
+
+export async function findExtraGatewayServices(
+ env: Record,
+ opts: FindExtraGatewayServicesOptions = {},
+): Promise {
+ const results: ExtraGatewayService[] = [];
+ const seen = new Set();
+ const push = (svc: ExtraGatewayService) => {
+ const key = `${svc.platform}:${svc.label}:${svc.detail}:${svc.scope}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ results.push(svc);
+ };
+
+ if (process.platform === "darwin") {
+ try {
+ const home = resolveHomeDir(env);
+ const userDir = path.join(home, "Library", "LaunchAgents");
+ for (const svc of await scanLaunchdDir({
+ dir: userDir,
+ scope: "user",
+ })) {
+ push(svc);
+ }
+ if (opts.deep) {
+ for (const svc of await scanLaunchdDir({
+ dir: path.join(path.sep, "Library", "LaunchAgents"),
+ scope: "system",
+ })) {
+ push(svc);
+ }
+ for (const svc of await scanLaunchdDir({
+ dir: path.join(path.sep, "Library", "LaunchDaemons"),
+ scope: "system",
+ })) {
+ push(svc);
+ }
+ }
+ } catch {
+ return results;
+ }
+ return results;
+ }
+
+ if (process.platform === "linux") {
+ try {
+ const home = resolveHomeDir(env);
+ const userDir = path.join(home, ".config", "systemd", "user");
+ for (const svc of await scanSystemdDir({
+ dir: userDir,
+ scope: "user",
+ })) {
+ push(svc);
+ }
+ if (opts.deep) {
+ for (const dir of [
+ "/etc/systemd/system",
+ "/usr/lib/systemd/system",
+ "/lib/systemd/system",
+ ]) {
+ for (const svc of await scanSystemdDir({
+ dir,
+ scope: "system",
+ })) {
+ push(svc);
+ }
+ }
+ }
+ } catch {
+ return results;
+ }
+ return results;
+ }
+
+ if (process.platform === "win32") {
+ if (!opts.deep) return results;
+ const res = await execSchtasks(["/Query", "/FO", "LIST", "/V"]);
+ if (res.code !== 0) return results;
+ const tasks = parseSchtasksList(res.stdout);
+ for (const task of tasks) {
+ const name = task.name.trim();
+ if (!name) continue;
+ if (name === GATEWAY_WINDOWS_TASK_NAME) continue;
+ if (LEGACY_GATEWAY_WINDOWS_TASK_NAMES.includes(name)) continue;
+ const lowerName = name.toLowerCase();
+ const lowerCommand = task.taskToRun?.toLowerCase() ?? "";
+ const matches = EXTRA_MARKERS.some(
+ (marker) => lowerName.includes(marker) || lowerCommand.includes(marker),
+ );
+ if (!matches) continue;
+ push({
+ platform: "win32",
+ label: name,
+ detail: task.taskToRun ? `task: ${name}, run: ${task.taskToRun}` : name,
+ scope: "system",
+ });
+ }
+ return results;
+ }
+
+ return results;
+}
From e41540e4ff3fd9293466a462b2ebd2a1b243959b Mon Sep 17 00:00:00 2001
From: Azade
Date: Wed, 7 Jan 2026 13:33:41 +0000
Subject: [PATCH 079/115] feat(commands): add dynamic / model switching
---
src/auto-reply/model.ts | 30 +++++++++++++++++++---
src/auto-reply/reply.ts | 7 ++++-
src/auto-reply/reply/directive-handling.ts | 9 +++++--
3 files changed, 40 insertions(+), 6 deletions(-)
diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts
index 56bb6e19e..37adeeab8 100644
--- a/src/auto-reply/model.ts
+++ b/src/auto-reply/model.ts
@@ -1,14 +1,36 @@
-export function extractModelDirective(body?: string): {
+function escapeRegExp(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+export function extractModelDirective(
+ body?: string,
+ options?: { aliases?: string[] },
+): {
cleaned: string;
rawModel?: string;
rawProfile?: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
- const match = body.match(
+
+ const modelMatch = body.match(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
);
- const raw = match?.[1]?.trim();
+
+ const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
+ const aliasMatch =
+ modelMatch || aliases.length === 0
+ ? null
+ : body.match(
+ new RegExp(
+ `(?:^|\\s)\\/(${aliases.map(escapeRegExp).join("|")})(?=$|\\s|:)`,
+ "i",
+ ),
+ );
+
+ const match = modelMatch ?? aliasMatch;
+ const raw = modelMatch ? modelMatch?.[1]?.trim() : aliasMatch?.[1]?.trim();
+
let rawModel = raw;
let rawProfile: string | undefined;
if (raw?.includes("@")) {
@@ -16,9 +38,11 @@ export function extractModelDirective(body?: string): {
rawModel = parts[0]?.trim();
rawProfile = parts.slice(1).join("@").trim() || undefined;
}
+
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim();
+
return {
cleaned,
rawModel,
diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts
index aaa9efd76..c2bd3ecb3 100644
--- a/src/auto-reply/reply.ts
+++ b/src/auto-reply/reply.ts
@@ -312,7 +312,12 @@ export async function getReplyFromConfig(
rawDrop: undefined,
hasQueueOptions: false,
});
- let parsedDirectives = parseInlineDirectives(rawBody);
+ const configuredAliases = Object.values(cfg.agent?.models ?? {})
+ .map((entry) => entry.alias)
+ .filter((alias): alias is string => Boolean(alias));
+ let parsedDirectives = parseInlineDirectives(rawBody, {
+ modelAliases: configuredAliases,
+ });
const hasDirective =
parsedDirectives.hasThinkDirective ||
parsedDirectives.hasVerboseDirective ||
diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts
index b811248c3..5c368721c 100644
--- a/src/auto-reply/reply/directive-handling.ts
+++ b/src/auto-reply/reply/directive-handling.ts
@@ -181,7 +181,10 @@ export type InlineDirectives = {
hasQueueOptions: boolean;
};
-export function parseInlineDirectives(body: string): InlineDirectives {
+export function parseInlineDirectives(
+ body: string,
+ options?: { modelAliases?: string[] },
+): InlineDirectives {
const {
cleaned: thinkCleaned,
thinkLevel,
@@ -213,7 +216,9 @@ export function parseInlineDirectives(body: string): InlineDirectives {
rawModel,
rawProfile,
hasDirective: hasModelDirective,
- } = extractModelDirective(statusCleaned);
+ } = extractModelDirective(statusCleaned, {
+ aliases: options?.modelAliases,
+ });
const {
cleaned: queueCleaned,
queueMode,
From bb29a3ee3ff1cac88a5dcc0de38f2de9917af026 Mon Sep 17 00:00:00 2001
From: Azade
Date: Wed, 7 Jan 2026 13:41:40 +0000
Subject: [PATCH 080/115] fix: filter reserved commands from model aliases +
add tests
---
src/auto-reply/model.test.ts | 115 +++++++++++++++++++++++++++++++++++
src/auto-reply/reply.ts | 13 +++-
2 files changed, 126 insertions(+), 2 deletions(-)
create mode 100644 src/auto-reply/model.test.ts
diff --git a/src/auto-reply/model.test.ts b/src/auto-reply/model.test.ts
new file mode 100644
index 000000000..f4dd64221
--- /dev/null
+++ b/src/auto-reply/model.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it } from "vitest";
+import { extractModelDirective } from "./model.js";
+
+describe("extractModelDirective", () => {
+ describe("basic /model command", () => {
+ it("extracts /model with argument", () => {
+ const result = extractModelDirective("/model gpt-5");
+ expect(result.hasDirective).toBe(true);
+ expect(result.rawModel).toBe("gpt-5");
+ expect(result.cleaned).toBe("");
+ });
+
+ it("extracts /model with provider/model format", () => {
+ const result = extractModelDirective("/model anthropic/claude-opus-4-5");
+ expect(result.hasDirective).toBe(true);
+ expect(result.rawModel).toBe("anthropic/claude-opus-4-5");
+ });
+
+ it("extracts /model with profile override", () => {
+ const result = extractModelDirective("/model gpt-5@myprofile");
+ expect(result.hasDirective).toBe(true);
+ expect(result.rawModel).toBe("gpt-5");
+ expect(result.rawProfile).toBe("myprofile");
+ });
+
+ it("returns no directive for plain text", () => {
+ const result = extractModelDirective("hello world");
+ expect(result.hasDirective).toBe(false);
+ expect(result.cleaned).toBe("hello world");
+ });
+ });
+
+ describe("alias shortcuts", () => {
+ it("recognizes /gpt as model directive when alias is configured", () => {
+ const result = extractModelDirective("/gpt", { aliases: ["gpt", "sonnet", "opus"] });
+ expect(result.hasDirective).toBe(true);
+ expect(result.rawModel).toBe("gpt");
+ expect(result.cleaned).toBe("");
+ });
+
+ it("recognizes /sonnet as model directive", () => {
+ const result = extractModelDirective("/sonnet", { aliases: ["gpt", "sonnet", "opus"] });
+ expect(result.hasDirective).toBe(true);
+ expect(result.rawModel).toBe("sonnet");
+ });
+
+ it("recognizes alias mid-message", () => {
+ const result = extractModelDirective("switch to /opus please", {
+ aliases: ["opus"],
+ });
+ expect(result.hasDirective).toBe(true);
+ expect(result.rawModel).toBe("opus");
+ expect(result.cleaned).toBe("switch to please");
+ });
+
+ it("is case-insensitive for aliases", () => {
+ const result = extractModelDirective("/GPT", { aliases: ["gpt"] });
+ expect(result.hasDirective).toBe(true);
+ expect(result.rawModel).toBe("GPT");
+ });
+
+ it("does not match alias without leading slash", () => {
+ const result = extractModelDirective("gpt is great", { aliases: ["gpt"] });
+ expect(result.hasDirective).toBe(false);
+ });
+
+ it("does not match unknown aliases", () => {
+ const result = extractModelDirective("/unknown", { aliases: ["gpt", "sonnet"] });
+ expect(result.hasDirective).toBe(false);
+ expect(result.cleaned).toBe("/unknown");
+ });
+
+ it("prefers /model over alias when both present", () => {
+ const result = extractModelDirective("/model haiku", { aliases: ["gpt"] });
+ expect(result.hasDirective).toBe(true);
+ expect(result.rawModel).toBe("haiku");
+ });
+
+ it("handles empty aliases array", () => {
+ const result = extractModelDirective("/gpt", { aliases: [] });
+ expect(result.hasDirective).toBe(false);
+ });
+
+ it("handles undefined aliases", () => {
+ const result = extractModelDirective("/gpt");
+ expect(result.hasDirective).toBe(false);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("handles alias with special regex characters", () => {
+ const result = extractModelDirective("/test.alias", {
+ aliases: ["test.alias"],
+ });
+ expect(result.hasDirective).toBe(true);
+ expect(result.rawModel).toBe("test.alias");
+ });
+
+ it("does not match partial alias", () => {
+ const result = extractModelDirective("/gpt-turbo", { aliases: ["gpt"] });
+ expect(result.hasDirective).toBe(false);
+ });
+
+ it("handles empty body", () => {
+ const result = extractModelDirective("", { aliases: ["gpt"] });
+ expect(result.hasDirective).toBe(false);
+ expect(result.cleaned).toBe("");
+ });
+
+ it("handles undefined body", () => {
+ const result = extractModelDirective(undefined, { aliases: ["gpt"] });
+ expect(result.hasDirective).toBe(false);
+ });
+ });
+});
diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts
index c2bd3ecb3..e7a063ee4 100644
--- a/src/auto-reply/reply.ts
+++ b/src/auto-reply/reply.ts
@@ -31,7 +31,10 @@ import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import { hasControlCommand } from "./command-detection.js";
-import { shouldHandleTextCommands } from "./commands-registry.js";
+import {
+ listChatCommands,
+ shouldHandleTextCommands,
+} from "./commands-registry.js";
import { getAbortMemory } from "./reply/abort.js";
import { runReplyAgent } from "./reply/agent-runner.js";
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
@@ -312,9 +315,15 @@ export async function getReplyFromConfig(
rawDrop: undefined,
hasQueueOptions: false,
});
+ const reservedCommands = new Set(
+ listChatCommands().flatMap((cmd) =>
+ cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()),
+ ),
+ );
const configuredAliases = Object.values(cfg.agent?.models ?? {})
.map((entry) => entry.alias)
- .filter((alias): alias is string => Boolean(alias));
+ .filter((alias): alias is string => Boolean(alias))
+ .filter((alias) => !reservedCommands.has(alias.toLowerCase()));
let parsedDirectives = parseInlineDirectives(rawBody, {
modelAliases: configuredAliases,
});
From 7ce1f635cd1c3686d2bf8ab22a09f9e50373492f Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 19:58:23 +0000
Subject: [PATCH 081/115] fix(commands): harden model alias parsing
---
CHANGELOG.md | 1 +
docs/tools/slash-commands.md | 2 +-
src/auto-reply/model.test.ts | 20 ++++++++++++-----
src/auto-reply/model.ts | 4 +++-
src/auto-reply/reply.directive.test.ts | 30 ++++++++++++++++++++++++++
src/auto-reply/reply.ts | 2 +-
6 files changed, 51 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82a4557e5..6bbf680e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -74,6 +74,7 @@
- Auto-reply: add per-channel/topic skill filters + system prompts for Discord/Slack/Telegram. Thanks @kitze for PR #286.
- Auto-reply: refresh `/status` output with build info, compact context, and queue depth.
- Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295.
+- Commands: allow `/` shorthand for `/model` using `agent.models.*.alias`, without shadowing built-ins. Thanks @azade-c for PR #393.
- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index 17caaec78..58af62b71 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -42,7 +42,7 @@ Text + native (when enabled):
- `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
-- `/model `
+- `/model ` (or `/` from `agent.models.*.alias`)
- `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`)
Text-only:
diff --git a/src/auto-reply/model.test.ts b/src/auto-reply/model.test.ts
index f4dd64221..85a3b3560 100644
--- a/src/auto-reply/model.test.ts
+++ b/src/auto-reply/model.test.ts
@@ -32,14 +32,18 @@ describe("extractModelDirective", () => {
describe("alias shortcuts", () => {
it("recognizes /gpt as model directive when alias is configured", () => {
- const result = extractModelDirective("/gpt", { aliases: ["gpt", "sonnet", "opus"] });
+ const result = extractModelDirective("/gpt", {
+ aliases: ["gpt", "sonnet", "opus"],
+ });
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt");
expect(result.cleaned).toBe("");
});
it("recognizes /sonnet as model directive", () => {
- const result = extractModelDirective("/sonnet", { aliases: ["gpt", "sonnet", "opus"] });
+ const result = extractModelDirective("/sonnet", {
+ aliases: ["gpt", "sonnet", "opus"],
+ });
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("sonnet");
});
@@ -60,18 +64,24 @@ describe("extractModelDirective", () => {
});
it("does not match alias without leading slash", () => {
- const result = extractModelDirective("gpt is great", { aliases: ["gpt"] });
+ const result = extractModelDirective("gpt is great", {
+ aliases: ["gpt"],
+ });
expect(result.hasDirective).toBe(false);
});
it("does not match unknown aliases", () => {
- const result = extractModelDirective("/unknown", { aliases: ["gpt", "sonnet"] });
+ const result = extractModelDirective("/unknown", {
+ aliases: ["gpt", "sonnet"],
+ });
expect(result.hasDirective).toBe(false);
expect(result.cleaned).toBe("/unknown");
});
it("prefers /model over alias when both present", () => {
- const result = extractModelDirective("/model haiku", { aliases: ["gpt"] });
+ const result = extractModelDirective("/model haiku", {
+ aliases: ["gpt"],
+ });
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("haiku");
});
diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts
index 37adeeab8..f85cb4ba5 100644
--- a/src/auto-reply/model.ts
+++ b/src/auto-reply/model.ts
@@ -17,7 +17,9 @@ export function extractModelDirective(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
);
- const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
+ const aliases = (options?.aliases ?? [])
+ .map((alias) => alias.trim())
+ .filter(Boolean);
const aliasMatch =
modelMatch || aliases.length === 0
? null
diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts
index 9f9105d44..a6014e8f9 100644
--- a/src/auto-reply/reply.directive.test.ts
+++ b/src/auto-reply/reply.directive.test.ts
@@ -144,6 +144,36 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("please now");
});
+ it("keeps reserved command aliases from matching after trimming", async () => {
+ await withTempHome(async (home) => {
+ vi.mocked(runEmbeddedPiAgent).mockReset();
+
+ const res = await getReplyFromConfig(
+ {
+ Body: "/help",
+ From: "+1222",
+ To: "+1222",
+ },
+ {},
+ {
+ agent: {
+ model: "anthropic/claude-opus-4-5",
+ workspace: path.join(home, "clawd"),
+ models: {
+ "anthropic/claude-opus-4-5": { alias: " help " },
+ },
+ },
+ whatsapp: { allowFrom: ["*"] },
+ session: { store: path.join(home, "sessions.json") },
+ },
+ );
+
+ const text = Array.isArray(res) ? res[0]?.text : res?.text;
+ expect(text).toContain("Help");
+ expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
+ });
+ });
+
it("errors on invalid queue options", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts
index e7a063ee4..1ac4fabb7 100644
--- a/src/auto-reply/reply.ts
+++ b/src/auto-reply/reply.ts
@@ -321,7 +321,7 @@ export async function getReplyFromConfig(
),
);
const configuredAliases = Object.values(cfg.agent?.models ?? {})
- .map((entry) => entry.alias)
+ .map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
let parsedDirectives = parseInlineDirectives(rawBody, {
From 9859ad31763d87ddd934fbde2a47d3ef86ebb165 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Wed, 7 Jan 2026 20:39:03 +0000
Subject: [PATCH 082/115] style(macos): swiftformat + swiftlint cleanup
---
.../Clawdbot/GatewayAgentChannel.swift | 7 +++----
.../Clawdbot/GatewayDiscoveryModel.swift | 19 +++++++++++++++----
.../Clawdbot/GatewayLaunchAgentManager.swift | 6 +++++-
apps/macos/Sources/Clawdbot/Launchctl.swift | 1 -
.../Clawdbot/MenuSessionsInjector.swift | 2 +-
.../Clawdbot/MenuUsageHeaderView.swift | 1 -
.../Sources/Clawdbot/RemotePortTunnel.swift | 4 ++--
apps/macos/Sources/Clawdbot/UsageData.swift | 5 ++---
.../Sources/Clawdbot/UsageMenuLabelView.swift | 5 ++---
9 files changed, 30 insertions(+), 20 deletions(-)
diff --git a/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift b/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift
index da96723a1..69e70b2b6 100644
--- a/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift
@@ -16,12 +16,11 @@ enum GatewayAgentChannel: String, CaseIterable, Sendable {
func shouldDeliver(_ isLast: Bool) -> Bool {
switch self {
case .webchat:
- return false
+ false
case .last:
- return isLast
+ isLast
case .whatsapp, .telegram:
- return true
+ true
}
}
}
-
diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift b/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift
index 5770670d1..384f9ca4f 100644
--- a/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift
@@ -208,9 +208,15 @@ final class GatewayDiscoveryModel {
return merged
}
- static func parseGatewayTXT(_ txt: [String: String])
- -> (lanHost: String?, tailnetDns: String?, sshPort: Int, gatewayPort: Int?, cliPath: String?)
- {
+ struct GatewayTXT: Equatable {
+ var lanHost: String?
+ var tailnetDns: String?
+ var sshPort: Int
+ var gatewayPort: Int?
+ var cliPath: String?
+ }
+
+ static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
var lanHost: String?
var tailnetDns: String?
var sshPort = 22
@@ -242,7 +248,12 @@ final class GatewayDiscoveryModel {
cliPath = trimmed.isEmpty ? nil : trimmed
}
- return (lanHost, tailnetDns, sshPort, gatewayPort, cliPath)
+ return GatewayTXT(
+ lanHost: lanHost,
+ tailnetDns: tailnetDns,
+ sshPort: sshPort,
+ gatewayPort: gatewayPort,
+ cliPath: cliPath)
}
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
index f97ae9fd9..a4b718f35 100644
--- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
@@ -62,7 +62,11 @@ enum GatewayLaunchAgentManager {
let desiredBind = self.preferredGatewayBind() ?? "loopback"
let desiredToken = self.preferredGatewayToken()
let desiredPassword = self.preferredGatewayPassword()
- let desiredConfig = DesiredConfig(port: port, bind: desiredBind, token: desiredToken, password: desiredPassword)
+ let desiredConfig = DesiredConfig(
+ port: port,
+ bind: desiredBind,
+ token: desiredToken,
+ password: desiredPassword)
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
diff --git a/apps/macos/Sources/Clawdbot/Launchctl.swift b/apps/macos/Sources/Clawdbot/Launchctl.swift
index 9a0cee654..ba52bb96b 100644
--- a/apps/macos/Sources/Clawdbot/Launchctl.swift
+++ b/apps/macos/Sources/Clawdbot/Launchctl.swift
@@ -79,4 +79,3 @@ enum LaunchAgentPlist {
return token.isEmpty ? nil : token
}
}
-
diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift
index 286f460f7..8c2e01656 100644
--- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift
+++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift
@@ -267,7 +267,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
let rows = self.usageRows
let errorText = self.cachedUsageErrorText
- if rows.isEmpty && errorText == nil {
+ if rows.isEmpty, errorText == nil {
return cursor
}
diff --git a/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift b/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift
index 199b01cf1..73152143d 100644
--- a/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift
+++ b/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift
@@ -42,4 +42,3 @@ struct MenuUsageHeaderView: View {
return "\(self.count) providers"
}
}
-
diff --git a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
index cf0818b28..34e952540 100644
--- a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
+++ b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
@@ -41,8 +41,8 @@ final class RemotePortTunnel {
static func create(
remotePort: Int,
preferredLocalPort: UInt16? = nil,
- allowRemoteUrlOverride: Bool = true
- ) async throws -> RemotePortTunnel {
+ allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
+ {
let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
throw NSError(
diff --git a/apps/macos/Sources/Clawdbot/UsageData.swift b/apps/macos/Sources/Clawdbot/UsageData.swift
index 0db492938..2318d98e8 100644
--- a/apps/macos/Sources/Clawdbot/UsageData.swift
+++ b/apps/macos/Sources/Clawdbot/UsageData.swift
@@ -29,8 +29,8 @@ struct UsageRow: Identifiable {
let error: String?
var titleText: String {
- if let plan, !plan.isEmpty { return "\(displayName) (\(plan))" }
- return displayName
+ if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" }
+ return self.displayName
}
var remainingPercent: Int? {
@@ -107,4 +107,3 @@ enum UsageLoader {
return try JSONDecoder().decode(GatewayUsageSummary.self, from: data)
}
}
-
diff --git a/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift b/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift
index c5514a53d..4b1193e2f 100644
--- a/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift
+++ b/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift
@@ -21,7 +21,7 @@ struct UsageMenuLabelView: View {
}
HStack(alignment: .firstTextBaseline, spacing: 6) {
- Text(row.titleText)
+ Text(self.row.titleText)
.font(.caption.weight(.semibold))
.foregroundStyle(self.primaryTextColor)
.lineLimit(1)
@@ -30,7 +30,7 @@ struct UsageMenuLabelView: View {
Spacer(minLength: 4)
- Text(row.detailText())
+ Text(self.row.detailText())
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
@@ -43,4 +43,3 @@ struct UsageMenuLabelView: View {
.padding(.trailing, self.paddingTrailing)
}
}
-
From 7905d1d92fdf59f1a204b1e2636ba17df1814cb3 Mon Sep 17 00:00:00 2001
From: Josh Palmer
Date: Wed, 7 Jan 2026 19:57:37 +0100
Subject: [PATCH 083/115] Docs: add Clawdhub showcase previews
---
docs/assets/markdown.css | 46 ++++++++++++++++++
docs/assets/showcase/gohome-grafana.png | Bin 0 -> 388314 bytes
docs/assets/showcase/padel-cli.svg | 11 +++++
docs/assets/showcase/roborock-status.svg | 13 +++++
docs/assets/showcase/xuezh-pronunciation.jpeg | Bin 0 -> 94947 bytes
docs/start/showcase.md | 6 +++
showcase.md | 6 +++
7 files changed, 82 insertions(+)
create mode 100644 docs/assets/showcase/gohome-grafana.png
create mode 100644 docs/assets/showcase/padel-cli.svg
create mode 100644 docs/assets/showcase/roborock-status.svg
create mode 100644 docs/assets/showcase/xuezh-pronunciation.jpeg
diff --git a/docs/assets/markdown.css b/docs/assets/markdown.css
index c6acd9785..6ad456334 100644
--- a/docs/assets/markdown.css
+++ b/docs/assets/markdown.css
@@ -84,6 +84,52 @@
box-shadow: 0 12px 0 -8px rgba(0, 0, 0, 0.18);
}
+.showcase-link {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.showcase-preview {
+ position: absolute;
+ left: 50%;
+ top: 100%;
+ width: min(420px, 80vw);
+ padding: 8px;
+ border-radius: 14px;
+ background: color-mix(in oklab, var(--panel) 92%, transparent);
+ border: 1px solid color-mix(in oklab, var(--frame-border) 30%, transparent);
+ box-shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.55);
+ transform: translate(-50%, 10px) scale(0.98);
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ z-index: 20;
+ transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease;
+}
+
+.showcase-preview img {
+ width: 100%;
+ height: auto;
+ border-radius: 10px;
+ border: 1px solid color-mix(in oklab, var(--frame-border) 25%, transparent);
+ box-shadow: none;
+}
+
+.showcase-link:hover .showcase-preview,
+.showcase-link:focus-within .showcase-preview {
+ opacity: 1;
+ visibility: visible;
+ transform: translate(-50%, 6px) scale(1);
+}
+
+@media (hover: none) {
+ .showcase-preview {
+ display: none;
+ }
+}
+
.markdown code {
font-family: var(--font-body);
font-size: 0.95em;
diff --git a/docs/assets/showcase/gohome-grafana.png b/docs/assets/showcase/gohome-grafana.png
new file mode 100644
index 0000000000000000000000000000000000000000..bd7cf07740207a1ba44bd8a071156eafd7ab4882
GIT binary patch
literal 388314
zcmZU)1yCKqvoDOh1a}VZ?hxGV;10pvH9)Z74gn7C1P$&4cXtQ`=iu(n0bc(1-S^(D
z@7t=b>7DLh_fC)0?(}T5nu;7c3NZ>46coCGytD=s6wD(O6f7JP!oQl&7$|K}P|(VD
zQc`LPQc@IZ?k?7Lj#f}m3`qe$df27ZaYw{TcqEP}5I8#(eqdMp`l}TwOT~kEZQ&PC
ztuwzg<);c)8T7rdUJv*%yhK0DCd$u&jyUE~HXKSGld%Ig1}2_#-6^go__@4wfAF`@6Rm+>=!kez_rJ4K
z<*a*yMn?MI($HxA58PV%(#gw+jVd0iPTats!3CMz^Zly*g<7$BPGHHv8AwK7b~nAI
zkztY$gWw>S9Y)>0WwAX?hE*2r;9DfS#^cb6O!Rkh}@UZ_uKuKsyDJc9aH7(q&teib;T|DonDog*F
zTC&s9^V9<>3t6~0v6)%Am|L;=IJy1@0wwGt^pAD2@-(CHadLF_5b_bB`Y#QkfBb){
z*{LZ0OU2Vcgh~&nMj_?mZbiYz#=*uxC5l2pK_TpJX)UB7E&Jc_e|I8Oww|7@LhS6`
z-rj89+-xrHHtd{&f`aTETJ~-`#G(A?Ear8XOI7G*1ra_|3|{k$;QF{e{KJR3je28NX^d2
z%28k1&goxx{`nB)=jRgsFa7_Yg97;i2LdysGtjo8`
zM(*pDZcppuX@H5s*Azz#o88GMi}N|Y7C1(v8xvBR?_dG-8&x!FaYqV-9P~LcS@S;e
zPkI{Wgu%Om7&&@rEOz*IvOndir=qx}Sh=a4feqP?4P%?>tslnS`5w+2Z<||hZ=080
zfx!1)uaC|fzckBKa(@YVooH?R+U$Ykewc9!!#I0*a68`dOPch2`c}A%YW9mCue7vu
ze>pY>uUIQ{}MV7l@a_WG3nq#}Y=%9$tr3
zZbR&xH+3e07|I{5BDLUY7v*OkqZ`|ZlfyN3;|9R3sjfY3#V=2vdbiHd5t*ExCeFXv
zE4I^AB{w1GDii^LlyE{FLRiL+KCxChRGRsE$#~vZJBbtA^Z$G~b-Am4t8x*4ZEwzA
zS^3RT>9abws8!({E_fi9edOY(M`C1RhqcNxh$htNGF
z5^LsW#(C$Xu*z1)$qoV5;K?RcUn9@6aZsGVEW?*pu>6el3j)x-b$9voALGdNA8X1D
z^u75F^!Mj1gBeEY?C3}{Tuu9fkrC-gk9wg!L4Q*3T|eI4+k9`G+uYt%^o)(qe{ji1
zW@5ZzvP8flK12k(KFUXCU^aTpJK!d~p6D7^ZD&@5owY+TCNdCf)Tm`GE$fzG0yFz@
zQ_suSy{Wshg{V>|aYbDpN-}`Jdzmmo!FFSHC1XY4Nd>%zi~|%J|DmrbEFZHm43(k)#EnFe|U9~Z19GVgnG
zMV5Fx)z+Bb()^Wnw`Njy`8DDAut>+}R8&Wmb74y;daS=(Y-25(A_-(TI5_BEKkEdd
z@C}uDW#^!19Lq?r=G^$wA~7fGWGs!M{Ca=_pM=z)D*MTP2KZgRYxq3R8$PT(tUC~D
z;b=(>XM`&QF>*t&xmL?1_-bvrrV_`lt5!vD>|abbEPu0Y$wK0>d|5(+5v2C6Y2S^g
z;s_la9I|F>*YuzU`ynBLe)I3yBj5eaCco1Yk{lc!oG+&WBACa-V;
z)nrehVEaImXiUktAP+mM=wOW-XX6_c9P2+Hae*olpIamy!6nLu59GA;{@owj#%-;h
zVl0!a^2aL{2!cNGas256nYY%X8YTgbv`yGQs2F^x;=_AyVqJNE-ewT?)YN~q@>&_j
zCNU}gJEi=4Zf;I?w4Fa3@8|sqbg5@eb%wg5qVT{huhQFGdNoF0I_l65jQmKDe9>xSyHn2GKSF-&ett!DrWC%
zUGFF3?h;eGn}!_2$i@f>goV7t_G_vkA-#=RCEp%|cx;l2Gv?D3NXERR=dJeTgkh8A
zj%}A#-Omm~ftVf@V;WTWx_EKDc7=GJ
zkk~U8EI{qZ?M~3>4D*3&TXpXVsxCKv8cRlB&C}H(+kuXq5J&BWm5tF)u^&Tn7q!*%#TY^ygx0h@tLz_gC+UG`@@31aZk>UkLZ!K
zY)FzJLnqMUFRJ%mdd5>0bhHVz)tKQy6!p^i!f~WJcNGM6$lBU~77il7ClqjaIvP;Q
z6@dc4fz-~(BIkL-SbcYwoY{5q2|V;Qhp`I#Wd*8`kEgltTt|^);^*&NO6t6ap`Q1M
zR>4*3{gSue8Z*W0Sato9d0pKgXs5&e7pjaWgV|ooOZ;
z`N148gh-l^KIvhZMrv<^64W1IgHPmR`yxV>4#o`>mEIwa6k2U#00cb*F$5e(6ox0Q
zawE11L}Bxw>f8kvp1qFu9-PgT-44{yG<_9e})j!rdkQHdR$Ve60S()--?b=Q6#pjETk62a_dE$t@Xa-v)!(vQ*dsfdNc@Pt^;GlKm$(=1nW`~9
zI_J?Q65*TW6E%c3bRtJ0!^6_%@e=KNwt!)}c;YJlBoV?J!fb7fIF~H)41Z^eIw{u*
zwDVfBt8?K|v*By~6V<}x!Lzhwtq|bI?q>B&SYLx^tXDVHnh{m!=cVvDs6bLjO9sGA?DM7XxBQ#N?5jV;3=Z<+;r4v
zPMtxi|Ceap<^0alc1W
z6#7JAJQcD4CQ?1&8YeVAU^Gng9Oxhz6pulCbNPIA1US_6-4FAZaOJU1DRb!*c9?V@
zp-u*OXf3*V#|pp9#WJu^Q&5ei*|W1B(a5_IO~c(X5XGtCPx$M8RIqCW96g~X*GBkF
zJodk~)`q8VePisrc~Al$bdf~E#A5ZauC>Ima5++y1&0UZ4UMfo*^$!AYA)4@Dn0Mm
zynY!v(?iJP8;#*yD#MF}-@@e$#4iQPd}|WH0<;L~b#TC?~8rc7=UX?V6MqB*VC0I%cRxP5^}(Y^@bcxT%Bj
z%QVSXX%OFlHHNPfbzC7M$k+oo_c89d`80b(StU(p5igqQORKV6&)j61?Q}SCF;biI
zHBW3P
zMob8;@=@vBc*w)_>u(~NG3{o^K5Ts+Ac+wruOwh*ljp80)t>jBh;);0f=0jHY1ccv
zXGD#e%%`fL$uT5kwXj&4D-J?&7Mw)O@oS)FuO!Qu79dn!J3|
z%vx{AZ=(eY>Pthl$H~C-GsuXdgrwU|WdW}b;U0(6;juGDXj>oYd6*xYs*p&tZ)Qx8
zprD5iu&T*N$`AQLt)zGq)?p;ktN!G$xV4zLbrokx_NL+BKcd*F+LOh0lKk$XUn;OU
zLE)Zl?@flY-x`vC-(HO(UOjj{iHE6v7ZU5m=k!)-lKOttmx_dD(!gQAKMcZfO8T3#
zg4QU)NdM9r;3bAIy~x!DYh2POG`t$a#W?i+JbqzS6ENWbkq}%=qsf|(H8BM2*K4c2
z$Q6ysh)9}BEw(J!w&;T+E*VPwV=KgPf!WZS)iCKdQSW<*0l!CVD#CD_SGxs<3NG-%QRrLcHu7Hff
z7wRu-uUj8eLCyL)e=RJEcYFN_XBtS9Od$3)x@3w08u%}V`ES3krd-|?o=Sr55(iv9
zrqjd4^7!V%zS}b}_h=dCY8XVomd)5-Th*xeRtHl%IIwV*T%CrFDeA3GQjyNoc)}V=
zd1qm|jWY(m3`9W8xzco{2tciV+3Ai~So!3;BU;keB5k-p+6L
zv?PQ?KFWeiI7IfapfP#e_$<+lOy0YR>(3<0C6?9psysS2njAEG1%Ik$7)T$aHkJ0S
zaI5#2tm|-adOeJo1>8uNyW%86P5oVXRKL=pqpYH~{2;&VFK`&XB9PivQdj#bhHm2`W+g_aTH_!}lxW+8Lyo0HBjXA#pe+F`tam}3Y
za4Z1aiPny)M3b1dqcw&nI-INvoNNU+W;hMpzdO-LP?27t==pQ?R%t*?Lc7d`L
zUW;!fWm{ar2Xrz`D`H%YD|Qr{;^7sp9N1zvg`$Z?84a%L!1{WVDg<#}Y&+kquBj67i
z)>_x`dDzNE5mdjX7r`>ubO}z@vg_&wm|{LllXs-dF$1UTtW?L+&L+g{BO=xHh8erh
z9*wA2@nRlfdz~^FREJFab}G7(ji|=W;c655=4#!Vdb?NyJP3rEW>r~9e@M86*HW}~
zW6|M@sDoap#YkS1{03$|AX_LVkezh2c?9v(^{7zcwFYluMwg54(QZ#7K(u>r2N=;O
zz0MIK@+n-}xHoEg4jH53#fAne$q|JI?`T9mr54#k+=8fuAEjO_MTdyijr257oI%F9
z-Ejwm_yZ!y&vO-2Z{$o>C#FbnCgZqX6%;g>M_w_(WeKE+F8aTH28KxW*>ZJ8KDmcW
zc*nUJZOagXGX|}Er3GTMAepY)Qhm@>>n2b0>1U7WVvm~Oo{!7-)2sDx)o^zIQ+`La
z9E0qJzDSW%bu(*UzgEGNV`W*#jxfMEVS4|@&9R)`*V47Y;CJ~sq&S@Z_^PPqzoyKa
zlmkpC>)(}&v?}Fuw5qht5lknGG;#H3O-&fF_RS_OAqE$xw*9;x@8$ukm(%6iT!@Da
zm6Wsh3v=M#YPbeHUJ0Vi5a?n+*
z|LYK^u{Ll-m?|>!n_P*rDXNi>f85Y-+7m1LD9ZYXzmyh|vua6N2HDG=OMP~~qX|Bu
z$+2J9{Y(h<>{d~jBT&V)i46Wem8A2@2M`{4BzUK9s@b7=
zS$-cqs`#paXAZ~G#L&_9^Qf*Nt5U){PtSIBoDPBst7k)>=1{7ok({-nCU;l!P%MM@
zz9xWFse&aH31RMgUGL;6i%FUTR6P-Pyh40bNiv(W^H|fL~vP!X&r-XY}ZbM`C26@axy0Kktl-y
z+F@(v4i*4kN8`|oyIkiX#_Q!j(?O_2V5jOW=+W2-SgUroeIp2;r_`AZf=G;VsIB$K2CA#M-%Hu2V@*o
zjdIC#TX-pSTMVthXYOaCgBDvEpEaTcOvuQq$|vn6yV`)RBnXUD
zZm?3hhF{JB!sx)N;efz2?%p4zA15Xs>p`e}`hTd0GGr8dqLYvUg>Yji%-$_p;(7+f
zKkbtqe{29g*1+k3&-r0N??LYqu8$XF9hN(j$BkxNn_U|6iQ#V=ce-im1C4-ZTLI52
z@iwp9G{&^Ud;ymb@XnA;|Hhj4K>bhpev89AqzR=;aDO;G`oZ3q=4$JQcxzxa!$tko
zlLbNl#^=X=?it}q6&{j@ixm=bzq4r9r){w7)pp@kbYhUk_{&KDpVlp8Rk7zdXB59r
zdGohpx2lnvVL81!CI*U*HWKq^Q+og2Wi
z-s87Cj`HyU5co8mBS>wKhq1gFu$)Hx%y4v7IKZ@!j4(EJ^*bLjk%e@Q-s-Z2fmq_1
z|JiS8-2Zw;<3TfjEAmC<(dWF7;#ZdKdgOw+o))->iFS<#S%HIAHSL$^Ey;jzYi8
zyFdxSdngq>r;^%vGx@mqhP#r7l>CE2nbY({k;>G)6VXUB)frEsw+
zWJ>;)#ENF7qCs$WP&t147`0`>ErzZr``*FEL-wd%Ra~VhJN&38s;UW85Qapz!x7El
zFN3v#3$V!=ZF`H^M0Uyx>b0;_!_
zi3I$vkZ|ckF+Q^|w@`F;MSqj@SJq!4OiUQ4L%(3|@Ml
zOo=PdTD5c!wO`0lNuln$Mg7N6(34oC$#cmEE2I|_A}&BjOB8$Gh4@%f1>l|#3E4TQ
z&hFph-hXOdHbzxd@E!REDb8zk-ayHJT{o$hh)#5x$?p+2MDi=SVq-tn5mx^gKCO*B
z%nuUvKkV2-&reRCc8m1KZIyc!y*z#p5=}N-f(r>gkLQPf4VXFA>39W?1m2DGhd4L+
zozGVq7^GW$>9_s}ev_R!bumI=SEvu#jh_IQ1@3r&+gOg4YkLSbgFXT#ZV>~w5oOlm
zuOK#JB;8dmh@#J9mnj8ic7CFe0^Eyienw3(qmBn>fp2%^ENFqm=Sc&R)o#XZu9Pf_
zL_y$ig8ut#?|uo!Bkv1^>yM)m);AYOb`aiX;HlGTRU`&E`b@yh0=PegphRFYoS^58
zbr*mibfo@@X)_~CnMYQq(7VEYI0(BDCnQ#;(C_YZJH_8`^D&PZu502e(?p6amq?oO
z-3T4<@mDbWcxw)B|0szX{E)i_=FayO?R?yP-C3Yj@zbQP8+u5~%992PT!D9cKb|lH
z=TP1NZ)HIukQDa!hB0>0AG-jUKdeB{g2+?CGpA!?<^%!FNgk-I<~(TZZ>`%u4oQ>7
zmY`fY>)lG?uceryP~ue@cMYj^4Ox
zCx9X3a7)kD+;CFNF4ZMs=2`V=UQ{t-XDjhB&R|^SA5OregZ{E(K9D^~&q^uG<@pk5rvI~XZa(#y=
zhp$SH{jgtlA*RoPdIo_7yWMGyvSHs#Ik$HlC(=L$F;_(A^&ivuCMYIvCxEbBdR0ZY
z&Yvqg_8Ep5n7Xybyr*;f%;^JNuaBMeoU`SWRAS4#6kmJ|=?{f{KH*z|O|V6XB|%?x
zV2A8|UQY~57mfWcMR<+Rp>YFPrp~r%wW0Kv2^Pog;QXtuAJ$ypx7Cc&)6)CNs>xl?
z)^>J6SPTCFa<48rbb(-ND04d-eA+J|L|}hu#mnQyvQ)(M^sqV`^)=99-BR;@1TumS
z+<6@GrEv%GbdCr$?OOp^3PQa1