diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 885d87fcb..3bda11de7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -278,6 +278,8 @@ jobs: checks-macos: if: github.event_name == 'pull_request' runs-on: macos-latest + env: + NODE_OPTIONS: --max-old-space-size=4096 strategy: fail-fast: false matrix: diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index f2dea61d4..6f4540c15 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -31,12 +31,22 @@ import { getDiscordRuntime } from "./runtime.js"; const meta = getChatChannelMeta("discord"); +function resolveDiscordMessageActions() { + try { + return getDiscordRuntime().channel.discord?.messageActions ?? null; + } catch { + return null; + } +} + const discordMessageActions: ChannelMessageActionAdapter = { - listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx), - extractToolSend: (ctx) => - getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx), - handleAction: async (ctx) => - await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx), + listActions: (ctx) => resolveDiscordMessageActions()?.listActions?.(ctx) ?? [], + extractToolSend: (ctx) => resolveDiscordMessageActions()?.extractToolSend?.(ctx), + handleAction: async (ctx) => { + const actions = resolveDiscordMessageActions(); + if (!actions?.handleAction) return null; + return await actions.handleAction(ctx); + }, }; export const discordPlugin: ChannelPlugin = { diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index ac006ec57..092583f16 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -1,7 +1,10 @@ import { buildChannelConfigSchema, + createReplyPrefixContext, + createTypingCallbacks, DEFAULT_ACCOUNT_ID, formatPairingApproveHint, + logTypingFailure, type ChannelPlugin, } from "clawdbot/plugin-sdk"; @@ -218,19 +221,153 @@ export const nostrPlugin: ChannelPlugin = { accountId: account.accountId, privateKey: account.privateKey, relays: account.relays, - onMessage: async (senderPubkey, text, reply) => { + onMessage: async (senderPubkey, text, reply, eventId) => { ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`); - // Forward to moltbot's message pipeline - await runtime.channel.reply.handleInboundMessage({ + const cfg = runtime.config.loadConfig(); + const route = runtime.channel.routing.resolveAgentRoute({ + cfg, channel: "nostr", accountId: account.accountId, - senderId: senderPubkey, - chatType: "direct", - chatId: senderPubkey, // For DMs, chatId is the sender's pubkey - text, - reply: async (responseText: string) => { - await reply(responseText); + peer: { kind: "dm", id: senderPubkey }, + }); + + ctx.log?.debug(`[${account.accountId}] Route resolved: sessionKey=${route.sessionKey}, agentId=${route.agentId}`); + + const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = runtime.channel.reply.formatAgentEnvelope({ + channel: "Nostr", + from: senderPubkey, + timestamp: Date.now(), + previousTimestamp, + envelope: envelopeOptions, + body: text, + }); + + // Create typing callbacks for this conversation + // Note: busHandle is checked at invocation time (not creation time) + // to handle the race condition during startup + const typingCallbacks = createTypingCallbacks({ + start: async () => { + if (!busHandle) { + ctx.log?.debug(`[${account.accountId}] Skipping typing START (bus not ready)`); + return; + } + ctx.log?.debug(`[${account.accountId}] Sending typing START to ${senderPubkey.slice(0, 8)}`); + return busHandle.sendTypingStart(senderPubkey); + }, + stop: async () => { + if (!busHandle) { + ctx.log?.debug(`[${account.accountId}] Skipping typing STOP (bus not ready)`); + return; + } + ctx.log?.debug(`[${account.accountId}] Sending typing STOP to ${senderPubkey.slice(0, 8)}`); + return busHandle.sendTypingStop(senderPubkey); + }, + onStartError: (err) => + logTypingFailure({ + log: (msg) => ctx.log?.warn(msg), + channel: "nostr", + target: senderPubkey, + action: "start", + error: err, + }), + onStopError: (err) => + logTypingFailure({ + log: (msg) => ctx.log?.warn(msg), + channel: "nostr", + target: senderPubkey, + action: "stop", + error: err, + }), + }); + + // Build the inbound message context + const ctxPayload = runtime.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: text, + CommandBody: text, + From: `nostr:${senderPubkey}`, + To: `nostr:${senderPubkey}`, + SessionKey: route.sessionKey, + AccountId: account.accountId, + ChatType: "direct", + ConversationLabel: senderPubkey, + SenderName: senderPubkey.slice(0, 8), + SenderId: senderPubkey, + Provider: "nostr" as const, + Surface: "nostr" as const, + Timestamp: Date.now(), + MessageSid: eventId, // Nostr event ID for deduplication + CommandAuthorized: true, // TODO: implement proper authorization + CommandSource: "text" as const, + OriginatingChannel: "nostr" as const, + OriginatingTo: `nostr:${senderPubkey}`, + }); + + await runtime.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + updateLastRoute: { + sessionKey: route.mainSessionKey, + channel: "nostr", + to: `nostr:${senderPubkey}`, + accountId: route.accountId, + }, + onRecordError: (err) => { + ctx.log?.warn?.(`nostr: failed updating session meta: ${String(err)}`); + }, + }); + + // Get table mode for formatting + const tableMode = runtime.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "nostr", + accountId: account.accountId, + }); + + // Create reply prefix context + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + + // Create the reply dispatcher + const { dispatcher, replyOptions, markDispatchIdle } = + runtime.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload) => { + const message = runtime.channel.text.convertMarkdownTables( + payload.text ?? "", + tableMode + ); + if (!message) return; + ctx.log?.debug(`[${account.accountId}] Delivering reply to ${senderPubkey.slice(0, 8)}: ${message.slice(0, 50)}...`); + await reply(message); + ctx.log?.info(`[${account.accountId}] Reply delivered to ${senderPubkey.slice(0, 8)}`); + }, + onError: (err, info) => { + ctx.log?.error(`[${account.accountId}] nostr ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks?.onReplyStart, + onIdle: typingCallbacks?.onIdle, + }); + + // Dispatch the reply + const { queuedFinal, counts } = await runtime.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + onModelSelected: prefixContext.onModelSelected, }, }); }, diff --git a/extensions/nostr/src/metrics.ts b/extensions/nostr/src/metrics.ts index e9e0e7fb7..ba5bd1636 100644 --- a/extensions/nostr/src/metrics.ts +++ b/extensions/nostr/src/metrics.ts @@ -41,6 +41,11 @@ export type RateLimitMetricName = "rate_limit.per_sender" | "rate_limit.global"; export type DecryptMetricName = "decrypt.success" | "decrypt.failure"; +export type TypingMetricName = + | "typing.start.sent" + | "typing.stop.sent" + | "typing.error"; + export type MemoryMetricName = | "memory.seen_tracker_size" | "memory.rate_limiter_entries"; @@ -50,6 +55,7 @@ export type MetricName = | RelayMetricName | RateLimitMetricName | DecryptMetricName + | TypingMetricName | MemoryMetricName; // ============================================================================ @@ -128,6 +134,13 @@ export interface MetricsSnapshot { failure: number; }; + /** Typing indicator stats */ + typing: { + startSent: number; + stopSent: number; + errors: number; + }; + /** Memory/capacity stats */ memory: { seenTrackerSize: number; @@ -213,6 +226,13 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics { failure: 0, }; + // Typing indicator stats + const typing = { + startSent: 0, + stopSent: 0, + errors: 0, + }; + // Memory stats (updated via gauge-style metrics) const memory = { seenTrackerSize: 0, @@ -371,6 +391,16 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics { decrypt.failure += value; break; + case "typing.start.sent": + typing.startSent += value; + break; + case "typing.stop.sent": + typing.stopSent += value; + break; + case "typing.error": + typing.errors += value; + break; + // Memory (gauge-style - value replaces, not adds) case "memory.seen_tracker_size": memory.seenTrackerSize = value; @@ -396,6 +426,7 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics { relays: relaysObj, rateLimiting: { ...rateLimiting }, decrypt: { ...decrypt }, + typing: { ...typing }, memory: { ...memory }, snapshotAt: Date.now(), }; @@ -422,6 +453,9 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics { rateLimiting.globalHits = 0; decrypt.success = 0; decrypt.failure = 0; + typing.startSent = 0; + typing.stopSent = 0; + typing.errors = 0; memory.seenTrackerSize = 0; memory.rateLimiterEntries = 0; } @@ -452,6 +486,7 @@ export function createNoopMetrics(): NostrMetrics { relays: {}, rateLimiting: { perSenderHits: 0, globalHits: 0 }, decrypt: { success: 0, failure: 0 }, + typing: { startSent: 0, stopSent: 0, errors: 0 }, memory: { seenTrackerSize: 0, rateLimiterEntries: 0 }, snapshotAt: 0, }; diff --git a/extensions/nostr/src/nostr-bus.publish.test.ts b/extensions/nostr/src/nostr-bus.publish.test.ts new file mode 100644 index 000000000..191158c34 --- /dev/null +++ b/extensions/nostr/src/nostr-bus.publish.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; + +const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEST_PUBKEY = "f".repeat(64); + +let lastPool: { + publish: ReturnType; + subscribeMany: ReturnType; +} | null = null; + +vi.mock("nostr-tools", async () => { + const actual = await vi.importActual("nostr-tools"); + class MockPool { + publish = vi.fn(); + subscribeMany = vi.fn(() => ({ close: vi.fn() })); + constructor() { + lastPool = this as unknown as { + publish: ReturnType; + subscribeMany: ReturnType; + }; + } + } + return { + ...actual, + SimplePool: MockPool, + }; +}); + +vi.mock("nostr-tools/nip04", async () => { + return { + encrypt: vi.fn(async () => "cipher"), + decrypt: vi.fn(async () => "plain"), + }; +}); + +vi.mock("./nostr-state-store.js", () => { + return { + readNostrBusState: vi.fn(async () => null), + writeNostrBusState: vi.fn(async () => undefined), + computeSinceTimestamp: vi.fn(() => 0), + readNostrProfileState: vi.fn(async () => null), + writeNostrProfileState: vi.fn(async () => undefined), + }; +}); + +describe("startNostrBus publish handling", () => { + it("awaits publish rejections for DMs without unhandled rejection", async () => { + const { startNostrBus } = await import("./nostr-bus.js"); + const onError = vi.fn(); + const bus = await startNostrBus({ + privateKey: TEST_HEX_KEY, + relays: ["wss://relay.test"], + onMessage: async () => {}, + onError, + }); + + expect(lastPool).not.toBeNull(); + lastPool?.publish.mockReturnValue([Promise.reject(new Error("rate-limited"))]); + + let unhandled: unknown; + process.once("unhandledRejection", (reason) => { + unhandled = reason; + }); + + await expect(bus.sendDm(TEST_PUBKEY, "hi")).rejects.toThrow("rate-limited"); + await new Promise((resolve) => setImmediate(resolve)); + + expect(unhandled).toBeUndefined(); + expect(onError).toHaveBeenCalled(); + + bus.close(); + }); + + it("does not throw on typing publish failures", async () => { + const { startNostrBus } = await import("./nostr-bus.js"); + const onError = vi.fn(); + const bus = await startNostrBus({ + privateKey: TEST_HEX_KEY, + relays: ["wss://relay.test"], + onMessage: async () => {}, + onError, + }); + + expect(lastPool).not.toBeNull(); + lastPool?.publish.mockReturnValue([Promise.reject(new Error("rate-limited"))]); + + let unhandled: unknown; + process.once("unhandledRejection", (reason) => { + unhandled = reason; + }); + + await expect(bus.sendTypingStart(TEST_PUBKEY)).resolves.toBeUndefined(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(unhandled).toBeUndefined(); + expect(onError).toHaveBeenCalled(); + + bus.close(); + }); +}); diff --git a/extensions/nostr/src/nostr-bus.ts b/extensions/nostr/src/nostr-bus.ts index 25ae6f082..792abca3f 100644 --- a/extensions/nostr/src/nostr-bus.ts +++ b/extensions/nostr/src/nostr-bus.ts @@ -51,6 +51,11 @@ const CIRCUIT_BREAKER_RESET_MS = 30000; // 30 seconds before half-open // Health tracker configuration const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats +// Typing indicator configuration (NIP-01 ephemeral events) +const TYPING_KIND = 20001; // Community convention for typing indicators +const TYPING_TTL_SEC = 30; // 30 second expiration +const TYPING_THROTTLE_MS = 5000; // Max 1 event per 5 seconds per recipient + // ============================================================================ // Types // ============================================================================ @@ -66,7 +71,8 @@ export interface NostrBusOptions { onMessage: ( pubkey: string, text: string, - reply: (text: string) => Promise + reply: (text: string) => Promise, + eventId: string ) => Promise; /** Called on errors (optional) */ onError?: (error: Error, context: string) => void; @@ -101,6 +107,10 @@ export interface NostrBusHandle { lastPublishedEventId: string | null; lastPublishResults: Record | null; }>; + /** Send typing indicator start (kind 20001) */ + sendTypingStart: (toPubkey: string, conversationEventId?: string) => Promise; + /** Send typing indicator stop (kind 20001) */ + sendTypingStop: (toPubkey: string, conversationEventId?: string) => Promise; } // ============================================================================ @@ -496,7 +506,7 @@ export async function startNostrBus( }; // Call the message handler - await onMessage(event.pubkey, plaintext, replyTo); + await onMessage(event.pubkey, plaintext, replyTo, event.id); // Mark as processed metrics.emit("event.processed"); @@ -512,26 +522,21 @@ export async function startNostrBus( const sub = pool.subscribeMany( relays, - [{ kinds: [4], "#p": [pk], since }], + { kinds: [4], "#p": [pk], since }, { onevent: handleEvent, oneose: () => { - // EOSE handler - called when all stored events have been received for (const relay of relays) { metrics.emit("relay.message.eose", 1, { relay }); } onEose?.(relays.join(", ")); }, onclose: (reason) => { - // Handle subscription close for (const relay of relays) { metrics.emit("relay.message.closed", 1, { relay }); options.onDisconnect?.(relay); } - onError?.( - new Error(`Subscription closed: ${reason}`), - "subscription" - ); + onError?.(new Error(`Subscription closed: ${reason}`), "subscription"); }, } ); @@ -590,6 +595,17 @@ export async function startNostrBus( }; }; + // Create typing controller for throttled typing indicators + const typingController = createTypingController( + pool, + sk, + relays, + metrics, + circuitBreakers, + healthTracker, + onError + ); + return { close: () => { sub.close(); @@ -610,6 +626,8 @@ export async function startNostrBus( getMetrics: () => metrics.getSnapshot(), publishProfile, getProfileState, + sendTypingStart: typingController.sendTypingStart, + sendTypingStop: typingController.sendTypingStop, }; } @@ -646,6 +664,7 @@ async function sendEncryptedDm( const sortedRelays = healthTracker.getSortedRelays(relays); // Try relays in order of health, respecting circuit breakers + let successCount = 0; let lastError: Error | undefined; for (const relay of sortedRelays) { const cb = circuitBreakers.get(relay); @@ -657,13 +676,16 @@ async function sendEncryptedDm( const startTime = Date.now(); try { - await pool.publish([relay], reply); + const [publishResult] = await Promise.allSettled(pool.publish([relay], reply)); + if (publishResult?.status === "rejected") { + throw publishResult.reason; + } const latency = Date.now() - startTime; // Record success cb?.recordSuccess(); healthTracker.recordSuccess(relay, latency); - + successCount++; return; // Success - exit early } catch (err) { lastError = err as Error; @@ -678,7 +700,150 @@ async function sendEncryptedDm( } } - throw new Error(`Failed to publish to any relay: ${lastError?.message}`); + if (successCount === 0) { + throw new Error(`Failed to publish to any relay: ${lastError?.message}`); + } +} + +// ============================================================================ +// Typing Indicator (Kind 20001 Ephemeral Event) +// ============================================================================ + +/** + * Send a typing indicator event to a pubkey + * Uses kind 20001 (community convention for typing) + * Content is NIP-04 encrypted for privacy consistency with DMs + */ +async function sendTypingIndicator( + pool: SimplePool, + sk: Uint8Array, + toPubkey: string, + action: "start" | "stop", + relays: string[], + metrics: NostrMetrics, + circuitBreakers: Map, + healthTracker: RelayHealthTracker, + conversationEventId?: string, + onError?: (error: Error, context: string) => void +): Promise { + // Encrypt the action for privacy (consistent with DMs) + const ciphertext = await encrypt(sk, toPubkey, action); + + // Build tags + const tags: string[][] = [ + ["p", toPubkey], + ["t", "clawdbot-typing"], // Namespace tag for collision protection + ["expiration", String(Math.floor(Date.now() / 1000) + TYPING_TTL_SEC)], + ]; + + // Add conversation scope if provided + if (conversationEventId) { + tags.push(["e", conversationEventId]); + } + + const event = finalizeEvent( + { + kind: TYPING_KIND, + content: ciphertext, + tags, + created_at: Math.floor(Date.now() / 1000), + }, + sk + ); + + // Sort relays by health score + const sortedRelays = healthTracker.getSortedRelays(relays); + + // Try relays in order, respecting circuit breakers + let lastError: Error | undefined; + for (const relay of sortedRelays) { + const cb = circuitBreakers.get(relay); + if (cb && !cb.canAttempt()) { + continue; + } + + const startTime = Date.now(); + try { + const [publishResult] = await Promise.allSettled(pool.publish([relay], event)); + if (publishResult?.status === "rejected") { + throw publishResult.reason; + } + const latency = Date.now() - startTime; + cb?.recordSuccess(); + healthTracker.recordSuccess(relay, latency); + const metricName = action === "start" ? "typing.start.sent" : "typing.stop.sent"; + metrics.emit(metricName, 1, { relay }); + return; // Success - exit early + } catch (err) { + lastError = err as Error; + cb?.recordFailure(); + healthTracker.recordFailure(relay); + metrics.emit("typing.error", 1, { relay }); + onError?.(lastError, `typing ${action} to ${relay}`); + } + } + + // Don't throw for typing failures - they're non-critical + if (lastError) { + onError?.(lastError, `typing ${action} failed on all relays`); + } +} + +/** + * Create throttled typing indicator functions + * Returns start/stop functions that respect throttling (max 1 event per 5s per recipient) + */ +function createTypingController( + pool: SimplePool, + sk: Uint8Array, + relays: string[], + metrics: NostrMetrics, + circuitBreakers: Map, + healthTracker: RelayHealthTracker, + onError?: (error: Error, context: string) => void +): { + sendTypingStart: (toPubkey: string, conversationEventId?: string) => Promise; + sendTypingStop: (toPubkey: string, conversationEventId?: string) => Promise; +} { + // Track last send time per recipient for throttling + const lastSendTime = new Map(); + + const sendWithThrottle = async ( + toPubkey: string, + action: "start" | "stop", + conversationEventId?: string + ): Promise => { + const now = Date.now(); + const lastSent = lastSendTime.get(toPubkey) ?? 0; + + // Stop events bypass throttle for better UX + if (action === "start") { + if (now - lastSent < TYPING_THROTTLE_MS) { + return; // Throttled + } + lastSendTime.set(toPubkey, now); + } + + await sendTypingIndicator( + pool, + sk, + toPubkey, + action, + relays, + metrics, + circuitBreakers, + healthTracker, + conversationEventId, + onError + ); + }; + + return { + sendTypingStart: (toPubkey: string, conversationEventId?: string) => + sendWithThrottle(toPubkey, "start", conversationEventId), + sendTypingStop: (toPubkey: string, conversationEventId?: string) => + sendWithThrottle(toPubkey, "stop", conversationEventId), + }; } // ============================================================================ diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index e9a3e2955..acad8d03f 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -25,11 +25,22 @@ import { import { getSignalRuntime } from "./runtime.js"; +function resolveSignalMessageActions() { + try { + return getSignalRuntime().channel.signal?.messageActions ?? null; + } catch { + return null; + } +} + const signalMessageActions: ChannelMessageActionAdapter = { - listActions: (ctx) => getSignalRuntime().channel.signal.messageActions.listActions(ctx), - supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions.supportsAction?.(ctx), - handleAction: async (ctx) => - await getSignalRuntime().channel.signal.messageActions.handleAction(ctx), + listActions: (ctx) => resolveSignalMessageActions()?.listActions?.(ctx) ?? [], + supportsAction: (ctx) => resolveSignalMessageActions()?.supportsAction?.(ctx), + handleAction: async (ctx) => { + const actions = resolveSignalMessageActions(); + if (!actions?.handleAction) return null; + return await actions.handleAction(ctx); + }, }; const meta = getChatChannelMeta("signal"); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index b8ab14b52..4063bd1ae 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -31,12 +31,22 @@ import { getTelegramRuntime } from "./runtime.js"; const meta = getChatChannelMeta("telegram"); +function resolveTelegramMessageActions() { + try { + return getTelegramRuntime().channel.telegram?.messageActions ?? null; + } catch { + return null; + } +} + const telegramMessageActions: ChannelMessageActionAdapter = { - listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions.listActions(ctx), - extractToolSend: (ctx) => - getTelegramRuntime().channel.telegram.messageActions.extractToolSend(ctx), - handleAction: async (ctx) => - await getTelegramRuntime().channel.telegram.messageActions.handleAction(ctx), + listActions: (ctx) => resolveTelegramMessageActions()?.listActions?.(ctx) ?? [], + extractToolSend: (ctx) => resolveTelegramMessageActions()?.extractToolSend?.(ctx), + handleAction: async (ctx) => { + const actions = resolveTelegramMessageActions(); + if (!actions?.handleAction) return null; + return await actions.handleAction(ctx); + }, }; function parseReplyToMessageId(replyToId?: string | null) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..522d14756 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,12 +383,12 @@ importers: '@microsoft/agents-hosting-extensions-teams': specifier: ^1.2.2 version: 1.2.2 - moltbot: - specifier: workspace:* - version: link:../.. express: specifier: ^5.2.1 version: 5.2.1 + moltbot: + specifier: workspace:* + version: link:../.. proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -3214,11 +3214,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawdbot@2026.1.24-3: - resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==} - engines: {node: '>=22.12.0'} - hasBin: true - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -9098,84 +9093,6 @@ snapshots: dependencies: clsx: 2.1.1 - clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3): - dependencies: - '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.975.0 - '@buape/carbon': 0.14.0(hono@4.11.4) - '@clack/prompts': 0.11.0 - '@grammyjs/runner': 2.0.3(grammy@1.39.3) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3) - '@homebridge/ciao': 1.3.4 - '@line/bot-sdk': 10.6.0 - '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.49.3 - '@mozilla/readability': 0.6.0 - '@sinclair/typebox': 0.34.47 - '@slack/bolt': 4.6.0(@types/express@5.0.6) - '@slack/web-api': 7.13.0 - '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) - ajv: 8.17.1 - body-parser: 2.2.2 - chalk: 5.6.2 - chokidar: 5.0.0 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482) - cli-highlight: 2.1.11 - commander: 14.0.2 - croner: 9.1.0 - detect-libc: 2.1.2 - discord-api-types: 0.38.37 - dotenv: 17.2.3 - express: 5.2.1 - file-type: 21.3.0 - grammy: 1.39.3 - hono: 4.11.4 - jiti: 2.6.1 - json5: 2.2.3 - jszip: 3.10.1 - linkedom: 0.18.12 - long: 5.3.2 - markdown-it: 14.1.0 - node-edge-tts: 1.2.9 - osc-progress: 0.3.0 - pdfjs-dist: 5.4.530 - playwright-core: 1.58.0 - proper-lockfile: 4.1.2 - qrcode-terminal: 0.12.0 - sharp: 0.34.5 - sqlite-vec: 0.1.7-alpha.2 - tar: 7.5.4 - tslog: 4.10.2 - undici: 7.19.0 - ws: 8.19.0 - yaml: 2.8.2 - zod: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': 0.1.88 - node-llama-cpp: 3.15.0(typescript@5.9.3) - transitivePeerDependencies: - - '@discordjs/opus' - - '@modelcontextprotocol/sdk' - - '@types/express' - - audio-decode - - aws-crt - - bufferutil - - canvas - - debug - - devtools-protocol - - encoding - - ffmpeg-static - - jimp - - link-preview-js - - node-opus - - opusscript - - supports-color - - typescript - - utf-8-validate - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 6747aadc8..d96f330c9 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -30,7 +30,8 @@ const defaultShell = isWin // PowerShell: Start-Sleep for delays, ; for command separation, $null for null device const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05"; const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2"; -const longDelayCmd = isWin ? "Start-Sleep -Seconds 2" : "sleep 2"; +// Use longer delay on Windows for reliable timeout testing (Windows scheduling is less precise) +const longDelayCmd = isWin ? "Start-Sleep -Seconds 5" : "sleep 2"; // Both PowerShell and bash use ; for command separation const joinCommands = (commands: string[]) => commands.join("; "); const echoAfterDelay = (message: string) => joinCommands([shortDelayCmd, `echo ${message}`]); diff --git a/vitest.config.ts b/vitest.config.ts index 92c962a1f..ad81b7671 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ import { defineConfig } from "vitest/config"; const repoRoot = path.dirname(fileURLToPath(import.meta.url)); const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isWindows = process.platform === "win32"; +const isMacOS = process.platform === "darwin"; const localWorkers = Math.max(4, Math.min(16, os.cpus().length)); const ciWorkers = isWindows ? 2 : 3; @@ -19,6 +20,8 @@ export default defineConfig({ testTimeout: 120_000, hookTimeout: isWindows ? 180_000 : 120_000, pool: "forks", + // Use singleFork on macOS CI to avoid vitest worker crash (vitest#8564) + poolOptions: isMacOS && isCI ? { forks: { singleFork: true } } : undefined, maxWorkers: isCI ? ciWorkers : localWorkers, include: [ "src/**/*.test.ts",