From 1c31726c63074be3b5ca850cd9a1963574339d39 Mon Sep 17 00:00:00 2001 From: Fritzzzz Date: Tue, 24 Feb 2026 22:19:28 +0200 Subject: [PATCH 1/4] add npm cache to gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ec01199..deda421 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Dependencies node_modules/ - +.npm-cache/ # Build output dist/ From 41e242485612ed0d63d921c7a7c74dc939b543ca Mon Sep 17 00:00:00 2001 From: Fritzzzz Date: Tue, 24 Feb 2026 22:29:34 +0200 Subject: [PATCH 2/4] refactor: restructure add-gmail skill for new skill architecture Co-Authored-By: Claude Opus 4.6 --- .claude/skills/add-gmail/SKILL.md | 778 ++++-------------- .../add-gmail/add/src/channels/gmail.test.ts | 71 ++ .../add-gmail/add/src/channels/gmail.ts | 326 ++++++++ .claude/skills/add-gmail/manifest.yaml | 21 + .../container/agent-runner/src/index.ts | 593 +++++++++++++ .../agent-runner/src/index.ts.intent.md | 32 + .claude/skills/add-gmail/modify/src/config.ts | 74 ++ .../add-gmail/modify/src/config.ts.intent.md | 20 + .../add-gmail/modify/src/container-runner.ts | 661 +++++++++++++++ .../modify/src/container-runner.ts.intent.md | 37 + .claude/skills/add-gmail/modify/src/index.ts | 506 ++++++++++++ .../add-gmail/modify/src/index.ts.intent.md | 43 + .../add-gmail/modify/src/routing.test.ts | 119 +++ .claude/skills/add-gmail/tests/gmail.test.ts | 44 + 14 files changed, 2714 insertions(+), 611 deletions(-) create mode 100644 .claude/skills/add-gmail/add/src/channels/gmail.test.ts create mode 100644 .claude/skills/add-gmail/add/src/channels/gmail.ts create mode 100644 .claude/skills/add-gmail/manifest.yaml create mode 100644 .claude/skills/add-gmail/modify/container/agent-runner/src/index.ts create mode 100644 .claude/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md create mode 100644 .claude/skills/add-gmail/modify/src/config.ts create mode 100644 .claude/skills/add-gmail/modify/src/config.ts.intent.md create mode 100644 .claude/skills/add-gmail/modify/src/container-runner.ts create mode 100644 .claude/skills/add-gmail/modify/src/container-runner.ts.intent.md create mode 100644 .claude/skills/add-gmail/modify/src/index.ts create mode 100644 .claude/skills/add-gmail/modify/src/index.ts.intent.md create mode 100644 .claude/skills/add-gmail/modify/src/routing.test.ts create mode 100644 .claude/skills/add-gmail/tests/gmail.test.ts diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md index ad9dbd0..29b21c6 100644 --- a/.claude/skills/add-gmail/SKILL.md +++ b/.claude/skills/add-gmail/SKILL.md @@ -1,111 +1,142 @@ ---- -name: add-gmail -description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration. ---- - # Add Gmail Integration -This skill adds Gmail capabilities to NanoClaw. It can be configured in two modes: +This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox. -1. **Tool Mode** - Agent can read/send emails, but only when triggered from WhatsApp -2. **Channel Mode** - Emails can trigger the agent, schedule tasks, and receive email replies +## Phase 1: Pre-flight -## Initial Questions +### Check if already applied -Use `AskUserQuestion` to determine the configuration: +Read `.nanoclaw/state.yaml`. If `gmail` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place. -AskUserQuestion: How do you want to use Gmail with NanoClaw? -- **Tool Mode** - Agent can read/send emails when triggered from WhatsApp (simpler setup) -- **Channel Mode** - Emails can trigger the agent, schedule tasks, and receive email replies (requires polling) +### Ask the user -Store their choice and proceed to the appropriate section. +Use `AskUserQuestion`: ---- +AskUserQuestion: Should incoming emails be able to trigger the agent? -## Prerequisites (Both Modes) +- **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically +- **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added. -### 1. Check Existing Gmail Setup +## Phase 2: Apply Code Changes -First, check if Gmail is already configured: +### Initialize skills system (if needed) + +If `.nanoclaw/` directory doesn't exist yet: + +```bash +npx tsx scripts/apply-skill.ts --init +``` + +### Path A: Tool-only (user chose "No") + +Do NOT run the full apply script. Only two source files need changes. This avoids adding dead code (`gmail.ts`, `gmail.test.ts`, config flags, index.ts channel logic, routing tests, `googleapis` dependency). + +#### 1. Mount Gmail credentials in container + +Apply the changes described in `modify/src/container-runner.ts.intent.md` to `src/container-runner.ts`: import `os`, add a conditional read-write mount of `~/.gmail-mcp` to `/home/node/.gmail-mcp` in `buildVolumeMounts()` after the session mounts. + +#### 2. Add Gmail MCP server to agent runner + +Apply the changes described in `modify/container/agent-runner/src/index.ts.intent.md` to `container/agent-runner/src/index.ts`: add `gmail` MCP server (`npx -y @gongrzhe/server-gmail-autoauth-mcp`) and `'mcp__gmail__*'` to `allowedTools`. + +#### 3. Record in state + +Add `gmail` to `.nanoclaw/state.yaml` under `applied_skills` with `mode: tool-only`. + +#### 4. Validate + +```bash +npm run build +``` + +Build must be clean before proceeding. Skip to Phase 3. + +### Path B: Channel mode (user chose "Yes") + +Run the full skills engine to apply all code changes: + +```bash +npx tsx scripts/apply-skill.ts .claude/skills/add-gmail +``` + +This deterministically: + +- Adds `src/channels/gmail.ts` (GmailChannel class implementing Channel interface) +- Adds `src/channels/gmail.test.ts` (unit tests) +- Three-way merges Gmail support into `src/index.ts` (conditional GmailChannel creation) +- Three-way merges Gmail config into `src/config.ts` (`GMAIL_CHANNEL_ENABLED`) +- Three-way merges Gmail credentials mount into `src/container-runner.ts` (~/.gmail-mcp -> /home/node/.gmail-mcp) +- Three-way merges Gmail MCP server into `container/agent-runner/src/index.ts` (@gongrzhe/server-gmail-autoauth-mcp) +- Three-way merges Gmail JID tests into `src/routing.test.ts` +- Installs the `googleapis` npm dependency +- Updates `.env.example` with `GMAIL_CHANNEL_ENABLED` +- Records the application in `.nanoclaw/state.yaml` + +If the apply reports merge conflicts, read the intent files: + +- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts +- `modify/src/config.ts.intent.md` — what changed for config.ts +- `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts +- `modify/container/agent-runner/src/index.ts.intent.md` — what changed for agent-runner + +#### Add email handling instructions + +Append the following to `groups/main/CLAUDE.md` (before the formatting section): + +```markdown +## Email Notifications + +When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email. +``` + +#### Validate + +```bash +npm test +npm run build +``` + +All tests must pass (including the new gmail tests) and build must be clean before proceeding. + +## Phase 3: Setup + +### Check existing Gmail credentials ```bash ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found" ``` -If `credentials.json` exists, skip to "Verify Gmail Access" below. +If `credentials.json` already exists, skip to "Configure environment" below. -### 2. Create Gmail Config Directory - -```bash -mkdir -p ~/.gmail-mcp -``` - -### 3. GCP Project Setup - -**USER ACTION REQUIRED** +### GCP Project Setup Tell the user: -> I need you to set up Google Cloud OAuth credentials. I'll walk you through it: +> I need you to set up Google Cloud OAuth credentials: > -> 1. Open https://console.cloud.google.com in your browser -> 2. Create a new project (or select existing) - click the project dropdown at the top - -Wait for user confirmation, then continue: - -> 3. Now enable the Gmail API: -> - In the left sidebar, go to **APIs & Services → Library** -> - Search for "Gmail API" -> - Click on it, then click **Enable** - -Wait for user confirmation, then continue: - -> 4. Now create OAuth credentials: -> - Go to **APIs & Services → Credentials** (in the left sidebar) -> - Click **+ CREATE CREDENTIALS** at the top -> - Select **OAuth client ID** -> - If prompted for consent screen, choose "External", fill in app name (e.g., "NanoClaw"), your email, and save -> - For Application type, select **Desktop app** -> - Name it anything (e.g., "NanoClaw Gmail") -> - Click **Create** - -Wait for user confirmation, then continue: - -> 5. Download the credentials: -> - Click **DOWNLOAD JSON** on the popup (or find it in the credentials list and click the download icon) -> - Save it as `gcp-oauth.keys.json` +> 1. Open https://console.cloud.google.com — create a new project or select existing +> 2. Go to **APIs & Services > Library**, search "Gmail API", click **Enable** +> 3. Go to **APIs & Services > Credentials**, click **+ CREATE CREDENTIALS > OAuth client ID** +> - If prompted for consent screen: choose "External", fill in app name and email, save +> - Application type: **Desktop app**, name: anything (e.g., "NanoClaw Gmail") +> 4. Click **DOWNLOAD JSON** and save as `gcp-oauth.keys.json` > -> Where did you save the file? (Give me the full path, or just paste the file contents here) +> Where did you save the file? (Give me the full path, or paste the file contents here) If user provides a path, copy it: ```bash +mkdir -p ~/.gmail-mcp cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json ``` -If user pastes the JSON content, write it directly: +If user pastes JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`. -```bash -cat > ~/.gmail-mcp/gcp-oauth.keys.json << 'EOF' -{paste the JSON here} -EOF -``` - -Verify the file is valid JSON: - -```bash -cat ~/.gmail-mcp/gcp-oauth.keys.json | head -5 -``` - -### 4. OAuth Authorization - -**USER ACTION REQUIRED** +### OAuth Authorization Tell the user: -> I'm going to run the Gmail authorization. A browser window will open asking you to sign in to Google and grant access. -> -> **Important:** If you see a warning that the app isn't verified, click "Advanced" then "Go to [app name] (unsafe)" - this is normal for personal OAuth apps. +> I'm going to run Gmail authorization. A browser window will open — sign in and grant access. If you see an "app isn't verified" warning, click "Advanced" then "Go to [app name] (unsafe)" — this is normal for personal OAuth apps. Run the authorization: @@ -113,591 +144,116 @@ Run the authorization: npx -y @gongrzhe/server-gmail-autoauth-mcp auth ``` -If that doesn't work (some versions don't have an auth subcommand), run it and let it prompt: +If that fails (some versions don't have an auth subcommand), try `timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`. + +### Configure environment + +**Channel mode only** — add to `.env`: + +``` +GMAIL_CHANNEL_ENABLED=true +``` + +Sync to container environment: ```bash -timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true +mkdir -p data/env && cp .env data/env/env ``` -Tell user: -> Complete the authorization in your browser. The window should close automatically when done. Let me know when you've authorized. +### Build and restart -### 5. Verify Gmail Access - -Check that credentials were saved: +Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server): ```bash -if [ -f ~/.gmail-mcp/credentials.json ]; then - echo "Gmail authorization successful!" - ls -la ~/.gmail-mcp/ -else - echo "ERROR: credentials.json not found - authorization may have failed" -fi +rm -r data/sessions/*/agent-runner-src 2>/dev/null || true ``` -Test the connection by listing labels (quick sanity check): - -```bash -echo '{"method": "tools/list"}' | timeout 10 npx -y @gongrzhe/server-gmail-autoauth-mcp 2>/dev/null | head -20 || echo "MCP responded (check output above)" -``` - -If everything works, proceed to implementation. - ---- - -## Tool Mode Implementation - -For Tool Mode, integrate Gmail MCP into the agent runner. Execute these changes directly. - -### Step 1: Add Gmail MCP to Agent Runner - -Read `container/agent-runner/src/index.ts` and find the `mcpServers` config in the `query()` call. - -Add `gmail` to the `mcpServers` object: - -```typescript -gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] } -``` - -Find the `allowedTools` array and add Gmail tools: - -```typescript -'mcp__gmail__*' -``` - -The result should look like: - -```typescript -mcpServers: { - nanoclaw: ipcMcp, - gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] } -}, -allowedTools: [ - 'Bash', - 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'mcp__nanoclaw__*', - 'mcp__gmail__*' -], -``` - -### Step 2: Mount Gmail Credentials in Container - -Read `src/container-runner.ts` and find the `buildVolumeMounts` function. - -Add this mount block (after the `.claude` mount is a good location): - -```typescript -// Gmail credentials directory -const gmailDir = path.join(homeDir, '.gmail-mcp'); -if (fs.existsSync(gmailDir)) { - mounts.push({ - hostPath: gmailDir, - containerPath: '/home/node/.gmail-mcp', - readonly: false // MCP may need to refresh tokens - }); -} -``` - -### Step 3: Update Group Memory - -Append to `groups/CLAUDE.md` (the global memory file): - -```markdown - -## Email (Gmail) - -You have access to Gmail via MCP tools: -- `mcp__gmail__search_emails` - Search emails with query -- `mcp__gmail__get_email` - Get full email content by ID -- `mcp__gmail__send_email` - Send an email -- `mcp__gmail__draft_email` - Create a draft -- `mcp__gmail__list_labels` - List available labels - -Example: "Check my unread emails from today" or "Send an email to john@example.com about the meeting" -``` - -Also append the same section to `groups/main/CLAUDE.md`. - -### Step 4: Rebuild and Restart - -Run these commands: +Rebuild the container (agent-runner changed): ```bash cd container && ./build.sh ``` -Wait for container build to complete, then: - -```bash -cd .. && npm run build -``` - -Wait for TypeScript compilation, then restart the service: +Then compile and restart: ```bash +npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` -Check that it started: +## Phase 4: Verify -```bash -sleep 2 && launchctl list | grep nanoclaw # macOS -# Linux: systemctl --user status nanoclaw -``` - -### Step 5: Test Gmail Integration +### Test tool access (both modes) Tell the user: -> Gmail integration is set up! Test it by sending this message in your WhatsApp main channel: +> Gmail is connected! Send this in your WhatsApp main channel: > -> `@Andy check my recent emails` -> -> Or: -> -> `@Andy list my Gmail labels` +> `@Andy check my recent emails` or `@Andy list my Gmail labels` -Watch the logs for any errors: +### Test channel mode (Channel mode only) + +Tell the user to send themselves a test email. The agent should pick it up within a minute. Monitor: `tail -f logs/nanoclaw.log | grep -iE "(gmail|email)"`. + +Once verified, offer filter customization via `AskUserQuestion` — by default, only emails in the Primary inbox trigger the agent (Promotions, Social, Updates, and Forums are excluded). The user can keep this default or narrow further by sender, label, or keywords. No code changes needed for filters. + +### Check logs if needed ```bash tail -f logs/nanoclaw.log ``` ---- - -## Channel Mode Implementation - -Channel Mode includes everything from Tool Mode, plus email polling and routing. - -### Additional Questions for Channel Mode - -Use `AskUserQuestion` to configure email triggering: - -AskUserQuestion: How should the agent be triggered from email? -- **Specific Label** - Create a Gmail label (e.g., "NanoClaw"), emails with this label trigger the agent -- **Email Address Pattern** - Emails to a specific address pattern (e.g., andy+task@gmail.com) via plus-addressing -- **Subject Prefix** - Emails with a subject starting with a keyword (e.g., "[Andy]") - -AskUserQuestion: How should email conversations be grouped? -- **Per Email Thread** - Each email thread gets its own conversation context -- **Per Sender** - All emails from the same sender share context -- **Single Context** - All emails share the main group context - -Store their choices for implementation. - -### Step 1: Complete Tool Mode First - -Complete all Tool Mode steps above before continuing. Verify Gmail tools work by having the user test `@Andy check my recent emails`. - -### Step 2: Add Email Polling Configuration - -Read `src/types.ts` and add this interface: - -```typescript -export interface EmailChannelConfig { - enabled: boolean; - triggerMode: 'label' | 'address' | 'subject'; - triggerValue: string; // Label name, address pattern, or subject prefix - contextMode: 'thread' | 'sender' | 'single'; - pollIntervalMs: number; - replyPrefix?: string; // Optional prefix for replies -} -``` - -Read `src/config.ts` and add this configuration (customize values based on user's earlier answers): - -```typescript -export const EMAIL_CHANNEL: EmailChannelConfig = { - enabled: true, - triggerMode: 'label', // or 'address' or 'subject' - triggerValue: 'NanoClaw', // the label name, address pattern, or prefix - contextMode: 'thread', - pollIntervalMs: 60000, // Check every minute - replyPrefix: '[Andy] ' -}; -``` - -### Step 3: Add Email State Tracking - -Read `src/db.ts` and add these functions for tracking processed emails: - -```typescript -// Track processed emails to avoid duplicates -export function initEmailTable(): void { - db.exec(` - CREATE TABLE IF NOT EXISTS processed_emails ( - message_id TEXT PRIMARY KEY, - thread_id TEXT NOT NULL, - sender TEXT NOT NULL, - subject TEXT, - processed_at TEXT NOT NULL, - response_sent INTEGER DEFAULT 0 - ) - `); -} - -export function isEmailProcessed(messageId: string): boolean { - const row = db.prepare('SELECT 1 FROM processed_emails WHERE message_id = ?').get(messageId); - return !!row; -} - -export function markEmailProcessed(messageId: string, threadId: string, sender: string, subject: string): void { - db.prepare(` - INSERT OR REPLACE INTO processed_emails (message_id, thread_id, sender, subject, processed_at) - VALUES (?, ?, ?, ?, ?) - `).run(messageId, threadId, sender, subject, new Date().toISOString()); -} - -export function markEmailResponded(messageId: string): void { - db.prepare('UPDATE processed_emails SET response_sent = 1 WHERE message_id = ?').run(messageId); -} -``` - -Also find the `initDatabase()` function in `src/db.ts` and add a call to `initEmailTable()`. - -### Step 4: Create Email Channel Module - -Create a new file `src/email-channel.ts` with this content: - -```typescript -import { EMAIL_CHANNEL } from './config.js'; -import { isEmailProcessed, markEmailProcessed, markEmailResponded } from './db.js'; -import pino from 'pino'; - -const logger = pino({ - level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } } -}); - -interface EmailMessage { - id: string; - threadId: string; - from: string; - subject: string; - body: string; - date: string; -} - -// Gmail MCP client functions (call via subprocess or import the MCP directly) -// These would invoke the Gmail MCP tools - -export async function checkForNewEmails(): Promise { - // Build query based on trigger mode - let query: string; - switch (EMAIL_CHANNEL.triggerMode) { - case 'label': - query = `label:${EMAIL_CHANNEL.triggerValue} is:unread`; - break; - case 'address': - query = `to:${EMAIL_CHANNEL.triggerValue} is:unread`; - break; - case 'subject': - query = `subject:${EMAIL_CHANNEL.triggerValue} is:unread`; - break; - } - - // This requires calling Gmail MCP's search_emails tool - // Implementation depends on how you want to invoke MCP from Node - // Option 1: Use @anthropic-ai/claude-agent-sdk with just gmail MCP - // Option 2: Run npx gmail MCP as subprocess and parse output - // Option 3: Import gmail-autoauth-mcp directly - - // Placeholder - implement based on preference - return []; -} - -export async function sendEmailReply( - threadId: string, - to: string, - subject: string, - body: string -): Promise { - // Call Gmail MCP's send_email tool with in_reply_to for threading - // Prefix subject with replyPrefix if configured - const replySubject = subject.startsWith('Re:') - ? subject - : `Re: ${subject}`; - - const prefixedBody = EMAIL_CHANNEL.replyPrefix - ? `${EMAIL_CHANNEL.replyPrefix}${body}` - : body; - - // Implementation: invoke Gmail MCP send_email -} - -export function getContextKey(email: EmailMessage): string { - switch (EMAIL_CHANNEL.contextMode) { - case 'thread': - return `email-thread-${email.threadId}`; - case 'sender': - return `email-sender-${email.from.toLowerCase()}`; - case 'single': - return 'email-main'; - } -} -``` - -### Step 5: Add Email Polling to Main Loop - -Read `src/index.ts` and add the email polling infrastructure. First, add these imports at the top: - -```typescript -import { checkForNewEmails, sendEmailReply, getContextKey } from './email-channel.js'; -import { EMAIL_CHANNEL } from './config.js'; -import { isEmailProcessed, markEmailProcessed, markEmailResponded } from './db.js'; -``` - -Then add the `startEmailLoop` function: - -```typescript -async function startEmailLoop(): Promise { - if (!EMAIL_CHANNEL.enabled) { - logger.info('Email channel disabled'); - return; - } - - logger.info(`Email channel running (trigger: ${EMAIL_CHANNEL.triggerMode}:${EMAIL_CHANNEL.triggerValue})`); - - while (true) { - try { - const emails = await checkForNewEmails(); - - for (const email of emails) { - if (isEmailProcessed(email.id)) continue; - - logger.info({ from: email.from, subject: email.subject }, 'Processing email'); - markEmailProcessed(email.id, email.threadId, email.from, email.subject); - - // Determine which group/context to use - const contextKey = getContextKey(email); - - // Build prompt with email content - const prompt = ` -${email.from} -${email.subject} -${email.body} - - -Respond to this email. Your response will be sent as an email reply.`; - - // Run agent with email context - // You'll need to create a registered group for email or use a special handler - const response = await runEmailAgent(contextKey, prompt, email); - - if (response) { - await sendEmailReply(email.threadId, email.from, email.subject, response); - markEmailResponded(email.id); - logger.info({ to: email.from }, 'Email reply sent'); - } - } - } catch (err) { - logger.error({ err }, 'Error in email loop'); - } - - await new Promise(resolve => setTimeout(resolve, EMAIL_CHANNEL.pollIntervalMs)); - } -} -``` - -Then add `startEmailLoop()` in the `main()` function, after `startMessageLoop()`: - -```typescript -// In main(), after startMessageLoop(): -startEmailLoop(); -``` - -### Step 6: Implement Email Agent Runner - -Add this function to `src/index.ts` (or create a separate `src/email-agent.ts` if preferred): - -```typescript -async function runEmailAgent( - contextKey: string, - prompt: string, - email: EmailMessage -): Promise { - // Email uses either: - // 1. A dedicated "email" group folder - // 2. Or dynamic folders per thread/sender - - const groupFolder = EMAIL_CHANNEL.contextMode === 'single' - ? 'main' // Use main group context - : `email/${contextKey}`; // Isolated email context - - // Ensure folder exists - const groupDir = path.join(GROUPS_DIR, groupFolder); - fs.mkdirSync(groupDir, { recursive: true }); - - // Create minimal registered group for email - const emailGroup: RegisteredGroup = { - name: contextKey, - folder: groupFolder, - trigger: '', // No trigger for email - added_at: new Date().toISOString() - }; - - // Use existing runContainerAgent - const output = await runContainerAgent(emailGroup, { - prompt, - sessionId: sessions[groupFolder], - groupFolder, - chatJid: `email:${email.from}`, // Use email: prefix for JID - isMain: false, - isScheduledTask: false - }); - - if (output.newSessionId) { - sessions[groupFolder] = output.newSessionId; - setSession(groupFolder, output.newSessionId); - } - - return output.status === 'success' ? output.result : null; -} -``` - -### Step 7: Update IPC for Email Responses (Optional) - -If you want the agent to be able to send emails proactively from within a session, read `container/agent-runner/src/ipc-mcp.ts` and add this tool: - -```typescript -// Add to the MCP tools -{ - name: 'send_email_reply', - description: 'Send an email reply in the current thread', - inputSchema: { - type: 'object', - properties: { - body: { type: 'string', description: 'Email body content' } - }, - required: ['body'] - } -} -``` - -Then add handling in `src/ipc.ts` in the `processTaskIpc` function or create a new IPC handler for email actions. - -### Step 8: Create Email Group Memory - -Create the email group directory and memory file: - -```bash -mkdir -p groups/email -``` - -Write `groups/email/CLAUDE.md`: - -```markdown -# Email Channel - -You are responding to emails. Your responses will be sent as email replies. - -## Guidelines - -- Be professional and clear -- Keep responses concise but complete -- Use proper email formatting (greetings, sign-off) -- If the email requires action you can't take, explain what the user should do - -## Context - -Each email thread or sender (depending on configuration) has its own conversation history. -``` - -### Step 9: Rebuild and Test - -Rebuild the container (required since agent-runner changed): - -```bash -cd container && ./build.sh -``` - -Wait for build to complete, then compile TypeScript: - -```bash -cd .. && npm run build -``` - -Restart the service: - -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -Verify it started and check for email channel startup message: - -```bash -sleep 3 && tail -20 logs/nanoclaw.log | grep -i email -``` - -Tell the user: - -> Email channel is now active! Test it by sending an email that matches your trigger: -> - **Label mode:** Apply the "${triggerValue}" label to any email -> - **Address mode:** Send an email to ${triggerValue} -> - **Subject mode:** Send an email with subject starting with "${triggerValue}" -> -> The agent should process it within a minute and send a reply. - -Monitor for the test: - -```bash -tail -f logs/nanoclaw.log | grep -E "(email|Email)" -``` - ---- - ## Troubleshooting -### Gmail MCP not responding +### Gmail connection not responding + +Test directly: + ```bash -# Test Gmail MCP directly npx -y @gongrzhe/server-gmail-autoauth-mcp ``` ### OAuth token expired + +Re-authorize: + ```bash -# Re-authorize rm ~/.gmail-mcp/credentials.json npx -y @gongrzhe/server-gmail-autoauth-mcp ``` -### Emails not being detected -- Check the trigger configuration matches your test email -- Verify the label exists (for label mode) -- Check `processed_emails` table for already-processed emails - ### Container can't access Gmail -- Verify `~/.gmail-mcp` is mounted in container + +- Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount - Check container logs: `cat groups/main/logs/container-*.log | tail -50` ---- +### Emails not being detected (Channel Mode only) -## Removing Gmail Integration +- Check `GMAIL_CHANNEL_ENABLED=true` in `.env` AND `data/env/env` +- By default, the channel polls unread Primary inbox emails (`is:unread category:primary`) +- Check logs for Gmail polling errors -To remove Gmail entirely: +## Removal -1. Remove from `container/agent-runner/src/index.ts`: - - Delete `gmail` from `mcpServers` - - Remove `mcp__gmail__*` from `allowedTools` +### Tool-only mode -2. Remove from `src/container-runner.ts`: - - Delete the `~/.gmail-mcp` mount block +1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` +2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` +3. Remove `gmail` from `.nanoclaw/state.yaml` +4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` +5. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) -3. Remove from `src/index.ts` (Channel Mode only): - - Delete `startEmailLoop()` call - - Delete email-related imports +### Channel mode -4. Delete `src/email-channel.ts` (if created) - -5. Remove Gmail sections from `groups/*/CLAUDE.md` - -6. Rebuild: - ```bash - cd container && ./build.sh && cd .. - npm run build - launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS - # Linux: systemctl --user restart nanoclaw - ``` +1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts` +2. Remove `GmailChannel` import and creation from `src/index.ts` +3. Remove Gmail config (`GMAIL_CHANNEL_ENABLED`) from `src/config.ts` +4. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` +5. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` +6. Remove Gmail JID tests from `src/routing.test.ts` +7. Uninstall: `npm uninstall googleapis` +8. Remove env var from `.env` and `data/env/env` +9. Remove `gmail` from `.nanoclaw/state.yaml` +10. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` +11. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-gmail/add/src/channels/gmail.test.ts b/.claude/skills/add-gmail/add/src/channels/gmail.test.ts new file mode 100644 index 0000000..52602dd --- /dev/null +++ b/.claude/skills/add-gmail/add/src/channels/gmail.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { GmailChannel, GmailChannelOpts } from './gmail.js'; + +function makeOpts(overrides?: Partial): GmailChannelOpts { + return { + onMessage: vi.fn(), + onChatMetadata: vi.fn(), + registeredGroups: () => ({}), + ...overrides, + }; +} + +describe('GmailChannel', () => { + let channel: GmailChannel; + + beforeEach(() => { + channel = new GmailChannel(makeOpts()); + }); + + describe('ownsJid', () => { + it('returns true for gmail: prefixed JIDs', () => { + expect(channel.ownsJid('gmail:abc123')).toBe(true); + expect(channel.ownsJid('gmail:thread-id-456')).toBe(true); + }); + + it('returns false for non-gmail JIDs', () => { + expect(channel.ownsJid('12345@g.us')).toBe(false); + expect(channel.ownsJid('tg:123')).toBe(false); + expect(channel.ownsJid('dc:456')).toBe(false); + expect(channel.ownsJid('user@s.whatsapp.net')).toBe(false); + }); + }); + + describe('name', () => { + it('is gmail', () => { + expect(channel.name).toBe('gmail'); + }); + }); + + describe('isConnected', () => { + it('returns false before connect', () => { + expect(channel.isConnected()).toBe(false); + }); + }); + + describe('disconnect', () => { + it('sets connected to false', async () => { + await channel.disconnect(); + expect(channel.isConnected()).toBe(false); + }); + }); + + describe('constructor options', () => { + it('accepts custom poll interval', () => { + const ch = new GmailChannel(makeOpts(), 30000); + expect(ch.name).toBe('gmail'); + }); + + it('defaults to unread query when no filter configured', () => { + const ch = new GmailChannel(makeOpts()); + const query = (ch as unknown as { buildQuery: () => string }).buildQuery(); + expect(query).toBe('is:unread category:primary'); + }); + + it('defaults with no options provided', () => { + const ch = new GmailChannel(makeOpts()); + expect(ch.name).toBe('gmail'); + }); + }); +}); diff --git a/.claude/skills/add-gmail/add/src/channels/gmail.ts b/.claude/skills/add-gmail/add/src/channels/gmail.ts new file mode 100644 index 0000000..704c364 --- /dev/null +++ b/.claude/skills/add-gmail/add/src/channels/gmail.ts @@ -0,0 +1,326 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { google, gmail_v1 } from 'googleapis'; +import { OAuth2Client } from 'google-auth-library'; + +import { ASSISTANT_NAME, MAIN_GROUP_FOLDER } from '../config.js'; +import { logger } from '../logger.js'; +import { + Channel, + OnChatMetadata, + OnInboundMessage, + RegisteredGroup, +} from '../types.js'; + +export interface GmailChannelOpts { + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; + registeredGroups: () => Record; +} + +interface ThreadMeta { + sender: string; + senderName: string; + subject: string; + messageId: string; // RFC 2822 Message-ID for In-Reply-To +} + +export class GmailChannel implements Channel { + name = 'gmail'; + + private oauth2Client: OAuth2Client | null = null; + private gmail: gmail_v1.Gmail | null = null; + private opts: GmailChannelOpts; + private pollIntervalMs: number; + private pollTimer: ReturnType | null = null; + private processedIds = new Set(); + private threadMeta = new Map(); + private userEmail = ''; + + constructor( + opts: GmailChannelOpts, + pollIntervalMs = 60000, + ) { + this.opts = opts; + this.pollIntervalMs = pollIntervalMs; + } + + async connect(): Promise { + const credDir = path.join(os.homedir(), '.gmail-mcp'); + const keysPath = path.join(credDir, 'gcp-oauth.keys.json'); + const tokensPath = path.join(credDir, 'credentials.json'); + + if (!fs.existsSync(keysPath) || !fs.existsSync(tokensPath)) { + throw new Error( + 'Gmail credentials not found in ~/.gmail-mcp/. Run Gmail OAuth setup first.', + ); + } + + const keys = JSON.parse(fs.readFileSync(keysPath, 'utf-8')); + const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); + + const clientConfig = keys.installed || keys.web || keys; + const { client_id, client_secret, redirect_uris } = clientConfig; + this.oauth2Client = new google.auth.OAuth2( + client_id, + client_secret, + redirect_uris?.[0], + ); + this.oauth2Client.setCredentials(tokens); + + // Persist refreshed tokens + this.oauth2Client.on('tokens', (newTokens) => { + try { + const current = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); + Object.assign(current, newTokens); + fs.writeFileSync(tokensPath, JSON.stringify(current, null, 2)); + logger.debug('Gmail OAuth tokens refreshed'); + } catch (err) { + logger.warn({ err }, 'Failed to persist refreshed Gmail tokens'); + } + }); + + this.gmail = google.gmail({ version: 'v1', auth: this.oauth2Client }); + + // Verify connection + const profile = await this.gmail.users.getProfile({ userId: 'me' }); + this.userEmail = profile.data.emailAddress || ''; + logger.info( + { email: this.userEmail }, + 'Gmail channel connected', + ); + + // Start polling + this.pollTimer = setInterval( + () => this.pollForMessages().catch((err) => + logger.error({ err }, 'Gmail poll error'), + ), + this.pollIntervalMs, + ); + + // Initial poll + await this.pollForMessages(); + } + + async sendMessage(jid: string, text: string): Promise { + if (!this.gmail) { + logger.warn('Gmail not initialized'); + return; + } + + const threadId = jid.replace(/^gmail:/, ''); + const meta = this.threadMeta.get(threadId); + + if (!meta) { + logger.warn({ jid }, 'No thread metadata for reply, cannot send'); + return; + } + + const subject = meta.subject.startsWith('Re:') + ? meta.subject + : `Re: ${meta.subject}`; + + const headers = [ + `To: ${meta.sender}`, + `From: ${this.userEmail}`, + `Subject: ${subject}`, + `In-Reply-To: ${meta.messageId}`, + `References: ${meta.messageId}`, + 'Content-Type: text/plain; charset=utf-8', + '', + text, + ].join('\r\n'); + + const encodedMessage = Buffer.from(headers) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + try { + await this.gmail.users.messages.send({ + userId: 'me', + requestBody: { + raw: encodedMessage, + threadId, + }, + }); + logger.info({ to: meta.sender, threadId }, 'Gmail reply sent'); + } catch (err) { + logger.error({ jid, err }, 'Failed to send Gmail reply'); + } + } + + isConnected(): boolean { + return this.gmail !== null; + } + + ownsJid(jid: string): boolean { + return jid.startsWith('gmail:'); + } + + async disconnect(): Promise { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + this.gmail = null; + this.oauth2Client = null; + logger.info('Gmail channel stopped'); + } + + // --- Private --- + + private buildQuery(): string { + return 'is:unread category:primary'; + } + + private async pollForMessages(): Promise { + if (!this.gmail) return; + + try { + const query = this.buildQuery(); + const res = await this.gmail.users.messages.list({ + userId: 'me', + q: query, + maxResults: 10, + }); + + const messages = res.data.messages || []; + + for (const stub of messages) { + if (!stub.id || this.processedIds.has(stub.id)) continue; + this.processedIds.add(stub.id); + + await this.processMessage(stub.id); + } + + // Cap processed ID set to prevent unbounded growth + if (this.processedIds.size > 5000) { + const ids = [...this.processedIds]; + this.processedIds = new Set(ids.slice(ids.length - 2500)); + } + } catch (err) { + logger.error({ err }, 'Gmail poll failed'); + } + } + + private async processMessage(messageId: string): Promise { + if (!this.gmail) return; + + const msg = await this.gmail.users.messages.get({ + userId: 'me', + id: messageId, + format: 'full', + }); + + const headers = msg.data.payload?.headers || []; + const getHeader = (name: string) => + headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value || ''; + + const from = getHeader('From'); + const subject = getHeader('Subject'); + const rfc2822MessageId = getHeader('Message-ID'); + const threadId = msg.data.threadId || messageId; + const timestamp = new Date( + parseInt(msg.data.internalDate || '0', 10), + ).toISOString(); + + // Extract sender name and email + const senderMatch = from.match(/^(.+?)\s*<(.+?)>$/); + const senderName = senderMatch ? senderMatch[1].replace(/"/g, '') : from; + const senderEmail = senderMatch ? senderMatch[2] : from; + + // Skip emails from self (our own replies) + if (senderEmail === this.userEmail) return; + + // Extract body text + const body = this.extractTextBody(msg.data.payload); + + if (!body) { + logger.debug({ messageId, subject }, 'Skipping email with no text body'); + return; + } + + const chatJid = `gmail:${threadId}`; + + // Cache thread metadata for replies + this.threadMeta.set(threadId, { + sender: senderEmail, + senderName, + subject, + messageId: rfc2822MessageId, + }); + + // Store chat metadata for group discovery + this.opts.onChatMetadata(chatJid, timestamp, subject, 'gmail', false); + + // Find the main group to deliver the email notification + const groups = this.opts.registeredGroups(); + const mainEntry = Object.entries(groups).find(([, g]) => g.folder === MAIN_GROUP_FOLDER); + + if (!mainEntry) { + logger.debug({ chatJid, subject }, 'No main group registered, skipping email'); + return; + } + + const mainJid = mainEntry[0]; + const content = `[Email from ${senderName} <${senderEmail}>]\nSubject: ${subject}\n\n${body}`; + + this.opts.onMessage(mainJid, { + id: messageId, + chat_jid: mainJid, + sender: senderEmail, + sender_name: senderName, + content, + timestamp, + is_from_me: false, + }); + + // Mark as read + try { + await this.gmail.users.messages.modify({ + userId: 'me', + id: messageId, + requestBody: { removeLabelIds: ['UNREAD'] }, + }); + } catch (err) { + logger.warn({ messageId, err }, 'Failed to mark email as read'); + } + + logger.info( + { mainJid, from: senderName, subject }, + 'Gmail email delivered to main group', + ); + } + + private extractTextBody( + payload: gmail_v1.Schema$MessagePart | undefined, + ): string { + if (!payload) return ''; + + // Direct text/plain body + if (payload.mimeType === 'text/plain' && payload.body?.data) { + return Buffer.from(payload.body.data, 'base64').toString('utf-8'); + } + + // Multipart: search parts recursively + if (payload.parts) { + // Prefer text/plain + for (const part of payload.parts) { + if (part.mimeType === 'text/plain' && part.body?.data) { + return Buffer.from(part.body.data, 'base64').toString('utf-8'); + } + } + // Recurse into nested multipart + for (const part of payload.parts) { + const text = this.extractTextBody(part); + if (text) return text; + } + } + + return ''; + } +} diff --git a/.claude/skills/add-gmail/manifest.yaml b/.claude/skills/add-gmail/manifest.yaml new file mode 100644 index 0000000..f407b75 --- /dev/null +++ b/.claude/skills/add-gmail/manifest.yaml @@ -0,0 +1,21 @@ +skill: gmail +version: 1.0.0 +description: "Gmail integration via Google APIs" +core_version: 0.1.0 +adds: + - src/channels/gmail.ts + - src/channels/gmail.test.ts +modifies: + - src/index.ts + - src/config.ts + - src/container-runner.ts + - container/agent-runner/src/index.ts + - src/routing.test.ts +structured: + npm_dependencies: + googleapis: "^144.0.0" + env_additions: + - GMAIL_CHANNEL_ENABLED +conflicts: [] +depends: [] +test: "npx vitest run src/channels/gmail.test.ts" diff --git a/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts b/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts new file mode 100644 index 0000000..4d98033 --- /dev/null +++ b/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts @@ -0,0 +1,593 @@ +/** + * 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 { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } 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; + secrets?: Record; +} + +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 {}; + }; +} + +// Secrets to strip from Bash tool subprocess environments. +// These are needed by claude-code for API auth but should never +// be visible to commands Kit runs. +const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; + +function createSanitizeBashHook(): HookCallback { + return async (input, _toolUseId, _context) => { + const preInput = input as PreToolUseHookInput; + const command = (preInput.tool_input as { command?: string })?.command; + if (!command) return {}; + + const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `; + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + updatedInput: { + ...(preInput.tool_input as Record), + command: unsetPrefix + command, + }, + }, + }; + }; +} + +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__*', + 'mcp__gmail__*', + ], + 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', + }, + }, + gmail: { + command: 'npx', + args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'], + }, + }, + hooks: { + PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], + PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], + }, + } + })) { + 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 }; +} + +async function main(): Promise { + let containerInput: ContainerInput; + + try { + const stdinData = await readStdin(); + containerInput = JSON.parse(stdinData); + // Delete the temp file the entrypoint wrote — it contains secrets + 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); + } + + // Build SDK env: merge secrets into process.env for the SDK only. + // Secrets never touch process.env itself, so Bash subprocesses can't see them. + const sdkEnv: Record = { ...process.env }; + for (const [key, value] of Object.entries(containerInput.secrets || {})) { + sdkEnv[key] = value; + } + + 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'); + } + + // 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/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md b/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md new file mode 100644 index 0000000..3d24be7 --- /dev/null +++ b/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md @@ -0,0 +1,32 @@ +# Intent: container/agent-runner/src/index.ts modifications + +## What changed +Added Gmail MCP server to the agent's available tools so it can read and send emails. + +## Key sections + +### mcpServers (inside runQuery → query() call) +- Added: `gmail` MCP server alongside the existing `nanoclaw` server: + ``` + gmail: { + command: 'npx', + args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'], + }, + ``` + +### allowedTools (inside runQuery → query() call) +- Added: `'mcp__gmail__*'` to allow all Gmail MCP tools + +## Invariants +- The `nanoclaw` MCP server configuration is unchanged +- All existing allowed tools are preserved +- The query loop, IPC handling, MessageStream, and all other logic is untouched +- Hooks (PreCompact, sanitize Bash) are unchanged +- Output protocol (markers) is unchanged + +## Must-keep +- The `nanoclaw` MCP server with its environment variables +- All existing allowedTools entries +- The hook system (PreCompact, PreToolUse sanitize) +- The IPC input/close sentinel handling +- The MessageStream class and query loop diff --git a/.claude/skills/add-gmail/modify/src/config.ts b/.claude/skills/add-gmail/modify/src/config.ts new file mode 100644 index 0000000..c52fb7f --- /dev/null +++ b/.claude/skills/add-gmail/modify/src/config.ts @@ -0,0 +1,74 @@ +import os from 'os'; +import path from 'path'; + +import { readEnvFile } from './env.js'; + +// Read config values from .env (falls back to process.env). +// Secrets are NOT read here — they stay on disk and are loaded only +// where needed (container-runner.ts) to avoid leaking to child processes. +const envConfig = readEnvFile([ + 'ASSISTANT_NAME', + 'ASSISTANT_HAS_OWN_NUMBER', + 'GMAIL_CHANNEL_ENABLED', +]); + +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 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 MAIN_GROUP_FOLDER = 'main'; + +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 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 const TRIGGER_PATTERN = new RegExp( + `^@${escapeRegex(ASSISTANT_NAME)}\\b`, + 'i', +); + +// Timezone for scheduled tasks (cron expressions, etc.) +// Uses system timezone by default +export const TIMEZONE = + process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; + +// Gmail configuration +export const GMAIL_CHANNEL_ENABLED = + (process.env.GMAIL_CHANNEL_ENABLED || envConfig.GMAIL_CHANNEL_ENABLED) === 'true'; diff --git a/.claude/skills/add-gmail/modify/src/config.ts.intent.md b/.claude/skills/add-gmail/modify/src/config.ts.intent.md new file mode 100644 index 0000000..defdffd --- /dev/null +++ b/.claude/skills/add-gmail/modify/src/config.ts.intent.md @@ -0,0 +1,20 @@ +# Intent: src/config.ts modifications + +## What changed +Added configuration exports for Gmail channel support. + +## Key sections +- **readEnvFile call**: Must include `GMAIL_CHANNEL_ENABLED` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`. +- **GMAIL_CHANNEL_ENABLED**: Boolean feature flag — when `true`, the Gmail channel is connected and polls for inbound emails. When `false` (default), Gmail is available as a tool only (agent can read/send emails when asked from other channels). + +## Invariants +- All existing config exports remain unchanged +- New Gmail keys are added to the `readEnvFile` call alongside existing keys +- New exports are appended at the end of the file +- No existing behavior is modified — Gmail config is additive and minimal +- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`) + +## Must-keep +- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.) +- The `readEnvFile` pattern — ALL config read from `.env` must go through this function +- The `escapeRegex` helper and `TRIGGER_PATTERN` construction diff --git a/.claude/skills/add-gmail/modify/src/container-runner.ts b/.claude/skills/add-gmail/modify/src/container-runner.ts new file mode 100644 index 0000000..7221338 --- /dev/null +++ b/.claude/skills/add-gmail/modify/src/container-runner.ts @@ -0,0 +1,661 @@ +/** + * Container Runner for NanoClaw + * Spawns agent execution in containers and handles IPC + */ +import { ChildProcess, exec, spawn } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + CONTAINER_IMAGE, + CONTAINER_MAX_OUTPUT_SIZE, + CONTAINER_TIMEOUT, + DATA_DIR, + GROUPS_DIR, + IDLE_TIMEOUT, + TIMEZONE, +} from './config.js'; +import { readEnvFile } from './env.js'; +import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; +import { logger } from './logger.js'; +import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js'; +import { validateAdditionalMounts } from './mount-security.js'; +import { RegisteredGroup } from './types.js'; + +// 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; + secrets?: Record; +} + +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 homeDir = os.homedir(); + const groupDir = resolveGroupFolderPath(group.folder); + + if (isMain) { + // Main gets the project root read-only. Writable paths the agent needs + // (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, + }); + + // Main also gets its group folder as the working directory + mounts.push({ + hostPath: groupDir, + containerPath: '/workspace/group', + 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, + }); + + // Gmail credentials directory (for Gmail MCP inside the container) + const gmailDir = path.join(homeDir, '.gmail-mcp'); + if (fs.existsSync(gmailDir)) { + mounts.push({ + hostPath: gmailDir, + containerPath: '/home/node/.gmail-mcp', + readonly: false, // MCP may need to refresh OAuth tokens + }); + } + + // 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(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { + 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; +} + +/** + * Read allowed secrets from .env for passing to the container via stdin. + * Secrets are never written to disk or mounted as files. + */ +function readSecrets(): Record { + return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']); +} + +function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] { + 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}`); + + // 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()}`; + const containerArgs = buildContainerArgs(mounts, containerName); + + 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; + + // Pass secrets via stdin (never written to disk or mounted as files) + input.secrets = readSecrets(); + container.stdin.write(JSON.stringify(input)); + container.stdin.end(); + // Remove secrets from input so they don't appear in logs + delete input.secrets; + + // 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'); + exec(stopContainer(containerName), { timeout: 15000 }, (err) => { + if (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) { + logLines.push( + `=== Input ===`, + JSON.stringify(input, null, 2), + ``, + `=== 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; + 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/.claude/skills/add-gmail/modify/src/container-runner.ts.intent.md b/.claude/skills/add-gmail/modify/src/container-runner.ts.intent.md new file mode 100644 index 0000000..a9847a9 --- /dev/null +++ b/.claude/skills/add-gmail/modify/src/container-runner.ts.intent.md @@ -0,0 +1,37 @@ +# Intent: src/container-runner.ts modifications + +## What changed +Added a volume mount for Gmail OAuth credentials (`~/.gmail-mcp/`) so the Gmail MCP server inside the container can authenticate with Google. + +## Key sections + +### buildVolumeMounts() +- Added: Gmail credentials mount after the `.claude` sessions mount: + ``` + const gmailDir = path.join(homeDir, '.gmail-mcp'); + if (fs.existsSync(gmailDir)) { + mounts.push({ + hostPath: gmailDir, + containerPath: '/home/node/.gmail-mcp', + readonly: false, // MCP may need to refresh OAuth tokens + }); + } + ``` +- Uses `os.homedir()` to resolve the home directory +- Mount is read-write because the Gmail MCP server needs to refresh OAuth tokens +- Mount is conditional — only added if `~/.gmail-mcp/` exists on the host + +### Imports +- Added: `os` import for `os.homedir()` + +## Invariants +- All existing mounts are unchanged +- Mount ordering is preserved (Gmail added after session mounts, before additional mounts) +- The `buildContainerArgs`, `runContainerAgent`, and all other functions are untouched +- Additional mount validation via `validateAdditionalMounts` is unchanged + +## Must-keep +- All existing volume mounts (project root, group dir, global, sessions, IPC, agent-runner, additional) +- The mount security model (allowlist validation for additional mounts) +- The `readSecrets` function and stdin-based secret passing +- Container lifecycle (spawn, timeout, output parsing) diff --git a/.claude/skills/add-gmail/modify/src/index.ts b/.claude/skills/add-gmail/modify/src/index.ts new file mode 100644 index 0000000..455d7ef --- /dev/null +++ b/.claude/skills/add-gmail/modify/src/index.ts @@ -0,0 +1,506 @@ +import fs from 'fs'; +import path from 'path'; + +import { + ASSISTANT_NAME, + GMAIL_CHANNEL_ENABLED, + IDLE_TIMEOUT, + MAIN_GROUP_FOLDER, + POLL_INTERVAL, + TRIGGER_PATTERN, +} from './config.js'; +import { GmailChannel } from './channels/gmail.js'; +import { WhatsAppChannel } from './channels/whatsapp.js'; +import { + ContainerOutput, + runContainerAgent, + writeGroupsSnapshot, + writeTasksSnapshot, +} from './container-runner.js'; +import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js'; +import { + getAllChats, + getAllRegisteredGroups, + getAllSessions, + getAllTasks, + 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 { 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; + +let whatsapp: WhatsAppChannel; +const channels: Channel[] = []; +const queue = new GroupQueue(); + +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', + ); +} + +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 }); + + 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) { + console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`); + return true; + } + + const isMainGroup = group.folder === MAIN_GROUP_FOLDER; + + const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; + const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); + + if (missedMessages.length === 0) return true; + + // For non-main groups, check if trigger is required and present + if (!isMainGroup && group.requiresTrigger !== false) { + const hasTrigger = missedMessages.some((m) => + TRIGGER_PATTERN.test(m.content.trim()), + ); + if (!hasTrigger) return true; + } + + const prompt = formatMessages(missedMessages); + + // 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.slice(0, 200)}`); + 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.folder === MAIN_GROUP_FOLDER; + 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, + 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') { + 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 (trigger: @${ASSISTANT_NAME})`); + + 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) { + console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`); + continue; + } + + const isMainGroup = group.folder === MAIN_GROUP_FOLDER; + 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 hasTrigger = groupMessages.some((m) => + TRIGGER_PATTERN.test(m.content.trim()), + ); + if (!hasTrigger) continue; + } + + // Pull all messages since lastAgentTimestamp so non-trigger + // context that accumulated between triggers is included. + const allPending = getMessagesSince( + chatJid, + lastAgentTimestamp[chatJid] || '', + ASSISTANT_NAME, + ); + const messagesToSend = + allPending.length > 0 ? allPending : groupMessages; + const formatted = formatMessages(messagesToSend); + + 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 sinceTimestamp = lastAgentTimestamp[chatJid] || ''; + const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); + 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(); + + // 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')); + + // Channel callbacks (shared by all channels) + const channelOpts = { + onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg), + onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) => + storeChatMetadata(chatJid, timestamp, name, channel, isGroup), + registeredGroups: () => registeredGroups, + }; + + // Create and connect channels + whatsapp = new WhatsAppChannel(channelOpts); + channels.push(whatsapp); + await whatsapp.connect(); + + if (GMAIL_CHANNEL_ENABLED) { + const gmail = new GmailChannel(channelOpts); + channels.push(gmail); + await gmail.connect(); + } + + // 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) { + console.log(`Warning: no channel owns JID ${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, + syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(), + getAvailableGroups, + writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj), + }); + 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/.claude/skills/add-gmail/modify/src/index.ts.intent.md b/.claude/skills/add-gmail/modify/src/index.ts.intent.md new file mode 100644 index 0000000..7df8bf7 --- /dev/null +++ b/.claude/skills/add-gmail/modify/src/index.ts.intent.md @@ -0,0 +1,43 @@ +# Intent: src/index.ts modifications + +## What changed + +Added Gmail as a channel option alongside WhatsApp (and any other channels). + +## Key sections + +### Imports (top of file) + +- Added: `GmailChannel` from `./channels/gmail.js` +- Added: `GMAIL_CHANNEL_ENABLED` from `./config.js` + +### main() + +- Added: conditional Gmail channel creation after WhatsApp: + ``` + if (GMAIL_CHANNEL_ENABLED) { + const gmail = new GmailChannel(channelOpts); + channels.push(gmail); + await gmail.connect(); + } + ``` +- Gmail uses the same `channelOpts` callbacks as other channels +- Incoming emails are delivered to the main group (agent decides how to respond, user can configure) + +## Invariants + +- All existing message processing logic (triggers, cursors, idle timers) is preserved +- The `runAgent` function is completely unchanged +- State management (loadState/saveState) is unchanged +- Recovery logic is unchanged +- Container runtime check is unchanged +- WhatsApp and any other channel creation is untouched +- Shutdown iterates `channels` array (Gmail is included automatically) + +## Must-keep + +- The `escapeXml` and `formatMessages` re-exports +- The `_setRegisteredGroups` test helper +- The `isDirectRun` guard at bottom +- All error handling and cursor rollback logic in processGroupMessages +- The outgoing queue flush and reconnection logic diff --git a/.claude/skills/add-gmail/modify/src/routing.test.ts b/.claude/skills/add-gmail/modify/src/routing.test.ts new file mode 100644 index 0000000..837b1da --- /dev/null +++ b/.claude/skills/add-gmail/modify/src/routing.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js'; +import { getAvailableGroups, _setRegisteredGroups } from './index.js'; + +beforeEach(() => { + _initTestDatabase(); + _setRegisteredGroups({}); +}); + +// --- JID ownership patterns --- + +describe('JID ownership patterns', () => { + // These test the patterns that will become ownsJid() on the Channel interface + + it('WhatsApp group JID: ends with @g.us', () => { + const jid = '12345678@g.us'; + expect(jid.endsWith('@g.us')).toBe(true); + }); + + it('WhatsApp DM JID: ends with @s.whatsapp.net', () => { + const jid = '12345678@s.whatsapp.net'; + expect(jid.endsWith('@s.whatsapp.net')).toBe(true); + }); + + it('Gmail JID: starts with gmail:', () => { + const jid = 'gmail:abc123def'; + expect(jid.startsWith('gmail:')).toBe(true); + }); + + it('Gmail thread JID: starts with gmail: followed by thread ID', () => { + const jid = 'gmail:18d3f4a5b6c7d8e9'; + expect(jid.startsWith('gmail:')).toBe(true); + }); +}); + +// --- getAvailableGroups --- + +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); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.jid)).toContain('group1@g.us'); + expect(groups.map((g) => g.jid)).toContain('group2@g.us'); + expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net'); + }); + + 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); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].jid).toBe('group@g.us'); + }); + + 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); + + _setRegisteredGroups({ + 'reg@g.us': { + name: 'Registered', + folder: 'registered', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + }); + + const groups = getAvailableGroups(); + const reg = groups.find((g) => g.jid === 'reg@g.us'); + const unreg = groups.find((g) => g.jid === 'unreg@g.us'); + + expect(reg?.isRegistered).toBe(true); + expect(unreg?.isRegistered).toBe(false); + }); + + 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); + + const groups = getAvailableGroups(); + expect(groups[0].jid).toBe('new@g.us'); + expect(groups[1].jid).toBe('mid@g.us'); + expect(groups[2].jid).toBe('old@g.us'); + }); + + 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'); + // Explicitly non-group with unusual JID + 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); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].jid).toBe('group@g.us'); + }); + + it('returns empty array when no chats exist', () => { + const groups = getAvailableGroups(); + expect(groups).toHaveLength(0); + }); + + it('excludes Gmail threads from group list (Gmail threads are not groups)', () => { + storeChatMetadata('gmail:abc123', '2024-01-01T00:00:01.000Z', 'Email thread', 'gmail', false); + storeChatMetadata('group@g.us', '2024-01-01T00:00:02.000Z', 'Group', 'whatsapp', true); + + const groups = getAvailableGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].jid).toBe('group@g.us'); + }); +}); diff --git a/.claude/skills/add-gmail/tests/gmail.test.ts b/.claude/skills/add-gmail/tests/gmail.test.ts new file mode 100644 index 0000000..2436bca --- /dev/null +++ b/.claude/skills/add-gmail/tests/gmail.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const root = process.cwd(); +const read = (f: string) => fs.readFileSync(path.join(root, f), 'utf-8'); + +function getGmailMode(): 'tool-only' | 'channel' { + const p = path.join(root, '.nanoclaw/state.yaml'); + if (!fs.existsSync(p)) return 'channel'; + return read('.nanoclaw/state.yaml').includes('mode: tool-only') ? 'tool-only' : 'channel'; +} + +const mode = getGmailMode(); +const channelOnly = mode === 'tool-only'; + +describe('add-gmail skill', () => { + it('container-runner mounts ~/.gmail-mcp', () => { + expect(read('src/container-runner.ts')).toContain('.gmail-mcp'); + }); + + it('agent-runner has gmail MCP server', () => { + const content = read('container/agent-runner/src/index.ts'); + expect(content).toContain('mcp__gmail__*'); + expect(content).toContain('@gongrzhe/server-gmail-autoauth-mcp'); + }); + + it.skipIf(channelOnly)('gmail channel file exists', () => { + expect(fs.existsSync(path.join(root, 'src/channels/gmail.ts'))).toBe(true); + }); + + it.skipIf(channelOnly)('config exports GMAIL_CHANNEL_ENABLED', () => { + expect(read('src/config.ts')).toContain('GMAIL_CHANNEL_ENABLED'); + }); + + it.skipIf(channelOnly)('index.ts wires up GmailChannel', () => { + expect(read('src/index.ts')).toContain('GmailChannel'); + }); + + it.skipIf(channelOnly)('googleapis dependency installed', () => { + const pkg = JSON.parse(read('package.json')); + expect(pkg.dependencies?.googleapis || pkg.devDependencies?.googleapis).toBeDefined(); + }); +}); From 6dd90829e145cf81d2e730838323d1bf06e01b15 Mon Sep 17 00:00:00 2001 From: Fritzzzz Date: Tue, 24 Feb 2026 23:46:24 +0200 Subject: [PATCH 3/4] refactor: remove GMAIL_CHANNEL_ENABLED env flag from add-gmail skill Channel vs tool-only is now a code-level decision at skill apply time. If the user chose channel mode, GmailChannel is wired unconditionally. If tool-only, no channel code is added. No runtime flag needed. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/add-gmail/SKILL.md | 44 +++-------- .claude/skills/add-gmail/manifest.yaml | 3 - .claude/skills/add-gmail/modify/src/config.ts | 74 ------------------- .../add-gmail/modify/src/config.ts.intent.md | 20 ----- .claude/skills/add-gmail/modify/src/index.ts | 9 +-- .../add-gmail/modify/src/index.ts.intent.md | 15 ++-- .claude/skills/add-gmail/tests/gmail.test.ts | 4 - 7 files changed, 21 insertions(+), 148 deletions(-) delete mode 100644 .claude/skills/add-gmail/modify/src/config.ts delete mode 100644 .claude/skills/add-gmail/modify/src/config.ts.intent.md diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md index 29b21c6..03a8cee 100644 --- a/.claude/skills/add-gmail/SKILL.md +++ b/.claude/skills/add-gmail/SKILL.md @@ -29,7 +29,7 @@ npx tsx scripts/apply-skill.ts --init ### Path A: Tool-only (user chose "No") -Do NOT run the full apply script. Only two source files need changes. This avoids adding dead code (`gmail.ts`, `gmail.test.ts`, config flags, index.ts channel logic, routing tests, `googleapis` dependency). +Do NOT run the full apply script. Only two source files need changes. This avoids adding dead code (`gmail.ts`, `gmail.test.ts`, index.ts channel logic, routing tests, `googleapis` dependency). #### 1. Mount Gmail credentials in container @@ -63,19 +63,16 @@ This deterministically: - Adds `src/channels/gmail.ts` (GmailChannel class implementing Channel interface) - Adds `src/channels/gmail.test.ts` (unit tests) -- Three-way merges Gmail support into `src/index.ts` (conditional GmailChannel creation) -- Three-way merges Gmail config into `src/config.ts` (`GMAIL_CHANNEL_ENABLED`) +- Three-way merges Gmail channel wiring into `src/index.ts` (GmailChannel creation) - Three-way merges Gmail credentials mount into `src/container-runner.ts` (~/.gmail-mcp -> /home/node/.gmail-mcp) - Three-way merges Gmail MCP server into `container/agent-runner/src/index.ts` (@gongrzhe/server-gmail-autoauth-mcp) - Three-way merges Gmail JID tests into `src/routing.test.ts` - Installs the `googleapis` npm dependency -- Updates `.env.example` with `GMAIL_CHANNEL_ENABLED` - Records the application in `.nanoclaw/state.yaml` If the apply reports merge conflicts, read the intent files: - `modify/src/index.ts.intent.md` — what changed and invariants for index.ts -- `modify/src/config.ts.intent.md` — what changed for config.ts - `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts - `modify/container/agent-runner/src/index.ts.intent.md` — what changed for agent-runner @@ -106,7 +103,7 @@ 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 "Configure environment" below. +If `credentials.json` already exists, skip to "Build and restart" below. ### GCP Project Setup @@ -146,20 +143,6 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp auth If that fails (some versions don't have an auth subcommand), try `timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`. -### Configure environment - -**Channel mode only** — add to `.env`: - -``` -GMAIL_CHANNEL_ENABLED=true -``` - -Sync to container environment: - -```bash -mkdir -p data/env && cp .env data/env/env -``` - ### Build and restart Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server): @@ -188,7 +171,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS Tell the user: -> Gmail is connected! Send this in your WhatsApp main channel: +> Gmail is connected! Send this in your main channel: > > `@Andy check my recent emails` or `@Andy list my Gmail labels` @@ -228,9 +211,8 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp - Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount - Check container logs: `cat groups/main/logs/container-*.log | tail -50` -### Emails not being detected (Channel Mode only) +### Emails not being detected (Channel mode only) -- Check `GMAIL_CHANNEL_ENABLED=true` in `.env` AND `data/env/env` - By default, the channel polls unread Primary inbox emails (`is:unread category:primary`) - Check logs for Gmail polling errors @@ -248,12 +230,10 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp 1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts` 2. Remove `GmailChannel` import and creation from `src/index.ts` -3. Remove Gmail config (`GMAIL_CHANNEL_ENABLED`) from `src/config.ts` -4. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` -5. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` -6. Remove Gmail JID tests from `src/routing.test.ts` -7. Uninstall: `npm uninstall googleapis` -8. Remove env var from `.env` and `data/env/env` -9. Remove `gmail` from `.nanoclaw/state.yaml` -10. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` -11. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) +3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` +4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` +5. Remove Gmail JID tests from `src/routing.test.ts` +6. Uninstall: `npm uninstall googleapis` +7. Remove `gmail` from `.nanoclaw/state.yaml` +8. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` +9. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-gmail/manifest.yaml b/.claude/skills/add-gmail/manifest.yaml index f407b75..ea7c66a 100644 --- a/.claude/skills/add-gmail/manifest.yaml +++ b/.claude/skills/add-gmail/manifest.yaml @@ -7,15 +7,12 @@ adds: - src/channels/gmail.test.ts modifies: - src/index.ts - - src/config.ts - src/container-runner.ts - container/agent-runner/src/index.ts - src/routing.test.ts structured: npm_dependencies: googleapis: "^144.0.0" - env_additions: - - GMAIL_CHANNEL_ENABLED conflicts: [] depends: [] test: "npx vitest run src/channels/gmail.test.ts" diff --git a/.claude/skills/add-gmail/modify/src/config.ts b/.claude/skills/add-gmail/modify/src/config.ts deleted file mode 100644 index c52fb7f..0000000 --- a/.claude/skills/add-gmail/modify/src/config.ts +++ /dev/null @@ -1,74 +0,0 @@ -import os from 'os'; -import path from 'path'; - -import { readEnvFile } from './env.js'; - -// Read config values from .env (falls back to process.env). -// Secrets are NOT read here — they stay on disk and are loaded only -// where needed (container-runner.ts) to avoid leaking to child processes. -const envConfig = readEnvFile([ - 'ASSISTANT_NAME', - 'ASSISTANT_HAS_OWN_NUMBER', - 'GMAIL_CHANNEL_ENABLED', -]); - -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 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 MAIN_GROUP_FOLDER = 'main'; - -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 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 const TRIGGER_PATTERN = new RegExp( - `^@${escapeRegex(ASSISTANT_NAME)}\\b`, - 'i', -); - -// Timezone for scheduled tasks (cron expressions, etc.) -// Uses system timezone by default -export const TIMEZONE = - process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; - -// Gmail configuration -export const GMAIL_CHANNEL_ENABLED = - (process.env.GMAIL_CHANNEL_ENABLED || envConfig.GMAIL_CHANNEL_ENABLED) === 'true'; diff --git a/.claude/skills/add-gmail/modify/src/config.ts.intent.md b/.claude/skills/add-gmail/modify/src/config.ts.intent.md deleted file mode 100644 index defdffd..0000000 --- a/.claude/skills/add-gmail/modify/src/config.ts.intent.md +++ /dev/null @@ -1,20 +0,0 @@ -# Intent: src/config.ts modifications - -## What changed -Added configuration exports for Gmail channel support. - -## Key sections -- **readEnvFile call**: Must include `GMAIL_CHANNEL_ENABLED` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`. -- **GMAIL_CHANNEL_ENABLED**: Boolean feature flag — when `true`, the Gmail channel is connected and polls for inbound emails. When `false` (default), Gmail is available as a tool only (agent can read/send emails when asked from other channels). - -## Invariants -- All existing config exports remain unchanged -- New Gmail keys are added to the `readEnvFile` call alongside existing keys -- New exports are appended at the end of the file -- No existing behavior is modified — Gmail config is additive and minimal -- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`) - -## Must-keep -- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.) -- The `readEnvFile` pattern — ALL config read from `.env` must go through this function -- The `escapeRegex` helper and `TRIGGER_PATTERN` construction diff --git a/.claude/skills/add-gmail/modify/src/index.ts b/.claude/skills/add-gmail/modify/src/index.ts index 455d7ef..1b04695 100644 --- a/.claude/skills/add-gmail/modify/src/index.ts +++ b/.claude/skills/add-gmail/modify/src/index.ts @@ -3,7 +3,6 @@ import path from 'path'; import { ASSISTANT_NAME, - GMAIL_CHANNEL_ENABLED, IDLE_TIMEOUT, MAIN_GROUP_FOLDER, POLL_INTERVAL, @@ -451,11 +450,9 @@ async function main(): Promise { channels.push(whatsapp); await whatsapp.connect(); - if (GMAIL_CHANNEL_ENABLED) { - const gmail = new GmailChannel(channelOpts); - channels.push(gmail); - await gmail.connect(); - } + const gmail = new GmailChannel(channelOpts); + channels.push(gmail); + await gmail.connect(); // Start subsystems (independently of connection handler) startSchedulerLoop({ diff --git a/.claude/skills/add-gmail/modify/src/index.ts.intent.md b/.claude/skills/add-gmail/modify/src/index.ts.intent.md index 7df8bf7..cd700f5 100644 --- a/.claude/skills/add-gmail/modify/src/index.ts.intent.md +++ b/.claude/skills/add-gmail/modify/src/index.ts.intent.md @@ -2,24 +2,21 @@ ## What changed -Added Gmail as a channel option alongside WhatsApp (and any other channels). +Added Gmail as a channel. ## Key sections ### Imports (top of file) - Added: `GmailChannel` from `./channels/gmail.js` -- Added: `GMAIL_CHANNEL_ENABLED` from `./config.js` ### main() -- Added: conditional Gmail channel creation after WhatsApp: +- Added Gmail channel creation: ``` - if (GMAIL_CHANNEL_ENABLED) { - const gmail = new GmailChannel(channelOpts); - channels.push(gmail); - await gmail.connect(); - } + const gmail = new GmailChannel(channelOpts); + channels.push(gmail); + await gmail.connect(); ``` - Gmail uses the same `channelOpts` callbacks as other channels - Incoming emails are delivered to the main group (agent decides how to respond, user can configure) @@ -31,7 +28,7 @@ Added Gmail as a channel option alongside WhatsApp (and any other channels). - State management (loadState/saveState) is unchanged - Recovery logic is unchanged - Container runtime check is unchanged -- WhatsApp and any other channel creation is untouched +- Any other channel creation is untouched - Shutdown iterates `channels` array (Gmail is included automatically) ## Must-keep diff --git a/.claude/skills/add-gmail/tests/gmail.test.ts b/.claude/skills/add-gmail/tests/gmail.test.ts index 2436bca..02d9721 100644 --- a/.claude/skills/add-gmail/tests/gmail.test.ts +++ b/.claude/skills/add-gmail/tests/gmail.test.ts @@ -29,10 +29,6 @@ describe('add-gmail skill', () => { expect(fs.existsSync(path.join(root, 'src/channels/gmail.ts'))).toBe(true); }); - it.skipIf(channelOnly)('config exports GMAIL_CHANNEL_ENABLED', () => { - expect(read('src/config.ts')).toContain('GMAIL_CHANNEL_ENABLED'); - }); - it.skipIf(channelOnly)('index.ts wires up GmailChannel', () => { expect(read('src/index.ts')).toContain('GmailChannel'); }); From df0e5fc26b4a0d1d4bb2ad38c6cd272c3bcdb3ce Mon Sep 17 00:00:00 2001 From: Fritzzzz Date: Tue, 24 Feb 2026 23:52:53 +0200 Subject: [PATCH 4/4] remove unused --- .../add-gmail/add/src/channels/gmail.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/.claude/skills/add-gmail/add/src/channels/gmail.ts b/.claude/skills/add-gmail/add/src/channels/gmail.ts index 704c364..4d11bdf 100644 --- a/.claude/skills/add-gmail/add/src/channels/gmail.ts +++ b/.claude/skills/add-gmail/add/src/channels/gmail.ts @@ -5,7 +5,7 @@ import path from 'path'; import { google, gmail_v1 } from 'googleapis'; import { OAuth2Client } from 'google-auth-library'; -import { ASSISTANT_NAME, MAIN_GROUP_FOLDER } from '../config.js'; +import { MAIN_GROUP_FOLDER } from '../config.js'; import { logger } from '../logger.js'; import { Channel, @@ -39,10 +39,7 @@ export class GmailChannel implements Channel { private threadMeta = new Map(); private userEmail = ''; - constructor( - opts: GmailChannelOpts, - pollIntervalMs = 60000, - ) { + constructor(opts: GmailChannelOpts, pollIntervalMs = 60000) { this.opts = opts; this.pollIntervalMs = pollIntervalMs; } @@ -87,16 +84,14 @@ export class GmailChannel implements Channel { // Verify connection const profile = await this.gmail.users.getProfile({ userId: 'me' }); this.userEmail = profile.data.emailAddress || ''; - logger.info( - { email: this.userEmail }, - 'Gmail channel connected', - ); + logger.info({ email: this.userEmail }, 'Gmail channel connected'); // Start polling this.pollTimer = setInterval( - () => this.pollForMessages().catch((err) => - logger.error({ err }, 'Gmail poll error'), - ), + () => + this.pollForMessages().catch((err) => + logger.error({ err }, 'Gmail poll error'), + ), this.pollIntervalMs, ); @@ -218,7 +213,8 @@ export class GmailChannel implements Channel { const headers = msg.data.payload?.headers || []; const getHeader = (name: string) => - headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value || ''; + headers.find((h) => h.name?.toLowerCase() === name.toLowerCase()) + ?.value || ''; const from = getHeader('From'); const subject = getHeader('Subject'); @@ -259,10 +255,15 @@ export class GmailChannel implements Channel { // Find the main group to deliver the email notification const groups = this.opts.registeredGroups(); - const mainEntry = Object.entries(groups).find(([, g]) => g.folder === MAIN_GROUP_FOLDER); + const mainEntry = Object.entries(groups).find( + ([, g]) => g.folder === MAIN_GROUP_FOLDER, + ); if (!mainEntry) { - logger.debug({ chatJid, subject }, 'No main group registered, skipping email'); + logger.debug( + { chatJid, subject }, + 'No main group registered, skipping email', + ); return; }