From b6291a44bde4ff345e58549853f931457d22546d Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 1 Jun 2026 14:59:00 +0200 Subject: [PATCH 01/99] Don't auto-detect language on bare code fences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit highlightCodeBlocks() called hljs.highlightElement() unconditionally, so fences without a language tag fell back to highlight.js auto-detection. That mis-classified prose as Ruby — an apostrophe (e.g. "пам'ятає") opens a string literal and the github-dark theme dims the rest of the block, making plain text hard to read. Now highlight only when the fence declares a language hljs recognizes; bare/unknown/text fences render as plain monospace (no auto-detect). Explicit-language blocks still highlight normally, so Cyrillic in code comments is unaffected. Co-Authored-By: Claude Opus 4.8 --- public/js/iclaw.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/public/js/iclaw.js b/public/js/iclaw.js index 2bbeea9..bf1f230 100644 --- a/public/js/iclaw.js +++ b/public/js/iclaw.js @@ -746,10 +746,26 @@ if (!pre || pre.tagName !== 'PRE') return; if (pre.closest('.exec-approval-card')) return; if (code.classList.contains('hljs')) return; - try { - hl.highlightElement(code); - } catch (_) { - /* unknown language / empty */ + // Only highlight when the fence declares a language hljs actually knows. + // On a bare/unknown fence, highlightElement() falls back to auto-detection, + // which mis-guesses prose as Ruby/Perl (an apostrophe opens a "string" and + // the github-dark theme dims whole paragraphs). Leave those as plain text. + const langClass = [...code.classList].find((c) => c.startsWith('language-')); + const lang = langClass && langClass.slice(9); + const known = + lang && + lang !== 'text' && + lang !== 'plaintext' && + typeof hl.getLanguage === 'function' && + hl.getLanguage(lang); + if (known) { + try { + hl.highlightElement(code); + } catch (_) { + code.classList.add('hljs'); /* keep block styling even if hljs throws */ + } + } else { + code.classList.add('hljs'); /* plaintext — styled, no auto-detect */ } }); } From c5918570a70da06de8ba7f469d70abaae2877b00 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 1 Jun 2026 18:02:22 +0000 Subject: [PATCH 02/99] feat(composer): add Ask / Execute mode selector - Config-driven modes in services/chatModes.ts (ask, execute + disabled research/image/safe_run placeholders); single source of truth, extensible. - Persist mode per message (messages.mode) and per queued row (queued_messages.mode); idempotent ensureColumn migrations, plain TEXT. - Thread mode frontend -> WS 'send' -> chatRunner -> queue flush; missing/ unknown -> 'execute' so legacy chats and older clients keep working. - Ask prepends a lightweight 'answer, don't execute' directive to the gateway message; Execute is byte-for-byte unchanged. Seams left for routing Ask to a no-tools OpenClaw profile or OpenRouter later (see chatModes.ts + README). - Minimal composer pill + dropdown selector, choice persisted in localStorage. --- README.md | 28 ++++++ public/css/style.css | 44 ++++++++++ public/js/iclaw.js | 90 ++++++++++++++++++- src/db/database.ts | 6 ++ src/routes/chats.ts | 9 ++ src/routes/index.ts | 3 + src/routes/ws.ts | 2 + src/services/chatModes.ts | 171 ++++++++++++++++++++++++++++++++++++ src/services/chatRunner.ts | 18 +++- src/services/store.ts | 20 +++-- src/types/index.ts | 25 ++++++ src/types/protocol.ts | 7 ++ views/partials/composer.ejs | 51 +++++++++++ 13 files changed, 464 insertions(+), 10 deletions(-) create mode 100644 src/services/chatModes.ts diff --git a/README.md b/README.md index 5a87464..00dcd2b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,34 @@ Hit **Share** in any chat to get an encrypted link. The chat is encrypted in you Powered by [iClaw-cloud](https://github.com/iClawApp/iClaw-cloud) — defaults to `https://app.iclaw.digital`. +## Chat modes (Ask / Execute) + +The composer has a small mode selector. **Execute** (default) is the full agent — +OpenClaw can use files, tools, shell, and the browser, exactly as before. +**Ask** is for quick questions, explanations, and planning with no heavy agent +execution. The selected mode is stored per message (`messages.mode`) and rides +along the whole `frontend → WS → chatRunner → OpenClaw` path. Missing/unknown +modes fall back to `execute`, so old chats and older clients keep working. + +Modes are config-driven in [`src/services/chatModes.ts`](src/services/chatModes.ts) +(it also lists disabled placeholders — Research, Image, Safe Run — so new modes +can be added without touching call sites or the DB; the column is plain `TEXT`). + +**How Ask works today and how to route it differently later.** For now Ask reuses +the same OpenClaw session as Execute and is differentiated by a short +"answer, don't execute" instruction prepended to the gateway message +(`applyModeToGatewayMessage`). Two cleaner backends are pre-seamed in +`chatModes.ts`: + +- **No-tools / lightweight OpenClaw profile** — if the gateway gains a way to run + a session with tools disabled, return it from `gatewayProfileForMode()` and + have `openclawWs` forward it on `sessions.create` / `chat.send`. Modes that + want this are already flagged `lightweight: true`. +- **OpenRouter / OpenAI direct** — for a true "no agent" answer, branch in + `chatRunner` on `getModeDef(mode).lightweight` and call an LLM client instead + of `openclawWs.runTurn`. The mode is already persisted per message, so this + needs no schema or UI change. + ## Remote Access (alpha) Open the iClaw UI from another device through an **iclaw-relay** tunnel (Settings → Remote Access). diff --git a/public/css/style.css b/public/css/style.css index 538de1d..c515aa1 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1731,6 +1731,50 @@ code { --composer-secret-strip-gap: var(--space-1); } +/* Mode selector (Ask / Execute) — compact pill + upward dropdown. */ +.composer-modes { + position: relative; + display: flex; + margin-bottom: 6px; + padding-left: 4px; +} +.composer-mode-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px 3px 11px; + font: inherit; + font-size: var(--text-xs); + font-weight: 600; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} +.composer-mode-btn:hover, +.composer-mode-btn[aria-expanded='true'] { + color: var(--text); + border-color: color-mix(in srgb, var(--text) 24%, var(--border)); +} +.composer-mode-btn svg { + opacity: 0.65; +} +.composer-mode-menu { + bottom: calc(100% + 6px); + left: 0; + min-width: 264px; +} +.composer-mode-menu-item[aria-checked='true'] { + background: color-mix(in srgb, var(--accent) 10%, transparent); +} +.composer-mode-menu-item__desc { + font-size: var(--text-xs); + color: var(--muted); + line-height: 1.35; +} + .composer-field { margin-top: -8px; margin-bottom: var(--space-4); diff --git a/public/js/iclaw.js b/public/js/iclaw.js index bf1f230..35146b3 100644 --- a/public/js/iclaw.js +++ b/public/js/iclaw.js @@ -42,6 +42,90 @@ const rawChatId = messagesEl?.dataset.chatId; const startedOnDraft = messagesEl?.dataset.draft === '1' || !rawChatId; let activeChatId = startedOnDraft ? null : Number(rawChatId); + + // ------------------------------------------------------------------------- + // composer mode (Ask / Execute). Mode rides along with each sent message. + // The set of selectable modes is rendered server-side from the config in + // services/chatModes.ts, so adding a mode there surfaces it here with no + // client change. Default + back-compat fallback is 'execute'. + // ------------------------------------------------------------------------- + const MODE_STORAGE_KEY = 'iclaw:composer-mode'; + const composerModesEl = document.getElementById('composer-modes'); + const composerModeBtn = document.getElementById('composer-mode-btn'); + const composerModeMenu = document.getElementById('composer-mode-menu'); + const composerModeLabel = document.getElementById('composer-mode-label'); + const composerModeDefault = + composerModesEl?.dataset.defaultMode || 'execute'; + const composerModeIds = composerModeMenu + ? Array.from(composerModeMenu.querySelectorAll('.composer-mode-menu-item')).map( + (el) => el.dataset.mode, + ) + : [composerModeDefault]; + let selectedComposerMode = composerModeDefault; + + /** Currently selected send mode (always one of the rendered, enabled ids). */ + function getComposerMode() { + return composerModeIds.includes(selectedComposerMode) + ? selectedComposerMode + : composerModeDefault; + } + + function setComposerMode(mode, opts) { + const next = composerModeIds.includes(mode) ? mode : composerModeDefault; + selectedComposerMode = next; + if (composerModeBtn) composerModeBtn.dataset.mode = next; + if (composerModeMenu) { + composerModeMenu.querySelectorAll('.composer-mode-menu-item').forEach((el) => { + const on = el.dataset.mode === next; + el.setAttribute('aria-checked', on ? 'true' : 'false'); + if (composerModeLabel && on) { + const t = el.querySelector('.menu-item__title'); + composerModeLabel.textContent = t ? t.textContent : next; + } + if (on) { + const desc = el.querySelector('.composer-mode-menu-item__desc'); + if (composerModeBtn && desc) composerModeBtn.title = desc.textContent || ''; + } + }); + } + if (!opts || opts.persist !== false) { + try { localStorage.setItem(MODE_STORAGE_KEY, next); } catch (_) {} + } + } + + function closeComposerModeMenu() { + if (!composerModeMenu) return; + composerModeMenu.hidden = true; + if (composerModeBtn) composerModeBtn.setAttribute('aria-expanded', 'false'); + } + + if (composerModeBtn && composerModeMenu) { + // Restore the last choice (falls back to the server default). + let stored = null; + try { stored = localStorage.getItem(MODE_STORAGE_KEY); } catch (_) {} + setComposerMode(stored || composerModeDefault, { persist: false }); + + composerModeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const open = composerModeMenu.hidden; + composerModeMenu.hidden = !open; + composerModeBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + composerModeMenu.addEventListener('click', (e) => { + const item = e.target.closest('.composer-mode-menu-item'); + if (!item) return; + setComposerMode(item.dataset.mode); + closeComposerModeMenu(); + input?.focus(); + }); + document.addEventListener('click', (e) => { + if (composerModeMenu.hidden) return; + if (composerModesEl && !composerModesEl.contains(e.target)) closeComposerModeMenu(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !composerModeMenu.hidden) closeComposerModeMenu(); + }); + } /** Clears timed reply-quote highlights in the transcript. */ let replyJumpHighlightTimer = null; let replyJumpHighlightFadeTimer = null; @@ -1385,6 +1469,7 @@ serverId: row.id, id: String(row.id), content: row.content, + mode: row.mode || undefined, }; if (row.reply_to_message_id != null && row.reply_quote) { item.replyTo = { @@ -1414,6 +1499,7 @@ async function enqueueQueueOnServer(chatId, draft) { const body = { content: draft.content }; + if (draft.mode) body.mode = draft.mode; if (draft.replyTo) body.replyTo = draft.replyTo; if (draft.inlineSecrets && draft.inlineSecrets.length > 0) { body.inlineSecrets = draft.inlineSecrets; @@ -3536,7 +3622,7 @@ // Optimistically append user msg. Mark it as pending-id so the // upcoming `message-appended` for the same user msg adopts this node // instead of duplicating. - const optimistic = { role: 'user', content: item.content }; + const optimistic = { role: 'user', content: item.content, mode: item.mode }; if (item.replyTo) { optimistic.reply_to_message_id = item.replyTo.messageId; optimistic.reply_quote = item.replyTo.quote; @@ -3561,6 +3647,7 @@ requestId: 'r-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6), content: item.content, }; + if (item.mode) payload.mode = item.mode; if (item.replyTo) { payload.replyTo = { messageId: item.replyTo.messageId, @@ -4667,6 +4754,7 @@ replyTo: replySnap || undefined, attachments: attachmentsSnap.length > 0 ? attachmentsSnap : undefined, inlineSecrets, + mode: getComposerMode(), }; let queued; const persistOnServer = diff --git a/src/db/database.ts b/src/db/database.ts index 6b0401d..3cec0ef 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -44,6 +44,8 @@ CREATE TABLE IF NOT EXISTS messages ( reply_to_role TEXT, /** JSON array of {url, mimeType, fileName, sizeBytes} for user-attached files. NULL when no attachments. */ attachments TEXT, + /** Send mode: 'ask' | 'execute' (see services/chatModes.ts). Legacy rows default to 'execute'. */ + mode TEXT NOT NULL DEFAULT 'execute', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -111,6 +113,8 @@ CREATE TABLE IF NOT EXISTS queued_messages ( attachments TEXT, /** JSON array of {slot, label, plain} for [[iclaw:sN]] markers; resolved on flush. */ inline_secrets TEXT, + /** Send mode chosen at enqueue time; preserved so flush sends with it. */ + mode TEXT NOT NULL DEFAULT 'execute', /** Lower sorts first; promote-to-front uses values below the current min. */ position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -257,6 +261,8 @@ function ensureColumn(table: string, column: string, ddl: string): void { } } ensureColumn('messages', 'attachments', 'TEXT'); +ensureColumn('messages', 'mode', "TEXT NOT NULL DEFAULT 'execute'"); +ensureColumn('queued_messages', 'mode', "TEXT NOT NULL DEFAULT 'execute'"); ensureColumn('chats', 'chat_kind', "TEXT NOT NULL DEFAULT 'normal'"); ensureColumn('remote_access_tunnels', 'access_token', 'TEXT'); ensureColumn('remote_access_tunnels', 'opaque_registration_record', 'TEXT'); diff --git a/src/routes/chats.ts b/src/routes/chats.ts index 07c7c39..95fa758 100644 --- a/src/routes/chats.ts +++ b/src/routes/chats.ts @@ -25,6 +25,11 @@ import { openclaw, cloudShareBaseUrl } from '../services/openclaw'; import { chatStatus } from '../services/chatStatus'; import { wsHub } from '../services/wsHub'; import { sendMessage } from '../services/chatRunner'; +import { + DEFAULT_MODE, + listSelectableModes, + normalizeChatMode, +} from '../services/chatModes'; import { shouldShowSendHint } from '../services/sendHint'; export const chatsRouter: Router = Router(); @@ -180,6 +185,8 @@ chatsRouter.get('/:id', async (req, res, next) => { scheduledList: scheduledMessages.listByChat(id), queueList: queuedMessages.listByChat(id), sendHintShow: shouldShowSendHint(), + chatModes: listSelectableModes(), + defaultChatMode: DEFAULT_MODE, }); } catch (err) { next(err); @@ -619,6 +626,7 @@ chatsRouter.post('/:id/queue', (req, res) => { replyTo: replyTo ?? null, attachments: persistedAttachments, inlineSecrets: inlineSecrets ?? null, + mode: normalizeChatMode(req.body?.mode), }); wsHub.broadcastAll({ type: 'queue-added', chatId: id, item: row }); res.json({ item: row }); @@ -696,6 +704,7 @@ chatsRouter.post('/:id/queue/:queueId/flush', async (req, res) => { prePersistedAttachments: row.attachments && row.attachments.length > 0 ? row.attachments : undefined, requestId: String(req.body?.requestId ?? '').trim() || undefined, + mode: row.mode, }); res.json({ ok: true }); } catch (err) { diff --git a/src/routes/index.ts b/src/routes/index.ts index bb1fbc2..d29b7c1 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -4,6 +4,7 @@ import { openclaw } from '../services/openclaw'; import { probeGateway } from '../services/gatewayProbe'; import { chatStatus } from '../services/chatStatus'; import { shouldShowSendHint } from '../services/sendHint'; +import { DEFAULT_MODE, listSelectableModes } from '../services/chatModes'; export const indexRouter: Router = Router(); @@ -41,5 +42,7 @@ indexRouter.get('/', async (req, res) => { openclawBaseUrl: openclaw.baseUrl, workingIds: chatStatus.workingIds(), sendHintShow: shouldShowSendHint(), + chatModes: listSelectableModes(), + defaultChatMode: DEFAULT_MODE, }); }); diff --git a/src/routes/ws.ts b/src/routes/ws.ts index 68fad60..617da8a 100644 --- a/src/routes/ws.ts +++ b/src/routes/ws.ts @@ -12,6 +12,7 @@ import { sendMessage, abortChatRun } from '../services/chatRunner'; import { openclawWs } from '../services/openclawWs'; import type { ClientMsg, ServerMsg } from '../types/protocol'; import type { InlineSecretWire } from '../services/inlineSecrets'; +import { normalizeChatMode } from '../services/chatModes'; const PATH = '/ws'; @@ -90,6 +91,7 @@ async function handleClientMsg(socket: WebSocket, msg: ClientMsg): Promise replyTo: msg.replyTo, incomingAttachments: msg.attachments, inlineSecrets, + mode: normalizeChatMode((msg as { mode?: unknown }).mode), }); } catch (err) { // Errors are already broadcast via chatRunner; nothing more to do. diff --git a/src/services/chatModes.ts b/src/services/chatModes.ts new file mode 100644 index 0000000..54a7167 --- /dev/null +++ b/src/services/chatModes.ts @@ -0,0 +1,171 @@ +/** + * Single source of truth for chat "modes" — the lightweight Ask vs. full + * Execute distinction the composer offers, plus a place to grow future modes + * (Research, Image, Safe Run) without hardcoding two modes everywhere. + * + * Design notes + * ------------ + * - The wire/DB value is a plain string. The TS union (`ChatMode` in + * ../types) only names the two LIVE modes; the catalog below can list + * `enabled: false` placeholders for modes we haven't built yet. Adding a + * live mode later = flip `enabled` and (if it needs a new union member) + * widen `ChatMode`. No DB migration is needed because the column is TEXT. + * + * - `DEFAULT_MODE` is 'execute' so anything that doesn't specify a mode — + * legacy rows, older clients, scheduled messages, task runs — behaves + * exactly as before. `normalizeChatMode()` enforces this fallback. + * + * Routing Ask differently in the future + * ------------------------------------- + * Today Ask reuses the same OpenClaw session as Execute and is differentiated + * by a short instruction prepended to the gateway message (see + * `applyModeToGatewayMessage`). Two cleaner backends are possible later and + * the seams are already here: + * + * 1. No-tools / lightweight OpenClaw profile — if the gateway gains a way to + * run a session with tools disabled, return that descriptor from + * `gatewayProfileForMode()` and have the WS client pass it on + * `sessions.create` / `chat.send`. `lightweight: true` already marks the + * modes that want it. + * 2. OpenRouter / OpenAI direct — for a true "no agent" answer, branch in + * chatRunner on `getModeDef(mode).lightweight` and call an LLM client + * instead of `openclawWs.runTurn`. The mode is persisted per message, so + * this can be added without any schema or UI change. + */ + +import type { ChatMode } from '../types'; + +export interface ChatModeDef { + /** Wire/DB value. */ + id: string; + /** Short label for the selector chip. */ + label: string; + /** One-liner shown in the selector / tooltip. */ + description: string; + /** Whether the mode is selectable today. Placeholders are `false`. */ + enabled: boolean; + /** + * True when the mode wants a no-tools / no-heavy-execution answer. Drives + * the Ask gateway preamble today and is the hook future routing keys off. + */ + lightweight: boolean; +} + +/** + * The full catalog. Order here is the order shown in the UI. Disabled entries + * are intentionally kept so the selector and any future router can enumerate + * the roadmap without scattering string literals across the codebase. + */ +export const CHAT_MODES: readonly ChatModeDef[] = [ + { + id: 'ask', + label: 'Ask', + description: + 'For simple questions, explanations, planning. No heavy agent execution.', + enabled: true, + lightweight: true, + }, + { + id: 'execute', + label: 'Execute', + description: + 'For tasks that may need files, tools, shell, browser, or actions.', + enabled: true, + lightweight: false, + }, + // --- Planned modes (not selectable yet) ------------------------------- + // Flip `enabled: true` and wire the backend when each is implemented. + { + id: 'research', + label: 'Research', + description: 'Deep multi-source research with citations. (Coming soon.)', + enabled: false, + lightweight: false, + }, + { + id: 'image', + label: 'Image', + description: 'Generate or edit images. (Coming soon.)', + enabled: false, + lightweight: false, + }, + { + id: 'safe_run', + label: 'Safe Run', + description: 'Execute with tighter sandboxing and approvals. (Coming soon.)', + enabled: false, + lightweight: false, + }, +] as const; + +/** Mode used whenever none is supplied or the supplied one is unknown/disabled. */ +export const DEFAULT_MODE: ChatMode = 'execute'; + +/** Modes a client is allowed to select right now. */ +export const ENABLED_MODE_IDS: readonly string[] = CHAT_MODES.filter( + (m) => m.enabled, +).map((m) => m.id); + +/** Enabled modes only — feeds the composer selector (EJS locals / client). */ +export function listSelectableModes(): ChatModeDef[] { + return CHAT_MODES.filter((m) => m.enabled); +} + +function findMode(id: string): ChatModeDef | undefined { + return CHAT_MODES.find((m) => m.id === id); +} + +/** True only for an enabled, known mode id. */ +export function isSelectableMode(id: string): boolean { + const def = findMode(id); + return Boolean(def && def.enabled); +} + +/** + * Coerce any untrusted input (query body, WS frame, DB row) into a valid, + * currently-selectable `ChatMode`. Unknown, disabled, or missing → DEFAULT_MODE. + */ +export function normalizeChatMode(raw: unknown): ChatMode { + if (typeof raw !== 'string') return DEFAULT_MODE; + const id = raw.trim().toLowerCase(); + if (isSelectableMode(id)) return id as ChatMode; + return DEFAULT_MODE; +} + +/** Definition for a (normalized) mode — always defined for selectable modes. */ +export function getModeDef(mode: ChatMode): ChatModeDef { + return findMode(mode) ?? findMode(DEFAULT_MODE)!; +} + +/** + * Instruction prepended to lightweight-mode messages so the agent answers + * without spinning up heavy tool/agent execution. Returns the message + * unchanged for Execute (and any non-lightweight mode) — Execute must stay + * byte-for-byte identical to today's behavior. + * + * This is the interim mechanism. See the module header for the no-tools + * profile / OpenRouter alternatives that can replace it later. + */ +const ASK_PREAMBLE = [ + '[iClaw Ask mode]', + 'The user is in "Ask" mode — a lightweight question, explanation, or planning request.', + 'Answer directly from reasoning and knowledge. Avoid running shell commands, editing or', + 'creating files, or driving the browser/other tools unless it is strictly required to answer.', + 'Do not begin a long autonomous task; keep it conversational and concise.', + '', + '', +].join('\n'); + +export function applyModeToGatewayMessage(mode: ChatMode, message: string): string { + return getModeDef(mode).lightweight ? ASK_PREAMBLE + message : message; +} + +/** + * Reserved hook for a future no-tools / lightweight OpenClaw session profile. + * Returns `null` today because the gateway exposes no such knob — callers must + * treat `null` as "no special profile, send normally". Wire this up (and have + * openclawWs forward it) if/when the gateway supports disabling tools. + */ +export function gatewayProfileForMode(_mode: ChatMode): null { + return null; +} diff --git a/src/services/chatRunner.ts b/src/services/chatRunner.ts index b233895..415d389 100644 --- a/src/services/chatRunner.ts +++ b/src/services/chatRunner.ts @@ -20,7 +20,8 @@ import { type IncomingAttachment, type ProcessedAttachment, } from './uploads'; -import type { Message, MessageAttachment } from '../types'; +import type { ChatMode, Message, MessageAttachment } from '../types'; +import { applyModeToGatewayMessage, DEFAULT_MODE } from './chatModes'; import { expandStoredSecretPlaceholdersForGateway, resolveInlineSecretMarkersInContent, @@ -211,6 +212,8 @@ async function runTurnLocked(opts: { /** Attachments already saved under data/uploads (queued-message flush). */ prePersistedAttachments?: MessageAttachment[]; inlineSecrets?: InlineSecretWire[]; + /** 'ask' | 'execute'. Defaults to 'execute' for full back-compat. */ + mode?: ChatMode; }): Promise { const { chatId, @@ -221,6 +224,7 @@ async function runTurnLocked(opts: { prePersistedAttachments, inlineSecrets, } = opts; + const mode: ChatMode = opts.mode ?? DEFAULT_MODE; const chat = chats.get(chatId)!; const sessionKey = await ensureSession(chatId); const projectId = chat.project_id ?? null; @@ -268,10 +272,16 @@ async function runTurnLocked(opts: { gatewayBody = formatReplyGatewayBlock(refExpanded, reply.quote) + gatewayBody; } - const gatewayMessage = + const gatewayMessageBase = chat.project_id != null && projects.get(chat.project_id) ? buildGatewayUserMessage(gatewayBody, chat.project_id) : gatewayBody; + // Ask mode: prepend a lightweight "answer, don't execute" directive. Execute + // mode passes through unchanged (applyModeToGatewayMessage is a no-op for it), + // so existing behavior is byte-for-byte identical. This is the interim + // mechanism — see services/chatModes.ts for the no-tools-profile / OpenRouter + // routing this can be swapped for later. + const gatewayMessage = applyModeToGatewayMessage(mode, gatewayMessageBase); // Persist user message + broadcast (stored text keeps placeholders only). const replyToRole = @@ -289,6 +299,7 @@ async function runTurnLocked(opts: { } : null, persistedAttachments.length > 0 ? persistedAttachments : null, + mode, ); for (const sid of newSecretIds) { projectSecrets.setSourceMessage(sid, userMsg.id); @@ -584,6 +595,8 @@ export async function sendMessage(opts: { incomingAttachments?: IncomingAttachment[]; /** Files already on disk (queued-message flush). */ prePersistedAttachments?: MessageAttachment[]; + /** 'ask' | 'execute'. Defaults to 'execute' when omitted. */ + mode?: ChatMode; }): Promise<{ chatId: number }> { let chatId = opts.chatId; let isFirstTurn = false; @@ -629,6 +642,7 @@ export async function sendMessage(opts: { incomingAttachments: opts.incomingAttachments, prePersistedAttachments: opts.prePersistedAttachments, inlineSecrets: opts.inlineSecrets, + mode: opts.mode, }), ); } catch (err) { diff --git a/src/services/store.ts b/src/services/store.ts index dbcc5fa..b2ade84 100644 --- a/src/services/store.ts +++ b/src/services/store.ts @@ -4,6 +4,7 @@ import { deriveTitle } from './chatTitle'; import type { Chat, ChatKind, + ChatMode, Message, MessageAttachment, Project, @@ -24,6 +25,7 @@ import type { TaskWithSteps, } from '../types'; import type { InlineSecretWire } from './inlineSecrets'; +import { DEFAULT_MODE } from './chatModes'; import { clampLogoColor, clampLogoEmoji } from '../constants/projectLogos'; // ---------- chats ---------- @@ -210,6 +212,8 @@ export const messages = { finishReason: string | null = null, reply?: { replyToMessageId: number; replyQuote: string; replyToRole: string } | null, attachments?: MessageAttachment[] | null, + /** Send mode for user rows. Defaults to 'execute' (back-compat). */ + mode: ChatMode = DEFAULT_MODE, ): Message { const rid = reply?.replyToMessageId ?? null; const rq = reply?.replyQuote ?? null; @@ -218,9 +222,9 @@ export const messages = { attachments && attachments.length > 0 ? JSON.stringify(attachments) : null; const info = db .prepare( - 'INSERT INTO messages (chat_id, role, content, finish_reason, reply_to_message_id, reply_quote, reply_to_role, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO messages (chat_id, role, content, finish_reason, reply_to_message_id, reply_quote, reply_to_role, attachments, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', ) - .run(chatId, role, content, finishReason, rid, rq, rrole, att); + .run(chatId, role, content, finishReason, rid, rq, rrole, att, mode); // chats.updated_at is bumped by the trg_chats_touch_on_message SQLite // trigger; no manual touch() needed here. We keep chats.touch() public // for callers that mutate parents without writing a message (e.g. @@ -767,7 +771,7 @@ export const queuedMessages = { listByChat(chatId: number): QueuedMessage[] { const rows = db .prepare( - 'SELECT id, chat_id, content, reply_to_message_id, reply_quote, reply_to_role, attachments, created_at FROM queued_messages WHERE chat_id = ? ORDER BY position ASC, id ASC', + 'SELECT id, chat_id, content, reply_to_message_id, reply_quote, reply_to_role, attachments, mode, created_at FROM queued_messages WHERE chat_id = ? ORDER BY position ASC, id ASC', ) .all(chatId) as QueuedRow[]; return rows.map(parseQueuedRow); @@ -775,7 +779,7 @@ export const queuedMessages = { get(id: number): QueuedMessage | undefined { const row = db .prepare( - 'SELECT id, chat_id, content, reply_to_message_id, reply_quote, reply_to_role, attachments, created_at FROM queued_messages WHERE id = ?', + 'SELECT id, chat_id, content, reply_to_message_id, reply_quote, reply_to_role, attachments, mode, created_at FROM queued_messages WHERE id = ?', ) .get(id) as QueuedRow | undefined; return row ? parseQueuedRow(row) : undefined; @@ -786,7 +790,7 @@ export const queuedMessages = { | undefined { const row = db .prepare( - 'SELECT id, chat_id, content, reply_to_message_id, reply_quote, reply_to_role, attachments, inline_secrets, created_at FROM queued_messages WHERE id = ?', + 'SELECT id, chat_id, content, reply_to_message_id, reply_quote, reply_to_role, attachments, mode, inline_secrets, created_at FROM queued_messages WHERE id = ?', ) .get(id) as (QueuedRow & { inline_secrets: string | null }) | undefined; if (!row) return undefined; @@ -808,6 +812,7 @@ export const queuedMessages = { replyTo?: { messageId: number; quote: string; role?: string } | null; attachments?: MessageAttachment[] | null; inlineSecrets?: InlineSecretWire[] | null; + mode?: ChatMode; }): QueuedMessage { const trimmed = opts.content.trim(); const hasAttachments = opts.attachments && opts.attachments.length > 0; @@ -826,8 +831,8 @@ export const queuedMessages = { .prepare( `INSERT INTO queued_messages ( chat_id, content, reply_to_message_id, reply_quote, reply_to_role, - attachments, inline_secrets, position - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + attachments, inline_secrets, mode, position + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( opts.chatId, @@ -837,6 +842,7 @@ export const queuedMessages = { opts.replyTo?.role ?? null, attachmentsJson, inlineJson, + opts.mode ?? DEFAULT_MODE, position, ); return this.get(Number(info.lastInsertRowid))!; diff --git a/src/types/index.ts b/src/types/index.ts index 7c74b97..69a15fe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -41,6 +41,8 @@ export interface QueuedMessage { reply_quote: string | null; reply_to_role: string | null; attachments: MessageAttachment[] | null; + /** Selected mode at enqueue time; preserved so the flush sends with it. */ + mode: ChatMode; created_at: string; } @@ -68,6 +70,23 @@ export interface ProjectSecret { export type ChatKind = 'normal' | 'draft' | 'task_execution'; +/** + * How a user message should be handled. + * + * - 'execute' — current behavior: OpenClaw may use tools, files, shell, + * browser, etc. This is the default and the back-compat fallback for any + * message whose `mode` is missing or unrecognized. + * - 'ask' — lightweight question / explanation / planning; OpenClaw is + * asked to answer without heavy agent execution. + * + * Kept as a string union for the two live modes, but the full catalog + * (incl. planned modes like research / image / safe_run) lives in + * `services/chatModes.ts` so new modes can be added without touching this + * type everywhere. Storage columns are plain TEXT, so adding a mode later + * needs no DB migration. + */ +export type ChatMode = 'ask' | 'execute'; + export type TaskStatus = | 'planning' | 'ready' @@ -214,5 +233,11 @@ export interface Message { reply_to_role?: string | null; /** Persisted user-uploaded files (image / doc). `null` row column is parsed to undefined here. */ attachments?: MessageAttachment[] | null; + /** + * How this message was sent. Only meaningful on `user` rows; assistant / + * system rows default to 'execute'. Missing/legacy rows read back as + * 'execute' (DB column default), so old chats stay fully compatible. + */ + mode: ChatMode; created_at: string; } diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 1ba4c9a..b1a9288 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -10,6 +10,7 @@ */ import type { + ChatMode, Message, Project, ProjectFact, @@ -39,6 +40,12 @@ export type ClientMsg = content: string; agent?: string; projectId?: number | null; + /** + * How to handle this message — 'ask' (lightweight, no heavy execution) + * or 'execute' (full agent, current default). Omitted/unknown → server + * treats it as 'execute'. See services/chatModes.ts. + */ + mode?: ChatMode; /** Reply to an existing user/assistant row in this chat (quote ≤240 chars). */ replyTo?: { messageId: number; quote: string; role?: string }; /** diff --git a/views/partials/composer.ejs b/views/partials/composer.ejs index dea5d7c..fd1b81b 100644 --- a/views/partials/composer.ejs +++ b/views/partials/composer.ejs @@ -37,7 +37,58 @@ }) %> <% }); %> +<% + // Mode selector data. Server passes the enabled modes (config-driven) and + // the default. Fall back gracefully if a render path forgot the locals so + // the composer never breaks — the selector just hides. + const _modes = + typeof chatModes !== 'undefined' && Array.isArray(chatModes) ? chatModes : []; + const _defaultMode = + typeof defaultChatMode !== 'undefined' && defaultChatMode ? defaultChatMode : 'execute'; + const _defaultModeDef = + _modes.find(function (m) { return m.id === _defaultMode; }) || _modes[0] || null; +%>
+ <% if (_modes.length > 1 && _defaultModeDef) { %> + +
+ + +
+ <% } %> +
+
+ Loading usage… +
+
+ + <% } else { %> +
+ <% } %> + + <% } %> + + <% if (settingsTab === 'remote-access') { %>
@@ -126,10 +190,12 @@ <% } %>
+ <% } %> +<% if (settingsTab === 'remote-access') { %>
@@ -191,6 +257,7 @@
+<% } %> +<% if (settingsTab === 'remote-access') { %> +<% } %> + +<% if (settingsTab === 'voice-ask') { %> + +<% } %> From c860b9558e77eeb2b07f650671eb5f05e6930475 Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 2 Jun 2026 08:23:43 +0200 Subject: [PATCH 09/99] feat(work-mode): add Work Mode runtime via NanoClaw fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend ChatMode type to include 'work' - Add Work Mode entry in CHAT_MODES catalog (requires OpenRouter key) - Wire chatRunner to route 'work' turns to iclaw-runtime service - Add workRuntime.ts HTTP client (port 7430, SSE events) - Add packages/iclaw-runtime/ — NanoClaw fork stripped to essentials: - No OneCLI dependency (removed) - No Telegram/WhatsApp/Slack/Discord channels - OpenRouter via ANTHROPIC_BASE_URL env passthrough - New iclaw-http channel: HTTP/SSE API for iClaw browser UI - Approval flow simplified for browser-first approval - Mount security module intact (path traversal prevention) - Convert repo to npm workspaces monorepo Attribution: packages/iclaw-runtime/ forked from NanoClaw (MIT) https://github.com/nanocoai/nanoclaw Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 56 + package.json | 3 + .../iclaw-runtime/container/.dockerignore | 2 + packages/iclaw-runtime/container/CLAUDE.md | 21 + packages/iclaw-runtime/container/Dockerfile | 132 +++ .../container/agent-runner/bun.lock | 250 ++++ .../container/agent-runner/package.json | 23 + .../agent-runner/scripts/sdk-signal-probe.ts | 169 +++ .../container/agent-runner/src/cli/ncl.ts | 254 ++++ .../agent-runner/src/compact-instructions.ts | 34 + .../container/agent-runner/src/config.ts | 59 + .../agent-runner/src/current-batch.ts | 29 + .../agent-runner/src/db/connection.ts | 270 +++++ .../container/agent-runner/src/db/index.ts | 20 + .../agent-runner/src/db/messages-in.ts | 166 +++ .../agent-runner/src/db/messages-out.ts | 143 +++ .../agent-runner/src/db/session-routing.ts | 30 + .../agent-runner/src/db/session-state.test.ts | 100 ++ .../agent-runner/src/db/session-state.ts | 79 ++ .../agent-runner/src/destinations.test.ts | 63 + .../agent-runner/src/destinations.ts | 130 +++ .../agent-runner/src/formatter.test.ts | 196 ++++ .../container/agent-runner/src/formatter.ts | 278 +++++ .../container/agent-runner/src/index.ts | 109 ++ .../agent-runner/src/integration.test.ts | 464 ++++++++ .../src/mcp-tools/agents.instructions.md | 26 + .../agent-runner/src/mcp-tools/agents.ts | 66 ++ .../src/mcp-tools/cli.instructions.md | 83 ++ .../src/mcp-tools/core.instructions.md | 27 + .../agent-runner/src/mcp-tools/core.test.ts | 50 + .../agent-runner/src/mcp-tools/core.ts | 263 +++++ .../agent-runner/src/mcp-tools/index.ts | 22 + .../src/mcp-tools/interactive.instructions.md | 22 + .../agent-runner/src/mcp-tools/interactive.ts | 169 +++ .../src/mcp-tools/scheduling.instructions.md | 40 + .../agent-runner/src/mcp-tools/scheduling.ts | 302 +++++ .../src/mcp-tools/self-mod.instructions.md | 25 + .../agent-runner/src/mcp-tools/self-mod.ts | 120 ++ .../agent-runner/src/mcp-tools/server.ts | 54 + .../agent-runner/src/mcp-tools/types.ts | 6 + .../agent-runner/src/poll-loop.test.ts | 397 +++++++ .../container/agent-runner/src/poll-loop.ts | 595 ++++++++++ .../src/providers/claude.rotate.test.ts | 89 ++ .../agent-runner/src/providers/claude.ts | 473 ++++++++ .../src/providers/factory.test.ts | 19 + .../agent-runner/src/providers/factory.ts | 13 + .../agent-runner/src/providers/index.ts | 6 + .../agent-runner/src/providers/mock.ts | 77 ++ .../src/providers/provider-registry.ts | 33 + .../agent-runner/src/providers/types.ts | 107 ++ .../src/scheduling/task-script.ts | 121 ++ .../agent-runner/src/timezone.test.ts | 93 ++ .../container/agent-runner/src/timezone.ts | 107 ++ .../agent-runner/src/upload-trace.test.ts | 84 ++ .../agent-runner/src/upload-trace.ts | 142 +++ .../container/agent-runner/tsconfig.json | 14 + packages/iclaw-runtime/container/build.sh | 45 + .../iclaw-runtime/container/entrypoint.sh | 16 + .../container/skills/agent-browser/SKILL.md | 159 +++ .../skills/frontend-engineer/SKILL.md | 157 +++ .../container/skills/onecli-gateway/SKILL.md | 85 ++ .../skills/onecli-gateway/instructions.md | 7 + .../container/skills/self-customize/SKILL.md | 87 ++ .../container/skills/vercel-cli/SKILL.md | 123 ++ .../container/skills/welcome/SKILL.md | 85 ++ packages/iclaw-runtime/package.json | 28 + .../src/attachment-naming.test.ts | 71 ++ .../iclaw-runtime/src/attachment-naming.ts | 69 ++ .../iclaw-runtime/src/attachment-safety.ts | 23 + .../src/backfill-container-configs.ts | 78 ++ .../iclaw-runtime/src/channels/adapter.ts | 179 +++ .../src/channels/ask-question.ts | 46 + .../src/channels/channel-registry.test.ts | 235 ++++ .../src/channels/channel-registry.ts | 107 ++ packages/iclaw-runtime/src/channels/cli.ts | 276 +++++ .../iclaw-runtime/src/channels/iclaw-http.ts | 232 ++++ packages/iclaw-runtime/src/channels/index.ts | 9 + .../iclaw-runtime/src/circuit-breaker.test.ts | 197 ++++ packages/iclaw-runtime/src/circuit-breaker.ts | 84 ++ .../iclaw-runtime/src/claude-md-compose.ts | 211 ++++ packages/iclaw-runtime/src/cli/client.ts | 112 ++ .../iclaw-runtime/src/cli/commands/help.ts | 137 +++ .../iclaw-runtime/src/cli/commands/index.ts | 10 + packages/iclaw-runtime/src/cli/crud.ts | 299 +++++ .../iclaw-runtime/src/cli/delivery-action.ts | 59 + .../iclaw-runtime/src/cli/dispatch.test.ts | 514 ++++++++ packages/iclaw-runtime/src/cli/dispatch.ts | 199 ++++ packages/iclaw-runtime/src/cli/format.ts | 52 + packages/iclaw-runtime/src/cli/frame.ts | 45 + packages/iclaw-runtime/src/cli/registry.ts | 45 + .../src/cli/resources/approvals.ts | 53 + .../src/cli/resources/destinations.test.ts | 147 +++ .../src/cli/resources/dropped-messages.ts | 28 + .../src/cli/resources/groups.test.ts | 220 ++++ .../iclaw-runtime/src/cli/resources/groups.ts | 373 ++++++ .../iclaw-runtime/src/cli/resources/index.ts | 15 + .../src/cli/resources/members.ts | 66 ++ .../src/cli/resources/messaging-groups.ts | 58 + .../iclaw-runtime/src/cli/resources/roles.ts | 67 ++ .../src/cli/resources/sessions.ts | 46 + .../src/cli/resources/user-dms.ts | 21 + .../iclaw-runtime/src/cli/resources/users.ts | 35 + .../src/cli/resources/wirings.ts | 70 ++ .../iclaw-runtime/src/cli/socket-client.ts | 63 + .../iclaw-runtime/src/cli/socket-server.ts | 111 ++ .../src/cli/transport-errors.test.ts | 31 + .../iclaw-runtime/src/cli/transport-errors.ts | 19 + packages/iclaw-runtime/src/cli/transport.ts | 10 + packages/iclaw-runtime/src/command-gate.ts | 63 + packages/iclaw-runtime/src/config.ts | 50 + .../iclaw-runtime/src/container-config.ts | 89 ++ .../src/container-restart.test.ts | 151 +++ .../iclaw-runtime/src/container-restart.ts | 59 + .../src/container-runner.test.ts | 27 + .../iclaw-runtime/src/container-runner.ts | 492 ++++++++ .../src/container-runtime.test.ts | 160 +++ .../iclaw-runtime/src/container-runtime.ts | 90 ++ packages/iclaw-runtime/src/db/agent-groups.ts | 44 + packages/iclaw-runtime/src/db/connection.ts | 48 + .../iclaw-runtime/src/db/container-configs.ts | 97 ++ packages/iclaw-runtime/src/db/db-v2.test.ts | 430 +++++++ .../iclaw-runtime/src/db/dropped-messages.ts | 44 + packages/iclaw-runtime/src/db/index.ts | 53 + .../iclaw-runtime/src/db/messaging-groups.ts | 195 ++++ .../src/db/migrations/001-initial.ts | 112 ++ .../src/db/migrations/002-chat-sdk-state.ts | 36 + .../src/db/migrations/008-dropped-messages.ts | 27 + .../009-drop-pending-credentials.ts | 13 + .../src/db/migrations/010-engage-modes.ts | 103 ++ .../011-pending-sender-approvals.ts | 40 + .../db/migrations/012-channel-registration.ts | 48 + .../013-approval-render-metadata.ts | 27 + .../db/migrations/014-container-configs.ts | 26 + .../src/db/migrations/015-cli-scope.ts | 10 + .../iclaw-runtime/src/db/migrations/index.ts | 77 ++ .../module-agent-to-agent-destinations.ts | 84 ++ .../module-approvals-pending-approvals.ts | 44 + .../module-approvals-title-options.ts | 40 + packages/iclaw-runtime/src/db/schema.ts | 266 +++++ .../iclaw-runtime/src/db/session-db.test.ts | 94 ++ packages/iclaw-runtime/src/db/session-db.ts | 370 ++++++ packages/iclaw-runtime/src/db/sessions.ts | 227 ++++ packages/iclaw-runtime/src/delivery.test.ts | 273 +++++ packages/iclaw-runtime/src/delivery.ts | 422 +++++++ packages/iclaw-runtime/src/env.ts | 42 + .../iclaw-runtime/src/group-folder.test.ts | 35 + packages/iclaw-runtime/src/group-folder.ts | 44 + packages/iclaw-runtime/src/group-init.ts | 133 +++ packages/iclaw-runtime/src/host-core.test.ts | 1032 +++++++++++++++++ packages/iclaw-runtime/src/host-sweep.test.ts | 336 ++++++ packages/iclaw-runtime/src/host-sweep.ts | 324 ++++++ packages/iclaw-runtime/src/index.ts | 167 +++ packages/iclaw-runtime/src/install-slug.ts | 33 + packages/iclaw-runtime/src/log.ts | 64 + .../src/modules/approvals/agent.md | 45 + .../src/modules/approvals/index.ts | 26 + .../src/modules/approvals/primitive.ts | 113 ++ .../src/modules/approvals/project.md | 30 + .../src/modules/approvals/response-handler.ts | 92 ++ packages/iclaw-runtime/src/modules/index.ts | 20 + .../src/modules/interactive/agent.md | 21 + .../src/modules/interactive/index.ts | 59 + .../src/modules/interactive/project.md | 12 + .../src/modules/mount-security/index.ts | 389 +++++++ .../iclaw-runtime/src/modules/typing/index.ts | 165 +++ packages/iclaw-runtime/src/platform-id.ts | 25 + .../iclaw-runtime/src/providers/claude.ts | 28 + packages/iclaw-runtime/src/providers/index.ts | 6 + .../providers/provider-container-registry.ts | 58 + .../iclaw-runtime/src/response-registry.ts | 45 + packages/iclaw-runtime/src/router.ts | 496 ++++++++ packages/iclaw-runtime/src/session-manager.ts | 543 +++++++++ packages/iclaw-runtime/src/timezone.test.ts | 64 + packages/iclaw-runtime/src/timezone.ts | 37 + packages/iclaw-runtime/src/types.ts | 209 ++++ packages/iclaw-runtime/tsconfig.json | 20 + src/services/chatModes.ts | 11 +- src/services/chatRunner.ts | 61 + src/services/workRuntime.ts | 140 +++ src/types/index.ts | 2 +- 180 files changed, 21595 insertions(+), 2 deletions(-) create mode 100644 packages/iclaw-runtime/container/.dockerignore create mode 100644 packages/iclaw-runtime/container/CLAUDE.md create mode 100644 packages/iclaw-runtime/container/Dockerfile create mode 100644 packages/iclaw-runtime/container/agent-runner/bun.lock create mode 100644 packages/iclaw-runtime/container/agent-runner/package.json create mode 100644 packages/iclaw-runtime/container/agent-runner/scripts/sdk-signal-probe.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/cli/ncl.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/compact-instructions.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/config.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/current-batch.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/db/connection.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/db/index.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/db/messages-in.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/db/messages-out.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/db/session-routing.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/db/session-state.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/db/session-state.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/destinations.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/destinations.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/formatter.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/formatter.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/index.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/integration.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/agents.instructions.md create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/agents.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/cli.instructions.md create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.instructions.md create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/index.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/interactive.instructions.md create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/interactive.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/scheduling.instructions.md create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/scheduling.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/self-mod.instructions.md create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/self-mod.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/server.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/mcp-tools/types.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/poll-loop.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/poll-loop.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/providers/claude.rotate.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/providers/claude.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/providers/factory.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/providers/factory.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/providers/index.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/providers/mock.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/providers/provider-registry.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/providers/types.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/scheduling/task-script.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/timezone.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/timezone.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/upload-trace.test.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/src/upload-trace.ts create mode 100644 packages/iclaw-runtime/container/agent-runner/tsconfig.json create mode 100755 packages/iclaw-runtime/container/build.sh create mode 100755 packages/iclaw-runtime/container/entrypoint.sh create mode 100644 packages/iclaw-runtime/container/skills/agent-browser/SKILL.md create mode 100644 packages/iclaw-runtime/container/skills/frontend-engineer/SKILL.md create mode 100644 packages/iclaw-runtime/container/skills/onecli-gateway/SKILL.md create mode 100644 packages/iclaw-runtime/container/skills/onecli-gateway/instructions.md create mode 100644 packages/iclaw-runtime/container/skills/self-customize/SKILL.md create mode 100644 packages/iclaw-runtime/container/skills/vercel-cli/SKILL.md create mode 100644 packages/iclaw-runtime/container/skills/welcome/SKILL.md create mode 100644 packages/iclaw-runtime/package.json create mode 100644 packages/iclaw-runtime/src/attachment-naming.test.ts create mode 100644 packages/iclaw-runtime/src/attachment-naming.ts create mode 100644 packages/iclaw-runtime/src/attachment-safety.ts create mode 100644 packages/iclaw-runtime/src/backfill-container-configs.ts create mode 100644 packages/iclaw-runtime/src/channels/adapter.ts create mode 100644 packages/iclaw-runtime/src/channels/ask-question.ts create mode 100644 packages/iclaw-runtime/src/channels/channel-registry.test.ts create mode 100644 packages/iclaw-runtime/src/channels/channel-registry.ts create mode 100644 packages/iclaw-runtime/src/channels/cli.ts create mode 100644 packages/iclaw-runtime/src/channels/iclaw-http.ts create mode 100644 packages/iclaw-runtime/src/channels/index.ts create mode 100644 packages/iclaw-runtime/src/circuit-breaker.test.ts create mode 100644 packages/iclaw-runtime/src/circuit-breaker.ts create mode 100644 packages/iclaw-runtime/src/claude-md-compose.ts create mode 100644 packages/iclaw-runtime/src/cli/client.ts create mode 100644 packages/iclaw-runtime/src/cli/commands/help.ts create mode 100644 packages/iclaw-runtime/src/cli/commands/index.ts create mode 100644 packages/iclaw-runtime/src/cli/crud.ts create mode 100644 packages/iclaw-runtime/src/cli/delivery-action.ts create mode 100644 packages/iclaw-runtime/src/cli/dispatch.test.ts create mode 100644 packages/iclaw-runtime/src/cli/dispatch.ts create mode 100644 packages/iclaw-runtime/src/cli/format.ts create mode 100644 packages/iclaw-runtime/src/cli/frame.ts create mode 100644 packages/iclaw-runtime/src/cli/registry.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/approvals.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/destinations.test.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/dropped-messages.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/groups.test.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/groups.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/index.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/members.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/messaging-groups.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/roles.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/sessions.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/user-dms.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/users.ts create mode 100644 packages/iclaw-runtime/src/cli/resources/wirings.ts create mode 100644 packages/iclaw-runtime/src/cli/socket-client.ts create mode 100644 packages/iclaw-runtime/src/cli/socket-server.ts create mode 100644 packages/iclaw-runtime/src/cli/transport-errors.test.ts create mode 100644 packages/iclaw-runtime/src/cli/transport-errors.ts create mode 100644 packages/iclaw-runtime/src/cli/transport.ts create mode 100644 packages/iclaw-runtime/src/command-gate.ts create mode 100644 packages/iclaw-runtime/src/config.ts create mode 100644 packages/iclaw-runtime/src/container-config.ts create mode 100644 packages/iclaw-runtime/src/container-restart.test.ts create mode 100644 packages/iclaw-runtime/src/container-restart.ts create mode 100644 packages/iclaw-runtime/src/container-runner.test.ts create mode 100644 packages/iclaw-runtime/src/container-runner.ts create mode 100644 packages/iclaw-runtime/src/container-runtime.test.ts create mode 100644 packages/iclaw-runtime/src/container-runtime.ts create mode 100644 packages/iclaw-runtime/src/db/agent-groups.ts create mode 100644 packages/iclaw-runtime/src/db/connection.ts create mode 100644 packages/iclaw-runtime/src/db/container-configs.ts create mode 100644 packages/iclaw-runtime/src/db/db-v2.test.ts create mode 100644 packages/iclaw-runtime/src/db/dropped-messages.ts create mode 100644 packages/iclaw-runtime/src/db/index.ts create mode 100644 packages/iclaw-runtime/src/db/messaging-groups.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/001-initial.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/002-chat-sdk-state.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/008-dropped-messages.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/009-drop-pending-credentials.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/010-engage-modes.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/011-pending-sender-approvals.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/012-channel-registration.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/013-approval-render-metadata.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/014-container-configs.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/015-cli-scope.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/index.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/module-agent-to-agent-destinations.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/module-approvals-pending-approvals.ts create mode 100644 packages/iclaw-runtime/src/db/migrations/module-approvals-title-options.ts create mode 100644 packages/iclaw-runtime/src/db/schema.ts create mode 100644 packages/iclaw-runtime/src/db/session-db.test.ts create mode 100644 packages/iclaw-runtime/src/db/session-db.ts create mode 100644 packages/iclaw-runtime/src/db/sessions.ts create mode 100644 packages/iclaw-runtime/src/delivery.test.ts create mode 100644 packages/iclaw-runtime/src/delivery.ts create mode 100644 packages/iclaw-runtime/src/env.ts create mode 100644 packages/iclaw-runtime/src/group-folder.test.ts create mode 100644 packages/iclaw-runtime/src/group-folder.ts create mode 100644 packages/iclaw-runtime/src/group-init.ts create mode 100644 packages/iclaw-runtime/src/host-core.test.ts create mode 100644 packages/iclaw-runtime/src/host-sweep.test.ts create mode 100644 packages/iclaw-runtime/src/host-sweep.ts create mode 100644 packages/iclaw-runtime/src/index.ts create mode 100644 packages/iclaw-runtime/src/install-slug.ts create mode 100644 packages/iclaw-runtime/src/log.ts create mode 100644 packages/iclaw-runtime/src/modules/approvals/agent.md create mode 100644 packages/iclaw-runtime/src/modules/approvals/index.ts create mode 100644 packages/iclaw-runtime/src/modules/approvals/primitive.ts create mode 100644 packages/iclaw-runtime/src/modules/approvals/project.md create mode 100644 packages/iclaw-runtime/src/modules/approvals/response-handler.ts create mode 100644 packages/iclaw-runtime/src/modules/index.ts create mode 100644 packages/iclaw-runtime/src/modules/interactive/agent.md create mode 100644 packages/iclaw-runtime/src/modules/interactive/index.ts create mode 100644 packages/iclaw-runtime/src/modules/interactive/project.md create mode 100644 packages/iclaw-runtime/src/modules/mount-security/index.ts create mode 100644 packages/iclaw-runtime/src/modules/typing/index.ts create mode 100644 packages/iclaw-runtime/src/platform-id.ts create mode 100644 packages/iclaw-runtime/src/providers/claude.ts create mode 100644 packages/iclaw-runtime/src/providers/index.ts create mode 100644 packages/iclaw-runtime/src/providers/provider-container-registry.ts create mode 100644 packages/iclaw-runtime/src/response-registry.ts create mode 100644 packages/iclaw-runtime/src/router.ts create mode 100644 packages/iclaw-runtime/src/session-manager.ts create mode 100644 packages/iclaw-runtime/src/timezone.test.ts create mode 100644 packages/iclaw-runtime/src/timezone.ts create mode 100644 packages/iclaw-runtime/src/types.ts create mode 100644 packages/iclaw-runtime/tsconfig.json create mode 100644 src/services/workRuntime.ts diff --git a/package-lock.json b/package-lock.json index 976c7b2..271b908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@iclawapp/iclaw", "version": "0.2.2", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@noble/hashes": "^2.2.0", "@serenity-kit/opaque": "^1.1.0", @@ -573,6 +576,10 @@ "node": ">=18" } }, + "node_modules/@iclaw/runtime": { + "resolved": "packages/iclaw-runtime", + "link": true + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1601,6 +1608,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cron-parser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", + "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", + "license": "MIT", + "dependencies": { + "luxon": "^3.7.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2332,6 +2351,15 @@ "dev": true, "license": "MIT" }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2605,6 +2633,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3828,6 +3865,25 @@ "optional": true } } + }, + "packages/iclaw-runtime": { + "name": "@iclaw/runtime", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "better-sqlite3": "^11.3.0", + "cron-parser": "5.5.0", + "kleur": "^4.1.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20" + } } } } diff --git a/package.json b/package.json index d3e325f..d0ab936 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "engines": { "node": ">=20" }, + "workspaces": [ + "packages/*" + ], "bin": { "iclaw": "bin/iclaw.js" }, diff --git a/packages/iclaw-runtime/container/.dockerignore b/packages/iclaw-runtime/container/.dockerignore new file mode 100644 index 0000000..598ce19 --- /dev/null +++ b/packages/iclaw-runtime/container/.dockerignore @@ -0,0 +1,2 @@ +agent-runner/node_modules +agent-runner/dist diff --git a/packages/iclaw-runtime/container/CLAUDE.md b/packages/iclaw-runtime/container/CLAUDE.md new file mode 100644 index 0000000..baf911a --- /dev/null +++ b/packages/iclaw-runtime/container/CLAUDE.md @@ -0,0 +1,21 @@ +You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn. + +## Communication + +Be concise — every message costs the reader's attention. Prefer outcomes over play-by-play; when the work is done, the final message should be about the result, not a transcript of what you did. + +## Workspace + +Files you create are saved in `/workspace/agent/`. Use this for notes, research, or anything that should persist across turns in this group. + +The file `CLAUDE.local.md` in your workspace is your per-group memory. Record things there that you'll want to remember in future sessions — user preferences, project context, recurring facts. Keep entries short and structured. + +## Memory + +When the user shares any substantive information with you, it must be stored somewhere you can retrieve it when relevant. If it's information that is pertinent to every single conversation turn it should be put into CLAUDE.local.md. Otherwise, create a system for storing the information depending on its type - e.g. create a file of people that the user mentions so you can keep track or a file of projects. For every file you create, add a concise reference in your CLAUDE.local.md so you'll be able to find it in future conversations. + +A core part of your job and the main thing that defines how useful you are to the user is how well you do in creating these systems for organizing information. These are your systems that help you do your job well. Evolve them over time as needed. + +## Conversation history + +The `conversations/` folder in your workspace holds searchable transcripts of past sessions with this group. Use it to recall prior context when a request references something that happened before. For structured long-lived data, prefer dedicated files (`customers.md`, `preferences.md`, etc.); split any file over ~500 lines into a folder with an index. diff --git a/packages/iclaw-runtime/container/Dockerfile b/packages/iclaw-runtime/container/Dockerfile new file mode 100644 index 0000000..a0ea0d0 --- /dev/null +++ b/packages/iclaw-runtime/container/Dockerfile @@ -0,0 +1,132 @@ +# syntax=docker/dockerfile:1.7 +# NanoClaw Agent Container +# Runs Claude Agent SDK in isolated Linux VM with browser automation. +# +# Runtime split: +# - agent-runner (our TypeScript code): Bun, mounted RO at /app/src by host +# - globally-installed Node CLIs (claude-code, agent-browser, vercel): pnpm + Node +# +# Source is never baked in — /app/src is provided by a shared read-only +# bind mount at runtime (see src/container-runner.ts). Source-only changes +# never require an image rebuild. + +FROM node:22-slim + +# ---- Build-time arguments ---------------------------------------------------- +# CJK fonts add ~200MB. Opt in only if you render Chinese/Japanese/Korean text. +ARG INSTALL_CJK_FONTS=false + +# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs +# mean every rebuild silently picks up the latest and can break in lockstep +# across all users. +ARG CLAUDE_CODE_VERSION=2.1.154 +ARG AGENT_BROWSER_VERSION=latest +ARG VERCEL_VERSION=52.2.1 +ARG BUN_VERSION=1.3.12 + +# ---- System dependencies ----------------------------------------------------- +# tini: correct PID 1 / signal forwarding so outbound.db writes finalize on +# SIGTERM instead of being orphaned by the shell entrypoint. +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + fonts-liberation \ + fonts-noto-color-emoji \ + libgbm1 \ + libnss3 \ + libatk-bridge2.0-0 \ + libgtk-3-0 \ + libx11-xcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libasound2 \ + libpangocairo-1.0-0 \ + libcups2 \ + libdrm2 \ + libxshmfence1 \ + ca-certificates \ + curl \ + git \ + tini \ + unzip \ + && if [ "$INSTALL_CJK_FONTS" = "true" ]; then \ + apt-get install -y --no-install-recommends fonts-noto-cjk; \ + fi \ + && rm -rf /var/lib/apt/lists/* + +# Chromium path for agent-browser / Playwright consumers +ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium +ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium +# Belt-and-braces: prevent Playwright's postinstall from downloading its own +# ~300MB Chromium. We've already installed the system one above. +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + +# ---- Bun runtime ------------------------------------------------------------- +# Install via the official script (handles multi-arch detection), then move +# the binary to /usr/local/bin so the non-root `node` user can execute it. +RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \ + install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun && \ + rm -rf /root/.bun + +# ---- agent-runner deps ------------------------------------------------------- +# Deps are cached independently of CLI versions. Source is NOT baked in — +# it's provided by the shared RO mount at runtime. +WORKDIR /app + +COPY agent-runner/package.json agent-runner/bun.lock ./ + +RUN --mount=type=cache,target=/root/.bun/install/cache \ + bun install --frozen-lockfile + +# ---- pnpm + global Node CLIs ------------------------------------------------- +# Most stable first, most frequently bumped last. Bumping claude-code +# (the most common change) only invalidates one layer. +# +# only-built-dependencies gates pnpm's supply-chain policy: +# - agent-browser has a postinstall build step. +# - @anthropic-ai/claude-code's postinstall downloads the native Claude +# binary (linux-arm64 variant on our image). Without the allowlist +# the SDK fails at spawn time with "native binary not found". +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +# Pin pnpm to match the host (package.json packageManager). pnpm 11 stopped +# honoring `only-built-dependencies[]=` in .npmrc for global installs, which +# silently skips claude-code's native-binary postinstall and agent-browser's +# bin chmod — the agent then crashes at runtime with "native binary not +# installed". Keep this in lockstep with package.json's `packageManager`. +ARG PNPM_VERSION=10.33.0 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate + +RUN --mount=type=cache,target=/root/.cache/pnpm \ + echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ + echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \ + pnpm install -g "vercel@${VERCEL_VERSION}" + +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" + +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" + +# ---- ncl CLI wrapper ---------------------------------------------------------- +# Actual script lives in the mounted source at /app/src/cli/ncl.ts. +RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \ + chmod +x /usr/local/bin/ncl + +# ---- Entrypoint -------------------------------------------------------------- +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# ---- Workspace + permissions ------------------------------------------------- +RUN mkdir -p /workspace/group /workspace/extra && \ + chown -R node:node /workspace && \ + chmod 777 /home/node + +USER node +WORKDIR /workspace/group + +# tini is PID 1, reaps zombies, forwards signals cleanly. entrypoint.sh does +# `exec bun ...` so bun runs as tini's direct child. +ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] diff --git a/packages/iclaw-runtime/container/agent-runner/bun.lock b/packages/iclaw-runtime/container/agent-runner/bun.lock new file mode 100644 index 0000000..f08ec3c --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/bun.lock @@ -0,0 +1,250 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "nanoclaw-agent-runner", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.154", + "@anthropic-ai/sdk": "^0.100.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "cron-parser": "^5.0.0", + "zod": "^4.0.0", + }, + "devDependencies": { + "@types/bun": "^1.1.0", + "@types/node": "^22.10.7", + "typescript": "^5.7.3", + }, + }, + }, + "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.154", "", { "os": "darwin", "cpu": "x64" }, "sha512-5BgWEueP+cqoctWjZYhCbyltuaV/N2DmKDXD3/69cKaVmJp8XL9OCzlq/HEirA/+Ssjskx6hDUBaOcpuZ3iwQA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRkW4SBL3W7zQvKscCIfIGlmoeuTbMV6dXFbPdmpRGvmYZIs79RpzO6xrGBnnhmm+B7znQ9oHAnffi/2FBgJbA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-o2bCQN4Xn3UqCLErC5m4T7u0yYArJYmgFCUFnA6K96DdW2RERvx+gTKXxWuHEBkDO+eMoHLHLxk0u2jGES00Ng=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-GpiFF8Ez6PbM3m0gqtCo/FKM346qyRdP7VhbmJzdnbNKTiiUZ66vDQyEUPZPCG24ZkrG4m96KpRIUwY08rHiNg=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-zA7S8Lm6O4QBsUpbhiOht8BgiXHOBBFUIo8ZLK6r5wAatK3Q44syWVxICeyCnR6wqfnkf3cugCw27ycS6vVgaA=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.154", "", { "os": "win32", "cpu": "arm64" }, "sha512-cDW1YFbU/PJFlrGXhlAGcbkXt80sEO6WtnH8nN8YHXLn5NWduy2q7o/qC6i8XozgvRGf6t/eMoH7IasGIEDhDw=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.154", "", { "os": "win32", "cpu": "x64" }, "sha512-tSKaIIpL72OPg3WfzZTCIl8OJgcbq4qieu8/fDWjsdeQuari9gQMIuEflFphk9HqNsxpSmDqKi8Sm5mW2V566Q=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cron-parser": ["cron-parser@5.5.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + } +} diff --git a/packages/iclaw-runtime/container/agent-runner/package.json b/packages/iclaw-runtime/container/agent-runner/package.json new file mode 100644 index 0000000..f6e42f6 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/package.json @@ -0,0 +1,23 @@ +{ + "name": "nanoclaw-agent-runner", + "version": "1.0.0", + "type": "module", + "description": "Container-side agent runner for NanoClaw", + "scripts": { + "start": "bun src/index.ts", + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.154", + "@anthropic-ai/sdk": "^0.100.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "cron-parser": "^5.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/bun": "^1.1.0", + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } +} diff --git a/packages/iclaw-runtime/container/agent-runner/scripts/sdk-signal-probe.ts b/packages/iclaw-runtime/container/agent-runner/scripts/sdk-signal-probe.ts new file mode 100644 index 0000000..a4a3c98 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/scripts/sdk-signal-probe.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env bun +/** + * SDK signal probe: run a prompt, log every signal the Agent SDK emits — + * async-iterator events + hook callbacks + CLI stderr — with absolute + * and relative timing. + * + * Usage: + * bun run scripts/sdk-signal-probe.ts "" # simple string mode + * bun run scripts/sdk-signal-probe.ts --stream "" # streaming-input mode + * bun run scripts/sdk-signal-probe.ts --stream "

" \ + * --push "5000:" --push "15000:" --timeout 60000 # multi-push + * + * Streaming mode (`--stream`) passes an AsyncIterable prompt to `query()`, + * which keeps the CLI subprocess alive past the first result (per SDK + * deep dive). Required for post-result pushes, agent teams, background + * task notifications. + */ +import { query } from '@anthropic-ai/claude-agent-sdk'; + +const args = process.argv.slice(2); +const prompts: string[] = []; +const pushes: Array<{ atMs: number; text: string }> = []; +let streamMode = false; +let timeoutMs: number | undefined; + +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--stream') streamMode = true; + else if (a === '--push') { + const val = args[++i] ?? ''; + const ix = val.indexOf(':'); + if (ix === -1) throw new Error(`bad --push (want MS:text): ${val}`); + pushes.push({ atMs: parseInt(val.slice(0, ix), 10), text: val.slice(ix + 1) }); + } else if (a === '--timeout') timeoutMs = parseInt(args[++i] ?? '0', 10); + else if (a === '--prompt') prompts.push(args[++i] ?? ''); + else prompts.push(a); +} + +const prompt = prompts.join(' '); +if (!prompt) { + console.error('usage: sdk-signal-probe.ts [--stream] "" [--push MS:]... [--timeout MS]'); + process.exit(1); +} + +const T0 = Date.now(); +let LAST = T0; + +function log(source: string, type: string, payload: unknown = {}): void { + const now = Date.now(); + const entry = { t_ms: now - T0, d_ms: now - LAST, source, type, payload }; + LAST = now; + console.log(JSON.stringify(entry)); +} + +function hookLogger(eventName: string) { + return async (input: unknown, toolUseID: string | undefined): Promise => { + log('hook', eventName, { toolUseID, input }); + // Stuck-tool simulation: if env flag is set and this is a PreToolUse for Bash, + // never resolve — simulates a tool that hangs forever. + if (process.env.PROBE_HANG === 'true' && eventName === 'PreToolUse') { + const toolName = (input as any)?.tool_name ?? (input as any)?.name; + if (toolName === 'Bash') { + log('meta', 'pre_tool_use_hanging', { toolUseID, toolName }); + await new Promise(() => { + /* never resolves */ + }); + } + } + return { continue: true }; + }; +} + +const HOOK_EVENTS = [ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'Notification', + 'UserPromptSubmit', + 'SessionStart', + 'SessionEnd', + 'Stop', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PermissionRequest', +] as const; + +const hooks: Record = {}; +for (const ev of HOOK_EVENTS) hooks[ev] = [{ hooks: [hookLogger(ev)] }]; + +// Build prompt — string (single-turn) or AsyncIterable (streaming-input) +let promptInput: any; + +if (streamMode) { + const sessionId = `probe-${Date.now()}`; + async function* streamPrompt() { + // Initial user message + yield { + type: 'user' as const, + message: { role: 'user' as const, content: prompt }, + parent_tool_use_id: null, + session_id: sessionId, + }; + // Schedule subsequent pushes + const startT = Date.now(); + const sorted = [...pushes].sort((a, b) => a.atMs - b.atMs); + for (const p of sorted) { + const waitMs = Math.max(0, p.atMs - (Date.now() - startT)); + if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs)); + log('meta', 'push_message', { atMs: p.atMs, text: p.text }); + yield { + type: 'user' as const, + message: { role: 'user' as const, content: p.text }, + parent_tool_use_id: null, + session_id: sessionId, + }; + } + // Keep stream open for tail events; iterator ends when we return + // (no more work expected). For post-result-idle scenarios, wait here. + await new Promise((r) => setTimeout(r, 5000)); + } + promptInput = streamPrompt(); +} else { + promptInput = prompt; +} + +log('meta', 'probe_start', { prompt, streamMode, pushes, timeoutMs }); + +const q = query({ + prompt: promptInput, + options: { + includePartialMessages: true, + hooks: hooks as any, + stderr: (data: string) => log('stderr', 'chunk', { data }), + settingSources: [], + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + }, +}); + +// Absolute time cap — exit cleanly so the log flushes +if (timeoutMs) { + setTimeout(() => { + log('meta', 'timeout_hit', { timeoutMs }); + setTimeout(() => process.exit(0), 250); + }, timeoutMs); +} + +try { + for await (const event of q) { + const snapshot: any = { ...event }; + try { + const raw = JSON.stringify(snapshot); + if (raw.length > 2000) { + snapshot._truncated_bytes = raw.length; + if (snapshot.message?.content) { + const c = JSON.stringify(snapshot.message.content); + snapshot.message = { ...snapshot.message, content: c.slice(0, 500) + `…<+${c.length - 500}b>` }; + } + } + } catch { + /* best-effort */ + } + log('event', snapshot.type ?? 'unknown', { subtype: snapshot.subtype, event: snapshot }); + } + log('meta', 'iterator_done'); +} catch (err: any) { + log('meta', 'iterator_error', { message: err?.message, stack: err?.stack?.split('\n').slice(0, 5) }); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/cli/ncl.ts b/packages/iclaw-runtime/container/agent-runner/src/cli/ncl.ts new file mode 100644 index 0000000..c835368 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/cli/ncl.ts @@ -0,0 +1,254 @@ +#!/usr/bin/env bun +/** + * ncl — NanoClaw CLI client (container edition). + * + * Same interface as the host-side `bin/ncl`. Detects that it's inside a + * container (the session DBs exist at /workspace/) and uses a DB transport + * instead of the Unix socket transport. + * + * Writes a cli_request system message to outbound.db, polls inbound.db + * for the response. Self-contained — no imports from agent-runner. + */ +import { Database } from 'bun:sqlite'; + +// --------------------------------------------------------------------------- +// Frame types (mirrors src/cli/frame.ts on the host) +// --------------------------------------------------------------------------- + +type RequestFrame = { + id: string; + command: string; + args: Record; +}; + +type ResponseFrame = + | { id: string; ok: true; data: unknown } + | { id: string; ok: false; error: { code: string; message: string } }; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const INBOUND_DB = '/workspace/inbound.db'; +const OUTBOUND_DB = '/workspace/outbound.db'; + +// --------------------------------------------------------------------------- +// DB transport +// --------------------------------------------------------------------------- + +function generateId(): string { + return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Write a cli_request to outbound.db. + * + * Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq), + * preventing seq collisions with concurrent agent-runner writes. + */ +function writeRequest(req: RequestFrame): void { + const db = new Database(OUTBOUND_DB); + db.exec('PRAGMA journal_mode = DELETE'); + db.exec('PRAGMA busy_timeout = 5000'); + + const inDb = new Database(INBOUND_DB, { readonly: true }); + inDb.exec('PRAGMA busy_timeout = 5000'); + + try { + db.exec('BEGIN IMMEDIATE'); + const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m; + const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; + const max = Math.max(maxOut, maxIn); + const nextSeq = max % 2 === 0 ? max + 1 : max + 2; + + db.prepare( + `INSERT INTO messages_out (id, seq, timestamp, kind, content) + VALUES ($id, $seq, datetime('now'), 'system', $content)`, + ).run({ + $id: req.id, + $seq: nextSeq, + $content: JSON.stringify({ + action: 'cli_request', + requestId: req.id, + command: req.command, + args: req.args, + }), + }); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } finally { + inDb.close(); + db.close(); + } +} + +/** + * Poll inbound.db for a cli_response matching our requestId. + * Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility. + */ +function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const inDb = new Database(INBOUND_DB, { readonly: true }); + inDb.exec('PRAGMA busy_timeout = 5000'); + inDb.exec('PRAGMA mmap_size = 0'); + + try { + const row = inDb + .prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null; + + if (row) { + // Mark as completed via processing_ack so agent-runner skips it + const outDb = new Database(OUTBOUND_DB); + outDb.exec('PRAGMA journal_mode = DELETE'); + outDb.exec('PRAGMA busy_timeout = 5000'); + outDb + .prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))", + ) + .run(row.id); + outDb.close(); + + const parsed = JSON.parse(row.content); + return parsed.frame as ResponseFrame; + } + } finally { + inDb.close(); + } + + Bun.sleepSync(500); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Arg parsing (mirrors host-side client.ts) +// --------------------------------------------------------------------------- + +function parseArgv(argv: string[]): { + command: string; + args: Record; + json: boolean; +} { + const positional: string[] = []; + const args: Record = {}; + let json = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json') { + json = true; + continue; + } + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i++; + } + continue; + } + positional.push(a); + } + + if (positional.length === 0) { + process.stderr.write('ncl: missing command\n'); + printUsage(); + process.exit(2); + } + + // Join all positionals with dashes. The dispatcher trims the last + // segment as a target ID if the full name isn't a registered command. + const command = positional.join('-'); + + return { command, args, json }; +} + +function printUsage(): void { + process.stdout.write( + ['Usage: ncl [--key value ...] [--json]', '', 'Run `ncl help` to list available commands.', ''].join('\n'), + ); +} + +// --------------------------------------------------------------------------- +// Formatting (mirrors src/cli/format.ts on the host) +// --------------------------------------------------------------------------- + +function formatHuman(resp: ResponseFrame): string { + if (!resp.ok) { + return `error (${resp.error.code}): ${resp.error.message}\n`; + } + + const data = resp.data; + if (!Array.isArray(data) || data.length === 0) { + return JSON.stringify(data, null, 2) + '\n'; + } + + const isFlat = data.every( + (r) => + typeof r === 'object' && + r !== null && + !Array.isArray(r) && + Object.values(r as Record).every((v) => typeof v !== 'object' || v === null), + ); + + if (!isFlat) return JSON.stringify(data, null, 2) + '\n'; + + const keys = Object.keys(data[0] as Record); + const widths = keys.map((k) => + Math.max(k.length, ...data.map((r) => String((r as Record)[k] ?? '').length)), + ); + + const header = keys.map((k, i) => k.padEnd(widths[i])).join(' '); + const sep = widths.map((w) => '-'.repeat(w)).join(' '); + const rows = data.map((r) => + keys + .map((k, i) => String((r as Record)[k] ?? '').padEnd(widths[i])) + .join(' '), + ); + + return [header, sep, ...rows, ''].join('\n'); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const argv = process.argv.slice(2); + +if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + printUsage(); + process.exit(0); +} + +const { command, args, json } = parseArgv(argv); +const requestId = generateId(); +const req: RequestFrame = { id: requestId, command, args }; + +writeRequest(req); + +const resp = pollResponse(requestId, 30_000); + +if (!resp) { + process.stderr.write('ncl: command timed out after 30s\n'); + process.exit(2); +} + +if (json) { + process.stdout.write(JSON.stringify(resp, null, 2) + '\n'); +} else { + const output = formatHuman(resp); + if (!resp.ok) { + process.stderr.write(output); + process.exit(1); + } + process.stdout.write(output); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/compact-instructions.ts b/packages/iclaw-runtime/container/agent-runner/src/compact-instructions.ts new file mode 100644 index 0000000..1cbea0d --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/compact-instructions.ts @@ -0,0 +1,34 @@ +/** + * PreCompact hook script — outputs custom compaction instructions to stdout. + * + * Claude Code captures the stdout of PreCompact shell hooks and passes it + * as `customInstructions` to the compaction prompt. This ensures the + * compaction summary preserves message routing context that the agent needs + * to correctly address responses. + * + * Invoked by the PreCompact hook in .claude-shared/settings.json: + * "command": "bun /app/src/compact-instructions.ts" + */ +import { getAllDestinations } from './destinations.js'; + +const destinations = getAllDestinations(); +const names = destinations.map((d) => d.name); + +const instructions = [ + 'Preserve the following in the compaction summary:', + '', + '1. For recent messages, keep the full XML structure including all attributes:', + ' - for chat messages', + ' - for scheduled tasks', + ' - for webhooks', + ' The message content can be summarized if long, but the XML tags and attributes must remain.', + '', + '2. Preserve the chronological message/reply sequence of recent exchanges.', + ' The agent needs to see: who said what, in what order, and from which destination.', + '', + '3. At the END of the compaction summary, include this verbatim reminder:', + ' "You MUST wrap all responses in ... blocks.', + ` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}."`, +]; + +console.log(instructions.join('\n')); diff --git a/packages/iclaw-runtime/container/agent-runner/src/config.ts b/packages/iclaw-runtime/container/agent-runner/src/config.ts new file mode 100644 index 0000000..1546011 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/config.ts @@ -0,0 +1,59 @@ +/** + * Runner config — reads /workspace/agent/container.json at startup. + * + * This file is mounted read-only inside the container. The host writes it; + * the runner only reads. All NanoClaw-specific configuration lives here + * instead of environment variables. + */ +import fs from 'fs'; + +const CONFIG_PATH = '/workspace/agent/container.json'; + +export interface RunnerConfig { + provider: string; + assistantName: string; + groupName: string; + agentGroupId: string; + maxMessagesPerPrompt: number; + mcpServers: Record }>; + model?: string; + effort?: string; +} + +const DEFAULT_MAX_MESSAGES = 10; + +let _config: RunnerConfig | null = null; + +/** + * Load config from container.json. Called once at startup. + * Falls back to sensible defaults for any missing field. + */ +export function loadConfig(): RunnerConfig { + if (_config) return _config; + + let raw: Record = {}; + try { + raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + } catch { + console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`); + } + + _config = { + provider: (raw.provider as string) || 'claude', + assistantName: (raw.assistantName as string) || '', + groupName: (raw.groupName as string) || '', + agentGroupId: (raw.agentGroupId as string) || '', + maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES, + mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {}, + model: (raw.model as string) || undefined, + effort: (raw.effort as string) || undefined, + }; + + return _config; +} + +/** Get the loaded config. Throws if loadConfig() hasn't been called. */ +export function getConfig(): RunnerConfig { + if (!_config) throw new Error('Config not loaded — call loadConfig() first'); + return _config; +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/current-batch.ts b/packages/iclaw-runtime/container/agent-runner/src/current-batch.ts new file mode 100644 index 0000000..b699c13 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/current-batch.ts @@ -0,0 +1,29 @@ +/** + * Per-batch context the poll loop publishes for downstream consumers + * (MCP tools, etc.) that don't sit on the poll-loop's call stack. + * + * Today the only field is `inReplyTo` — the id of the first inbound + * message in the batch the agent is currently processing. MCP tools like + * `send_message` and `send_file` read this and stamp it onto the outbound + * row so the host's a2a return-path routing can correlate replies back to + * the originating session. + * + * This is module-level state on purpose: the agent-runner is single-process + * and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo` + * before invoking the provider and `clearCurrentInReplyTo` after the batch + * completes (or errors out). + */ +let currentInReplyTo: string | null = null; + +export function setCurrentInReplyTo(id: string | null): void { + currentInReplyTo = id; +} + +export function clearCurrentInReplyTo(): void { + currentInReplyTo = null; +} + +export function getCurrentInReplyTo(): string | null { + return currentInReplyTo; +} + diff --git a/packages/iclaw-runtime/container/agent-runner/src/db/connection.ts b/packages/iclaw-runtime/container/agent-runner/src/db/connection.ts new file mode 100644 index 0000000..51a82d7 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/db/connection.ts @@ -0,0 +1,270 @@ +/** + * Two-DB connection layer. + * + * The session uses two SQLite files to eliminate write contention across + * the host-container mount boundary: + * + * inbound.db — host writes new messages here; container opens READ-ONLY + * outbound.db — container writes responses + acks here; host opens read-only + * + * Each file has exactly one writer, so no cross-process lock contention. + * + * ⚠ Cross-mount visibility: inbound.db MUST be journal_mode=DELETE (set by + * the host when the file is created). WAL's `-shm` is memory-mapped and + * VirtioFS does not propagate mmap coherency from host to guest, so a + * WAL-mode inbound.db would leave this reader frozen on an early snapshot + * and it would silently never see new host messages. See + * src/session-manager.ts for the full set of cross-mount invariants and + * scripts/sanity-live-poll.ts for the empirical validation. + */ +import { Database } from 'bun:sqlite'; +import fs from 'fs'; + +const DEFAULT_INBOUND_PATH = '/workspace/inbound.db'; +const DEFAULT_OUTBOUND_PATH = '/workspace/outbound.db'; +const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat'; + +let _inbound: Database | null = null; +let _outbound: Database | null = null; +let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; +let _testMode = false; + +/** + * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. + * + * Use this (not getInboundDb) for readers that need to see host-written rows + * promptly — e.g. messages_in polling. Caller must .close() the returned + * connection (try/finally). + * + * Needed for mounts where host writes don't reliably invalidate + * SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple + * Container), NFS. + * + * Cost is microseconds per query, so safe for universal use. + */ +export function openInboundDb(): Database { + // In test mode return a thin wrapper over the in-memory singleton. + // Callers do try/finally { db.close() } — the wrapper no-ops close() + // so the singleton survives for the rest of the test. + if (_testMode && _inbound) { + const db = _inbound; + return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database; + } + const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA mmap_size = 0'); + return db; +} + +/** + * Inbound DB — long-lived singleton, OK for tables the host writes once + * at spawn and never again (destinations, session_routing). For + * messages_in polling — where the host writes continuously and a stale + * view causes the pollHandle hang — use `openInboundDb()` instead. + */ +export function getInboundDb(): Database { + if (!_inbound) { + _inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); + _inbound.exec('PRAGMA busy_timeout = 5000'); + _inbound.exec('PRAGMA mmap_size = 0'); + } + return _inbound; +} + +/** Outbound DB — container owns this file (sole writer). */ +export function getOutboundDb(): Database { + if (!_outbound) { + _outbound = new Database(DEFAULT_OUTBOUND_PATH); + _outbound.exec('PRAGMA journal_mode = DELETE'); + _outbound.exec('PRAGMA busy_timeout = 5000'); + _outbound.exec('PRAGMA foreign_keys = ON'); + // Lightweight forward-compat: session_state was added after the initial + // v2 schema, so older session DBs don't have it. Create it on demand + // instead of requiring a formal migration pass. Also handle the case + // where an earlier revision of this table existed without updated_at — + // ALTER TABLE to add any missing columns. + _outbound.exec(` + CREATE TABLE IF NOT EXISTS session_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + const cols = new Set( + (_outbound.prepare("PRAGMA table_info('session_state')").all() as Array<{ name: string }>).map((c) => c.name), + ); + if (!cols.has('updated_at')) { + _outbound.exec(`ALTER TABLE session_state ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''`); + } + // container_state: tracks the current tool in flight (if any) so the host + // sweep can widen its stuck tolerance when Bash is running with a user- + // declared long timeout. Forward-compat for older outbound.db files. + _outbound.exec(` + CREATE TABLE IF NOT EXISTS container_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_tool TEXT, + tool_declared_timeout_ms INTEGER, + tool_started_at TEXT, + updated_at TEXT NOT NULL + ); + `); + } + return _outbound; +} + +/** + * Record that a tool is starting. `declaredTimeoutMs` is the tool's own + * timeout hint when one is available (Bash exposes it in the tool_use input); + * omit for tools with no declared timeout. + */ +export function setContainerToolInFlight(tool: string, declaredTimeoutMs: number | null): void { + const now = new Date().toISOString(); + getOutboundDb() + .prepare( + `INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at) + VALUES (1, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + current_tool = excluded.current_tool, + tool_declared_timeout_ms = excluded.tool_declared_timeout_ms, + tool_started_at = excluded.tool_started_at, + updated_at = excluded.updated_at`, + ) + .run(tool, declaredTimeoutMs, now, now); +} + +/** Clear the in-flight tool — called on PostToolUse / PostToolUseFailure. */ +export function clearContainerToolInFlight(): void { + const now = new Date().toISOString(); + getOutboundDb() + .prepare( + `INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at) + VALUES (1, NULL, NULL, NULL, ?) + ON CONFLICT(id) DO UPDATE SET + current_tool = NULL, + tool_declared_timeout_ms = NULL, + tool_started_at = NULL, + updated_at = excluded.updated_at`, + ) + .run(now); +} + +/** + * Touch the heartbeat file — replaces the old touchProcessing() DB writes. + * The host checks this file's mtime for stale container detection. + * A file touch is cheaper and avoids cross-boundary DB write contention. + */ +export function touchHeartbeat(): void { + const p = _heartbeatPath; + const now = new Date(); + try { + fs.utimesSync(p, now, now); + } catch { + try { + fs.writeFileSync(p, ''); + } catch { + // Silently ignore — parent dir may not exist (e.g., in-memory test DBs) + } + } +} + +/** + * Clear stale processing_ack entries on container startup. + * If the previous container crashed, 'processing' entries are leftover. + * Clearing them lets the new container re-process those messages. + */ +export function clearStaleProcessingAcks(): void { + getOutboundDb().prepare("DELETE FROM processing_ack WHERE status = 'processing'").run(); +} + +/** For tests — creates in-memory DBs with the session schemas. */ +export function initTestSessionDb(): { inbound: Database; outbound: Database } { + _testMode = true; + _inbound = new Database(':memory:'); + _inbound.exec('PRAGMA foreign_keys = ON'); + _inbound.exec(` + CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', + process_after TEXT, + recurrence TEXT, + series_id TEXT, + tries INTEGER DEFAULT 0, + trigger INTEGER NOT NULL DEFAULT 1, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL, + on_wake INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE delivered ( + message_out_id TEXT PRIMARY KEY, + platform_message_id TEXT, + status TEXT NOT NULL DEFAULT 'delivered', + delivered_at TEXT NOT NULL + ); + CREATE TABLE destinations ( + name TEXT PRIMARY KEY, + display_name TEXT, + type TEXT NOT NULL, + channel_type TEXT, + platform_id TEXT, + agent_group_id TEXT + ); + `); + + _outbound = new Database(':memory:'); + _outbound.exec('PRAGMA foreign_keys = ON'); + _outbound.exec(` + CREATE TABLE messages_out ( + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, + in_reply_to TEXT, + timestamp TEXT NOT NULL, + deliver_after TEXT, + recurrence TEXT, + kind TEXT NOT NULL, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL + ); + CREATE TABLE processing_ack ( + message_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + status_changed TEXT NOT NULL + ); + CREATE TABLE session_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE container_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_tool TEXT, + tool_declared_timeout_ms INTEGER, + tool_started_at TEXT, + updated_at TEXT NOT NULL + ); + `); + + return { inbound: _inbound, outbound: _outbound }; +} + +export function closeSessionDb(): void { + _inbound?.close(); + _inbound = null; + _testMode = false; + _outbound?.close(); + _outbound = null; +} + +/** + * @deprecated Use getInboundDb() / getOutboundDb() instead. + * Kept for backward compatibility during migration. + */ +export function getSessionDb(): Database { + return getInboundDb(); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/db/index.ts b/packages/iclaw-runtime/container/agent-runner/src/db/index.ts new file mode 100644 index 0000000..d1b97fe --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/db/index.ts @@ -0,0 +1,20 @@ +export { + getInboundDb, + getOutboundDb, + getSessionDb, + initTestSessionDb, + closeSessionDb, + touchHeartbeat, + clearStaleProcessingAcks, +} from './connection.js'; +export { + getPendingMessages, + markProcessing, + markCompleted, + markFailed, + getMessageIn, + findQuestionResponse, +} from './messages-in.js'; +export type { MessageInRow } from './messages-in.js'; +export { writeMessageOut, getUndeliveredMessages } from './messages-out.js'; +export type { MessageOutRow, WriteMessageOut } from './messages-out.js'; diff --git a/packages/iclaw-runtime/container/agent-runner/src/db/messages-in.ts b/packages/iclaw-runtime/container/agent-runner/src/db/messages-in.ts new file mode 100644 index 0000000..dc6eeac --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/db/messages-in.ts @@ -0,0 +1,166 @@ +/** + * Inbound message operations (container side). + * + * Reads from inbound.db (host-owned, opened read-only). + * Writes processing status to processing_ack in outbound.db (container-owned). + * + * The container never writes to inbound.db — all status tracking goes through + * processing_ack. The host reads processing_ack to sync message lifecycle. + */ +import { getConfig } from '../config.js'; +import { openInboundDb, getOutboundDb } from './connection.js'; + +// Cache whether inbound.db has the on_wake column (added in v2.0.48). +// The container opens inbound.db read-only, so it can't ALTER — +// gracefully degrade when running against an older session DB. +let _hasOnWake: boolean | null = null; +function hasOnWakeColumn(db: ReturnType): boolean { + if (_hasOnWake !== null) return _hasOnWake; + const cols = new Set( + (db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name), + ); + _hasOnWake = cols.has('on_wake'); + return _hasOnWake; +} + +export interface MessageInRow { + id: string; + seq: number | null; + kind: string; + timestamp: string; + status: string; + process_after: string | null; + recurrence: string | null; + tries: number; + /** 1 = wake-eligible (default); 0 = accumulated context only */ + trigger: number; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; +} + +// Cap on how many messages reach the agent in one prompt. Read from +// container.json; falls back to 10. +function getMaxMessagesPerPrompt(): number { + try { + return getConfig().maxMessagesPerPrompt; + } catch { + // Config not loaded yet (e.g. test harness) — use default + return 10; + } +} + +/** + * Fetch pending messages that are due for processing. + * Reads from inbound.db (read-only), filters against processing_ack in outbound.db + * to skip messages already picked up by this or a previous container run. + * + * Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in + * chronological order, regardless of their `trigger` flag: accumulated + * context (trigger=0) rides along with the wake-eligible rows so the agent + * sees the prior context it missed. Host's countDueMessages gates waking on + * trigger=1 separately (see src/db/session-db.ts). + */ +export function getPendingMessages(isFirstPoll = false): MessageInRow[] { + const inbound = openInboundDb(); + const outbound = getOutboundDb(); + + try { + const onWakeFilter = hasOnWakeColumn(inbound) ? 'AND (on_wake = 0 OR ?1 = 1)' : ''; + const pending = inbound + .prepare( + `SELECT * FROM messages_in + WHERE status = 'pending' + AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) + ${onWakeFilter} + ORDER BY seq DESC + LIMIT ?2`, + ) + .all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[]; + + if (pending.length === 0) return []; + + // Filter out messages already acknowledged in outbound.db + const ackedIds = new Set( + (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map( + (r) => r.message_id, + ), + ); + + // Reverse: we fetched DESC to take the most recent N, but the agent + // should see them in chronological order (oldest first). + return pending.filter((m) => !ackedIds.has(m.id)).reverse(); + } finally { + inbound.close(); + } +} + +/** Mark messages as processing — writes to processing_ack in outbound.db. */ +export function markProcessing(ids: string[]): void { + if (ids.length === 0) return; + const db = getOutboundDb(); + const stmt = db.prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', datetime('now'))", + ); + db.transaction(() => { + for (const id of ids) stmt.run(id); + })(); +} + +/** Mark messages as completed — updates processing_ack in outbound.db. */ +export function markCompleted(ids: string[]): void { + if (ids.length === 0) return; + const db = getOutboundDb(); + const stmt = db.prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))", + ); + db.transaction(() => { + for (const id of ids) stmt.run(id); + })(); +} + +/** Mark a single message as failed — writes to processing_ack in outbound.db. */ +export function markFailed(id: string): void { + getOutboundDb() + .prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', datetime('now'))", + ) + .run(id); +} + +/** Get a message by ID (read from inbound.db). */ +export function getMessageIn(id: string): MessageInRow | undefined { + const inbound = openInboundDb(); + try { + return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; + } finally { + inbound.close(); + } +} + +/** + * Find a pending response to a question (by questionId in content). + * Reads from inbound.db, checks processing_ack to skip already-handled responses. + */ +export function findQuestionResponse(questionId: string): MessageInRow | undefined { + const inbound = openInboundDb(); + const outbound = getOutboundDb(); + + try { + const response = inbound + .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined; + + if (!response) return undefined; + + // Check it hasn't been acked already + const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); + if (acked) return undefined; + + return response; + } finally { + inbound.close(); + } +} + diff --git a/packages/iclaw-runtime/container/agent-runner/src/db/messages-out.ts b/packages/iclaw-runtime/container/agent-runner/src/db/messages-out.ts new file mode 100644 index 0000000..85b0467 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/db/messages-out.ts @@ -0,0 +1,143 @@ +/** + * Outbound message operations (container side). + * + * Writes to outbound.db (container-owned). + * The host polls this DB (read-only) for undelivered messages. + */ +import { getInboundDb, getOutboundDb } from './connection.js'; + +export interface MessageOutRow { + id: string; + seq: number | null; + in_reply_to: string | null; + timestamp: string; + deliver_after: string | null; + recurrence: string | null; + kind: string; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; +} + +export interface WriteMessageOut { + id: string; + in_reply_to?: string | null; + deliver_after?: string | null; + recurrence?: string | null; + kind: string; + platform_id?: string | null; + channel_type?: string | null; + thread_id?: string | null; + content: string; +} + +/** + * Write a new outbound message, auto-assigning an odd seq number. + * Container uses odd seq (1, 3, 5...), host uses even (2, 4, 6...). + * + * The disjoint namespace is load-bearing, not just collision avoidance: + * seq is the agent-facing message ID returned by send_message and accepted + * by edit_message / add_reaction, and getMessageIdBySeq() below looks up + * by seq across BOTH tables. If inbound and outbound could share a seq, + * the agent's "edit message #5" could resolve to the wrong row. + */ +export function writeMessageOut(msg: WriteMessageOut): number { + const outbound = getOutboundDb(); + const inbound = getInboundDb(); + + // Read max seq from both DBs to maintain global ordering. + // Safe: each side only reads the other DB, never writes to it. + const maxOut = (outbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m; + const maxIn = (inbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; + const max = Math.max(maxOut, maxIn); + const nextSeq = max % 2 === 0 ? max + 1 : max + 2; // next odd + + // bun:sqlite requires named parameters to be passed with the prefix character + // in the JS object keys (better-sqlite3 auto-stripped it, bun:sqlite does not). + outbound + .prepare( + `INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content) + VALUES ($id, $seq, $in_reply_to, datetime('now'), $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`, + ) + .run({ + $id: msg.id, + $seq: nextSeq, + $in_reply_to: msg.in_reply_to ?? null, + $deliver_after: msg.deliver_after ?? null, + $recurrence: msg.recurrence ?? null, + $kind: msg.kind, + $platform_id: msg.platform_id ?? null, + $channel_type: msg.channel_type ?? null, + $thread_id: msg.thread_id ?? null, + $content: msg.content, + }); + + return nextSeq; +} + +/** + * Look up a message's platform ID by seq number. + * Searches both inbound and outbound DBs since seq spans both. + * + * For inbound messages, the Chat SDK message ID is already the platform message ID + * (e.g., "6037840640:42" for Telegram). + * + * For outbound messages, the internal ID (msg-xxx) won't work for edits/reactions. + * Instead, look up the platform_message_id from the delivered table (host writes this + * after successful delivery). + */ +export function getMessageIdBySeq(seq: number): string | null { + const inbound = getInboundDb(); + + // Inbound messages: ID is already the platform message ID + const inRow = inbound.prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as + | { id: string } + | undefined; + if (inRow) return inRow.id; + + // Outbound messages: look up platform message ID from delivered table + const outRow = getOutboundDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as + | { id: string } + | undefined; + if (!outRow) return null; + + // Check if host has stored the platform message ID after delivery + const deliveredRow = inbound + .prepare('SELECT platform_message_id FROM delivered WHERE message_out_id = ?') + .get(outRow.id) as { platform_message_id: string | null } | undefined; + if (deliveredRow?.platform_message_id) return deliveredRow.platform_message_id; + + // Fallback to internal ID (edits/reactions on undelivered messages won't work) + return outRow.id; +} + +/** + * Look up the routing fields for a message by seq (for edit/reaction targeting). + * Returns the channel_type, platform_id, thread_id of the referenced message. + */ +export function getRoutingBySeq( + seq: number, +): { channel_type: string | null; platform_id: string | null; thread_id: string | null } | null { + const inbound = getInboundDb(); + const inRow = inbound + .prepare('SELECT channel_type, platform_id, thread_id FROM messages_in WHERE seq = ?') + .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined; + if (inRow) return inRow; + + const outRow = getOutboundDb() + .prepare('SELECT channel_type, platform_id, thread_id FROM messages_out WHERE seq = ?') + .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined; + return outRow ?? null; +} + +/** Get undelivered messages (for host polling — reads from outbound.db). */ +export function getUndeliveredMessages(): MessageOutRow[] { + return getOutboundDb() + .prepare( + `SELECT * FROM messages_out + WHERE (deliver_after IS NULL OR deliver_after <= datetime('now')) + ORDER BY timestamp ASC`, + ) + .all() as MessageOutRow[]; +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/db/session-routing.ts b/packages/iclaw-runtime/container/agent-runner/src/db/session-routing.ts new file mode 100644 index 0000000..94abca6 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/db/session-routing.ts @@ -0,0 +1,30 @@ +/** + * Default reply routing for this session — written by the host on every + * container wake (see src/session-manager.ts `writeSessionRouting`). + * + * Read by the MCP tools as the default destination for outbound messages + * when the agent doesn't specify an explicit `to`. This is what makes + * "agent replies in the thread it's currently in" work: the router strips + * or preserves thread_id based on the adapter's thread support, and we + * just read the fixed routing the host committed for this session. + */ +import { getInboundDb } from './connection.js'; + +export interface SessionRouting { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; +} + +export function getSessionRouting(): SessionRouting { + const db = getInboundDb(); + try { + const row = db + .prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1') + .get() as SessionRouting | undefined; + if (row) return row; + } catch { + // Table may not exist on an older session DB — fall through to defaults + } + return { channel_type: null, platform_id: null, thread_id: null }; +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/db/session-state.test.ts b/packages/iclaw-runtime/container/agent-runner/src/db/session-state.test.ts new file mode 100644 index 0000000..b5aa269 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/db/session-state.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { getOutboundDb, initTestSessionDb } from './connection.js'; +import { + clearContinuation, + getContinuation, + migrateLegacyContinuation, + setContinuation, +} from './session-state.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +function seedLegacy(value: string): void { + getOutboundDb() + .prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)') + .run('sdk_session_id', value, new Date().toISOString()); +} + +describe('session-state — per-provider continuations', () => { + test('set/get round-trip, case-insensitive provider key', () => { + setContinuation('claude', 'claude-conv-1'); + expect(getContinuation('claude')).toBe('claude-conv-1'); + expect(getContinuation('Claude')).toBe('claude-conv-1'); + expect(getContinuation('CLAUDE')).toBe('claude-conv-1'); + }); + + test('providers are isolated — switching reads the right slot', () => { + setContinuation('claude', 'claude-conv-1'); + setContinuation('codex', 'codex-thread-xyz'); + + expect(getContinuation('claude')).toBe('claude-conv-1'); + expect(getContinuation('codex')).toBe('codex-thread-xyz'); + }); + + test('clearContinuation only affects the specified provider', () => { + setContinuation('claude', 'keep-me'); + setContinuation('codex', 'drop-me'); + + clearContinuation('codex'); + + expect(getContinuation('claude')).toBe('keep-me'); + expect(getContinuation('codex')).toBeUndefined(); + }); + + test('unknown provider returns undefined', () => { + expect(getContinuation('never-used')).toBeUndefined(); + }); +}); + +describe('session-state — legacy migration', () => { + test('adopts legacy value into current provider when current is empty', () => { + seedLegacy('old-session-id'); + + const adopted = migrateLegacyContinuation('claude'); + + expect(adopted).toBe('old-session-id'); + expect(getContinuation('claude')).toBe('old-session-id'); + }); + + test('always deletes legacy row regardless of migration outcome', () => { + seedLegacy('old-session-id'); + setContinuation('claude', 'existing'); + + migrateLegacyContinuation('claude'); + + // After migration the legacy key must be gone, whether or not it was adopted. + // A subsequent migration for a different provider must not see it. + const resultAfterSecondCall = migrateLegacyContinuation('codex'); + expect(resultAfterSecondCall).toBeUndefined(); + }); + + test('prefers existing current-provider slot over legacy', () => { + seedLegacy('legacy-value'); + setContinuation('claude', 'claude-value'); + + const result = migrateLegacyContinuation('claude'); + + expect(result).toBe('claude-value'); + expect(getContinuation('claude')).toBe('claude-value'); + }); + + test('no legacy row — returns current provider value (possibly undefined)', () => { + expect(migrateLegacyContinuation('claude')).toBeUndefined(); + + setContinuation('codex', 'codex-value'); + expect(migrateLegacyContinuation('codex')).toBe('codex-value'); + }); + + test('migration is idempotent on a second call (legacy already gone)', () => { + seedLegacy('once'); + + const first = migrateLegacyContinuation('claude'); + expect(first).toBe('once'); + + const second = migrateLegacyContinuation('claude'); + expect(second).toBe('once'); + }); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/db/session-state.ts b/packages/iclaw-runtime/container/agent-runner/src/db/session-state.ts new file mode 100644 index 0000000..9e12309 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/db/session-state.ts @@ -0,0 +1,79 @@ +/** + * Persistent key/value state for the container. Lives in outbound.db + * (container-owned, already scoped per channel/thread). + * + * Primary use: remember each provider's opaque continuation id so the + * agent's conversation resumes across container restarts. Keyed per + * provider because continuations are provider-private — a Claude + * conversation id means nothing to Codex and vice versa. Switching + * providers is therefore lossless: each provider's last thread stays + * on file and resumes cleanly if the user flips back. + */ +import { getOutboundDb } from './connection.js'; + +const LEGACY_KEY = 'sdk_session_id'; + +function continuationKey(providerName: string): string { + return `continuation:${providerName.toLowerCase()}`; +} + +function getValue(key: string): string | undefined { + const row = getOutboundDb() + .prepare('SELECT value FROM session_state WHERE key = ?') + .get(key) as { value: string } | undefined; + return row?.value; +} + +function setValue(key: string, value: string): void { + getOutboundDb() + .prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)') + .run(key, value, new Date().toISOString()); +} + +function deleteValue(key: string): void { + getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key); +} + +/** + * One-time migration of the pre-per-provider continuation row. + * + * Before this was keyed per provider, continuations lived under the + * single key `sdk_session_id`. On container start, if that legacy row + * exists and the current provider has no continuation of its own, adopt + * the legacy value into the current provider's slot (best-guess — the + * legacy row was written by whatever provider ran last). The legacy row + * is always deleted so future provider flips never re-read a stale id + * through the wrong lens. + * + * Returns the continuation the caller should use at startup (either the + * current provider's existing value, the adopted legacy value, or + * undefined). + */ +export function migrateLegacyContinuation(providerName: string): string | undefined { + const legacy = getValue(LEGACY_KEY); + const currentKey = continuationKey(providerName); + const current = getValue(currentKey); + + if (legacy === undefined) return current; + + // Always drop the legacy row so no future provider reads it. + deleteValue(LEGACY_KEY); + + // Prefer the current provider's own slot if one already exists. + if (current !== undefined) return current; + + setValue(currentKey, legacy); + return legacy; +} + +export function getContinuation(providerName: string): string | undefined { + return getValue(continuationKey(providerName)); +} + +export function setContinuation(providerName: string, id: string): void { + setValue(continuationKey(providerName), id); +} + +export function clearContinuation(providerName: string): void { + deleteValue(continuationKey(providerName)); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/destinations.test.ts b/packages/iclaw-runtime/container/agent-runner/src/destinations.test.ts new file mode 100644 index 0000000..61e5f4b --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/destinations.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js'; +import { buildSystemPromptAddendum } from './destinations.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void { + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES (?, ?, 'channel', ?, ?, NULL)`, + ) + .run(name, displayName, channelType, platformId); +} + +describe('buildSystemPromptAddendum — multi-destination routing guidance', () => { + it('includes default-routing nudge when there are >1 destinations', () => { + seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); + seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net'); + + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('default to addressing the destination it came `from`'); + expect(prompt).toContain('from="name"'); + expect(prompt).toContain('`casa`'); + expect(prompt).toContain('`whatsapp-mg-17780`'); + }); + + it('describes message wrapping for a single destination', () => { + seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); + + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('Wrap each delivered message'); + expect(prompt).toContain(''); + expect(prompt).toContain('`casa`'); + }); + + it('handles the no-destination case without crashing', () => { + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('no configured destinations'); + expect(prompt).not.toContain('default to addressing'); + }); + + it('includes default-routing and wrapping instructions for single destination', () => { + seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); + + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('Wrap each delivered message'); + expect(prompt).toContain(''); + expect(prompt).toContain('default to addressing the destination it came `from`'); + expect(prompt).toContain('`casa`'); + }); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/destinations.ts b/packages/iclaw-runtime/container/agent-runner/src/destinations.ts new file mode 100644 index 0000000..cf1751d --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/destinations.ts @@ -0,0 +1,130 @@ +/** + * Destination map — lives in inbound.db's `destinations` table. + * + * The host writes this table before every container wake AND on demand + * (e.g. when a new child agent is created mid-session). The container + * queries the table live on every lookup, so admin changes take effect + * immediately — no restart required. + * + * This table is BOTH the routing map and the container-visible ACL. + * The host re-validates on the delivery side against the central DB, + * so even if this table is stale the host's enforcement is authoritative. + */ +import { getInboundDb } from './db/connection.js'; + +export interface DestinationEntry { + name: string; + displayName: string; + type: 'channel' | 'agent'; + channelType?: string; + platformId?: string; + agentGroupId?: string; +} + +interface DestRow { + name: string; + display_name: string | null; + type: 'channel' | 'agent'; + channel_type: string | null; + platform_id: string | null; + agent_group_id: string | null; +} + +function rowToEntry(row: DestRow): DestinationEntry { + return { + name: row.name, + displayName: row.display_name ?? row.name, + type: row.type, + channelType: row.channel_type ?? undefined, + platformId: row.platform_id ?? undefined, + agentGroupId: row.agent_group_id ?? undefined, + }; +} + +export function getAllDestinations(): DestinationEntry[] { + const rows = getInboundDb().prepare('SELECT * FROM destinations ORDER BY name').all() as DestRow[]; + return rows.map(rowToEntry); +} + +export function findByName(name: string): DestinationEntry | undefined { + const row = getInboundDb().prepare('SELECT * FROM destinations WHERE name = ?').get(name) as DestRow | undefined; + return row ? rowToEntry(row) : undefined; +} + +/** + * Reverse lookup: given routing fields from an inbound message, find + * which destination they correspond to (what does this agent call the sender?). + */ +export function findByRouting( + channelType: string | null | undefined, + platformId: string | null | undefined, +): DestinationEntry | undefined { + if (!channelType || !platformId) return undefined; + const db = getInboundDb(); + const row = + channelType === 'agent' + ? (db + .prepare("SELECT * FROM destinations WHERE type = 'agent' AND agent_group_id = ?") + .get(platformId) as DestRow | undefined) + : (db + .prepare("SELECT * FROM destinations WHERE type = 'channel' AND channel_type = ? AND platform_id = ?") + .get(channelType, platformId) as DestRow | undefined); + return row ? rowToEntry(row) : undefined; +} + +/** + * Generate the system-prompt addendum: agent identity + destination map. + * + * Identity is injected here (not in the shared CLAUDE.md) because it's + * per-agent-group and changes when the operator renames an agent, while + * the shared base is identical across all agents. + */ +export function buildSystemPromptAddendum(assistantName?: string): string { + const sections: string[] = []; + + if (assistantName) { + sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n')); + } + + sections.push(buildDestinationsSection()); + + return sections.join('\n\n'); +} + +function buildDestinationsSection(): string { + const all = getAllDestinations(); + + if (all.length === 0) { + return [ + '## Sending messages', + '', + 'You currently have no configured destinations. You cannot send messages until an admin wires one up.', + ].join('\n'); + } + + const lines = ['## Sending messages', '']; + if (all.length === 1) { + const d = all[0]; + const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; + lines.push(`Your destination is \`${d.name}\`${label}.`); + } else { + lines.push('You can send messages to the following destinations:', ''); + for (const d of all) { + const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; + lines.push(`- \`${d.name}\`${label}`); + } + } + lines.push(''); + lines.push( + 'Wrap each delivered message in a `` block; include several blocks in one response to address several destinations. `` marks thinking you don\'t want sent.', + ); + lines.push(''); + lines.push( + 'When replying to an incoming message, default to addressing the destination it came `from` (every inbound `` tag carries a `from="name"` attribute). Pick a different destination when the request asks for it (e.g., "tell Laura that…").', + ); + lines.push(''); + lines.push( + 'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.', + ); + return lines.join('\n'); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/formatter.test.ts b/packages/iclaw-runtime/container/agent-runner/src/formatter.test.ts new file mode 100644 index 0000000..9121366 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/formatter.test.ts @@ -0,0 +1,196 @@ +/** + * v1-parity tests for formatter behavior. + * + * Port of src/v1/formatting.test.ts (at commit 27c5220, parent of the v1 + * deletion commit 86becf8). Covers: context timezone header, reply_to + + * quoted_message rendering, XML escaping, and stripInternalTags. + * + * Timestamp-format assertions use `formatLocalTime()` output format, which + * is host locale-dependent for decorators (month abbr, "," separator) but + * stable for the numeric parts we assert on (hour, minute, year). + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js'; +import { getPendingMessages } from './db/messages-in.js'; +import { formatMessages, stripInternalTags } from './formatter.js'; +import { TIMEZONE } from './timezone.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function insertMessage( + id: string, + kind: string, + content: object, + opts?: { timestamp?: string }, +) { + const timestamp = opts?.timestamp ?? new Date().toISOString(); + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, content) + VALUES (?, ?, ?, 'pending', ?)`, + ) + .run(id, kind, timestamp, JSON.stringify(content)); +} + +describe('context timezone header', () => { + it('prepends to formatted output', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hello' }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain(` { + const result = formatMessages([]); + expect(result).toContain(` block when multiple are present', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' }); + insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' }); + const result = formatMessages(getPendingMessages()); + const ctxIdx = result.indexOf(' { + // Regression guard for #2555: an outer `` envelope around + // multiple chat messages caused the Claude Agent SDK to emit a synthetic + // `No response requested.` stub instead of calling the API. Each + // `` block is self-contained; concatenating them is enough. + it('does NOT wrap multiple chat messages in an outer envelope', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' }); + insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' }); + const result = formatMessages(getPendingMessages()); + expect(result).not.toContain(''); + expect(result).not.toContain(''); + }); + + it('emits one block per inbound row, in order', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'first' }); + insertMessage('m2', 'chat', { sender: 'Bob', text: 'second' }); + insertMessage('m3', 'chat', { sender: 'Carol', text: 'third' }); + const result = formatMessages(getPendingMessages()); + const matches = result.match(/]*>/g) ?? []; + expect(matches.length).toBe(3); + const firstIdx = result.indexOf('first'); + const secondIdx = result.indexOf('second'); + const thirdIdx = result.indexOf('third'); + expect(firstIdx).toBeGreaterThan(0); + expect(secondIdx).toBeGreaterThan(firstIdx); + expect(thirdIdx).toBeGreaterThan(secondIdx); + }); +}); + +describe('timestamp formatting', () => { + it('renders time via formatLocalTime (user TZ)', () => { + // 2026-06-15T12:00:00Z — timezone-agnostic assertions (year is stable) + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T12:00:00.000Z' }); + const result = formatMessages(getPendingMessages()); + // formatLocalTime's format in en-US contains the year and a month abbrev + expect(result).toContain('2026'); + expect(result).toMatch(/Jun/); + }); + + it('uses 12-hour AM/PM format', () => { + // 15:30 UTC — some hour will show with AM or PM depending on TZ + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T15:30:00.000Z' }); + const result = formatMessages(getPendingMessages()); + expect(result).toMatch(/(AM|PM)/); + }); +}); + +describe('reply_to + quoted_message rendering', () => { + it('renders reply_to attribute and quoted_message when all fields present', () => { + insertMessage('m1', 'chat', { + sender: 'Alice', + text: 'Yes, on my way!', + replyTo: { id: '42', sender: 'Bob', text: 'Are you coming tonight?' }, + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('reply_to="42"'); + expect(result).toContain('Are you coming tonight?'); + expect(result).toContain('Yes, on my way!'); + }); + + it('omits reply_to and quoted_message when no reply context', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'plain' }); + const result = formatMessages(getPendingMessages()); + expect(result).not.toContain('reply_to'); + expect(result).not.toContain('quoted_message'); + }); + + it('renders reply_to but omits quoted_message when original content is missing', () => { + insertMessage('m1', 'chat', { + sender: 'Alice', + text: 'ack', + replyTo: { id: '42', sender: 'Bob' }, // no text + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('reply_to="42"'); + expect(result).not.toContain('quoted_message'); + }); + + it('XML-escapes reply context', () => { + insertMessage('m1', 'chat', { + sender: 'Alice', + text: 'reply', + replyTo: { id: '1', sender: 'A & B', text: '' }, + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('from="A & B"'); + expect(result).toContain('<script>'); + expect(result).toContain('"xss"'); + }); +}); + +describe('XML escaping', () => { + it('escapes <, >, &, " in sender and body', () => { + insertMessage('m1', 'chat', { + sender: 'A & B ', + text: '', + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('sender="A & B <Co>"'); + expect(result).toContain('<script>alert("xss")</script>'); + }); +}); + +describe('stripInternalTags', () => { + it('strips single-line internal tags and trims', () => { + expect(stripInternalTags('hello secret world')).toBe('hello world'); + }); + + it('strips multi-line internal tags', () => { + expect(stripInternalTags('hello \nsecret\nstuff\n world')).toBe( + 'hello world', + ); + }); + + it('strips multiple internal tag blocks', () => { + expect(stripInternalTags('ahellob')).toBe('hello'); + }); + + it('returns empty string when input is only internal tags', () => { + expect(stripInternalTags('only this')).toBe(''); + }); + + it('returns input unchanged when there are no internal tags', () => { + expect(stripInternalTags('hello world')).toBe('hello world'); + }); + + it('preserves content that surrounds internal tags', () => { + expect(stripInternalTags('thinkingThe answer is 42')).toBe( + 'The answer is 42', + ); + }); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/formatter.ts b/packages/iclaw-runtime/container/agent-runner/src/formatter.ts new file mode 100644 index 0000000..f525447 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/formatter.ts @@ -0,0 +1,278 @@ +import { findByRouting } from './destinations.js'; +import type { MessageInRow } from './db/messages-in.js'; +import { TIMEZONE, formatLocalTime } from './timezone.js'; + +/** + * Command categories for messages starting with '/'. + * - admin: sender must be in NANOCLAW_ADMIN_USER_IDS + * - filtered: silently drop (mark completed without processing) + * - passthrough: pass raw to the agent (no XML wrapping) + * - none: not a command — format normally + */ +export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none'; + +const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']); +const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']); + +export interface CommandInfo { + category: CommandCategory; + command: string; // the command name (e.g., '/clear') + text: string; // full original text + senderId: string | null; +} + +/** + * Categorize a message as a command or not. + * Only applies to chat/chat-sdk messages. + * + * The extracted `senderId` is compared against `NANOCLAW_ADMIN_USER_IDS` + * which stores ids in the namespaced form `:` (see + * src/db/users.ts). chat-sdk-bridge serializes `author.userId` as a raw + * platform id with no prefix, so we prefix it here. If the id already + * contains a `:` we assume it's pre-namespaced (non-chat-sdk adapters + * that populate `senderId` directly) and leave it alone. + */ +export function categorizeMessage(msg: MessageInRow): CommandInfo { + const content = parseContent(msg.content); + const text = (content.text || '').trim(); + const senderId = extractSenderId(msg, content); + + if (!text.startsWith('/')) { + return { category: 'none', command: '', text, senderId }; + } + + // Extract the command name (e.g., '/clear' from '/clear some args') + const command = text.split(/\s/)[0].toLowerCase(); + + if (ADMIN_COMMANDS.has(command)) { + return { category: 'admin', command, text, senderId }; + } + + if (FILTERED_COMMANDS.has(command)) { + return { category: 'filtered', command, text, senderId }; + } + + return { category: 'passthrough', command, text, senderId }; +} + +/** + * Narrow check for /clear — the only command the runner handles directly. + * All other command gating (filtered, admin) is done by the host router + * before messages reach the container. + */ +export function isClearCommand(msg: MessageInRow): boolean { + const content = parseContent(msg.content); + const text = (content.text || '').trim(); + return text.toLowerCase().startsWith('/clear'); +} + +/** + * True for any chat that needs the outer loop's command path: /clear plus + * admin/passthrough slash commands the SDK can only dispatch when they are + * a query's first input. Used by the follow-up poller to bail out and let + * the outer loop reopen the query. + */ +export function isRunnerCommand(msg: MessageInRow): boolean { + if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') return false; + const cat = categorizeMessage(msg).category; + return cat === 'admin' || cat === 'passthrough'; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractSenderId(msg: MessageInRow, content: any): string | null { + const raw: string | null = content?.senderId || content?.author?.userId || null; + if (!raw) return null; + // Already namespaced (e.g. "telegram:123") — use as-is. + if (raw.includes(':')) return raw; + // Raw platform id from chat-sdk serialization — prefix with channel type. + if (!msg.channel_type) return raw; + return `${msg.channel_type}:${raw}`; +} + +/** + * Routing context extracted from messages_in rows. + * Copied to messages_out by default so responses go back to the sender. + */ +export interface RoutingContext { + platformId: string | null; + channelType: string | null; + threadId: string | null; + inReplyTo: string | null; +} + +/** + * Extract routing context from a batch of messages. + * Uses the first message's routing fields. + */ +export function extractRouting(messages: MessageInRow[]): RoutingContext { + const first = messages[0]; + return { + platformId: first?.platform_id ?? null, + channelType: first?.channel_type ?? null, + threadId: first?.thread_id ?? null, + inReplyTo: first?.id ?? null, + }; +} + +/** + * Format a batch of messages_in rows into a prompt string. + * + * Prepends a `` header so the agent always knows + * what timezone it's in — every timestamp it sees in message bodies is the + * user's local time, and every time it produces (schedules, suggests) should + * be interpreted as local time in that same zone. This header is v1 behavior + * (src/v1/router.ts:20-22); dropping it led to misinterpretations where the + * agent scheduled tasks for the wrong hour. + * + * Strips routing fields — the agent never sees platform_id, channel_type, thread_id. + */ +export function formatMessages(messages: MessageInRow[]): string { + const header = `\n`; + if (messages.length === 0) return header; + + // Group by kind + const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk'); + const taskMessages = messages.filter((m) => m.kind === 'task'); + const webhookMessages = messages.filter((m) => m.kind === 'webhook'); + const systemMessages = messages.filter((m) => m.kind === 'system'); + + const parts: string[] = []; + + if (chatMessages.length > 0) { + parts.push(formatChatMessages(chatMessages)); + } + if (taskMessages.length > 0) { + parts.push(...taskMessages.map(formatTaskMessage)); + } + if (webhookMessages.length > 0) { + parts.push(...webhookMessages.map(formatWebhookMessage)); + } + if (systemMessages.length > 0) { + parts.push(...systemMessages.map(formatSystemMessage)); + } + + return header + parts.join('\n\n'); +} + +function formatChatMessages(messages: MessageInRow[]): string { + // Each `...` block is self-contained; + // concatenating them reads to the agent as a sequence of distinct messages. + // Earlier revisions wrapped multi-message batches in an outer `` + // envelope, but the Claude Agent SDK responded to that shape with a + // synthetic stub (`model: ""`, `content: "No response + // requested."`) instead of calling the API — see #2555 for the full trace. + // The fix is simply to drop the wrapper; the single-message path (which + // already worked) is now just the N=1 case of the same code. + return messages.map(formatSingleChat).join('\n'); +} + +function formatSingleChat(msg: MessageInRow): string { + const content = parseContent(msg.content); + const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown'; + const time = formatLocalTime(msg.timestamp, TIMEZONE); + const text = content.text || ''; + const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; + const replyAttr = content.replyTo?.id ? ` reply_to="${escapeXml(String(content.replyTo.id))}"` : ''; + const replyPrefix = formatReplyContext(content.replyTo); + const attachmentsSuffix = formatAttachments(content.attachments); + + const fromAttr = originAttr(msg); + + return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; +} + +/** + * Build a ` from="destination_name"` attribute string from a message's routing + * fields. Shared by all formatters so the agent always knows where a message + * originated — critical for explicit addressing. + */ +function originAttr(msg: MessageInRow): string { + const fromDest = findByRouting(msg.channel_type, msg.platform_id); + if (fromDest) return ` from="${escapeXml(fromDest.name)}"`; + if (msg.channel_type || msg.platform_id) { + return ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`; + } + return ''; +} + +function formatTaskMessage(msg: MessageInRow): string { + const content = parseContent(msg.content); + const from = originAttr(msg); + const time = formatLocalTime(msg.timestamp, TIMEZONE); + const parts: string[] = []; + if (content.scriptOutput) { + parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), ''); + } + parts.push('Instructions:', content.prompt || ''); + return `${parts.join('\n')}`; +} + +function formatWebhookMessage(msg: MessageInRow): string { + const content = parseContent(msg.content); + const source = content.source || 'unknown'; + const event = content.event || 'unknown'; + const from = originAttr(msg); + return `${JSON.stringify(content.payload || content, null, 2)}`; +} + +function formatSystemMessage(msg: MessageInRow): string { + const content = parseContent(msg.content); + const from = originAttr(msg); + return `${JSON.stringify(content.result || null)}`; +} + +/** + * Render the quoted original inside the body. + * + * Matches v1 format (src/v1/router.ts:10-18): `Y`. + * Requires BOTH sender and text — if only id is present the reply_to attribute + * on the parent carries the link without an inline preview. + * + * No truncation here (v1 didn't truncate). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function formatReplyContext(replyTo: any): string { + if (!replyTo) return ''; + const sender = replyTo.sender; + const text = replyTo.text; + if (!sender || !text) return ''; + return `\n ${escapeXml(text)}\n`; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function formatAttachments(attachments: any[] | undefined): string { + if (!Array.isArray(attachments) || attachments.length === 0) return ''; + const parts = attachments.map((a) => { + const name = a.name || a.filename || 'attachment'; + const type = a.type || 'file'; + const localPath = a.localPath ? `/workspace/${a.localPath}` : ''; + const url = a.url || ''; + if (localPath) { + return `[${type}: ${escapeXml(name)} — saved to ${escapeXml(localPath)}]`; + } + return url ? `[${type}: ${escapeXml(name)} (${escapeXml(url)})]` : `[${type}: ${escapeXml(name)}]`; + }); + return '\n' + parts.join('\n'); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseContent(json: string): any { + try { + return JSON.parse(json); + } catch { + return { text: json }; + } +} + +function escapeXml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** + * Strip `...` blocks from agent output, then trim. + * Ported from v1 (src/v1/router.ts:25-27). Used to remove the agent's + * own scratchpad/reasoning before a reply goes out over a channel. + */ +export function stripInternalTags(text: string): string { + return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/index.ts b/packages/iclaw-runtime/container/agent-runner/src/index.ts new file mode 100644 index 0000000..d579592 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/index.ts @@ -0,0 +1,109 @@ +/** + * NanoClaw Agent Runner v2 + * + * Runs inside a container. All IO goes through the session DB. + * No stdin, no stdout markers, no IPC files. + * + * Config is read from /workspace/agent/container.json (mounted RO). + * Only TZ and OneCLI networking vars come from env. + * + * Mount structure: + * /workspace/ + * inbound.db ← host-owned session DB (container reads only) + * outbound.db ← container-owned session DB + * .heartbeat ← container touches for liveness detection + * outbox/ ← outbound files + * agent/ ← agent group folder (CLAUDE.md, container.json, working files) + * container.json ← per-group config (RO nested mount) + * global/ ← shared global memory (RO) + * /app/src/ ← shared agent-runner source (RO) + * /app/skills/ ← shared skills (RO) + * /home/node/.claude/ ← Claude SDK state + skill symlinks (RW) + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { loadConfig } from './config.js'; +import { buildSystemPromptAddendum } from './destinations.js'; +// Providers barrel — each enabled provider self-registers on import. +// Provider skills append imports to providers/index.ts. +import './providers/index.js'; +import { createProvider, type ProviderName } from './providers/factory.js'; +import { runPollLoop } from './poll-loop.js'; + +function log(msg: string): void { + console.error(`[agent-runner] ${msg}`); +} + +const CWD = '/workspace/agent'; + +async function main(): Promise { + const config = loadConfig(); + const providerName = config.provider.toLowerCase() as ProviderName; + + log(`Starting v2 agent-runner (provider: ${providerName})`); + + // Runtime-generated system-prompt addendum: agent identity (name) plus + // the live destinations map. Everything else (capabilities, per-module + // instructions, per-channel formatting) is loaded by Claude Code from + // /workspace/agent/CLAUDE.md — the composed entry imports the shared + // base (/app/CLAUDE.md) and each enabled module's fragment. Per-group + // memory lives in /workspace/agent/CLAUDE.local.md (auto-loaded). + const instructions = buildSystemPromptAddendum(config.assistantName || undefined); + + // Discover additional directories mounted at /workspace/extra/* + const additionalDirectories: string[] = []; + const extraBase = '/workspace/extra'; + if (fs.existsSync(extraBase)) { + for (const entry of fs.readdirSync(extraBase)) { + const fullPath = path.join(extraBase, entry); + if (fs.statSync(fullPath).isDirectory()) { + additionalDirectories.push(fullPath); + } + } + if (additionalDirectories.length > 0) { + log(`Additional directories: ${additionalDirectories.join(', ')}`); + } + } + + // MCP server path — bun runs TS directly; no tsc build step in-image. + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.ts'); + + // Build MCP servers config: nanoclaw built-in + any from container.json + const mcpServers: Record }> = { + nanoclaw: { + command: 'bun', + args: ['run', mcpServerPath], + env: {}, + }, + }; + + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + mcpServers[name] = serverConfig; + log(`Additional MCP server: ${name} (${serverConfig.command})`); + } + + const provider = createProvider(providerName, { + assistantName: config.assistantName || undefined, + mcpServers, + env: { ...process.env }, + additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, + model: config.model, + effort: config.effort, + }); + + await runPollLoop({ + provider, + providerName, + cwd: CWD, + systemContext: { instructions }, + }); +} + +main().catch((err) => { + log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/integration.test.ts b/packages/iclaw-runtime/container/agent-runner/src/integration.test.ts new file mode 100644 index 0000000..063d064 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/integration.test.ts @@ -0,0 +1,464 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; +import { getUndeliveredMessages } from './db/messages-out.js'; +import { getPendingMessages } from './db/messages-in.js'; +import { getContinuation, setContinuation } from './db/session-state.js'; +import { MockProvider } from './providers/mock.js'; +import { runPollLoop } from './poll-loop.js'; + +beforeEach(() => { + initTestSessionDb(); + // Seed a destination so output parsing can resolve "discord-test" → routing + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('discord-test', 'Discord Test', 'channel', 'discord', 'chan-1', NULL)`, + ) + .run(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content) + VALUES (?, 'chat', datetime('now'), 'pending', ?, ?, ?, ?)`, + ) + .run(id, opts?.platformId ?? null, opts?.channelType ?? null, opts?.threadId ?? null, JSON.stringify(content)); +} + +describe('poll loop integration', () => { + it('should pick up a message, process it, and write a response', async () => { + insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' }); + + const provider = new MockProvider({}, () => '42'); + + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('42'); + expect(out[0].platform_id).toBe('chan-1'); + expect(out[0].channel_type).toBe('discord'); + expect(out[0].in_reply_to).toBe('m1'); + + // Input message should be acked (not pending) + const pending = getPendingMessages(); + expect(pending).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); + + it('should process multiple messages in a batch', async () => { + insertMessage('m1', { sender: 'Alice', text: 'Hello' }); + insertMessage('m2', { sender: 'Bob', text: 'World' }); + + const provider = new MockProvider({}, () => 'Got both messages'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('Got both messages'); + + await loopPromise.catch(() => {}); + }); + + it('should resolve thread_id per-destination, not from global routing', async () => { + // Seed a second destination + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`, + ) + .run(); + + // Insert messages from each destination with distinct thread IDs + insertMessage('m-discord', { sender: 'Alice', text: 'from discord' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread-1' }); + insertMessage('m-slack', { sender: 'Bob', text: 'from slack' }, { platformId: 'chan-2', channelType: 'slack', threadId: 'slack-thread-99' }); + + // Agent replies to both destinations + const provider = new MockProvider({}, () => + 'reply-dreply-s', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length >= 2, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + const discordOut = out.find((m) => m.platform_id === 'chan-1'); + const slackOut = out.find((m) => m.platform_id === 'chan-2'); + + expect(discordOut).toBeDefined(); + expect(discordOut!.thread_id).toBe('discord-thread-1'); + expect(discordOut!.in_reply_to).toBe('m-discord'); + + expect(slackOut).toBeDefined(); + expect(slackOut!.thread_id).toBe('slack-thread-99'); + expect(slackOut!.in_reply_to).toBe('m-slack'); + + await loopPromise.catch(() => {}); + }); + + it('bare text produces no outbound messages (scratchpad only)', async () => { + insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' }); + + // Agent responds with bare text — no wrapping + const provider = new MockProvider({}, () => 'I am thinking about this...'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + // Wait long enough for the poll loop to process + await sleep(1000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); + + it('unknown destination is dropped, valid destination is sent', async () => { + insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new MockProvider( + {}, + () => 'droppeddelivered', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + // Only the valid destination should produce output + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('delivered'); + expect(out[0].platform_id).toBe('chan-1'); + + await loopPromise.catch(() => {}); + }); + + it('multiple blocks each produce an outbound message', async () => { + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`, + ) + .run(); + + insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new MockProvider( + {}, + () => 'for discordfor slack', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length >= 2, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(2); + const discord = out.find((m) => m.platform_id === 'chan-1'); + const slack = out.find((m) => m.platform_id === 'chan-2'); + expect(discord).toBeDefined(); + expect(JSON.parse(discord!.content).text).toBe('for discord'); + expect(slack).toBeDefined(); + expect(JSON.parse(slack!.content).text).toBe('for slack'); + + await loopPromise.catch(() => {}); + }); + + it('sends null thread_id when no prior inbound from destination', async () => { + // Seed a second destination that has NO inbound messages + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`, + ) + .run(); + + // Only insert a message from discord — slack-new has never sent anything + insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' }); + + const provider = new MockProvider({}, () => 'hello slack'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].platform_id).toBe('chan-new'); + expect(out[0].thread_id).toBeNull(); + + await loopPromise.catch(() => {}); + }); + + it('resolves most recent thread_id when destination has multiple inbound messages', async () => { + // Two messages from same destination, different threads + insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' }); + insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' }); + + const provider = new MockProvider({}, () => 'reply'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].thread_id).toBe('thread-new'); + expect(out[0].in_reply_to).toBe('m-new'); + + await loopPromise.catch(() => {}); + }); + + it('should process messages arriving after loop starts', async () => { + const provider = new MockProvider({}, () => 'Processed'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000); + + // Insert message after loop has started + await sleep(200); + insertMessage('m-late', { sender: 'Charlie', text: 'Late arrival' }); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out.length).toBeGreaterThanOrEqual(1); + + await loopPromise.catch(() => {}); + }); + + it('internal tags between message blocks are stripped from scratchpad', async () => { + insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new MockProvider( + {}, + () => 'thinking about this...answerdone thinking', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('answer'); + + await loopPromise.catch(() => {}); + }); + + it('handles mixed task + chat batch with correct origin metadata', async () => { + // Seed destination for routing lookup + insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' }); + // Task with same routing — simulates a scheduled task in a channel session + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`, + ) + .run(JSON.stringify({ prompt: 'daily check' })); + + const provider = new MockProvider({}, () => 'done'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].platform_id).toBe('chan-1'); + + await loopPromise.catch(() => {}); + }); + +}); + +// Helper: run poll loop until aborted or timeout +async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise { + return Promise.race([ + runPollLoop({ + provider, + providerName: 'mock', + cwd: '/tmp', + }), + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted'))); + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)), + ]); +} + +async function waitFor(condition: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout'); + await sleep(50); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('poll loop — provider error recovery', () => { + it('writes error to outbound and continues loop on provider throw', async () => { + insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new ThrowingProvider('API rate limit exceeded'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toContain('Error:'); + expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded'); + + // Input message should be marked completed despite the error + const pending = getPendingMessages(); + expect(pending).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); +}); + +describe('poll loop — stale session recovery', () => { + it('clears continuation when provider reports session invalid', async () => { + // Pre-seed a continuation so the local variable in runPollLoop is set. + // Without this, the `if (continuation && isSessionInvalid)` check skips. + setContinuation('mock', 'pre-existing-session'); + + insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new InvalidSessionProvider(); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + // Error was written to outbound + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toContain('Error:'); + + // Continuation was cleared (isSessionInvalid returned true) + expect(getContinuation('mock')).toBeUndefined(); + + await loopPromise.catch(() => {}); + }); +}); + +describe('poll loop — /clear command', () => { + it('clears session, writes confirmation, skips query', async () => { + // Seed a continuation so we can verify it gets cleared + setContinuation('mock', 'existing-session-id'); + expect(getContinuation('mock')).toBe('existing-session-id'); + + // Insert a /clear command + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`, + ) + .run(JSON.stringify({ text: '/clear' })); + + const provider = new MockProvider({}, () => 'should not run'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('Session cleared.'); + + // Continuation was cleared + expect(getContinuation('mock')).toBeUndefined(); + + // Command message was completed + const pending = getPendingMessages(); + expect(pending).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); +}); + +/** + * Provider that throws on every query, simulating API failures. + */ +class ThrowingProvider { + readonly supportsNativeSlashCommands = false; + private errorMessage: string; + + constructor(errorMessage: string) { + this.errorMessage = errorMessage; + } + + isSessionInvalid(): boolean { + return false; + } + + query(_input: { prompt: string; cwd: string }) { + const errorMessage = this.errorMessage; + return { + push() {}, + end() {}, + abort() {}, + events: (async function* () { + throw new Error(errorMessage); + })(), + }; + } +} + +/** + * Provider that throws with an error that triggers isSessionInvalid. + * First emits an init event (setting continuation), then throws. + */ +class InvalidSessionProvider { + readonly supportsNativeSlashCommands = false; + + isSessionInvalid(): boolean { + return true; + } + + query(_input: { prompt: string; cwd: string }) { + return { + push() {}, + end() {}, + abort() {}, + events: (async function* () { + yield { type: 'init' as const, continuation: 'doomed-session' }; + throw new Error('session not found'); + })(), + }; + } +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/agents.instructions.md b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/agents.instructions.md new file mode 100644 index 0000000..8ada129 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/agents.instructions.md @@ -0,0 +1,26 @@ +## Companion and collaborator agents (`create_agent`) + +`mcp__nanoclaw__create_agent({ name, instructions })` spins up a new long-lived agent and wires it as a destination — bidirectional, so you can send it tasks and it can message you back. + +### How it works + +- Creates a new agent with its own container, workspace, and session. Your `instructions` string seeds the agent's `CLAUDE.local.md` — its starting role and personality. +- The agent's `name` becomes a destination on both sides: you address it via `send_message({ to: "", ... })`, and its replies arrive as inbound messages with `from=""`. +- Each agent has its own persistent workspace under `groups//` — memory, conversation history, and notes all survive across sessions. This is a full standalone agent, not a stateless sub-query. +- **Fire-and-forget:** the call returns immediately without waiting for the agent to confirm it's ready. Messages you send will queue until it's up. + +### When to use + +- **Companions** — a long-running presence that accumulates context over time: a `Researcher` tracking an ongoing inquiry, a `Calendar` agent managing scheduling, an assistant that knows your preferences and history. +- **Collaborators** — a parallel specialist that works independently and reports back: a `Builder` handling code edits while you stay in conversation, a `Reviewer` running checks in the background. + +The right frame is: does this agent need its own memory and context that builds over time, or does it need to work independently without blocking your turn? Either is a good reason to spawn one. + +### When NOT to use + +- **One-off lookups or short tasks** — use the SDK `Agent` tool instead. It's stateless, spins up and completes in one shot, and leaves no persistent footprint. +- **Work that finishes before the user's next message** — agents persist indefinitely. Don't create one for something you could do inline. + +### Writing good `instructions` + +Cover: the agent's role, who it takes tasks from (you, by name), how it should report back (on completion only? with milestones for long work?), and any domain-specific rules. Don't restate NanoClaw base behavior — the shared base is already loaded on the agent's end. \ No newline at end of file diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/agents.ts b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/agents.ts new file mode 100644 index 0000000..b341b74 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/agents.ts @@ -0,0 +1,66 @@ +/** + * Agent management MCP tools: create_agent. + * + * send_to_agent was removed — sending to another agent is now just + * send_message(to="agent-name") since agents and channels share the + * unified destinations namespace. + * + * create_agent is admin-only. Non-admin containers never see this tool + * (see mcp-tools/index.ts). The host re-checks permission on receive. + */ +import { writeMessageOut } from '../db/messages-out.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +export const createAgent: McpToolDefinition = { + tool: { + name: 'create_agent', + description: + 'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. Admin-only. Fire-and-forget.', + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'Human-readable name (also becomes your destination name for this agent)' }, + instructions: { type: 'string', description: 'CLAUDE.md content for the new agent (personality, role, instructions)' }, + }, + required: ['name'], + }, + }, + async handler(args) { + const name = args.name as string; + if (!name) return err('name is required'); + + const requestId = generateId(); + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'create_agent', + requestId, + name, + instructions: (args.instructions as string) || null, + }), + }); + + log(`create_agent: ${requestId} → "${name}"`); + return ok(`Creating agent "${name}". You will be notified when it is ready.`); + }, +}; + +registerTools([createAgent]); diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/cli.instructions.md b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/cli.instructions.md new file mode 100644 index 0000000..b6f3173 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -0,0 +1,83 @@ +## Admin CLI (`ncl`) + +The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration. + +### Usage + +``` +ncl [--flags] +ncl help +ncl help +``` + +### Scope + +Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them. + +### Resources + +Run `ncl help` for the full list. Common resources: + +| Resource | Verbs | What it is | +|----------|-------|------------| +| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) | +| sessions | list, get | Active sessions (read-only) | +| destinations | list, add, remove | Where an agent group can send messages | +| members | list, add, remove | Unprivileged access gate for an agent group | + +Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals. + +### When to use + +- **Looking up your own config** — `ncl groups get` or `ncl groups config get` to see your container config. +- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`). +- **Checking who's in your group** — `ncl members list`. +- **Seeing your destinations** — `ncl destinations list`. +- **Answering questions about the system** — query `ncl` rather than guessing. + +### Access rules + +Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it. + +### Approval flow + +Write commands require admin approval. Here's what happens: + +1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`). +2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet. +3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options. +4. Once the admin responds: + - **Approved:** the command executes and the result is delivered back to you as a system message in this conversation. + - **Rejected:** you get a system message saying the request was rejected. + +You don't need to poll or retry — the result arrives automatically. + +### Examples + +```bash +# Read commands (no approval needed) +ncl groups get +ncl groups config get +ncl sessions list +ncl destinations list +ncl members list + +# Write commands (approval required) +ncl groups restart +ncl groups restart --rebuild --message "Config updated." +ncl groups config update --model claude-sonnet-4-5-20250514 +ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]' +ncl groups config add-package --npm some-package +ncl members add --user telegram:jane +``` + +### Important + +Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details. + +### Tips + +- Use `ncl help` to see all available fields, types, enums, and which fields are auto-filled. +- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically. +- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`. +- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result. diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.instructions.md b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.instructions.md new file mode 100644 index 0000000..85ad24b --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.instructions.md @@ -0,0 +1,27 @@ +## Sending messages + +**Every response** must be wrapped in `...` blocks — even if you only have one destination. Bare text outside of `` blocks is scratchpad (logged but never sent). See the `## Sending messages` section in your runtime system prompt for the current destination list and names. + +### Mid-turn updates (`send_message`) + +Use the `mcp__nanoclaw__send_message` tool to send a message while you're still working (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work: + +- **Short turn (≤2 quick tool calls):** Don't narrate. Output any response. +- **Longer turn (multiple tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it, checking the logs now") so the user knows you got the message. +- **Long-running turns (long-running tasks with many stages):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. + +**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call. + +**Outcomes, not play-by-play.** When the turn is done, the final message should be about the result, not a transcript of what you did. + +### Sending files (`send_file`) + +Use `mcp__nanoclaw__send_file({ path, text?, filename?, to? })` to deliver a file from your workspace. `path` is absolute or relative to `/workspace/agent/`; `filename` overrides the display name shown in chat (defaults to the file's basename); `text` is an optional accompanying message. Use this for artifacts you produce (charts, PDFs, generated images, reports) rather than dumping contents into chat. + +### Reacting to messages (`add_reaction`) + +Use `mcp__nanoclaw__add_reaction({ messageId, emoji })` to react to a specific inbound message by its `#N` id — pass `messageId` as an integer (e.g. `22`, not `"22"`). Good for lightweight acknowledgment (`eyes` = seen, `white_check_mark` = done) when a full reply would be noise. `emoji` is the shortcode name (e.g. `thumbs_up`, `heart`), not the raw character. + +### Internal thoughts + +Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.test.ts b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.test.ts new file mode 100644 index 0000000..4cef950 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for the core MCP tools' interaction with the per-batch routing + * context. The agent-runner sets a current `inReplyTo` at the top of each + * batch in poll-loop, and outbound writes from MCP tools (send_message, + * send_file) must pick it up so a2a return-path routing on the host can + * correlate replies back to the originating session. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js'; +import { getUndeliveredMessages } from '../db/messages-out.js'; +import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js'; +import { sendMessage } from './core.js'; + +beforeEach(() => { + initTestSessionDb(); + // Seed a peer agent destination + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`, + ) + .run(); +}); + +afterEach(() => { + clearCurrentInReplyTo(); + closeSessionDb(); +}); + +describe('send_message MCP tool — in_reply_to plumbing', () => { + it('stamps current batch in_reply_to on outbound rows', async () => { + setCurrentInReplyTo('inbound-msg-1'); + + await sendMessage.handler({ to: 'peer', text: 'hello' }); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].in_reply_to).toBe('inbound-msg-1'); + }); + + it('writes null when no batch is active', async () => { + // No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation. + await sendMessage.handler({ to: 'peer', text: 'hello' }); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].in_reply_to).toBeNull(); + }); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.ts b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.ts new file mode 100644 index 0000000..48f87d5 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/core.ts @@ -0,0 +1,263 @@ +/** + * Core MCP tools: send_message, send_file, edit_message, add_reaction. + * + * All outbound tools resolve destinations via the local destination map + * (see destinations.ts). Agents reference destinations by name; the map + * translates name → routing tuple. Permission enforcement happens on + * the host side in delivery.ts via the agent_destinations table. + */ +import fs from 'fs'; +import path from 'path'; + +import { getCurrentInReplyTo } from '../current-batch.js'; +import { findByName, getAllDestinations } from '../destinations.js'; +import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js'; +import { getSessionRouting } from '../db/session-routing.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +function destinationList(): string { + const all = getAllDestinations(); + if (all.length === 0) return '(none)'; + return all.map((d) => d.name).join(', '); +} + +/** + * Resolve a destination name to routing fields. + * + * If `to` is omitted, use the session's default reply routing (channel + + * thread the conversation is in) — the agent replies in place. + * + * If `to` is specified, look up the named destination. If it resolves to + * the same channel the session is bound to, the session's thread_id is + * preserved so replies land in the correct thread. Otherwise thread_id + * is null (a cross-destination send starts a new conversation). + */ +function resolveRouting( + to: string | undefined, +): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } { + if (!to) { + // Default: reply to whatever thread/channel this session is bound to. + const session = getSessionRouting(); + if (session.channel_type && session.platform_id) { + return { + channel_type: session.channel_type, + platform_id: session.platform_id, + thread_id: session.thread_id, + resolvedName: '(current conversation)', + }; + } + // No session routing (e.g., agent-shared or internal-only agent) — + // fall back to the legacy single-destination shortcut. + const all = getAllDestinations(); + if (all.length === 0) return { error: 'No destinations configured.' }; + if (all.length > 1) { + return { + error: `You have multiple destinations — specify "to". Options: ${all.map((d) => d.name).join(', ')}`, + }; + } + to = all[0].name; + } + const dest = findByName(to); + if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` }; + if (dest.type === 'channel') { + // If the destination is the same channel the session is bound to, + // preserve the thread_id so replies land in the correct thread. + const session = getSessionRouting(); + const threadId = + session.channel_type === dest.channelType && session.platform_id === dest.platformId ? session.thread_id : null; + return { + channel_type: dest.channelType!, + platform_id: dest.platformId!, + thread_id: threadId, + resolvedName: to, + }; + } + return { channel_type: 'agent', platform_id: dest.agentGroupId!, thread_id: null, resolvedName: to }; +} + +export const sendMessage: McpToolDefinition = { + tool: { + name: 'send_message', + description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.', + inputSchema: { + type: 'object' as const, + properties: { + to: { + type: 'string', + description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.', + }, + text: { type: 'string', description: 'Message content' }, + }, + required: ['text'], + }, + }, + async handler(args) { + const text = args.text as string; + if (!text) return err('text is required'); + + const routing = resolveRouting(args.to as string | undefined); + if ('error' in routing) return err(routing.error); + + const id = generateId(); + const seq = writeMessageOut({ + id, + in_reply_to: getCurrentInReplyTo(), + kind: 'chat', + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, + content: JSON.stringify({ text }), + }); + + log(`send_message: #${seq} → ${routing.resolvedName}`); + return ok(`Message sent to ${routing.resolvedName} (id: ${seq})`); + }, +}; + +export const sendFile: McpToolDefinition = { + tool: { + name: 'send_file', + description: 'Send a file to a named destination. If you have only one destination, you can omit `to`.', + inputSchema: { + type: 'object' as const, + properties: { + to: { type: 'string', description: 'Destination name. Optional if you have only one destination.' }, + path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' }, + text: { type: 'string', description: 'Optional accompanying message' }, + filename: { type: 'string', description: 'Display name (default: basename of path)' }, + }, + required: ['path'], + }, + }, + async handler(args) { + const filePath = args.path as string; + if (!filePath) return err('path is required'); + + const routing = resolveRouting(args.to as string | undefined); + if ('error' in routing) return err(routing.error); + + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath); + if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`); + + const id = generateId(); + const filename = (args.filename as string) || path.basename(resolvedPath); + + const outboxDir = path.join('/workspace/outbox', id); + fs.mkdirSync(outboxDir, { recursive: true }); + fs.copyFileSync(resolvedPath, path.join(outboxDir, filename)); + + writeMessageOut({ + id, + in_reply_to: getCurrentInReplyTo(), + kind: 'chat', + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, + content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }), + }); + + log(`send_file: ${id} → ${routing.resolvedName} (${filename})`); + return ok(`File sent to ${routing.resolvedName} (id: ${id}, filename: ${filename})`); + }, +}; + +export const editMessage: McpToolDefinition = { + tool: { + name: 'edit_message', + description: 'Edit a previously sent message. Targets the same destination the original message was sent to.', + inputSchema: { + type: 'object' as const, + properties: { + messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' }, + text: { type: 'string', description: 'New message content' }, + }, + required: ['messageId', 'text'], + }, + }, + async handler(args) { + const seq = Number(args.messageId); + const text = args.text as string; + if (!seq || !text) return err('messageId and text are required'); + + const platformId = getMessageIdBySeq(seq); + if (!platformId) return err(`Message #${seq} not found`); + + const routing = getRoutingBySeq(seq); + if (!routing || !routing.channel_type || !routing.platform_id) { + return err(`Cannot determine destination for message #${seq}`); + } + + const id = generateId(); + writeMessageOut({ + id, + kind: 'chat', + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, + content: JSON.stringify({ operation: 'edit', messageId: platformId, text }), + }); + + log(`edit_message: #${seq} → ${platformId}`); + return ok(`Message edit queued for #${seq}`); + }, +}; + +export const addReaction: McpToolDefinition = { + tool: { + name: 'add_reaction', + description: 'Add an emoji reaction to a message.', + inputSchema: { + type: 'object' as const, + properties: { + messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' }, + emoji: { type: 'string', description: 'Emoji name (e.g., thumbs_up, heart, check)' }, + }, + required: ['messageId', 'emoji'], + }, + }, + async handler(args) { + const seq = Number(args.messageId); + const emoji = args.emoji as string; + if (!seq || !emoji) return err('messageId and emoji are required'); + + const platformId = getMessageIdBySeq(seq); + if (!platformId) return err(`Message #${seq} not found`); + + const routing = getRoutingBySeq(seq); + if (!routing || !routing.channel_type || !routing.platform_id) { + return err(`Cannot determine destination for message #${seq}`); + } + + const id = generateId(); + writeMessageOut({ + id, + kind: 'chat', + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, + content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }), + }); + + log(`add_reaction: #${seq} → ${emoji} on ${platformId}`); + return ok(`Reaction queued for #${seq}`); + }, +}; + +registerTools([sendMessage, sendFile, editMessage, addReaction]); diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/index.ts b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/index.ts new file mode 100644 index 0000000..bdaef5c --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/index.ts @@ -0,0 +1,22 @@ +/** + * MCP tools barrel — imports each tool module for its side-effect + * `registerTools([...])` call, then starts the MCP server. + * + * Adding a new tool module: create the file, call `registerTools([...])` + * at module scope, and append the import here. No central list. + */ +import './core.js'; +import './scheduling.js'; +import './interactive.js'; +import './agents.js'; +import './self-mod.js'; +import { startMcpServer } from './server.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +startMcpServer().catch((err) => { + log(`MCP server error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/interactive.instructions.md b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/interactive.instructions.md new file mode 100644 index 0000000..f6601bd --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/interactive.instructions.md @@ -0,0 +1,22 @@ +## Interactive prompts + +The two tools here solve different problems: `ask_user_question` forces a decision and waits for it; `send_card` displays structured content and moves on. + +### Asking a multiple-choice question (`ask_user_question`) + +`mcp__nanoclaw__ask_user_question({ title, question, options, timeout? })` presents the user with a set of choices and **blocks your turn** until they tap one or the timeout expires (default: 300 seconds). Returns their chosen value. + +`options` can be plain strings or `{ label, selectedLabel?, value? }` objects: +- `label` — the button text shown before selection +- `selectedLabel` — the text shown on the button *after* selection (useful for confirmations, e.g. `"✓ Confirmed"`) +- `value` — the string returned to you when that option is chosen (defaults to `label`) + +Use this when you genuinely cannot proceed without a decision. For free-text input, send a normal message and wait for their reply — don't reach for this tool. + +### Structured cards (`send_card`) + +`mcp__nanoclaw__send_card({ card, fallbackText? })` renders a structured card and **returns immediately** — it does not pause your turn or collect a response. + +`card` supports: `title`, `description`, `children` (nested text or content blocks), and `actions` (buttons). `fallbackText` is sent as a plain message on platforms without card support. + +Use this for presenting information in a cleaner format than prose: summaries, options the user can read (but you're not waiting on), or results with contextual buttons. If you need the user to actually *choose* something and return a value, use `ask_user_question` instead. \ No newline at end of file diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/interactive.ts b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/interactive.ts new file mode 100644 index 0000000..6924a9e --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/interactive.ts @@ -0,0 +1,169 @@ +/** + * Interactive MCP tools: ask_user_question, send_card. + * + * ask_user_question is a blocking tool call — it writes a messages_out row + * with a question card, then polls messages_in for the response. + */ +import { findQuestionResponse, markCompleted } from '../db/messages-in.js'; +import { writeMessageOut } from '../db/messages-out.js'; +import { getSessionRouting } from '../db/session-routing.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function routing() { + return getSessionRouting(); +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const askUserQuestion: McpToolDefinition = { + tool: { + name: 'ask_user_question', + description: + 'Ask the user a multiple-choice question and wait for their response. This is a blocking call — execution pauses until the user responds or the timeout expires. Provide a short card title (e.g. "Confirm deletion") and an array of options — each option may be a plain string (used as both button label and result value) or an object { label, selectedLabel?, value? } where selectedLabel is the text shown on the card after the user clicks.', + inputSchema: { + type: 'object' as const, + properties: { + title: { type: 'string', description: 'Short card title shown above the question' }, + question: { type: 'string', description: 'The question to ask' }, + options: { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + label: { type: 'string' }, + selectedLabel: { type: 'string' }, + value: { type: 'string' }, + }, + required: ['label'], + }, + ], + }, + description: 'Options for the user to choose from (string or {label, selectedLabel?, value?})', + }, + timeout: { type: 'number', description: 'Timeout in seconds (default: 300)' }, + }, + required: ['title', 'question', 'options'], + }, + }, + async handler(args) { + const title = args.title as string; + const question = args.question as string; + const rawOptions = args.options as unknown[]; + const timeout = ((args.timeout as number) || 300) * 1000; + if (!title || !question || !rawOptions?.length) { + return err('title, question, and options are required'); + } + + const options = rawOptions.map((o) => { + if (typeof o === 'string') return { label: o, selectedLabel: o, value: o }; + const obj = o as { label: string; selectedLabel?: string; value?: string }; + return { + label: obj.label, + selectedLabel: obj.selectedLabel ?? obj.label, + value: obj.value ?? obj.label, + }; + }); + + const questionId = generateId(); + const r = routing(); + + // Write question card to outbound.db + writeMessageOut({ + id: questionId, + kind: 'chat-sdk', + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.thread_id, + content: JSON.stringify({ + type: 'ask_question', + questionId, + title, + question, + options, + }), + }); + + log(`ask_user_question: ${questionId} → "${question}" [${options.join(', ')}]`); + + // Poll for response in inbound.db (host writes the response there) + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const response = findQuestionResponse(questionId); + + if (response) { + const parsed = JSON.parse(response.content); + // Mark the response as completed via processing_ack (outbound.db) + markCompleted([response.id]); + + log(`ask_user_question response: ${questionId} → ${parsed.selectedOption}`); + return ok(parsed.selectedOption); + } + + await sleep(1000); + } + + log(`ask_user_question timeout: ${questionId}`); + return err(`Question timed out after ${timeout / 1000}s`); + }, +}; + +export const sendCard: McpToolDefinition = { + tool: { + name: 'send_card', + description: 'Send a structured card (interactive or display-only) to the current conversation.', + inputSchema: { + type: 'object' as const, + properties: { + card: { + type: 'object', + description: 'Card structure with title, description, and optional children/actions', + }, + fallbackText: { type: 'string', description: 'Text fallback for platforms without card support' }, + }, + required: ['card'], + }, + }, + async handler(args) { + const card = args.card as Record; + if (!card) return err('card is required'); + + const id = generateId(); + const r = routing(); + + writeMessageOut({ + id, + kind: 'chat-sdk', + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.thread_id, + content: JSON.stringify({ type: 'card', card, fallbackText: (args.fallbackText as string) || '' }), + }); + + log(`send_card: ${id}`); + return ok(`Card sent (id: ${id})`); + }, +}; + +registerTools([askUserQuestion, sendCard]); diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/scheduling.instructions.md b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/scheduling.instructions.md new file mode 100644 index 0000000..9b6b829 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/scheduling.instructions.md @@ -0,0 +1,40 @@ +## Task scheduling (`schedule_task`) + +For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. + +To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule. + +Frequent recurring scheduled tasks — more than a few times a day — consume API credits and can risk account restrictions. You can add a `script` that runs first, and you will only be called when the check passes. + +### How it works + +1. Provide a bash `script` alongside the `prompt` when scheduling +2. When the task fires, the script runs first +3. Script returns: `{ "wakeAgent": true/false, "data": {...} }` +4. If `wakeAgent: false` — nothing happens, task waits for next run +5. If `wakeAgent: true` — claude receives the script's data + prompt and handles + +### Always test your script first + +Before scheduling, run the script directly to verify it works: + +```bash +bash -c 'node --input-type=module -e " + const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); + const prs = await r.json(); + console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); +"' +``` + +### When NOT to use scripts + +If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. Do not attempt to do things like sentiment analysis or advanced nlp in scripts. + +### Frequent task guidance + +If a user wants a task to run more than a few times a day and a script can't be used: + +- Explain that each time the task fires it uses API credits and risks rate limits +- Suggest adjusting the task requirements in a way that will allow you to use a script +- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script +- Help the user find the minimum viable frequency diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/scheduling.ts b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/scheduling.ts new file mode 100644 index 0000000..9b8451d --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/scheduling.ts @@ -0,0 +1,302 @@ +/** + * Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task. + * + * With the two-DB split, the container cannot write to inbound.db (host-owned). + * Scheduling operations are sent as system actions via messages_out — the host + * reads them during delivery and applies the changes to inbound.db. + */ +import { getInboundDb } from '../db/connection.js'; +import { writeMessageOut } from '../db/messages-out.js'; +import { getSessionRouting } from '../db/session-routing.js'; +import { TIMEZONE, parseZonedToUtc } from '../timezone.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function routing() { + return getSessionRouting(); +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +export const scheduleTask: McpToolDefinition = { + tool: { + name: 'schedule_task', + description: + `Schedule a one-shot or recurring task. The user's timezone is declared in the header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`, + inputSchema: { + type: 'object' as const, + properties: { + prompt: { type: 'string', description: 'Task instructions/prompt' }, + processAfter: { + type: 'string', + description: + `ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`, + }, + recurrence: { + type: 'string', + description: + 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.', + }, + script: { type: 'string', description: 'Optional pre-agent script to run before processing' }, + }, + required: ['prompt', 'processAfter'], + }, + }, + async handler(args) { + const prompt = args.prompt as string; + const processAfterIn = args.processAfter as string; + if (!prompt || !processAfterIn) return err('prompt and processAfter are required'); + + let processAfter: string; + try { + const d = parseZonedToUtc(processAfterIn, TIMEZONE); + if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`); + processAfter = d.toISOString(); + } catch { + return err(`invalid processAfter: ${processAfterIn}`); + } + + const id = generateId(); + const r = routing(); + const recurrence = (args.recurrence as string) || null; + const script = (args.script as string) || null; + + // Write as a system action — host will insert into inbound.db + writeMessageOut({ + id, + kind: 'system', + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.thread_id, + content: JSON.stringify({ + action: 'schedule_task', + taskId: id, + prompt, + script, + processAfter, + recurrence, + platformId: r.platform_id, + channelType: r.channel_type, + threadId: r.thread_id, + }), + }); + + log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`); + return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`); + }, +}; + +export const listTasks: McpToolDefinition = { + tool: { + name: 'list_tasks', + description: + 'List scheduled tasks. Returns one row per series — the live (pending or paused) occurrence. The id shown is the series id, which is what update_task / cancel_task / pause_task / resume_task expect.', + inputSchema: { + type: 'object' as const, + properties: { + status: { type: 'string', description: 'Filter by status: pending or paused (default: both)' }, + }, + }, + }, + async handler(args) { + const status = args.status as string | undefined; + const db = getInboundDb(); + // One row per series — the live (pending or paused) occurrence. Recurring + // tasks accumulate one completed row per firing plus one live follow-up; + // exposing the whole pile to the agent is noisy and confuses task identity + // ("which id do I cancel?"). The series_id is the stable handle. + // + // SQLite quirk: when MAX(seq) appears in the SELECT list of a GROUP BY + // query, the bare columns take values from the row that contains that max + // — that's how we pick "the latest live row per series" in one pass. + let rows; + if (status) { + rows = db + .prepare( + `SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq + FROM messages_in + WHERE kind = 'task' AND status = ? + GROUP BY series_id + ORDER BY process_after ASC`, + ) + .all(status); + } else { + rows = db + .prepare( + `SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq + FROM messages_in + WHERE kind = 'task' AND status IN ('pending', 'paused') + GROUP BY series_id + ORDER BY process_after ASC`, + ) + .all(); + } + + if ((rows as unknown[]).length === 0) return ok('No tasks found.'); + + const lines = (rows as Array<{ id: string; status: string; process_after: string | null; recurrence: string | null; content: string }>).map((r) => { + const content = JSON.parse(r.content); + const prompt = (content.prompt as string || '').slice(0, 80); + return `- ${r.id} [${r.status}] at=${r.process_after || 'now'} ${r.recurrence ? `recur=${r.recurrence} ` : ''}→ ${prompt}`; + }); + + return ok(lines.join('\n')); + }, +}; + +export const cancelTask: McpToolDefinition = { + tool: { + name: 'cancel_task', + description: 'Cancel a scheduled task.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID to cancel' }, + }, + required: ['taskId'], + }, + }, + async handler(args) { + const taskId = args.taskId as string; + if (!taskId) return err('taskId is required'); + + // Write as a system action — host will update inbound.db + writeMessageOut({ + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'system', + content: JSON.stringify({ action: 'cancel_task', taskId }), + }); + + log(`cancel_task: ${taskId}`); + return ok(`Task cancellation requested: ${taskId}`); + }, +}; + +export const pauseTask: McpToolDefinition = { + tool: { + name: 'pause_task', + description: 'Pause a scheduled task. It will not run until resumed.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID to pause' }, + }, + required: ['taskId'], + }, + }, + async handler(args) { + const taskId = args.taskId as string; + if (!taskId) return err('taskId is required'); + + writeMessageOut({ + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'system', + content: JSON.stringify({ action: 'pause_task', taskId }), + }); + + log(`pause_task: ${taskId}`); + return ok(`Task pause requested: ${taskId}`); + }, +}; + +export const resumeTask: McpToolDefinition = { + tool: { + name: 'resume_task', + description: 'Resume a paused task.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID to resume' }, + }, + required: ['taskId'], + }, + }, + async handler(args) { + const taskId = args.taskId as string; + if (!taskId) return err('taskId is required'); + + writeMessageOut({ + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'system', + content: JSON.stringify({ action: 'resume_task', taskId }), + }); + + log(`resume_task: ${taskId}`); + return ok(`Task resume requested: ${taskId}`); + }, +}; + +export const updateTask: McpToolDefinition = { + tool: { + name: 'update_task', + description: + 'Update a scheduled task. Pass the series id from list_tasks. Any field omitted is left unchanged. Use this instead of cancel + reschedule when adjusting an existing task.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Series id of the task to update (as shown by list_tasks)' }, + prompt: { type: 'string', description: 'New task prompt (optional)' }, + recurrence: { + type: 'string', + description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.', + }, + processAfter: { + type: 'string', + description: + `New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`, + }, + script: { + type: 'string', + description: 'New pre-agent script (optional). Pass empty string to clear.', + }, + }, + required: ['taskId'], + }, + }, + async handler(args) { + const taskId = args.taskId as string; + if (!taskId) return err('taskId is required'); + + const update: Record = { taskId }; + if (typeof args.prompt === 'string') update.prompt = args.prompt; + if (typeof args.processAfter === 'string') { + try { + const d = parseZonedToUtc(args.processAfter, TIMEZONE); + if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`); + update.processAfter = d.toISOString(); + } catch { + return err(`invalid processAfter: ${args.processAfter}`); + } + } + // Empty string clears recurrence/script; undefined leaves them as-is. + if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence; + if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script; + + if (Object.keys(update).length === 1) return err('at least one field to update is required'); + + writeMessageOut({ + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'system', + content: JSON.stringify({ action: 'update_task', ...update }), + }); + + log(`update_task: ${taskId}`); + return ok(`Task update requested: ${taskId}`); + }, +}; + +registerTools([scheduleTask, listTasks, updateTask, cancelTask, pauseTask, resumeTask]); diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/self-mod.instructions.md b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/self-mod.instructions.md new file mode 100644 index 0000000..b2a4b3a --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/self-mod.instructions.md @@ -0,0 +1,25 @@ +## Installing packages & tools + +To install packages that persist, use the self-modification tools: + +**`install_packages`** — request system (apt) or global npm packages. Requires admin approval. + +Example flow: +``` +install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" }) +# → Admin gets an approval card → approves +``` + +**When to use this vs workspace `pnpm install`:** +- `pnpm install` if you only need it temporarily to do one task. Will not be available in subsequent truns. +- `install_packages` persists for all future turns. Use especially if the user specifically asks you to add a capability + +### MCP servers (`add_mcp_server`) + +Use **`add_mcp_server`** to add an MCP server to your configuration. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.: + +``` +add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] }) +``` + +Do not ask the user to give you credentials or tell them how to create credentials (OAuth, API keys, etc.) — NEVER fabricate credential setup instructions. Credentials are handled by the OneCLI gateway. Use `"onecli-managed"` as the placeholder value for any credential env vars or config fields. After the MCP server is installed and the container restarts, load `/onecli-gateway` for the full credential-handling flow (connect URLs, stubs, error recovery). diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/self-mod.ts b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/self-mod.ts new file mode 100644 index 0000000..3e2a2d8 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/self-mod.ts @@ -0,0 +1,120 @@ +/** + * Self-modification MCP tools: install_packages, add_mcp_server. + * + * Both are fire-and-forget — the tool writes a system action row and returns + * immediately. The host processes the request (including admin approval) + * and notifies the agent via a chat message when complete. Admin approval + * is approval to apply the change: `install_packages` auto-rebuilds the + * per-agent image and restarts the container; `add_mcp_server` just + * updates `container.json` and restarts (bun runs TS directly — no build + * step needed for a pure MCP wiring change). + * + * Package names are sanitized here at the tool boundary AND re-validated on + * the host side (defense in depth). + */ +import { writeMessageOut } from '../db/messages-out.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/; +const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const MAX_PACKAGES = 20; + +export const installPackages: McpToolDefinition = { + tool: { + name: 'install_packages', + description: + 'Install apt and/or npm packages into YOUR per-agent container image. Requires admin approval; fire-and-forget. On approval, the image is rebuilt and the container is restarted automatically.', + inputSchema: { + type: 'object' as const, + properties: { + apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install (names only, no version specs or flags)' }, + npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally (names only, no version specs)' }, + reason: { type: 'string', description: 'Why these packages are needed' }, + }, + }, + }, + async handler(args) { + const apt = (args.apt as string[]) || []; + const npm = (args.npm as string[]) || []; + if (apt.length === 0 && npm.length === 0) return err('At least one apt or npm package is required'); + if (apt.length + npm.length > MAX_PACKAGES) return err(`Maximum ${MAX_PACKAGES} packages per request`); + + const invalidApt = apt.find((p) => !APT_RE.test(p)); + if (invalidApt) return err(`Invalid apt package name: "${invalidApt}". Only lowercase letters, digits, and ._+- allowed.`); + const invalidNpm = npm.find((p) => !NPM_RE.test(p)); + if (invalidNpm) return err(`Invalid npm package name: "${invalidNpm}". No version specs or shell characters.`); + + const requestId = generateId(); + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'install_packages', + apt, + npm, + reason: (args.reason as string) || '', + }), + }); + + log(`install_packages: ${requestId} → apt=[${apt.join(',')}] npm=[${npm.join(',')}]`); + return ok(`Package install request submitted. You will be notified when admin approves or rejects.`); + }, +}; + +export const addMcpServer: McpToolDefinition = { + tool: { + name: 'add_mcp_server', + description: + 'Wire an EXISTING third-party MCP server into YOUR per-agent runtime config — you must already know the exact `command` + `args` to invoke it (e.g. `npx @modelcontextprotocol/server-github`). Requires admin approval; fire-and-forget.', + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'MCP server name (unique identifier)' }, + command: { type: 'string', description: 'Command to run the MCP server' }, + args: { type: 'array', items: { type: 'string' }, description: 'Command arguments' }, + env: { type: 'object', description: 'Environment variables for the server' }, + }, + required: ['name', 'command'], + }, + }, + async handler(args) { + const name = args.name as string; + const command = args.command as string; + if (!name || !command) return err('name and command are required'); + + const requestId = generateId(); + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'add_mcp_server', + name, + command, + args: (args.args as string[]) || [], + env: (args.env as Record) || {}, + }), + }); + + log(`add_mcp_server: ${requestId} → "${name}" (${command})`); + return ok(`MCP server request submitted. You will be notified when admin approves or rejects.`); + }, +}; + +registerTools([installPackages, addMcpServer]); diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/server.ts b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/server.ts new file mode 100644 index 0000000..3df45ed --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/server.ts @@ -0,0 +1,54 @@ +/** + * MCP server bootstrap + tool self-registration. + * + * Each tool module calls `registerTools([...])` at import time. The + * barrel (`index.ts`) imports every tool module for side effects, then + * calls `startMcpServer()` which uses whatever was registered. + * + * Default when only `core.ts` is imported: the core `send_message` / + * `send_file` / `edit_message` / `add_reaction` tools are available. + */ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +const allTools: McpToolDefinition[] = []; +const toolMap = new Map(); + +export function registerTools(tools: McpToolDefinition[]): void { + for (const t of tools) { + if (toolMap.has(t.tool.name)) { + log(`Warning: tool "${t.tool.name}" already registered, skipping duplicate`); + continue; + } + allTools.push(t); + toolMap.set(t.tool.name, t); + } +} + +export async function startMcpServer(): Promise { + const server = new Server({ name: 'nanoclaw', version: '2.0.0' }, { capabilities: { tools: {} } }); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: allTools.map((t) => t.tool), + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const tool = toolMap.get(name); + if (!tool) { + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] }; + } + return tool.handler(args ?? {}); + }); + + const transport = new StdioServerTransport(); + await server.connect(transport); + log(`MCP server started with ${allTools.length} tools: ${allTools.map((t) => t.tool.name).join(', ')}`); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/types.ts b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/types.ts new file mode 100644 index 0000000..d4637d0 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/mcp-tools/types.ts @@ -0,0 +1,6 @@ +import type { Tool, CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +export interface McpToolDefinition { + tool: Tool; + handler: (args: Record) => Promise; +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/poll-loop.test.ts b/packages/iclaw-runtime/container/agent-runner/src/poll-loop.test.ts new file mode 100644 index 0000000..7b85faa --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/poll-loop.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; +import { getPendingMessages, markCompleted } from './db/messages-in.js'; +import { getUndeliveredMessages } from './db/messages-out.js'; +import { formatMessages, extractRouting } from './formatter.js'; +import { isCorruptionError } from './poll-loop.js'; +import { MockProvider } from './providers/mock.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function insertMessage( + id: string, + kind: string, + content: object, + opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 }, +) { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`, + ) + .run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content)); +} + +describe('formatter', () => { + it('should format a single chat message', () => { + insertMessage('m1', 'chat', { sender: 'John', text: 'Hello world' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('sender="John"'); + expect(prompt).toContain('Hello world'); + }); + + it('should format multiple chat messages as distinct blocks', () => { + insertMessage('m1', 'chat', { sender: 'John', text: 'Hello' }); + insertMessage('m2', 'chat', { sender: 'Jane', text: 'Hi there' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + // The envelope was dropped in fe2e881b (#2556) so the SDK calls + // the API; each message is now its own self-contained block. + expect(prompt).not.toContain(''); + expect(prompt.match(/ { + insertMessage('m1', 'task', { prompt: 'Review open PRs' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain(' { + insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain(' { + insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain(' { + insertMessage('m1', 'chat', { sender: 'John', text: 'Hello' }); + insertMessage('m2', 'system', { action: 'test', status: 'ok', result: null }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('sender="John"'); + expect(prompt).toContain(' { + insertMessage('m1', 'chat', { sender: 'A y && z' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('A<B'); + expect(prompt).toContain('x > y && z'); + }); +}); + +describe('accumulate gate (trigger column)', () => { + it('getPendingMessages returns both trigger=0 and trigger=1 rows', () => { + // trigger=0 rides along as context, trigger=1 is the wake-eligible row. + // The poll loop's gate depends on this data contract. + insertMessage('m1', 'chat', { sender: 'A', text: 'chit chat' }, { trigger: 0 }); + insertMessage('m2', 'chat', { sender: 'B', text: 'actual mention' }, { trigger: 1 }); + const messages = getPendingMessages(); + expect(messages).toHaveLength(2); + const byId = Object.fromEntries(messages.map((m) => [m.id, m])); + expect(byId.m1.trigger).toBe(0); + expect(byId.m2.trigger).toBe(1); + }); + + it('trigger=0-only batch: gate predicate `some(trigger===1)` is false', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'noise' }, { trigger: 0 }); + insertMessage('m2', 'chat', { sender: 'B', text: 'more noise' }, { trigger: 0 }); + const messages = getPendingMessages(); + // This is the exact predicate the poll loop uses to skip accumulate-only + // batches — gate should be false, so the loop sleeps without waking the agent. + expect(messages.some((m) => m.trigger === 1)).toBe(false); + }); + + it('mixed batch: gate is true → loop proceeds, accumulated rows ride along', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'earlier chatter' }, { trigger: 0 }); + insertMessage('m2', 'chat', { sender: 'B', text: 'the real mention' }, { trigger: 1 }); + const messages = getPendingMessages(); + expect(messages.some((m) => m.trigger === 1)).toBe(true); + // Both messages are present for the formatter → agent sees the prior context. + expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']); + }); + + it('trigger column defaults to 1 for legacy inserts without explicit value', () => { + // The schema default is 1 (see src/db/schema.ts INBOUND_SCHEMA) — existing + // rows / tests without the column set are effectively wake-eligible. + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, content) + VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`, + ) + .run(); + const [msg] = getPendingMessages(); + expect(msg.trigger).toBe(1); + }); +}); + +describe('on_wake filtering', () => { + it('first poll returns on_wake=1 messages', () => { + insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(true); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe('m1'); + }); + + it('subsequent polls skip on_wake=1 messages', () => { + insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(false); + expect(messages).toHaveLength(0); + }); + + it('normal messages returned regardless of isFirstPoll', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'hello' }); + expect(getPendingMessages(true)).toHaveLength(1); + + // Reset: mark completed so we can re-test with a fresh message + markCompleted(['m1']); + insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' }); + expect(getPendingMessages(false)).toHaveLength(1); + }); + + it('mixed batch: first poll returns both normal and on_wake messages', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' }); + insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(true); + expect(messages).toHaveLength(2); + expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']); + }); + + it('mixed batch: subsequent poll returns only normal messages', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' }); + insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(false); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe('m1'); + }); + + it('on_wake defaults to 0 for inserts without explicit value', () => { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, content) + VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`, + ) + .run(); + // Should be returned even on non-first poll (on_wake=0) + expect(getPendingMessages(false)).toHaveLength(1); + }); +}); + +describe('routing', () => { + it('should extract routing from messages', () => { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content) + VALUES ('m1', 'chat', datetime('now'), 'pending', 'chan-123', 'discord', 'thread-456', '{"text":"hi"}')`, + ) + .run(); + + const messages = getPendingMessages(); + const routing = extractRouting(messages); + expect(routing.platformId).toBe('chan-123'); + expect(routing.channelType).toBe('discord'); + expect(routing.threadId).toBe('thread-456'); + expect(routing.inReplyTo).toBe('m1'); + }); +}); + +describe('origin metadata (from= attribute)', () => { + function seedDestination(name: string, channelType: string, platformId: string): void { + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES (?, ?, 'channel', ?, ?, NULL)`, + ) + .run(name, name, channelType, platformId); + } + + function insertWithRouting(id: string, kind: string, content: object, channelType: string | null, platformId: string | null): void { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`, + ) + .run(id, kind, platformId, channelType, JSON.stringify(content)); + } + + it('chat message includes from= when destination matches', () => { + seedDestination('discord-main', 'discord', 'chan-1'); + insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'discord', 'chan-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain('from="discord-main"'); + }); + + it('chat message falls back to raw routing when no destination matches', () => { + insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'telegram', 'chat-999'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain('from="unknown:telegram:chat-999"'); + }); + + it('chat message omits from= when routing is null', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).not.toContain('from='); + }); + + it('task message includes from= when destination matches', () => { + seedDestination('slack-ops', 'slack', 'C-OPS'); + insertWithRouting('t1', 'task', { prompt: 'check status' }, 'slack', 'C-OPS'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + insertMessage('t1', 'task', { prompt: 'check status' }); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + seedDestination('github-ch', 'github', 'repo-1'); + insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + seedDestination('discord-main', 'discord', 'chan-1'); + insertWithRouting('s1', 'system', { action: 'test', status: 'ok', result: null }, 'discord', 'chan-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + it('should produce init + result events', async () => { + const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`); + const query = provider.query({ + prompt: 'Hello', + cwd: '/tmp', + }); + + const events: Array<{ type: string }> = []; + setTimeout(() => query.end(), 50); + + for await (const event of query.events) { + events.push(event); + } + + const typed = events.filter((e) => e.type !== 'activity'); + expect(typed.length).toBeGreaterThanOrEqual(2); + expect(typed[0].type).toBe('init'); + expect(typed[1].type).toBe('result'); + expect((typed[1] as { text: string }).text).toBe('Echo: Hello'); + }); + + it('should handle push() during active query', async () => { + const provider = new MockProvider({}, (prompt) => `Re: ${prompt}`); + const query = provider.query({ + prompt: 'First', + cwd: '/tmp', + }); + + const events: Array<{ type: string; text?: string }> = []; + + setTimeout(() => query.push('Second'), 30); + setTimeout(() => query.end(), 60); + + for await (const event of query.events) { + events.push(event); + } + + const results = events.filter((e) => e.type === 'result'); + expect(results).toHaveLength(2); + expect(results[0].text).toBe('Re: First'); + expect(results[1].text).toBe('Re: Second'); + }); +}); + +describe('end-to-end with mock provider', () => { + it('should read messages_in, process with mock provider, write messages_out', async () => { + // Insert a chat message into inbound DB + insertMessage('m1', 'chat', { sender: 'User', text: 'What is 2+2?' }); + + // Read and process + const messages = getPendingMessages(); + expect(messages).toHaveLength(1); + + const routing = extractRouting(messages); + const prompt = formatMessages(messages); + + // Create mock provider and run query + const provider = new MockProvider({}, () => 'The answer is 4'); + const query = provider.query({ + prompt, + cwd: '/tmp', + }); + + // Process events — simulate what poll-loop does + const { markProcessing } = await import('./db/messages-in.js'); + const { writeMessageOut } = await import('./db/messages-out.js'); + + markProcessing(['m1']); + + setTimeout(() => query.end(), 50); + + for await (const event of query.events) { + if (event.type === 'result' && event.text) { + writeMessageOut({ + id: `out-${Date.now()}`, + in_reply_to: routing.inReplyTo, + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: event.text }), + }); + } + } + + markCompleted(['m1']); + + // Verify: message was processed (not pending, acked in processing_ack) + const processed = getPendingMessages(); + expect(processed).toHaveLength(0); + + // Verify: response was written to outbound DB + const outMessages = getUndeliveredMessages(); + expect(outMessages).toHaveLength(1); + expect(JSON.parse(outMessages[0].content).text).toBe('The answer is 4'); + expect(outMessages[0].in_reply_to).toBe('m1'); + }); +}); + +describe('isCorruptionError', () => { + it('matches the Docker Desktop macOS torn-read symptom', () => { + expect(isCorruptionError('database disk image is malformed')).toBe(true); + }); + + it('matches wrapped SQLite corruption codes', () => { + expect(isCorruptionError('SqliteError: SQLITE_CORRUPT_VTAB: ...')).toBe(true); + expect(isCorruptionError('file is not a database')).toBe(true); + }); + + it('returns false for unrelated errors', () => { + expect(isCorruptionError('database is locked')).toBe(false); + expect(isCorruptionError('no such table: messages_in')).toBe(false); + expect(isCorruptionError('')).toBe(false); + }); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/poll-loop.ts b/packages/iclaw-runtime/container/agent-runner/src/poll-loop.ts new file mode 100644 index 0000000..51d7af3 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/poll-loop.ts @@ -0,0 +1,595 @@ +import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js'; +import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; +import { writeMessageOut } from './db/messages-out.js'; +import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; +import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js'; +import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js'; +import { + formatMessages, + extractRouting, + categorizeMessage, + isClearCommand, + isRunnerCommand, + stripInternalTags, + type RoutingContext, +} from './formatter.js'; +import { isUploadTraceCommand, uploadTrace } from './upload-trace.js'; +import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; + +const POLL_INTERVAL_MS = 1000; +const ACTIVE_POLL_INTERVAL_MS = 500; + +/** + * Number of consecutive `database disk image is malformed` errors after which + * the follow-up poll gives up and exits the process. At ACTIVE_POLL_INTERVAL_MS + * = 500ms this is roughly 5 seconds — long enough to dodge a transient torn + * read during a host write, short enough to recover quickly from a poisoned + * page cache (host-sweep then respawns with a fresh mount). + */ +const CORRUPTION_STREAK_EXIT = 10; + +/** + * True for SQLite errors that indicate a corrupt READ view — almost always a + * cross-mount page-cache coherency issue on Docker Desktop macOS rather than + * actual file damage (host-side integrity_check passes). Reopening the DB + * handle inside this process does NOT recover; only a fresh container mount + * does. Caller's job is to exit so host-sweep respawns the container. + */ +export function isCorruptionError(msg: string): boolean { + return ( + msg.includes('database disk image is malformed') || + msg.includes('SQLITE_CORRUPT') || + msg.includes('file is not a database') + ); +} + +function log(msg: string): void { + console.error(`[poll-loop] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export interface PollLoopConfig { + provider: AgentProvider; + /** + * Name of the provider (e.g. "claude", "codex", "opencode"). Used to key + * the stored continuation per-provider so flipping providers doesn't + * resurrect a stale id from a different backend. + */ + providerName: string; + cwd: string; + systemContext?: { + instructions?: string; + }; +} + +/** + * Main poll loop. Runs indefinitely until the process is killed. + * + * 1. Poll messages_in for pending rows + * 2. Format into prompt, call provider.query() + * 3. While query active: continue polling, push new messages via provider.push() + * 4. On result: write messages_out + * 5. Mark messages completed + * 6. Loop + */ +export async function runPollLoop(config: PollLoopConfig): Promise { + // Resume the agent's prior session from a previous container run if one + // was persisted. The continuation is opaque to the poll-loop — the + // provider decides how to use it (Claude resumes a .jsonl transcript, + // other providers may reload a thread ID, etc.). Keyed per-provider so + // a Codex thread id never gets handed to Claude or vice versa. + let continuation: string | undefined = migrateLegacyContinuation(config.providerName); + + // Before resuming, drop a session whose on-disk transcript has grown too + // large/old to cold-resume within the host's idle ceiling. Without this a + // long-lived hub keeps trying to reload an ever-growing .jsonl, hangs the + // first turn, and gets killed before it can reply (then repeats forever). + if (continuation) { + const rotateReason = config.provider.maybeRotateContinuation?.(continuation, config.cwd); + if (rotateReason) { + log(`Rotating session — ${rotateReason}; starting fresh`); + clearContinuation(config.providerName); + continuation = undefined; + } + } + + if (continuation) { + log(`Resuming agent session ${continuation}`); + } + + // Clear leftover 'processing' acks from a previous crashed container. + // This lets the new container re-process those messages. + clearStaleProcessingAcks(); + + let pollCount = 0; + let isFirstPoll = true; + while (true) { + // Skip system messages — they're responses for MCP tools (e.g., ask_user_question) + const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system'); + isFirstPoll = false; + pollCount++; + + // Periodic heartbeat so we know the loop is alive + if (pollCount % 30 === 0) { + log(`Poll heartbeat (${pollCount} iterations, ${messages.length} pending)`); + } + + if (messages.length === 0) { + await sleep(POLL_INTERVAL_MS); + continue; + } + + // Accumulate gate: if the batch contains only trigger=0 rows + // (context-only, router-stored under ignored_message_policy='accumulate'), + // don't wake the agent. Leave them `pending` — they'll ride along the + // next time a real trigger=1 message lands via this same getPendingMessages + // query. Without this gate, a warm container keeps processing + // (and potentially responding to) every accumulate-only batch, defeating + // the "store as context, don't engage" contract. Host-side countDueMessages + // gates the same way for wake-from-cold (see src/db/session-db.ts). + if (!messages.some((m) => m.trigger === 1)) { + await sleep(POLL_INTERVAL_MS); + continue; + } + + const ids = messages.map((m) => m.id); + markProcessing(ids); + + const routing = extractRouting(messages); + + // Command handling: the host router gates filtered and unauthorized + // admin commands before they reach the container. The only command + // the runner handles directly is /clear (session reset). + const normalMessages: MessageInRow[] = []; + const commandIds: string[] = []; + + for (const msg of messages) { + if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) { + log('Clearing session (resetting continuation)'); + continuation = undefined; + clearContinuation(config.providerName); + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: 'Session cleared.' }), + }); + commandIds.push(msg.id); + continue; + } + if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isUploadTraceCommand(msg)) { + log('Uploading session trace to Hugging Face'); + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: uploadTrace() }), + }); + commandIds.push(msg.id); + continue; + } + normalMessages.push(msg); + } + + if (commandIds.length > 0) { + markCompleted(commandIds); + } + + if (normalMessages.length === 0) { + const remainingIds = ids.filter((id) => !commandIds.includes(id)); + if (remainingIds.length > 0) markCompleted(remainingIds); + log(`All ${messages.length} message(s) were commands, skipping query`); + continue; + } + + // Pre-task scripts: for any task rows with a `script`, run it before the + // provider call. Scripts returning wakeAgent=false (or erroring) gate + // their own task row only — surviving messages still go to the agent. + // Without the scheduling module, the marker block is empty, `keep` + // falls back to `normalMessages`, and no gating happens. + let keep: MessageInRow[] = normalMessages; + let skipped: string[] = []; + // MODULE-HOOK:scheduling-pre-task:start + const { applyPreTaskScripts } = await import('./scheduling/task-script.js'); + const preTask = await applyPreTaskScripts(normalMessages); + keep = preTask.keep; + skipped = preTask.skipped; + if (skipped.length > 0) { + markCompleted(skipped); + log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`); + } + // MODULE-HOOK:scheduling-pre-task:end + + if (keep.length === 0) { + log(`All ${normalMessages.length} non-command message(s) gated by script, skipping query`); + continue; + } + + // Format messages: passthrough commands get raw text (only if the + // provider natively handles slash commands), others get XML. + const prompt = formatMessagesWithCommands(keep, config.provider.supportsNativeSlashCommands); + + log(`Processing ${keep.length} message(s), kinds: ${[...new Set(keep.map((m) => m.kind))].join(',')}`); + + const query = config.provider.query({ + prompt, + continuation, + cwd: config.cwd, + systemContext: config.systemContext, + }); + + // Process the query while concurrently polling for new messages + const skippedSet = new Set(skipped); + const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); + // Publish the batch's in_reply_to so MCP tools (send_message, send_file) + // can stamp it on outbound rows — needed for a2a return-path routing. + setCurrentInReplyTo(routing.inReplyTo); + try { + const result = await processQuery(query, routing, processingIds, config.providerName); + if (result.continuation && result.continuation !== continuation) { + continuation = result.continuation; + setContinuation(config.providerName, continuation); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + log(`Query error: ${errMsg}`); + + // Stale/corrupt continuation recovery: ask the provider whether + // this error means the stored continuation is unusable, and clear + // it so the next attempt starts fresh. + if (continuation && config.provider.isSessionInvalid(err)) { + log(`Stale session detected (${continuation}) — clearing for next retry`); + continuation = undefined; + clearContinuation(config.providerName); + } + + // Write error response so the user knows something went wrong + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); + } finally { + clearCurrentInReplyTo(); + } + + // Ensure completed even if processQuery ended without a result event + // (e.g. stream closed unexpectedly). + markCompleted(processingIds); + log(`Completed ${ids.length} message(s)`); + } +} + +/** + * Format messages, handling passthrough commands differently. + * When the provider handles slash commands natively (Claude Code), + * passthrough commands are sent raw (no XML wrapping) so the SDK can + * dispatch them. Otherwise they fall through to standard XML formatting. + */ +function formatMessagesWithCommands(messages: MessageInRow[], nativeSlashCommands: boolean): string { + const parts: string[] = []; + const normalBatch: MessageInRow[] = []; + + for (const msg of messages) { + if (nativeSlashCommands && (msg.kind === 'chat' || msg.kind === 'chat-sdk')) { + const cmdInfo = categorizeMessage(msg); + if (cmdInfo.category === 'passthrough' || cmdInfo.category === 'admin') { + // Flush normal batch first + if (normalBatch.length > 0) { + parts.push(formatMessages(normalBatch)); + normalBatch.length = 0; + } + // Pass raw command text (no XML wrapping) — SDK handles it natively + parts.push(cmdInfo.text); + continue; + } + } + normalBatch.push(msg); + } + + if (normalBatch.length > 0) { + parts.push(formatMessages(normalBatch)); + } + + return parts.join('\n\n'); +} + +interface QueryResult { + continuation?: string; +} + +async function processQuery( + query: AgentQuery, + routing: RoutingContext, + initialBatchIds: string[], + providerName: string, +): Promise { + let queryContinuation: string | undefined; + let done = false; + let unwrappedNudged = false; + + // Concurrent polling: push follow-ups into the active query as they arrive. + // We do NOT force-end the stream on silence — keeping the query open avoids + // re-spawning the SDK subprocess (~few seconds) and re-loading the .jsonl + // transcript on every turn. The Anthropic prompt cache is server-side with + // a 5-min TTL keyed on prefix hash, so stream lifecycle does NOT affect + // cache lifetime — close+reopen within 5 min still gets cache hits. + // Stream liveness is decided host-side via the heartbeat file + processing + // claim age (see src/host-sweep.ts); if something is truly stuck, the host + // will kill the container and messages get reset to pending. + let pollInFlight = false; + let endedForCommand = false; + let corruptionStreak = 0; + const pollHandle = setInterval(() => { + if (done || pollInFlight || endedForCommand) return; + pollInFlight = true; + + void (async () => { + try { + const pending = getPendingMessages(); + + // Slash commands need a fresh query: /clear resets the SDK's + // resume id (fixed at sdkQuery() time); admin/passthrough commands + // (/compact, /cost, …) only dispatch when they're the first input + // of a query — pushed mid-stream they arrive as plain text and + // the SDK never runs them. End the stream and leave the rows + // pending; the outer loop handles them on next iteration via the + // canonical command path + formatMessagesWithCommands. + if (pending.some((m) => isRunnerCommand(m))) { + log('Pending slash command — ending stream so outer loop can process'); + endedForCommand = true; + query.end(); + return; + } + + // Skip system messages (MCP tool responses). + // Thread routing is the router's concern — if a message landed in this + // session, the agent should see it. Per-thread sessions already isolate + // threads into separate containers; shared sessions intentionally merge + // everything. Filtering on thread_id here caused deadlocks when the + // initial batch and follow-ups had mismatched thread_ids (e.g. a + // host-generated welcome trigger with null thread vs a Discord DM reply). + const newMessages = pending.filter((m) => m.kind !== 'system'); + if (newMessages.length === 0) return; + + const newIds = newMessages.map((m) => m.id); + markProcessing(newIds); + + // Run pre-task scripts on follow-ups too — without this, a task that + // arrives during an active query (e.g. a */10 monitoring cron) bypasses + // its script gate and always wakes the agent, defeating the gate. + // Mirrors the initial-batch hook above. + let keep = newMessages; + let skipped: string[] = []; + // MODULE-HOOK:scheduling-pre-task-followup:start + const { applyPreTaskScripts } = await import('./scheduling/task-script.js'); + const preTask = await applyPreTaskScripts(newMessages); + keep = preTask.keep; + skipped = preTask.skipped; + if (skipped.length > 0) { + markCompleted(skipped); + log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`); + } + // MODULE-HOOK:scheduling-pre-task-followup:end + + if (keep.length === 0) return; + // Re-check done — the outer query may have finished while the script + // was awaited. Pushing into a closed stream is wasted work; the + // claimed messages get released by the host's processing-claim sweep. + if (done) return; + + const keptIds = keep.map((m) => m.id); + const prompt = formatMessages(keep); + log(`Pushing ${keep.length} follow-up message(s) into active query`); + unwrappedNudged = false; + query.push(prompt); + markCompleted(keptIds); + } catch (err) { + // Without this catch the rejection escapes the void IIFE and Node + // terminates the container on unhandled-rejection. The initial-batch + // path is wrapped by processQuery's outer try/catch; the follow-up + // path is not, so it needs its own. + const errMsg = err instanceof Error ? err.message : String(err); + log(`Follow-up poll error: ${errMsg}`); + + // Detect SQLite cross-mount corruption (Docker Desktop macOS virtiofs / + // gRPC-FUSE coherency bug — the kernel page cache for the inbound.db + // bind mount can latch a torn snapshot mid-host-write, after which + // every fresh openInboundDb() in this process sees the same broken + // view. Reopening inside the container does NOT recover; only a fresh + // container mount does. Exit so the host sweep respawns us. + if (isCorruptionError(errMsg)) { + corruptionStreak += 1; + if (corruptionStreak >= CORRUPTION_STREAK_EXIT) { + log( + `Follow-up poll: ${corruptionStreak} consecutive '${errMsg}' errors — ` + + `inbound.db page cache is poisoned. Exiting so host respawns with a fresh mount.`, + ); + // Stop touching the heartbeat so host-sweep stale detection fires + // promptly even if exit() races with in-flight async work. + done = true; + clearInterval(pollHandle); + // Defer exit one tick so this log line flushes through Docker's + // log driver before the process dies. + setTimeout(() => process.exit(75), 100); + } + } else { + corruptionStreak = 0; + } + } finally { + pollInFlight = false; + } + })(); + }, ACTIVE_POLL_INTERVAL_MS); + + try { + for await (const event of query.events) { + handleEvent(event, routing); + touchHeartbeat(); + + if (event.type === 'init') { + queryContinuation = event.continuation; + // Persist immediately so a mid-turn container crash still lets the + // next wake resume the conversation. Without this, the session id + // was only written after the full stream completed — if the + // container died between `init` and `result`, the SDK session was + // effectively orphaned and the next message started a blank + // Claude session with no prior context. + setContinuation(providerName, event.continuation); + } else if (event.type === 'result') { + // A result — with or without text — means the turn is done. Mark + // the initial batch completed now so the host sweep doesn't see + // stale 'processing' claims while the query stays open for + // follow-up pushes. The agent may have responded via MCP + // (send_message) mid-turn, or the message may not need a response + // at all — either way the turn is finished. + markCompleted(initialBatchIds); + if (event.text) { + const { hasUnwrapped } = dispatchResultText(event.text, routing); + if (hasUnwrapped && !unwrappedNudged) { + unwrappedNudged = true; + const destinations = getAllDestinations(); + const names = destinations.map((d) => d.name).join(', '); + query.push( + `Your response was not delivered — it was not wrapped in ... blocks. ` + + `All output must be wrapped: use for content to send, or for scratchpad. ` + + `Your destinations: ${names}. ` + + `Please re-send your response with the correct wrapping.`, + ); + } + } + } + } + } finally { + done = true; + clearInterval(pollHandle); + } + + return { continuation: queryContinuation }; +} + +function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { + switch (event.type) { + case 'init': + log(`Session: ${event.continuation}`); + break; + case 'result': + log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`); + break; + case 'error': + log( + `Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`, + ); + break; + case 'progress': + log(`Progress: ${event.message}`); + break; + } +} + +/** + * Parse the agent's final text for ... blocks + * and dispatch each one to its resolved destination. Text outside of blocks + * (including ...) is scratchpad — logged but not sent. + * + * The agent must always wrap output in ... + * blocks, even with a single destination. Bare text is scratchpad only. + */ +function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } { + const MESSAGE_RE = /([\s\S]*?)<\/message>/g; + + let match: RegExpExecArray | null; + let sent = 0; + let lastIndex = 0; + const scratchpadParts: string[] = []; + + while ((match = MESSAGE_RE.exec(text)) !== null) { + if (match.index > lastIndex) { + scratchpadParts.push(text.slice(lastIndex, match.index)); + } + const toName = match[1]; + const body = match[2].trim(); + lastIndex = MESSAGE_RE.lastIndex; + + const dest = findByName(toName); + if (!dest) { + log(`Unknown destination in , dropping block`); + scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`); + continue; + } + sendToDestination(dest, body, routing); + sent++; + } + if (lastIndex < text.length) { + scratchpadParts.push(text.slice(lastIndex)); + } + + const scratchpad = stripInternalTags(scratchpadParts.join('')); + + if (scratchpad) { + log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); + } + + const hasUnwrapped = sent === 0 && !!scratchpad; + if (hasUnwrapped) { + log(`WARNING: agent output had no blocks — nothing was sent`); + } + return { sent, hasUnwrapped }; +} + +function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void { + const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; + const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; + // Resolve thread_id per-destination from the most recent inbound message + // that came from this same channel+platform. In agent-shared sessions, + // different destinations have different thread contexts — using a single + // routing.threadId would stamp one channel's thread onto another. + const destRouting = resolveDestinationThread(channelType, platformId); + writeMessageOut({ + id: generateId(), + in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo, + kind: 'chat', + platform_id: platformId, + channel_type: channelType, + thread_id: destRouting?.threadId ?? null, + content: JSON.stringify({ text: body }), + }); +} + +/** + * Find the thread_id and message id from the most recent inbound message + * matching the given channel+platform. Returns null if no match found. + */ +function resolveDestinationThread( + channelType: string, + platformId: string, +): { threadId: string | null; inReplyTo: string | null } | null { + try { + const db = getInboundDb(); + const row = db + .prepare( + `SELECT thread_id, id FROM messages_in + WHERE channel_type = ? AND platform_id = ? + ORDER BY seq DESC LIMIT 1`, + ) + .get(channelType, platformId) as { thread_id: string | null; id: string } | undefined; + if (row) return { threadId: row.thread_id, inReplyTo: row.id }; + } catch (err) { + log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`); + } + return null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/providers/claude.rotate.test.ts b/packages/iclaw-runtime/container/agent-runner/src/providers/claude.rotate.test.ts new file mode 100644 index 0000000..2d22f24 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/providers/claude.rotate.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { ClaudeProvider } from './claude.js'; + +// maybeRotateContinuation guards the cold-resume failure mode: a long-lived +// session whose on-disk transcript has grown so large (or old) that the SDK +// can't reload it before the host's idle ceiling kills the container. + +let tmp: string; +let prevHome: string | undefined; +let prevConv: string | undefined; +let prevBytes: string | undefined; +let prevDays: string | undefined; + +const PROJECT_DIR = '-workspace-agent'; +const CWD = '/workspace/agent'; + +function writeTranscript(sessionId: string, bytes: number, firstTs?: string): string { + const dir = path.join(tmp, '.claude', 'projects', PROJECT_DIR); + fs.mkdirSync(dir, { recursive: true }); + const p = path.join(dir, `${sessionId}.jsonl`); + const first = + JSON.stringify({ + type: 'user', + timestamp: firstTs ?? new Date().toISOString(), + message: { role: 'user', content: 'hello' }, + }) + '\n'; + const filler = 'x'.repeat(Math.max(0, bytes - first.length)); + fs.writeFileSync(p, first + filler); + return p; +} + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-rotate-')); + prevHome = process.env.HOME; + prevConv = process.env.NANOCLAW_CONVERSATIONS_DIR; + prevBytes = process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES; + prevDays = process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS; + process.env.HOME = tmp; + delete process.env.CLAUDE_CONFIG_DIR; + process.env.NANOCLAW_CONVERSATIONS_DIR = path.join(tmp, 'conversations'); +}); + +afterEach(() => { + const restore = (k: string, v: string | undefined) => (v === undefined ? delete process.env[k] : (process.env[k] = v)); + restore('HOME', prevHome); + restore('NANOCLAW_CONVERSATIONS_DIR', prevConv); + restore('CLAUDE_TRANSCRIPT_ROTATE_BYTES', prevBytes); + restore('CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS', prevDays); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('ClaudeProvider.maybeRotateContinuation', () => { + it('keeps a small, recent transcript (returns null, leaves file in place)', () => { + process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(1024 * 1024); + const p = writeTranscript('sess-small', 4096); + const provider = new ClaudeProvider(); + expect(provider.maybeRotateContinuation('sess-small', CWD)).toBeNull(); + expect(fs.existsSync(p)).toBe(true); + }); + + it('rotates an oversized transcript (returns reason, moves the .jsonl aside)', () => { + process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(64 * 1024); + const p = writeTranscript('sess-big', 200 * 1024); + const provider = new ClaudeProvider(); + const reason = provider.maybeRotateContinuation('sess-big', CWD); + expect(reason).toContain('MB'); + expect(fs.existsSync(p)).toBe(false); // original moved out of the resume path + const dir = path.dirname(p); + expect(fs.readdirSync(dir).some((f) => f.startsWith('sess-big.jsonl.rotated-'))).toBe(true); + }); + + it('rotates an aged transcript even when small', () => { + process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(1024 * 1024); + process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS = '7'; + const old = new Date(Date.now() - 10 * 86400_000).toISOString(); + writeTranscript('sess-old', 2048, old); + const provider = new ClaudeProvider(); + expect(provider.maybeRotateContinuation('sess-old', CWD)).toContain('d'); + }); + + it('returns null for an unknown session id', () => { + const provider = new ClaudeProvider(); + expect(provider.maybeRotateContinuation('does-not-exist', CWD)).toBeNull(); + }); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/providers/claude.ts b/packages/iclaw-runtime/container/agent-runner/src/providers/claude.ts new file mode 100644 index 0000000..0653d60 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/providers/claude.ts @@ -0,0 +1,473 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; + +import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js'; +import { registerProvider } from './provider-registry.js'; +import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; + +function log(msg: string): void { + console.error(`[claude-provider] ${msg}`); +} + +// Deferred SDK builtins that either sidestep nanoclaw's own scheduling or +// don't fit our async message-passing model (they're designed for Claude +// Code's interactive UI and would hang here). +// +// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable +// scheduling via mcp__nanoclaw__schedule_task. +// - AskUserQuestion: SDK returns a placeholder instead of blocking on a +// real answer — we have mcp__nanoclaw__ask_user_question that persists +// the question and blocks on the real reply. +// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude +// Code UI affordances; in a headless container they'd appear stuck. +const SDK_DISALLOWED_TOOLS = [ + 'CronCreate', + 'CronDelete', + 'CronList', + 'ScheduleWakeup', + 'AskUserQuestion', + 'EnterPlanMode', + 'ExitPlanMode', + 'EnterWorktree', + 'ExitWorktree', +]; + +// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived +// at the call site from the registered `mcpServers` map so that any server +// added via `add_mcp_server` (or wired in container.json directly) is +// reachable to the agent — without this, the SDK's allowedTools filter +// silently drops every MCP namespace not listed here. +const TOOL_ALLOWLIST = [ + 'Bash', + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'WebSearch', + 'WebFetch', + 'Task', + 'TaskOutput', + 'TaskStop', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'TodoWrite', + 'ToolSearch', + 'Skill', + 'NotebookEdit', +]; + +// MCP server names are sanitized by the SDK when forming tool prefixes: +// any character outside [A-Za-z0-9_-] becomes '_'. Mirror that here so our +// allowlist patterns match what the SDK actually exposes. +function mcpAllowPattern(serverName: string): string { + return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`; +} + +interface SDKUserMessage { + type: 'user'; + message: { role: 'user'; content: string }; + parent_tool_use_id: null; + session_id: string; +} + +/** + * Push-based async iterable for streaming user messages to the Claude SDK. + */ +class MessageStream { + private queue: SDKUserMessage[] = []; + private waiting: (() => void) | null = null; + private done = false; + + push(text: string): void { + this.queue.push({ + type: 'user', + message: { role: 'user', content: text }, + parent_tool_use_id: null, + session_id: '', + }); + this.waiting?.(); + } + + end(): void { + this.done = true; + this.waiting?.(); + } + + async *[Symbol.asyncIterator](): AsyncGenerator { + while (true) { + while (this.queue.length > 0) { + yield this.queue.shift()!; + } + if (this.done) return; + await new Promise((r) => { + this.waiting = r; + }); + this.waiting = null; + } + } +} + +// ── Transcript archiving (PreCompact hook) ── + +interface ParsedMessage { + role: 'user' | 'assistant'; + content: string; +} + +function parseTranscript(content: string): ParsedMessage[] { + const messages: ParsedMessage[] = []; + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + if (entry.type === 'user' && entry.message?.content) { + const text = typeof entry.message.content === 'string' ? entry.message.content : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); + if (text) messages.push({ role: 'user', content: text }); + } else if (entry.type === 'assistant' && entry.message?.content) { + const textParts = entry.message.content.filter((c: { type: string }) => c.type === 'text').map((c: { text: string }) => c.text); + const text = textParts.join(''); + if (text) messages.push({ role: 'assistant', content: text }); + } + } catch { + /* skip unparseable lines */ + } + } + return messages; +} + +function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { + const now = new Date(); + const dateStr = now.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); + const lines = [`# ${title || 'Conversation'}`, '', `Archived: ${dateStr}`, '', '---', '']; + for (const msg of messages) { + const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant'; + const content = msg.content.length > 2000 ? msg.content.slice(0, 2000) + '...' : msg.content; + lines.push(`**${sender}**: ${content}`, ''); + } + return lines.join('\n'); +} + +/** + * PreToolUse hook: record the current tool + its declared timeout so the host + * sweep can widen its stuck tolerance while Bash is running a long-declared + * script. Defense-in-depth: if SDK_DISALLOWED_TOOLS slips through somehow, + * block the call here instead of letting the agent hang. + */ +const preToolUseHook: HookCallback = async (input) => { + const i = input as { tool_name?: string; tool_input?: Record }; + const toolName = i.tool_name ?? ''; + if (SDK_DISALLOWED_TOOLS.includes(toolName)) { + return { + decision: 'block', + stopReason: `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`, + } as unknown as ReturnType; + } + // Bash exposes its timeout via the tool_input.timeout field (ms). Any other + // tool: no declared timeout. + const declaredTimeoutMs = + toolName === 'Bash' && typeof i.tool_input?.timeout === 'number' ? (i.tool_input.timeout as number) : null; + try { + setContainerToolInFlight(toolName, declaredTimeoutMs); + } catch (err) { + log(`PreToolUse: failed to record container_state: ${err instanceof Error ? err.message : String(err)}`); + } + return { continue: true }; +}; + +/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */ +const postToolUseHook: HookCallback = async () => { + try { + clearContainerToolInFlight(); + } catch (err) { + log(`PostToolUse: failed to clear container_state: ${err instanceof Error ? err.message : String(err)}`); + } + return { continue: true }; +}; + +/** + * Read a Claude transcript .jsonl, render a markdown summary, and drop it into + * the agent's `conversations/` folder so context survives a compaction or a + * session rotation. Best-effort: returns false (and logs) on any failure. + */ +function archiveTranscriptFile(transcriptPath: string | undefined, sessionId: string | undefined, assistantName?: string): boolean { + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + log('No transcript found for archiving'); + return false; + } + + try { + const content = fs.readFileSync(transcriptPath, 'utf-8'); + const messages = parseTranscript(content); + if (messages.length === 0) return false; + + // Try to get summary from sessions index + let summary: string | undefined; + const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json'); + if (fs.existsSync(indexPath)) { + try { + const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); + summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary; + } catch { + /* ignore */ + } + } + + const name = summary + ? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) + : `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`; + + const conversationsDir = process.env.NANOCLAW_CONVERSATIONS_DIR || '/workspace/agent/conversations'; + fs.mkdirSync(conversationsDir, { recursive: true }); + const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`; + fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName)); + log(`Archived conversation to ${filename}`); + return true; + } catch (err) { + log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); + return false; + } +} + +function createPreCompactHook(assistantName?: string): HookCallback { + return async (input) => { + const preCompact = input as PreCompactHookInput; + archiveTranscriptFile(preCompact.transcript_path, preCompact.session_id, assistantName); + return {}; + }; +} + +// ── Continuation rotation (cold-resume guard) ── + +/** + * Resume cost is dominated by transcript size. Past this many bytes a fresh + * cold container can't reload the .jsonl before the host's 30-min idle ceiling + * fires, so the session is dropped and started clean. Operator-overridable. + */ +function transcriptRotateBytes(): number { + return Number(process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES) || 12 * 1024 * 1024; +} + +/** + * Secondary age trigger, measured from the transcript's first entry. 0 (or a + * non-positive value) disables the age check; size alone then governs. + */ +function transcriptRotateAgeMs(): number { + const raw = process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS; + if (raw === undefined || raw.trim() === '') return 14 * 86_400_000; + const days = Number(raw); + if (!Number.isFinite(days)) return 14 * 86_400_000; + // Explicit non-positive override disables the age check; size alone governs. + return days > 0 ? days * 86_400_000 : Infinity; +} + +function claudeProjectsDir(): string { + const base = process.env.CLAUDE_CONFIG_DIR || path.join(process.env.HOME || os.homedir(), '.claude'); + return path.join(base, 'projects'); +} + +/** + * Locate the .jsonl backing a session id. The SDK names project dirs by a + * mangled cwd; rather than reproduce that convention we scan project dirs for + * `.jsonl` (session ids are UUIDs, so this is unambiguous). + */ +function findTranscriptPath(sessionId: string): string | null { + const projects = claudeProjectsDir(); + let dirs: string[]; + try { + dirs = fs.readdirSync(projects); + } catch { + return null; + } + for (const dir of dirs) { + const candidate = path.join(projects, dir, `${sessionId}.jsonl`); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** Epoch-ms of the first transcript entry, or null if unreadable. */ +function transcriptStartMs(transcriptPath: string): number | null { + try { + const fd = fs.openSync(transcriptPath, 'r'); + try { + const buf = Buffer.alloc(4096); + const n = fs.readSync(fd, buf, 0, buf.length, 0); + const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0]; + const ts = JSON.parse(firstLine)?.timestamp; + const ms = ts ? Date.parse(ts) : NaN; + return Number.isNaN(ms) ? null : ms; + } finally { + fs.closeSync(fd); + } + } catch { + return null; + } +} + +// ── Provider ── + +/** + * Claude Code auto-compacts context at this window (tokens). Kept here so + * the generic bootstrap doesn't need to know about Claude-specific env vars. + * + * Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to + * raise or lower the threshold without editing source — useful when running + * with a 1M-context model variant or when emergency-tuning a deployment. + */ +const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000'; + +/** + * Stale-session detection. Matches Claude Code's error text when a + * resumed session can't be found — missing transcript .jsonl, unknown + * session ID, etc. + */ +const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; + +export class ClaudeProvider implements AgentProvider { + readonly supportsNativeSlashCommands = true; + + private assistantName?: string; + private mcpServers: Record; + private env: Record; + private additionalDirectories?: string[]; + private model?: string; + private effort?: string; + + constructor(options: ProviderOptions = {}) { + this.assistantName = options.assistantName; + this.mcpServers = options.mcpServers ?? {}; + this.additionalDirectories = options.additionalDirectories; + this.model = options.model; + this.effort = options.effort; + this.env = { + ...(options.env ?? {}), + CLAUDE_CODE_AUTO_COMPACT_WINDOW, + }; + } + + isSessionInvalid(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return STALE_SESSION_RE.test(msg); + } + + maybeRotateContinuation(continuation: string): string | null { + const transcriptPath = findTranscriptPath(continuation); + if (!transcriptPath) return null; + + let size: number; + try { + size = fs.statSync(transcriptPath).size; + } catch { + return null; + } + + const maxBytes = transcriptRotateBytes(); + const startMs = transcriptStartMs(transcriptPath); + const ageMs = startMs === null ? 0 : Date.now() - startMs; + const maxAgeMs = transcriptRotateAgeMs(); + + let reason: string | null = null; + if (size > maxBytes) { + reason = `transcript ${(size / 1_048_576).toFixed(1)}MB > ${(maxBytes / 1_048_576).toFixed(0)}MB cap`; + } else if (startMs !== null && ageMs > maxAgeMs) { + reason = `transcript ${(ageMs / 86_400_000).toFixed(1)}d old > ${(maxAgeMs / 86_400_000).toFixed(0)}d cap`; + } + if (!reason) return null; + + // Preserve a readable summary, then move the heavy .jsonl out of the + // resume path so the SDK starts a fresh session and the disk is reclaimed. + archiveTranscriptFile(transcriptPath, continuation, this.assistantName); + try { + fs.renameSync(transcriptPath, `${transcriptPath}.rotated-${Date.now()}`); + } catch (err) { + log(`Failed to move rotated transcript aside: ${err instanceof Error ? err.message : String(err)}`); + } + return reason; + } + + query(input: QueryInput): AgentQuery { + const stream = new MessageStream(); + stream.push(input.prompt); + + const instructions = input.systemContext?.instructions; + + const sdkResult = sdkQuery({ + prompt: stream, + options: { + cwd: input.cwd, + additionalDirectories: this.additionalDirectories, + resume: input.continuation, + pathToClaudeCodeExecutable: '/pnpm/claude', + systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined, + allowedTools: [ + ...TOOL_ALLOWLIST, + ...Object.keys(this.mcpServers).map(mcpAllowPattern), + ], + disallowedTools: SDK_DISALLOWED_TOOLS, + env: this.env, + model: this.model, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + effort: this.effort as any, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + settingSources: ['project', 'user', 'local'], + mcpServers: this.mcpServers, + hooks: { + PreToolUse: [{ hooks: [preToolUseHook] }], + PostToolUse: [{ hooks: [postToolUseHook] }], + PostToolUseFailure: [{ hooks: [postToolUseHook] }], + PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }], + }, + }, + }); + + let aborted = false; + + async function* translateEvents(): AsyncGenerator { + let messageCount = 0; + for await (const message of sdkResult) { + if (aborted) return; + messageCount++; + + // Yield activity for every SDK event so the poll loop knows the agent is working + yield { type: 'activity' }; + + if (message.type === 'system' && message.subtype === 'init') { + yield { type: 'init', continuation: message.session_id }; + } else if (message.type === 'result') { + const text = 'result' in message ? (message as { result?: string }).result ?? null : null; + yield { type: 'result', text }; + } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') { + yield { type: 'error', message: 'API retry', retryable: true }; + } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') { + yield { type: 'error', message: 'Rate limit', retryable: false, classification: 'quota' }; + } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { + const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata; + const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : ''; + yield { type: 'result', text: `Context compacted${detail}.` }; + } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { + const tn = message as { summary?: string }; + yield { type: 'progress', message: tn.summary || 'Task notification' }; + } + } + log(`Query completed after ${messageCount} SDK messages`); + } + + return { + push: (msg) => stream.push(msg), + end: () => stream.end(), + events: translateEvents(), + abort: () => { + aborted = true; + stream.end(); + }, + }; + } +} + +registerProvider('claude', (opts) => new ClaudeProvider(opts)); diff --git a/packages/iclaw-runtime/container/agent-runner/src/providers/factory.test.ts b/packages/iclaw-runtime/container/agent-runner/src/providers/factory.test.ts new file mode 100644 index 0000000..61fa7a8 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/providers/factory.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'bun:test'; + +import { createProvider, type ProviderName } from './factory.js'; +import { ClaudeProvider } from './claude.js'; +import { MockProvider } from './mock.js'; + +describe('createProvider', () => { + it('returns ClaudeProvider for claude', () => { + expect(createProvider('claude')).toBeInstanceOf(ClaudeProvider); + }); + + it('returns MockProvider for mock', () => { + expect(createProvider('mock')).toBeInstanceOf(MockProvider); + }); + + it('throws for unknown name', () => { + expect(() => createProvider('bogus' as ProviderName)).toThrow(/Unknown provider/); + }); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/providers/factory.ts b/packages/iclaw-runtime/container/agent-runner/src/providers/factory.ts new file mode 100644 index 0000000..8a14da9 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/providers/factory.ts @@ -0,0 +1,13 @@ +import type { AgentProvider, ProviderOptions } from './types.js'; +import { getProviderFactory } from './provider-registry.js'; + +/** + * Any registered provider name. Kept as a named alias for readability; the + * set of valid names is open and determined at runtime by whichever provider + * modules the `providers/index.ts` barrel imports. + */ +export type ProviderName = string; + +export function createProvider(name: ProviderName, options: ProviderOptions = {}): AgentProvider { + return getProviderFactory(name)(options); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/providers/index.ts b/packages/iclaw-runtime/container/agent-runner/src/providers/index.ts new file mode 100644 index 0000000..70497cf --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/providers/index.ts @@ -0,0 +1,6 @@ +// Provider self-registration barrel. +// Each import triggers the provider module's registerProvider() call at top +// level. Skills add a new provider by appending one import line below. + +import './claude.js'; +import './mock.js'; diff --git a/packages/iclaw-runtime/container/agent-runner/src/providers/mock.ts b/packages/iclaw-runtime/container/agent-runner/src/providers/mock.ts new file mode 100644 index 0000000..f941f09 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/providers/mock.ts @@ -0,0 +1,77 @@ +import { registerProvider } from './provider-registry.js'; +import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; + +/** + * Mock provider for testing. Returns canned responses. + * Supports push() — queued messages produce additional results. + */ +export class MockProvider implements AgentProvider { + readonly supportsNativeSlashCommands = false; + + private responseFactory: (prompt: string) => string; + + constructor(_options: ProviderOptions = {}, responseFactory?: (prompt: string) => string) { + this.responseFactory = responseFactory ?? ((prompt) => `Mock response to: ${prompt.slice(0, 100)}`); + } + + isSessionInvalid(_err: unknown): boolean { + return false; + } + + query(input: QueryInput): AgentQuery { + const pending: string[] = []; + let waiting: (() => void) | null = null; + let ended = false; + let aborted = false; + const responseFactory = this.responseFactory; + + const events: AsyncIterable = { + async *[Symbol.asyncIterator]() { + yield { type: 'activity' }; + yield { type: 'init', continuation: `mock-session-${Date.now()}` }; + + // Process initial prompt + yield { type: 'activity' }; + yield { type: 'result', text: responseFactory(input.prompt) }; + + // Process any pushed follow-ups + while (!ended && !aborted) { + if (pending.length > 0) { + const msg = pending.shift()!; + yield { type: 'result', text: responseFactory(msg) }; + continue; + } + // Wait for push() or end() + await new Promise((resolve) => { + waiting = resolve; + }); + waiting = null; + } + + // Drain remaining + while (pending.length > 0) { + const msg = pending.shift()!; + yield { type: 'result', text: responseFactory(msg) }; + } + }, + }; + + return { + push(message: string) { + pending.push(message); + waiting?.(); + }, + end() { + ended = true; + waiting?.(); + }, + events, + abort() { + aborted = true; + waiting?.(); + }, + }; + } +} + +registerProvider('mock', (opts) => new MockProvider(opts)); diff --git a/packages/iclaw-runtime/container/agent-runner/src/providers/provider-registry.ts b/packages/iclaw-runtime/container/agent-runner/src/providers/provider-registry.ts new file mode 100644 index 0000000..250cf72 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/providers/provider-registry.ts @@ -0,0 +1,33 @@ +/** + * Provider self-registration registry. + * + * Mirrors `src/channels/channel-registry.ts` on the host. Each provider module + * calls `registerProvider()` at top level; the barrel (`providers/index.ts`) + * imports every provider module for its side effect so registrations fire + * before `createProvider()` is called. + */ +import type { AgentProvider, ProviderOptions } from './types.js'; + +export type ProviderFactory = (options: ProviderOptions) => AgentProvider; + +const registry = new Map(); + +export function registerProvider(name: string, factory: ProviderFactory): void { + if (registry.has(name)) { + throw new Error(`Provider already registered: ${name}`); + } + registry.set(name, factory); +} + +export function getProviderFactory(name: string): ProviderFactory { + const factory = registry.get(name); + if (!factory) { + const known = [...registry.keys()].join(', ') || '(none)'; + throw new Error(`Unknown provider: ${name}. Registered: ${known}`); + } + return factory; +} + +export function listProviderNames(): string[] { + return [...registry.keys()]; +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/providers/types.ts b/packages/iclaw-runtime/container/agent-runner/src/providers/types.ts new file mode 100644 index 0000000..d906a8c --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/providers/types.ts @@ -0,0 +1,107 @@ +export interface AgentProvider { + /** + * True if the provider's underlying SDK handles slash commands natively and + * wants them passed through as raw text. When false, the poll-loop formats + * slash commands like any other chat message. + */ + readonly supportsNativeSlashCommands: boolean; + + /** Start a new query. Returns a handle for streaming input and output. */ + query(input: QueryInput): AgentQuery; + + /** + * True if the given error indicates the stored continuation is invalid + * (missing transcript, unknown session, etc.) and should be cleared. + */ + isSessionInvalid(err: unknown): boolean; + + /** + * Optional pre-resume maintenance. Given the stored continuation token, + * decide whether its backing transcript has grown too large or too old to + * resume cheaply. Return a non-null reason string to tell the caller to drop + * the continuation and start a fresh session (the provider archives any + * recoverable summary first); return null to keep resuming. + * + * Guards the cold-resume failure mode: a long-lived hub session accumulates + * days of history — including base64 image blocks the agent Read — and the + * SDK reloads the whole .jsonl on every resume. Past a threshold the first + * turn alone can exceed the host's idle ceiling, so the container is killed + * before it ever replies. Providers without an on-disk transcript omit this. + */ + maybeRotateContinuation?(continuation: string, cwd: string): string | null; +} + +/** + * Options passed to provider constructors. Fields are common to most + * providers; individual providers may ignore any they don't need. + */ +export interface ProviderOptions { + assistantName?: string; + mcpServers?: Record; + env?: Record; + additionalDirectories?: string[]; + /** + * Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through + * to the underlying SDK. If omitted, the SDK default is used. + */ + model?: string; + /** + * Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed + * through to the underlying SDK. If omitted, the SDK default is used. + */ + effort?: string; +} + +export interface QueryInput { + /** Initial prompt (already formatted by agent-runner). */ + prompt: string; + + /** + * Opaque continuation token from a previous query. The provider decides + * what this means (session ID, thread ID, nothing at all). + */ + continuation?: string; + + /** Working directory inside the container. */ + cwd: string; + + /** + * System context to inject. Providers translate this into whatever their + * SDK expects (preset append, full system prompt, per-turn injection…). + */ + systemContext?: { + instructions?: string; + }; +} + +export interface McpServerConfig { + command: string; + args: string[]; + env: Record; +} + +export interface AgentQuery { + /** Push a follow-up message into the active query. */ + push(message: string): void; + + /** Signal that no more input will be sent. */ + end(): void; + + /** Output event stream. */ + events: AsyncIterable; + + /** Force-stop the query. */ + abort(): void; +} + +export type ProviderEvent = + | { type: 'init'; continuation: string } + | { type: 'result'; text: string | null } + | { type: 'error'; message: string; retryable: boolean; classification?: string } + | { type: 'progress'; message: string } + /** + * Liveness signal. Providers MUST yield this on every underlying SDK + * event (tool call, thinking, partial message, anything) so the + * poll-loop's idle timer stays honest during long tool runs. + */ + | { type: 'activity' }; diff --git a/packages/iclaw-runtime/container/agent-runner/src/scheduling/task-script.ts b/packages/iclaw-runtime/container/agent-runner/src/scheduling/task-script.ts new file mode 100644 index 0000000..112d175 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/scheduling/task-script.ts @@ -0,0 +1,121 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { MessageInRow } from '../db/messages-in.js'; +import { touchHeartbeat } from '../db/connection.js'; + +const SCRIPT_TIMEOUT_MS = 30_000; +const SCRIPT_MAX_BUFFER = 1024 * 1024; + +export interface ScriptResult { + wakeAgent: boolean; + data?: unknown; +} + +function log(msg: string): void { + console.error(`[task-script] ${msg}`); +} + +export async function runScript(script: string, taskId: string): Promise { + const scriptPath = path.join('/tmp', `task-script-${taskId}.sh`); + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + + return new Promise((resolve) => { + execFile( + 'bash', + [scriptPath], + { timeout: SCRIPT_TIMEOUT_MS, maxBuffer: SCRIPT_MAX_BUFFER, env: process.env }, + (error, stdout, stderr) => { + try { + fs.unlinkSync(scriptPath); + } catch { + /* best-effort cleanup */ + } + + if (stderr) { + log(`[${taskId}] stderr: ${stderr.slice(0, 500)}`); + } + + if (error) { + log(`[${taskId}] error: ${error.message}`); + return resolve(null); + } + + const lines = stdout.trim().split('\n'); + const lastLine = lines[lines.length - 1]; + if (!lastLine) { + log(`[${taskId}] no output`); + return resolve(null); + } + + try { + const result = JSON.parse(lastLine); + if (typeof result.wakeAgent !== 'boolean') { + log(`[${taskId}] output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`); + return resolve(null); + } + resolve(result as ScriptResult); + } catch { + log(`[${taskId}] output is not valid JSON: ${lastLine.slice(0, 200)}`); + resolve(null); + } + }, + ); + }); +} + +export interface TaskScriptOutcome { + keep: MessageInRow[]; + skipped: string[]; +} + +/** + * Run pre-task scripts for any task messages that carry one, serially. + * - Errors / missing output / wakeAgent=false → task id added to `skipped`. + * - wakeAgent=true → content JSON is mutated to carry `scriptOutput`, so the + * formatter renders it into the prompt. + * Non-task messages and tasks without scripts pass through unchanged. + */ +export async function applyPreTaskScripts(messages: MessageInRow[]): Promise { + const keep: MessageInRow[] = []; + const skipped: string[] = []; + + for (const msg of messages) { + if (msg.kind !== 'task') { + keep.push(msg); + continue; + } + + let content: Record; + try { + content = JSON.parse(msg.content); + } catch { + keep.push(msg); + continue; + } + + const script = typeof content.script === 'string' ? (content.script as string) : null; + if (!script) { + keep.push(msg); + continue; + } + + log(`running script for task ${msg.id}`); + touchHeartbeat(); + const result = await runScript(script, msg.id); + touchHeartbeat(); + + if (!result || !result.wakeAgent) { + const reason = result ? 'wakeAgent=false' : 'script error/no output'; + log(`task ${msg.id} skipped: ${reason}`); + skipped.push(msg.id); + continue; + } + + log(`task ${msg.id} wakeAgent=true, enriching prompt`); + content.scriptOutput = result.data ?? null; + keep.push({ ...msg, content: JSON.stringify(content) }); + } + + return { keep, skipped }; +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/timezone.test.ts b/packages/iclaw-runtime/container/agent-runner/src/timezone.test.ts new file mode 100644 index 0000000..a4539e9 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/timezone.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'bun:test'; + +import { formatLocalTime, isValidTimezone, parseZonedToUtc, resolveTimezone } from './timezone.js'; + +// --- formatLocalTime --- + +describe('formatLocalTime', () => { + it('converts UTC to local time display', () => { + // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM + const result = formatLocalTime('2026-02-04T18:30:00.000Z', 'America/New_York'); + expect(result).toContain('1:30'); + expect(result).toContain('PM'); + expect(result).toContain('Feb'); + expect(result).toContain('2026'); + }); + + it('handles different timezones', () => { + // Same UTC time should produce different local times + const utc = '2026-06-15T12:00:00.000Z'; + const ny = formatLocalTime(utc, 'America/New_York'); + const tokyo = formatLocalTime(utc, 'Asia/Tokyo'); + // NY is UTC-4 in summer (EDT), Tokyo is UTC+9 + expect(ny).toContain('8:00'); + expect(tokyo).toContain('9:00'); + }); + + it('does not throw on invalid timezone, falls back to UTC', () => { + expect(() => formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2')).not.toThrow(); + const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2'); + // Should format as UTC (noon UTC = 12:00 PM) + expect(result).toContain('12:00'); + expect(result).toContain('PM'); + }); +}); + +describe('isValidTimezone', () => { + it('accepts valid IANA identifiers', () => { + expect(isValidTimezone('America/New_York')).toBe(true); + expect(isValidTimezone('UTC')).toBe(true); + expect(isValidTimezone('Asia/Tokyo')).toBe(true); + expect(isValidTimezone('Asia/Jerusalem')).toBe(true); + }); + + it('rejects invalid timezone strings', () => { + expect(isValidTimezone('IST-2')).toBe(false); + expect(isValidTimezone('XYZ+3')).toBe(false); + }); + + it('rejects empty and garbage strings', () => { + expect(isValidTimezone('')).toBe(false); + expect(isValidTimezone('NotATimezone')).toBe(false); + }); +}); + +describe('resolveTimezone', () => { + it('returns the timezone if valid', () => { + expect(resolveTimezone('America/New_York')).toBe('America/New_York'); + }); + + it('falls back to UTC for invalid timezone', () => { + expect(resolveTimezone('IST-2')).toBe('UTC'); + expect(resolveTimezone('')).toBe('UTC'); + }); +}); + +describe('parseZonedToUtc', () => { + it('passes strings with Z suffix through unchanged', () => { + const d = parseZonedToUtc('2026-01-15T09:00:00Z', 'America/New_York'); + expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z'); + }); + + it('passes strings with numeric offset through unchanged', () => { + const d = parseZonedToUtc('2026-01-15T09:00:00+02:00', 'America/New_York'); + expect(d.toISOString()).toBe('2026-01-15T07:00:00.000Z'); + }); + + it('interprets naive ISO as wall-clock in the given timezone', () => { + // 09:00 naive in NY in January = 09:00 EST = 14:00 UTC + const d = parseZonedToUtc('2026-01-15T09:00:00', 'America/New_York'); + expect(d.toISOString()).toBe('2026-01-15T14:00:00.000Z'); + }); + + it('handles a different positive-offset zone', () => { + // 09:00 naive in Tokyo (UTC+9) = 00:00 UTC + const d = parseZonedToUtc('2026-06-15T09:00:00', 'Asia/Tokyo'); + expect(d.toISOString()).toBe('2026-06-15T00:00:00.000Z'); + }); + + it('treats invalid timezone as UTC', () => { + const d = parseZonedToUtc('2026-01-15T09:00:00', 'NotATimezone'); + expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z'); + }); +}); diff --git a/packages/iclaw-runtime/container/agent-runner/src/timezone.ts b/packages/iclaw-runtime/container/agent-runner/src/timezone.ts new file mode 100644 index 0000000..d9a2e1b --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/timezone.ts @@ -0,0 +1,107 @@ +/** + * Timezone utilities — mirror of src/timezone.ts (host). + * + * The container can't import from src/ (separate tsconfig, different runtime). + * Kept deliberately byte-aligned with the host module so behaviour is the + * same on both sides of the session-DB boundary. + * + * TIMEZONE is resolved once at module load from process.env.TZ (which the host + * sets from its own TIMEZONE constant when spawning the container; see + * src/container-runner.ts). Invalid values fall back to UTC. + */ + +/** + * Check whether a timezone string is a valid IANA identifier + * that Intl.DateTimeFormat can use. + */ +export function isValidTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** + * Return the given timezone if valid IANA, otherwise fall back to UTC. + */ +export function resolveTimezone(tz: string): string { + return isValidTimezone(tz) ? tz : 'UTC'; +} + +/** + * Convert a UTC ISO timestamp to a localized display string. + * Uses the Intl API (no external dependencies). + * Falls back to UTC if the timezone is invalid. + */ +export function formatLocalTime(utcIso: string, timezone: string): string { + const date = new Date(utcIso); + return date.toLocaleString('en-US', { + timeZone: resolveTimezone(timezone), + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +function resolveContainerTimezone(): string { + const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; + for (const tz of candidates) { + if (tz && isValidTimezone(tz)) return tz; + } + return 'UTC'; +} + +export const TIMEZONE = resolveContainerTimezone(); + +/** + * Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock + * time in `tz` and return the corresponding UTC Date. Strings that already carry + * offset info (`Z` or `±HH:MM`) are passed through to the Date constructor + * unchanged. + * + * Algorithm: treat the naive string as UTC, ask Intl.DateTimeFormat what that + * UTC instant is called in `tz`, then invert the offset. Near DST boundaries + * this can be off by an hour for ~1h of wall-clock time per year; acceptable + * for scheduling where the agent normally picks round-hour targets. + */ +export function parseZonedToUtc(input: string, tz: string): Date { + const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim()); + if (hasOffset) return new Date(input); + + const zone = resolveTimezone(tz); + const asIfUtc = new Date(input + 'Z'); + if (Number.isNaN(asIfUtc.getTime())) return asIfUtc; + + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = Object.fromEntries( + fmt + .formatToParts(asIfUtc) + .filter((p) => p.type !== 'literal') + .map((p) => [p.type, p.value]), + ); + const hour = parts.hour === '24' ? '00' : parts.hour; + const zonedAsUtcMs = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(hour), + Number(parts.minute), + Number(parts.second), + ); + const offsetMs = zonedAsUtcMs - asIfUtc.getTime(); + return new Date(asIfUtc.getTime() - offsetMs); +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/upload-trace.test.ts b/packages/iclaw-runtime/container/agent-runner/src/upload-trace.test.ts new file mode 100644 index 0000000..4a20ac6 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/upload-trace.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js'; +import { getUndeliveredMessages } from './db/messages-out.js'; +import { getPendingMessages } from './db/messages-in.js'; +import type { MessageInRow } from './db/messages-in.js'; +import { MockProvider } from './providers/mock.js'; +import { runPollLoop } from './poll-loop.js'; +import { isUploadTraceCommand } from './upload-trace.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +describe('isUploadTraceCommand', () => { + const make = (text: unknown) => ({ content: JSON.stringify({ text }) }) as MessageInRow; + + it('matches /upload-trace (case-insensitive, with args)', () => { + expect(isUploadTraceCommand(make('/upload-trace'))).toBe(true); + expect(isUploadTraceCommand(make('/UPLOAD-TRACE'))).toBe(true); + expect(isUploadTraceCommand(make(' /upload-trace now '))).toBe(true); + }); + + it('does not match other text or commands', () => { + expect(isUploadTraceCommand(make('hello'))).toBe(false); + expect(isUploadTraceCommand(make('/upload'))).toBe(false); + expect(isUploadTraceCommand(make('/clear'))).toBe(false); + expect(isUploadTraceCommand({ content: 'not json' } as MessageInRow)).toBe(false); + }); +}); + +describe('poll loop — /upload-trace command', () => { + it('handles the command in the runner, writes a status, skips query', async () => { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES ('m-upload-trace', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`, + ) + .run(JSON.stringify({ text: '/upload-trace' })); + + // If the provider were ever queried it would emit this — asserting its + // absence proves the runner intercepted /upload-trace instead of the LLM. + const provider = new MockProvider({}, () => 'should not run'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 5000); + + await waitFor(() => getUndeliveredMessages().length > 0, 5000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + // A status line from uploadTrace() — never the provider's reply. + const text = JSON.parse(out[0].content).text as string; + expect(text.length).toBeGreaterThan(0); + expect(text).not.toBe('should not run'); + + // Command message was completed (not left pending). + expect(getPendingMessages()).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); +}); + +async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise { + return Promise.race([ + runPollLoop({ provider, providerName: 'mock', cwd: '/tmp' }), + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted'))); + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)), + ]); +} + +async function waitFor(condition: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout'); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} diff --git a/packages/iclaw-runtime/container/agent-runner/src/upload-trace.ts b/packages/iclaw-runtime/container/agent-runner/src/upload-trace.ts new file mode 100644 index 0000000..e7d9a70 --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/src/upload-trace.ts @@ -0,0 +1,142 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { MessageInRow } from './db/messages-in.js'; + +/** + * `/upload-trace` command: upload this session's Claude Code transcript to the user's + * own private `{hf_user}/nanoclaw-traces` dataset, browsable in the HF Agent + * Trace Viewer. The transcript the Claude provider keeps under + * `~/.claude/projects/

/.jsonl` is already in the format the + * viewer auto-detects, so this just locates the newest one and pushes it. + * + * Auth is the OneCLI gateway's job: curl goes out through the injected + * HTTPS_PROXY, which adds the user's HF token. We never see the raw token, and + * a 401 from `whoami` is our "not signed in" signal. + */ + +/** + * Narrow check for /upload-trace — the runner handles this command directly + * (no LLM turn). Admin-gated by the host router before it reaches the container. + */ +export function isUploadTraceCommand(msg: MessageInRow): boolean { + let text = ''; + try { + text = (JSON.parse(msg.content)?.text ?? '').trim(); + } catch { + return false; // non-JSON content is never a command + } + return text.toLowerCase().startsWith('/upload-trace'); +} + +/** Newest Claude Code transcript jsonl (the current session). */ +function newestTranscript(): string | null { + const projects = path.join(os.homedir(), '.claude', 'projects'); + let best: { p: string; m: number } | null = null; + let dirs: string[]; + try { + dirs = fs.readdirSync(projects); + } catch { + return null; + } + for (const dir of dirs) { + let files: string[]; + try { + files = fs.readdirSync(path.join(projects, dir)); + } catch { + continue; + } + for (const f of files) { + if (!f.endsWith('.jsonl')) continue; + const p = path.join(projects, dir, f); + const m = fs.statSync(p).mtimeMs; + if (!best || m > best.m) best = { p, m }; + } + } + return best?.p ?? null; +} + +function curl(args: string[], input?: string): { ok: boolean; out: string } { + const r = spawnSync('curl', args, { input, encoding: 'utf-8' }); + return { ok: r.status === 0, out: (r.stdout ?? '') + (r.stderr ?? '') }; +} + +/** Returns a user-facing status line. Never throws. */ +export function uploadTrace(): string { + const file = newestTranscript(); + if (!file) return 'No transcript to upload for this session yet.'; + + const who = curl(['-sf', 'https://huggingface.co/api/whoami-v2']); + if (!who.ok) { + return [ + "Can't upload — no Hugging Face token is available to this agent. To set it up:", + '', + '1. Create a token with WRITE access at https://huggingface.co/settings/tokens', + ' (New token → type "Write" → copy it).', + '', + '2. Add it to the OneCLI vault. Open the dashboard — remotely at https://app.onecli.sh/', + ' or on the host at http://127.0.0.1:10254 — then Secrets → New secret,', + ' paste the token, and set the host pattern to huggingface.co', + '', + '3. Assign it to this agent — new agents start with no secrets attached.', + ' In the same dashboard, open this agent and set its secret mode to "all"; or from the host run:', + ' onecli agents list # find this agent\'s id', + ' onecli agents set-secret-mode --id --mode all', + '', + 'Then run /upload-trace again — no restart needed.', + ].join('\n'); + } + let user: string | undefined; + try { + user = JSON.parse(who.out)?.name; + } catch { + /* fall through */ + } + if (!user) return 'Could not resolve your Hugging Face username.'; + + const repo = `${user}/nanoclaw-traces`; + // Idempotent create — ignore failure (already exists / no-op). The + // Content-Type header is required: without it curl sends form-encoding and + // the Hub rejects the body with 400 (expected string at "name"). + curl([ + '-sf', + '-X', + 'POST', + 'https://huggingface.co/api/repos/create', + '-H', + 'Content-Type: application/json', + '-d', + JSON.stringify({ type: 'dataset', name: 'nanoclaw-traces', private: true }), + ]); + + const content = fs.readFileSync(file).toString('base64'); + const repoPath = `sessions/${path.basename(file)}`; + const ndjson = + JSON.stringify({ key: 'header', value: { summary: 'add session trace' } }) + + '\n' + + JSON.stringify({ + key: 'file', + value: { path: repoPath, encoding: 'base64', content }, + }) + + '\n'; + + const commit = curl( + [ + '-sf', + '-X', + 'POST', + `https://huggingface.co/api/datasets/${repo}/commit/main`, + '-H', + 'Content-Type: application/x-ndjson', + '--data-binary', + '@-', + ], + ndjson, + ); + if (!commit.ok) { + return 'Upload to Hugging Face failed (the transcript may be too large for an inline commit).'; + } + return `Uploaded → https://huggingface.co/datasets/${repo}/blob/main/${repoPath}`; +} diff --git a/packages/iclaw-runtime/container/agent-runner/tsconfig.json b/packages/iclaw-runtime/container/agent-runner/tsconfig.json new file mode 100644 index 0000000..6ca456d --- /dev/null +++ b/packages/iclaw-runtime/container/agent-runner/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/iclaw-runtime/container/build.sh b/packages/iclaw-runtime/container/build.sh new file mode 100755 index 0000000..ae0c3d9 --- /dev/null +++ b/packages/iclaw-runtime/container/build.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Build the NanoClaw agent container image. +# +# Reads one optional build flag from ../.env: +# INSTALL_CJK_FONTS=true — add Chinese/Japanese/Korean fonts (~200MB) +# setup/container.ts reads the same file, so both build paths stay in sync. +# Callers can also override by exporting INSTALL_CJK_FONTS directly. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$SCRIPT_DIR" + +# Derive the image name from the project root so two NanoClaw installs on the +# same host don't overwrite each other's `nanoclaw-agent:latest` tag. Matches +# setup/lib/install-slug.sh + src/install-slug.ts. +# shellcheck source=../setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +IMAGE_NAME="$(container_image_base)" +TAG="${1:-latest}" +CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" + +# Caller's env takes precedence; fall back to .env. +if [ -z "${INSTALL_CJK_FONTS:-}" ] && [ -f "../.env" ]; then + INSTALL_CJK_FONTS="$(grep '^INSTALL_CJK_FONTS=' ../.env | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'" | tr -d '[:space:]')" +fi + +BUILD_ARGS=() +if [ "${INSTALL_CJK_FONTS:-false}" = "true" ]; then + echo "CJK fonts: enabled (adds ~200MB)" + BUILD_ARGS+=(--build-arg INSTALL_CJK_FONTS=true) +fi + +echo "Building NanoClaw agent container image..." +echo "Image: ${IMAGE_NAME}:${TAG}" + +${CONTAINER_RUNTIME} build "${BUILD_ARGS[@]}" -t "${IMAGE_NAME}:${TAG}" . + +echo "" +echo "Build complete!" +echo "Image: ${IMAGE_NAME}:${TAG}" +echo "" +echo "Test with:" +echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | ${CONTAINER_RUNTIME} run -i ${IMAGE_NAME}:${TAG}" diff --git a/packages/iclaw-runtime/container/entrypoint.sh b/packages/iclaw-runtime/container/entrypoint.sh new file mode 100755 index 0000000..1c867f2 --- /dev/null +++ b/packages/iclaw-runtime/container/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# NanoClaw agent container entrypoint. +# +# The host passes initial session parameters via stdin as a single JSON blob, +# then the agent-runner opens the session DBs at /workspace/{inbound,outbound}.db +# and enters its poll loop. All further IO flows through those DBs. +# +# We capture stdin to a file first so /tmp/input.json is available for +# post-mortem inspection if the container exits unexpectedly, then exec bun +# so that bun becomes PID 1's direct child (under tini) and receives signals. + +set -e + +cat > /tmp/input.json + +exec bun run /app/src/index.ts < /tmp/input.json diff --git a/packages/iclaw-runtime/container/skills/agent-browser/SKILL.md b/packages/iclaw-runtime/container/skills/agent-browser/SKILL.md new file mode 100644 index 0000000..dd6c6bf --- /dev/null +++ b/packages/iclaw-runtime/container/skills/agent-browser/SKILL.md @@ -0,0 +1,159 @@ +--- +name: agent-browser +description: Browse the web for any task — research topics, read articles, interact with web apps, fill forms, take screenshots, extract data, and test web pages. Use whenever a browser would be useful, not just when the user explicitly asks. +allowed-tools: Bash(agent-browser:*) +--- + +# Browser Automation with agent-browser + +## Quick start + +```bash +agent-browser open # Navigate to page +agent-browser snapshot -i # Get interactive elements with refs +agent-browser click @e1 # Click element by ref +agent-browser fill @e2 "text" # Fill input by ref +agent-browser close # Close browser +``` + +## Core workflow + +1. Navigate: `agent-browser open ` +2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`) +3. Interact using refs from the snapshot +4. Re-snapshot after navigation or significant DOM changes + +## Commands + +### Navigation + +```bash +agent-browser open # Navigate to URL +agent-browser back # Go back +agent-browser forward # Go forward +agent-browser reload # Reload page +agent-browser close # Close browser +``` + +### Snapshot (page analysis) + +```bash +agent-browser snapshot # Full accessibility tree +agent-browser snapshot -i # Interactive elements only (recommended) +agent-browser snapshot -c # Compact output +agent-browser snapshot -d 3 # Limit depth to 3 +agent-browser snapshot -s "#main" # Scope to CSS selector +``` + +### Interactions (use @refs from snapshot) + +```bash +agent-browser click @e1 # Click +agent-browser dblclick @e1 # Double-click +agent-browser fill @e2 "text" # Clear and type +agent-browser type @e2 "text" # Type without clearing +agent-browser press Enter # Press key +agent-browser hover @e1 # Hover +agent-browser check @e1 # Check checkbox +agent-browser uncheck @e1 # Uncheck checkbox +agent-browser select @e1 "value" # Select dropdown option +agent-browser scroll down 500 # Scroll page +agent-browser upload @e1 file.pdf # Upload files +``` + +### Get information + +```bash +agent-browser get text @e1 # Get element text +agent-browser get html @e1 # Get innerHTML +agent-browser get value @e1 # Get input value +agent-browser get attr @e1 href # Get attribute +agent-browser get title # Get page title +agent-browser get url # Get current URL +agent-browser get count ".item" # Count matching elements +``` + +### Screenshots & PDF + +```bash +agent-browser screenshot # Save to temp directory +agent-browser screenshot path.png # Save to specific path +agent-browser screenshot --full # Full page +agent-browser pdf output.pdf # Save as PDF +``` + +### Wait + +```bash +agent-browser wait @e1 # Wait for element +agent-browser wait 2000 # Wait milliseconds +agent-browser wait --text "Success" # Wait for text +agent-browser wait --url "**/dashboard" # Wait for URL pattern +agent-browser wait --load networkidle # Wait for network idle +``` + +### Semantic locators (alternative to refs) + +```bash +agent-browser find role button click --name "Submit" +agent-browser find text "Sign In" click +agent-browser find label "Email" fill "user@test.com" +agent-browser find placeholder "Search" type "query" +``` + +### Authentication with saved state + +```bash +# Login once +agent-browser open https://app.example.com/login +agent-browser snapshot -i +agent-browser fill @e1 "username" +agent-browser fill @e2 "password" +agent-browser click @e3 +agent-browser wait --url "**/dashboard" +agent-browser state save auth.json + +# Later: load saved state +agent-browser state load auth.json +agent-browser open https://app.example.com/dashboard +``` + +### Cookies & Storage + +```bash +agent-browser cookies # Get all cookies +agent-browser cookies set name value # Set cookie +agent-browser cookies clear # Clear cookies +agent-browser storage local # Get localStorage +agent-browser storage local set k v # Set value +``` + +### JavaScript + +```bash +agent-browser eval "document.title" # Run JavaScript +``` + +## Example: Form submission + +```bash +agent-browser open https://example.com/form +agent-browser snapshot -i +# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3] + +agent-browser fill @e1 "user@example.com" +agent-browser fill @e2 "password123" +agent-browser click @e3 +agent-browser wait --load networkidle +agent-browser snapshot -i # Check result +``` + +## Example: Data extraction + +```bash +agent-browser open https://example.com/products +agent-browser snapshot -i +agent-browser get text @e1 # Get product title +agent-browser get attr @e2 href # Get link URL +agent-browser screenshot products.png +``` diff --git a/packages/iclaw-runtime/container/skills/frontend-engineer/SKILL.md b/packages/iclaw-runtime/container/skills/frontend-engineer/SKILL.md new file mode 100644 index 0000000..ef09224 --- /dev/null +++ b/packages/iclaw-runtime/container/skills/frontend-engineer/SKILL.md @@ -0,0 +1,157 @@ +--- +name: frontend-engineer +description: Pro frontend engineering discipline. Enforces build-test-verify workflow for every web project. Never declare done until the site is built, tested, responsive, accessible, and visually verified in a real browser. Use alongside vercel-cli for production-quality deployments. +--- + +# Frontend Engineer + +You are a senior frontend engineer. You build production-quality websites and web applications. You do not cut corners. You do not declare work done until everything is tested and working. + +## Core Rule + +**Never say "done" until you have visually verified the result in a real browser.** Screenshots are your proof. If you can't take a screenshot, you're not done. + +## Build Workflow + +Every frontend task follows this sequence. Do not skip steps. + +### 1. Understand Before Coding + +- For existing projects: read `package.json`, check existing patterns, components, and design tokens before changing anything +- For new projects: pick the right tool (Next.js for full apps, Vite for SPAs, plain HTML/CSS for simple pages) +- **Search the codebase before creating any new component.** If an existing component does 80% of what you need, extend it with props. If two components share the same pattern, extract a shared component. + +### 2. Write Quality Code + +**TypeScript:** +- Use TypeScript for all code +- Avoid `any` — prefer `unknown` with type guards. If `any` is genuinely the simplest correct approach (e.g. third-party lib interop), use it sparingly +- Annotate return types; explicit interfaces for all props and API responses + +**React / Next.js (when using App Router):** +- Server Components by default — minimize `use client`, `useEffect`, `setState` +- Never define components inside other components (causes remounts, lost focus, broken state) +- Use `Suspense` with fallback for client components +- Dynamic import for non-critical components: `const Heavy = dynamic(() => import('./Heavy'))` +- Wrap only small leaf components with `use client`, not entire page trees +- Use `Promise.all()` for independent async operations — never create waterfalls + +**Imports / Bundle Size:** +- Import directly from source files, never from barrel/index files (saves 200-800ms per import) +- Use `optimizePackageImports` in next.config for icon/UI libraries (lucide-react, @mui/material, etc.) +- Defer third-party scripts; lazy load below-the-fold content + +**HTML:** +- Semantic tags: `
`, `