Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
738b3592cd | ||
|
|
cd2af64860 | ||
|
|
04f1e767b2 | ||
|
|
1c7ac2a6ab | ||
|
|
77cf40da87 |
No files matched your search
+2
-1
@@ -4,9 +4,9 @@
|
||||
|
||||
### Changes
|
||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
||||
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
|
||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||
- Messages: add responsePrefix template variables (model/provider/identity/think), including routed replies. (#928) — thanks @sebslight.
|
||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
|
||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
|
||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||
@@ -21,6 +21,7 @@
|
||||
### Fixes
|
||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
|
||||
@@ -1209,31 +1209,6 @@ streaming, final replies) across channels unless already present.
|
||||
If `messages.responsePrefix` is unset, no prefix is applied by default.
|
||||
Set it to `"auto"` to derive `[{identity.name}]` for the routed agent (when set).
|
||||
|
||||
#### Template variables
|
||||
|
||||
The `responsePrefix` string can include template variables that resolve dynamically:
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `{model}` | Short model name | `claude-opus-4-5`, `gpt-4o` |
|
||||
| `{modelFull}` | Full model identifier | `anthropic/claude-opus-4-5` |
|
||||
| `{provider}` | Provider name | `anthropic`, `openai` |
|
||||
| `{thinkingLevel}` | Current thinking level | `high`, `low`, `off` |
|
||||
| `{identity.name}` | Agent identity name | (same as `"auto"` mode) |
|
||||
|
||||
Variables are case-insensitive (`{MODEL}` = `{model}`). `{think}` is an alias for `{thinkingLevel}`.
|
||||
Unresolved variables remain as literal text.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
responsePrefix: "[{model} | think:{thinkingLevel}]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example output: `[claude-opus-4-5 | think:high] Here's my response...`
|
||||
|
||||
WhatsApp inbound prefix is configured via `channels.whatsapp.messagePrefix` (deprecated:
|
||||
`messages.messagePrefix`). Default stays **unchanged**: `"[clawdbot]"` when
|
||||
`channels.whatsapp.allowFrom` is empty, otherwise `""` (no prefix). When using
|
||||
|
||||
@@ -29,6 +29,7 @@ Label:
|
||||
|
||||
Plist location (per‑user):
|
||||
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
|
||||
(or `~/Library/LaunchAgents/com.clawdbot.<profile>.plist`)
|
||||
|
||||
Manager:
|
||||
- The macOS app owns LaunchAgent install/update in Local mode.
|
||||
|
||||
@@ -26,11 +26,6 @@ export function resolveIdentityNamePrefix(
|
||||
return `[${name}]`;
|
||||
}
|
||||
|
||||
/** Returns just the identity name (without brackets) for template context. */
|
||||
export function resolveIdentityName(cfg: ClawdbotConfig, agentId: string): string | undefined {
|
||||
return resolveAgentIdentity(cfg, agentId)?.name?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function resolveMessagePrefix(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
|
||||
@@ -52,8 +52,9 @@ export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMe
|
||||
|
||||
// Gemini rejects thinking blocks that lack a signature; downgrade to text for safety.
|
||||
let hasDowngraded = false;
|
||||
const nextContent = assistantMsg.content.flatMap((block) => {
|
||||
if (!block || typeof block !== "object") return [block];
|
||||
type AssistantContentBlock = (typeof assistantMsg.content)[number];
|
||||
const nextContent = assistantMsg.content.flatMap((block): AssistantContentBlock[] => {
|
||||
if (!block || typeof block !== "object") return [block as AssistantContentBlock];
|
||||
const record = block as GeminiThinkingBlock;
|
||||
if (record.type !== "thinking") return [block];
|
||||
const signature =
|
||||
|
||||
@@ -170,4 +170,36 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
};
|
||||
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
|
||||
});
|
||||
|
||||
it("sanitizes tool call ids for OpenAI-compatible APIs", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const longId = `call_${"a".repeat(60)}`;
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: longId, name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longId,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "openai-responses",
|
||||
sessionManager,
|
||||
sessionId: "session:openai",
|
||||
});
|
||||
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const toolCall = assistant.content?.[0] as { id?: string };
|
||||
expect(toolCall.id).toBeDefined();
|
||||
expect(toolCall.id?.length).toBeLessThanOrEqual(40);
|
||||
|
||||
const toolResult = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(toolResult.toolCallId).toBe(toolCall.id);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,17 @@ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
const OPENAI_TOOL_CALL_ID_APIS = new Set([
|
||||
"openai",
|
||||
"openai-completions",
|
||||
"openai-responses",
|
||||
"openai-codex-responses",
|
||||
]);
|
||||
|
||||
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
||||
if (!modelApi) return false;
|
||||
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
|
||||
}
|
||||
|
||||
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
||||
if (!schema || typeof schema !== "object") return [];
|
||||
@@ -145,7 +156,7 @@ export async function sanitizeSessionHistory(params: {
|
||||
sessionId: string;
|
||||
}): Promise<AgentMessage[]> {
|
||||
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||
sanitizeToolCallIds: isGoogleModelApi(params.modelApi),
|
||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||
});
|
||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||
|
||||
@@ -65,4 +65,48 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
|
||||
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
|
||||
const longA = `call_${"a".repeat(60)}`;
|
||||
const longB = `call_${"a".repeat(59)}b`;
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: longA, name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: longB, name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longA,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "one" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longB,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const a = assistant.content?.[0] as { id?: string };
|
||||
const b = assistant.content?.[1] as { id?: string };
|
||||
|
||||
expect(typeof a.id).toBe("string");
|
||||
expect(typeof b.id).toBe("string");
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(a.id?.length).toBeLessThanOrEqual(40);
|
||||
expect(b.id?.length).toBeLessThanOrEqual(40);
|
||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
||||
|
||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
});
|
||||
@@ -24,21 +24,25 @@ function shortHash(text: string): string {
|
||||
}
|
||||
|
||||
function makeUniqueToolId(params: { id: string; used: Set<string> }): string {
|
||||
const base = sanitizeToolCallId(params.id);
|
||||
const MAX_LEN = 40;
|
||||
|
||||
const base = sanitizeToolCallId(params.id).slice(0, MAX_LEN);
|
||||
if (!params.used.has(base)) return base;
|
||||
|
||||
const hash = shortHash(params.id);
|
||||
const maxBaseLen = 64 - 1 - hash.length;
|
||||
const maxBaseLen = MAX_LEN - 1 - hash.length;
|
||||
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
|
||||
const candidate = `${clippedBase}_${hash}`;
|
||||
if (!params.used.has(candidate)) return candidate;
|
||||
|
||||
for (let i = 2; i < 1000; i += 1) {
|
||||
const next = `${candidate}_${i}`;
|
||||
const suffix = `_${i}`;
|
||||
const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`;
|
||||
if (!params.used.has(next)) return next;
|
||||
}
|
||||
|
||||
return `${candidate}_${Date.now()}`;
|
||||
const ts = `_${Date.now()}`;
|
||||
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
|
||||
}
|
||||
|
||||
function rewriteAssistantToolCallIds(params: {
|
||||
|
||||
@@ -125,14 +125,6 @@ export async function runAgentTurnWithFallback(params: {
|
||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||
),
|
||||
run: (provider, model) => {
|
||||
// Notify that model selection is complete (including after fallback).
|
||||
// This allows responsePrefix template interpolation with the actual model.
|
||||
params.opts?.onModelSelected?.({
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: params.followupRun.run.thinkLevel,
|
||||
});
|
||||
|
||||
if (isCliProvider(provider, params.followupRun.run.config)) {
|
||||
const startedAt = Date.now();
|
||||
emitAgentEvent({
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveIdentityName } from "../../agents/identity.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { getReplyFromConfig } from "../reply.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions, ModelSelectedContext, ReplyPayload } from "../types.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { tryFastAbortFromMessage } from "./abort.js";
|
||||
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||
import {
|
||||
applyModelSelectionToResponsePrefixContext,
|
||||
createResponsePrefixContext,
|
||||
} from "./response-prefix-template.js";
|
||||
|
||||
export type DispatchFromConfigResult = {
|
||||
queuedFinal: boolean;
|
||||
@@ -45,23 +39,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
const shouldRouteToOriginating =
|
||||
isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface;
|
||||
|
||||
const sessionAgentId = resolveSessionAgentId({
|
||||
sessionKey: ctx.SessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
const responsePrefixContext = shouldRouteToOriginating
|
||||
? createResponsePrefixContext(resolveIdentityName(cfg, sessionAgentId))
|
||||
: undefined;
|
||||
const onModelSelected =
|
||||
responsePrefixContext || params.replyOptions?.onModelSelected
|
||||
? (selection: ModelSelectedContext) => {
|
||||
if (responsePrefixContext) {
|
||||
applyModelSelectionToResponsePrefixContext(responsePrefixContext, selection);
|
||||
}
|
||||
params.replyOptions?.onModelSelected?.(selection);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Helper to send a payload via route-reply (async).
|
||||
* Only used when actually routing to a different provider.
|
||||
@@ -84,7 +61,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
accountId: ctx.AccountId,
|
||||
threadId: ctx.MessageThreadId,
|
||||
cfg,
|
||||
responsePrefixContext,
|
||||
abortSignal,
|
||||
});
|
||||
if (!result.ok) {
|
||||
@@ -106,7 +82,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
accountId: ctx.AccountId,
|
||||
threadId: ctx.MessageThreadId,
|
||||
cfg,
|
||||
responsePrefixContext,
|
||||
});
|
||||
queuedFinal = result.ok;
|
||||
if (result.ok) routedFinalCount += 1;
|
||||
@@ -128,7 +103,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
ctx,
|
||||
{
|
||||
...params.replyOptions,
|
||||
onModelSelected,
|
||||
onToolResult: (payload: ReplyPayload) => {
|
||||
if (shouldRouteToOriginating) {
|
||||
// Fire-and-forget for streaming tool results when routing.
|
||||
@@ -166,7 +140,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
accountId: ctx.AccountId,
|
||||
threadId: ctx.MessageThreadId,
|
||||
cfg,
|
||||
responsePrefixContext,
|
||||
});
|
||||
if (!result.ok) {
|
||||
logVerbose(
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
resolveResponsePrefixTemplate,
|
||||
type ResponsePrefixContext,
|
||||
} from "./response-prefix-template.js";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
responsePrefix?: string;
|
||||
/** Context for template variable interpolation in responsePrefix */
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
onHeartbeatStrip?: () => void;
|
||||
stripHeartbeat?: boolean;
|
||||
silentToken?: string;
|
||||
@@ -42,18 +36,13 @@ export function normalizeReplyPayload(
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
// Resolve template variables in responsePrefix if context is provided
|
||||
const effectivePrefix = opts.responsePrefixContext
|
||||
? resolveResponsePrefixTemplate(opts.responsePrefix, opts.responsePrefixContext)
|
||||
: opts.responsePrefix;
|
||||
|
||||
if (
|
||||
effectivePrefix &&
|
||||
opts.responsePrefix &&
|
||||
text &&
|
||||
text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!text.startsWith(effectivePrefix)
|
||||
!text.startsWith(opts.responsePrefix)
|
||||
) {
|
||||
text = `${effectivePrefix} ${text}`;
|
||||
text = `${opts.responsePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...payload, text };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { HumanDelayConfig } from "../../config/types.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
import type { ResponsePrefixContext } from "./response-prefix-template.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export type ReplyDispatchKind = "tool" | "block" | "final";
|
||||
@@ -34,11 +33,6 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export type ReplyDispatcherOptions = {
|
||||
deliver: ReplyDispatchDeliverer;
|
||||
responsePrefix?: string;
|
||||
/** Static context for response prefix template interpolation. */
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
/** Dynamic context provider for response prefix template interpolation.
|
||||
* Called at normalization time, after model selection is complete. */
|
||||
responsePrefixContextProvider?: () => ResponsePrefixContext;
|
||||
onHeartbeatStrip?: () => void;
|
||||
onIdle?: () => void;
|
||||
onError?: ReplyDispatchErrorHandler;
|
||||
@@ -67,17 +61,10 @@ export type ReplyDispatcher = {
|
||||
|
||||
function normalizeReplyPayloadInternal(
|
||||
payload: ReplyPayload,
|
||||
opts: Pick<
|
||||
ReplyDispatcherOptions,
|
||||
"responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip"
|
||||
>,
|
||||
opts: Pick<ReplyDispatcherOptions, "responsePrefix" | "onHeartbeatStrip">,
|
||||
): ReplyPayload | null {
|
||||
// Prefer dynamic context provider over static context
|
||||
const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext;
|
||||
|
||||
return normalizeReplyPayload(payload, {
|
||||
responsePrefix: opts.responsePrefix,
|
||||
responsePrefixContext: prefixContext,
|
||||
onHeartbeatStrip: opts.onHeartbeatStrip,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractShortModelName,
|
||||
hasTemplateVariables,
|
||||
resolveResponsePrefixTemplate,
|
||||
} from "./response-prefix-template.js";
|
||||
|
||||
describe("resolveResponsePrefixTemplate", () => {
|
||||
it("returns undefined for undefined template", () => {
|
||||
expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns template as-is when no variables present", () => {
|
||||
expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]");
|
||||
});
|
||||
|
||||
it("resolves {model} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model}]", {
|
||||
model: "gpt-5.2",
|
||||
});
|
||||
expect(result).toBe("[gpt-5.2]");
|
||||
});
|
||||
|
||||
it("resolves {modelFull} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{modelFull}]", {
|
||||
modelFull: "openai-codex/gpt-5.2",
|
||||
});
|
||||
expect(result).toBe("[openai-codex/gpt-5.2]");
|
||||
});
|
||||
|
||||
it("resolves {provider} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{provider}]", {
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(result).toBe("[anthropic]");
|
||||
});
|
||||
|
||||
it("resolves {thinkingLevel} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", {
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
expect(result).toBe("think:high");
|
||||
});
|
||||
|
||||
it("resolves {think} as alias for thinkingLevel", () => {
|
||||
const result = resolveResponsePrefixTemplate("think:{think}", {
|
||||
thinkingLevel: "low",
|
||||
});
|
||||
expect(result).toBe("think:low");
|
||||
});
|
||||
|
||||
it("resolves {identity.name} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{identity.name}]", {
|
||||
identityName: "Clawdbot",
|
||||
});
|
||||
expect(result).toBe("[Clawdbot]");
|
||||
});
|
||||
|
||||
it("resolves {identityName} as alias", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{identityName}]", {
|
||||
identityName: "Clawdbot",
|
||||
});
|
||||
expect(result).toBe("[Clawdbot]");
|
||||
});
|
||||
|
||||
it("resolves multiple variables", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", {
|
||||
model: "claude-opus-4-5",
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
expect(result).toBe("[claude-opus-4-5 | think:high]");
|
||||
});
|
||||
|
||||
it("leaves unresolved variables as-is", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model}]", {});
|
||||
expect(result).toBe("[{model}]");
|
||||
});
|
||||
|
||||
it("leaves unrecognized variables as-is", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{unknownVar}]", {
|
||||
model: "gpt-5.2",
|
||||
});
|
||||
expect(result).toBe("[{unknownVar}]");
|
||||
});
|
||||
|
||||
it("handles case insensitivity", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", {
|
||||
model: "gpt-5.2",
|
||||
thinkingLevel: "low",
|
||||
});
|
||||
expect(result).toBe("[gpt-5.2 | low]");
|
||||
});
|
||||
|
||||
it("handles mixed resolved and unresolved variables", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model} | {provider}]", {
|
||||
model: "gpt-5.2",
|
||||
// provider not provided
|
||||
});
|
||||
expect(result).toBe("[gpt-5.2 | {provider}]");
|
||||
});
|
||||
|
||||
it("handles complex template with all variables", () => {
|
||||
const result = resolveResponsePrefixTemplate(
|
||||
"[{identity.name}] {provider}/{model} (think:{thinkingLevel})",
|
||||
{
|
||||
identityName: "Clawdbot",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
);
|
||||
expect(result).toBe("[Clawdbot] anthropic/claude-opus-4-5 (think:high)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractShortModelName", () => {
|
||||
it("strips provider prefix", () => {
|
||||
expect(extractShortModelName("openai/gpt-5.2")).toBe("gpt-5.2");
|
||||
expect(extractShortModelName("anthropic/claude-opus-4-5")).toBe("claude-opus-4-5");
|
||||
expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex");
|
||||
});
|
||||
|
||||
it("strips date suffix", () => {
|
||||
expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5");
|
||||
expect(extractShortModelName("gpt-5.2-20250115")).toBe("gpt-5.2");
|
||||
});
|
||||
|
||||
it("strips -latest suffix", () => {
|
||||
expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2");
|
||||
expect(extractShortModelName("claude-sonnet-latest")).toBe("claude-sonnet");
|
||||
});
|
||||
|
||||
it("handles model without provider", () => {
|
||||
expect(extractShortModelName("gpt-5.2")).toBe("gpt-5.2");
|
||||
expect(extractShortModelName("claude-opus-4-5")).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("handles full path with provider and date suffix", () => {
|
||||
expect(extractShortModelName("anthropic/claude-opus-4-5-20251101")).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("preserves version numbers that look like dates but are not", () => {
|
||||
// Date suffix must be exactly 8 digits at the end
|
||||
expect(extractShortModelName("model-v1234567")).toBe("model-v1234567");
|
||||
expect(extractShortModelName("model-123456789")).toBe("model-123456789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasTemplateVariables", () => {
|
||||
it("returns false for undefined", () => {
|
||||
expect(hasTemplateVariables(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(hasTemplateVariables("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for static prefix", () => {
|
||||
expect(hasTemplateVariables("[Claude]")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when template variables present", () => {
|
||||
expect(hasTemplateVariables("[{model}]")).toBe(true);
|
||||
expect(hasTemplateVariables("{provider}")).toBe(true);
|
||||
expect(hasTemplateVariables("prefix {thinkingLevel} suffix")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for multiple variables", () => {
|
||||
expect(hasTemplateVariables("[{model} | {provider}]")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles consecutive calls correctly (regex lastIndex reset)", () => {
|
||||
// First call
|
||||
expect(hasTemplateVariables("[{model}]")).toBe(true);
|
||||
// Second call should still work
|
||||
expect(hasTemplateVariables("[{model}]")).toBe(true);
|
||||
// Static string should return false
|
||||
expect(hasTemplateVariables("[Claude]")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Template interpolation for response prefix.
|
||||
*
|
||||
* Supports variables like `{model}`, `{provider}`, `{thinkingLevel}`, etc.
|
||||
* Variables are case-insensitive and unresolved ones remain as literal text.
|
||||
*/
|
||||
|
||||
export type ResponsePrefixContext = {
|
||||
/** Short model name (e.g., "gpt-5.2", "claude-opus-4-5") */
|
||||
model?: string;
|
||||
/** Full model ID including provider (e.g., "openai-codex/gpt-5.2") */
|
||||
modelFull?: string;
|
||||
/** Provider name (e.g., "openai-codex", "anthropic") */
|
||||
provider?: string;
|
||||
/** Current thinking level (e.g., "high", "low", "off") */
|
||||
thinkingLevel?: string;
|
||||
/** Agent identity name */
|
||||
identityName?: string;
|
||||
};
|
||||
|
||||
export type ModelSelectionInfo = {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkLevel?: string;
|
||||
};
|
||||
|
||||
export function createResponsePrefixContext(identityName?: string): ResponsePrefixContext {
|
||||
return identityName ? { identityName } : {};
|
||||
}
|
||||
|
||||
// Regex pattern for template variables: {variableName} or {variable.name}
|
||||
const TEMPLATE_VAR_PATTERN = /\{([a-zA-Z][a-zA-Z0-9.]*)\}/g;
|
||||
|
||||
/**
|
||||
* Interpolate template variables in a response prefix string.
|
||||
*
|
||||
* @param template - The template string with `{variable}` placeholders
|
||||
* @param context - Context object with values for interpolation
|
||||
* @returns The interpolated string, or undefined if template is undefined
|
||||
*
|
||||
* @example
|
||||
* resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", {
|
||||
* model: "gpt-5.2",
|
||||
* thinkingLevel: "high"
|
||||
* })
|
||||
* // Returns: "[gpt-5.2 | think:high]"
|
||||
*/
|
||||
export function resolveResponsePrefixTemplate(
|
||||
template: string | undefined,
|
||||
context: ResponsePrefixContext,
|
||||
): string | undefined {
|
||||
if (!template) return undefined;
|
||||
|
||||
return template.replace(TEMPLATE_VAR_PATTERN, (match, varName: string) => {
|
||||
const normalizedVar = varName.toLowerCase();
|
||||
|
||||
switch (normalizedVar) {
|
||||
case "model":
|
||||
return context.model ?? match;
|
||||
case "modelfull":
|
||||
return context.modelFull ?? match;
|
||||
case "provider":
|
||||
return context.provider ?? match;
|
||||
case "thinkinglevel":
|
||||
case "think":
|
||||
return context.thinkingLevel ?? match;
|
||||
case "identity.name":
|
||||
case "identityname":
|
||||
return context.identityName ?? match;
|
||||
default:
|
||||
// Leave unrecognized variables as-is
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract short model name from a full model string.
|
||||
*
|
||||
* Strips:
|
||||
* - Provider prefix (e.g., "openai/" from "openai/gpt-5.2")
|
||||
* - Date suffixes (e.g., "-20251101" from "claude-opus-4-5-20251101")
|
||||
* - Common version suffixes (e.g., "-latest")
|
||||
*
|
||||
* @example
|
||||
* extractShortModelName("openai-codex/gpt-5.2") // "gpt-5.2"
|
||||
* extractShortModelName("claude-opus-4-5-20251101") // "claude-opus-4-5"
|
||||
* extractShortModelName("gpt-5.2-latest") // "gpt-5.2"
|
||||
*/
|
||||
export function extractShortModelName(fullModel: string): string {
|
||||
// Strip provider prefix
|
||||
const slash = fullModel.lastIndexOf("/");
|
||||
const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
|
||||
|
||||
// Strip date suffixes (YYYYMMDD format)
|
||||
return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
|
||||
}
|
||||
|
||||
export function applyModelSelectionToResponsePrefixContext(
|
||||
context: ResponsePrefixContext,
|
||||
selection: ModelSelectionInfo,
|
||||
): void {
|
||||
context.provider = selection.provider;
|
||||
context.model = extractShortModelName(selection.model);
|
||||
context.modelFull = `${selection.provider}/${selection.model}`;
|
||||
context.thinkingLevel = selection.thinkLevel ?? "off";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template string contains any template variables.
|
||||
*/
|
||||
export function hasTemplateVariables(template: string | undefined): boolean {
|
||||
if (!template) return false;
|
||||
// Reset lastIndex since we're using a global regex
|
||||
TEMPLATE_VAR_PATTERN.lastIndex = 0;
|
||||
return TEMPLATE_VAR_PATTERN.test(template);
|
||||
}
|
||||
@@ -111,28 +111,6 @@ describe("routeReply", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves responsePrefix template variables when context is provided", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
messages: { responsePrefix: "[{model} | {identity.name}]" },
|
||||
} as unknown as ClawdbotConfig;
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg,
|
||||
responsePrefixContext: {
|
||||
model: "gpt-5.2",
|
||||
identityName: "Clawdbot",
|
||||
},
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"[gpt-5.2 | Clawdbot] hi",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not derive responsePrefix from agent identity when routing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
import type { ResponsePrefixContext } from "./response-prefix-template.js";
|
||||
|
||||
export type RouteReplyParams = {
|
||||
/** The reply payload to send. */
|
||||
@@ -32,8 +31,6 @@ export type RouteReplyParams = {
|
||||
threadId?: number;
|
||||
/** Config for provider-specific settings. */
|
||||
cfg: ClawdbotConfig;
|
||||
/** Optional response prefix template context (model/provider/identity). */
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
/** Optional abort signal for cooperative cancellation. */
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
@@ -72,7 +69,6 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
: cfg.messages?.responsePrefix;
|
||||
const normalized = normalizeReplyPayload(payload, {
|
||||
responsePrefix,
|
||||
responsePrefixContext: params.responsePrefixContext,
|
||||
});
|
||||
if (!normalized) return { ok: true };
|
||||
|
||||
|
||||
@@ -5,13 +5,6 @@ export type BlockReplyContext = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
/** Context passed to onModelSelected callback with actual model used. */
|
||||
export type ModelSelectedContext = {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkLevel: string | undefined;
|
||||
};
|
||||
|
||||
export type GetReplyOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
onTypingController?: (typing: TypingController) => void;
|
||||
@@ -20,9 +13,6 @@ export type GetReplyOptions = {
|
||||
onReasoningStream?: (payload: ReplyPayload) => Promise<void> | void;
|
||||
onBlockReply?: (payload: ReplyPayload, context?: BlockReplyContext) => Promise<void> | void;
|
||||
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
|
||||
/** Called when the actual model is selected (including after fallback).
|
||||
* Use this to get model/provider/thinkLevel for responsePrefix template interpolation. */
|
||||
onModelSelected?: (ctx: ModelSelectedContext) => void;
|
||||
disableBlockStreaming?: boolean;
|
||||
/** Timeout for block reply delivery (ms). */
|
||||
blockReplyTimeoutMs?: number;
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
loaded = await service.isLoaded({ profile });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function runDaemonStart() {
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
loaded = await service.isLoaded({ profile });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
@@ -38,11 +38,7 @@ export async function runDaemonStart() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
await service.restart({ profile, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
|
||||
for (const hint of renderGatewayServiceStartHints()) {
|
||||
@@ -57,7 +53,7 @@ export async function runDaemonStop() {
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
loaded = await service.isLoaded({ profile });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
@@ -68,7 +64,7 @@ export async function runDaemonStop() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await service.stop({ env: process.env, profile, stdout: process.stdout });
|
||||
await service.stop({ profile, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
@@ -85,7 +81,7 @@ export async function runDaemonRestart(): Promise<boolean> {
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
loaded = await service.isLoaded({ profile });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
@@ -99,11 +95,7 @@ export async function runDaemonRestart(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
await service.restart({ profile, stdout: process.stdout });
|
||||
return true;
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
|
||||
|
||||
@@ -112,12 +112,7 @@ export async function gatherDaemonStatus(
|
||||
): Promise<DaemonStatus> {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, command, runtime] = await Promise.all([
|
||||
service
|
||||
.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
})
|
||||
.catch(() => false),
|
||||
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false),
|
||||
service.readCommand(process.env).catch(() => null),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
]);
|
||||
|
||||
@@ -89,10 +89,7 @@ export async function maybeExplainGatewayServiceStop() {
|
||||
const service = resolveGatewayService();
|
||||
let loaded: boolean | null = null;
|
||||
try {
|
||||
loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
|
||||
} catch {
|
||||
loaded = null;
|
||||
}
|
||||
|
||||
@@ -26,10 +26,7 @@ export async function maybeInstallDaemon(params: {
|
||||
daemonRuntime?: GatewayDaemonRuntime;
|
||||
}) {
|
||||
const service = resolveGatewayService();
|
||||
const loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
|
||||
let shouldCheckLinger = false;
|
||||
let shouldInstall = true;
|
||||
let daemonRuntime = params.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
||||
@@ -47,7 +44,6 @@ export async function maybeInstallDaemon(params: {
|
||||
);
|
||||
if (action === "restart") {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
|
||||
@@ -37,10 +37,7 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
if (params.healthOk) return;
|
||||
|
||||
const service = resolveGatewayService();
|
||||
const loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
|
||||
let serviceRuntime: Awaited<ReturnType<typeof service.readRuntime>> | undefined;
|
||||
if (loaded) {
|
||||
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
|
||||
@@ -132,7 +129,6 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
});
|
||||
if (start) {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
@@ -155,7 +151,6 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
});
|
||||
if (restart) {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
|
||||
@@ -89,10 +89,7 @@ export async function maybeMigrateLegacyGatewayService(
|
||||
}
|
||||
|
||||
const service = resolveGatewayService();
|
||||
const loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
|
||||
if (loaded) {
|
||||
note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway");
|
||||
return;
|
||||
|
||||
@@ -212,10 +212,7 @@ export async function doctorCommand(
|
||||
const service = resolveGatewayService();
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
|
||||
} catch {
|
||||
loaded = false;
|
||||
}
|
||||
|
||||
@@ -41,14 +41,14 @@ async function stopGatewayIfRunning(runtime: RuntimeEnv) {
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
loaded = await service.isLoaded({ profile });
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway service check failed: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (!loaded) return;
|
||||
try {
|
||||
await service.stop({ env: process.env, profile, stdout: process.stdout });
|
||||
await service.stop({ profile, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway stop failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
@@ -133,12 +133,7 @@ export async function statusAllCommand(
|
||||
try {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, runtimeInfo, command] = await Promise.all([
|
||||
service
|
||||
.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
})
|
||||
.catch(() => false),
|
||||
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
service.readCommand(process.env).catch(() => null),
|
||||
]);
|
||||
|
||||
@@ -10,12 +10,7 @@ export async function getDaemonStatusSummary(): Promise<{
|
||||
try {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, runtime, command] = await Promise.all([
|
||||
service
|
||||
.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
})
|
||||
.catch(() => false),
|
||||
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
service.readCommand(process.env).catch(() => null),
|
||||
]);
|
||||
|
||||
@@ -58,7 +58,7 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env, profile });
|
||||
loaded = await service.isLoaded({ profile });
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway service check failed: ${String(err)}`);
|
||||
return false;
|
||||
@@ -68,7 +68,7 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await service.stop({ env: process.env, profile, stdout: process.stdout });
|
||||
await service.stop({ profile, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway stop failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
@@ -44,21 +44,8 @@ export type MessagesConfig = {
|
||||
messagePrefix?: string;
|
||||
/**
|
||||
* Prefix auto-added to all outbound replies.
|
||||
*
|
||||
* - string: explicit prefix (may include template variables)
|
||||
* - string: explicit prefix
|
||||
* - special value: `"auto"` derives `[{agents.list[].identity.name}]` for the routed agent (when set)
|
||||
*
|
||||
* Supported template variables (case-insensitive):
|
||||
* - `{model}` - short model name (e.g., `claude-opus-4-5`, `gpt-4o`)
|
||||
* - `{modelFull}` - full model identifier (e.g., `anthropic/claude-opus-4-5`)
|
||||
* - `{provider}` - provider name (e.g., `anthropic`, `openai`)
|
||||
* - `{thinkingLevel}` or `{think}` - current thinking level (`high`, `low`, `off`)
|
||||
* - `{identity.name}` or `{identityName}` - agent identity name
|
||||
*
|
||||
* Example: `"[{model} | think:{thinkingLevel}]"` → `"[claude-opus-4-5 | think:high]"`
|
||||
*
|
||||
* Unresolved variables remain as literal text (e.g., `{model}` if context unavailable).
|
||||
*
|
||||
* Default: none
|
||||
*/
|
||||
responsePrefix?: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Default service labels (for backward compatibility and when no profile specified)
|
||||
export const GATEWAY_LAUNCH_AGENT_LABEL = "com.clawdbot.gateway";
|
||||
export const GATEWAY_SYSTEMD_SERVICE_NAME = "clawdbot-gateway";
|
||||
export const GATEWAY_WINDOWS_TASK_NAME = "Clawdbot Gateway";
|
||||
|
||||
@@ -47,8 +47,7 @@ function resolveLaunchAgentPlistPathForLabel(
|
||||
}
|
||||
|
||||
export function resolveLaunchAgentPlistPath(env: Record<string, string | undefined>): string {
|
||||
const label =
|
||||
env.CLAWDBOT_LAUNCHD_LABEL?.trim() || resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
|
||||
const label = resolveLaunchAgentLabel({ env });
|
||||
return resolveLaunchAgentPlistPathForLabel(env, label);
|
||||
}
|
||||
|
||||
@@ -206,8 +205,7 @@ export async function readLaunchAgentRuntime(
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<GatewayServiceRuntime> {
|
||||
const domain = resolveGuiDomain();
|
||||
const label =
|
||||
env.CLAWDBOT_LAUNCHD_LABEL?.trim() || resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
|
||||
const label = resolveLaunchAgentLabel({ env });
|
||||
const res = await execLaunchctl(["print", `${domain}/${label}`]);
|
||||
if (res.code !== 0) {
|
||||
return {
|
||||
@@ -309,6 +307,7 @@ export async function uninstallLaunchAgent({
|
||||
stdout: NodeJS.WritableStream;
|
||||
}): Promise<void> {
|
||||
const domain = resolveGuiDomain();
|
||||
const label = resolveLaunchAgentLabel({ env });
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
await execLaunchctl(["bootout", domain, plistPath]);
|
||||
await execLaunchctl(["unload", plistPath]);
|
||||
@@ -322,8 +321,6 @@ export async function uninstallLaunchAgent({
|
||||
|
||||
const home = resolveHomeDir(env);
|
||||
const trashDir = path.join(home, ".Trash");
|
||||
const label =
|
||||
env.CLAWDBOT_LAUNCHD_LABEL?.trim() || resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
|
||||
const dest = path.join(trashDir, `${label}.plist`);
|
||||
try {
|
||||
await fs.mkdir(trashDir, { recursive: true });
|
||||
@@ -378,8 +375,7 @@ export async function installLaunchAgent({
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
|
||||
const domain = resolveGuiDomain();
|
||||
const label =
|
||||
env.CLAWDBOT_LAUNCHD_LABEL?.trim() || resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
|
||||
const label = resolveLaunchAgentLabel({ env });
|
||||
for (const legacyLabel of LEGACY_GATEWAY_LAUNCH_AGENT_LABELS) {
|
||||
const legacyPlistPath = resolveLaunchAgentPlistPathForLabel(env, legacyLabel);
|
||||
await execLaunchctl(["bootout", domain, legacyPlistPath]);
|
||||
|
||||
@@ -2,12 +2,7 @@ import {
|
||||
resolveAckReaction,
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
resolveIdentityName,
|
||||
} from "../../agents/identity.js";
|
||||
import {
|
||||
applyModelSelectionToResponsePrefixContext,
|
||||
createResponsePrefixContext,
|
||||
} from "../../auto-reply/reply/response-prefix-template.js";
|
||||
import { formatAgentEnvelope, formatThreadStarterEnvelope } from "../../auto-reply/envelope.js";
|
||||
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
|
||||
import { buildHistoryContextFromMap, clearHistoryEntries } from "../../auto-reply/reply/history.js";
|
||||
@@ -285,12 +280,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
const typingChannelId = deliverTarget.startsWith("channel:")
|
||||
? deliverTarget.slice("channel:".length)
|
||||
: message.channelId;
|
||||
|
||||
const prefixContext = createResponsePrefixContext(resolveIdentityName(cfg, route.agentId));
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const replyToId = replyReference.use();
|
||||
@@ -325,9 +316,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
typeof discordConfig?.blockStreaming === "boolean"
|
||||
? !discordConfig.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
applyModelSelectionToResponsePrefixContext(prefixContext, ctx);
|
||||
},
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
|
||||
@@ -69,10 +69,7 @@ const hoisted = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const testConfigRoot = {
|
||||
value: path.join(
|
||||
os.tmpdir(),
|
||||
`clawdbot-gateway-test-${process.pid}-${crypto.randomUUID()}`,
|
||||
),
|
||||
value: path.join(os.tmpdir(), `clawdbot-gateway-test-${process.pid}-${crypto.randomUUID()}`),
|
||||
};
|
||||
|
||||
export const setTestConfigRoot = (root: string) => {
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
resolveIdentityName,
|
||||
} from "../../agents/identity.js";
|
||||
import {
|
||||
applyModelSelectionToResponsePrefixContext,
|
||||
createResponsePrefixContext,
|
||||
} from "../../auto-reply/reply/response-prefix-template.js";
|
||||
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
||||
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
|
||||
@@ -349,12 +341,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
|
||||
const prefixContext = createResponsePrefixContext(resolveIdentityName(cfg, route.agentId));
|
||||
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
await deliverReplies({
|
||||
@@ -382,9 +370,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
typeof accountInfo.config.blockStreaming === "boolean"
|
||||
? !accountInfo.config.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
applyModelSelectionToResponsePrefixContext(prefixContext, ctx);
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!queuedFinal) {
|
||||
|
||||
@@ -57,6 +57,7 @@ async function waitForSocketConnect(socket: net.Socket) {
|
||||
|
||||
describe("node bridge server", () => {
|
||||
let baseDir = "";
|
||||
const pairingTimeoutMs = process.platform === "win32" ? 8000 : 3000;
|
||||
|
||||
const pickNonLoopbackIPv4 = () => {
|
||||
const ifaces = os.networkInterfaces();
|
||||
@@ -174,7 +175,7 @@ describe("node bridge server", () => {
|
||||
const list = await listNodePairing(baseDir);
|
||||
return list.pending.find((p) => p.nodeId === "n2");
|
||||
},
|
||||
{ timeoutMs: 3000 },
|
||||
{ timeoutMs: pairingTimeoutMs },
|
||||
);
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
@@ -220,7 +221,7 @@ describe("node bridge server", () => {
|
||||
await waitForSocketConnect(socket);
|
||||
sendLine(socket, { type: "pair-request", nodeId: "n3", platform: "ios" });
|
||||
|
||||
await pollUntil(async () => requested, { timeoutMs: 3000 });
|
||||
await pollUntil(async () => requested, { timeoutMs: pairingTimeoutMs });
|
||||
|
||||
expect(requested?.nodeId).toBe("n3");
|
||||
expect(typeof requested?.requestId).toBe("string");
|
||||
@@ -257,7 +258,7 @@ describe("node bridge server", () => {
|
||||
const list = await listNodePairing(baseDir);
|
||||
return list.pending.find((p) => p.nodeId === "n3-rpc");
|
||||
},
|
||||
{ timeoutMs: 3000 },
|
||||
{ timeoutMs: pairingTimeoutMs },
|
||||
);
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
@@ -353,7 +354,7 @@ describe("node bridge server", () => {
|
||||
const list = await listNodePairing(baseDir);
|
||||
return list.pending.find((p) => p.nodeId === "n4");
|
||||
},
|
||||
{ timeoutMs: 3000 },
|
||||
{ timeoutMs: pairingTimeoutMs },
|
||||
);
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
@@ -385,7 +386,7 @@ describe("node bridge server", () => {
|
||||
expect(line3.type).toBe("hello-ok");
|
||||
|
||||
await pollUntil(async () => (lastAuthed?.nodeId === "n4" ? lastAuthed : null), {
|
||||
timeoutMs: 3000,
|
||||
timeoutMs: pairingTimeoutMs,
|
||||
});
|
||||
|
||||
expect(lastAuthed?.nodeId).toBe("n4");
|
||||
|
||||
@@ -6,6 +6,16 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fixSecurityFootguns } from "./fix.js";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
const expectPerms = (actual: number, expected: number) => {
|
||||
if (isWindows) {
|
||||
expect([expected, 0o666, 0o777]).toContain(actual);
|
||||
return;
|
||||
}
|
||||
expect(actual).toBe(expected);
|
||||
};
|
||||
|
||||
describe("security fix", () => {
|
||||
it("tightens groupPolicy + filesystem perms", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-fix-"));
|
||||
@@ -63,10 +73,10 @@ describe("security fix", () => {
|
||||
);
|
||||
|
||||
const stateMode = (await fs.stat(stateDir)).mode & 0o777;
|
||||
expect(stateMode).toBe(0o700);
|
||||
expectPerms(stateMode, 0o700);
|
||||
|
||||
const configMode = (await fs.stat(configPath)).mode & 0o777;
|
||||
expect(configMode).toBe(0o600);
|
||||
expectPerms(configMode, 0o600);
|
||||
|
||||
const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
|
||||
const channels = parsed.channels as Record<string, Record<string, unknown>>;
|
||||
@@ -192,9 +202,9 @@ describe("security fix", () => {
|
||||
expect(res.ok).toBe(false);
|
||||
|
||||
const stateMode = (await fs.stat(stateDir)).mode & 0o777;
|
||||
expect(stateMode).toBe(0o700);
|
||||
expectPerms(stateMode, 0o700);
|
||||
|
||||
const configMode = (await fs.stat(configPath)).mode & 0o777;
|
||||
expect(configMode).toBe(0o600);
|
||||
expectPerms(configMode, 0o600);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
resolveIdentityName,
|
||||
} from "../../agents/identity.js";
|
||||
import {
|
||||
applyModelSelectionToResponsePrefixContext,
|
||||
createResponsePrefixContext,
|
||||
} from "../../auto-reply/reply/response-prefix-template.js";
|
||||
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
|
||||
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
|
||||
import { buildHistoryContextFromMap, clearHistoryEntries } from "../../auto-reply/reply/history.js";
|
||||
@@ -318,14 +310,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
|
||||
const prefixContext = createResponsePrefixContext(
|
||||
resolveIdentityName(deps.cfg, route.agentId),
|
||||
);
|
||||
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
await deps.deliverReplies({
|
||||
@@ -352,9 +338,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
replyOptions: {
|
||||
disableBlockStreaming:
|
||||
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
applyModelSelectionToResponsePrefixContext(prefixContext, ctx);
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!queuedFinal) {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import {
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
resolveIdentityName,
|
||||
} from "../../../agents/identity.js";
|
||||
import {
|
||||
applyModelSelectionToResponsePrefixContext,
|
||||
createResponsePrefixContext,
|
||||
} from "../../../auto-reply/reply/response-prefix-template.js";
|
||||
import { dispatchReplyFromConfig } from "../../../auto-reply/reply/dispatch-from-config.js";
|
||||
import { clearHistoryEntries } from "../../../auto-reply/reply/history.js";
|
||||
import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js";
|
||||
@@ -67,12 +62,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
};
|
||||
|
||||
let didSendReply = false;
|
||||
|
||||
const prefixContext = createResponsePrefixContext(resolveIdentityName(cfg, route.agentId));
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
const replyThreadTs = replyPlan.nextThreadTs();
|
||||
@@ -113,9 +104,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
applyModelSelectionToResponsePrefixContext(prefixContext, ctx);
|
||||
},
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
|
||||
import {
|
||||
applyModelSelectionToResponsePrefixContext,
|
||||
createResponsePrefixContext,
|
||||
} from "../auto-reply/reply/response-prefix-template.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
|
||||
import { clearHistoryEntries } from "../auto-reply/reply/history.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
@@ -118,15 +114,12 @@ export const dispatchTelegramMessage = async ({
|
||||
Boolean(draftStream) ||
|
||||
(typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined);
|
||||
|
||||
const prefixContext = createResponsePrefixContext(resolveIdentityName(cfg, route.agentId));
|
||||
|
||||
let didSendReply = false;
|
||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
deliver: async (payload, info) => {
|
||||
if (info.kind === "final") {
|
||||
await flushDraft();
|
||||
@@ -158,9 +151,6 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
: undefined,
|
||||
disableBlockStreaming,
|
||||
onModelSelected: (ctx) => {
|
||||
applyModelSelectionToResponsePrefixContext(prefixContext, ctx);
|
||||
},
|
||||
},
|
||||
});
|
||||
draftStream?.stop();
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../../agents/identity.js";
|
||||
import {
|
||||
applyModelSelectionToResponsePrefixContext,
|
||||
createResponsePrefixContext,
|
||||
} from "../../../auto-reply/reply/response-prefix-template.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../../../agents/identity.js";
|
||||
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
||||
import { formatAgentEnvelope } from "../../../auto-reply/envelope.js";
|
||||
import { buildHistoryContext } from "../../../auto-reply/reply/history.js";
|
||||
@@ -177,10 +173,6 @@ export async function processMessage(params: {
|
||||
params.route.agentId,
|
||||
).responsePrefix;
|
||||
|
||||
const prefixContext = createResponsePrefixContext(
|
||||
resolveIdentityName(params.cfg, params.route.agentId),
|
||||
);
|
||||
|
||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: {
|
||||
Body: combinedBody,
|
||||
@@ -218,7 +210,6 @@ export async function processMessage(params: {
|
||||
replyResolver: params.replyResolver,
|
||||
dispatcherOptions: {
|
||||
responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
onHeartbeatStrip: () => {
|
||||
if (!didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
@@ -276,9 +267,6 @@ export async function processMessage(params: {
|
||||
typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean"
|
||||
? !params.cfg.channels.whatsapp.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
applyModelSelectionToResponsePrefixContext(prefixContext, ctx);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -112,10 +112,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
);
|
||||
}
|
||||
const service = resolveGatewayService();
|
||||
const loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
|
||||
if (loaded) {
|
||||
const action = (await prompter.select({
|
||||
message: "Gateway service already installed",
|
||||
@@ -127,7 +124,6 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
})) as "restart" | "reinstall" | "skip";
|
||||
if (action === "restart") {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
@@ -139,10 +135,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
if (
|
||||
!loaded ||
|
||||
(loaded &&
|
||||
(await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
})) === false)
|
||||
(await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })) === false)
|
||||
) {
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts");
|
||||
|
||||
Reference in new issue
Block a user