mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-15 18:21:47 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db42032ca4 | |||
| f773b73ee0 | |||
| c7a24f4716 | |||
| b582498e27 |
@@ -0,0 +1,153 @@
|
||||
---
|
||||
name: add-github
|
||||
description: Add GitHub skill to container agents. Installs gh CLI in the container, passes GITHUB_TOKEN via stdin, and adds the agent-github skill doc so agents can manage issues, PRs, and code review.
|
||||
---
|
||||
|
||||
# Add GitHub Skill
|
||||
|
||||
This skill gives container agents access to the GitHub CLI (`gh`) for managing issues, pull requests, and code review.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Read `.nanoclaw/state.yaml`. If `github` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
Use `AskUserQuestion` to collect the GitHub token:
|
||||
|
||||
AskUserQuestion: Do you have a GitHub personal access token, or do you need to create one?
|
||||
|
||||
If they have one, collect it now. If not, guide them in Phase 3.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts --init
|
||||
```
|
||||
|
||||
### Apply the skill
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts .claude/skills/add-github
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
- Adds `container/skills/agent-github/SKILL.md` (agent-facing GitHub CLI docs)
|
||||
- Three-way merges `gh` CLI installation into `container/Dockerfile`
|
||||
- Three-way merges `GITHUB_TOKEN` and `GH_REPO` into `readSecrets()` in `src/container-runner.ts`
|
||||
- Records the application in `.nanoclaw/state.yaml`
|
||||
|
||||
If the apply reports merge conflicts, read the intent files:
|
||||
- `modify/container/Dockerfile.intent.md` — what changed for the Dockerfile
|
||||
- `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure Token and Repository
|
||||
|
||||
### Step 1: Ask for token or guide creation
|
||||
|
||||
Use `AskUserQuestion`:
|
||||
|
||||
AskUserQuestion: Do you have a GitHub personal access token already, or do you need to create one?
|
||||
|
||||
If they need to create one, tell them:
|
||||
|
||||
> Create a GitHub personal access token (classic):
|
||||
>
|
||||
> 1. Go to https://github.com/settings/tokens/new
|
||||
> 2. **Note**: `NanoClaw agent` (or whatever you like)
|
||||
> 3. **Expiration**: 90 days (recommended) or "No expiration"
|
||||
> 4. **Scopes**: select `repo` (full repo access — covers issues, PRs, code, status)
|
||||
> 5. Click **Generate token** and copy it immediately (you can only see it once)
|
||||
>
|
||||
> The token will start with `ghp_...`. Paste it here when you have it.
|
||||
|
||||
Wait for the user to provide the token.
|
||||
|
||||
### Step 2: Ask for target repository
|
||||
|
||||
Use `AskUserQuestion`:
|
||||
|
||||
AskUserQuestion: Which GitHub repository should the agent manage? (format: `owner/repo`)
|
||||
|
||||
### Step 3: Add to `.env`
|
||||
|
||||
Add both values to `.env`:
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=ghp_...
|
||||
GH_REPO=owner/repo
|
||||
```
|
||||
|
||||
`GH_REPO` sets the default repository so the agent doesn't need `--repo` on every command.
|
||||
|
||||
### Step 4: Sync env and restart
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
./container/build.sh
|
||||
rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test the connection
|
||||
|
||||
Tell the user:
|
||||
|
||||
> GitHub is connected! Send this in your main channel:
|
||||
>
|
||||
> "List the open issues on our repo"
|
||||
|
||||
The agent should invoke `gh issue list` and return results.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### gh: not found
|
||||
|
||||
The container needs to be rebuilt with the `gh` CLI:
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
### gh: authentication required
|
||||
|
||||
- Check `GITHUB_TOKEN` is set in `.env`
|
||||
- Restart NanoClaw
|
||||
|
||||
### Rate limit exceeded
|
||||
|
||||
GitHub allows 5,000 requests/hour with token auth. If hitting limits, the agent is likely making too many API calls in a loop.
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete `container/skills/agent-github/SKILL.md`
|
||||
2. Remove `gh` installation block from `container/Dockerfile`
|
||||
3. Remove `GITHUB_TOKEN` and `GH_REPO` from `readSecrets()` in `src/container-runner.ts`
|
||||
4. Remove `GITHUB_TOKEN` and `GH_REPO` from `.env`
|
||||
5. Remove `github` from `.nanoclaw/state.yaml`
|
||||
6. Rebuild: `./container/build.sh && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: agent-github
|
||||
description: Manage GitHub issues, pull requests, and code review for the repository. Use for any GitHub task — listing issues, reviewing PRs, checking CI status, adding labels, posting comments.
|
||||
allowed-tools: Bash(gh:*)
|
||||
---
|
||||
|
||||
# GitHub CLI (`gh`)
|
||||
|
||||
## Authentication
|
||||
|
||||
`gh` is pre-authenticated via `GITHUB_TOKEN` env var. The target repo is set via `GH_REPO` (e.g., `owner/repo`), so you don't need to specify `--repo` on every command.
|
||||
|
||||
## Read operations
|
||||
|
||||
### Issues
|
||||
|
||||
```bash
|
||||
gh issue list # Open issues
|
||||
gh issue list --state closed --limit 10 # Recent closed issues
|
||||
gh issue list --label "bug" # Filter by label
|
||||
gh issue list --assignee "@me" # Assigned to me
|
||||
gh issue list --search "keyword" # Search issues
|
||||
gh issue view 42 # View issue details
|
||||
gh issue view 42 --comments # Include comments
|
||||
```
|
||||
|
||||
### Pull requests
|
||||
|
||||
```bash
|
||||
gh pr list # Open PRs
|
||||
gh pr list --state merged --limit 10 # Recent merged PRs
|
||||
gh pr list --author "username" # Filter by author
|
||||
gh pr view 99 # View PR details
|
||||
gh pr view 99 --comments # Include comments
|
||||
gh pr diff 99 # View PR diff
|
||||
gh pr checks 99 # CI/check status
|
||||
```
|
||||
|
||||
### Repository info
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/branches # List branches
|
||||
gh api repos/{owner}/{repo}/commits?per_page=10 # Recent commits
|
||||
gh api repos/{owner}/{repo}/releases/latest # Latest release
|
||||
gh api repos/{owner}/{repo}/actions/runs?per_page=5 # Recent workflow runs
|
||||
```
|
||||
|
||||
## Safe write operations
|
||||
|
||||
### Issue comments and labels
|
||||
|
||||
```bash
|
||||
gh issue comment 42 --body "Looks good, marking as reviewed."
|
||||
gh issue edit 42 --add-label "reviewed"
|
||||
gh issue edit 42 --remove-label "needs-triage"
|
||||
gh issue edit 42 --add-assignee "username"
|
||||
```
|
||||
|
||||
### PR comments and reviews
|
||||
|
||||
```bash
|
||||
gh pr comment 99 --body "LGTM, one minor suggestion below."
|
||||
gh pr review 99 --approve --body "Approved."
|
||||
gh pr review 99 --request-changes --body "Please fix the failing test."
|
||||
gh pr review 99 --comment --body "Looks good overall."
|
||||
```
|
||||
|
||||
## NOT allowed
|
||||
|
||||
Do NOT perform these operations — they are destructive or require human decision:
|
||||
|
||||
- `gh pr merge` / `gh pr close` / `gh pr reopen`
|
||||
- `gh issue create` / `gh issue close` / `gh issue reopen`
|
||||
- `gh release create` / `gh release delete`
|
||||
- `gh repo delete` / `gh repo rename`
|
||||
- `git push --force` / `git push --force-with-lease`
|
||||
- Any `gh api -X DELETE` call
|
||||
- Any `gh api -X PUT/PATCH` on branch protection rules
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `--json` for structured output: `gh issue list --json number,title,labels`
|
||||
- Combine with `jq` for filtering: `gh pr list --json number,title,checks --jq '.[] | select(.checks | length > 0)'`
|
||||
- Rate limits: GitHub allows 5,000 requests/hour with token auth — plenty for normal use
|
||||
- For large lists, use `--limit`: `gh issue list --limit 50`
|
||||
- To get the repo owner/name: `gh repo view --json nameWithOwner --jq .nameWithOwner`
|
||||
@@ -0,0 +1,17 @@
|
||||
skill: github
|
||||
version: 1.0.0
|
||||
description: "GitHub CLI skill for container agents"
|
||||
core_version: 0.1.0
|
||||
adds:
|
||||
- container/skills/agent-github/SKILL.md
|
||||
modifies:
|
||||
- container/Dockerfile
|
||||
- src/container-runner.ts
|
||||
structured:
|
||||
npm_dependencies: {}
|
||||
env_additions:
|
||||
- GITHUB_TOKEN
|
||||
- GH_REPO
|
||||
conflicts: []
|
||||
depends: []
|
||||
test: "npm run build"
|
||||
@@ -0,0 +1,77 @@
|
||||
# NanoClaw Agent Container
|
||||
# Runs Claude Agent SDK in isolated Linux VM with browser automation
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
# Install system dependencies for Chromium
|
||||
RUN apt-get update && apt-get install -y \
|
||||
chromium \
|
||||
fonts-liberation \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-color-emoji \
|
||||
libgbm1 \
|
||||
libnss3 \
|
||||
libatk-bridge2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libx11-xcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
libasound2 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxshmfence1 \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install GitHub CLI
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Chromium path for agent-browser
|
||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
# Install agent-browser and claude-code globally
|
||||
RUN npm install -g agent-browser @anthropic-ai/claude-code
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better caching
|
||||
COPY agent-runner/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY agent-runner/ ./
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Create workspace directories
|
||||
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
|
||||
|
||||
# Create entrypoint script
|
||||
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
|
||||
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
|
||||
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
|
||||
|
||||
# Set ownership to node user (non-root) for writable directories
|
||||
RUN chown -R node:node /workspace && chmod 777 /home/node
|
||||
|
||||
# Switch to non-root user (required for --dangerously-skip-permissions)
|
||||
USER node
|
||||
|
||||
# Set working directory to group workspace
|
||||
WORKDIR /workspace/group
|
||||
|
||||
# Entry point reads JSON from stdin, outputs JSON to stdout
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Intent: container/Dockerfile modifications
|
||||
|
||||
## What changed
|
||||
Added GitHub CLI (`gh`) installation from GitHub's official apt repository, placed after the existing system dependencies block.
|
||||
|
||||
## Key sections
|
||||
|
||||
### gh CLI installation
|
||||
- Added after the `rm -rf /var/lib/apt/lists/*` line that closes the system dependencies block:
|
||||
```dockerfile
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
- Uses the official GitHub apt repo with signed keyring
|
||||
- Cleans apt lists to minimize image size
|
||||
|
||||
## Invariants
|
||||
- All existing system dependencies (chromium, fonts, curl, git) are unchanged
|
||||
- The Chromium env vars below are unchanged
|
||||
- All subsequent steps (npm install, COPY, build, entrypoint) are unchanged
|
||||
|
||||
## Must-keep
|
||||
- The existing `apt-get install` block for system dependencies
|
||||
- The `AGENT_BROWSER_EXECUTABLE_PATH` and `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` env vars
|
||||
- The npm global install of `agent-browser` and `@anthropic-ai/claude-code`
|
||||
- The entire build, COPY, and entrypoint sequence
|
||||
@@ -0,0 +1,704 @@
|
||||
/**
|
||||
* Container Runner for NanoClaw
|
||||
* Spawns agent execution in containers and handles IPC
|
||||
*/
|
||||
import { ChildProcess, exec, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
export interface ContainerOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface VolumeMount {
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
function buildVolumeMounts(
|
||||
group: RegisteredGroup,
|
||||
isMain: boolean,
|
||||
): VolumeMount[] {
|
||||
const mounts: VolumeMount[] = [];
|
||||
const projectRoot = process.cwd();
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
|
||||
if (isMain) {
|
||||
// Main gets the project root read-only. Writable paths the agent needs
|
||||
// (group folder, IPC, .claude/) are mounted separately below.
|
||||
// Read-only prevents the agent from modifying host application code
|
||||
// (src/, dist/, package.json, etc.) which would bypass the sandbox
|
||||
// entirely on next restart.
|
||||
mounts.push({
|
||||
hostPath: projectRoot,
|
||||
containerPath: '/workspace/project',
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
// Shadow .env so the agent cannot read secrets from the mounted project root.
|
||||
// Secrets are passed via stdin instead (see readSecrets()).
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
mounts.push({
|
||||
hostPath: '/dev/null',
|
||||
containerPath: '/workspace/project/.env',
|
||||
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,
|
||||
});
|
||||
|
||||
// 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<string, string> {
|
||||
return readEnvFile([
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'GITHUB_TOKEN',
|
||||
'GH_REPO',
|
||||
]);
|
||||
}
|
||||
|
||||
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<void>,
|
||||
): Promise<ContainerOutput> {
|
||||
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<string>,
|
||||
): 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Intent: src/container-runner.ts modifications
|
||||
|
||||
## What changed
|
||||
Added `GITHUB_TOKEN` and `GH_REPO` to the `readSecrets()` allowlist so they are passed to the container via stdin JSON.
|
||||
|
||||
## Key sections
|
||||
|
||||
### readSecrets()
|
||||
- Added two keys to the array passed to `readEnvFile()`:
|
||||
```typescript
|
||||
function readSecrets(): Record<string, string> {
|
||||
return readEnvFile([
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'GITHUB_TOKEN',
|
||||
'GH_REPO',
|
||||
]);
|
||||
}
|
||||
```
|
||||
- These values flow via stdin -> `containerInput.secrets` -> `sdkEnv` -> available to Bash
|
||||
- `GITHUB_TOKEN` and `GH_REPO` are NOT added to `SECRET_ENV_VARS` in agent-runner because `gh` CLI needs them visible in Bash commands
|
||||
|
||||
## Invariants
|
||||
- All existing secrets (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`) are unchanged
|
||||
- The stdin-based secret passing flow is unchanged
|
||||
- No changes to volume mounts, container args, or any other functions
|
||||
|
||||
## Must-keep
|
||||
- All existing volume mounts
|
||||
- The mount security model
|
||||
- Container lifecycle (spawn, timeout, output parsing)
|
||||
- The `buildContainerArgs`, `runContainerAgent`, and all other functions
|
||||
@@ -1,504 +0,0 @@
|
||||
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
|
||||
|
||||
;; Author: NanoClaw
|
||||
;; Version: 0.1.0
|
||||
;; Package-Requires: ((emacs "27.1"))
|
||||
;; Keywords: ai, assistant, chat
|
||||
;;
|
||||
;; Vanilla Emacs (init.el):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
|
||||
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
|
||||
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Doom Emacs (config.el):
|
||||
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
|
||||
;; (map! :leader
|
||||
;; :prefix ("N" . "NanoClaw")
|
||||
;; :desc "Chat buffer" "c" #'nanoclaw-chat
|
||||
;; :desc "Send org" "o" #'nanoclaw-org-send)
|
||||
;; ;; Evil users: teach evil about the C-c C-c send binding
|
||||
;; (after! evil
|
||||
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
|
||||
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'url)
|
||||
(require 'json)
|
||||
(require 'org)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Customization
|
||||
|
||||
(defgroup nanoclaw nil
|
||||
"NanoClaw AI assistant interface."
|
||||
:group 'tools
|
||||
:prefix "nanoclaw-")
|
||||
|
||||
(defcustom nanoclaw-host "localhost"
|
||||
"Hostname where NanoClaw is running."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-port 8766
|
||||
"Port for the NanoClaw Emacs channel HTTP server."
|
||||
:type 'integer
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-auth-token nil
|
||||
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
|
||||
Leave nil if EMACS_AUTH_TOKEN is not set."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-poll-interval 1.5
|
||||
"Seconds between response polls when waiting for a reply."
|
||||
:type 'number
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-agent-name "Andy"
|
||||
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-convert-to-org t
|
||||
"When non-nil, convert agent responses to org-mode format.
|
||||
Uses pandoc when available; falls back to regex substitutions."
|
||||
:type 'boolean
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-timestamp-format "%H:%M"
|
||||
"Format string for timestamps shown next to agent replies in the chat buffer.
|
||||
Passed to `format-time-string'. Set to nil to suppress timestamps."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Formatting helpers
|
||||
|
||||
(defun nanoclaw--to-org (text)
|
||||
"Convert TEXT (markdown or plain) to org-mode markup.
|
||||
Tries pandoc -f gfm -t org when available; falls back to regex."
|
||||
(if (not nanoclaw-convert-to-org)
|
||||
text
|
||||
(if (executable-find "pandoc")
|
||||
(with-temp-buffer
|
||||
(insert text)
|
||||
(let* ((coding-system-for-read 'utf-8)
|
||||
(coding-system-for-write 'utf-8)
|
||||
(exit (call-process-region
|
||||
(point-min) (point-max)
|
||||
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
|
||||
(if (zerop exit)
|
||||
(string-trim (buffer-string))
|
||||
text)))
|
||||
(nanoclaw--md-to-org-regex text))))
|
||||
|
||||
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
|
||||
;; Agents responding on this channel must output markdown, not org-mode syntax.
|
||||
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
|
||||
;; re-converted to /bold/ by the italic rule.
|
||||
(defun nanoclaw--md-to-org-regex (text)
|
||||
"Lightweight markdown → org conversion using regexp substitutions."
|
||||
(let ((s text))
|
||||
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
|
||||
;; (must run before inline-code to avoid mangling backticks)
|
||||
(setq s (replace-regexp-in-string
|
||||
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
|
||||
(lambda (m)
|
||||
(let ((lang (match-string 1 m))
|
||||
(body (match-string 2 m)))
|
||||
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
|
||||
"\n" body "#+end_src")))
|
||||
s t))
|
||||
;; Bold **text** → *text*, italic *text* → /text/
|
||||
;; Two-pass to prevent the italic regex from re-matching the bold result:
|
||||
;; 1. Mark bold spans with a placeholder (control char \x01)
|
||||
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
|
||||
;; 2. Convert remaining single-star spans to italic
|
||||
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
|
||||
;; 3. Resolve bold placeholders to org bold markers
|
||||
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
|
||||
;; Strikethrough ~~text~~ → +text+
|
||||
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
|
||||
;; Underline __text__ → _text_
|
||||
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
|
||||
;; Inline code `code` → ~code~
|
||||
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
|
||||
;; ATX headings ## … → ** …
|
||||
(setq s (replace-regexp-in-string
|
||||
"^\\(#+\\) "
|
||||
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
|
||||
s))
|
||||
;; Links [text](url) → [[url][text]]
|
||||
(setq s (replace-regexp-in-string
|
||||
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
|
||||
s))
|
||||
|
||||
(defun nanoclaw--format-timestamp ()
|
||||
"Return a formatted timestamp string, or nil if disabled."
|
||||
(when nanoclaw-timestamp-format
|
||||
(format-time-string nanoclaw-timestamp-format)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Internal state
|
||||
|
||||
(defvar nanoclaw--poll-timer nil
|
||||
"Timer used to poll for responses in the chat buffer.")
|
||||
|
||||
(defvar nanoclaw--last-timestamp 0
|
||||
"Epoch ms of the most recently received message.")
|
||||
|
||||
(defvar nanoclaw--pending nil
|
||||
"Non-nil while waiting for a response.")
|
||||
|
||||
(defvar-local nanoclaw--thinking-dot-count 0
|
||||
"Dot cycle counter for the animated thinking indicator.")
|
||||
|
||||
(defvar-local nanoclaw--input-beg nil
|
||||
"Marker for the start of the current user input area.")
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; HTTP helpers
|
||||
|
||||
(defun nanoclaw--url (path)
|
||||
"Return the full URL for PATH on the NanoClaw server."
|
||||
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
|
||||
|
||||
(defun nanoclaw--headers ()
|
||||
"Return alist of HTTP headers for NanoClaw requests."
|
||||
(let ((hdrs '(("Content-Type" . "application/json"))))
|
||||
(when nanoclaw-auth-token
|
||||
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
|
||||
hdrs))
|
||||
|
||||
(defun nanoclaw--post (text callback)
|
||||
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
|
||||
(let* ((url-request-method "POST")
|
||||
(url-request-extra-headers (nanoclaw--headers))
|
||||
(url-request-data (encode-coding-string
|
||||
(json-encode `((text . ,text)))
|
||||
'utf-8)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url "/api/message")
|
||||
(lambda (status)
|
||||
(if (plist-get status :error)
|
||||
(message "NanoClaw: POST error %s" (plist-get status :error))
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let ((data (ignore-errors (json-read))))
|
||||
(funcall callback data))))
|
||||
nil t t)))
|
||||
|
||||
(defun nanoclaw--poll (since callback)
|
||||
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
|
||||
(let* ((url-request-method "GET")
|
||||
(url-request-extra-headers (nanoclaw--headers)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url (format "/api/messages?since=%d" since))
|
||||
(lambda (status)
|
||||
(unless (plist-get status :error)
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
|
||||
(body (decode-coding-string raw 'utf-8))
|
||||
(data (ignore-errors (json-read-from-string body)))
|
||||
(msgs (cdr (assq 'messages data))))
|
||||
(when msgs (funcall callback (append msgs nil))))))
|
||||
nil t t)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Chat buffer
|
||||
|
||||
(defvar nanoclaw-chat-mode-map
|
||||
(let ((map (make-sparse-keymap)))
|
||||
(define-key map (kbd "RET") #'newline)
|
||||
(define-key map (kbd "<return>") #'newline)
|
||||
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
|
||||
map)
|
||||
"Keymap for `nanoclaw-chat-mode'.")
|
||||
|
||||
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
|
||||
"Major mode for the NanoClaw chat buffer.
|
||||
Derives from org-mode so that org markup (headings, bold, code blocks,
|
||||
etc.) is fontified automatically. RET and <return> insert plain newlines
|
||||
for multi-line input; send with C-c C-c."
|
||||
(setq-local word-wrap t)
|
||||
(visual-line-mode 1)
|
||||
;; Disable org features that conflict with a linear chat buffer
|
||||
(setq-local org-return-follows-link nil)
|
||||
(setq-local org-cycle-emulate-tab nil)
|
||||
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
|
||||
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
(defun nanoclaw--advance-input-beg ()
|
||||
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))))
|
||||
|
||||
(defun nanoclaw--chat-buffer ()
|
||||
"Return the NanoClaw chat buffer, creating it if necessary."
|
||||
(or (get-buffer "*NanoClaw*")
|
||||
(with-current-buffer (get-buffer-create "*NanoClaw*")
|
||||
(nanoclaw-chat-mode)
|
||||
(set-buffer-file-coding-system 'utf-8)
|
||||
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
|
||||
(nanoclaw--insert-header)
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))
|
||||
(current-buffer))))
|
||||
|
||||
(defun nanoclaw--insert-header ()
|
||||
"Insert the welcome header into the chat buffer."
|
||||
(let ((inhibit-read-only t))
|
||||
(insert (propertize
|
||||
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
|
||||
nanoclaw-agent-name)
|
||||
'face 'font-lock-comment-face))))
|
||||
|
||||
(defun nanoclaw--chat-insert (speaker text)
|
||||
"Append SPEAKER: TEXT to the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let* ((inhibit-read-only t)
|
||||
(is-agent (not (string= speaker "You")))
|
||||
(display-text (if is-agent (nanoclaw--to-org text) text))
|
||||
(ts (nanoclaw--format-timestamp))
|
||||
(label (if ts (format "%s [%s]" speaker ts) speaker))
|
||||
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
|
||||
(goto-char (point-max))
|
||||
(insert (propertize (concat label ": ") 'face face))
|
||||
(insert display-text "\n\n")
|
||||
(goto-char (point-max))
|
||||
(when is-agent
|
||||
(nanoclaw--advance-input-beg)))))
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-chat ()
|
||||
"Open the NanoClaw chat buffer."
|
||||
(interactive)
|
||||
(pop-to-buffer (nanoclaw--chat-buffer))
|
||||
(goto-char (point-max)))
|
||||
|
||||
(defun nanoclaw-chat-send ()
|
||||
"Send the accumulated input area as a message to NanoClaw.
|
||||
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
|
||||
(interactive)
|
||||
(when nanoclaw--pending
|
||||
(message "NanoClaw: waiting for previous response...")
|
||||
(cl-return-from nanoclaw-chat-send))
|
||||
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
|
||||
(marker-position nanoclaw--input-beg)
|
||||
(line-beginning-position)))
|
||||
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
|
||||
(when (string-empty-p text)
|
||||
(user-error "Nothing to send"))
|
||||
(let ((inhibit-read-only t))
|
||||
(delete-region beg (point-max)))
|
||||
(nanoclaw--chat-insert "You" text)
|
||||
(nanoclaw--advance-input-beg)
|
||||
(setq nanoclaw--pending t)
|
||||
(nanoclaw--post text
|
||||
(lambda (data)
|
||||
(when data
|
||||
(setq nanoclaw--last-timestamp
|
||||
(or (cdr (assq 'timestamp data))
|
||||
nanoclaw--last-timestamp))
|
||||
(nanoclaw--start-thinking)
|
||||
(nanoclaw--start-poll))))))
|
||||
|
||||
(defun nanoclaw--start-poll ()
|
||||
"Start polling for new messages."
|
||||
(nanoclaw--stop-poll)
|
||||
(setq nanoclaw--poll-timer
|
||||
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
|
||||
#'nanoclaw--poll-tick)))
|
||||
|
||||
(defun nanoclaw--stop-poll ()
|
||||
"Stop the polling timer."
|
||||
(when nanoclaw--poll-timer
|
||||
(cancel-timer nanoclaw--poll-timer)
|
||||
(setq nanoclaw--poll-timer nil)))
|
||||
|
||||
(defun nanoclaw--start-thinking ()
|
||||
"Insert an animated thinking indicator at the end of the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let ((inhibit-read-only t))
|
||||
(goto-char (point-max))
|
||||
(setq nanoclaw--thinking-dot-count 1)
|
||||
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))
|
||||
|
||||
(defun nanoclaw--tick-thinking ()
|
||||
"Advance the dot animation in the thinking indicator."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(when nanoclaw--pending
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(let* ((end (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))
|
||||
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
|
||||
(setq nanoclaw--thinking-dot-count n)
|
||||
(delete-region pos end)
|
||||
(save-excursion
|
||||
(goto-char pos)
|
||||
(insert (propertize
|
||||
(format "%s: %s\n\n" nanoclaw-agent-name
|
||||
(make-string n ?.))
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))))))))
|
||||
|
||||
(defun nanoclaw--clear-thinking ()
|
||||
"Remove the thinking indicator from the chat buffer."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(delete-region pos (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))))))))
|
||||
|
||||
(defun nanoclaw--poll-tick ()
|
||||
"Poll for new messages and insert them into the chat buffer."
|
||||
(nanoclaw--tick-thinking)
|
||||
(nanoclaw--poll
|
||||
nanoclaw--last-timestamp
|
||||
(lambda (msgs)
|
||||
(dolist (msg msgs)
|
||||
(let ((text (cdr (assq 'text msg)))
|
||||
(ts (cdr (assq 'timestamp msg))))
|
||||
(when (and text (> ts nanoclaw--last-timestamp))
|
||||
(setq nanoclaw--last-timestamp ts)
|
||||
(nanoclaw--clear-thinking)
|
||||
(nanoclaw--chat-insert nanoclaw-agent-name text))))
|
||||
(when msgs
|
||||
(setq nanoclaw--pending nil)
|
||||
(nanoclaw--stop-poll)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Org integration
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-org-send ()
|
||||
"Send the current org subtree to NanoClaw and insert the response as a child.
|
||||
|
||||
If a region is active, send the region text instead."
|
||||
(interactive)
|
||||
(unless (derived-mode-p 'org-mode)
|
||||
(user-error "Not in an org-mode buffer"))
|
||||
(let ((text (if (use-region-p)
|
||||
(buffer-substring-no-properties (region-beginning) (region-end))
|
||||
(nanoclaw--org-subtree-text))))
|
||||
(when (string-empty-p (string-trim text))
|
||||
(user-error "Nothing to send"))
|
||||
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
|
||||
(let ((marker (point-marker))
|
||||
(buf (current-buffer)))
|
||||
(nanoclaw--post
|
||||
text
|
||||
(lambda (data)
|
||||
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
|
||||
(level (with-current-buffer buf
|
||||
(save-excursion (goto-char marker) (org-outline-level))))
|
||||
(ph (with-current-buffer buf
|
||||
(save-excursion
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-placeholder level)))))
|
||||
(nanoclaw--poll-until-response
|
||||
ts
|
||||
(lambda (response)
|
||||
(with-current-buffer buf
|
||||
(save-excursion
|
||||
(when (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil))
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-response response))))
|
||||
(lambda ()
|
||||
(message "NanoClaw: timed out waiting for response")
|
||||
(when (marker-buffer ph)
|
||||
(with-current-buffer (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil)))))))))))
|
||||
|
||||
(defun nanoclaw--org-insert-placeholder (level)
|
||||
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
|
||||
(org-back-to-heading t)
|
||||
(org-end-of-subtree t t)
|
||||
(let ((beg (point)))
|
||||
(insert "\n" (make-string (1+ level) ?*) " "
|
||||
nanoclaw-agent-name " [processing...]\n\n")
|
||||
(copy-marker beg)))
|
||||
|
||||
(defun nanoclaw--org-subtree-text ()
|
||||
"Return the text of the org subtree at point (heading + body)."
|
||||
(org-with-wide-buffer
|
||||
(org-back-to-heading t)
|
||||
(let ((start (point))
|
||||
(end (progn (org-end-of-subtree t t) (point))))
|
||||
(buffer-substring-no-properties start end))))
|
||||
|
||||
(defun nanoclaw--org-insert-response (text)
|
||||
"Insert TEXT as a child org heading under the current subtree."
|
||||
(org-back-to-heading t)
|
||||
(let* ((level (org-outline-level))
|
||||
(child-stars (make-string (1+ level) ?*))
|
||||
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
|
||||
(body (nanoclaw--to-org text)))
|
||||
(org-end-of-subtree t t)
|
||||
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
|
||||
body "\n")))
|
||||
|
||||
(defun nanoclaw--now-ms ()
|
||||
"Return current time as milliseconds since epoch."
|
||||
(let ((time (current-time)))
|
||||
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
|
||||
(/ (caddr time) 1000))))
|
||||
|
||||
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
|
||||
"Poll until a message newer than SINCE arrives, then call CALLBACK.
|
||||
Calls TIMEOUT-FN after 60 attempts (~90s)."
|
||||
(let ((n (or attempts 0)))
|
||||
(if (>= n 60)
|
||||
(funcall timeout-fn)
|
||||
(nanoclaw--poll
|
||||
since
|
||||
(lambda (msgs)
|
||||
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
|
||||
msgs)))
|
||||
(if fresh
|
||||
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
|
||||
fresh "\n")))
|
||||
(funcall callback text))
|
||||
(run-with-timer nanoclaw-poll-interval nil
|
||||
#'nanoclaw--poll-until-response
|
||||
since callback timeout-fn (1+ n)))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(provide 'nanoclaw)
|
||||
;;; nanoclaw.el ends here
|
||||
@@ -1,531 +0,0 @@
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// --- Mocks (hoisted — must appear before any imports of the modules they replace) ---
|
||||
|
||||
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
|
||||
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
|
||||
vi.mock('../config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
GROUPS_DIR: '/tmp/test-groups',
|
||||
}));
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../db.js', () => ({ setRegisteredGroup: vi.fn() }));
|
||||
|
||||
// Stub out all filesystem calls so tests never touch disk.
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
// Simulate missing symlink by default — triggers creation path
|
||||
lstatSync: vi.fn(() => {
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}),
|
||||
existsSync: vi.fn(() => true),
|
||||
mkdirSync: vi.fn(),
|
||||
symlinkSync: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { setRegisteredGroup } from '../db.js';
|
||||
import type { ChannelOpts } from './registry.js';
|
||||
import { EmacsBridgeChannel } from './emacs.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
||||
function createTestOpts(overrides?: Partial<ChannelOpts>): ChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'main:jid': {
|
||||
name: 'main',
|
||||
folder: 'main',
|
||||
trigger: '',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
isMain: true,
|
||||
},
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Make an HTTP request to the test server; returns status code and parsed body. */
|
||||
async function req(
|
||||
port: number,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: string,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): Promise<{ status: number; data: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...extraHeaders,
|
||||
};
|
||||
const request = http.request(
|
||||
{ host: '127.0.0.1', port, method, path, headers },
|
||||
(res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk: Buffer) => (raw += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, data: raw });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
if (body) request.write(body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the actual bound port after connect() (server listens on port 0). */
|
||||
function boundPort(channel: EmacsBridgeChannel): number {
|
||||
return (((channel as any).server as http.Server).address() as AddressInfo)
|
||||
.port;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EmacsBridgeChannel', () => {
|
||||
let opts: ChannelOpts;
|
||||
let channel: EmacsBridgeChannel;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
opts = createTestOpts();
|
||||
// Port 0 tells the OS to pick a free ephemeral port — no conflicts between test runs
|
||||
channel = new EmacsBridgeChannel(0, null, opts);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (channel.isConnected()) await channel.disconnect();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('connect / disconnect / isConnected', () => {
|
||||
it('isConnected returns false before connect', () => {
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('isConnected returns true after connect', async () => {
|
||||
await channel.connect();
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('isConnected returns false after disconnect', async () => {
|
||||
await channel.connect();
|
||||
await channel.disconnect();
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('disconnect is a no-op when not connected', async () => {
|
||||
await expect(channel.disconnect()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('ownsJid', () => {
|
||||
it('returns true for emacs:default', () => {
|
||||
expect(channel.ownsJid('emacs:default')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-emacs JIDs', () => {
|
||||
expect(channel.ownsJid('tg:123456')).toBe(false);
|
||||
expect(channel.ownsJid('main:jid')).toBe(false);
|
||||
expect(channel.ownsJid('')).toBe(false);
|
||||
expect(channel.ownsJid('emacs:other')).toBe(false);
|
||||
expect(channel.ownsJid('123456@g.us')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('group auto-registration', () => {
|
||||
it('calls setRegisteredGroup when emacs:default is absent', async () => {
|
||||
await channel.connect();
|
||||
expect(setRegisteredGroup).toHaveBeenCalledWith(
|
||||
'emacs:default',
|
||||
expect.objectContaining({
|
||||
name: 'emacs',
|
||||
folder: 'emacs',
|
||||
requiresTrigger: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('mutates the live registeredGroups map immediately (no restart needed)', async () => {
|
||||
const groups: Record<string, any> = {};
|
||||
const localOpts = createTestOpts({
|
||||
registeredGroups: vi.fn(() => groups),
|
||||
});
|
||||
const c = new EmacsBridgeChannel(0, null, localOpts);
|
||||
await c.connect();
|
||||
expect(groups['emacs:default']).toBeDefined();
|
||||
await c.disconnect();
|
||||
});
|
||||
|
||||
it('skips registration when emacs:default is already present', async () => {
|
||||
const localOpts = createTestOpts({
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'emacs:default': {
|
||||
name: 'emacs',
|
||||
folder: 'emacs',
|
||||
trigger: '',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
});
|
||||
const c = new EmacsBridgeChannel(0, null, localOpts);
|
||||
await c.connect();
|
||||
expect(setRegisteredGroup).not.toHaveBeenCalled();
|
||||
await c.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('POST /api/message', () => {
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await channel.connect();
|
||||
port = boundPort(channel);
|
||||
});
|
||||
|
||||
it('returns 200 with messageId and timestamp for valid text', async () => {
|
||||
const { status, data } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: 'hello' }),
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(data).toHaveProperty('messageId');
|
||||
expect(data).toHaveProperty('timestamp');
|
||||
expect(typeof data.timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('calls opts.onMessage with correct structure', async () => {
|
||||
await req(port, 'POST', '/api/message', JSON.stringify({ text: 'ping' }));
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'emacs:default',
|
||||
expect.objectContaining({
|
||||
chat_jid: 'emacs:default',
|
||||
content: 'ping',
|
||||
sender: 'emacs',
|
||||
sender_name: 'Emacs',
|
||||
is_from_me: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls opts.onChatMetadata before opts.onMessage', async () => {
|
||||
const order: string[] = [];
|
||||
(opts.onChatMetadata as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
order.push('meta'),
|
||||
);
|
||||
(opts.onMessage as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
order.push('msg'),
|
||||
);
|
||||
await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
|
||||
expect(order).toEqual(['meta', 'msg']);
|
||||
});
|
||||
|
||||
it('returns 400 for empty text', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: '' }),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for whitespace-only text', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: ' ' }),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid JSON', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', 'not-json');
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown paths', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/unknown',
|
||||
JSON.stringify({ text: 'hi' }),
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('GET /api/messages', () => {
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await channel.connect();
|
||||
port = boundPort(channel);
|
||||
});
|
||||
|
||||
it('returns empty messages array when nothing has been sent', async () => {
|
||||
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(200);
|
||||
expect(data).toEqual({ messages: [] });
|
||||
});
|
||||
|
||||
it('returns messages added via sendMessage', async () => {
|
||||
await channel.sendMessage('emacs:default', 'hello back');
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(data.messages).toHaveLength(1);
|
||||
expect(data.messages[0].text).toBe('hello back');
|
||||
});
|
||||
|
||||
it('filters out messages at or before the since timestamp', async () => {
|
||||
await channel.sendMessage('emacs:default', 'old');
|
||||
// Capture `since` after the first push, then wait to guarantee the
|
||||
// second push lands at a strictly later timestamp
|
||||
const since = Date.now();
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
await channel.sendMessage('emacs:default', 'new');
|
||||
|
||||
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
|
||||
expect(data.messages.map((m: any) => m.text)).not.toContain('old');
|
||||
expect(data.messages.map((m: any) => m.text)).toContain('new');
|
||||
});
|
||||
|
||||
it('caps buffer at 200 messages, dropping the oldest', async () => {
|
||||
for (let i = 0; i < 201; i++) {
|
||||
await channel.sendMessage('emacs:default', `msg-${i}`);
|
||||
}
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(data.messages).toHaveLength(200);
|
||||
// msg-0 was the first in and should have been evicted
|
||||
expect(data.messages.map((m: any) => m.text)).not.toContain('msg-0');
|
||||
expect(data.messages.map((m: any) => m.text)).toContain('msg-1');
|
||||
expect(data.messages.map((m: any) => m.text)).toContain('msg-200');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('sendMessage', () => {
|
||||
beforeEach(async () => {
|
||||
await channel.connect();
|
||||
});
|
||||
|
||||
it('pushes exact text to the buffer', async () => {
|
||||
await channel.sendMessage('emacs:default', 'response text');
|
||||
const { data } = await req(
|
||||
boundPort(channel),
|
||||
'GET',
|
||||
'/api/messages?since=0',
|
||||
);
|
||||
expect(data.messages[0].text).toBe('response text');
|
||||
});
|
||||
|
||||
it('attaches a numeric epoch-ms timestamp', async () => {
|
||||
const before = Date.now();
|
||||
await channel.sendMessage('emacs:default', 'ts-check');
|
||||
const after = Date.now();
|
||||
const { data } = await req(
|
||||
boundPort(channel),
|
||||
'GET',
|
||||
'/api/messages?since=0',
|
||||
);
|
||||
expect(data.messages[0].timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(data.messages[0].timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('authentication', () => {
|
||||
let authChannel: EmacsBridgeChannel;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
authChannel = new EmacsBridgeChannel(0, 'secret', opts);
|
||||
await authChannel.connect();
|
||||
port = boundPort(authChannel);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (authChannel.isConnected()) await authChannel.disconnect();
|
||||
});
|
||||
|
||||
it('rejects POST without Authorization header (401)', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: 'hi' }),
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects POST with wrong token (401)', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: 'hi' }),
|
||||
{ Authorization: 'Bearer wrong' },
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts POST with correct Bearer token (200)', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: 'hi' }),
|
||||
{ Authorization: 'Bearer secret' },
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects GET without Authorization header (401)', async () => {
|
||||
const { status } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts GET with correct Bearer token (200)', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'GET',
|
||||
'/api/messages?since=0',
|
||||
undefined,
|
||||
{ Authorization: 'Bearer secret' },
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('channel without authToken ignores Authorization header entirely', async () => {
|
||||
const noAuthChannel = new EmacsBridgeChannel(0, null, opts);
|
||||
await noAuthChannel.connect();
|
||||
const noAuthPort = boundPort(noAuthChannel);
|
||||
try {
|
||||
const { status } = await req(
|
||||
noAuthPort,
|
||||
'GET',
|
||||
'/api/messages?since=0',
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
} finally {
|
||||
await noAuthChannel.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nanoclaw--md-to-org-regex (Emacs Lisp, tested via emacs --batch)
|
||||
|
||||
function emacsAvailable(): boolean {
|
||||
try {
|
||||
execSync('emacs --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mdToOrg(input: string): string {
|
||||
const elFile = path.resolve('emacs/nanoclaw.el');
|
||||
// Escape input as an Emacs string literal — no shell involved so no shell quoting needed
|
||||
const escaped = input
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n');
|
||||
// execFileSync passes args as an array (no shell), bypassing both shell quoting
|
||||
// and the vi.mock('fs') stub that would block writeFileSync
|
||||
return execFileSync(
|
||||
'emacs',
|
||||
[
|
||||
'--batch',
|
||||
'--load',
|
||||
elFile,
|
||||
'--eval',
|
||||
`(princ (nanoclaw--md-to-org-regex "${escaped}"))`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
}
|
||||
|
||||
describe.skipIf(!emacsAvailable())('nanoclaw--md-to-org-regex', () => {
|
||||
it('converts bold **text** → *text*', () => {
|
||||
expect(mdToOrg('**hello**')).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('converts italic *text* → /text/', () => {
|
||||
expect(mdToOrg('*hello*')).toBe('/hello/');
|
||||
});
|
||||
|
||||
it('handles bold before italic in the same string', () => {
|
||||
expect(mdToOrg('**bold** and *italic*')).toBe('*bold* and /italic/');
|
||||
});
|
||||
|
||||
it('converts strikethrough ~~text~~ → +text+', () => {
|
||||
expect(mdToOrg('~~gone~~')).toBe('+gone+');
|
||||
});
|
||||
|
||||
it('converts underline __text__ → _text_', () => {
|
||||
expect(mdToOrg('__under__')).toBe('_under_');
|
||||
});
|
||||
|
||||
it('converts inline code `code` → ~code~', () => {
|
||||
expect(mdToOrg('`foo()`')).toBe('~foo()~');
|
||||
});
|
||||
|
||||
it('converts fenced code block with language', () => {
|
||||
expect(mdToOrg('```typescript\nconst x = 1;\n```')).toBe(
|
||||
'#+begin_src typescript\nconst x = 1;\n#+end_src',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts fenced code block without language', () => {
|
||||
expect(mdToOrg('```\nhello\n```')).toBe(
|
||||
'#+begin_src text\nhello\n#+end_src',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts ## heading → ** heading', () => {
|
||||
expect(mdToOrg('## Section')).toBe('** Section');
|
||||
});
|
||||
|
||||
it('converts ### heading → *** heading', () => {
|
||||
expect(mdToOrg('### Deep')).toBe('*** Deep');
|
||||
});
|
||||
|
||||
it('leaves list items unchanged', () => {
|
||||
expect(mdToOrg('- item one')).toBe('- item one');
|
||||
});
|
||||
|
||||
it('converts links [text](url) → [[url][text]]', () => {
|
||||
expect(mdToOrg('[NanoClaw](https://example.com)')).toBe(
|
||||
'[[https://example.com][NanoClaw]]',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../config.js';
|
||||
import { setRegisteredGroup } from '../db.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { Channel, RegisteredGroup } from '../types.js';
|
||||
import { ChannelOpts, registerChannel } from './registry.js';
|
||||
|
||||
const EMACS_JID = 'emacs:default';
|
||||
|
||||
interface BufferedMessage {
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class EmacsBridgeChannel implements Channel {
|
||||
name = 'emacs';
|
||||
|
||||
private server: http.Server | null = null;
|
||||
private port: number;
|
||||
private authToken: string | null;
|
||||
private opts: ChannelOpts;
|
||||
private buffer: BufferedMessage[] = [];
|
||||
|
||||
constructor(port: number, authToken: string | null, opts: ChannelOpts) {
|
||||
this.port = port;
|
||||
this.authToken = authToken;
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.ensureGroupRegistered();
|
||||
this.ensureSymlink();
|
||||
this.ensureClaudeMd();
|
||||
|
||||
this.server = http.createServer((req, res) => {
|
||||
if (!this.checkAuth(req, res)) return;
|
||||
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${this.port}`);
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/message') {
|
||||
this.handlePost(req, res);
|
||||
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
|
||||
this.handlePoll(url, res);
|
||||
} else {
|
||||
res.writeHead(404).end(JSON.stringify({ error: 'Not found' }));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.listen(this.port, '127.0.0.1', () => {
|
||||
logger.info(
|
||||
{ port: this.port },
|
||||
'Emacs channel listening — load emacs/nanoclaw.el to connect',
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
this.server!.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.server) {
|
||||
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
|
||||
this.server = null;
|
||||
logger.info('Emacs channel stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(_jid: string, text: string): Promise<void> {
|
||||
this.buffer.push({ text, timestamp: Date.now() });
|
||||
// Keep buffer bounded — 200 messages max
|
||||
if (this.buffer.length > 200) this.buffer.shift();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.server?.listening ?? false;
|
||||
}
|
||||
|
||||
ownsJid(jid: string): boolean {
|
||||
return jid === EMACS_JID;
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private checkAuth(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): boolean {
|
||||
if (!this.authToken) return true;
|
||||
const header = req.headers['authorization'] ?? '';
|
||||
if (header === `Bearer ${this.authToken}`) return true;
|
||||
res.writeHead(401).end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return false;
|
||||
}
|
||||
|
||||
private handlePost(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { text } = JSON.parse(body) as { text?: string };
|
||||
if (!text?.trim()) {
|
||||
res.writeHead(400).end(JSON.stringify({ error: 'text required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const msgId = `emacs-${Date.now()}`;
|
||||
|
||||
this.opts.onChatMetadata(EMACS_JID, timestamp, 'Emacs', 'emacs', false);
|
||||
this.opts.onMessage(EMACS_JID, {
|
||||
id: msgId,
|
||||
chat_jid: EMACS_JID,
|
||||
sender: 'emacs',
|
||||
sender_name: 'Emacs',
|
||||
content: text,
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
});
|
||||
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ messageId: msgId, timestamp: Date.now() }));
|
||||
|
||||
logger.info({ length: text.length }, 'Emacs message received');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Emacs channel: failed to parse POST body');
|
||||
res.writeHead(400).end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handlePoll(url: URL, res: http.ServerResponse): void {
|
||||
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
|
||||
const messages = this.buffer.filter((m) => m.timestamp > since);
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ messages }));
|
||||
}
|
||||
|
||||
private ensureClaudeMd(): void {
|
||||
const claudeMd = path.join(GROUPS_DIR, 'emacs', 'CLAUDE.md');
|
||||
// groups/emacs symlinks to the main group folder on typical installs, so
|
||||
// this is a no-op when that CLAUDE.md already exists. On a fresh setup it
|
||||
// bootstraps the file so the agent knows to output markdown, not org-mode.
|
||||
if (fs.existsSync(claudeMd)) return;
|
||||
const content = [
|
||||
'## Message Formatting',
|
||||
'',
|
||||
'This is an Emacs channel. Responses are automatically converted from markdown',
|
||||
'to org-mode by the bridge before display.',
|
||||
'',
|
||||
'**Always format responses in standard markdown:**',
|
||||
'- `**bold**` not `*bold*`',
|
||||
'- `*italic*` not `/italic/`',
|
||||
'- `~~strikethrough~~` not `+strikethrough+`',
|
||||
'- `` `code` `` not `~code~`',
|
||||
'- ` ```lang ` fenced code blocks',
|
||||
'- `- ` for bullet points',
|
||||
'',
|
||||
'Do NOT output org-mode syntax directly. The bridge handles conversion.',
|
||||
'',
|
||||
].join('\n');
|
||||
try {
|
||||
fs.writeFileSync(claudeMd, content, 'utf8');
|
||||
logger.info('Emacs channel: wrote CLAUDE.md');
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Emacs channel: could not write CLAUDE.md');
|
||||
}
|
||||
}
|
||||
|
||||
private ensureGroupRegistered(): void {
|
||||
const groups = this.opts.registeredGroups();
|
||||
if (groups[EMACS_JID]) return;
|
||||
|
||||
const newGroup: RegisteredGroup = {
|
||||
name: 'emacs',
|
||||
folder: 'emacs',
|
||||
trigger: '',
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: false,
|
||||
};
|
||||
|
||||
try {
|
||||
setRegisteredGroup(EMACS_JID, newGroup);
|
||||
// Mutate the live cache so the message loop sees it immediately
|
||||
groups[EMACS_JID] = newGroup;
|
||||
logger.info('Emacs group auto-registered');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Emacs channel: failed to auto-register group');
|
||||
}
|
||||
}
|
||||
|
||||
private ensureSymlink(): void {
|
||||
const emacsDir = path.join(GROUPS_DIR, 'emacs');
|
||||
|
||||
// Find the main group's folder name
|
||||
const groups = this.opts.registeredGroups();
|
||||
const mainGroup = Object.values(groups).find((g) => g.isMain);
|
||||
const targetFolder = mainGroup?.folder ?? 'main';
|
||||
const targetDir = path.join(GROUPS_DIR, targetFolder);
|
||||
|
||||
try {
|
||||
const stat = fs.lstatSync(emacsDir);
|
||||
if (stat.isSymbolicLink()) return; // already set up
|
||||
// Exists as a real directory — leave it alone
|
||||
logger.debug(
|
||||
{ emacsDir },
|
||||
'Emacs groups dir already exists as a directory',
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// Does not exist — create it
|
||||
}
|
||||
|
||||
// Ensure the target exists before symlinking
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
fs.symlinkSync(targetDir, emacsDir);
|
||||
logger.info({ target: targetDir }, 'Created groups/emacs symlink');
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
'Emacs channel: failed to create groups/emacs symlink',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerChannel('emacs', (opts: ChannelOpts) => {
|
||||
const envVars = readEnvFile(['EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN']);
|
||||
const portStr =
|
||||
process.env.EMACS_CHANNEL_PORT || envVars.EMACS_CHANNEL_PORT || '8766';
|
||||
const port = parseInt(portStr, 10);
|
||||
const authToken =
|
||||
process.env.EMACS_AUTH_TOKEN || envVars.EMACS_AUTH_TOKEN || null;
|
||||
|
||||
return new EmacsBridgeChannel(port, authToken, opts);
|
||||
});
|
||||
@@ -10,6 +10,3 @@
|
||||
// telegram
|
||||
|
||||
// whatsapp
|
||||
|
||||
// emacs
|
||||
import './emacs.js';
|
||||
|
||||
Reference in New Issue
Block a user